diff --git a/localization/i18n/OrcaSlicer.pot b/localization/i18n/OrcaSlicer.pot index c55cdb7336..771a9e183c 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -5945,7 +5945,7 @@ msgstr "" msgid "Save current project as" msgstr "" -msgid "Publish" +msgid "Publish 3MF" msgstr "" msgid "Export a 3MF file with the selected settings embedded" @@ -8043,6 +8043,9 @@ msgid "" "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 "" diff --git a/localization/i18n/ca/OrcaSlicer_ca.po b/localization/i18n/ca/OrcaSlicer_ca.po index 4eb07b2635..244cf7f41b 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: 2025-03-15 10:55+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -6406,8 +6406,8 @@ msgstr "Desa el projecte com a" msgid "Save current project as" msgstr "Desar el projecte actual com" -msgid "Publish" -msgstr "Publicar" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8632,6 +8632,9 @@ msgid "" "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." diff --git a/localization/i18n/cs/OrcaSlicer_cs.po b/localization/i18n/cs/OrcaSlicer_cs.po index 95259e0895..f5a8cc6717 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: \n" "Last-Translator: Jakub Hencl\n" "Language-Team: \n" @@ -6367,8 +6367,8 @@ msgstr "Uložit projekt jako" msgid "Save current project as" msgstr "Uložit aktuální projekt jako" -msgid "Publish" -msgstr "Publikovat" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8592,6 +8592,9 @@ msgid "" "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." diff --git a/localization/i18n/de/OrcaSlicer_de.po b/localization/i18n/de/OrcaSlicer_de.po index 0a86439cab..28113d16d3 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: \n" "Last-Translator: Heiko Liebscher \n" "Language-Team: \n" @@ -6259,8 +6259,8 @@ msgstr "Projekt speichern als" msgid "Save current project as" msgstr "Aktuelles Projekt speichern als" -msgid "Publish" -msgstr "Veröffentlichen" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8464,6 +8464,9 @@ msgid "" "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." diff --git a/localization/i18n/en/OrcaSlicer_en.po b/localization/i18n/en/OrcaSlicer_en.po index 03b83309de..29b88cdab0 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: 2026-06-17 15:44-0300\n" "Last-Translator: Alexandre Folle de Menezes\n" "Language-Team: \n" @@ -5941,7 +5941,7 @@ msgstr "" msgid "Save current project as" msgstr "" -msgid "Publish" +msgid "Publish 3MF" msgstr "" msgid "Export a 3MF file with the selected settings embedded" @@ -8039,6 +8039,9 @@ msgid "" "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 "" diff --git a/localization/i18n/es/OrcaSlicer_es.po b/localization/i18n/es/OrcaSlicer_es.po index 74617b88ec..54c4bccafe 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: \n" "Last-Translator: Ian A. Bassi <>\n" "Language-Team: \n" @@ -6116,8 +6116,8 @@ msgstr "Guardar proyecto como" msgid "Save current project as" msgstr "Guardar el proyecto actual como" -msgid "Publish" -msgstr "Publicar" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8267,6 +8267,9 @@ msgid "" "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." diff --git a/localization/i18n/eu/OrcaSlicer_eu.po b/localization/i18n/eu/OrcaSlicer_eu.po index cceccaa64c..8a9a0a6ad3 100644 --- a/localization/i18n/eu/OrcaSlicer_eu.po +++ b/localization/i18n/eu/OrcaSlicer_eu.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: 2026-07-20 13:33+0200\n" "Last-Translator: Manu Goiogana \n" "Language-Team: \n" @@ -6161,8 +6161,8 @@ msgstr "Gorde proiektua honela" msgid "Save current project as" msgstr "Gorde uneko proiektua honela" -msgid "Publish" -msgstr "Argitaratu" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8334,6 +8334,9 @@ msgid "" "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." diff --git a/localization/i18n/fr/OrcaSlicer_fr.po b/localization/i18n/fr/OrcaSlicer_fr.po index 5aeed754f0..5197f6c725 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: Guislain Cyril, Thomas Lété\n" @@ -6208,8 +6208,8 @@ msgstr "Enregistrer le projet sous" msgid "Save current project as" msgstr "Enregistrer le projet actuel sous" -msgid "Publish" -msgstr "Publier" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8390,6 +8390,9 @@ msgid "" "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." diff --git a/localization/i18n/hu/OrcaSlicer_hu.po b/localization/i18n/hu/OrcaSlicer_hu.po index 6c9c5669d8..e87f21c567 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "Language: hu\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -6310,8 +6310,8 @@ msgstr "Projekt mentése másként" msgid "Save current project as" msgstr "Jelenlegi projekt mentése másként" -msgid "Publish" -msgstr "Közzététel" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8516,6 +8516,9 @@ msgid "" "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." diff --git a/localization/i18n/it/OrcaSlicer_it.po b/localization/i18n/it/OrcaSlicer_it.po index 8533b8b39f..8ce671c1e6 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -6312,8 +6312,8 @@ msgstr "Salva progetto con nome" msgid "Save current project as" msgstr "Salva progetto corrente con nome" -msgid "Publish" -msgstr "Pubblica" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8515,6 +8515,9 @@ msgid "" "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." diff --git a/localization/i18n/ja/OrcaSlicer_ja.po b/localization/i18n/ja/OrcaSlicer_ja.po index 2c28d2b166..ca68f62e0c 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -6321,8 +6321,8 @@ msgstr "プロジェクトを名前を付けて保存" msgid "Save current project as" msgstr "プロジェクトを名前を付けて保存" -msgid "Publish" -msgstr "公開する" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8534,6 +8534,9 @@ msgid "" "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 "ノズルタイプが設定されていません。ノズルを設定して再試行してください。" diff --git a/localization/i18n/ko/OrcaSlicer_ko.po b/localization/i18n/ko/OrcaSlicer_ko.po index 5e81f19e0d..a9d1a699af 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: 2025-06-02 17:12+0900\n" "Last-Translator: crwusiz \n" "Language-Team: \n" @@ -6335,8 +6335,8 @@ msgstr "프로젝트 다른 이름으로 저장" msgid "Save current project as" msgstr "현재 프로젝트 다른 이름으로 저장" -msgid "Publish" -msgstr "게시" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8567,6 +8567,9 @@ msgid "" "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 "노즐 유형이 설정되지 않았습니다. 노즐을 설정하고 다시 시도하세요." diff --git a/localization/i18n/lt/OrcaSlicer_lt.po b/localization/i18n/lt/OrcaSlicer_lt.po index aac9ef9dc6..9f16194315 100644 --- a/localization/i18n/lt/OrcaSlicer_lt.po +++ b/localization/i18n/lt/OrcaSlicer_lt.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: 2026-07-02 14:13+0300\n" "Last-Translator: Gintaras Kučinskas \n" "Language-Team: \n" @@ -6298,8 +6298,8 @@ msgstr "Įrašyti projektą kaip" msgid "Save current project as" msgstr "Įrašyti dabartinį projektą kaip" -msgid "Publish" -msgstr "Talpinti" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8510,6 +8510,9 @@ msgid "" "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ą." diff --git a/localization/i18n/nl/OrcaSlicer_nl.po b/localization/i18n/nl/OrcaSlicer_nl.po index 4d37e65fee..96840c0844 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -6873,8 +6873,8 @@ msgstr "Bewaar project als" msgid "Save current project as" msgstr "Bewaar huidig project als" -msgid "Publish" -msgstr "Publiceren" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -9284,6 +9284,9 @@ msgid "" "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." diff --git a/localization/i18n/pl/OrcaSlicer_pl.po b/localization/i18n/pl/OrcaSlicer_pl.po index e9430155ea..5df6507348 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: \n" "Last-Translator: Krzysztof Morga <>\n" "Language-Team: \n" @@ -6454,8 +6454,8 @@ msgstr "Zapisz projekt jako" msgid "Save current project as" msgstr "Zapisz bieżący projekt jako" -msgid "Publish" -msgstr "Opublikuj" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8724,6 +8724,9 @@ msgid "" "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." diff --git a/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po b/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po index b001ea24b8..48ed2fb774 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: 2026-07-26 11:14-0300\n" "Last-Translator: Alexandre Folle de Menezes\n" "Language-Team: Portuguese, Brazilian\n" @@ -6130,8 +6130,8 @@ msgstr "Salvar projeto como" msgid "Save current project as" msgstr "Salvar o projeto atual como" -msgid "Publish" -msgstr "Publicar" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8290,6 +8290,9 @@ msgid "" "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." diff --git a/localization/i18n/ru/OrcaSlicer_ru.po b/localization/i18n/ru/OrcaSlicer_ru.po index 919710f917..fd7b617642 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+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" @@ -6363,8 +6363,8 @@ msgstr "Сохранить проект как" msgid "Save current project as" msgstr "Сохранить текущий проект как" -msgid "Publish" -msgstr "Опубликовать" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8569,6 +8569,9 @@ msgid "" "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 "Не указан тип сопла. Укажите его и попробуйте ещё раз." diff --git a/localization/i18n/sv/OrcaSlicer_sv.po b/localization/i18n/sv/OrcaSlicer_sv.po index 1caffffd36..2e2ca51d7c 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "Language: sv\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -6957,8 +6957,8 @@ msgstr "Spara Projekt som" msgid "Save current project as" msgstr "Spara nuvarande projekt som" -msgid "Publish" -msgstr "Publicera" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -9377,6 +9377,9 @@ msgid "" "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." diff --git a/localization/i18n/th/OrcaSlicer_th.po b/localization/i18n/th/OrcaSlicer_th.po index 7f86af0e5e..1826f2be8e 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: 2026-06-19 13:40+0700\n" "Last-Translator: Icezaza\n" "Language-Team: Thai\n" @@ -6289,8 +6289,8 @@ msgstr "บันทึกโปรเจกต์เป็น" msgid "Save current project as" msgstr "บันทึกโครงการปัจจุบันเป็น" -msgid "Publish" -msgstr "เผยแพร่" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8470,6 +8470,9 @@ msgid "" "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 "ไม่ได้ตั้งค่าประเภทหัวฉีด โปรดตั้งหัวฉีดแล้วลองอีกครั้ง" diff --git a/localization/i18n/tr/OrcaSlicer_tr.po b/localization/i18n/tr/OrcaSlicer_tr.po index 944c322a94..b2394caeb3 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: 2026-08-01 20:32+0300\n" "Last-Translator: GlauTech\n" "Language-Team: \n" @@ -6389,8 +6389,8 @@ msgstr "Projeyi farklı kaydet" msgid "Save current project as" msgstr "Mevcut projeyi farklı kaydet" -msgid "Publish" -msgstr "Yayınla" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8606,6 +8606,9 @@ msgid "" "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." diff --git a/localization/i18n/uk/OrcaSlicer_uk.po b/localization/i18n/uk/OrcaSlicer_uk.po index 5c162e8475..40290ba12c 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: 2026-07-17 16:25+0300\n" "Last-Translator: Andrij Mizyk \n" "Language-Team: Ukrainian\n" @@ -6326,8 +6326,8 @@ msgstr "Зберегти проєкт як" msgid "Save current project as" msgstr "Зберегти поточний проєкт як" -msgid "Publish" -msgstr "Публікувати" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8580,6 +8580,9 @@ msgid "" "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 "Тип сопла не встановлений. Будь ласка, оберіть сопло та спробуйте ще раз." diff --git a/localization/i18n/vi/OrcaSlicer_vi.po b/localization/i18n/vi/OrcaSlicer_vi.po index f900f3fc1f..dc269a9519 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: 2025-10-02 17:43+0700\n" "Last-Translator: \n" "Language-Team: hainguyen.ts13@gmail.com\n" @@ -6674,8 +6674,8 @@ 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" -msgstr "Xuất bản" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -9001,6 +9001,9 @@ msgid "" "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." diff --git a/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po b/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po index f89ad1dd52..93a9f0ae0b 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+0800\n" "PO-Revision-Date: 2026-06-11 12:37-0300\n" "Last-Translator: Handle \n" "Language-Team: \n" @@ -6144,8 +6144,8 @@ msgstr "项目另存为" msgid "Save current project as" msgstr "项目另存为" -msgid "Publish" -msgstr "发布" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8299,6 +8299,9 @@ msgid "" "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 "未设置喷嘴类型。请设置喷嘴并重试。" diff --git a/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po b/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po index 88596d495a..7a19a1c540 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-08-18 16:25+0800\n" +"POT-Creation-Date: 2026-08-19 14:59+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" @@ -6274,8 +6274,8 @@ msgstr "另存專案為" msgid "Save current project as" msgstr "將目前專案另存為" -msgid "Publish" -msgstr "發布" +msgid "Publish 3MF" +msgstr "" msgid "Export a 3MF file with the selected settings embedded" msgstr "" @@ -8465,6 +8465,9 @@ msgid "" "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 "噴嘴類型尚未設定。請設定噴嘴後再試一次。" diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index fd3d4d24b6..7a90036fe2 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -4752,9 +4752,8 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool } } // !is_published - // 4) Load the project config values (the per extruder wipe matrix etc). - // In published mode the receiver must not inherit the author's filament/purge data, - // so only the plate/bed geometry project keys are applied. + // 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; @@ -4774,29 +4773,23 @@ 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 only the author-selected published keys onto the - // user's currently-selected (edited) process preset. Scalar keys are applied directly; - // vector (multi-extruder) keys are applied only when the edited preset has a matching - // vector size. Keys that cannot be applied are collected for notification; legacy - // filament/printer keys in a published file fall through into skipped_keys. + // 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; - // Set whenever the material overlay actually modifies a receiver filament preset - // (applied key, colour or slot replacement). Only then must the edited preset be - // re-snapshotted: re-selecting unconditionally would discard the user's unsaved - // in-memory filament edits when the published file touches nothing. + // 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 must never be applied to the user's presets: doing so would - // rewrite their preset inheritance/structure. This is the single source of truth - // shared with PublishSettingsDialog.cpp (publish_structural_keys in - // PublishSettings.hpp). Defense-in-depth: a hand-crafted 3MF could set - // published_keys to these regardless of the dialog, so skip them here too. + // 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 and never - // reported as skipped (a hand-crafted 3MF listing machine_start_gcode or - // nozzle_diameter must not apply them and must not spam the warning). + // 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()); @@ -4805,10 +4798,10 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool for (const std::string &key : published_config->published_keys) { if (applied_keys.count(key) != 0) continue; // already applied - // A '#' suffix denotes a variant (per-extruder/per-filament) key; resolve the base key. + // A '#' suffix denotes a variant key; resolve the base key. const std::string base_key = key.substr(0, key.find('#')); - // Structural keys are intentionally never applied (not "skipped due to - // mismatch"), so bail out before the applied/skipped bookkeeping. + // 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 && @@ -4822,23 +4815,29 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool if (src_opt == nullptr) continue; // key not present in the loaded config; record later if (src_opt->is_vector()) { - // Vector key: apply only when the edited preset has a matching vector size. const ConfigOption *dst_opt = target.option(base_key); - if (dst_opt == nullptr || !dst_opt->is_vector() || - static_cast(src_opt)->size() != static_cast(dst_opt)->size()) + if (dst_opt == nullptr || !dst_opt->is_vector()) continue; // cannot apply; will be reported as skipped - // A '#' variant index must be in range: ConfigOptionVector::set_at would - // otherwise resize the destination vector, corrupting the receiver's preset. + // A '#N' variant key (e.g. per-extruder retraction_length#2) applies one + // element, so the index only needs to be in range on both sides - 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()) { const size_t idx = static_cast(std::atoi(key.c_str() + base_key.size() + 1)); - if (idx >= static_cast(src_opt)->size()) + if (idx >= static_cast(src_opt)->size() || + idx >= static_cast(dst_opt)->size()) continue; // out-of-range variant: cannot apply; reported as skipped + } 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 } target.apply_only(config, {key}, true); applied_keys.insert(key); } else { - // A scalar key cannot carry a '#N' variant suffix; a hand-crafted file - // listing one is reported as skipped instead of being silently marked applied. + // 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. @@ -4852,229 +4851,87 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool apply_published(this->prints.get_edited_preset().config, nullptr); apply_published(this->printers.get_edited_preset().config, &printer_allowlist); - // Material pass: apply the author's material-qualified keys onto the receiver's - // matching filament presets. The file config carries the author's per-slot identity - // (filament_type / filament_vendor remain in config; filament_ids was moved into a - // local earlier) and the per-slot material retraction values. - if (!published_config->material_keys.empty()) { - const ConfigOptionStrings *file_types = config.option("filament_type"); - const ConfigOptionStrings *file_vendors = config.option("filament_vendor"); - auto identity_matches = [](const std::string &id, const std::string &type, const std::string &vendor, - const std::string &slot_id, const std::string &slot_type, const std::string &slot_vendor) { - // When both sides carry a filament_id, equality is required; otherwise fall - // back to filament_type, with filament_vendor as an additional qualifier only - // when both sides have a non-empty vendor. - if (!id.empty() && !slot_id.empty()) - return id == slot_id; - if (type.empty() || type != slot_type) - return false; - if (!vendor.empty() && !slot_vendor.empty()) - return vendor == slot_vendor; - return true; - }; - for (const PublishedMaterialEntry &entry : published_config->material_keys) { - // Entries using the filament-publishing-v2 features (full dump, published type or - // colour) are handled by the positional per-slot pass below; the legacy identity - // matching here applies only to files that predate those features. - if (entry.full || entry.publish_type || entry.publish_color) - continue; - // Resolve the author's source slot and its ordinal among the author slots - // carrying this entry's identity. A slotted entry (slot >= 0) names the exact - // author slot and targets the receiver's Nth matching preset (N = ordinal); - // a legacy entry (slot -1) uses the first matching author slot and applies to - // every matching receiver preset. - auto slot_identity = [&filament_ids, file_types, file_vendors](size_t slot, std::string &id, std::string &type, std::string &vendor) { - id = (slot < filament_ids.size()) ? filament_ids[slot] : std::string(); - type = (file_types && slot < file_types->size()) ? file_types->get_at(slot) : std::string(); - vendor = (file_vendors && slot < file_vendors->size()) ? file_vendors->get_at(slot) : std::string(); - }; - bool author_found = false; - size_t author_slot = 0; - size_t author_ordinal = 0; - if (entry.slot >= 0) { - // Collect every author slot carrying this identity, in slot order; the - // entry's slot must be among them, and its position is the ordinal used - // to pick the receiver's matching preset. - std::vector matching_author_slots; - for (size_t slot = 0; slot < filament_ids.size(); ++slot) { - std::string slot_id, slot_type, slot_vendor; - slot_identity(slot, slot_id, slot_type, slot_vendor); - if (identity_matches(entry.filament_id, entry.filament_type, entry.filament_vendor, - slot_id, slot_type, slot_vendor)) - matching_author_slots.emplace_back(slot); - } - const auto ordinal_it = std::find(matching_author_slots.begin(), matching_author_slots.end(), size_t(entry.slot)); - if (ordinal_it != matching_author_slots.end()) { - author_slot = size_t(entry.slot); - author_ordinal = size_t(ordinal_it - matching_author_slots.begin()); - author_found = true; - } - // Out of range, or the slot does not carry this identity: silent skip below. - } else { - // Legacy: the first author slot whose identity matches. - for (size_t slot = 0; slot < filament_ids.size(); ++slot) { - std::string slot_id, slot_type, slot_vendor; - slot_identity(slot, slot_id, slot_type, slot_vendor); - if (identity_matches(entry.filament_id, entry.filament_type, entry.filament_vendor, - slot_id, slot_type, slot_vendor)) { - author_slot = slot; - author_found = true; - break; - } - } - } - if (!author_found) - // No author slot carries this material: nothing to apply, nothing to report. - continue; - - const std::string material_label = entry.filament_id.empty() ? entry.filament_type : entry.filament_id; - auto report_skipped = [&skipped_keys, &material_label](const std::string &key, const std::string &slot_qualifier = std::string()) { - skipped_keys.emplace_back("material:" + material_label + - (slot_qualifier.empty() ? std::string() : " " + slot_qualifier) + - " (" + key + ")"); - }; - - // Collect the receiver's matching filament presets (distinct by preset name). - std::vector matched_preset_names; - std::set fallback_matched_names; - for (const std::string &preset_name : this->filament_presets) { - Preset *preset = this->filaments.find_preset(preset_name); - if (preset == nullptr) - continue; - const std::string slot_id = preset->filament_id; - // Null-guard the identity reads: a malformed user preset may lack - // filament_type / filament_vendor entirely (hand-edited preset file). - const ConfigOptionStrings *slot_types = preset->config.option("filament_type"); - const ConfigOptionStrings *slot_vendors = preset->config.option("filament_vendor"); - const std::string slot_type = (slot_types && !slot_types->values.empty()) ? slot_types->get_at(0) : std::string(); - const std::string slot_vendor = (slot_vendors && !slot_vendors->values.empty()) ? slot_vendors->get_at(0) : std::string(); - if (!identity_matches(entry.filament_id, entry.filament_type, entry.filament_vendor, - slot_id, slot_type, slot_vendor)) - continue; - if (std::find(matched_preset_names.begin(), matched_preset_names.end(), preset_name) == matched_preset_names.end()) - matched_preset_names.emplace_back(preset_name); - if (entry.filament_id.empty() || slot_id.empty()) - fallback_matched_names.insert(preset_name); - } - if (matched_preset_names.empty()) { - // No receiver material matches this entry: report each key as skipped. - for (const std::string &key : entry.keys) - report_skipped(key); - continue; - } - if (fallback_matched_names.size() > 1) { - // The type fallback matched more than one distinct receiver preset: never - // guess which one the author meant. - for (const std::string &key : entry.keys) - report_skipped(key); - continue; - } - - // Slotted entries target the receiver's matching preset at the author's - // ordinal; legacy entries apply to every matching receiver preset. - std::vector apply_to_preset_names; - if (entry.slot >= 0) { - if (author_ordinal >= matched_preset_names.size()) { - // The receiver has fewer matching presets than the author's ordinal: - // this slot's values cannot be placed, report each key. - const std::string slot_qualifier = "slot " + std::to_string(entry.slot); - for (const std::string &key : entry.keys) - report_skipped(key, slot_qualifier); - continue; - } - apply_to_preset_names.emplace_back(matched_preset_names[author_ordinal]); - } else { - apply_to_preset_names = matched_preset_names; - } - - for (const std::string &key : entry.keys) { - const std::string base_key = key.substr(0, key.find('#')); - if (structural_keys.count(base_key) != 0) - continue; // structural: silent - const ConfigOption *src_opt = config.option(base_key); - if (src_opt == nullptr || !src_opt->is_vector() || - author_slot >= static_cast(src_opt)->size()) { - report_skipped(key); - continue; - } - for (const std::string &preset_name : apply_to_preset_names) { - Preset *preset = this->filaments.find_preset(preset_name); - if (preset == nullptr) - continue; - ConfigOption *dst_opt = preset->config.option(base_key); - // Per-slot scalar copy: the receiver's filament preset holds a single - // value per key (vector of size 1), the file holds the per-slot vector. - if (dst_opt == nullptr || !dst_opt->is_vector() || - static_cast(dst_opt)->empty() || - dst_opt->type() != src_opt->type()) { - report_skipped(key); - continue; - } - static_cast(dst_opt)->set_at(src_opt, 0, author_slot); - material_applied = true; - } - } - } - } - - // Filament-publishing-v2: 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. The receiver's slot is matched positionally against the published type: + // 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. + // The receiver's slot is matched positionally against the published type: // - colour: always applied to the slot, independent of the type gate; - // - type match: a full dump is intentionally ignored (the receiver keeps its material), - // a partial entry's keys are applied as usual; - // - type mismatch: the slot is replaced with the first visible same-type filament from - // the receiver's library; the author's values are applied on top of it (full) or the - // published keys are applied (partial); - // - no replacement available: a full entry falls back to applying the author's values - // in-memory onto the receiver's current preset (no library import); a partial entry - // keeps the receiver's material and reports its keys as skipped. + // - type match: the full dump still applies wholesale (every setting, as if the slot's + // filament had been loaded from a normal save); a partial entry's keys are applied + // as usual; + // - type mismatch: the slot is replaced with the best visible candidate, scored by the + // published identity (exact filament_id, then vendor+type, then type only); a + // preset no other slot references wins on equal scores, and a shared exact-material + // preset is taken even though mutating it also affects the other slot; the author's + // values are applied on top of it (full) or the published keys are applied (partial); + // - no replacement available: a full entry falls back to the first available visible + // preset, applying the author's values on top of it; a partial entry keeps the + // receiver's material and reports its keys as skipped. + // All applied values (colour and keys) are written onto the slot's stored preset + // directly (mutate in place): the receiver's material keeps its identity and is simply + // overridden. To keep slot-to-slot aliasing (several slots referencing one preset) from + // leaking one slot's values into another, published slots sharing a preset with another + // slot are re-pointed at distinct presets before the values are applied. { - // Slot growth is tied to the author slots that carry published content (full, - // type or colour): the file's total filament count is irrelevant, and a slot the - // author left unpublished must not pull a filler material into the receiver's - // setup. Grow only as far as the highest published slot (never shrink, never - // remove the receiver's existing materials). - bool has_new_semantics = false; - size_t target_slots = this->filament_presets.size(); + // 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; + size_t target_slots = this->filament_presets.size(); for (const PublishedMaterialEntry &entry : published_config->material_keys) { - if (!entry.full && !entry.publish_type && !entry.publish_color) - continue; // legacy entry, handled above - has_new_semantics = true; + has_published_entries = true; if (entry.slot >= 0) target_slots = std::max(target_slots, size_t(entry.slot) + 1); } - if (has_new_semantics) { + if (has_published_entries) { // Defensive cap: never exceed the file's own filament count. target_slots = std::min(target_slots, num_filaments); - // Slots that carry published content (full/type/colour) must reference a stored - // preset that no other slot shares: the overlay mutates stored presets in place - // (colour and keys), so a shared preset would leak one slot's published values - // into every slot that references it. + // 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.full || entry.publish_type || entry.publish_color) && entry.slot >= 0) + if (entry.slot >= 0) published_slots.insert(entry.slot); std::set used_preset_names(this->filament_presets.begin(), this->filament_presets.end()); // 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 setting_id (variant-level, + // since "Generic PLA" and "Generic PLA Matte" share filament_id), then exact + // filament_id, then vendor+type, then type only (a type-only pick may surface an + // unrelated preset, e.g. a different vendor's PLA). + auto candidate_score = [](const Preset &candidate, const PublishedMaterialEntry &entry) -> int { + 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; + if (normalize_filament_type(type) == entry.publish_type_value) { + 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 (published_slots.count(static_cast(new_slot_idx)) != 0) { - // Proactively assign a distinct matching candidate preset if this slot - // carries a published type... + // Prefer the best distinct candidate for the slot's published material + // (exact id, then vendor+type, then type only)... for (const PublishedMaterialEntry &entry : published_config->material_keys) { if (entry.slot != static_cast(new_slot_idx) || !entry.publish_type || entry.publish_type_value.empty()) continue; - for (size_t i = 0; i < this->filaments.size(); ++i) { + int best_score = -1; + for (size_t i = first_candidate; i < this->filaments.size(); ++i) { const Preset &candidate = this->filaments.preset(i); if (!candidate.is_visible || used_preset_names.count(candidate.name) != 0) continue; - if (normalize_filament_type(candidate.config.opt_string("filament_type", 0u)) == entry.publish_type_value) { + const int score = candidate_score(candidate, entry); + if (score > best_score) { + best_score = score; initial_preset = candidate.name; - break; } } break; @@ -5091,62 +4948,65 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool } } if (initial_preset.empty()) - // Unpublished filler slot, or every visible preset is already used: repeat - // the receiver's last preset, mirroring the "Add one filament" behaviour - // (PresetBundle::set_num_filaments). + // 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); used_preset_names.insert(initial_preset); } - // Slots that were grown before this block (e.g. by update_multi_material_filament_presets - // matching the extruder count) may still alias another slot; re-point them at a - // distinct preset. Slot 0, the receiver's own material, is never re-assigned. + // Published slots that alias another slot (multi-extruder with one filament) + // get re-pointed at distinct presets: the overlay mutates stored presets 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) - continue; - bool shared = false; - for (size_t other = 0; other < this->filament_presets.size(); ++other) - if (other != slot && this->filament_presets[other] == this->filament_presets[slot]) { - shared = true; - break; - } - if (!shared) + if (published_slots.count(static_cast(slot)) == 0 || + !referenced_elsewhere(this->filament_presets[slot], slot)) continue; + // Prefer the best distinct candidate for the slot's published material + // (exact id, then vendor+type, then type only)... std::string replacement; + int best_score = -1; for (const PublishedMaterialEntry &entry : published_config->material_keys) { if (entry.slot != static_cast(slot) || !entry.publish_type || entry.publish_type_value.empty()) continue; - for (size_t i = 0; i < this->filaments.size(); ++i) { + for (size_t i = first_candidate; i < this->filaments.size(); ++i) { const Preset &candidate = this->filaments.preset(i); - if (!candidate.is_visible || used_preset_names.count(candidate.name) != 0) + if (!candidate.is_visible || referenced_elsewhere(candidate.name, size_t(-1))) continue; - if (normalize_filament_type(candidate.config.opt_string("filament_type", 0u)) == entry.publish_type_value) { + const int score = candidate_score(candidate, entry); + if (score > best_score) { + best_score = score; replacement = candidate.name; - break; } } break; } + // ...otherwise any distinct visible preset not referenced by another slot. if (replacement.empty()) { for (size_t i = first_candidate; i < this->filaments.size(); ++i) { const Preset &candidate = this->filaments.preset(i); - if (!candidate.is_visible || used_preset_names.count(candidate.name) != 0) - continue; - replacement = candidate.name; - break; + if (candidate.is_visible && !referenced_elsewhere(candidate.name, size_t(-1))) { + replacement = candidate.name; + break; + } } } if (replacement.empty()) - continue; // every visible preset is used: aliasing is unavoidable - used_preset_names.erase(this->filament_presets[slot]); + continue; // every visible preset is referenced: aliasing is unavoidable this->filament_presets[slot] = replacement; - used_preset_names.insert(replacement); + material_applied = true; } - // Mirror set_num_filaments' project_config vector handling ("Add one filament"): - // resize the per-slot colour/type/map vectors to the grown slot count and seed the - // new entries so the slots render with colours instead of blank chips. Only the - // new entries are seeded; the receiver's existing values are left untouched. + // 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"); @@ -5184,8 +5044,7 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool 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 (set_num_filaments does the - // same; without it the matrix would stay at the receiver's old size). + // Rebuild the flush volumes for the grown slot count (as set_num_filaments does). this->update_multi_material_filament_presets(); auto apply_slot_keys = [&](Preset &preset, const std::vector &slot_keys, int author_slot, @@ -5207,23 +5066,22 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); continue; } - // Per-slot scalar copy: the receiver's filament preset holds a single - // value per key (vector of size 1), the file holds the per-slot vector. + // 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 directly onto the slot's stored preset (mutate + // in place); the per-entry type gate below may re-point the slot first. for (const PublishedMaterialEntry &entry : published_config->material_keys) { - if (!entry.full && !entry.publish_type && !entry.publish_color) - continue; // legacy entry, handled above 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); - // Modify the stored preset itself (real=true), never the edited snapshot: - // find_preset would return &m_edited_preset for the currently selected slot, - // and the re-select at the end of this block re-snapshots from the stored - // preset, silently discarding any values applied to the snapshot. + // Resolve the stored preset itself (real=true), never the edited snapshot: + // find_preset would return &m_edited_preset for the selected slot, and the + // re-select at the end re-snapshots from the stored preset. Preset *recv = this->filaments.find_preset(this->filament_presets[slot], false, true); if (recv == nullptr) continue; @@ -5234,83 +5092,96 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool bool apply_slot = true; if (entry.publish_type && !entry.publish_type_value.empty()) { - const std::string recv_type = normalize_filament_type(recv->config.opt_string("filament_type", 0u)); - if (recv_type == entry.publish_type_value) { - // Type match: the receiver keeps its material. A full dump is - // intentionally ignored for this slot; partial keys still apply. - if (entry.full) - apply_slot = false; - } else { - // Type mismatch: replace the slot with the first visible same-type - // filament from the receiver's library, preferring one that no other - // slot references (a shared stored preset would leak this slot's - // published values into that slot). - std::string replacement, first_same_type; - for (size_t i = 0; i < this->filaments.size(); ++i) { - const Preset &candidate = this->filaments.preset(i); - if (!candidate.is_visible) - continue; - if (normalize_filament_type(candidate.config.opt_string("filament_type", 0u)) != entry.publish_type_value) - continue; - if (first_same_type.empty()) - first_same_type = candidate.name; - bool used_elsewhere = false; - for (size_t s = 0; s < this->filament_presets.size(); ++s) - if (s != slot && this->filament_presets[s] == candidate.name) { - used_elsewhere = true; - break; + // Null-guard: a malformed receiver preset may lack filament_type. + const ConfigOptionStrings *recv_types = recv->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 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. + auto find_best = [&](bool unreferenced_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 (!candidate.is_visible) + continue; + const int score = candidate_score(candidate, entry); + if (score <= best_score) + 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; } - if (!used_elsewhere) { - replacement = candidate.name; - break; + best_score = score; + best_name = candidate.name; } + return { best_score, best_name }; + }; + const auto [strict_score, strict_name] = find_best(true); + const auto [relaxed_score, relaxed_name] = find_best(false); + int score = strict_score; + std::string replacement = strict_name; + if (relaxed_score > strict_score) { + score = relaxed_score; + replacement = relaxed_name; } - if (replacement.empty()) - replacement = first_same_type; 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; - published_config->material_replacements.emplace_back( - "slot " + std::to_string(slot) + ": " + old_name + " -> " + replacement); + 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: no exact material match)"; + published_config->material_replacements.emplace_back(std::move(replacement_line)); } else if (entry.full) { - // No library match: create a temporary project-embedded custom preset - // populated with default settings and overlaid with the author's values. - std::string custom_name = entry.publish_type_value + " (Published)"; - for (size_t idx = 1; this->filaments.find_preset(custom_name, false) != nullptr; ++idx) - custom_name = entry.publish_type_value + " (Published " + std::to_string(idx) + ")"; - - // Capture the slot's current name BEFORE load_preset: the custom - // name sorts ahead of the slot's material, so the deque insertion - // relocates it and recv would dangle after the call. - const std::string old_name = recv->name; - - DynamicPrintConfig custom_cfg = this->filaments.default_preset_for(config).config; - // filament_type is a per-slot vector option: set it via the strings - // accessor. opt_string(key, bool) would ask for the scalar - // ConfigOptionString, fail the cast and dereference nullptr. - if (ConfigOptionStrings *type_opt = custom_cfg.opt("filament_type", true)) { - if (type_opt->values.empty()) - type_opt->values.emplace_back(); - type_opt->values[0] = entry.publish_type_value; + // No same-type library preset: fall back to the first available + // visible preset, preferring one no other slot references, and + // apply the author's full values on top of it (the dump carries + // filament_type, so the preset takes the author's type). + std::string fallback; + for (size_t i = first_candidate; i < this->filaments.size(); ++i) { + const Preset &candidate = this->filaments.preset(i); + if (!candidate.is_visible) + continue; + if (fallback.empty()) + fallback = candidate.name; + bool referenced = false; + for (size_t s = 0; s < this->filament_presets.size(); ++s) + if (this->filament_presets[s] == candidate.name) { + referenced = true; + break; + } + if (!referenced) { + fallback = candidate.name; + break; + } } - if (ConfigOptionStrings *id_opt = custom_cfg.opt("filament_settings_id", true)) - if (!id_opt->values.empty()) - id_opt->values[0] = custom_name; - - Preset &created = this->filaments.load_preset("", custom_name, std::move(custom_cfg), false, file_version); - created.is_project_embedded = true; - created.is_visible = true; - - this->filament_presets[slot] = custom_name; - recv = &created; - material_applied = true; - published_config->material_replacements.emplace_back( - "slot " + std::to_string(slot) + ": " + old_name + " -> " + custom_name); + if (!fallback.empty() && fallback != recv->name) { + const std::string old_name = recv->name; + this->filament_presets[slot] = fallback; + recv = this->filaments.find_preset(fallback, false, true); + material_applied = true; + published_config->material_replacements.emplace_back( + "slot " + std::to_string(slot) + ": " + old_name + " -> " + fallback + + " (substitute: no " + entry.publish_type_value + " available)"); + } + // No visible preset at all: keep the receiver's material and let + // the full dump mutate it below. } else { - // Partial publish with no replacement available: keep the - // receiver's material and report this slot's keys as skipped. + // Partial publish with no replacement: keep the receiver's + // material and report the slot's keys as skipped. for (const std::string &key : entry.keys) skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); apply_slot = false; @@ -5318,14 +5189,15 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool } } - // Colour is slot-scoped and independent of the type gate: it is applied to - // whichever material ends up in the slot (original, replacement or the - // in-memory fallback), and synced into project_config for GUI rendering. + // The values below are applied onto whatever stored preset the slot ended up + // on (original, type replacement or the full-publish fallback), in place. + + // 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()) { if (recv != nullptr) { - // Create the key when the target preset lacks it (e.g. a replacement - // built from the static defaults): the colour is a requirement, not - // an optional override. + // Create the key when the target preset lacks it: the colour is a + // requirement, not an override. if (ConfigOptionStrings *colour = recv->config.opt("filament_colour", true)) { if (colour->values.empty()) colour->values.emplace_back(); @@ -5364,11 +5236,9 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool } published_config->skipped_keys = std::move(skipped_keys); - // The material overlay above modified the filament collection presets in place, but - // the edited preset (what the GUI displays) is a snapshot taken when the preset was - // last selected. Re-select the first slot's filament (mirroring a normal project load) - // so the applied values (colour, type, keys and slot replacements) surface in the GUI; - // selecting any other slot's filament afterwards snapshots its modified preset too. + // The material overlay mutates the collection presets in place, but the edited preset + // (what the GUI displays) is a snapshot taken when the preset was last selected. + // Re-select the first slot's filament so the applied values surface in the GUI. if (material_applied && !this->filament_presets.empty()) this->filaments.select_preset_by_name(this->filament_presets.front(), true); } diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index d568de30e3..ad78a7c59c 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -167,22 +167,21 @@ struct PresetBundleMetadata } }; -// Configuration describing a "published" 3MF project: the file carries a flag plus a list of -// author-selected setting keys. When loading such a project the user's currently-selected -// presets are kept and only the published keys are overlaid onto the edited presets. +// 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; - // Material-qualified published keys chosen by the author for the materials used in the - // project; applied on load only to the receiver's filament presets whose material - // identity matches (see PublishedMaterialEntry in PublishSettings.hpp). + // Per-slot published material keys, applied positionally (author slot N -> receiver slot N), + // gated by the author's optional type requirement and written onto the slot's stored preset + // in place (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 (e.g. "Slot 2: replaced PETG with PLA"), for the load notification. + // published project, for the load notification. std::vector material_replacements; }; diff --git a/src/libslic3r/PublishSettings.cpp b/src/libslic3r/PublishSettings.cpp index 0d1bd07989..2be7a869d1 100644 --- a/src/libslic3r/PublishSettings.cpp +++ b/src/libslic3r/PublishSettings.cpp @@ -30,11 +30,9 @@ std::string normalize_filament_type(const std::string& type) const std::set& publish_structural_keys() { - // Structural / non-publishable keys. The *_settings_id keys are also part of - // PresetCollection::skipped_in_dirty (Preset.cpp) and are excluded there too. - // This mirrors the structural keys stripped from configs in Preset.cpp - // (profile_print_params_same) plus other keys that must never be published - // because they would rewrite the user's preset inheritance/structure. + // 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", @@ -88,9 +86,8 @@ const std::vector& publishable_printer_z_hop_options() const std::set& publishable_printer_keys() { - // The union of the printer tab's "Retraction" and "Z-Hop" optgroups. The "Retraction when - // switching material" keys are intentionally excluded: toolchange retraction is - // device/profile territory, not a publishable behavior tweak. + // 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()) @@ -113,9 +110,8 @@ std::vector collect_dirty_settings_keys(const PresetBundle& bundle) } }; - // Print and printer presets each track a single edited preset; filaments may span - // multiple slots (multi-material). Union the dirty keys of each collection's edited - // preset; this feeds only the Publish dialog's pre-check. + // Union the dirty keys of each collection's edited preset (filaments may span multiple + // slots); feeds only the Publish dialog's pre-check. append_dirty(bundle.prints.current_dirty_options(true)); append_dirty(bundle.printers.current_dirty_options(true)); append_dirty(bundle.filaments.current_dirty_options(true)); @@ -131,12 +127,11 @@ DynamicPrintConfig filter_published_config( DynamicPrintConfig filtered; std::set base_keys_to_include; - // Base keys that must never be masked: identity, plate geometry, process/printer keys and - // partially-published material keys keep today's whole-vector serialization (all slots). + // Never masked (whole-vector serialization): identity, plate geometry, process/printer + // keys and partially-published material keys. std::set mask_exempt_keys; - // For keys carried only by "full" entries: base key -> author slots whose values must - // survive; the other slots are masked to their defaults so a full publish does not leak - // the author's unrelated slot data. + // "Full" entries only: base key -> author slots whose values must survive; other slots are + // masked to their defaults so a full publish does not leak unrelated slot data. std::map> full_slot_map; // 1. Mandatory material identity & slot count keys for 3MF validation/normalization @@ -183,8 +178,7 @@ DynamicPrintConfig filter_published_config( mask_exempt_keys.insert(base_key); } } - // 4b. "Full publish" entries carry the entire slot; the values of the covered keys are - // masked to the author's slot on export (see the copy loop below). + // Full-publish keys: mask to the author's slot on export (see the copy loop below). for (const std::string &key : entry.full_keys) { const std::string base_key = key.substr(0, key.find('#')); if (base_key.empty()) @@ -195,9 +189,8 @@ DynamicPrintConfig filter_published_config( } } - // Mask a vector option's slots that are not author-published: copy the option default over - // each non-published index. Keys without an option default are left unmasked (the file then - // carries the whole vector, matching the partial-publish behavior). + // Mask non-published vector slots with the option default; keys without a default stay + // unmasked (whole vector, matching partial-publish behavior). auto mask_slots = [](ConfigOption &opt, const ConfigOptionDef *def, const std::set &keep_slots) { auto *vec = dynamic_cast(&opt); if (vec == nullptr || vec->size() == 0 || def == nullptr || !def->default_value) @@ -212,7 +205,7 @@ DynamicPrintConfig filter_published_config( vec->set_at(def->default_value.get(), idx, 0); }; - // Copy selected options from full_config into filtered config + // Copy the selected options from full_config into the filtered config. for (const std::string &key : base_keys_to_include) { if (const ConfigOption *opt = full_config.option(key)) { ConfigOption *cloned = opt->clone(); diff --git a/src/libslic3r/PublishSettings.hpp b/src/libslic3r/PublishSettings.hpp index aadf0d3234..ecb51523f5 100644 --- a/src/libslic3r/PublishSettings.hpp +++ b/src/libslic3r/PublishSettings.hpp @@ -6,78 +6,63 @@ namespace Slic3r { class PresetBundle; -// Structural / non-publishable setting keys, shared by the Publish dialog and the published-3MF -// overlay path in PresetBundle::load_config_file_config. These keys must never be published -// because they would rewrite the user's preset inheritance/structure. This is the single -// source of truth for the denylist. +// Structural keys that must never be published (single source of truth for the denylist): +// publishing them would rewrite the user's preset inheritance/structure. const std::set& publish_structural_keys(); -// One option row of the printer tab's "Retraction" / "Z-Hop" optgroups (TabPrinter::build_fff, -// Tab.cpp). Key and icon id are kept together so the tab can later be migrated onto these -// lists; publishable_printer_keys() is their union, and the published-3MF loader/dialog must -// never accept printer keys outside it. +// One row of the printer tab's "Retraction" / "Z-Hop" optgroups (key + tab icon id), kept +// together so the tab can later be migrated onto these lists. 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" optgroup options, in tab order. +// The printer tab's "Retraction" / "Z-Hop" optgroup options, in tab order. const std::vector& publishable_printer_retraction_options(); -// The printer tab's "Z-Hop" optgroup options, in tab order. const std::vector& publishable_printer_z_hop_options(); -// Printer-class retraction / z-hop keys that are publishable: the union of -// publishable_printer_retraction_options() and publishable_printer_z_hop_options(). The -// published-3MF overlay applies printer keys only when their base key is in this allowlist; -// any other printer-class key in a published file is contract-excluded (never applied, never -// reported as skipped). +// Union of the two optgroup option lists; the published-3MF overlay applies printer keys only +// when their base key is in this allowlist (anything else is contract-excluded). const std::set& publishable_printer_keys(); -// Returns the union of setting keys that differ from the base/system preset across the current -// print, printer and filament presets (feeds the Publish dialog's pre-check). +// 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); -// A material-qualified set of published setting keys, chosen by the author for one of the -// materials used in the project. The identity fields let the receiver apply the keys only -// when a matching material is selected: filament_id is the most precise (stable across -// machines/vendors when present, empty for user presets); filament_type + filament_vendor -// are the fallback. Keys are base keys (no "#N" variant suffix). +// Per-slot published material keys, applied positionally (author slot N -> receiver slot N). +// The identity fields are carried for reference/notification labels only; the 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) - // 0-based author filament slot this entry's values came from; -1 = legacy/unspecified - // (files written before the slot field). Slotted entries apply to the receiver's Nth - // matching preset (N = the slot's ordinal among the author's matching slots); legacy - // entries apply to every matching receiver preset. + // Unique preset id of the author's slot preset (e.g. Orca Filament Library "setting_id"); + // used on load to match the exact published variant, which filament_id alone cannot + // distinguish ("Generic PLA" and "Generic PLA Matte" share their inherited id). + std::string setting_id; + // 0-based author filament slot; -1 (hand-crafted files) is skipped. int slot{-1}; std::vector keys; - // "Full Publish": the entire filament preset of this slot is serialized (see full_keys), - // not just the individually selected keys. On load the type gate (publish_type_value) - // decides whether the receiver keeps its material (type match) or is replaced; a full - // entry carries no partial keys. + // "Full Publish": serialize the whole filament preset (full_keys); the type gate then + // decides whether the receiver keeps its material (type match) or is replaced. bool full{false}; - // All non-structural filament keys of the author's slot preset, present when full is true. - // Values travel in the file config, masked to the author's slot index. + // 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, curated (MaterialType) filament type the author requires for this slot. - // On load the receiver's slot material is matched against it; on mismatch the slot is - // replaced with a same-type filament from the receiver's library. + // Vendor-agnostic (MaterialType) filament type the author requires for this slot; on + // mismatch the slot is replaced with a same-type filament from the receiver's library. bool publish_type{false}; std::string publish_type_value; - // Required filament colour for this slot, applied on load regardless of the type match. + // Required filament colour, applied on load regardless of the type match. bool publish_color{false}; std::string color; }; -// Normalizes a filament type string against the curated MaterialType list: an exact match -// wins, then the value is stripped after its first space ("PLA High Speed" -> "PLA"); a -// value still not recognized is returned unchanged. Shared by the Publish dialog's type row -// default and by the published-3MF loader's type matching. +// "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); -// Constructs a minimal DynamicPrintConfig for a published 3MF export containing only the -// author-selected published keys, material keys, material identity fields, and plate geometry keys. +// Minimal DynamicPrintConfig for a published 3MF export: only the selected published keys, +// material keys, identity fields and plate geometry keys. class DynamicPrintConfig; DynamicPrintConfig filter_published_config( const DynamicPrintConfig &full_config, diff --git a/src/slic3r/GUI/ConfigValueFormatter.hpp b/src/slic3r/GUI/ConfigValueFormatter.hpp index e7ed549930..c51a8e8a17 100644 --- a/src/slic3r/GUI/ConfigValueFormatter.hpp +++ b/src/slic3r/GUI/ConfigValueFormatter.hpp @@ -11,18 +11,16 @@ class DynamicPrintConfig; namespace GUI { -// Return the value of the given option (identified by opt_key, which may contain -// a "#" suffix) formatted as a human readable string. +// Human-readable value of opt_key (may carry a "#" suffix) in config. wxString get_string_value(const std::string& opt_key, const DynamicPrintConfig& config); -// Return the full label of the given option (identified by opt_key, which may contain -// a "#" suffix). Returns "N/A" when the option is not set. +// 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 given option key. +// Strip the "#" suffix (if any) from the option key. std::string get_pure_opt_key(const std::string& opt_key); -// Return the localized label of the currently selected value of an enum option. +// 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 diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 3cb246529f..48f315453c 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -2837,11 +2837,11 @@ void MainFrame::init_menubar_as_editor() auto publish_handler = [this](wxCommandEvent&) { publish_project(); }; #ifndef __APPLE__ - append_menu_item(fileMenu, wxID_ANY, _L("Publish") + dots + "\t" + ctrl + shift + "E", _L("Export a 3MF file with the selected settings embedded"), + 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") + dots + "\t" + ctrl + shift + "E", _L("Export a 3MF file with the selected settings embedded"), + 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 diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index cef3613ae9..5bd1fa6d87 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -7208,9 +7208,8 @@ std::vector Plater::priv::load_files(const std::vector& input_ } } - // BBS: a "published" 3MF project carries a flag plus a list of author-selected - // setting keys. When present, keep the user's currently-selected presets and - // overlay only the published keys onto the edited presets on load. + // 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. PublishedConfig published_config; if (model.model_info != nullptr) { auto published_it = model.model_info->metadata_items.find("published"); @@ -7249,6 +7248,8 @@ std::vector Plater::priv::load_files(const std::vector& input_ 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 (m.contains("slot") && m["slot"].is_number_integer()) entry.slot = m["slot"].get(); @@ -7257,7 +7258,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ for (const auto &k : *entry_keys_it) if (k.is_string()) entry.keys.emplace_back(k.get()); - // Filament-publishing-v2 fields; absent in legacy files. + // 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"); @@ -7282,10 +7283,10 @@ std::vector Plater::priv::load_files(const std::vector& input_ } } - // BBS: a "published" 3MF behaves like a new project once loaded: the file's path - // must not become the project filename (Save/Ctrl-S would otherwise overwrite the - // shared file), and the published metadata is consumed by the overlay above and - // stripped so a later save produces a normal, unpublished 3MF. + // 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) { @@ -13309,10 +13310,10 @@ void Plater::load_project(wxString const& filename2, p->set_project_filename(filename); } else if (loaded_published) { - // A "published" 3MF loads as a new project: the shared file's path must not become - // the project filename, so Save/Ctrl-S prompts for a destination instead of - // overwriting the published file. reset() above already cleared the project name - // and folder; restore the default new-project title and keep the file in recents. + // 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. p->set_project_name(_L("Untitled")); if (!filename.IsEmpty()) wxGetApp().mainframe->add_to_recent_projects(filename); @@ -16225,10 +16226,9 @@ void Plater::export_core_3mf() export_3mf(path_u8, SaveStrategy::Silence); } -// Export the current project as a "published" 3MF. This is a pure export: unlike save_project(), -// it never touches the project's file name, dirty state, backup path or title, and the -// published metadata is attached to the model only for the duration of the export so the -// in-memory project stays exactly as it was (a later Save Project produces a normal 3MF). +// 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:")); @@ -16240,14 +16240,14 @@ int Plater::export_published_3mf(const std::vector& 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}}}, {"slot", e.slot}, {"keys", e.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}}}, {"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(); - // Remember the previous metadata state so it can be restored after the export, keeping the - // in-memory project pristine (the published flag lives only in the exported file). + // 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("published") != model.model_info->metadata_items.end()); const bool had_published_keys = had_model_info && (model.model_info->metadata_items.find("published_keys") != model.model_info->metadata_items.end()); @@ -16261,15 +16261,13 @@ int Plater::export_published_3mf(const std::vector& published_keys, model.model_info->metadata_items["published_keys"] = j.dump(); model.model_info->metadata_items["published_material_keys"] = jm.dump(); - // Minimal published export: filter full_config to only the published keys, material keys, - // identity fields, and plate geometry keys, and omit project-embedded preset dumps. + // Minimal published export: filter full_config to the published keys, material keys, + // identity fields and plate geometry keys, and omit project-embedded preset dumps. DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config_secure(); DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, published_keys, material_keys); - // Same file layout save_project() uses for its project files, plus SaveStrategy::Silence and SaveStrategy::MinimalPublished: - // without it export_3mf() calls set_project_filename() on success, which would make this - // pure export the current project file. Silence keeps the project state untouched, exactly - // like export_core_3mf(). + // 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) diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index d6ec283409..826fe29a5a 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -495,9 +495,8 @@ public: void export_gcode_3mf(bool export_all = false); void send_gcode_finish(wxString name); void export_core_3mf(); - // Export the current project as a "published" 3MF: embeds the author-selected settings - // (published_keys / published_material_keys) into the file's metadata. A pure export: the - // in-memory project (filename, dirty state, model_info metadata) is left untouched. + // 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); 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); diff --git a/src/slic3r/GUI/PublishSettingsDialog.cpp b/src/slic3r/GUI/PublishSettingsDialog.cpp index 009f672d3e..f62d7af3a4 100644 --- a/src/slic3r/GUI/PublishSettingsDialog.cpp +++ b/src/slic3r/GUI/PublishSettingsDialog.cpp @@ -23,8 +23,8 @@ namespace Slic3r { namespace GUI { namespace { -// Menu ids for show_menu(). Dedicated range above the standard ids so the popup cannot -// collide with application-level bindings (e.g. MainFrame's recent-files wxID_FILE1.. range). +// 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, @@ -49,9 +49,7 @@ PublishMaterialIdentity material_identity(size_t slot, const DynamicPrintConfig& return identity; } -// "Generic PLA @System" -> "Generic PLA"; mirrors the alias derivation in -// PresetBundle::load_vendor_configs_from_json (PresetBundle.cpp) and -// PresetCollection::set_custom_preset_alias (Preset.cpp). +// "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('@'); @@ -62,8 +60,8 @@ std::string material_display_name(const std::string& preset_name) return bare.empty() ? preset_name : bare; } -// Human-readable section title for a filament slot: the resolved preset name, -// falling back to the filament type, then to the generic "Material". +// 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()) { @@ -165,8 +163,7 @@ PublishSettingsDialog::PublishSettingsDialog(wxWindow* parent) auto dlg_btns = new DialogButtons(this, {"OK", "Cancel"}); dlg_btns->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { - // Publish is always allowed: no settings selected means a publish with - // no settings override. + // Publish is always allowed: no settings selected means no settings override. EndModal(wxID_OK); }); dlg_btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); @@ -183,12 +180,11 @@ PublishSettingsDialog::~PublishSettingsDialog() {} void PublishSettingsDialog::build_option_model() { - // Structural / non-publishable keys, shared with the published-3MF overlay - // path (see libslic3r/PublishSettings.hpp). + // 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/printer sections. Printer rows share - // this set: a base key appears once (per-extruder "#N" variants collapse to - // the first occurrence - acceptable MVP; the per-extruder context is lost). + // Base keys already added in the print/printer sections. Printer rows share this set: + // per-extruder "#N" variants collapse to the first occurrence (acceptable MVP; the + // per-extruder context is lost in the UI). std::set added; PresetBundle* bundle = wxGetApp().preset_bundle; @@ -198,17 +194,17 @@ void PublishSettingsDialog::build_option_model() m_info_allsel = _L("All items selected..."); m_info_empty = _L("No matching items..."); - // Keep the tab order explicit: Section's enum order is Print, Printer, - // Material, while the dialog presents Printer, Filament, Process. + // 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 pure - // stringified value; unit is the translated sidetext (may be empty). + // 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) @@ -224,9 +220,8 @@ void PublishSettingsDialog::build_option_model() return true; }; - // --- Phase 1: printer per-extruder retraction settings (displayed first, - // mirroring the sidebar's Printer group). The printer tab's - // "Extruder"/"Extruder N" pages carry the per-extruder retraction options. + // --- Phase 1: printer per-extruder retraction settings (first, mirroring the sidebar's + // Printer group), from the printer tab's "Extruder"/"Extruder N" pages. { size_t g = section_group_for(Section::Printer); category_index_for(_L("Extruder"), Section::Printer, "custom-gcode_extruder", g, 0); @@ -238,18 +233,17 @@ void PublishSettingsDialog::build_option_model() 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. + // 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; - // Per-extruder "#N" variants collapse to the first base key. The row stores - // the BASE key (whole-vector semantics on load: the size-guarded apply - // copies the author's full vector), while the "#0" opt_id is only used to - // display the first extruder's value. + // Per-extruder "#N" variants collapse to the first base key. The row + // stores the base key; GetPublishedKeys() later expands it back to one + // "#N" entry per extruder so the load side can apply per-extruder values. if (!added.insert(pure_key).second) continue; wxString label, value, unit; @@ -264,8 +258,8 @@ void PublishSettingsDialog::build_option_model() } } - // --- Phase 2: per-material sections synthesized from the filament tab's - // "Setting Overrides" page, under the Filament group. + // --- 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; @@ -283,17 +277,16 @@ void PublishSettingsDialog::build_option_model() } if (overrides_page != nullptr) { - // One section per filament slot: a 4-slot printer (e.g. 1 PLA + - // 3 PETG) shows 4 separate pages, each disambiguated internally by - // its colour chip and slot identity while displaying the bare name. + // One section per filament slot (a 4-slot printer shows 4 pages), each + // disambiguated by its colour chip and slot identity while showing the bare name. for (size_t slot = 0; slot < bundle->filament_presets.size(); ++slot) { const PublishMaterialIdentity identity = material_identity(slot, full); const wxString title = material_title(slot, bundle, full); const size_t category_index = category_index_for(title, Section::Material, "custom-gcode_filament", g, slot, identity); - // Filament-publishing-v2 rows: the author may require a filament colour and/or - // a vendor-agnostic material type for this slot. They live in their own - // optgroup so they stay visually separated from the setting rows. + // 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"); std::string hex; @@ -309,18 +302,16 @@ void PublishSettingsDialog::build_option_model() RowKind::Type); } - // A material section must not repeat a key; the same key may - // appear in other material sections - that is intended. + // 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; the - // "Ironing" group is intentionally skipped. + // 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 (no "#N"): the load side - // matches the material and uses the author's slot. + // Row keys are base keys; the load side applies them positionally. const std::string& opt_id = opt.first; std::string base = opt_id.substr(0, opt_id.find('#')); if (!material_added.insert(base).second) @@ -381,17 +372,15 @@ void PublishSettingsDialog::build_option_model() } } - // Pre-check the dirty (modified) settings and mark them bold. The base-key - // match covers all sections; collect_dirty_settings_keys already unions the - // prints, printers and filaments of the bundle. + // Pre-check the dirty (modified) settings and mark them bold (base-key match, across all + // sections; collect_dirty_settings_keys unions the prints, printers and filaments). std::set dirty_base; for (const std::string& key : collect_dirty_settings_keys(*wxGetApp().preset_bundle)) { auto n = key.find('#'); dirty_base.insert(n == std::string::npos ? key : key.substr(0, n)); } for (Row& row : m_rows) { - // The Color/Type requirement rows are not "dirty overrides": they are never - // auto-checked by the dirty pre-check. + // The Color/Type requirement rows are not "dirty overrides": never auto-checked. if (row.kind != RowKind::Setting) continue; std::string base = row.key.substr(0, row.key.find('#')); @@ -402,8 +391,8 @@ void PublishSettingsDialog::build_option_model() } } - // Wire the "Full Publish" checkboxes: toggling one disables/enables the material's - // rows. Bind by index so the lambda stays valid even if the vector is reallocated later. + // Wire the "Full Publish" checkboxes: toggling one disables/enables the material's rows. + // 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].full_check != nullptr) m_categories[c].full_check->Bind(wxEVT_CHECKBOX, [this, c](wxCommandEvent&) { on_full_toggle(c); }); @@ -449,6 +438,7 @@ size_t PublishSettingsDialog::section_group_for(Section kind) section.icon_name = "process"; break; } + section.icon_bmp = ScalableBitmap(this, section.icon_name, 16); constexpr long tab_style = wxTR_NO_BUTTONS | wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES | wxBORDER_NONE | wxWANTS_CHARS | wxTR_FULL_ROW_HIGHLIGHT; @@ -466,7 +456,10 @@ size_t PublishSettingsDialog::section_group_for(Section kind) page_sizer->Add(section.page_host, 1, wxEXPAND | wxTOP, FromDIP(4)); section.page->SetSizer(page_sizer); - m_outer_tabs->AppendItem(section.title); + 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)); @@ -610,7 +603,7 @@ void PublishSettingsDialog::add_row_ui(const std::string& key, 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, the author cannot pick a different one here). + // 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"))); @@ -643,8 +636,7 @@ void PublishSettingsDialog::on_full_toggle(size_t category_index) void PublishSettingsDialog::set_row_bold(Row& row, bool bold) { - // Real set/clear: rebase on the dialog's body font so that clearing bold - // restores the exact original font (the old CheckList::SetBold was one-way). + // 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); } @@ -718,12 +710,11 @@ void PublishSettingsDialog::apply_filter(const wxString& filter_text) Freeze(); wxString filter = filter_text.Lower(); - // Pseudo filters (menu only): show only checked ("::sel") or only - // unchecked ("::nonsel") rows. + // Pseudo filters (menu only): show only checked ("::sel") or only unchecked ("::nonsel"). const bool pseudo = (filter == "::sel" || filter == "::nonsel"); m_fb_sizer->Show(!pseudo); - // Update row matches first; page and optgroup visibility is applied below. + // Row matches are computed first; page and optgroup visibility is applied below. if (pseudo) { if (m_filter_ctrl->GetValue().Lower() != filter) { m_filter_ctrl->ChangeValue(filter); @@ -808,8 +799,7 @@ void PublishSettingsDialog::apply_visibility() void PublishSettingsDialog::select_all(bool value) { - // "All" does not auto-enable gated material sections; "None" leaves a gated - // row's preserved value untouched. + // "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); @@ -829,19 +819,18 @@ bool PublishSettingsDialog::row_is_visible(const Row& row) const void PublishSettingsDialog::select_visible(bool value) { wxString filter = m_filter_ctrl->GetValue().Lower(); - // In a pseudo-filter view the rows being toggled would all disappear; - // drop the filter afterwards so the result stays visible. + // In a pseudo-filter view the rows being toggled would all disappear; drop the filter + // afterwards so the result stays visible. bool clear_pseudo = (!value && filter == "::nonsel") || (value && filter == "::sel"); - // Toggle the rows that are visible under the *current* filter. + // 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, which - // re-enters apply_filter() - that is fine, the rows above were already - // toggled and the trailing call below is idempotent. + // 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 } @@ -895,12 +884,31 @@ void PublishSettingsDialog::show_menu(wxMouseEvent& evt) 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. - for (const Row& row : m_rows) - if ((row.section == Section::Print || row.section == Section::Printer) && row.check->GetValue()) + // 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. + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + for (const Row& row : m_rows) { + if ((row.section != Section::Print && row.section != Section::Printer) || !row.check->GetValue()) + continue; + if (row.section == Section::Printer) { + // Printer rows store the base key (per-extruder "#N" variants collapsed during + // build). Publish every extruder element so the load side can apply per-extruder + // values even when the receiver has a different extruder count; a scalar printer + // key is published as-is. + const std::string base_key = row.key.substr(0, row.key.find('#')); + if (const ConfigOption* opt = full.option(base_key)) { + if (const auto* vec = dynamic_cast(opt)) { + for (size_t i = 0; i < vec->size(); ++i) + out.push_back(base_key + "#" + std::to_string(i)); + } else { + out.push_back(base_key); + } + } + } else { out.push_back(row.key); + } + } return out; } @@ -915,8 +923,15 @@ std::vector PublishSettingsDialog::GetPublishedM entry.filament_vendor = cat.filament_vendor; entry.filament_id = cat.filament_id; entry.slot = static_cast(cat.filament_slot); - // "Full Publish": the entire filament preset of the slot is embedded; type and color - // are implicitly published, and the per-key rows are disabled and their state is ignored. + // The author's preset id distinguishes exact variants that share filament_id + // ("Generic PLA" vs "Generic PLA Matte"), so the receiver can match precisely. + 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; + } + // "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(); @@ -946,8 +961,7 @@ std::vector PublishSettingsDialog::GetPublishedM entry.keys.push_back(row.key); } } - // A material with only setting keys but none checked, or with nothing selected at all, - // carries no information for the writer. + // Nothing checked at all -> nothing to write. if (!entry.keys.empty() || entry.publish_type || entry.publish_color) out.push_back(std::move(entry)); } @@ -956,9 +970,9 @@ std::vector PublishSettingsDialog::GetPublishedM std::vector PublishSettingsDialog::full_keys_for_slot() const { - // The canonical filament preset keys, minus the structural keys the published overlay must + // 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). The values travel in the exported config, masked to + // 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; @@ -971,7 +985,7 @@ std::vector PublishSettingsDialog::full_keys_for_slot() const void PublishSettingsDialog::on_dpi_changed(const wxRect& suggested_rect) { - // Rescale toolbar bitmaps and icons; collapse chevrons are vector-drawn and repaint themselves. + // 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()); @@ -1000,8 +1014,13 @@ void PublishSettingsDialog::on_dpi_changed(const wxRect& suggested_rect) cat.list_sizer->Layout(); } - for (SectionGroup& section : m_sections) + 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(); + } // Refresh the per-row Color chips at the new DPI. for (Row& row : m_rows) { diff --git a/src/slic3r/GUI/PublishSettingsDialog.hpp b/src/slic3r/GUI/PublishSettingsDialog.hpp index 70b1dc8a2d..74fc6a7077 100644 --- a/src/slic3r/GUI/PublishSettingsDialog.hpp +++ b/src/slic3r/GUI/PublishSettingsDialog.hpp @@ -28,25 +28,21 @@ struct PublishMaterialIdentity std::string id; }; -// Dialog that lets a model author select which settings get embedded in a 3MF. -// Settings are grouped into the same nested custom tab layout used by the -// Process settings: Printer, Filament, and Process outer tabs, with category or -// material tabs inside each section. Optgroups are ordinary grouped headers. -// Modified (dirty) settings are pre-checked and shown bold. On OK, the print -// rows become the "published_keys" list and the material rows become the -// per-material "published_material_keys". +// 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 +// "published_keys" and the material rows become "published_material_keys". class PublishSettingsDialog : public DPIDialog { public: PublishSettingsDialog(wxWindow* parent = nullptr); ~PublishSettingsDialog(); - // The selected print-section setting keys (in display order). Keys may - // contain '#'. + // 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: one entry per material section - // with at least one checked key. Keys are base keys (no "#N" suffix). + // The selected keys grouped per material section (base keys, no '#N' suffix). std::vector GetPublishedMaterialKeys() const; protected: @@ -56,9 +52,9 @@ private: // 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 a (optional) grey unit label. key is the full config key and may carry - // a "#N" variant suffix (print/printer rows); material rows carry the base key. + // 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) @@ -113,9 +109,9 @@ private: ScalableBitmap icon_bmp; // scalable bitmap for DPI changes wxStaticBitmap* icon{nullptr}; wxStaticBitmap* filament_color_chip{nullptr}; - wxStaticText* title_label{nullptr}; // material title (static text, Full Publish carries the label elsewhere) - // "Full Publish": serializing the entire filament preset of this slot. While checked, - // the slot's rows (incl. Color/Type) are disabled. + wxStaticText* title_label{nullptr}; // material title (static text; Full Publish carries the label elsewhere) + // "Full Publish": while checked, the whole slot preset is serialized and its rows + // (incl. Color/Type) are disabled. bool full{false}; wxCheckBox* full_check{nullptr}; // Material identity, only for Section::Material categories. @@ -134,6 +130,7 @@ private: 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}; wxPanel* page_host{nullptr}; diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index d9d6833a5a..34ff31183f 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -501,12 +501,10 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") { } } -// The "Publish" feature stores a published flag plus a JSON array of author-selected setting keys -// in model.model_info->metadata_items. This locks the serialization contract: both keys must survive -// a store_bbs_3mf -> load_bbs_3mf round-trip unchanged. (The full preset-preservation behavior — -// keeping the user's currently-selected presets and overlaying only the published keys onto them — -// is exercised headlessly in "Published 3MF overlays only the author-selected process keys onto the -// edited preset" in test_preset_bundle_loading.cpp.) +// Locks the serialization contract of the "Publish" metadata: the published flag and the +// 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; @@ -518,8 +516,8 @@ SCENARIO("Published 3MF round-trips the published flag and published_keys metada model.model_info->metadata_items["published"] = "1"; model.model_info->metadata_items["published_keys"] = R"(["layer_height","wall_thickness"])"; - // store_bbs_3mf stages Metadata/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). + // 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()); @@ -564,8 +562,8 @@ SCENARIO("Published 3MF round-trips the published flag and published_keys metada } } -// A project saved without the Publish metadata (i.e. a normal 3MF) must load identically: the -// loader must not fabricate a "published" flag or published_keys for files that never carried them. +// A normal 3MF (no Publish metadata) must load identically: the loader must not fabricate a +// "published" flag or 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; @@ -610,10 +608,8 @@ SCENARIO("Legacy 3MF without published metadata loads unchanged", "[3mf]") { } } -// The "Publish" feature can also store material-qualified setting keys, one entry per material -// the author uses, in model.model_info->metadata_items. This locks the serialization contract -// for that entry list: the JSON must survive a store_bbs_3mf -> load_bbs_3mf round-trip -// verbatim, exactly like the plain published_keys array. +// Locks the serialization contract of the published_material_keys metadata: the per-entry JSON +// must survive a store_bbs_3mf -> load_bbs_3mf round-trip verbatim, exactly like published_keys. SCENARIO("Published 3MF round-trips the published_material_keys metadata", "[3mf]") { GIVEN("a model carrying published material keys metadata") { Model model; @@ -658,8 +654,7 @@ SCENARIO("Published 3MF round-trips the published_material_keys metadata", "[3mf REQUIRE(dst_model.model_info->metadata_items["published_material_keys"] == 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, so the loader can match it - // to the receiver's filaments. + // 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); @@ -689,19 +684,19 @@ SCENARIO("Minimal published 3MF serialization filters config and omits embedded const std::vector published_keys = { "layer_height", "retraction_length" }; const std::vector material_keys = { - { "PLA", "Generic", "GFL99", 0, { "filament_retraction_length" } } + { "PLA", "Generic", "GFL99", "", 0, { "filament_retraction_length" } } }; DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, published_keys, material_keys); - // Filtered config must contain the published keys and identity keys + // Filtered config keeps the published and identity 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); - // Non-published settings should NOT be in filtered_cfg + // ...and drops everything else. REQUIRE(filtered_cfg.option("sparse_infill_density") == nullptr); REQUIRE(filtered_cfg.option("machine_start_gcode") == nullptr); @@ -716,7 +711,7 @@ SCENARIO("Minimal published 3MF serialization filters config and omits embedded ScopedTemporaryFile temp(".3mf"); const std::string test_file = temp.string(); - // Create a fake project preset to verify it gets omitted with MinimalPublished + // 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 }; @@ -753,9 +748,9 @@ SCENARIO("Minimal published 3MF serialization filters config and omits embedded } } -// Filament-publishing v2: a "full publish" entry carries the whole slot's key list. Its vector -// options keep only the author's slot value; the other slots are masked to their defaults so a -// slot-1 full publish does not leak slot 0's data into the file. +// A "full publish" entry carries the whole slot's key list: its vector options keep only the +// author's slot value, the other slots are masked to their defaults so a slot-1 full publish +// does not leak slot 0's data into the file. SCENARIO("Full-publish entries filter the whole slot and mask the other slots", "[3mf]") { GIVEN("a full print configuration with two filament slots") { DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); @@ -787,9 +782,9 @@ SCENARIO("Full-publish entries filter the whole slot and mask the other slots", } } -// Filament-publishing v2: 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 filament-publishing-v2 material metadata", "[3mf]") { +// 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"; @@ -797,7 +792,7 @@ SCENARIO("Published 3MF round-trips the filament-publishing-v2 material metadata model.add_default_instances(); const std::string material_keys_json = - R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99"},"slot":1,"keys":[],"full":true,"full_keys":["filament_retraction_length","filament_colour"],"publish_type":true,"type":"PLA","publish_color":false,"color":""}])"; + R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99","setting_id":"RFs9eCKYOMUSmvZf"},"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["published_material_keys"] = material_keys_json; @@ -832,7 +827,7 @@ SCENARIO("Published 3MF round-trips the filament-publishing-v2 material metadata REQUIRE(dst_model.model_info != nullptr); REQUIRE(dst_model.model_info->metadata_items["published_material_keys"] == material_keys_json); - // The value must parse back with every filament-publishing-v2 field intact. + // 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); diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 8716af417d..3a9ff77be9 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -549,36 +549,26 @@ TEST_CASE("A printer specific filament supersedes the generic library filament w } // 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 -// (src/slic3r/GUI/Plater.cpp): Preset::normalize before load_config_model, then the -// published overlay in PresetBundle::load_config_file_config. +// 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 ("Invalid configuration file"); a 3mf always carries it. + // empty; a 3mf always carries it. config.opt("filament_colour")->values = { "#FF0000" }; - // Process scalar key. - config.opt_float("layer_height") = 0.28; - // Process vector key, size 2 to match the edited preset's resized vector. - config.opt("wiping_volumes_extruders")->values = { 140., 150. }; - // Process vector key, size 2: deliberately mismatched against the edited preset. - config.opt("post_process")->values = { "script-a", "script-b" }; - // A filament key: published files may still carry legacy filament keys. - config.opt("nozzle_temperature")->values = { 220 }; - // A structural (denylisted) key: must be silently ignored even if a hand-crafted - // file lists it as published. full_print_config() omits the *_settings_id keys (they - // have no static counterpart), while a real 3mf project config carries it, so create - // it explicitly. + 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"; - // Project-level filament/purge data: must NOT cross over in published mode. - config.opt("flush_multiplier")->values = { 2., 2. }; - // A project-level option, to pin the project_config.apply_only() invariant. - config.opt("wipe_tower_x")->values = { 100. }; - // The author's bed type must NOT cross over either: the receiver keeps its own. - config.option("curr_bed_type")->setInt(BedType::btPC); + config.opt("flush_multiplier")->values = { 2., 2. }; // must NOT cross over + config.opt("wipe_tower_x")->values = { 100. }; // plate geometry, does cross over + config.option("curr_bed_type")->setInt(BedType::btPC); // must NOT cross over return config; }; @@ -595,14 +585,14 @@ TEST_CASE("Published 3MF overlays only the author-selected process keys onto the 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; the assertions below check that the - // published load leaves them untouched rather than hardcoding the defaults. + // 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; do the same so only the production path is exercised. + // The GUI normalizes the config before load; mirror that so only the production path runs. Preset::normalize(config); PublishedConfig pub; @@ -610,48 +600,36 @@ TEST_CASE("Published 3MF overlays only the author-selected process keys onto the pub.published_keys = published_keys; bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); - // a) The process scalar is overlaid onto the edited preset. + // 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)); - - // b) A matching-size process vector is applied; a size-mismatched one is neither applied - // nor reported as skipped by accident — it lands in skipped_keys. CHECK(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values == std::vector{ 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")); - - // c) A filament key is never applied anywhere and is reported as skipped (warning). - CHECK(bundle.prints.get_edited_preset().config.option("nozzle_temperature") == nullptr); - CHECK(contains_key(pub.skipped_keys, "nozzle_temperature")); - - // d) 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")); - - // Applied keys are not reported as skipped. CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders")); - // e) project_config.apply_only() still runs, but in published mode only the plate/bed - // geometry crosses: the file's filament/purge data must NOT port to the receiver. - // Filament colors do not port; project_config keeps its ctor-seeded values. - CHECK(bundle.project_config.opt("filament_colour")->values != std::vector{ "#FF0000" }); + // 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); - // Purge data does not port either, and update_multi_material_filament_presets() cannot - // resurrect it (flush_multiplier stays at the ctor seed). CHECK(bundle.project_config.opt("flush_multiplier")->values == seed_flush_multiplier); - // The author's bed type does not cross over: the receiver keeps its own. CHECK(bundle.project_config.option("curr_bed_type")->getInt() == seed_bed_type); - // Plate/bed geometry still crosses. CHECK(bundle.project_config.opt("wipe_tower_x")->values == std::vector{ 100. }); - // The published path keeps the user's currently-selected presets: the edited process - // preset is the same preset as before the load. + // 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: with published=false the overlay is disabled. The file's - // presets are loaded and selected instead (the user's preset is not kept) and no - // skipped_keys are produced. + // 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; @@ -663,26 +641,22 @@ TEST_CASE("Published 3MF overlays only the author-selected process keys onto the CHECK(control_pub.skipped_keys.empty()); CHECK(control_bundle.prints.size() > control_pre_size); - // The file's layer_height reached the edited preset through the normal preset import, - // not through the published overlay. + // 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 -// (publishable_printer_keys). Matching-size retraction vectors apply; mismatched vectors are -// reported as skipped; any other printer-class key (e.g. machine_start_gcode) is -// contract-excluded: never applied and never reported. +// 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); - // Matching size (the receiver's default printer has one extruder). - config.opt("retraction_length")->values = { 1.4 }; - // Size 2: mismatched against the single-extruder receiver. - config.opt("retraction_speed")->values = { 45., 55. }; - // Printer-class but outside the allowlist: must be silently contract-excluded. - config.opt_string("machine_start_gcode") = "G28 ; from file"; + 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 }; @@ -693,9 +667,8 @@ TEST_CASE("Published 3MF overlays only the allowlisted retraction and z-hop keys 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 to the edited printer preset. + // Matching-size retraction vector applied; mismatched vector reported as skipped. CHECK(bundle.printers.get_edited_preset().config.opt("retraction_length")->values == std::vector{ 1.4 }); - // Mismatched vector not applied and reported as skipped. CHECK(bundle.printers.get_edited_preset().config.opt("retraction_speed")->values == std::vector{ 30. }); CHECK(contains_key(pub.skipped_keys, "retraction_speed")); // Contract-excluded printer key: silently ignored, absent from skipped_keys. @@ -703,10 +676,10 @@ TEST_CASE("Published 3MF overlays only the allowlisted retraction and z-hop keys CHECK_FALSE(contains_key(pub.skipped_keys, "machine_start_gcode")); } -// A published 3MF can carry material-qualified keys; on load they are applied to the -// receiver's filament presets whose material identity matches the author's (filament_id when -// both sides have one, filament_type + vendor fallback otherwise). -TEST_CASE("Published 3MF applies material retraction keys onto the receiver's matching filament presets", "[Preset][Bundle][Published]") +// 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(); @@ -715,7 +688,6 @@ TEST_CASE("Published 3MF applies material retraction keys onto the receiver's ma // 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" }; - // Author per-slot material identity (filament_ids feeds the loader's local copy). config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; config.opt("filament_type")->values = { "PLA", "PETG" }; config.opt("filament_vendor")->values = { "Generic", "Generic" }; @@ -729,13 +701,10 @@ TEST_CASE("Published 3MF applies material retraction keys onto the receiver's ma }; PresetBundle bundle; - // Receiver materials with matching stable ids. 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"; - // In-memory preset configs carry the per-filament retraction keys as nullable options - // (the type real filament presets hold), so access them through the nullable type. 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"); @@ -743,52 +712,46 @@ TEST_CASE("Published 3MF applies material retraction keys onto the receiver's ma 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 }; - // The z-hop key must exist on the receiver preset for the overlay to apply into it. petg.config.opt("filament_z_hop", true)->values = { 0.1 }; bundle.filament_presets = { "My PLA", "My PETG" }; PublishedMaterialEntry pla_entry; - pla_entry.filament_type = "PLA"; - pla_entry.filament_vendor = "Generic"; - pla_entry.filament_id = "GFL99"; - pla_entry.slot = 0; // the author's PLA slot - pla_entry.keys = { "filament_retraction_length", "filament_settings_id" }; + 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_type = "PETG"; - petg_entry.filament_vendor = "Generic"; - petg_entry.filament_id = "GFT99"; - petg_entry.slot = 1; // the author's PETG slot - petg_entry.keys = { "filament_retraction_length", "filament_z_hop" }; - // A material that does not exist on the author's side: whole entry skipped, no reporting. - PublishedMaterialEntry abs_entry; - abs_entry.filament_type = "ABS"; - abs_entry.filament_id = "GFX99"; - abs_entry.keys = { "filament_retraction_length" }; + 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, abs_entry }; + 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); - // Per-slot scalar copy: the author's slot value lands in the matching receiver preset. + // The author's slot values are written onto the receiver's stored presets in place. CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); CHECK(bundle.filaments.find_preset("My PETG")->config.opt("filament_retraction_length")->values == std::vector{ 1.2 }); CHECK(bundle.filaments.find_preset("My PETG")->config.opt("filament_z_hop")->values == std::vector{ 0.3 }); // Structural keys inside a material entry are silently ignored: the receiver's own - // filament_settings_id is left untouched and nothing is reported for it. + // 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 unknown material entry produced no skipped entry. + // Everything applied; the slot-less entry produced no skipped entry. CHECK(pub.skipped_keys.empty()); } -// Filament-publishing v2: a "full publish" slot serializes the entire filament of the slot. On -// load the slot is matched positionally against the published (curated, vendor-agnostic) type: -// a matching receiver type leaves the slot untouched, a mismatched type replaces it with the -// first same-type visible preset (applying the author's full values on top), and a slot whose -// type cannot be found in the receiver's library falls back to the author's values in-memory. +// A "full publish" slot serializes the whole filament. On load the slot is matched positionally +// against the published type: a matching receiver type still receives the author's full values +// (like a normal save/load of the filament), a mismatched type replaces it with the first +// same-type visible preset (applying the author's full values on top), and a type not in the +// receiver's library falls back to the first available visible preset. TEST_CASE("Published 3MF full-published slots replace or ignore the receiver material by type", "[Preset][Bundle][Published]") { auto make_file_config = [] { @@ -815,7 +778,7 @@ TEST_CASE("Published 3MF full-published slots replace or ignore the receiver mat return entry; }; - SECTION("type match leaves a full-published slot untouched") { + SECTION("type match applies the full dump onto the receiver's material") { PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; @@ -832,8 +795,9 @@ TEST_CASE("Published 3MF full-published slots replace or ignore the receiver mat Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); - // The receiver keeps its own material and its own values: the full dump is ignored. - CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); + // The type matches, but a full publish behaves like a normal save: the author's values + // are written onto the slot's preset wholesale. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); CHECK(pub.skipped_keys.empty()); CHECK(pub.material_replacements.empty()); } @@ -855,47 +819,306 @@ TEST_CASE("Published 3MF full-published slots replace or ignore the receiver mat Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); - CHECK(bundle.filament_presets.size() == 1); + REQUIRE(bundle.filament_presets.size() == 1); + // The slot is re-pointed at the library's ABS preset and the author's full values are + // written onto it in place (the original 0.3 is overwritten); the receiver's own + // material is untouched. CHECK(bundle.filament_presets[0] == "My ABS"); - // The author's slot-0 full values were applied onto the replacement. - CHECK(bundle.filaments.find_preset("My ABS")->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); + CHECK(bundle.filaments.find_preset("My ABS", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); CHECK(pub.skipped_keys.empty()); REQUIRE(pub.material_replacements.size() == 1); + // The entry carries no identity fields, so the pick cannot be judged as a substitute. + CHECK(pub.material_replacements[0] == "slot 0: My PLA -> My ABS"); } - SECTION("no same-type match creates a temporary project-embedded custom preset") { + SECTION("no same-type match falls back to the first available visible 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 }; + 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 fallback preset must take the author's type + // and vendor. + full.full_keys = { "filament_retraction_length", "filament_type", "filament_vendor" }; + PublishedConfig pub; pub.published = true; - pub.material_keys = { make_full_abs_entry() }; + 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 in the library: a temporary embedded preset is created and selected. - CHECK(bundle.filament_presets[0] == "ABS (Published)"); - Preset *created = bundle.filaments.find_preset("ABS (Published)"); - REQUIRE(created != nullptr); - CHECK(created->is_project_embedded); - CHECK(created->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); - // The original user preset remains untouched. Re-fetch by name: load_preset's deque - // insertion relocated the presets, so the pre-load `pla` reference points at the - // newly created "ABS (Published)" slot. + // No ABS in the library: the slot falls back to the first available visible preset (the + // unused "Other PLA") and the author's full values are written onto it, type and vendor + // included. The receiver's own material is untouched. + CHECK(bundle.filament_presets[0] == "Other PLA"); + Preset *fallback = bundle.filaments.find_preset("Other PLA", false, true); + REQUIRE(fallback != nullptr); + CHECK(fallback->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); + CHECK(fallback->config.opt_string("filament_type", 0u) == "ABS"); + CHECK(fallback->config.opt_string("filament_vendor", 0u) == "Generic"); CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt_string("filament_type", 0u) == "PLA"); CHECK(pub.skipped_keys.empty()); REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PLA -> Other PLA (substitute: no ABS available)"); } } -// Filament-publishing v2: 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. +// Regression for the author's published material being skipped by a type-only replacement +// search: the slot must prefer the exact published material (filament_id) over the first other +// same-type preset, even when the exact preset is already referenced by another slot. +TEST_CASE("Published 3MF replaces a mismatched slot with the exact published material when available", "[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; + }; + + 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 }; + // The receiver's second slot already uses the exact published material. + Preset &generic_pla = add_inmemory_preset(bundle.filaments, "Generic PLA"); + generic_pla.filament_id = "GFL99"; + generic_pla.config.opt_string("filament_type", 0u) = "PLA"; + generic_pla.config.opt_string("filament_vendor", 0u) = "Generic"; + generic_pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + // An unrelated PLA, unreferenced: a type-only search picks it because Generic PLA is + // referenced by slot 1. + Preset &bambu = add_inmemory_preset(bundle.filaments, "Bambu PLA Basic"); + bambu.filament_id = "GFB00"; + bambu.config.opt_string("filament_type", 0u) = "PLA"; + bambu.config.opt_string("filament_vendor", 0u) = "Bambu Lab"; + bambu.config.opt("filament_retraction_length", true)->values = { 0.4 }; + bundle.filament_presets = { "My PETG", "Generic PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.filament_id = "GFL99"; + entry.filament_vendor = "Generic"; + 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 exact published material (id GFL99) wins over the unreferenced type-only preset. + CHECK(bundle.filament_presets[0] == "Generic PLA"); + CHECK(bundle.filament_presets[1] == "Generic PLA"); + CHECK(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); + // The receiver's own material is untouched; the unrelated PLA too. + CHECK(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 0.6 }); + CHECK(bundle.filaments.find_preset("Bambu PLA Basic", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 0.4 }); + // Accepted mutate tradeoff: the shared exact-material preset was mutated, so slot 1 also + // carries the author's values (the leak is documented, not accidental). + CHECK(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); + // An exact-material pick is reported without a substitute qualifier. + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA"); + CHECK(pub.skipped_keys.empty()); +} + +// The replacement search prefers the published identity: exact filament_id, then vendor+type, +// then type only (collection order decides equal scores; the pick is reported as a substitute +// when it is not the exact published material). +TEST_CASE("Published 3MF prefers the published material identity when replacing a slot", "[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.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.filament_id = "GFL99"; + entry.filament_vendor = "Generic"; + entry.full_keys = { "filament_retraction_length" }; + return entry; + }; + auto add_pla = [](PresetBundle &bundle, const char *name, const char *id, const char *vendor) { + Preset &preset = add_inmemory_preset(bundle.filaments, name); + preset.filament_id = 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; + }; + auto load = [&](PresetBundle &bundle, PublishedConfig &pub) { + PublishedMaterialEntry entry = make_entry(); + 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); + }; + + SECTION("exact filament_id beats collection order") { + 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 }; + // "Bambu PLA Basic" sorts before "Zebra PLA"; only the latter carries the published id. + add_pla(bundle, "Bambu PLA Basic", "GFB00", "Bambu Lab"); + add_pla(bundle, "Zebra PLA", "GFL99", "Generic"); + bundle.filament_presets = { "My PETG" }; + + PublishedConfig pub; + load(bundle, pub); + CHECK(bundle.filament_presets[0] == "Zebra PLA"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Zebra PLA"); + } + + SECTION("vendor and type beat a type-only preset, reported as a substitute") { + 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, "Bambu PLA Basic", "GFB00", "Bambu Lab"); + add_pla(bundle, "Zebra PLA", "ZZZ99", "Generic"); // same vendor+type, different id + bundle.filament_presets = { "My PETG" }; + + PublishedConfig pub; + load(bundle, pub); + CHECK(bundle.filament_presets[0] == "Zebra PLA"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Zebra PLA (substitute: no exact material match)"); + } + + SECTION("type-only candidates keep collection order and are reported as substitutes") { + 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, "Bambu PLA Basic", "GFB00", "Bambu Lab"); + add_pla(bundle, "Zebra PLA", "ZZZ99", "Acme"); // no identity match at all + bundle.filament_presets = { "My PETG" }; + + PublishedConfig pub; + load(bundle, pub); + CHECK(bundle.filament_presets[0] == "Bambu PLA Basic"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Bambu PLA Basic (substitute: no exact material match)"); + } +} + +// "Generic PLA" and "Generic PLA Matte" share their inherited filament_id (OGFL99), so the +// exact variant can only be matched via the preset setting_id carried in the published file. +TEST_CASE("Published 3MF matches the exact published variant via setting_id", "[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 *setting_id) { + Preset &preset = add_inmemory_preset(bundle.filaments, name); + preset.setting_id = setting_id; + preset.filament_id = "OGFL99"; // shared by all Generic PLA variants + preset.config.opt_string("filament_type", 0u) = "PLA"; + preset.config.opt_string("filament_vendor", 0u) = "Generic"; + preset.config.opt("filament_retraction_length", true)->values = { 0.5 }; + return &preset; + }; + auto load = [&](PresetBundle &bundle, PublishedConfig &pub, const std::string &setting_id) { + 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 = setting_id; + entry.full_keys = { "filament_retraction_length" }; + 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); + }; + + SECTION("the published variant wins over its same-id sibling") { + 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", "RcBNzytWgwRrwXXz"); + add_pla(bundle, "Generic PLA Matte", "RFs9eCKYOMUSmvZf"); + bundle.filament_presets = { "My PETG" }; + + PublishedConfig pub; + load(bundle, pub, "RFs9eCKYOMUSmvZf"); // the author published "Generic PLA Matte" + CHECK(bundle.filament_presets[0] == "Generic PLA Matte"); + CHECK(bundle.filaments.find_preset("Generic PLA Matte", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); + CHECK(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA Matte"); + CHECK(pub.skipped_keys.empty()); + } + + SECTION("without a setting_id the same-id siblings fall back to collection order") { + 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", "RcBNzytWgwRrwXXz"); + add_pla(bundle, "Generic PLA Matte", "RFs9eCKYOMUSmvZf"); + bundle.filament_presets = { "My PETG" }; + + PublishedConfig pub; + load(bundle, pub, ""); // legacy file without the field + CHECK(bundle.filament_presets[0] == "Generic PLA"); + CHECK(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); + 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 = [] { @@ -934,9 +1157,11 @@ TEST_CASE("Published 3MF partial slots apply colour and gate keys by the publish Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); - // Type matched: keys applied, colour applied. - CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); - CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + // 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(pla_preset->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); + CHECK(pla_preset->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); CHECK(pub.skipped_keys.empty()); CHECK(pub.material_replacements.empty()); } @@ -964,10 +1189,13 @@ TEST_CASE("Published 3MF partial slots apply colour and gate keys by the publish Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); - // Colour still applies (type-independent); the material is kept and the keys skipped. + // The slot keeps the receiver's material: the colour applies in place, the keys are + // skipped. CHECK(bundle.filament_presets[0] == "My PLA"); - CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); - CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + Preset *pla_preset = bundle.filaments.find_preset("My PLA", false, true); + REQUIRE(pla_preset != nullptr); + CHECK(pla_preset->config.opt("filament_retraction_length")->values == std::vector{ 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()); } @@ -997,15 +1225,18 @@ TEST_CASE("Published 3MF partial slots apply colour and gate keys by the publish 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, automatically assigning the PETG preset. + // 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(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 1.2 }); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); } } -// The receiver's slot list grows only as far as the highest author slot that carries published -// content: a 4-filament 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. +// 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 = [] { @@ -1066,18 +1297,16 @@ TEST_CASE("Published 3MF grows the receiver's slots only as far as the published 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{ "#ABCDEF" }); + // 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. The unpublished - // filler slots repeat the receiver's last preset ("Add one filament" behaviour); the - // published slot gets a visible preset not used by another slot (with a single-preset - // library it falls back to the receiver's last preset, aliasing being unavoidable). + // 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" }; - const std::string filler = bundle.filaments.first_visible().name; PublishedConfig pub; pub.published = true; @@ -1089,9 +1318,12 @@ TEST_CASE("Published 3MF grows the receiver's slots only as far as the published REQUIRE(bundle.filament_presets.size() == 4); CHECK(bundle.filament_presets[1] == "My PLA"); CHECK(bundle.filament_presets[2] == "My PLA"); - CHECK(bundle.filament_presets[3] == filler); - // The project-level per-slot vectors were grown and seeded like "Add one filament": - // fillers take their preset's colour, the published slot its published colour. + // 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"); @@ -1119,11 +1351,9 @@ TEST_CASE("Published 3MF grows the receiver's slots only as far as the published } } -// The published overlay mutates stored filament presets in place per slot, so a slot carrying -// published content must never share its stored preset with another slot: its colour/keys -// would leak into the sibling slot - and, with "repeat the last preset" growth, into the -// receiver's own first slot. Regression for the slot-aliasing hazard. -TEST_CASE("Published 3MF gives grown published slots a distinct preset so values never leak", "[Preset][Bundle][Published]") +// 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 seeds published slots from unused presets and mutates them in place", "[Preset][Bundle][Published]") { auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); @@ -1137,10 +1367,9 @@ TEST_CASE("Published 3MF gives grown published slots a distinct preset so values return config; }; - // The receiver has one slot of its own material plus one more preset in the library; the - // author publishes only slot 4 (Red). With naive repeat-last growth the new slot would - // reference the receiver's own preset and the published red would recolor it; the grown - // slot must point at a distinct preset. + // Receiver with its own material plus one more library preset; author publishes only slot 4 + // (Red). The grown slot is seeded from the unused library preset, so the published red + // recolors that preset in place and never the receiver's own material. PresetBundle bundle; Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); mine.config.opt_string("filament_type", 0u) = "PLA"; @@ -1165,9 +1394,9 @@ TEST_CASE("Published 3MF gives grown published slots a distinct preset so values // Unpublished filler slots repeat the receiver's last preset ("Add one filament"). CHECK(bundle.filament_presets[1] == "My PLA"); CHECK(bundle.filament_presets[2] == "My PLA"); - // The published slot references the unused library preset, not the receiver's own... + // The published slot was seeded from the unused library preset; the published colour was + // written onto it in place, never onto the receiver's own material. CHECK(bundle.filament_presets[3] == "Other PLA"); - // ...so the published colour landed there and never recoloured the receiver's material. CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#123456" }); CHECK(bundle.filaments.find_preset("Other PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); // The project-level colours are sized and seeded for every grown slot. @@ -1180,9 +1409,9 @@ TEST_CASE("Published 3MF gives grown published slots a distinct preset so values } // The GUI displays the edited preset, a snapshot of the selected collection preset taken at -// selection time. The published overlay modifies the collection presets in place, so the load -// must re-select the first slot's filament (mirroring a normal project load) for the applied -// colour/type/keys - and slot replacements - to surface in the GUI. +// 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 = [] { @@ -1226,8 +1455,11 @@ TEST_CASE("Published 3MF refreshes the edited preset so the applied material val bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); const Preset &edited = bundle.filaments.get_edited_preset(); + // The slot's preset was mutated in place; the edited preset displays the applied values. + CHECK(edited.name == "My PLA"); CHECK(edited.config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); CHECK(edited.config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); CHECK(pub.skipped_keys.empty()); } @@ -1261,203 +1493,15 @@ TEST_CASE("Published 3MF refreshes the edited preset so the applied material val } } -// Material-qualified keys whose receiver-side material match is missing or ambiguous must be -// reported as skipped (material-qualified) and never applied; a single unqualified type -// fallback still applies. -TEST_CASE("Published 3MF reports material keys with no unique receiver match as skipped", "[Preset][Bundle][Published]") -{ - auto make_file_config = [] { - DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); - // Three author slots: PLA, PETG, ABS. - 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", "#00FF00", "#0000FF" }; - config.opt("filament_type")->values = { "PLA", "PETG", "ABS" }; - config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; - config.opt("filament_ids")->values = { "GFL99", "GFT99", "GFA99" }; - config.option("filament_retraction_length", true)->values = { 0.9, 1.2, 1.5 }; - return config; - }; - - PresetBundle bundle; - // Two receiver presets of the SAME type with no filament_id: the type fallback is ambiguous. - Preset &pla_a = add_inmemory_preset(bundle.filaments, "My PLA A"); - pla_a.config.opt_string("filament_type", 0u) = "PLA"; - pla_a.config.opt_string("filament_vendor", 0u) = "Generic"; - pla_a.config.opt("filament_retraction_length", true)->values = { 0.5 }; - Preset &pla_b = add_inmemory_preset(bundle.filaments, "My PLA B"); - pla_b.config.opt_string("filament_type", 0u) = "PLA"; - pla_b.config.opt_string("filament_vendor", 0u) = "Generic"; - pla_b.config.opt("filament_retraction_length", true)->values = { 0.5 }; - // A unique PETG receiver preset: the single type fallback is unambiguous. - Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); - 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 }; - bundle.filament_presets = { "My PLA A", "My PLA B", "My PETG" }; - - auto make_entry = [](const std::string &type, const std::string &key) { - PublishedMaterialEntry entry; - entry.filament_type = type; - entry.filament_vendor = "Generic"; - entry.keys = { key }; - return entry; - }; - - PublishedMaterialEntry pla_entry = make_entry("PLA", "filament_retraction_length"); - pla_entry.slot = 0; // the author's PLA slot - PublishedMaterialEntry petg_entry = make_entry("PETG", "filament_retraction_length"); - petg_entry.slot = 1; // the author's PETG slot - PublishedMaterialEntry abs_entry = make_entry("ABS", "filament_retraction_length"); // author slot exists, no receiver match - abs_entry.slot = 2; - - PublishedConfig pub; - pub.published = true; - pub.material_keys = { pla_entry, petg_entry, abs_entry }; - DynamicPrintConfig config = make_file_config(); - Preset::normalize(config); - bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); - - // Ambiguous type fallback: neither PLA preset is touched, reported as skipped. - CHECK(bundle.filaments.find_preset("My PLA A")->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); - CHECK(bundle.filaments.find_preset("My PLA B")->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); - CHECK(contains_key(pub.skipped_keys, "material:PLA (filament_retraction_length)")); - // Unambiguous single fallback: applied. - CHECK(bundle.filaments.find_preset("My PETG")->config.opt("filament_retraction_length")->values == std::vector{ 1.2 }); - CHECK_FALSE(contains_key(pub.skipped_keys, "material:PETG (filament_retraction_length)")); - // Receiver-side miss: the author slot exists but no receiver preset matches. - CHECK(contains_key(pub.skipped_keys, "material:ABS (filament_retraction_length)")); -} - -// A slotted material entry carries the author's per-slot overrides: on load it applies to the -// receiver's matching preset at the author's slot ordinal (first matching author slot -> first -// matching receiver preset, second -> second, ...). Legacy entries without a slot keep applying -// to every matching receiver preset. -TEST_CASE("Published material keys apply to the receiver's matching filament preset by author slot ordinal", "[Preset][Bundle][Published]") -{ - auto make_file_config = [] { - DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); - // Three author slots of the SAME material (PETG) with distinct per-slot retraction. - 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", "#00FF00", "#0000FF" }; - config.opt("filament_type")->values = { "PETG", "PETG", "PETG" }; - config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; - config.opt("filament_ids")->values = { "GFT99", "GFT99", "GFT99" }; - config.option("filament_retraction_length", true)->values = { 0.7, 0.8, 0.9 }; - return config; - }; - auto make_slotted_entry = [](int slot) { - PublishedMaterialEntry entry; - entry.filament_type = "PETG"; - entry.filament_vendor = "Generic"; - entry.filament_id = "GFT99"; - entry.slot = slot; - entry.keys = { "filament_retraction_length" }; - return entry; - }; - auto add_petg_preset = [](PresetBundle &bundle, const std::string &name) { - Preset &preset = add_inmemory_preset(bundle.filaments, name); - preset.filament_id = "GFT99"; - preset.config.opt_string("filament_type", 0u) = "PETG"; - preset.config.opt_string("filament_vendor", 0u) = "Generic"; - preset.config.opt("filament_retraction_length", true)->values = { 0.5 }; - return &preset; - }; - - // Three receiver presets, one per author slot: each gets its ordinal's value. - { - PresetBundle bundle; - add_petg_preset(bundle, "My PETG 1"); - add_petg_preset(bundle, "My PETG 2"); - add_petg_preset(bundle, "My PETG 3"); - bundle.filament_presets = { "My PETG 1", "My PETG 2", "My PETG 3" }; - - PublishedConfig pub; - pub.published = true; - pub.material_keys = { make_slotted_entry(0), make_slotted_entry(1), make_slotted_entry(2) }; - DynamicPrintConfig config = make_file_config(); - Preset::normalize(config); - bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); - - // Each author slot's value lands in the receiver preset at the same ordinal. - CHECK(bundle.filaments.find_preset("My PETG 1")->config.opt("filament_retraction_length")->values == std::vector{ 0.7 }); - CHECK(bundle.filaments.find_preset("My PETG 2")->config.opt("filament_retraction_length")->values == std::vector{ 0.8 }); - CHECK(bundle.filaments.find_preset("My PETG 3")->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); - CHECK(pub.skipped_keys.empty()); - } - - // A single receiver preset: only the first ordinal fits; the later slots are reported - // with a slot-qualified label. - { - PresetBundle bundle; - add_petg_preset(bundle, "My PETG 1"); - bundle.filament_presets = { "My PETG 1" }; - - PublishedConfig pub; - pub.published = true; - pub.material_keys = { make_slotted_entry(0), make_slotted_entry(1), make_slotted_entry(2) }; - DynamicPrintConfig config = make_file_config(); - Preset::normalize(config); - bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); - - CHECK(bundle.filaments.find_preset("My PETG 1")->config.opt("filament_retraction_length")->values == std::vector{ 0.7 }); - CHECK(contains_key(pub.skipped_keys, "material:GFT99 slot 1 (filament_retraction_length)")); - CHECK(contains_key(pub.skipped_keys, "material:GFT99 slot 2 (filament_retraction_length)")); - CHECK_FALSE(contains_key(pub.skipped_keys, "material:GFT99 slot 0 (filament_retraction_length)")); - } - - // A legacy entry (no slot) applies to every matching receiver preset, from the first - // author slot. - { - PresetBundle bundle; - add_petg_preset(bundle, "My PETG 1"); - add_petg_preset(bundle, "My PETG 2"); - bundle.filament_presets = { "My PETG 1", "My PETG 2" }; - - PublishedMaterialEntry legacy = make_slotted_entry(0); - legacy.slot = -1; // legacy: no slot field - PublishedConfig pub; - pub.published = true; - pub.material_keys = { legacy }; - DynamicPrintConfig config = make_file_config(); - Preset::normalize(config); - bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); - - CHECK(bundle.filaments.find_preset("My PETG 1")->config.opt("filament_retraction_length")->values == std::vector{ 0.7 }); - CHECK(bundle.filaments.find_preset("My PETG 2")->config.opt("filament_retraction_length")->values == std::vector{ 0.7 }); - CHECK(pub.skipped_keys.empty()); - } - - // An out-of-range author slot is silently skipped: nothing applied, nothing reported. - { - PresetBundle bundle; - add_petg_preset(bundle, "My PETG 1"); - bundle.filament_presets = { "My PETG 1" }; - - PublishedConfig pub; - pub.published = true; - pub.material_keys = { make_slotted_entry(5) }; - DynamicPrintConfig config = make_file_config(); - Preset::normalize(config); - bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); - - CHECK(bundle.filaments.find_preset("My PETG 1")->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); - CHECK(pub.skipped_keys.empty()); - } -} - -// The published overlay must validate '#' variant indices: an out-of-range index must be -// reported as skipped and must NOT resize/corrupt the receiver's vector, and a variant suffix -// on a scalar key must be rejected instead of silently no-op'd. +// 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 so the - // applied element is observable. + // 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); @@ -1482,9 +1526,10 @@ TEST_CASE("Published 3MF rejects out-of-range vector variants and variant-suffix CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.1, 0.000001)); } -// A receiver filament preset whose material identity fields are missing (hand-edited preset -// file) must not crash the material pass: the entry simply cannot match and is reported skipped. -TEST_CASE("Published 3MF survives a receiver filament preset missing its material identity", "[Preset][Bundle][Published]") +// 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 }; @@ -1502,28 +1547,40 @@ TEST_CASE("Published 3MF survives a receiver filament preset missing its materia // 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.keys = { "filament_retraction_length" }; + 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); - // No match possible without the identity fields: reported skipped, preset untouched. + // 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.filaments.find_preset("My PLA")->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); + 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" }); } -// The printer publishable allowlist is the union of the printer tab's "Retraction" and -// "Z-Hop" optgroup option lists; lock the exact contents and order (Tab.cpp). +// 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) { @@ -1552,3 +1609,166 @@ TEST_CASE("Printer publishable allowlist matches the printer tab's Retraction an 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(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values == std::vector{ 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(preset->config.opt("filament_retraction_length")->values == std::vector{ expected[slot] }); + } + 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(bundle.printers.get_edited_preset().config.opt("retraction_length")->values == std::vector{ 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(bundle.printers.get_edited_preset().config.opt("retraction_length")->values == std::vector{ 0.7, 0.8, 0.8, 0.8 }); + CHECK(pub.skipped_keys.empty()); + } +} +