diff --git a/docs/HLSD/precise-seam.md b/docs/HLSD/precise-seam.md new file mode 100644 index 0000000000..2976e8c7c2 --- /dev/null +++ b/docs/HLSD/precise-seam.md @@ -0,0 +1,239 @@ +# Precise Seam — High Level Design + +## Purpose and scope + +Precise Seam places the seam where a helper volume intersects the external +wall. The user attaches a mesh to an object as a Precise Seam modifier, and on +every layer the seam placer reads the modifier's slice to decide where the seam +of each external perimeter may, must or must not go. The same mesh keeps +working after the model changes, so the seam does not have to be repainted +after every design revision, and a swept helper body can guide the seam along +any path. + +The modifier is non-printing geometry. It does not take part in slicing, region +assignment, filament selection or brim adhesion. It affects only seam +placement, which runs during G-code export. + +## Volume types and priority + +Precise Seam adds six `ModelVolumeType` values after `SUPPORT_ENFORCER`. The +strong types come first and the weak types follow. `is_precise_seam()`, +`is_precise_seam_strong()` and `is_precise_seam_weak()` are range checks that +depend on this order. + +| Type | Group | Effect on the perimeter | +| --- | --- | --- | +| `PRECISE_SEAM_CENTER` | strong | seam at the arc-length midpoint of the intersection | +| `PRECISE_SEAM_LEFT` | strong | seam at the first point of the intersection | +| `PRECISE_SEAM_RIGHT` | strong | seam at the last point of the intersection | +| `PRECISE_SEAM_ENFORCED` | weak | intersection marked as enforced | +| `PRECISE_SEAM_BLOCKED` | weak | intersection marked as blocked | +| `PRECISE_SEAM_NEUTRAL` | weak | intersection reset to neutral | + +A strong modifier fixes one point. A weak modifier only changes the +enforced/blocked type of seam candidates, and the configured seam position then +chooses among them. First and last are taken along the perimeter made +counter-clockwise seen from above. On an outer wall seen from outside, Left is +the left end of the intersection. On the wall of a hole seen from inside the +hole, the two ends are swapped. + +The order of volumes in the object is the priority order, highest first. +`ModelObject::sort_volumes()` keeps every strong modifier before every weak one +and preserves the user's order within each group. The object list lets the user +drag a modifier only within its own group. A type change that crosses a group +boundary moves the volume to the end of its new group, where it has the lowest +priority. Strong modifiers are tried in this order, and the first one that +yields a seam on a perimeter wins. Weak modifiers are applied from the lowest +priority to the highest, so the highest one overwrites any overlapping zone. + +## Model storage and 3MF compatibility + +Projects must stay readable by earlier releases, and the modifier must not +change a print there. Both 3MF writers therefore store a Precise Seam volume as +an ordinary parameter modifier: `modifier_part` in the Bambu-format part +subtype, and `ParameterModifier` together with the legacy `modifier` flag in +the Prusa-format volume metadata. The seam mode is written separately under +`precise_seam_type`, using the names from `ModelVolume::type_to_string()` +(`precise_seam_center` and so on). + +On load, the mode applies after all other volume metadata, regardless of XML +key order, and only when the base type is a modifier. Missing or unknown modes +leave an ordinary modifier. Seam metadata on any other base type is ignored. +Files that stored the seam mode directly as the volume type still load. + +A Precise Seam volume keeps any per-volume settings it had as a part or +modifier, but they are inactive and the object list shows no settings item for +it. The writers prefix these keys with `precise_seam_config:`, so an earlier +reader drops them as unknown options. The volume therefore loads there as a +modifier without settings and has no effect on the print. The current reader +restores the keys only when the volume ends up as a Precise Seam type, so the +settings return when the user changes the type back. Configuration values are +XML-escaped in both writers, for every volume type. + +## Print invalidation + +`Print::apply()` compares the Precise Seam volumes of each object by type, ID +and transformation. Adding, removing, moving, reordering or retyping one +cancels background processing and invalidates only `psGCodeExport`; the sliced +layers are kept. `model_volume_list_update_supports_and_seams()` then brings +the support and Precise Seam volumes of the print's model copy in line with the +new model in one pass. A volume may switch between the two families, since +neither affects slicing. A conversion to or from a part or ordinary modifier +changes the solid and modifier volume lists and reslices as before. + +## Modifier slices + +`SeamPlacer::init()` collects the Precise Seam volumes of each object once: +strong ones in priority order and weak ones reversed. It slices each volume +separately with `PrintObject::slice_single_volume()`, which shares +`slice_modifier_volumes()` with support blockers and enforcers but does not +merge volumes, so each keeps its own priority. The result is cached per volume +and indexed by object layer; `Layer::id()` includes raft layers, which are +subtracted. Seam candidates are then gathered in parallel over the layers and +read the cache without locking. + +Objects without Precise Seam volumes follow the unchanged seam placement path. +For objects that have them, perimeter extraction also removes consecutive +duplicate points and the repeated closing point of each extrusion loop. +Zero-length edges at path junctions would otherwise prevent point insertion +there. Distinct visits to one point of a self-touching contour are kept. + +## Finding the wall segment + +The seam placer works on the external perimeter loops of each layer, both +outer contours and holes, each made counter-clockwise. For every modifier +polygon on the layer that overlaps the perimeter's bounding box, the region +enclosed by the perimeter is clipped against the modifier polygon. The boundary +of each intersection polygon alternates between runs that follow the perimeter +and runs that follow the modifier outline. The wall segment is the longest +continuous run of intersection vertices that lie on the perimeter, measured in +vertices. + +The fast path first finds an intersection vertex that exactly matches a +perimeter vertex. It then walks forward and backward, expecting the adjacent +perimeter vertex and falling back to projection when Clipper has merged or +split collinear edges. A vertex counts as on the perimeter when its projection +is within about 1.6 nm, which covers Clipper's rounding. If no vertex matches +exactly, or every vertex lies on the perimeter, the general path projects all +vertices. When every vertex is on the perimeter, the edge midpoints are checked +instead: a modifier chord can join two perimeter vertices directly, and the +chords split the vertex ring into runs. If no edge leaves the perimeter, the +perimeter lies entirely inside the modifier. + +`Polygon::point_projection()` optionally reports the edge that holds the +projection, and every point of the segment keeps the index of its perimeter +edge. New points are inserted on that edge. A point within 1 µm of an existing +vertex snaps to that vertex instead. + +## Strong modifiers + +For a strong modifier, the target is the first point, the last point or the +arc-length midpoint of the segment. The midpoint is projected back onto the +original perimeter, because Clipper may have merged several perimeter edges +into one segment edge. The target is inserted into the perimeter, and a helper +point is inserted 1 µm before and after it. Strong modifiers are tried in +priority order, the first valid intersection decides the seam, and weak +modifiers are not processed for that perimeter. + +When candidates are built, the inserted point is the only enforced candidate +and becomes the central enforcer; every other candidate is blocked. The seam +position modes then pick that point: Aligned and Aligned Back prefer the central +enforcer, while Back, Random and Nearest rank enforced candidates above blocked +ones. Alignment and random placement can still move the final position along an +edge. After alignment, `restore_precise_seam_positions()` writes the exact point +and its index back into every perimeter that has a strong seam. Inner walls take +their seam from the external seam as usual, including staggering. + +## Weak modifiers + +Weak modifiers produce one segment per intersection polygon, so one modifier can +mark several zones on one perimeter. All segment boundaries are inserted into +the perimeter in order of decreasing arc length. Each insertion then leaves the +indices of the pending, shorter ones unchanged; a point on the closing edge is +appended rather than inserted at index zero. A helper point is added 1 µm +outside each boundary. Random placement picks a position along the edge that +follows a candidate. These helpers keep that edge 1 µm long at each boundary, so +a zone cannot extend or intrude further than that. Boundaries that coincide +share their helper points. + +The zone types are then resolved in priority order, and the edges of enforced +zones are subdivided into steps of at most +`SeamPlacer::enforcer_oversampling_distance` (0.2 mm). The middle candidate of +the longest enforced patch is therefore close to the geometric middle of the +zone. That patch is measured in candidates, across the closing edge, regardless +of where the contour starts; the same rule applies to painted seams. + +Candidates first receive their type from seam painting. The weak zones then +overwrite it, lowest priority first. Blocked and Enforced zones therefore take +precedence over painting, and Neutral clears painting inside its zone. + +## Unsupported geometry and warnings + +Some modifier shapes cannot be resolved to one seam or one zone per crossing. +They are detected cheaply and reported rather than guessed: + +- A strong modifier that crosses a perimeter in more than one place uses only + its first valid segment. The other crossings are ignored. +- A modifier that crosses the whole region enclosed by the perimeter is + detected when the modifier outline minus that region leaves more than one + piece, none of them a hole. Its intersection holds two wall runs, and only + one of them is used. +- A modifier whose slice has a hole on a layer, found as a clockwise polygon in + the flattened slice, is skipped on that layer. The flattened slice no longer + records which hole belongs to which contour. +- A perimeter that lies entirely inside a modifier is ignored by that modifier. + +The conditions are atomic flags shared by all layers and objects. After all +objects are processed, `SeamPlacer::init()` issues at most one non-critical +warning with the ID `SlicingPreciseSeamWarning`. The warning is a single line +that lists every cause found, because the export warnings dialog shows only the +first line of each warning. Repeated warning events replace this notification +instead of appending text to it. + +## User interface + +- *Add Precise Seam* in the object menu creates a Center modifier from a + primitive or a loaded mesh. Text and SVG volumes cannot become Precise Seam + modifiers: the menu does not offer them, and `ObjectList::set_volume_type()` + refuses the change. +- *Change Type* has a single *Precise Seam* entry. It converts other volumes to + Center and keeps the mode of volumes that are already Precise Seam. The + *Precise Seam Type* submenu appears only when every selected item is a + Precise Seam volume, including settings rows that resolve to one. It sets the + chosen mode on all selected volumes. +- Each mode has its own icon in the object list and its own color in the 3D + view, at 60% opacity: warm oranges for the strong modes, and green, red and + gray for Enforced, Blocked and Neutral. +- Object list drops map visible rows to volume indices while skipping hidden + cut connectors, and they refresh the row-to-volume map of the object. +- Precise Seam volumes have no filament, block pasting into SLA, and are exposed + to Python plugins as `ModelVolumeType` values plus the `is_precise_seam*()` + methods. + +## Implementation and verification + +- [PreciseSeam.cpp](../../src/libslic3r/GCode/PreciseSeam.cpp) implements segment + detection, point insertion, weak-zone resolution and position restoration. + [SeamPlacer.cpp](../../src/libslic3r/GCode/SeamPlacer.cpp) integrates it into + candidate gathering and issues the warning. +- [Model.hpp](../../src/libslic3r/Model.hpp) defines the types and their order, + [PrintApply.cpp](../../src/libslic3r/PrintApply.cpp) handles invalidation, and + [PrintObjectSlice.cpp](../../src/libslic3r/PrintObjectSlice.cpp) slices the + modifiers. [bbs_3mf.cpp](../../src/libslic3r/Format/bbs_3mf.cpp) and + [3mf.cpp](../../src/libslic3r/Format/3mf.cpp) store them. +- [GUI_Factories.cpp](../../src/slic3r/GUI/GUI_Factories.cpp) and + [GUI_ObjectList.cpp](../../src/slic3r/GUI/GUI_ObjectList.cpp) provide the menus, + type changes and ordering. +- [Precise Seam tests](../../tests/fff_print/test_precise_seam.cpp) cover the + strong positions, including a midpoint on an existing vertex or the closing + edge. They also cover shared and coincident weak boundaries, every warning, + and the priority order. +- [Seam placer tests](../../tests/fff_print/test_seam_placer.cpp) cover + enforced-patch selection independent of the contour start, fully painted + contours, duplicate removal, and `Print::apply()` synchronization through + type changes and restored model snapshots. +- [3MF tests](../../tests/libslic3r/test_precise_seam_3mf.cpp) cover the round + trip of every mode and of inactive settings, attribute escaping, and which + metadata combinations restore a seam mode. + [Plugin tests](../../tests/slic3rutils/test_precise_seam_plugin.cpp) cover the + Python bindings. diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index ef049fa48e..296829814a 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -213,6 +213,7 @@ src/slic3r/Utils/Process.cpp src/libslic3r/GCode.cpp src/libslic3r/GCodeWriter.cpp src/libslic3r/GCode/ToolOrdering.cpp +src/libslic3r/GCode/SeamPlacer.cpp src/libslic3r/ExtrusionEntity.cpp src/libslic3r/Flow.cpp src/libslic3r/Format/AMF.cpp diff --git a/localization/i18n/ru/OrcaSlicer_ru.po b/localization/i18n/ru/OrcaSlicer_ru.po index 2d35c31a26..043aa5b476 100644 --- a/localization/i18n/ru/OrcaSlicer_ru.po +++ b/localization/i18n/ru/OrcaSlicer_ru.po @@ -27357,3 +27357,22 @@ msgstr "" #~ msgid "Right click to reset value to system default." #~ msgstr "Правая кнопка мыши - сброс значения до системного по умолчанию." + +# AI Translated +msgid "Precise Seam" +msgstr "Точный шов" + +msgid "multiple intersections with a perimeter detected" +msgstr "обнаружены множественные пересечения с периметром" + +msgid "modifier fully crosses the printable perimeter" +msgstr "модификатор пересекает печатаемый периметр насквозь" + +msgid "modifier shape is not solid (has holes inside) and was ignored" +msgstr "форма модификатора не сплошная (имеет отверстия) и была проигнорирована" + +msgid "perimeter is fully contained inside modifier and was ignored" +msgstr "периметр полностью содержится внутри модификатора и был проигнорирован" + +msgid "Seam placement may differ from expected." +msgstr "Размещение шва может отличаться от ожидаемого." diff --git a/resources/images/menu_precise_seam_add.svg b/resources/images/menu_precise_seam_add.svg new file mode 100644 index 0000000000..f7efb2feb0 --- /dev/null +++ b/resources/images/menu_precise_seam_add.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/menu_precise_seam_blocked.svg b/resources/images/menu_precise_seam_blocked.svg new file mode 100644 index 0000000000..d84c2f1468 --- /dev/null +++ b/resources/images/menu_precise_seam_blocked.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/menu_precise_seam_center.svg b/resources/images/menu_precise_seam_center.svg new file mode 100644 index 0000000000..f7efb2feb0 --- /dev/null +++ b/resources/images/menu_precise_seam_center.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/menu_precise_seam_enforced.svg b/resources/images/menu_precise_seam_enforced.svg new file mode 100644 index 0000000000..116cdb5bed --- /dev/null +++ b/resources/images/menu_precise_seam_enforced.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/menu_precise_seam_left.svg b/resources/images/menu_precise_seam_left.svg new file mode 100644 index 0000000000..737d422373 --- /dev/null +++ b/resources/images/menu_precise_seam_left.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/resources/images/menu_precise_seam_neutral.svg b/resources/images/menu_precise_seam_neutral.svg new file mode 100644 index 0000000000..cf5af99506 --- /dev/null +++ b/resources/images/menu_precise_seam_neutral.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/menu_precise_seam_right.svg b/resources/images/menu_precise_seam_right.svg new file mode 100644 index 0000000000..202895cac2 --- /dev/null +++ b/resources/images/menu_precise_seam_right.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/resources/images/menu_precise_seam_type.svg b/resources/images/menu_precise_seam_type.svg new file mode 100644 index 0000000000..42436d5441 --- /dev/null +++ b/resources/images/menu_precise_seam_type.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/libslic3r/Brim.cpp b/src/libslic3r/Brim.cpp index 9cee5a0e4b..8ba4dfb596 100644 --- a/src/libslic3r/Brim.cpp +++ b/src/libslic3r/Brim.cpp @@ -131,6 +131,7 @@ double getadhesionCoeff(const PrintObject* printObject) } double adhesionCoeff = 1; for (const ModelVolume* modelVolume : objectVolumes) { + if (modelVolume->is_precise_seam()) continue; // non-printing helper geometry for (auto iter = extrudersFirstLayer.begin(); iter != extrudersFirstLayer.end(); iter++) { if (modelVolume->extruder_id() == *iter) { if (Model::extruderParamsMap.find(modelVolume->extruder_id()) != Model::extruderParamsMap.end()) { diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index e734c036fa..eede13737c 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -259,6 +259,8 @@ set(lisbslic3r_sources GCode/RetractWhenCrossingPerimeters.hpp GCode/SeamPlacer.cpp GCode/SeamPlacer.hpp + GCode/PreciseSeam.cpp + GCode/PreciseSeam.hpp #GCodeSender.cpp #GCodeSender.hpp GCode/SmallAreaInfillFlowCompensator.cpp diff --git a/src/libslic3r/Format/3mf.cpp b/src/libslic3r/Format/3mf.cpp index 812e9f0154..21805d258c 100644 --- a/src/libslic3r/Format/3mf.cpp +++ b/src/libslic3r/Format/3mf.cpp @@ -125,6 +125,10 @@ static constexpr const char* VOLUME_TYPE = "volume"; static constexpr const char* NAME_KEY = "name"; static constexpr const char* MODIFIER_KEY = "modifier"; static constexpr const char* VOLUME_TYPE_KEY = "volume_type"; +// Keep seam modes separate from the base type so older readers see a non-printing modifier. +static constexpr const char* PRECISE_SEAM_TYPE_KEY = "precise_seam_type"; +// Preserve dormant settings without turning an older reader's modifier into an active override. +static constexpr char PRECISE_SEAM_CONFIG_PREFIX[] = "precise_seam_config:"; static constexpr const char* MATRIX_KEY = "matrix"; static constexpr const char* SOURCE_FILE_KEY = "source_file"; static constexpr const char* SOURCE_OBJECT_ID_KEY = "source_object_id"; @@ -368,7 +372,14 @@ ModelVolumeType type_from_string(const std::string &s) if (s == "ParameterModifier") return ModelVolumeType::PARAMETER_MODIFIER; if (s == "SupportEnforcer") return ModelVolumeType::SUPPORT_ENFORCER; if (s == "SupportBlocker") return ModelVolumeType::SUPPORT_BLOCKER; - // Default value if invalud type string received. + // Precise Seam types (snake_case strings from ModelVolume::type_to_string) + if (s == "precise_seam_center") return ModelVolumeType::PRECISE_SEAM_CENTER; + if (s == "precise_seam_left") return ModelVolumeType::PRECISE_SEAM_LEFT; + if (s == "precise_seam_right") return ModelVolumeType::PRECISE_SEAM_RIGHT; + if (s == "precise_seam_enforced") return ModelVolumeType::PRECISE_SEAM_ENFORCED; + if (s == "precise_seam_blocked") return ModelVolumeType::PRECISE_SEAM_BLOCKED; + if (s == "precise_seam_neutral") return ModelVolumeType::PRECISE_SEAM_NEUTRAL; + // Default value if invalid type string received. return ModelVolumeType::MODEL_PART; } @@ -2035,6 +2046,7 @@ ModelVolumeType type_from_string(const std::string &s) std::vector valid_keys = { "name", "volume_type", + PRECISE_SEAM_TYPE_KEY, "matrix", "source_file", "source_object_id", @@ -2047,7 +2059,7 @@ ModelVolumeType type_from_string(const std::string &s) }; auto itor = std::find(valid_keys.begin(), valid_keys.end(), key); - if (itor == valid_keys.end()) { + if (itor == valid_keys.end() && !(type == VOLUME_TYPE && boost::starts_with(key, PRECISE_SEAM_CONFIG_PREFIX))) { // do nothing if not valid keys return true; } @@ -2180,6 +2192,8 @@ ModelVolumeType type_from_string(const std::string &s) volume->mmu_segmentation_facets.shrink_to_fit(); volume->fuzzy_skin_facets.shrink_to_fit(); + // Apply the seam mode after all base-type metadata, regardless of XML key order. + ModelVolumeType precise_seam_type = ModelVolumeType::INVALID; // apply the remaining volume's metadata for (const Metadata& metadata : volume_data.metadata) { if (metadata.key == NAME_KEY) @@ -2188,6 +2202,10 @@ ModelVolumeType type_from_string(const std::string &s) volume->set_type(ModelVolumeType::PARAMETER_MODIFIER); else if (metadata.key == VOLUME_TYPE_KEY) volume->set_type(type_from_string(metadata.value)); + else if (metadata.key == PRECISE_SEAM_TYPE_KEY) + precise_seam_type = ModelVolume::type_from_string(metadata.value); + else if (boost::starts_with(metadata.key, PRECISE_SEAM_CONFIG_PREFIX)) + continue; // Restore dormant settings only after the final volume type is known. else if (metadata.key == SOURCE_FILE_KEY) volume->source.input_file = metadata.value; else if (metadata.key == SOURCE_OBJECT_ID_KEY) @@ -2208,6 +2226,22 @@ ModelVolumeType type_from_string(const std::string &s) volume->config.set_deserialize(metadata.key, metadata.value, config_substitutions); } + // Missing or unknown seam modes retain the ordinary modifier fallback. + // Ignore seam metadata on other base types; legacy inline seam types still load above. + if (volume->is_modifier() && is_precise_seam(precise_seam_type)) + volume->set_type(precise_seam_type); + + // Unknown seam modes must remain inert modifiers, even when dormant settings are present. + if (volume->is_precise_seam()) { + for (const Metadata& metadata : volume_data.metadata) { + if (boost::starts_with(metadata.key, PRECISE_SEAM_CONFIG_PREFIX)) { + const std::string key = metadata.key.substr(sizeof(PRECISE_SEAM_CONFIG_PREFIX) - 1); + if (!key.empty()) + volume->config.set_deserialize(key, metadata.value, config_substitutions); + } + } + } + // this may happen for 3mf saved by 3rd part softwares if (volume->name.empty()) { volume->name = object.name; @@ -3123,11 +3157,18 @@ ModelVolumeType type_from_string(const std::string &s) stream << " <" << METADATA_TAG << " " << TYPE_ATTR << "=\"" << VOLUME_TYPE << "\" " << KEY_ATTR << "=\"" << NAME_KEY << "\" " << VALUE_ATTR << "=\"" << xml_escape(volume->name) << "\"/>\n"; // stores volume's modifier field (legacy, to support old slicers) - if (volume->is_modifier()) + // Readers with only the legacy flag still see helper geometry as a modifier. + if (volume->is_modifier() || volume->is_precise_seam()) stream << " <" << METADATA_TAG << " " << TYPE_ATTR << "=\"" << VOLUME_TYPE << "\" " << KEY_ATTR << "=\"" << MODIFIER_KEY << "\" " << VALUE_ATTR << "=\"1\"/>\n"; - // stores volume's type (overrides the modifier field above) + // This Prusa-format reader uses ParameterModifier, not Bambu's modifier_part. + // The base type overrides the legacy flag, so it must also be backward-compatible. + // Use the same spelling for ordinary modifiers, including a downgraded seam helper. + const bool store_as_modifier = volume->is_modifier() || volume->is_precise_seam(); stream << " <" << METADATA_TAG << " " << TYPE_ATTR << "=\"" << VOLUME_TYPE << "\" " << KEY_ATTR << "=\"" << VOLUME_TYPE_KEY << "\" " << - VALUE_ATTR << "=\"" << ModelVolume::type_to_string(volume->type()) << "\"/>\n"; + VALUE_ATTR << "=\"" << (store_as_modifier ? "ParameterModifier" : ModelVolume::type_to_string(volume->type())) << "\"/>\n"; + if (volume->is_precise_seam()) + stream << " <" << METADATA_TAG << " " << TYPE_ATTR << "=\"" << VOLUME_TYPE << "\" " << KEY_ATTR << "=\"" << PRECISE_SEAM_TYPE_KEY << "\" " << + VALUE_ATTR << "=\"" << ModelVolume::type_to_string(volume->type()) << "\"/>\n"; // stores volume's local matrix stream << " <" << METADATA_TAG << " " << TYPE_ATTR << "=\"" << VOLUME_TYPE << "\" " << KEY_ATTR << "=\"" << MATRIX_KEY << "\" " << VALUE_ATTR << "=\""; @@ -3162,7 +3203,12 @@ ModelVolumeType type_from_string(const std::string &s) // stores volume's config data for (const std::string& key : volume->config.keys()) { - stream << " <" << METADATA_TAG << " " << TYPE_ATTR << "=\"" << VOLUME_TYPE << "\" " << KEY_ATTR << "=\"" << key << "\" " << VALUE_ATTR << "=\"" << volume->config.opt_serialize(key) << "\"/>\n"; + // Seam settings are inactive but must survive changing the helper back into a part/modifier. + const bool dormant = volume->is_precise_seam(); + const std::string stored_key = dormant ? PRECISE_SEAM_CONFIG_PREFIX + key : key; + const std::string value = volume->config.opt_serialize(key); + // Config serialization is C-style, not XML: escape active settings too, including tabs. + stream << " <" << METADATA_TAG << " " << TYPE_ATTR << "=\"" << VOLUME_TYPE << "\" " << KEY_ATTR << "=\"" << stored_key << "\" " << VALUE_ATTR << "=\"" << xml_escape_double_quotes_attribute_value(value) << "\"/>\n"; } // stores mesh's statistics diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 09899aa4c2..d34a7b84b3 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -353,6 +353,10 @@ static constexpr const char* PART_TYPE = "part"; static constexpr const char* NAME_KEY = "name"; static constexpr const char* VOLUME_TYPE_KEY = "volume_type"; static constexpr const char* PART_TYPE_KEY = "part_type"; +// Keep seam modes separate from the base type so older readers see a non-printing modifier. +static constexpr const char* PRECISE_SEAM_TYPE_KEY = "precise_seam_type"; +// Preserve dormant settings without turning an older reader's modifier into an active override. +static constexpr char PRECISE_SEAM_CONFIG_PREFIX[] = "precise_seam_config:"; static constexpr const char* MATRIX_KEY = "matrix"; static constexpr const char* SOURCE_FILE_KEY = "source_file"; static constexpr const char* SOURCE_OBJECT_ID_KEY = "source_object_id"; @@ -5212,6 +5216,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) if (auto &tc = volume_data->text_configuration; tc.has_value()) volume->text_configuration = std::move(tc); + // Apply the seam mode after all base-type metadata, regardless of XML key order. + ModelVolumeType precise_seam_type = ModelVolumeType::INVALID; // apply the remaining volume's metadata for (const Metadata& metadata : volume_data->metadata) { if (metadata.key == NAME_KEY) @@ -5221,6 +5227,10 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) //for old format else if ((metadata.key == VOLUME_TYPE_KEY) || (metadata.key == PART_TYPE_KEY)) volume->set_type(ModelVolume::type_from_string(metadata.value)); + else if (metadata.key == PRECISE_SEAM_TYPE_KEY) + precise_seam_type = ModelVolume::type_from_string(metadata.value); + else if (boost::starts_with(metadata.key, PRECISE_SEAM_CONFIG_PREFIX)) + continue; // Restore dormant settings only after the final volume type is known. else if (metadata.key == SOURCE_FILE_KEY) volume->source.input_file = metadata.value; else if (metadata.key == SOURCE_OBJECT_ID_KEY) @@ -5243,6 +5253,22 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) volume->config.set_deserialize(metadata.key, metadata.value, config_substitutions); } + // Missing or unknown seam modes retain the ordinary modifier fallback. + // Ignore seam metadata on other base types; legacy inline seam types still load above. + if (volume->is_modifier() && is_precise_seam(precise_seam_type)) + volume->set_type(precise_seam_type); + + // Unknown seam modes must remain inert modifiers, even when dormant settings are present. + if (volume->is_precise_seam()) { + for (const Metadata& metadata : volume_data->metadata) { + if (boost::starts_with(metadata.key, PRECISE_SEAM_CONFIG_PREFIX)) { + const std::string key = metadata.key.substr(sizeof(PRECISE_SEAM_CONFIG_PREFIX) - 1); + if (!key.empty()) + volume->config.set_deserialize(key, metadata.value, config_substitutions); + } + } + } + // this may happen for 3mf saved by 3rd part softwares if (volume->name.empty()) { volume->name = object.name; @@ -8002,7 +8028,12 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) volume_id = m_volume_paths.find(volume)->second.second; stream << ID_ATTR << "=\"" << volume_id << "\" "; - stream << SUBTYPE_ATTR << "=\"" << ModelVolume::type_to_string(volume->type()) << "\">\n"; + // Older slicers must recognize the base type even when they ignore seam metadata. + const ModelVolumeType stored_type = volume->is_precise_seam() ? ModelVolumeType::PARAMETER_MODIFIER : volume->type(); + stream << SUBTYPE_ATTR << "=\"" << ModelVolume::type_to_string(stored_type) << "\">\n"; + if (volume->is_precise_seam()) + stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << PRECISE_SEAM_TYPE_KEY << "\" " << VALUE_ATTR << "=\"" << + ModelVolume::type_to_string(volume->type()) << "\"/>\n"; //stream << " <" << PART_TAG << " " << ID_ATTR << "=\"" << it->second << "\" " << SUBTYPE_ATTR << "=\"" << ModelVolume::type_to_string(volume->type()) << "\">\n"; // stores volume's name @@ -8052,7 +8083,12 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // stores volume's config data for (const std::string& key : volume->config.keys()) { - stream << " <" << METADATA_TAG << " "<< KEY_ATTR << "=\"" << key << "\" " << VALUE_ATTR << "=\"" << volume->config.opt_serialize(key) << "\"/>\n"; + // Seam settings are inactive but must survive changing the helper back into a part/modifier. + const bool dormant = volume->is_precise_seam(); + const std::string stored_key = dormant ? PRECISE_SEAM_CONFIG_PREFIX + key : key; + const std::string value = volume->config.opt_serialize(key); + // Config serialization is C-style, not XML: escape active settings too, including tabs. + stream << " <" << METADATA_TAG << " "<< KEY_ATTR << "=\"" << stored_key << "\" " << VALUE_ATTR << "=\"" << xml_escape_double_quotes_attribute_value(value) << "\"/>\n"; } if (const std::optional &es = volume->emboss_shape; es.has_value()) { diff --git a/src/libslic3r/GCode/PreciseSeam.cpp b/src/libslic3r/GCode/PreciseSeam.cpp new file mode 100644 index 0000000000..378ab2d2d3 --- /dev/null +++ b/src/libslic3r/GCode/PreciseSeam.cpp @@ -0,0 +1,1353 @@ +#include "PreciseSeam.hpp" +#include "SeamPlacer.hpp" +#include "libslic3r/BoundingBox.hpp" +#include +#include +#include + +namespace Slic3r { +namespace PreciseSeam { + +// Import EnforcedBlockedSeamPoint from SeamPlacerImpl namespace for convenience +using SeamPlacerImpl::EnforcedBlockedSeamPoint; + +// Machine precision for checking exact coordinate matching (squared distance) +// Ideally, intersection points should match perimeter vertices bitwise, +// but we account for possible machine rounding errors in Clipper calculations +// Actual deviations: maximum ~0.27, using 2.5 with margin (nanometers) +static constexpr double MACHINE_PRECISION_SQUARED = 2.5; + +// Tolerance for checking proximity when inserting seam points into perimeter +static const coord_t TOLERANCE_LINEAR = scale_(0.001); // 1.0 micrometers +static const coord_t TOLERANCE_SQUARED = TOLERANCE_LINEAR * TOLERANCE_LINEAR; + +// Find common segment between intersection polygon and object perimeter +// +// REQUIREMENTS: +// - intersection_polygon must be converted to CCW (counter-clockwise) beforehand +// - perimeter_polygon must be converted to CCW (counter-clockwise) beforehand +// +// Parameters: +// intersection_polygon - intersection area polygon (result of intersection()), CCW orientation +// perimeter_polygon - object outline (outer perimeter), CCW orientation +// modifier_polygon - modifier that formed the intersection +// +// Returns: +// SegmentData - longest continuous segment with point correspondence +// std::nullopt - if segment not found (< 2 points) or error +// +static std::optional common_segment_in_intersection( + const Polygon &intersection_polygon, + const Polygon &perimeter_polygon, + PreciseSeamWarnings* warnings = nullptr) +{ + const size_t isect_n = intersection_polygon.points.size(); + const size_t perim_n = perimeter_polygon.points.size(); + + // Check for empty polygons + if (isect_n == 0 || perim_n == 0) { + return std::nullopt; + } + + // Minimum 2 points required to form segment + if (isect_n < 2) { + return std::nullopt; + } + + // ============================================================ + // STEP 1: Project all intersection points onto perimeter + // ============================================================ + + struct PointProjection { + double dist_squared; // Squared distance to perimeter + size_t edge_index; // Edge/vertex index of perimeter + bool on_perimeter; // Whether point lies on perimeter (within machine precision) + }; + + std::vector projections; + projections.reserve(isect_n); + + // Project each intersection point onto perimeter + for (size_t i = 0; i < isect_n; ++i) { + size_t edge_idx; + const Point proj_point = perimeter_polygon.point_projection(intersection_polygon.points[i], &edge_idx); + const double dist_sq = (intersection_polygon.points[i] - proj_point).cast().squaredNorm(); + + const auto invalid = std::numeric_limits::max(); + if (edge_idx == invalid) { + // Projection error - point does not belong to perimeter + projections.push_back({dist_sq, edge_idx, false}); + } else { + // Check whether point lies on perimeter (within machine precision) + bool on_perim = (dist_sq <= MACHINE_PRECISION_SQUARED); + projections.push_back({dist_sq, edge_idx, on_perim}); + } + } + + // ============================================================ + // STEP 2: Find all continuous segments of points on perimeter + // ============================================================ + + std::vector processed(isect_n, false); // Processed point flags + std::vector> segments; // Found segments (point indices) + + for (size_t start_idx = 0; start_idx < isect_n; ++start_idx) { + // Skip processed or non-perimeter points + if (processed[start_idx] || !projections[start_idx].on_perimeter) { + continue; + } + + // Found point on perimeter - search for continuous segment + std::vector backward_indices; // Point indices backward from start_idx + std::vector forward_indices; // Point indices forward from start_idx (including start_idx) + + // Add start point to forward + forward_indices.push_back(start_idx); + processed[start_idx] = true; + + // Backward traversal (only if start_idx == 0, for wrap-around handling) + if (start_idx == 0) { + for (size_t offset = 1; offset < isect_n; ++offset) { + size_t curr_idx = (start_idx + isect_n - offset) % isect_n; + + // Stop if point already processed or not on perimeter + if (processed[curr_idx] || !projections[curr_idx].on_perimeter) { + break; + } + + backward_indices.push_back(curr_idx); + processed[curr_idx] = true; + } + } + + // Forward traversal from start_idx + for (size_t offset = 1; offset < isect_n; ++offset) { + size_t curr_idx = (start_idx + offset) % isect_n; + + // Stop if point already processed or not on perimeter + if (processed[curr_idx] || !projections[curr_idx].on_perimeter) { + break; + } + + forward_indices.push_back(curr_idx); + processed[curr_idx] = true; + } + + // Merge backward (in reverse order) + forward into one segment + std::vector segment_indices; + segment_indices.reserve(backward_indices.size() + forward_indices.size()); + + // Add backward in reverse order + segment_indices.insert( + segment_indices.end(), + backward_indices.rbegin(), + backward_indices.rend() + ); + + // Add forward + segment_indices.insert( + segment_indices.end(), + forward_indices.begin(), + forward_indices.end() + ); + + // Save found segment + segments.push_back(std::move(segment_indices)); + } + + // ============================================================ + // STEP 3: Select longest segment + // ============================================================ + + // If no segments found + if (segments.empty()) { + return std::nullopt; + } + + // Search for segment with maximum point count + auto it_longest = std::max_element( + segments.begin(), + segments.end(), + [](const auto &a, const auto &b) { return a.size() < b.size(); } + ); + + const std::vector &longest_segment = *it_longest; + + // Check minimum requirement: >= 2 points + if (longest_segment.size() < 2) { + return std::nullopt; + } + + // Special case: all intersection vertices lie on perimeter + if (longest_segment.size() == isect_n) { + // Threshold for edge midpoint check: increased by 0.5 due to rounding error in integer coordinate division + constexpr double EDGE_CENTER_THRESHOLD = MACHINE_PRECISION_SQUARED + 0.5; + + // Check each intersection edge - does its midpoint lie on perimeter + std::vector edges_not_on_perim_indices; + + for (size_t i = 0; i < isect_n; ++i) { + size_t next_i = (i + 1) % isect_n; + + // Calculate edge midpoint i→next_i + const Point &pt1 = intersection_polygon.points[i]; + const Point &pt2 = intersection_polygon.points[next_i]; + Point edge_center( + (pt1.x() + pt2.x()) / 2, + (pt1.y() + pt2.y()) / 2 + ); + + // Project midpoint onto perimeter + const Point proj = perimeter_polygon.point_projection(edge_center); + const double dist_sq = (edge_center - proj).cast().squaredNorm(); + + // Check if midpoint lies on perimeter (accounting for rounding error) + if (dist_sq > EDGE_CENTER_THRESHOLD) { + edges_not_on_perim_indices.push_back(i); + } + } + + // Analyze results + if (edges_not_on_perim_indices.empty()) { + // All edges on perimeter → modifier fully contains perimeter → not suitable for seam placement + if (warnings) + warnings->full_containment.store(true, std::memory_order_relaxed); + return std::nullopt; + } + + // Edges not on perimeter act as "cuts" that split the circular ring of vertices + // into separate on-perimeter segments. For k cuts there are k segments. + // We iterate over consecutive pairs of cuts and pick the longest segment. + const size_t k = edges_not_on_perim_indices.size(); + size_t best_start = 0; + size_t best_length = 0; + + for (size_t i = 0; i < k; ++i) { + size_t gap_cur = edges_not_on_perim_indices[i]; + size_t gap_next = edges_not_on_perim_indices[(i + 1) % k]; + + // Segment starts at the vertex right after the current cut + size_t start = (gap_cur + 1) % isect_n; + // Number of vertices from start up to and including the vertex before the next cut + size_t length = (gap_next - gap_cur - 1 + isect_n) % isect_n + 1; + + if (length > best_length) { + best_length = length; + best_start = start; + } + } + + if (best_length < 2) { + return std::nullopt; + } + + // Form SegmentData from the longest on-perimeter segment + SegmentData result; + result.segment.points.reserve(best_length); + result.perimeter_edge_indices.reserve(best_length); + + for (size_t i = 0; i < best_length; ++i) { + size_t idx = (best_start + i) % isect_n; + result.segment.points.push_back(intersection_polygon.points[idx]); + result.perimeter_edge_indices.push_back(projections[idx].edge_index); + } + + return result; + } + + // ============================================================ + // STEP 4: Form SegmentData result + // ============================================================ + + SegmentData result; + result.segment.points.reserve(longest_segment.size()); + result.perimeter_edge_indices.reserve(longest_segment.size()); + + // Fill points and edge_index for each segment point + for (size_t idx : longest_segment) { + result.segment.points.push_back(intersection_polygon.points[idx]); + result.perimeter_edge_indices.push_back(projections[idx].edge_index); + } + + return result; +} + +// Fast search for common segment between intersection polygon and object perimeter +// Hybrid algorithm: first exact coordinate matching, then geometric check +// +// REQUIREMENTS: +// - intersection_polygon must be converted to CCW (counter-clockwise) beforehand +// - perimeter_polygon must be converted to CCW (counter-clockwise) beforehand +// +// Parameters: +// intersection_polygon - intersection area polygon (result of intersection()), CCW orientation +// perimeter_polygon - object outline (outer perimeter), CCW orientation +// +// Returns: +// SegmentData - continuous segment with point correspondence +// std::nullopt - if segment not found (< 2 points) or error +// +static std::optional common_segment_in_intersection_fast( + const Polygon &intersection_polygon, + const Polygon &perimeter_polygon, + PreciseSeamWarnings* warnings = nullptr) +{ + const size_t isect_n = intersection_polygon.points.size(); + const size_t perim_n = perimeter_polygon.points.size(); + + // ============================================================ + // STEP 1: Input data validation + // ============================================================ + + if (isect_n == 0 || perim_n == 0) { + return std::nullopt; + } + + if (isect_n < 2) { + return std::nullopt; + } + + // ============================================================ + // STEP 2: Find first point (exact coordinate match) + // ============================================================ + + // Vectors for forward direction + std::vector forward_intersection_indices; + std::vector forward_edge_indices; + forward_intersection_indices.reserve(isect_n); + forward_edge_indices.reserve(isect_n); + + // Vectors for backward direction + std::vector backward_intersection_indices; + std::vector backward_edge_indices; + backward_intersection_indices.reserve(isect_n); + backward_edge_indices.reserve(isect_n); + + size_t first_isect_idx = 0; // index of first matching point in intersection_polygon + size_t first_perim_idx = 0; // index of first matching point in perimeter_polygon (also edge_index) + bool found_first = false; + + // Search for first exact match (not optimized — expected gain is negligible) + for (size_t i = 0; i < isect_n; ++i) { + const Point &isect_pt = intersection_polygon.points[i]; + + auto it = std::find(perimeter_polygon.points.begin(), + perimeter_polygon.points.end(), + isect_pt); + + if (it != perimeter_polygon.points.end()) { + first_isect_idx = i; + first_perim_idx = std::distance(perimeter_polygon.points.begin(), it); + found_first = true; + + // Add first point to forward vectors + forward_intersection_indices.push_back(i); + forward_edge_indices.push_back(first_perim_idx); + break; + } + } + + // If no matching point found - use full geometric algorithm + if (!found_first) { + return common_segment_in_intersection(intersection_polygon, perimeter_polygon, warnings); + } + + // Sentinel value for an invalid edge_index returned by Polygon::point_projection. + const auto invalid = std::numeric_limits::max(); + + // ============================================================ + // STEP 3: Forward pass (from first point forward) + // ============================================================ + + // Adaptive tracking of position in perimeter (instead of fixed prediction) + size_t next_expected_perim_idx = (first_perim_idx + 1) % perim_n; + + for (size_t offset = 1; offset < isect_n; ++offset) { + size_t curr_isect_idx = (first_isect_idx + offset) % isect_n; + const Point &curr_isect_pt = intersection_polygon.points[curr_isect_idx]; + const Point &expected_pt = perimeter_polygon.points[next_expected_perim_idx]; + + // First check exact match with expected position + if (curr_isect_pt == expected_pt) { + forward_intersection_indices.push_back(curr_isect_idx); + forward_edge_indices.push_back(next_expected_perim_idx); + next_expected_perim_idx = (next_expected_perim_idx + 1) % perim_n; + continue; + } + + // Exact match not found - check geometrically + size_t edge_idx; + const Point proj_point = perimeter_polygon.point_projection(curr_isect_pt, &edge_idx); + const double dist_sq = (curr_isect_pt - proj_point).cast().squaredNorm(); + + if (edge_idx == invalid || dist_sq > MACHINE_PRECISION_SQUARED) { + // Point not on perimeter - break forward pass + break; + } + + // Point on perimeter - add and adjust expected position + forward_intersection_indices.push_back(curr_isect_idx); + forward_edge_indices.push_back(edge_idx); + next_expected_perim_idx = (edge_idx + 1) % perim_n; + } + + // ============================================================ + // STEP 4: Backward pass + // ============================================================ + + // Optimization: backward can find maximum (isect_n - forward_count) points + size_t forward_count = forward_intersection_indices.size(); + size_t max_backward_iterations = isect_n - forward_count; + + // Adaptive tracking of position in perimeter for backward direction + next_expected_perim_idx = (first_perim_idx + perim_n - 1) % perim_n; + + for (size_t offset = 1; offset <= max_backward_iterations; ++offset) { + size_t curr_isect_idx = (first_isect_idx + isect_n - offset) % isect_n; + const Point &curr_isect_pt = intersection_polygon.points[curr_isect_idx]; + const Point &expected_pt = perimeter_polygon.points[next_expected_perim_idx]; + + // First check exact match with expected position + if (curr_isect_pt == expected_pt) { + backward_intersection_indices.push_back(curr_isect_idx); + backward_edge_indices.push_back(next_expected_perim_idx); + next_expected_perim_idx = (next_expected_perim_idx + perim_n - 1) % perim_n; + continue; + } + + // Exact match not found - check geometrically + size_t edge_idx; + const Point proj_point = perimeter_polygon.point_projection(curr_isect_pt, &edge_idx); + const double dist_sq = (curr_isect_pt - proj_point).cast().squaredNorm(); + + if (edge_idx == invalid || dist_sq > MACHINE_PRECISION_SQUARED) { + // Point not on perimeter - break backward pass + break; + } + + // Point on perimeter - add and adjust expected position + backward_intersection_indices.push_back(curr_isect_idx); + backward_edge_indices.push_back(edge_idx); + next_expected_perim_idx = (edge_idx + perim_n - 1) % perim_n; + } + + // Check special case: all intersection vertices lie on perimeter + // Use full geometric algorithm (rare case but requires special handling) + size_t total_points = forward_intersection_indices.size() + backward_intersection_indices.size(); + if (total_points == isect_n) { + return common_segment_in_intersection(intersection_polygon, perimeter_polygon, warnings); + } + + // ============================================================ + // STEP 5: Merge backward (reversed) + forward + // ============================================================ + + std::vector continuous_intersection_indices; + std::vector continuous_edge_indices; + + if (backward_intersection_indices.empty()) { + // No backward - just move forward + continuous_intersection_indices = std::move(forward_intersection_indices); + continuous_edge_indices = std::move(forward_edge_indices); + } else { + // Merge: backward (reversed) + forward + size_t total_size = backward_intersection_indices.size() + forward_intersection_indices.size(); + continuous_intersection_indices.reserve(total_size); + continuous_edge_indices.reserve(total_size); + + // Add backward in reverse order + continuous_intersection_indices.insert( + continuous_intersection_indices.end(), + backward_intersection_indices.rbegin(), + backward_intersection_indices.rend() + ); + continuous_edge_indices.insert( + continuous_edge_indices.end(), + backward_edge_indices.rbegin(), + backward_edge_indices.rend() + ); + + // Add forward + continuous_intersection_indices.insert( + continuous_intersection_indices.end(), + forward_intersection_indices.begin(), + forward_intersection_indices.end() + ); + continuous_edge_indices.insert( + continuous_edge_indices.end(), + forward_edge_indices.begin(), + forward_edge_indices.end() + ); + } + + // ============================================================ + // STEP 6: Check minimum size and special cases + // ============================================================ + + if (continuous_intersection_indices.size() < 2) { + return std::nullopt; + } + + // ============================================================ + // STEP 7: Form SegmentData result + // ============================================================ + + SegmentData result; + result.segment.points.reserve(continuous_intersection_indices.size()); + result.perimeter_edge_indices.reserve(continuous_intersection_indices.size()); + + // Fill with original coordinates from intersection_polygon + edge_index + for (size_t i = 0; i < continuous_intersection_indices.size(); ++i) { + size_t idx = continuous_intersection_indices[i]; + result.segment.points.push_back(intersection_polygon.points[idx]); + result.perimeter_edge_indices.push_back(continuous_edge_indices[i]); + } + + return result; +} + +void init_precise_seam_data( + std::vector& strong_volumes_out, + std::vector& weak_volumes_out, + bool& has_strong_out, + const ModelObject* model_object) +{ + // Clear output vectors + strong_volumes_out.clear(); + weak_volumes_out.clear(); + + if (model_object == nullptr) { + has_strong_out = false; + return; + } + + // Collect and categorize precise seam modifiers + for (const ModelVolume* volume : model_object->volumes) { + if (volume->is_precise_seam()) { + ModelVolumeType type = volume->type(); + // Categorization: strong modifiers have priority + if (type == ModelVolumeType::PRECISE_SEAM_CENTER || + type == ModelVolumeType::PRECISE_SEAM_LEFT || + type == ModelVolumeType::PRECISE_SEAM_RIGHT) { + strong_volumes_out.push_back(volume); + } else { + // ENFORCED, BLOCKED, NEUTRAL - weak modifiers (processed later) + weak_volumes_out.push_back(volume); + } + } + } + + has_strong_out = !strong_volumes_out.empty(); + + // Collection already preserves model order, with higher-priority strong modifiers first. + // Weak modifiers use last-write-wins, so apply the higher-priority ones last. + std::reverse(weak_volumes_out.begin(), weak_volumes_out.end()); +} + +// Calculate cumulative lengths for each Polyline point +// Analog of Polygon::parameter_by_length(), adapted for open line +static std::vector polyline_parameter_by_length(const Polyline &polyline) +{ + // Keep scaled-coordinate lengths in double precision for midpoint interpolation. + std::vector lengths(polyline.points.size(), 0.); + for (size_t i = 1; i < polyline.points.size(); ++i) { + lengths[i] = lengths[i-1] + (polyline.points[i] - polyline.points[i-1]).cast().norm(); + } + return lengths; +} + +// Find geometric center coordinates of segment +// Returns: {center coordinates, perimeter vertex index} +// Index is start vertex of edge containing center +static std::optional> segment_center(const SegmentData &data, const Polygon &perimeter_polygon) +{ + const Polyline &segment = data.segment; + + if (segment.points.size() < 2) { + return std::nullopt; // Need at least a line to find middle + } + + std::vector lengths = polyline_parameter_by_length(segment); + if (lengths.empty()) { + return std::nullopt; // Polyline contains no points + } + + double half_length = lengths.back() * 0.5; // Take half of total length + size_t mid_idx = segment.points.size() / 2; + double mid_length = lengths[mid_idx]; + + bool found = false; + size_t start_idx = 0; + size_t end_idx = 0; + + if (mid_length < half_length) { + // Go right (to end) + for (size_t i = mid_idx; i < segment.points.size() - 1; ++i) { + if (lengths[i] <= half_length && half_length < lengths[i+1]) { + start_idx = i; // Fix left point of segment + end_idx = i + 1; // Fix right point of segment + found = true; + break; + } + } + } else if (mid_length > half_length) { + // Go left (to start) + for (size_t i = mid_idx; i > 0; --i) { + if (lengths[i-1] <= half_length && half_length < lengths[i]) { + start_idx = i - 1; // Take neighboring point on left + end_idx = i; // And nearest on right + found = true; + break; + } + } + } else { + // Middle sits exactly at vertex + start_idx = mid_idx; + end_idx = (mid_idx + 1) % segment.points.size(); // use next point (wrap-around) + found = true; + } + + if (!found) { + return std::nullopt; // Didn't find suitable segment + } + + const Point &p1 = segment.points[start_idx]; + const Point &p2 = segment.points[end_idx]; + + double local_mid_length = half_length - lengths[start_idx]; + double edge_length = lengths[end_idx] - lengths[start_idx]; + + Point mid_point; + if (edge_length <= 0.0) { + mid_point = p1; // Degenerate case, take start point + } else { + double k = local_mid_length / edge_length; + mid_point = p1 + (k * (p2 - p1).cast()).cast(); // Linear interpolation + } + + // Clipper may merge several collinear perimeter edges into one segment edge. + // Locate the midpoint on the original perimeter instead of reusing the start's edge. + size_t edge_idx; + const Point projected_midpoint = perimeter_polygon.point_projection(mid_point, &edge_idx); + if (edge_idx == std::numeric_limits::max()) + return std::nullopt; + + return std::make_pair(projected_midpoint, edge_idx); +} + +// Find coordinates of left (first) point of segment +// Returns: {first point coordinates, perimeter vertex index} +// Index is start vertex of edge containing first point +static std::optional> segment_left(const SegmentData &data) +{ + if (data.segment.points.empty()) { + return std::nullopt; + } + + return std::make_pair(data.segment.points[0], data.perimeter_edge_indices[0]); +} + +// Find coordinates of right (last) point of segment +// Returns: {last point coordinates, perimeter vertex index} +// Index is start vertex of edge containing last point +static std::optional> segment_right(const SegmentData &data) +{ + if (data.segment.points.empty()) { + return std::nullopt; + } + + size_t last_idx = data.segment.points.size() - 1; + return std::make_pair(data.segment.points[last_idx], data.perimeter_edge_indices[last_idx]); +} + +// Insert point into perimeter with proximity check to existing vertices +// If point is close to vertex (< TOLERANCE_SQUARED) - use existing vertex +// Returns pair: {final coordinates, point index in polygon} +// edge_start_idx is start vertex of edge containing point +static std::optional> insert_point_into_perimeter( + const Point &point, + size_t edge_start_idx, + Polygon &perimeter_polygon +) +{ + // Check input data + if (perimeter_polygon.points.size() < 3) { + return std::nullopt; // Polygon must be at least a triangle + } + + size_t perim_max = perimeter_polygon.points.size(); + + // Determine edge start and end + size_t vtx_start = edge_start_idx; + size_t vtx_end = (edge_start_idx + 1) % perim_max; + + const Point &perim_p_start = perimeter_polygon.points[vtx_start]; + const Point &perim_p_end = perimeter_polygon.points[vtx_end]; + + // Check proximity to edge vertices + coord_t dist_sq_start = (point - perim_p_start).squaredNorm(); + if (dist_sq_start < TOLERANCE_SQUARED) { + return std::make_pair(perim_p_start, vtx_start); + } + + coord_t dist_sq_end = (point - perim_p_end).squaredNorm(); + if (dist_sq_end < TOLERANCE_SQUARED) { + return std::make_pair(perim_p_end, vtx_end); + } + + // Insert point into perimeter + // IMPORTANT: Special handling for the last edge to preserve indexing for subsequent insertions. + // If this is the last edge (edge_start_idx == perim_max - 1), we append to the end instead of + // inserting at position 0 (which would shift all indices). This allows sorting points by + // descending arc length and inserting them without invalidating previously computed indices. + size_t insert_pos; + if (edge_start_idx == perim_max - 1) { + // Last edge: add to end of vector + perimeter_polygon.points.push_back(point); + insert_pos = perimeter_polygon.points.size() - 1; + } else { + // Regular edge: insert before end vertex + insert_pos = vtx_end; + perimeter_polygon.points.insert( + perimeter_polygon.points.begin() + insert_pos, + point + ); + } + + return std::make_pair(perimeter_polygon.points[insert_pos], insert_pos); +} + +// Insert new point at distance TOLERANCE_LINEAR from specified perimeter vertex +// Insertion direction specified by direction parameter: +1 = after vertex, -1 = before vertex +// If target edge length < 2*TOLERANCE_LINEAR, insertion not performed (new point would be too close to edge end) +// Returns true if point was inserted, false otherwise +// point_idx is index of perimeter vertex from which insertion is performed +static bool refine_at_vertex( + size_t point_idx, + int direction, + Polygon &perimeter_polygon +) +{ + // Check input data + if (perimeter_polygon.points.size() < 3) { + return false; // Polygon must be at least a triangle + } + + if (direction != 1 && direction != -1) { + return false; // Direction must be +1 or -1 + } + + size_t perim_max = perimeter_polygon.points.size(); + + // Determine target edge based on direction + size_t edge_start_idx, edge_end_idx; + + if (direction == 1) { + // Direction +1: insertion AFTER point_idx (edge point_idx → point_idx+1) + edge_start_idx = point_idx; + edge_end_idx = (point_idx + 1) % perim_max; + } else { + // Direction -1: insertion BEFORE point_idx (edge point_idx-1 → point_idx) + edge_start_idx = (point_idx + perim_max - 1) % perim_max; + edge_end_idx = point_idx; + } + + const Point &edge_start = perimeter_polygon.points[edge_start_idx]; + const Point &edge_end = perimeter_polygon.points[edge_end_idx]; + + // Calculate edge length + Vec2d edge_vector = (edge_end - edge_start).cast(); + double edge_length = edge_vector.norm(); + + // Check if edge is long enough for insertion + // New point must be at distance TOLERANCE_LINEAR from start + // and at distance >= TOLERANCE_LINEAR from end + if (edge_length < 2.0 * TOLERANCE_LINEAR) { + return false; // Edge too short - new point would be too close to end + } + + // Calculate new point coordinates: edge_start + TOLERANCE_LINEAR * direction_normalized + Vec2d direction_normalized = edge_vector / edge_length; + // Place helper point near point_idx: after it for +1, before it for -1 + auto offset = (TOLERANCE_LINEAR * direction_normalized).cast(); + Point new_point = (direction == 1) + ? Point(edge_start + offset) + : Point(edge_end - offset); + + // Insert point into perimeter + // IMPORTANT: Special handling of last edge to preserve indexing for subsequent insertions. + // If this is last edge (edge_start_idx == perim_max - 1), add point to end of vector + // instead of inserting at position 0 (which would shift all indices). This allows sorting points + // by descending arc length and inserting them without invalidating previously computed indices. + if (edge_start_idx == perim_max - 1) { + // Last edge: add to end of vector + perimeter_polygon.points.push_back(new_point); + } else { + // Regular edge: insert before end vertex + perimeter_polygon.points.insert( + perimeter_polygon.points.begin() + edge_end_idx, + new_point + ); + } + + return true; +} + +// Insert strong seam point into perimeter polygon +std::optional insert_strong_seam_point( + const std::vector &strong_volumes, + Polygon &polygon, + const Layer *layer, + const ModifierSlicesCache &slices_cache, + PreciseSeamWarnings* warnings) +{ + if (strong_volumes.empty() || layer == nullptr) { + return std::nullopt; + } + + // layer->id() is offset by raft layer count, but modifier_slices is 0-based + // (built from PrintObject::layers() via slice_single_volume). Subtract raft + // offset to get the correct index into the cache. + const size_t raft_layers = layer->object()->slicing_parameters().raft_layers(); + size_t layer_id = layer->id() - raft_layers; + + // Reject disjoint bounds before running polygon clipping; touching bounds still overlap. + const BoundingBox perimeter_bbox(polygon.points); + + // Iterate through strong modifiers in hierarchy order + for (const ModelVolume* modifier_volume : strong_volumes) { + // Look up pre-sliced polygons from cache (sliced once in SeamPlacer::init). + // TODO: slice_single_volume() converts ExPolygons to flat Polygons, losing + // the association between outer contours and their holes. This makes correct + // handling of multiply-connected modifier regions (e.g. a torus cross-section) + // impossible. Consider a variant returning std::vector and adapting + // the algorithm to work with multiply-connected domains. + auto it = slices_cache.find(modifier_volume); + if (it == slices_cache.end()) + continue; // modifier not in cache (should not happen) + const std::vector &modifier_slices = it->second; + + // Check if this layer has slices for this modifier + if (layer_id >= modifier_slices.size()) { + continue; // No slices for this layer + } + + const Polygons &modifier_polygons = modifier_slices[layer_id]; + + // Check for multiply-connected regions (holes = CW polygons). + // slice_single_volume() flattens ExPolygons into Polygons, but preserves + // orientation: CCW = outer contour, CW = hole. If any CW polygon is present, + // the modifier is multiply-connected and cannot be processed correctly. + bool has_holes = std::any_of(modifier_polygons.begin(), modifier_polygons.end(), + [](const Polygon &p) { return p.is_clockwise(); }); + if (has_holes) { + if (warnings) + warnings->multiply_connected.store(true, std::memory_order_relaxed); + continue; + } + + // Iterate through all polygons of the modifier on this layer + // After finding a match, check if remaining modifier polygons also intersect the perimeter. + // Strong modifiers process only one intersection (one seam per perimeter), so any additional + // intersections from unprocessed polygons indicate a multiple-intersection situation. + auto check_remaining_polygons = [&](size_t current_idx) { + if (warnings && !warnings->multiple_intersections.load(std::memory_order_relaxed)) { + // Refinement may have rounded newly inserted points, so use the current bounds. + const BoundingBox refined_bbox(polygon.points); + for (size_t j = current_idx + 1; j < modifier_polygons.size(); ++j) { + if (!refined_bbox.overlap(BoundingBox(modifier_polygons[j].points))) + continue; + if (!intersection(Polygons{polygon}, Polygons{modifier_polygons[j]}).empty()) { + warnings->multiple_intersections.store(true, std::memory_order_relaxed); + break; // one extra intersection is enough to trigger the warning + } + } + } + }; + for (size_t modifier_polygon_idx = 0; modifier_polygon_idx < modifier_polygons.size(); ++modifier_polygon_idx) { + const Polygon &modifier_polygon = modifier_polygons[modifier_polygon_idx]; + if (!perimeter_bbox.overlap(BoundingBox(modifier_polygon.points))) + continue; + // Find intersection with perimeter + Polygons intersection_polygons = intersection(Polygons{polygon}, Polygons{modifier_polygon}); + + // Multiple intersection polygons = modifier crosses perimeter in several places + if (warnings && intersection_polygons.size() > 1) + warnings->multiple_intersections.store(true, std::memory_order_relaxed); + // Diff check: modifier minus perimeter yields >1 polygon = through-body intersection. + // However, if any diff polygon is CW, it is a hole left by full containment + // (modifier fully covers perimeter), not a real through-body case. + // Full containment is detected separately in common_segment_in_intersection(). + if (warnings && !warnings->through_body.load(std::memory_order_relaxed)) { + Polygons diff_polygons = diff(Polygons{modifier_polygon}, Polygons{polygon}); + if (diff_polygons.size() > 1) { + bool has_cw = std::any_of(diff_polygons.begin(), diff_polygons.end(), + [](const Polygon &p) { return p.is_clockwise(); }); + if (!has_cw) + warnings->through_body.store(true, std::memory_order_relaxed); + } + } + + // Process each intersection polygon + for (Polygon &intersection_polygon : intersection_polygons) { + // Convert intersection_polygon to CCW to guarantee same traversal direction as perimeter + intersection_polygon.make_counter_clockwise(); + + // Try to find perimeter segment in this intersection + std::optional segment = common_segment_in_intersection_fast( + intersection_polygon, + polygon, + warnings + ); + + if (!segment.has_value()) { + continue; + } + + // Select target point finder based on modifier type + std::optional> target; + switch (modifier_volume->type()) { + case ModelVolumeType::PRECISE_SEAM_CENTER: target = segment_center(segment.value(), polygon); break; + case ModelVolumeType::PRECISE_SEAM_LEFT: target = segment_left(segment.value()); break; + case ModelVolumeType::PRECISE_SEAM_RIGHT: target = segment_right(segment.value()); break; + default: continue; + } + + if (!target.has_value()) + continue; + + // Insert target point into perimeter with tolerance check + std::optional> result = insert_point_into_perimeter( + target->first, // target_point + target->second, // insert_idx + polygon + ); + + if (!result.has_value()) + continue; + + // Add additional points on both sides to create transition zone. + // +1 must be called before -1: reverse order shifts result->second and breaks insertion. + refine_at_vertex(result->second, +1, polygon); + refine_at_vertex(result->second, -1, polygon); + + check_remaining_polygons(modifier_polygon_idx); + return result->first; + } + } + } + + // No matching segment found or insertion failed + return std::nullopt; +} + +// Convert ModelVolumeType of weak modifier to EnforcedBlockedSeamPoint. +// Precondition: called only with weak precise-seam types (filtered via is_precise_seam_weak()). +// Exhaustive switch (no default) so -Wswitch flags any future PRECISE_SEAM_* additions. +static EnforcedBlockedSeamPoint convert_weak_modifier_type(ModelVolumeType type) { + switch (type) { + case ModelVolumeType::PRECISE_SEAM_ENFORCED: + return EnforcedBlockedSeamPoint::Enforced; + case ModelVolumeType::PRECISE_SEAM_BLOCKED: + return EnforcedBlockedSeamPoint::Blocked; + case ModelVolumeType::PRECISE_SEAM_NEUTRAL: + return EnforcedBlockedSeamPoint::Neutral; + // Non-weak types are unreachable by precondition; listed to keep the switch exhaustive. + case ModelVolumeType::INVALID: + case ModelVolumeType::MODEL_PART: + case ModelVolumeType::NEGATIVE_VOLUME: + case ModelVolumeType::PARAMETER_MODIFIER: + case ModelVolumeType::SUPPORT_BLOCKER: + case ModelVolumeType::SUPPORT_ENFORCER: + case ModelVolumeType::PRECISE_SEAM_CENTER: + case ModelVolumeType::PRECISE_SEAM_LEFT: + case ModelVolumeType::PRECISE_SEAM_RIGHT: + break; + } + assert(false && "convert_weak_modifier_type called with non-weak type"); + return EnforcedBlockedSeamPoint::Neutral; +} + +// Collect all weak modifier segments for given perimeter +// Process weak modifiers (ENFORCED/BLOCKED/NEUTRAL) and collect segment boundaries +// Also insert boundary points into perimeter polygon (sorted by descending arc length) +// Split enforced edges into small segments (≤ enforcer_oversampling_distance) for precise seam placement +// Return ordered vector of segments with updated coordinates (in same order as weak_volumes list) +std::vector collect_weak_modifier_segments( + const std::vector &weak_volumes, + Polygon &polygon, + const Layer *layer, + const ModifierSlicesCache &slices_cache, + PreciseSeamWarnings* warnings) +{ + std::vector result; + + // Check input parameters + if (weak_volumes.empty() || layer == nullptr) { + return result; // Empty vector + } + + // layer->id() is offset by raft layer count, but modifier_slices is 0-based + // (built from PrintObject::layers() via slice_single_volume). Subtract raft + // offset to get the correct index into the cache. + const size_t raft_layers = layer->object()->slicing_parameters().raft_layers(); + size_t layer_id = layer->id() - raft_layers; + + // The perimeter is not modified until all weak segments have been collected. + const BoundingBox perimeter_bbox(polygon.points); + + // Iterate through all weak modifiers in hierarchy order + for (const ModelVolume* modifier_volume : weak_volumes) { + // Look up pre-sliced polygons from cache (sliced once in SeamPlacer::init). + // TODO: slice_single_volume() converts ExPolygons to flat Polygons, losing + // the association between outer contours and their holes. This makes correct + // handling of multiply-connected modifier regions (e.g. a torus cross-section) + // impossible. Consider a variant returning std::vector and adapting + // the algorithm to work with multiply-connected domains. + auto it = slices_cache.find(modifier_volume); + if (it == slices_cache.end()) + continue; // modifier not in cache (should not happen) + const std::vector &modifier_slices = it->second; + + // Check if slices exist for given layer + if (layer_id >= modifier_slices.size()) { + continue; // No slices for this layer + } + + const Polygons &modifier_polygons = modifier_slices[layer_id]; + + // Check for multiply-connected regions (holes = CW polygons). + // slice_single_volume() flattens ExPolygons into Polygons, but preserves + // orientation: CCW = outer contour, CW = hole. If any CW polygon is present, + // the modifier is multiply-connected and cannot be processed correctly. + bool has_holes = std::any_of(modifier_polygons.begin(), modifier_polygons.end(), + [](const Polygon &p) { return p.is_clockwise(); }); + if (has_holes) { + if (warnings) + warnings->multiply_connected.store(true, std::memory_order_relaxed); + continue; + } + + // Iterate through all modifier polygons on this layer + for (const Polygon &modifier_polygon : modifier_polygons) { + // Preserve touching and contained pairs for the existing clipping and warning logic. + if (!perimeter_bbox.overlap(BoundingBox(modifier_polygon.points))) + continue; + // Find intersection with perimeter + Polygons intersection_polygons = intersection(Polygons{polygon}, Polygons{modifier_polygon}); + + // Note: intersection_polygons.size() > 1 is NOT flagged as a warning here. + // For weak modifiers, multiple intersection polygons are expected (the modifier + // may legitimately cross the perimeter in several places). + // Only through-body intersections (detected by diff below) are abnormal. + + // Diff check: if modifier minus perimeter yields >1 polygon, the modifier + // passes through the model body, creating a through-body intersection. + // CW polygon in diff = hole from full containment, not through-body. + // Full containment is detected separately in common_segment_in_intersection(). + if (warnings && !warnings->through_body.load(std::memory_order_relaxed)) { + Polygons diff_polygons = diff(Polygons{modifier_polygon}, Polygons{polygon}); + if (diff_polygons.size() > 1) { + bool has_cw = std::any_of(diff_polygons.begin(), diff_polygons.end(), + [](const Polygon &p) { return p.is_clockwise(); }); + if (!has_cw) + warnings->through_body.store(true, std::memory_order_relaxed); + } + } + + // Process each intersection polygon + for (Polygon &intersection_polygon : intersection_polygons) { + // Convert intersection_polygon to CCW to guarantee same traversal direction as perimeter + intersection_polygon.make_counter_clockwise(); + + // Search for perimeter segment in this intersection + std::optional segment = common_segment_in_intersection_fast( + intersection_polygon, + polygon, + warnings + ); + + if (!segment.has_value()) { + continue; // Segment not found + } + + // Get left (first) point of segment + std::optional> left = segment_left(segment.value()); + + // Get right (last) point of segment + std::optional> right = segment_right(segment.value()); + + // If both boundaries found, add segment to result + if (left.has_value() && right.has_value()) { + result.push_back({ + convert_weak_modifier_type(modifier_volume->type()), // Type: Enforced/Blocked/Neutral + left->first, // Left point coordinates + left->second, // Perimeter vertex index for left point + right->first, // Right point coordinates + right->second // Perimeter vertex index for right point + }); + } + } + } + } + + // If no segments, return empty vector + if (result.empty()) { + return result; + } + + // Insert boundary points into perimeter polygon + // Sort points by descending arc length to avoid breaking indexing + + // 1. Parameterize polygon: calculate cumulative lengths for each vertex + std::vector cumulative_lengths(polygon.points.size() + 1); + cumulative_lengths[0] = 0.0; + for (size_t i = 0; i < polygon.points.size(); ++i) { + size_t next_i = (i + 1) % polygon.points.size(); + double edge_length = (polygon.points[next_i] - polygon.points[i]).cast().norm(); + cumulative_lengths[i + 1] = cumulative_lengths[i] + edge_length; + } + + // 2. Create helper vector for sorting: {segment index, left/right point, arc length} + struct PointToInsert { + size_t segment_idx; // Index in result + bool is_left; // true = left point, false = right point + double arc_length; // Arc length from perimeter start + }; + std::vector points_to_insert; + points_to_insert.reserve(result.size() * 2); + + for (size_t seg_idx = 0; seg_idx < result.size(); ++seg_idx) { + const WeakModifierSegment &seg = result[seg_idx]; + + // Left point + double left_base = cumulative_lengths[seg.left_idx]; + double left_offset = (seg.left_point - polygon.points[seg.left_idx]).cast().norm(); + points_to_insert.push_back({seg_idx, true, left_base + left_offset}); + + // Right point + double right_base = cumulative_lengths[seg.right_idx]; + double right_offset = (seg.right_point - polygon.points[seg.right_idx]).cast().norm(); + points_to_insert.push_back({seg_idx, false, right_base + right_offset}); + } + + // 3. Sort by descending arc length (insert distant points first) + std::sort(points_to_insert.begin(), points_to_insert.end(), + [](const PointToInsert &a, const PointToInsert &b) { + return a.arc_length > b.arc_length; + }); + + // 4. Insert points in descending arc length order + std::vector segment_valid(result.size(), true); + bool any_insertion_failed = false; + + for (const PointToInsert &pt : points_to_insert) { + WeakModifierSegment &seg = result[pt.segment_idx]; + Point &point_coords = pt.is_left ? seg.left_point : seg.right_point; + size_t edge_idx = pt.is_left ? seg.left_idx : seg.right_idx; + + // Insert point with tolerance check + std::optional> insert_result = + insert_point_into_perimeter(point_coords, edge_idx, polygon); + + if (insert_result.has_value()) { + // Update coordinates in result (if point coincided with existing vertex, take its coordinates) + point_coords = insert_result->first; + } else { + // Failed to insert point - segment becomes invalid + segment_valid[pt.segment_idx] = false; + any_insertion_failed = true; + } + } + + // 5. Remove segments whose boundaries could not be inserted + if (any_insertion_failed) { + // Critical error: boundary point not inserted (shouldn't happen in normal conditions) + BOOST_LOG_TRIVIAL(error) << "PreciseSeam: boundary point insertion failed, performing segment compaction"; + + // Remove invalid segments (array compaction) + size_t write_pos = 0; + for (size_t read_pos = 0; read_pos < result.size(); ++read_pos) { + if (segment_valid[read_pos]) { + if (write_pos != read_pos) { + result[write_pos] = std::move(result[read_pos]); + } + ++write_pos; + } + } + result.resize(write_pos); + } + + // 6. Group coincident boundaries by their snapped vertex, including wraparound to vertex 0. + // Scan original vertices backwards so insertions cannot shift pending vertex indices. + // O(vertices * segments), matching the boundary lookup below; typically only a few segments. + for (size_t poly_idx = polygon.size(); poly_idx-- > 0; ) { + bool refine_before = false; + bool refine_after = false; + for (const WeakModifierSegment &segment : result) { + refine_before |= polygon[poly_idx] == segment.left_point; + refine_after |= polygon[poly_idx] == segment.right_point; + } + // Insert after first: inserting before would shift the current vertex index. + if (refine_after) + refine_at_vertex(poly_idx, +1, polygon); + if (refine_before) + refine_at_vertex(poly_idx, -1, polygon); + } + + // 7. Split edges in enforced zones into segments ≤ enforcer_oversampling_distance + // Determine type pattern for each polygon edge (sequential application of hierarchy) + std::vector edge_types(polygon.size(), EnforcedBlockedSeamPoint::Neutral); + + // Helper lambda: search for point index in modified polygon by coordinates. + // Linear scan is intentional — O(N×M) is acceptable for typical M ≤ 5 weak segments. + auto find_point_index = [&](const Point &pt) -> std::optional { + for (size_t i = 0; i < polygon.size(); ++i) { + if (polygon[i] == pt) return i; + } + return std::nullopt; + }; + + // Apply types sequentially: segments are sorted low-priority-first + // (bottom of object tree first), so higher-priority modifiers overwrite + // lower-priority ones via last-write-wins. + for (const auto &segment : result) { + // Find boundary point indices in modified polygon + std::optional left_idx = find_point_index(segment.left_point); + std::optional right_idx = find_point_index(segment.right_point); + + if (!left_idx.has_value() || !right_idx.has_value()) { + BOOST_LOG_TRIVIAL(error) << "PreciseSeam: boundary point not found in modified polygon, skipping segment"; + continue; + } + + // Set type for edges [left_idx, right_idx] + for (size_t idx = left_idx.value(); ; idx = (idx + 1) % polygon.size()) { + edge_types[idx] = segment.type; + if (idx == right_idx.value()) break; + } + } + + // Split enforced edges into small segments + const double STEP_SCALED = scale_(SeamPlacer::enforcer_oversampling_distance); + + // Collect new list of polygon points with split enforced edges + Points new_points; + new_points.reserve(polygon.size() * 10); // Approximate estimate + + for (size_t i = 0; i < polygon.size(); ++i) { + size_t next_i = (i + 1) % polygon.size(); + Point p_start = polygon[i]; + Point p_end = polygon[next_i]; + + // Add current vertex + new_points.push_back(p_start); + + // Check edge type [i, next_i] + if (edge_types[i] != EnforcedBlockedSeamPoint::Enforced) { + continue; // Not enforced - don't split + } + + // Calculate subdivision parameters + Vec2d edge_vec = (p_end - p_start).cast(); + double edge_length = edge_vec.norm(); + + if (edge_length <= STEP_SCALED) { + continue; // Edge too short - don't split + } + + // Number of segments: ceil(L / S) + size_t num_segments = static_cast(std::ceil(edge_length / STEP_SCALED)); + + // Uniform step: L / num_segments (each segment ≤ S) + double actual_step = edge_length / num_segments; + Vec2d step_vec = edge_vec.normalized() * actual_step; + + // Add intermediate points incrementally + Vec2d current_pos = p_start.cast(); + for (size_t j = 1; j < num_segments; ++j) { + current_pos += step_vec; + new_points.push_back(current_pos.cast()); + } + } + + // Replace polygon points with new ones (with split enforced edges) + polygon.points = std::move(new_points); + + return result; +} + +// Apply weak modifier types to perimeter points based on segment boundaries. +// Find boundary points in refined polygon by coordinates and set types +// for all points inside each segment. +void apply_weak_modifiers_to_perimeter( + const std::vector &weak_segments, + PrintObjectSeamData::LayerSeams &result, + const SeamPlacerImpl::Perimeter &perimeter, + bool &some_point_enforced) +{ + // Get z-coordinate for unscaling boundary points + const float z_coord = result.points[perimeter.start_index].position.z(); + const size_t perimeter_size = perimeter.end_index - perimeter.start_index; + + // Helper lambda: search for point index in result.points by unscaled coordinates. + // Linear scan is intentional — O(N×M) is acceptable for typical M ≤ 5 weak segments. + auto find_point_index = [&](const Point &pt) -> std::optional { + Vec2f unscaled_pt = unscale(pt).cast(); + Vec3f target(unscaled_pt.x(), unscaled_pt.y(), z_coord); + for (size_t i = perimeter.start_index; i < perimeter.end_index; ++i) { + if (result.points[i].position == target) return i - perimeter.start_index; + } + return std::nullopt; + }; + + // Apply weak modifiers sequentially: sorted low-priority-first, + // so higher-priority modifiers (higher in object tree) overwrite via last-write-wins. + for (size_t seg_idx = 0; seg_idx < weak_segments.size(); ++seg_idx) { + const auto &segment = weak_segments[seg_idx]; + + // Find boundary point indices in result.points + std::optional left_idx = find_point_index(segment.left_point); + std::optional right_idx = find_point_index(segment.right_point); + + if (!left_idx.has_value() || !right_idx.has_value()) { + BOOST_LOG_TRIVIAL(error) << "PreciseSeam: boundary point not found in perimeter, skipping segment"; + continue; + } + + // Apply modifier type to range [left_idx, right_idx] with wraparound + for (size_t idx = left_idx.value(); ; idx = (idx + 1) % perimeter_size) { + result.points[perimeter.start_index + idx].type = segment.type; + if (idx == right_idx.value()) break; + } + + if (segment.type == EnforcedBlockedSeamPoint::Enforced) { + some_point_enforced = true; + } + } +} + +// Restore precise seam positions that may have been modified +void restore_precise_seam_positions(std::vector &layers) { + using SeamPlacerImpl::SeamCandidate; + using SeamPlacerImpl::Perimeter; + + tbb::parallel_for(tbb::blocked_range(0, layers.size()), + [&layers](tbb::blocked_range r) { + for (size_t layer_idx = r.begin(); layer_idx < r.end(); ++layer_idx) { + std::vector &layer_perimeter_points = layers[layer_idx].points; + // Iterate over perimeters (jump by end_index) + for (size_t current = 0; current < layer_perimeter_points.size(); + current = layer_perimeter_points[current].perimeter.end_index) { + Perimeter &perimeter = layer_perimeter_points[current].perimeter; + if (perimeter.precise_seam_point.has_value()) { + perimeter.final_seam_position = perimeter.precise_seam_point.value(); + perimeter.seam_index = perimeter.precise_seam_index; + } + } + } + }); +} + +} // namespace PreciseSeam +} // namespace Slic3r diff --git a/src/libslic3r/GCode/PreciseSeam.hpp b/src/libslic3r/GCode/PreciseSeam.hpp new file mode 100644 index 0000000000..d5c1e98952 --- /dev/null +++ b/src/libslic3r/GCode/PreciseSeam.hpp @@ -0,0 +1,146 @@ +#ifndef slic3r_PreciseSeam_hpp_ +#define slic3r_PreciseSeam_hpp_ + +#include +#include +#include +#include +#include "libslic3r/Polygon.hpp" +#include "libslic3r/Polyline.hpp" +#include "libslic3r/Model.hpp" +#include "libslic3r/Layer.hpp" +#include "libslic3r/Print.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "SeamPlacer.hpp" + +// CURRENT STATUS: +// Strong modifiers (Center/Left/Right): only one intersection per perimeter is supported, +// since there can be only one seam. Additional intersections are ignored. +// +// Weak modifiers (Enforced/Blocked/Neutral): multiple intersections are supported, +// but none of them should pass through the model entirely. A through-body intersection +// produces multiple segments, of which only one will be processed. +// +// In both cases, a pop-up warning is shown when unsupported intersections are detected. +// +// If any modifier has a multiply-connected cross-section (e.g. a hollow shape), +// it is skipped and a corresponding notification is shown. +// +// Full containment of the perimeter within modifier is not handled. +// +// FUTURE DIRECTION: +// A lightweight algorithm is needed to detect and handle through-body intersections +// for Weak modifiers. The algorithm must not slow down the 99.9% common case. +// Possible approach: if intersection passes the diff check (no through-body), +// use the current fast algorithm. If diff check fails, fall back to a heavier +// method: compute midpoints of intersection polygon edges, then check which +// midpoints lie strictly inside the modifier (not on boundary) using +// point_in_polygon. Those edges originate from the perimeter; the rest +// originate from the modifier boundary. Collect perimeter edges into a polyline. +// Additionally, multiply-connected cross-sections could be supported instead of +// being skipped entirely (e.g. by decomposing them into simple polygons). +// For Enforced and Neutral weak modifiers, full containment of the perimeter +// within modifier could be handled (currently ignored). + +namespace Slic3r { +namespace PreciseSeam { + +// Import EnforcedBlockedSeamPoint from SeamPlacerImpl namespace for convenience +using SeamPlacerImpl::EnforcedBlockedSeamPoint; + +// Pre-sliced modifier cache: ModelVolume pointer → per-layer Polygons. +// Built once in SeamPlacer::init(), then passed read-only into per-perimeter functions. +using ModifierSlicesCache = std::unordered_map>; + +// Warning flags set during Precise Seam processing (thread-safe) +struct PreciseSeamWarnings { + std::atomic multiple_intersections{false}; // modifier intersects perimeter in multiple separate places (strong only) + std::atomic through_body{false}; // modifier passes through the model body entirely + std::atomic multiply_connected{false}; // modifier has holes (multiply-connected cross-section) + std::atomic full_containment{false}; // modifier fully contains perimeter, no intersection edges +}; + +// Result of finding common segment between perimeter and intersection +struct SegmentData { + Polyline segment; // Points from intersection_polygon forming the segment + std::vector perimeter_edge_indices; // edge_index for each point in segment +}; + +// Result of weak modifier segment processing +struct WeakModifierSegment { + EnforcedBlockedSeamPoint type; // Enforced/Blocked/Neutral + Point left_point; // Coordinates of left (first) point of segment + size_t left_idx; // Perimeter vertex index for left_point + Point right_point; // Coordinates of right (last) point of segment + size_t right_idx; // Perimeter vertex index for right_point +}; + +// Initialize Precise Seam data by populating provided vectors and flag +// Collects precise seam modifiers and fills output parameters +// Call once during SeamPlacer::init() before gather_seam_candidates() +// Parameters: +// strong_volumes_out - output vector for strong modifiers (CENTER/LEFT/RIGHT) +// weak_volumes_out - output vector for weak modifiers (ENFORCED/BLOCKED/NEUTRAL) +// has_strong_out - output flag indicating presence of strong modifiers +// model_object - model object containing volumes +void init_precise_seam_data( + std::vector& strong_volumes_out, + std::vector& weak_volumes_out, + bool& has_strong_out, + const ModelObject* model_object); + +// Insert strong seam point into perimeter polygon +// Processes strong modifiers (CENTER/LEFT/RIGHT) and inserts seam point into polygon +// Parameters: +// strong_volumes - list of strong precise seam modifiers +// polygon - perimeter polygon (will be modified if point inserted) +// layer - current layer +// slices_cache - pre-sliced modifier polygons (built once in SeamPlacer::init) +// Returns: +// Coordinates of inserted point (internal units) or std::nullopt if nothing inserted +std::optional insert_strong_seam_point( + const std::vector &strong_volumes, + Polygon &polygon, + const Layer *layer, + const ModifierSlicesCache &slices_cache, + PreciseSeamWarnings* warnings = nullptr); + +// Collect all weak modifier segments for a perimeter polygon +// Processes weak modifiers (ENFORCED/BLOCKED/NEUTRAL) and collects segment boundaries +// Also inserts boundary points into the perimeter polygon (sorted by descending arc length) +// Refines enforced edges by subdividing them into segments ≤ enforcer_oversampling_distance +// Parameters: +// weak_volumes - list of weak precise seam modifiers +// polygon - perimeter polygon (will be modified with inserted points and refined edges) +// layer - current layer +// slices_cache - pre-sliced modifier polygons (built once in SeamPlacer::init) +// Returns: +// Ordered vector of segments with updated coordinates (same order as weak_volumes list) +std::vector collect_weak_modifier_segments( + const std::vector &weak_volumes, + Polygon &polygon, + const Layer *layer, + const ModifierSlicesCache &slices_cache, + PreciseSeamWarnings* warnings = nullptr); + +// Apply weak modifier types to perimeter points based on segment boundaries +// Finds boundary points in refined polygon and sets types for points within segments +// Parameters: +// weak_segments - segments with boundary coordinates and types +// result - layer seams data to modify +// perimeter - perimeter info (start/end indices) +// some_point_enforced - flag to update if Enforced points are set +void apply_weak_modifiers_to_perimeter( + const std::vector &weak_segments, + PrintObjectSeamData::LayerSeams &result, + const SeamPlacerImpl::Perimeter &perimeter, + bool &some_point_enforced); + +// Restore precise seam positions that may have been modified by alignment +// Iterates through all perimeters and restores precise_seam_point positions +void restore_precise_seam_positions(std::vector &layers); + +} // namespace PreciseSeam +} // namespace Slic3r + +#endif // slic3r_PreciseSeam_hpp_ diff --git a/src/libslic3r/GCode/SeamPlacer.cpp b/src/libslic3r/GCode/SeamPlacer.cpp index 35bc436d5a..d527ec6aa9 100644 --- a/src/libslic3r/GCode/SeamPlacer.cpp +++ b/src/libslic3r/GCode/SeamPlacer.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include "libslic3r/AABBTreeLines.hpp" #include "libslic3r/KDTreeIndirect.hpp" @@ -19,10 +20,12 @@ #include "libslic3r/Layer.hpp" #include "libslic3r/Geometry/Curves.hpp" +#include "libslic3r/I18N.hpp" #include "libslic3r/ShortEdgeCollapse.hpp" #include "libslic3r/TriangleSetSampling.hpp" #include "libslic3r/Utils.hpp" +#include "PreciseSeam.hpp" //#define DEBUG_FILES @@ -303,6 +306,16 @@ struct GlobalModelInfo { AABBTreeIndirect::Tree<3, float> enforcers_tree; AABBTreeIndirect::Tree<3, float> blockers_tree; + // Precise Seam modifiers: strong modifiers (CENTER/LEFT/RIGHT) determine exact seam placement + std::vector precise_seam_strong_volumes; + // Precise Seam modifiers: weak modifiers (ENFORCED/BLOCKED/NEUTRAL) provide hints for seam placement + std::vector precise_seam_weak_volumes; + + // Pre-sliced modifier polygons, keyed by ModelVolume pointer. + // Populated once in SeamPlacer::init() to avoid re-slicing on every perimeter. + // Each value is a per-layer vector of Polygons for that modifier volume. + std::unordered_map> precise_seam_slices; + bool is_enforced(const Vec3f &position, float radius) const { if (enforcers.empty()) { return false; @@ -403,7 +416,8 @@ struct GlobalModelInfo { ; //Extract perimeter polygons of the given layer -Polygons extract_perimeter_polygons(const Layer *layer, std::vector &corresponding_regions_out) { +Polygons extract_perimeter_polygons(const Layer *layer, std::vector &corresponding_regions_out, + bool has_precise_seam_modifiers) { Polygons polygons; for (const LayerRegion *layer_region : layer->regions()) { for (const ExtrusionEntity *ex_entity : layer_region->perimeters.entities) { @@ -440,6 +454,18 @@ Polygons extract_perimeter_polygons(const Layer *layer, std::vector 1 && polygon.points.front() == polygon.points.back()) + polygon.points.pop_back(); + } + } + if (polygons.empty()) { // If there are no perimeter polygons for whatever reason (disabled perimeters .. ) insert dummy point // it is easier than checking everywhere if the layer is not emtpy, no seam will be placed to this layer anyway polygons.emplace_back(Points{ { 0, 0 } }); @@ -449,17 +475,42 @@ Polygons extract_perimeter_polygons(const Layer *layer, std::vectorlayer() : nullptr; + + // Use pre-computed Precise Seam volumes from global_model_info (computed once in init) + const auto& strong_volumes = global_model_info.precise_seam_strong_volumes; + const auto& weak_volumes = global_model_info.precise_seam_weak_volumes; + + // Use pre-sliced cache from global_model_info instead of re-slicing on every call + auto seam_point = PreciseSeam::insert_strong_seam_point(strong_volumes, polygon, layer, global_model_info.precise_seam_slices, warnings); + + // Store the inserted point position for marking as central_enforcer later + std::optional inserted_seam_position; + if (seam_point.has_value()) { + Vec2f unscaled_p = unscale(seam_point.value()).cast(); + inserted_seam_position = Vec3f(unscaled_p.x(), unscaled_p.y(), z_coord); + } + + // Process weak modifiers (ENFORCED/BLOCKED/NEUTRAL) only if no strong modifier was inserted + std::vector weak_segments; + if (!inserted_seam_position.has_value()) { + weak_segments = PreciseSeam::collect_weak_modifier_segments(weak_volumes, polygon, layer, global_model_info.precise_seam_slices, warnings); + } + float angle_arm_len = region != nullptr ? region->flow(FlowRole::frExternalPerimeter).nozzle_diameter() : 0.5f; std::vector lengths { }; @@ -528,10 +579,15 @@ void process_perimeter_polygon(const Polygon &orig_polygon, float z_coord, const perimeter.end_index = result.points.size(); + // Apply weak modifiers if no strong modifier was inserted + if (!inserted_seam_position.has_value() && !weak_segments.empty()) { + PreciseSeam::apply_weak_modifiers_to_perimeter( + weak_segments, result, perimeter, some_point_enforced); + } + if (some_point_enforced) { - // We will patches of enforced points (patch: continuous section of enforced points), choose - // the longest patch, and select the middle point or sharp point (depending on the angle) - // this point will have high priority on this perimeter + // Choose the continuous enforced patch with the most candidates, then select its middle + // candidate or a sharp corner. Patch length here is a point count, not geometric distance. size_t perimeter_size = perimeter.end_index - perimeter.start_index; const auto next_index = [&](size_t idx) { return perimeter.start_index + Slic3r::next_idx_modulo(idx - perimeter.start_index, perimeter_size); @@ -562,7 +618,9 @@ void process_perimeter_polygon(const Polygon &orig_polygon, float z_coord, const std::pair longest_patch { 0, 0 }; auto patch_len = [perimeter_size](const std::pair &start_end) { if (start_end.second < start_end.first) { - return start_end.first + (perimeter_size - start_end.second); + // Count [start, end) across the closing edge, independently of the contour's start. + // Subtract indices first: they are offsets in the layer, not local perimeter indices. + return perimeter_size - (start_end.first - start_end.second); } else { return start_end.second - start_end.first; } @@ -595,30 +653,24 @@ void process_perimeter_polygon(const Polygon &orig_polygon, float z_coord, const } } -} - -// Get index of previous and next perimeter point of the layer. Because SeamCandidates of all polygons of the given layer -// are sequentially stored in the vector, each perimeter contains info about start and end index. These vales are used to -// deduce index of previous and next neigbour in the corresponding perimeter. -std::pair find_previous_and_next_perimeter_point(const std::vector &perimeter_points, - size_t point_index) { - const SeamCandidate ¤t = perimeter_points[point_index]; - int prev = point_index - 1; //for majority of points, it is true that neighbours lie behind and in front of them in the vector - int next = point_index + 1; - - if (point_index == current.perimeter.start_index) { - // if point_index is equal to start, it means that the previous neighbour is at the end - prev = current.perimeter.end_index; + // Apply precise seam point if it was inserted + if (inserted_seam_position.has_value()) { + // Set single point as Enforced, block all others + for (size_t i = perimeter.start_index; i < perimeter.end_index; ++i) { + if (result.points[i].position == inserted_seam_position.value()) { + // Mark as the single enforced point with highest priority + result.points[i].type = EnforcedBlockedSeamPoint::Enforced; + result.points[i].central_enforcer = true; + perimeter.precise_seam_point = inserted_seam_position; + perimeter.precise_seam_index = i; + } else { + // Block all other points + result.points[i].type = EnforcedBlockedSeamPoint::Blocked; + result.points[i].central_enforcer = false; + } + } } - if (point_index == current.perimeter.end_index - 1) { - // if point_index is equal to end, than next neighbour is at the start - next = current.perimeter.start_index; - } - - assert(prev >= 0); - assert(next >= 0); - return {size_t(prev),size_t(next)}; } // Computes all global model info - transforms object, performs raycasting @@ -636,7 +688,9 @@ void compute_global_occlusion(GlobalModelInfo &result, const PrintObject *po, || model_volume->type() == ModelVolumeType::NEGATIVE_VOLUME) { auto model_transformation = model_volume->get_matrix(); indexed_triangle_set model_its = model_volume->mesh().its; - // ORCA: Mirrored transforms flip winding, keep normals outward + // ORCA fix (not related to Precise Seam, discovered during its development): + // Mirror transforms have negative determinant which flips triangle winding. + // fix_left_handed=true swaps indices to keep normals pointing outward. its_transform(model_its, model_transformation, true); if (model_volume->type() == ModelVolumeType::MODEL_PART) { its_merge(triangle_set, model_its); @@ -657,7 +711,10 @@ void compute_global_occlusion(GlobalModelInfo &result, const PrintObject *po, size_t negative_volumes_start_index = triangle_set.indices.size(); its_merge(triangle_set, negative_volumes_set); - // ORCA: Mirroring flips normals, keep them outward for visibility sampling + // ORCA fix (not related to Precise Seam, discovered during its development): + // Object-level transform may include mirroring (negative determinant), + // which inverts triangle winding. fix_left_handed=true corrects this + // so visibility ray sampling sees outward-facing normals. its_transform(triangle_set, obj_transform, true); BOOST_LOG_TRIVIAL(debug) << "SeamPlacer: decimate: end"; @@ -719,12 +776,12 @@ void gather_enforcers_blockers(GlobalModelInfo &result, const PrintObject *po) { auto model_transformation = obj_transform * mv->get_matrix(); indexed_triangle_set enforcers = mv->seam_facets.get_facets(*mv, EnforcerBlockerType::ENFORCER); - // ORCA: Keep normals outward when mirroring seam enforcers + // ORCA fix (not related to Precise Seam): fix winding for mirrored transforms its_transform(enforcers, model_transformation, true); its_merge(result.enforcers, enforcers); indexed_triangle_set blockers = mv->seam_facets.get_facets(*mv, EnforcerBlockerType::BLOCKER); - // ORCA: Keep normals outward when mirroring seam blockers + // ORCA fix (not related to Precise Seam): fix winding for mirrored transforms its_transform(blockers, model_transformation, true); its_merge(result.blockers, blockers); } @@ -1011,13 +1068,14 @@ void pick_random_seam_point(const std::vector &perimeter_points, // Parallel process and extract each perimeter polygon of the given print object. // Gather SeamCandidates of each layer into vector and build KDtree over them // Store results in the SeamPlacer variables m_seam_per_object -void SeamPlacer::gather_seam_candidates(const PrintObject *po, const SeamPlacerImpl::GlobalModelInfo &global_model_info) { +void SeamPlacer::gather_seam_candidates(const PrintObject *po, const SeamPlacerImpl::GlobalModelInfo &global_model_info, + PreciseSeam::PreciseSeamWarnings* warnings) { using namespace SeamPlacerImpl; PrintObjectSeamData &seam_data = m_seam_per_object.emplace(po, PrintObjectSeamData { }).first->second; seam_data.layers.resize(po->layer_count()); tbb::parallel_for(tbb::blocked_range(0, po->layers().size()), - [po, &global_model_info, &seam_data] + [po, &global_model_info, &seam_data, warnings] (tbb::blocked_range r) { for (size_t layer_idx = r.begin(); layer_idx < r.end(); ++layer_idx) { PrintObjectSeamData::LayerSeams &layer_seams = seam_data.layers[layer_idx]; @@ -1025,10 +1083,13 @@ void SeamPlacer::gather_seam_candidates(const PrintObject *po, const SeamPlacerI auto unscaled_z = layer->slice_z; std::vector regions; //NOTE corresponding region ptr may be null, if the layer has zero perimeters - Polygons polygons = extract_perimeter_polygons(layer, regions); + const bool has_precise_seam_modifiers = !global_model_info.precise_seam_strong_volumes.empty() || + !global_model_info.precise_seam_weak_volumes.empty(); + Polygons polygons = extract_perimeter_polygons(layer, regions, has_precise_seam_modifiers); for (size_t poly_index = 0; poly_index < polygons.size(); ++poly_index) { process_perimeter_polygon(polygons[poly_index], unscaled_z, - regions[poly_index], global_model_info, layer_seams); + regions[poly_index], global_model_info, layer_seams, + warnings); } auto functor = SeamCandidateCoordinateFunctor { layer_seams.points }; seam_data.layers[layer_idx].points_tree = @@ -1424,10 +1485,13 @@ void SeamPlacer::align_seam_points(const PrintObject *po, const SeamPlacerImpl:: } -void SeamPlacer::init(const Print &print, std::function throw_if_canceled_func) { +void SeamPlacer::init(Print &print, std::function throw_if_canceled_func) { using namespace SeamPlacerImpl; m_seam_per_object.clear(); + // Warning flags for Precise Seam processing — shared across all objects + PreciseSeam::PreciseSeamWarnings precise_seam_warnings; + for (const PrintObject *po : print.objects()) { throw_if_canceled_func(); SeamPosition configured_seam_preference = po->config().seam_position.value; @@ -1436,14 +1500,29 @@ void SeamPlacer::init(const Print &print, std::function throw_if_can { GlobalModelInfo global_model_info { }; gather_enforcers_blockers(global_model_info, po); + PreciseSeam::init_precise_seam_data( + global_model_info.precise_seam_strong_volumes, + global_model_info.precise_seam_weak_volumes, + m_seam_per_object[po].has_precise_seam_strong_volumes, + po->model_object()); + + // Pre-slice all precise seam modifier volumes once per object. + // Without this cache, slice_single_volume() would be called for every + // modifier × every perimeter × every layer — thousands of redundant slicing operations. + for (const ModelVolume* vol : global_model_info.precise_seam_strong_volumes) + global_model_info.precise_seam_slices[vol] = po->slice_single_volume(vol); + for (const ModelVolume* vol : global_model_info.precise_seam_weak_volumes) + global_model_info.precise_seam_slices[vol] = po->slice_single_volume(vol); + throw_if_canceled_func(); if (configured_seam_preference == spAligned || configured_seam_preference == spNearest || configured_seam_preference == spAlignedBack) { compute_global_occlusion(global_model_info, po, throw_if_canceled_func, configured_seam_preference); } throw_if_canceled_func(); + BOOST_LOG_TRIVIAL(debug) << "SeamPlacer: gather_seam_candidates: start"; - gather_seam_candidates(po, global_model_info); + gather_seam_candidates(po, global_model_info, &precise_seam_warnings); BOOST_LOG_TRIVIAL(debug) << "SeamPlacer: gather_seam_candidates: end"; throw_if_canceled_func(); @@ -1491,10 +1570,49 @@ void SeamPlacer::init(const Print &print, std::function throw_if_can << "SeamPlacer: align_seam_points : end"; } + // Restore precise seam positions that were potentially modified + if (m_seam_per_object[po].has_precise_seam_strong_volumes) { + PreciseSeam::restore_precise_seam_positions(m_seam_per_object[po].layers); + } + #ifdef DEBUG_FILES debug_export_points(m_seam_per_object[po].layers, po->bounding_box(), comparator); #endif } + + // Show Precise Seam warnings (once for all objects). + // Only ONE active_step_add_warning() call — multiple calls generate multiple UI events, + // each re-pushing ALL current warnings via Plater handler, causing NotificationManager::append() + // to duplicate text within each popup. + { + const bool mi = precise_seam_warnings.multiple_intersections.load(std::memory_order_relaxed); + const bool tb = precise_seam_warnings.through_body.load(std::memory_order_relaxed); + const bool mc = precise_seam_warnings.multiply_connected.load(std::memory_order_relaxed); + const bool fc = precise_seam_warnings.full_containment.load(std::memory_order_relaxed); + std::vector parts; + if (mi) + parts.push_back(_u8L("multiple intersections with a perimeter detected")); + if (tb) + parts.push_back(_u8L("modifier fully crosses the printable perimeter")); + if (mc) + parts.push_back(_u8L("modifier shape is not solid (has holes inside) and was ignored")); + if (fc) + parts.push_back(_u8L("perimeter is fully contained inside modifier and was ignored")); + if (!parts.empty()) { + // One line: the export warnings dialog shows only the first line of each warning. + std::string warning_text = _u8L("Precise Seam") + ": "; + for (size_t i = 0; i < parts.size(); ++i) { + if (i > 0) warning_text += "; "; + warning_text += parts[i]; + } + warning_text += ". "; + warning_text += _u8L("Seam placement may differ from expected."); + print.active_step_add_warning( + PrintStateBase::WarningLevel::NON_CRITICAL, + warning_text, + PrintStateBase::SlicingPreciseSeamWarning); + } + } } void SeamPlacer::place_seam(const Layer *layer, ExtrusionLoop &loop, diff --git a/src/libslic3r/GCode/SeamPlacer.hpp b/src/libslic3r/GCode/SeamPlacer.hpp index 8a973d0d8a..9855b4f4aa 100644 --- a/src/libslic3r/GCode/SeamPlacer.hpp +++ b/src/libslic3r/GCode/SeamPlacer.hpp @@ -18,6 +18,7 @@ namespace Slic3r { class PrintObject; +namespace PreciseSeam { struct PreciseSeamWarnings; } class ExtrusionLoop; class Print; class Layer; @@ -41,7 +42,7 @@ enum class EnforcedBlockedSeamPoint { // struct representing single perimeter loop struct Perimeter { size_t start_index{}; - size_t end_index{}; //inclusive! + size_t end_index{}; //exclusive (one-past-the-end) size_t seam_index{}; float flow_width{}; @@ -50,6 +51,10 @@ struct Perimeter { // Random position also uses this flexibility to set final seam point position bool finalized = false; Vec3f final_seam_position = Vec3f::Zero(); + + // Stores precise seam coordinates found by Precise Seam modifiers + std::optional precise_seam_point; + size_t precise_seam_index{}; }; //Struct over which all processing of perimeters is done. For each perimeter point, its respective candidate is created, @@ -102,6 +107,9 @@ struct PrintObjectSeamData // Map of PrintObjects (PO) -> vector of layers of PO -> unique_ptr to KD // tree of all points of the given layer + // Indicates presence of strong Precise Seam modifiers (CENTER/LEFT/RIGHT) for this object + bool has_precise_seam_strong_volumes = false; + void clear() { layers.clear(); @@ -141,11 +149,12 @@ public: //The following data structures hold all perimeter points for all PrintObject. std::unordered_map m_seam_per_object; - void init(const Print &print, std::function throw_if_canceled_func); + void init(Print &print, std::function throw_if_canceled_func); void place_seam(const Layer *layer, ExtrusionLoop &loop, const Point &last_pos, float& overhang) const; private: - void gather_seam_candidates(const PrintObject *po, const SeamPlacerImpl::GlobalModelInfo &global_model_info); + void gather_seam_candidates(const PrintObject *po, const SeamPlacerImpl::GlobalModelInfo &global_model_info, + PreciseSeam::PreciseSeamWarnings* warnings = nullptr); void calculate_candidates_visibility(const PrintObject *po, const SeamPlacerImpl::GlobalModelInfo &global_model_info); void calculate_overhangs_and_layer_embedding(const PrintObject *po); diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 209ccdafa2..5f7bfaaaec 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -23,6 +23,7 @@ #include "libslic3r/Geometry/ConvexHull.hpp" +#include #include #include @@ -1229,6 +1230,7 @@ ModelObject& ModelObject::assign_copy(const ModelObject &rhs) this->volumes.emplace_back(new ModelVolume(*model_volume)); this->volumes.back()->set_model_object(this); } + this->clear_instances(); this->instances.reserve(rhs.instances.size()); for (const ModelInstance *model_instance : rhs.instances) { @@ -1267,6 +1269,7 @@ ModelObject& ModelObject::assign_copy(ModelObject &&rhs) rhs.volumes.clear(); for (ModelVolume *model_volume : this->volumes) model_volume->set_model_object(this); + this->clear_instances(); this->instances = std::move(rhs.instances); rhs.instances.clear(); @@ -1390,7 +1393,9 @@ ModelVolume* ModelObject::add_volume_with_shared_mesh(const ModelVolume &other, void ModelObject::delete_volume(size_t idx) { ModelVolumePtrs::iterator i = this->volumes.begin() + idx; - delete *i; + ModelVolume* volume_to_delete = *i; + + delete volume_to_delete; this->volumes.erase(i); if (this->volumes.size() == 1) @@ -1449,6 +1454,20 @@ void ModelObject::sort_volumes(bool full_sort) // sort volumes inside the object to order "Model Part, Negative Volume, Modifier, Support Blocker and Support Enforcer. " if (full_sort) std::stable_sort(volumes.begin(), volumes.end(), [](ModelVolume* vl, ModelVolume* vr) { + // Special handling for Precise Seam modifiers: group-based sorting with user order preservation + if (vl->is_precise_seam() && vr->is_precise_seam()) { + // Strong (center/left/right) always before weak (enforced/blocked/neutral) + bool vl_strong = vl->is_precise_seam_strong(); + bool vr_strong = vr->is_precise_seam_strong(); + if (vl_strong != vr_strong) + return vl_strong; // strong < weak → strong group appears first + + // Within same group (both strong or both weak): preserve current order + // stable_sort will maintain relative positions when comparator returns false + return false; + } + + // For non-Precise-Seam or mixed types: use standard enum-based ordering return vl->type() < vr->type(); }); // sort have to controll "place" of the support blockers/enforcers. But one of the model parts have to be on the first place. @@ -1456,10 +1475,19 @@ void ModelObject::sort_volumes(bool full_sort) std::stable_sort(volumes.begin(), volumes.end(), [](ModelVolume* vl, ModelVolume* vr) { ModelVolumeType vl_type = vl->type() > ModelVolumeType::PARAMETER_MODIFIER ? vl->type() : ModelVolumeType::PARAMETER_MODIFIER; ModelVolumeType vr_type = vr->type() > ModelVolumeType::PARAMETER_MODIFIER ? vr->type() : ModelVolumeType::PARAMETER_MODIFIER; + + // Apply same Precise Seam grouping logic for partial sort + if (vl->is_precise_seam() && vr->is_precise_seam()) { + bool vl_strong = vl->is_precise_seam_strong(); + bool vr_strong = vr->is_precise_seam_strong(); + if (vl_strong != vr_strong) + return vl_strong; + return false; // preserve order within same group + } + return vl_type < vr_type; }); } - ModelInstance* ModelObject::add_instance() { ModelInstance* i = new ModelInstance(this); @@ -2615,7 +2643,8 @@ std::vector ModelVolume::get_extruders() const if (m_type == ModelVolumeType::INVALID || m_type == ModelVolumeType::NEGATIVE_VOLUME || m_type == ModelVolumeType::SUPPORT_BLOCKER - || m_type == ModelVolumeType::SUPPORT_ENFORCER) + || m_type == ModelVolumeType::SUPPORT_ENFORCER + || this->is_precise_seam()) // Precise Seam is non-printing helper geometry return std::vector(); if (mmu_segmentation_facets.timestamp() != mmuseg_ts) { @@ -2819,6 +2848,19 @@ ModelVolumeType ModelVolume::type_from_string(const std::string &s) return ModelVolumeType::SUPPORT_ENFORCER; if (s == "support_blocker") return ModelVolumeType::SUPPORT_BLOCKER; + // Precise Seam types + if (s == "precise_seam_center") + return ModelVolumeType::PRECISE_SEAM_CENTER; + if (s == "precise_seam_left") + return ModelVolumeType::PRECISE_SEAM_LEFT; + if (s == "precise_seam_right") + return ModelVolumeType::PRECISE_SEAM_RIGHT; + if (s == "precise_seam_enforced") + return ModelVolumeType::PRECISE_SEAM_ENFORCED; + if (s == "precise_seam_blocked") + return ModelVolumeType::PRECISE_SEAM_BLOCKED; + if (s == "precise_seam_neutral") + return ModelVolumeType::PRECISE_SEAM_NEUTRAL; //assert(s == "0"); // Default value if invalud type string received. return ModelVolumeType::MODEL_PART; @@ -2833,6 +2875,12 @@ std::string ModelVolume::type_to_string(const ModelVolumeType t) case ModelVolumeType::PARAMETER_MODIFIER: return "modifier_part"; case ModelVolumeType::SUPPORT_ENFORCER: return "support_enforcer"; case ModelVolumeType::SUPPORT_BLOCKER: return "support_blocker"; + case ModelVolumeType::PRECISE_SEAM_CENTER: return "precise_seam_center"; + case ModelVolumeType::PRECISE_SEAM_LEFT: return "precise_seam_left"; + case ModelVolumeType::PRECISE_SEAM_RIGHT: return "precise_seam_right"; + case ModelVolumeType::PRECISE_SEAM_ENFORCED: return "precise_seam_enforced"; + case ModelVolumeType::PRECISE_SEAM_BLOCKED: return "precise_seam_blocked"; + case ModelVolumeType::PRECISE_SEAM_NEUTRAL: return "precise_seam_neutral"; default: assert(false); return "normal_part"; @@ -3785,6 +3833,7 @@ bool model_volume_list_changed(const ModelObject &model_object_old, const ModelO }); } + template< typename TypeFilterFn, typename CompareFn> bool model_property_changed(const ModelObject &model_object_old, const ModelObject &model_object_new, TypeFilterFn type_filter, CompareFn compare) { diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index 815b93c362..9b8c340ac8 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -347,8 +347,22 @@ enum class ModelVolumeType : int { PARAMETER_MODIFIER, SUPPORT_BLOCKER, SUPPORT_ENFORCER, + // Precise seam modifiers (6 subtypes for seam placement control). + // Order is critical: strong types first, then weak. Range checks in is_precise_seam*() depend on it. + PRECISE_SEAM_CENTER, + PRECISE_SEAM_LEFT, + PRECISE_SEAM_RIGHT, + PRECISE_SEAM_ENFORCED, + PRECISE_SEAM_BLOCKED, + PRECISE_SEAM_NEUTRAL, }; +// Free functions for checking ModelVolumeType without a ModelVolume object. +// Keep in sync with ModelVolume::is_precise_seam*() methods below. +inline bool is_precise_seam(ModelVolumeType t) { return t >= ModelVolumeType::PRECISE_SEAM_CENTER && t <= ModelVolumeType::PRECISE_SEAM_NEUTRAL; } +inline bool is_precise_seam_strong(ModelVolumeType t) { return t >= ModelVolumeType::PRECISE_SEAM_CENTER && t <= ModelVolumeType::PRECISE_SEAM_RIGHT; } +inline bool is_precise_seam_weak(ModelVolumeType t) { return t >= ModelVolumeType::PRECISE_SEAM_ENFORCED && t <= ModelVolumeType::PRECISE_SEAM_NEUTRAL; } + // A printable object, possibly having multiple print volumes (each with its own set of parameters and materials), // and possibly having multiple modifier volumes, each modifier volume with its set of parameters and materials. // Each ModelObject may be instantiated mutliple times, each instance having different placement on the print bed, @@ -917,6 +931,14 @@ public: bool is_support_enforcer() const { return m_type == ModelVolumeType::SUPPORT_ENFORCER; } bool is_support_blocker() const { return m_type == ModelVolumeType::SUPPORT_BLOCKER; } bool is_support_modifier() const { return m_type == ModelVolumeType::SUPPORT_BLOCKER || m_type == ModelVolumeType::SUPPORT_ENFORCER; } + // Check if this volume is any of the precise seam modifier subtypes + bool is_precise_seam() const { return m_type >= ModelVolumeType::PRECISE_SEAM_CENTER && m_type <= ModelVolumeType::PRECISE_SEAM_NEUTRAL; } + // Helper to check if volume is a "strong" Precise Seam type (center, left, right) + // Strong modifiers have priority and always appear above weak modifiers in UI + bool is_precise_seam_strong() const { return m_type >= ModelVolumeType::PRECISE_SEAM_CENTER && m_type <= ModelVolumeType::PRECISE_SEAM_RIGHT; } + // Helper to check if volume is a "weak" Precise Seam type (enforced, blocked, neutral) + // Weak modifiers always appear below strong modifiers in UI + bool is_precise_seam_weak() const { return m_type >= ModelVolumeType::PRECISE_SEAM_ENFORCED && m_type <= ModelVolumeType::PRECISE_SEAM_NEUTRAL; } bool is_text() const { return text_configuration.has_value(); } bool is_svg() const { return emboss_shape.has_value() && !text_configuration.has_value(); } bool is_the_only_one_part() const; // behave like an object @@ -1041,7 +1063,7 @@ protected: friend class SLAPrint; friend class Model; friend class ModelObject; - friend void model_volume_list_update_supports(ModelObject& model_object_dst, const ModelObject& model_object_new); + friend void model_volume_list_update_supports_and_seams(ModelObject& model_object_dst, const ModelObject& model_object_new); // Copies IDs of both the ModelVolume and its config. explicit ModelVolume(const ModelVolume &rhs) = default; diff --git a/src/libslic3r/Polygon.cpp b/src/libslic3r/Polygon.cpp index 32b4d062f1..77ff30fb40 100644 --- a/src/libslic3r/Polygon.cpp +++ b/src/libslic3r/Polygon.cpp @@ -318,10 +318,13 @@ Points Polygon::concave_points(double angle_threshold) const } // Projection of a point onto the polygon. -Point Polygon::point_projection(const Point &point) const +Point Polygon::point_projection(const Point &point, size_t *edge_index) const { Point proj = point; double dmin = std::numeric_limits::max(); + // Preserve the existing projection and tie order while optionally tracking its edge. + if (edge_index) + *edge_index = std::numeric_limits::max(); if (! this->points.empty()) { for (size_t i = 0; i < this->points.size(); ++ i) { const Point &pt0 = this->points[i]; @@ -330,11 +333,15 @@ Point Polygon::point_projection(const Point &point) const if (d < dmin) { dmin = d; proj = pt0; + if (edge_index) + *edge_index = i; } d = (point - pt1).cast().norm(); if (d < dmin) { dmin = d; proj = pt1; + if (edge_index) + *edge_index = (i + 1) % this->points.size(); } Vec2d v1(coordf_t(pt1(0) - pt0(0)), coordf_t(pt1(1) - pt0(1))); coordf_t div = v1.squaredNorm(); @@ -347,6 +354,8 @@ Point Polygon::point_projection(const Point &point) const if (d < dmin) { dmin = d; proj = foot; + if (edge_index) + *edge_index = i; } } } diff --git a/src/libslic3r/Polygon.hpp b/src/libslic3r/Polygon.hpp index 7d996055e5..34ca214cf3 100644 --- a/src/libslic3r/Polygon.hpp +++ b/src/libslic3r/Polygon.hpp @@ -85,7 +85,9 @@ public: Points convex_points(double angle_threshold = 0.) const; Points concave_points(double angle_threshold = 0.) const; // Projection of a point onto the polygon. - Point point_projection(const Point &point) const; + // Optional index: start of the closest edge, or the vertex itself for an endpoint. + // Empty polygons return the query point and std::numeric_limits::max() as the index. + Point point_projection(const Point &point, size_t *edge_index = nullptr) const; std::vector parameter_by_length() const; //BBS diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 12be35ea3c..0e5503c656 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -470,6 +470,10 @@ public: std::vector slice_support_volumes(const ModelVolumeType model_volume_type) const; std::vector slice_support_blockers() const { return this->slice_support_volumes(ModelVolumeType::SUPPORT_BLOCKER); } std::vector slice_support_enforcers() const { return this->slice_support_volumes(ModelVolumeType::SUPPORT_ENFORCER); } + // Shared slicing path; multiple volumes are united per layer. + std::vector slice_modifier_volumes(const std::vector &volumes) const; + // Keep Precise Seam volumes separate so their individual priority is preserved. + std::vector slice_single_volume(const ModelVolume* volume) const { return this->slice_modifier_volumes({volume}); } // Helpers to project custom facets on slices void project_and_append_custom_facets(bool seam, EnforcerBlockerType type, std::vector& expolys, std::vector>* vertical_points=nullptr) const; diff --git a/src/libslic3r/PrintApply.cpp b/src/libslic3r/PrintApply.cpp index 90d60b5917..67bf873629 100644 --- a/src/libslic3r/PrintApply.cpp +++ b/src/libslic3r/PrintApply.cpp @@ -8,12 +8,12 @@ namespace Slic3r { -// Add or remove support modifier ModelVolumes from model_object_dst to match the ModelVolumes of model_object_new +// Add or remove support and Precise Seam modifier ModelVolumes from model_object_dst to match the ModelVolumes of model_object_new // in the exact order and with the same IDs. -// It is expected, that the model_object_dst already contains the non-support volumes of model_object_new in the correct order. +// Other volume types must already match model_object_new in the correct order. // Friend to ModelVolume to allow copying. // static is not accepted by gcc if declared as a friend of ModelObject. -/* static */ void model_volume_list_update_supports(ModelObject &model_object_dst, const ModelObject &model_object_new) +/* static */ void model_volume_list_update_supports_and_seams(ModelObject &model_object_dst, const ModelObject &model_object_new) { typedef std::pair ModelVolumeWithStatus; std::vector old_volumes; @@ -33,18 +33,22 @@ namespace Slic3r { assert(! it->second); // not consumed yet it->second = true; ModelVolume *model_volume_dst = const_cast(it->first); - // For support modifiers, the type may have been switched from blocker to enforcer and vice versa. - assert((model_volume_dst->is_support_modifier() && model_volume_src->is_support_modifier()) || model_volume_dst->type() == model_volume_src->type()); + // Type may switch within support family, within precise_seam family, or between them. + assert((model_volume_dst->is_support_modifier() && model_volume_src->is_support_modifier()) || + (model_volume_dst->is_precise_seam() && model_volume_src->is_precise_seam()) || + (model_volume_dst->is_support_modifier() && model_volume_src->is_precise_seam()) || + (model_volume_dst->is_precise_seam() && model_volume_src->is_support_modifier()) || + model_volume_dst->type() == model_volume_src->type()); model_object_dst.volumes.emplace_back(model_volume_dst); - if (model_volume_dst->is_support_modifier()) { - // For support modifiers, the type may have been switched from blocker to enforcer and vice versa. + if (model_volume_dst->is_support_modifier() || model_volume_dst->is_precise_seam()) { + // Type may have been switched within or between support/precise_seam families. model_volume_dst->set_type(model_volume_src->type()); model_volume_dst->set_transformation(model_volume_src->get_transformation()); } assert(model_volume_dst->get_matrix().isApprox(model_volume_src->get_matrix())); } else { // The volume was not found in the old list. Create a new copy. - assert(model_volume_src->is_support_modifier()); + assert(model_volume_src->is_support_modifier() || model_volume_src->is_precise_seam()); model_object_dst.volumes.emplace_back(new ModelVolume(*model_volume_src)); model_object_dst.volumes.back()->set_model_object(&model_object_dst); } @@ -55,17 +59,20 @@ namespace Slic3r { delete mv_with_status.first; } -static inline void model_volume_list_copy_configs(ModelObject &model_object_dst, const ModelObject &model_object_src, const ModelVolumeType type) +// Copy configs of ModelVolumes matching type_filter predicate from src to dst. +// Mirrors the template pattern of model_volume_list_changed() in Model.cpp. +template +static inline void model_volume_list_copy_configs(ModelObject &model_object_dst, const ModelObject &model_object_src, TypeFilterFn type_filter) { size_t i_src, i_dst; for (i_src = 0, i_dst = 0; i_src < model_object_src.volumes.size() && i_dst < model_object_dst.volumes.size();) { const ModelVolume &mv_src = *model_object_src.volumes[i_src]; ModelVolume &mv_dst = *model_object_dst.volumes[i_dst]; - if (mv_src.type() != type) { + if (! type_filter(mv_src.type())) { ++ i_src; continue; } - if (mv_dst.type() != type) { + if (! type_filter(mv_dst.type())) { ++ i_dst; continue; } @@ -88,6 +95,20 @@ static inline void model_volume_list_copy_configs(ModelObject &model_object_dst, } } +// Convenience overload: single volume type. +static inline void model_volume_list_copy_configs(ModelObject &model_object_dst, const ModelObject &model_object_src, const ModelVolumeType type) +{ + model_volume_list_copy_configs(model_object_dst, model_object_src, [type](const ModelVolumeType t) { return t == type; }); +} + +// Convenience overload: multiple volume types at once (e.g. all precise seam types). +static inline void model_volume_list_copy_configs(ModelObject &model_object_dst, const ModelObject &model_object_src, const std::initializer_list &types) +{ + model_volume_list_copy_configs(model_object_dst, model_object_src, [&types](const ModelVolumeType t) { + return std::find(types.begin(), types.end(), t) != types.end(); + }); +} + static inline void layer_height_ranges_copy_configs(t_layer_config_ranges &lr_dst, const t_layer_config_ranges &lr_src) { assert(lr_dst.size() == lr_src.size()); @@ -1618,6 +1639,11 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ // 3) Synchronize ModelObjects & PrintObjects. const std::initializer_list solid_or_modifier_types { ModelVolumeType::MODEL_PART, ModelVolumeType::NEGATIVE_VOLUME, ModelVolumeType::PARAMETER_MODIFIER }; + const std::initializer_list precise_seam_types { + ModelVolumeType::PRECISE_SEAM_CENTER, ModelVolumeType::PRECISE_SEAM_LEFT, + ModelVolumeType::PRECISE_SEAM_RIGHT, ModelVolumeType::PRECISE_SEAM_ENFORCED, + ModelVolumeType::PRECISE_SEAM_BLOCKED, ModelVolumeType::PRECISE_SEAM_NEUTRAL + }; for (size_t idx_model_object = 0; idx_model_object < model.objects.size(); ++ idx_model_object) { ModelObject &model_object = *m_model.objects[idx_model_object]; ModelObjectStatus &model_object_status = const_cast(model_object_status_db.reuse(model_object)); @@ -1635,6 +1661,7 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ model_fuzzy_skin_data_changed(model_object, model_object_new); bool supports_differ = model_volume_list_changed(model_object, model_object_new, ModelVolumeType::SUPPORT_BLOCKER) || model_volume_list_changed(model_object, model_object_new, ModelVolumeType::SUPPORT_ENFORCER); + bool precise_seam_differ = model_volume_list_changed(model_object, model_object_new, precise_seam_types); bool layer_height_ranges_differ = ! layer_height_ranges_equal(model_object.layer_config_ranges, model_object_new.layer_config_ranges, model_object_new.layer_height_profile.empty()); bool model_origin_translation_differ = model_object.origin_translation != model_object_new.origin_translation; bool brim_points_differ = model_brim_points_data_changed(model_object, model_object_new); @@ -1677,13 +1704,20 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ // Invalidate just the supports step. for (const PrintObjectStatus &print_object_status : print_objects_range) update_apply_status(print_object_status.print_object->invalidate_step(posSupportMaterial)); - if (supports_differ) { - // Copy just the support volumes. - model_volume_list_update_supports(model_object, model_object_new); - } + } + if (precise_seam_differ) { + // First stop background processing before shuffling or deleting the ModelVolumes in the ModelObject's list. + this->call_cancel_callback(); + update_apply_status(false); + // Invalidate seam placement (affects G-code export). + update_apply_status(this->invalidate_step(psGCodeExport)); } else if (model_custom_seam_data_changed(model_object, model_object_new)) { update_apply_status(this->invalidate_step(psGCodeExport)); } + // Synchronize both families once, after cancellation and all affected-step invalidations. + // This also handles type changes between supports and Precise Seam before copying configs below. + if (supports_differ || precise_seam_differ) + model_volume_list_update_supports_and_seams(model_object, model_object_new); if (brim_points_differ) { model_object.brim_points = model_object_new.brim_points; update_apply_status(this->invalidate_all_steps()); @@ -1708,6 +1742,8 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ //FIXME What to do with m_material_id? model_volume_list_copy_configs(model_object /* dst */, model_object_new /* src */, ModelVolumeType::MODEL_PART); model_volume_list_copy_configs(model_object /* dst */, model_object_new /* src */, ModelVolumeType::PARAMETER_MODIFIER); + // Synchronize Precise Seam modifier volumes + model_volume_list_copy_configs(model_object /* dst */, model_object_new /* src */, precise_seam_types); layer_height_ranges_copy_configs(model_object.layer_config_ranges /* dst */, model_object_new.layer_config_ranges /* src */); // Copy the ModelObject name, input_file and instances. The instances will be compared against PrintObject instances in the next step. model_object.name = model_object_new.name; diff --git a/src/libslic3r/PrintBase.hpp b/src/libslic3r/PrintBase.hpp index d4f99c764e..f1b158e51f 100644 --- a/src/libslic3r/PrintBase.hpp +++ b/src/libslic3r/PrintBase.hpp @@ -62,7 +62,8 @@ public: SlicingReplaceInitEmptyLayers, SlicingNeedSupportOn, SlicingEmptyGcodeLayers, - SlicingGcodeOverlap + SlicingGcodeOverlap, + SlicingPreciseSeamWarning }; typedef size_t TimeStamp; diff --git a/src/libslic3r/PrintObjectSlice.cpp b/src/libslic3r/PrintObjectSlice.cpp index d16e381604..14bd65c9cf 100644 --- a/src/libslic3r/PrintObjectSlice.cpp +++ b/src/libslic3r/PrintObjectSlice.cpp @@ -1551,12 +1551,19 @@ ExPolygons PrintObject::_shrink_contour_holes(double contour_delta, double hole_ std::vector PrintObject::slice_support_volumes(const ModelVolumeType model_volume_type) const { - auto it_volume = this->model_object()->volumes.begin(); - auto it_volume_end = this->model_object()->volumes.end(); - for (; it_volume != it_volume_end && (*it_volume)->type() != model_volume_type; ++ it_volume) ; + // Supports merge every matching volume; Precise Seam calls the shared slicer one volume at a time. + std::vector volumes; + for (const ModelVolume *volume : this->model_object()->volumes) + if (volume->type() == model_volume_type) + volumes.push_back(volume); + return this->slice_modifier_volumes(volumes); +} + +std::vector PrintObject::slice_modifier_volumes(const std::vector &volumes) const +{ std::vector slices; - if (it_volume != it_volume_end) { - // Found at least a single support volume of model_volume_type. + if (!volumes.empty()) { + // Share layer heights, transforms and cancellation handling across the selected volumes. std::vector zs = zs_from_layers(this->layers()); std::vector merge_layers; bool merge = false; @@ -1564,27 +1571,26 @@ std::vector PrintObject::slice_support_volumes(const ModelVolumeType m auto throw_on_cancel_callback = std::function([print](){ print->throw_if_canceled(); }); MeshSlicingParamsEx params; params.trafo = this->trafo_centered(); - for (; it_volume != it_volume_end; ++ it_volume) - if ((*it_volume)->type() == model_volume_type) { - std::vector slices2 = slice_volume(*(*it_volume), zs, params, throw_on_cancel_callback); - if (slices.empty()) { - slices.reserve(slices2.size()); - for (ExPolygons &src : slices2) - slices.emplace_back(to_polygons(std::move(src))); - } else if (!slices2.empty()) { - if (merge_layers.empty()) - merge_layers.assign(zs.size(), false); - for (size_t i = 0; i < zs.size(); ++ i) { - if (slices[i].empty()) - slices[i] = to_polygons(std::move(slices2[i])); - else if (! slices2[i].empty()) { - append(slices[i], to_polygons(std::move(slices2[i]))); - merge_layers[i] = true; - merge = true; - } + for (const ModelVolume *volume : volumes) { + std::vector slices2 = slice_volume(*volume, zs, params, throw_on_cancel_callback); + if (slices.empty()) { + slices.reserve(slices2.size()); + for (ExPolygons &src : slices2) + slices.emplace_back(to_polygons(std::move(src))); + } else if (!slices2.empty()) { + if (merge_layers.empty()) + merge_layers.assign(zs.size(), false); + for (size_t i = 0; i < zs.size(); ++ i) { + if (slices[i].empty()) + slices[i] = to_polygons(std::move(slices2[i])); + else if (! slices2[i].empty()) { + append(slices[i], to_polygons(std::move(slices2[i]))); + merge_layers[i] = true; + merge = true; } } } + } if (merge) { std::vector to_merge; to_merge.reserve(zs.size()); diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index bf3cdc5303..d72407a77e 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -162,6 +162,14 @@ ColorRGBA GLVolume::SUPPORT_BLOCKER_COL = {1.0f, 0.3f, 0.3f, 0.4f}; ColorRGBA GLVolume::MODEL_HIDDEN_COL = {0.f, 0.f, 0.f, 0.3f}; +// Precise Seam modifier colors +ColorRGBA GLVolume::PRECISE_SEAM_CENTER_COL = {1.0f, 0.627f, 0.082f, 0.6f}; // FFA015 - orange +ColorRGBA GLVolume::PRECISE_SEAM_LEFT_COL = {1.0f, 0.753f, 0.0f, 0.6f}; // FFC000 - golden +ColorRGBA GLVolume::PRECISE_SEAM_RIGHT_COL = {1.0f, 0.514f, 0.0f, 0.6f}; // FF8300 - dark orange +ColorRGBA GLVolume::PRECISE_SEAM_ENFORCED_COL = {0.412f, 0.820f, 0.412f, 0.6f}; // 69D169 - green +ColorRGBA GLVolume::PRECISE_SEAM_NEUTRAL_COL = {0.655f, 0.655f, 0.655f, 0.6f}; // A7A7A7 - gray +ColorRGBA GLVolume::PRECISE_SEAM_BLOCKED_COL = {0.820f, 0.412f, 0.412f, 0.6f}; // D16969 - red + std::array GLVolume::MODEL_COLOR = { { { 1.0f, 1.0f, 0.0f, 1.f }, { 1.0f, 0.5f, 0.5f, 1.f }, @@ -363,6 +371,28 @@ ColorRGBA color_from_model_volume(const ModelVolume& model_volume) ColorRGBA color; if (model_volume.is_negative_volume()) return GLVolume::MODEL_NEGTIVE_COL; + else if (model_volume.is_precise_seam()) { + // Return color based on Precise Seam subtype. + // Exhaustive switch (no default) so -Wswitch flags any future PRECISE_SEAM_* additions. + switch (model_volume.type()) { + case ModelVolumeType::PRECISE_SEAM_CENTER: return GLVolume::PRECISE_SEAM_CENTER_COL; + case ModelVolumeType::PRECISE_SEAM_LEFT: return GLVolume::PRECISE_SEAM_LEFT_COL; + case ModelVolumeType::PRECISE_SEAM_RIGHT: return GLVolume::PRECISE_SEAM_RIGHT_COL; + case ModelVolumeType::PRECISE_SEAM_ENFORCED: return GLVolume::PRECISE_SEAM_ENFORCED_COL; + case ModelVolumeType::PRECISE_SEAM_NEUTRAL: return GLVolume::PRECISE_SEAM_NEUTRAL_COL; + case ModelVolumeType::PRECISE_SEAM_BLOCKED: return GLVolume::PRECISE_SEAM_BLOCKED_COL; + // Non-seam types are unreachable due to the outer is_precise_seam() guard; + // listed explicitly so this switch stays exhaustive over ModelVolumeType. + case ModelVolumeType::INVALID: + case ModelVolumeType::MODEL_PART: + case ModelVolumeType::NEGATIVE_VOLUME: + case ModelVolumeType::PARAMETER_MODIFIER: + case ModelVolumeType::SUPPORT_BLOCKER: + case ModelVolumeType::SUPPORT_ENFORCER: + break; + } + return GLVolume::MODEL_MIDIFIER_COL; // unreachable fallback + } else if (model_volume.is_modifier()) #if ENABLE_MODIFIERS_ALWAYS_TRANSPARENT return GLVolume::MODEL_MIDIFIER_COL; diff --git a/src/slic3r/GUI/3DScene.hpp b/src/slic3r/GUI/3DScene.hpp index 1217d32182..b70cb4f565 100644 --- a/src/slic3r/GUI/3DScene.hpp +++ b/src/slic3r/GUI/3DScene.hpp @@ -93,6 +93,13 @@ public: static ColorRGBA SUPPORT_ENFORCER_COL; static ColorRGBA SUPPORT_BLOCKER_COL; static ColorRGBA MODEL_HIDDEN_COL; + // Precise Seam modifier colors + static ColorRGBA PRECISE_SEAM_CENTER_COL; + static ColorRGBA PRECISE_SEAM_LEFT_COL; + static ColorRGBA PRECISE_SEAM_RIGHT_COL; + static ColorRGBA PRECISE_SEAM_ENFORCED_COL; + static ColorRGBA PRECISE_SEAM_NEUTRAL_COL; + static ColorRGBA PRECISE_SEAM_BLOCKED_COL; static void update_render_colors(); static void load_render_colors(); diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index ecca540aa9..8bc899bb3d 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -336,13 +336,20 @@ wxBitmap SettingsFactory::get_category_bitmap(const std::string& category_name, //------------------------------------- // Note: id accords to type of the sub-object (adding volume), so sequence of the menu items is important -static const constexpr std::array, 5> ADD_VOLUME_MENU_ITEMS = {{ +static const constexpr std::array, 11> ADD_VOLUME_MENU_ITEMS = {{ // menu_item Name menu_item bitmap name {L("Add Part"), "menu_add_part" }, // ~ModelVolumeType::MODEL_PART {L("Add Negative Part"), "menu_add_negative" }, // ~ModelVolumeType::NEGATIVE_VOLUME {L("Add Modifier"), "menu_add_modifier"}, // ~ModelVolumeType::PARAMETER_MODIFIER {L("Add Support Blocker"), "menu_support_blocker"}, // ~ModelVolumeType::SUPPORT_BLOCKER {L("Add Support Enforcer"), "menu_support_enforcer"}, // ~ModelVolumeType::SUPPORT_ENFORCER + // Precise Seam modifiers (all 6 subtypes - only first one shown in menu, others used for tree icons) + {L("Add Precise Seam"), "menu_precise_seam_center"}, // ~ModelVolumeType::PRECISE_SEAM_CENTER + {L("Add Precise Seam"), "menu_precise_seam_left"}, // ~ModelVolumeType::PRECISE_SEAM_LEFT + {L("Add Precise Seam"), "menu_precise_seam_right"}, // ~ModelVolumeType::PRECISE_SEAM_RIGHT + {L("Add Precise Seam"), "menu_precise_seam_enforced"}, // ~ModelVolumeType::PRECISE_SEAM_ENFORCED + {L("Add Precise Seam"), "menu_precise_seam_blocked"}, // ~ModelVolumeType::PRECISE_SEAM_BLOCKED + {L("Add Precise Seam"), "menu_precise_seam_neutral"}, // ~ModelVolumeType::PRECISE_SEAM_NEUTRAL }}; // Note: id accords to type of the sub-object (adding volume), so sequence of the menu items is important @@ -740,10 +747,20 @@ void MenuFactory::append_menu_items_add_volume(wxMenu* menu) for (size_t type = 0; type < ADD_VOLUME_MENU_ITEMS.size(); type++) { + // Skip Precise Seam subtypes except the first one (CENTER) - they are only used for tree icons + if (type >= int(ModelVolumeType::PRECISE_SEAM_LEFT) && + type <= int(ModelVolumeType::PRECISE_SEAM_NEUTRAL)) + continue; + auto& item = ADD_VOLUME_MENU_ITEMS[type]; + // Use special icon for "Add Precise Seam" menu (different from tree icon) + std::string icon_name = item.second; + if (type == int(ModelVolumeType::PRECISE_SEAM_CENTER)) + icon_name = "menu_precise_seam_add"; + wxMenu* sub_menu = append_submenu_add_generic(menu, ModelVolumeType(type)); - append_submenu(menu, sub_menu, wxID_ANY, _(item.first), "", item.second, + append_submenu(menu, sub_menu, wxID_ANY, _(item.first), "", icon_name, []() { return obj_list()->is_instance_or_object_selected(); }, m_parent); } @@ -843,11 +860,16 @@ wxMenuItem* MenuFactory::append_menu_item_change_type(wxMenu* menu) }; std::vector types = { - { ModelVolumeType::MODEL_PART, _L("Part") }, - { ModelVolumeType::NEGATIVE_VOLUME, _L("Negative Part") }, - { ModelVolumeType::PARAMETER_MODIFIER, _L("Modifier") }, - { ModelVolumeType::SUPPORT_BLOCKER, _L("Support Blocker") }, - { ModelVolumeType::SUPPORT_ENFORCER, _L("Support Enforcer") } + { ModelVolumeType::MODEL_PART, _L("Part") }, + { ModelVolumeType::NEGATIVE_VOLUME, _L("Negative Part") }, + { ModelVolumeType::PARAMETER_MODIFIER, _L("Modifier") }, + { ModelVolumeType::SUPPORT_BLOCKER, _L("Support Blocker") }, + { ModelVolumeType::SUPPORT_ENFORCER, _L("Support Enforcer") }, + // Single "Precise Seam" entry that maps to PRECISE_SEAM_CENTER as the default subtype. + // set_volume_type() preserves the existing subtype (LEFT/RIGHT/etc.) for volumes that + // are already Precise Seam; subtype picking is done via the separate + // "Precise Seam Type" submenu (see append_menu_item_precise_seam_submenu). + { ModelVolumeType::PRECISE_SEAM_CENTER, _L("Precise Seam") } }; for (const auto& info : types) { @@ -863,7 +885,13 @@ wxMenuItem* MenuFactory::append_menu_item_change_type(wxMenu* menu) obj_list()->GetSelections(sels); for (auto item : sels) { ModelVolumeType vol_type = model->GetVolumeType(item); - if (vol_type == type) { + // The "Precise Seam" entry represents all six PS subtypes, so any subtype + // among selected volumes counts as a match (keeps the checkbox ticked when + // user has PS_LEFT/RIGHT/etc. selected, not only plain CENTER). + const bool match = (type == ModelVolumeType::PRECISE_SEAM_CENTER) + ? is_precise_seam(vol_type) + : (vol_type == type); + if (match) { has_type = true; break; } @@ -871,6 +899,7 @@ wxMenuItem* MenuFactory::append_menu_item_change_type(wxMenu* menu) evt.Check(has_type); // ORCA Fix crash caused by SVG/TEXT volumes cant be Support Enforcer/Blocker type + // The same applies to Precise Seam subtypes. for (auto item : sels) { if (model->GetItemType(item) == itVolume){ auto vol_idx = model->GetVolumeIdByItem(item); @@ -881,7 +910,8 @@ wxMenuItem* MenuFactory::append_menu_item_change_type(wxMenu* menu) auto vol = (*objs)[obj_idx]->volumes[vol_idx]; // disable Support Enforcer/Blocker if selection contains svg or text - if (vol != nullptr && (vol->is_svg() || vol->is_text()) && (type == ModelVolumeType::SUPPORT_BLOCKER || type == ModelVolumeType::SUPPORT_ENFORCER)){ + // (same applies to Precise Seam) + if (vol != nullptr && (vol->is_svg() || vol->is_text()) && (type == ModelVolumeType::SUPPORT_BLOCKER || type == ModelVolumeType::SUPPORT_ENFORCER || is_precise_seam(type))){ evt.Enable(false); break; } @@ -1309,6 +1339,131 @@ void MenuFactory::append_menu_items_mirror(wxMenu* menu) []() { return plater()->can_mirror(); }, m_parent); } +void MenuFactory::append_menu_item_precise_seam_submenu(wxMenu* menu) +{ + wxString submenu_name = _L("Precise Seam Type"); + + // Remove existing submenu if present (menu is rebuilt on every right-click) + const int menu_item_id = menu->FindItem(submenu_name); + if (menu_item_id != wxNOT_FOUND) + menu->Destroy(menu_item_id); + + // --- Precondition: ALL selected volumes must be Precise Seam --- + // Mixed selections (PS + non-PS) are ambiguous for subtype switching: applying a subtype + // would implicitly convert the non-PS volumes to PS, which is not what the user expects + // from a subtype picker. For mixed selections the user should first use + // "Change type → Precise Seam" to unify them, then come back to this submenu. + wxDataViewItemArray sels; + obj_list()->GetSelections(sels); + if (sels.IsEmpty()) + return; + + // A right-click on a volume in the 3D view routes through Plater::show_context_menu + // → ObjectList::update_selections(), which intentionally preserves a pre-existing + // settings-row selection alongside the clicked volume. As a result GetSelections() + // may return settings-row items that are children of a volume. Treat each settings + // row as selecting its parent volume (same pattern used by set_volume_type() + // internally). Without this resolution, GetVolumeType(settings_row) returns INVALID + // and the submenu would hide even when a valid PS volume is in the selection. + // + // Precomputing the resolved PS types once here also lets the checkmark loop below + // display checks correctly for selections that contain settings rows. + std::vector selected_ps_types; + selected_ps_types.reserve(sels.size()); + + auto* model = obj_list()->GetModel(); + for (const auto& sel_item : sels) { + wxDataViewItem vol_item = sel_item; + const ItemType type_mask = model->GetItemType(sel_item); + if (!(type_mask & itVolume)) { + // Only settings rows whose parent is a volume map to "selecting that volume". + // Any other non-volume item (object, instance, etc.) means the selection is + // not purely PS-volume-based — hide the submenu. + if ((type_mask & itSettings) && (model->GetItemType(model->GetParent(sel_item)) & itVolume)) + vol_item = model->GetParent(sel_item); + else + return; + } + const ModelVolumeType vol_type = model->GetVolumeType(vol_item); + if (!is_precise_seam(vol_type)) + return; + selected_ps_types.push_back(vol_type); + } + + // Match the label used by append_menu_item_change_type, including its translation. + int insert_pos = wxNOT_FOUND; + int change_type_id = menu->FindItem(_L("Change Type")); + if (change_type_id != wxNOT_FOUND) { + for (size_t i = 0; i < menu->GetMenuItemCount(); i++) { + wxMenuItem* item = menu->FindItemByPosition(i); + if (item && item->GetId() == change_type_id) { + insert_pos = i + 1; // insert directly after the Change Type entry + break; + } + } + } + + // --- Build the subtype submenu --- + wxMenu* ps_menu = new wxMenu(); + + // Array of all 6 Precise Seam subtypes with labels. + // "Seam ..." prefix disambiguates from other i18n contexts (extruder Left/Right, "Center on bed", etc.) + static const std::array, 6> PS_TYPES = {{ + {L("Seam Center"), ModelVolumeType::PRECISE_SEAM_CENTER}, + {L("Seam Left"), ModelVolumeType::PRECISE_SEAM_LEFT}, + {L("Seam Right"), ModelVolumeType::PRECISE_SEAM_RIGHT}, + {L("Seam Enforced"), ModelVolumeType::PRECISE_SEAM_ENFORCED}, + {L("Seam Blocked"), ModelVolumeType::PRECISE_SEAM_BLOCKED}, + {L("Seam Neutral"), ModelVolumeType::PRECISE_SEAM_NEUTRAL}, + }}; + + for (const auto& ps_type : PS_TYPES) { + wxString label = _(ps_type.first); + + // Check-items (not radio): radio groups in wxWidgets auto-select the first item when + // no item is explicitly checked, which misleads the user into seeing "Seam Center" + // as the active subtype for mixed-subtype selections. With check-items we can leave + // every item unchecked in that case, and the menu matches the pattern of the + // neighbouring "Change type" submenu (see append_menu_item_change_type). + // + // Handler: delegate to set_volume_type() with preserve_ps_subtype=false. The user + // explicitly picked a subtype here, so "Seam Center" must set every selected PS + // volume to CENTER verbatim — preservation (the default for "Change type") would + // keep existing subtypes and make CENTER unreachable on mixed-subtype selections. + // set_volume_type() already handles multi-select iteration, the last-solid-part + // guard, and the PS group-change reordering via move_volume_to_end(). + wxMenuItem* item = append_menu_check_item(ps_menu, wxID_ANY, label, "", + [ps_type](wxCommandEvent&) { + obj_list()->set_volume_type(ps_type.second, /*preserve_ps_subtype=*/false); + }, + ps_menu); + + // Tick every subtype that is present in the current selection. Same pattern as the + // neighbouring "Change type" submenu (where selecting [Part + Modifier] ticks both + // "Part" and "Modifier" boxes). For homogeneous selections exactly one checkbox is + // ticked; for mixed subtype selections (e.g. PS_LEFT + PS_RIGHT) both "Seam Left" + // and "Seam Right" appear checked, so the user sees at a glance which subtypes are + // currently in the selection. + // + // Uses the precomputed selected_ps_types (which resolved settings rows to their + // parent volumes); iterating sels directly would miss subtypes for selections that + // arrive via settings rows from right-click-in-3D-view. + const bool is_present = std::find(selected_ps_types.begin(), selected_ps_types.end(), + ps_type.second) != selected_ps_types.end(); + if (is_present && item) + item->Check(true); + } + + // Add submenu to parent menu + append_submenu(menu, ps_menu, wxID_ANY, + submenu_name, + _L("Choose precise seam subtype"), + "menu_precise_seam_type", + []() { return true; }, + m_parent, + insert_pos); +} + void MenuFactory::append_menu_item_edit_text(wxMenu *menu) { wxString name = _L("Edit text"); @@ -1578,6 +1733,7 @@ void MenuFactory::create_part_menu() menu->AppendSeparator(); append_menu_item_change_type(menu); + append_menu_item_precise_seam_submenu(menu); append_menu_items_mirror(&m_part_menu); append_menu_item(&m_part_menu, wxID_ANY, _L("Split"), _L("Split the selected object into multiple parts"), [](wxCommandEvent&) { plater()->split_volume(); }, "split_parts", nullptr, @@ -1875,6 +2031,7 @@ wxMenu* MenuFactory::part_menu() { append_menu_items_convert_unit(&m_part_menu); append_menu_item_change_filament(&m_part_menu); + append_menu_item_precise_seam_submenu(&m_part_menu); append_menu_item_per_object_settings(&m_part_menu); return &m_part_menu; } @@ -1989,6 +2146,10 @@ wxMenu* MenuFactory::multi_selection_menu() append_menu_item_per_object_process(menu); menu->AppendSeparator(); append_menu_item_change_type(menu); + // Subtype picker for Precise Seam — shown only when all selected volumes are PS. + // Must be paired with Change Type here (as in single-volume part_menu), otherwise + // the user cannot switch PS subtypes (LEFT/RIGHT/etc.) on multi-selection. + append_menu_item_precise_seam_submenu(menu); append_menu_item_change_filament(menu); } return menu; diff --git a/src/slic3r/GUI/GUI_Factories.hpp b/src/slic3r/GUI/GUI_Factories.hpp index ec7b0a0b03..ad694f15de 100644 --- a/src/slic3r/GUI/GUI_Factories.hpp +++ b/src/slic3r/GUI/GUI_Factories.hpp @@ -174,6 +174,7 @@ private: void append_menu_item_merge_to_single_object(wxMenu* menu); void append_menu_item_merge_parts_to_single_part(wxMenu *menu); void append_menu_items_mirror(wxMenu *menu); + void append_menu_item_precise_seam_submenu(wxMenu* menu); void append_menu_item_invalidate_cut_info(wxMenu *menu); void append_menu_item_edit_text(wxMenu *menu); void append_menu_item_edit_svg(wxMenu *menu); diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index 5220e026c7..99c023e4a1 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -1944,6 +1944,12 @@ bool ObjectList::can_drop(const wxDataViewItem& item, int& src_obj_id, int& src_ if (dragged_item_v_type == item_v_type && dragged_item_v_type != ModelVolumeType::MODEL_PART) return true; + + // Use tree item types: hidden cut connectors make tree indices differ from volumes indices. + if (is_precise_seam(dragged_item_v_type) && is_precise_seam(item_v_type)) + // Allow reordering only within the strong or weak modifier group. + return is_precise_seam_strong(dragged_item_v_type) == is_precise_seam_strong(item_v_type); + if ((dragged_item_v_type != item_v_type) || // we can't reorder volumes outside of types item_v_type >= ModelVolumeType::SUPPORT_BLOCKER) // support blockers/enforcers can't change its place return false; @@ -2033,10 +2039,43 @@ void ObjectList::OnDrop(wxDataViewEvent &event) int to_volume_id = m_objects_model->GetVolumeIdByItem(item); int delta = to_volume_id < from_volume_id ? -1 : 1; - auto &volumes = (*m_objects)[m_dragged_data.obj_idx()]->volumes; + const int obj_idx = m_dragged_data.obj_idx(); + // Object-indexed UI maps may be stale after another object is removed or reordered. + if (obj_idx < 0 || size_t(obj_idx) >= m_objects->size()) { + event.Veto(); + m_dragged_data.clear(); + return; + } + const ModelObject *object = (*m_objects)[obj_idx]; + auto &volumes = (*m_objects)[obj_idx]->volumes; + std::vector visible_volume_indices; + visible_volume_indices.reserve(volumes.size()); + for (size_t idx = 0; idx < volumes.size(); ++idx) + // Match add_volumes_to_object_in_list: only connectors of cut objects are hidden. + if (!(object->is_cut() && volumes[idx]->is_cut_connector())) + visible_volume_indices.push_back(idx); - int cnt = 0; - for (int id = from_volume_id; cnt < abs(from_volume_id - to_volume_id); id += delta, cnt++) std::swap(volumes[id], volumes[id + delta]); + // Validate the entire move before any swap; these checks must also protect Release builds. + if (from_volume_id < 0 || to_volume_id < 0 || + size_t(from_volume_id) >= visible_volume_indices.size() || size_t(to_volume_id) >= visible_volume_indices.size()) { + event.Veto(); + m_dragged_data.clear(); + return; + } + + // Move through visible slots only, keeping hidden cut connectors at their original indices. + // The local mapping stays valid because no hidden volume changes slots during the move. + for (int id = from_volume_id; id != to_volume_id; id += delta) { + const size_t current_idx = visible_volume_indices[id]; + const size_t next_idx = visible_volume_indices[id + delta]; + std::swap(volumes[current_idx], volumes[next_idx]); + } + + // Later selection/filament handlers use this cache; repair it for the moved object too. + auto &ui_to_model = m_objects_model->get_ui_and_3d_volume_map()[obj_idx]; + ui_to_model.clear(); + for (size_t idx = 0; idx < visible_volume_indices.size(); ++idx) + ui_to_model[int(idx)] = int(visible_volume_indices[idx]); select_item(m_objects_model->ReorganizeChildren(from_volume_id, to_volume_id, m_objects_model->GetParent(item))); @@ -3855,7 +3894,8 @@ wxDataViewItem ObjectList::add_settings_item(wxDataViewItem parent_item, const D const bool is_layer_settings = m_objects_model->GetItemType(parent_item) == itLayer; if (!is_object_settings) { ModelVolumeType volume_type = m_objects_model->GetVolumeType(parent_item); - if (volume_type == ModelVolumeType::NEGATIVE_VOLUME || volume_type == ModelVolumeType::SUPPORT_BLOCKER || volume_type == ModelVolumeType::SUPPORT_ENFORCER) + // Precise Seam is non-printing helper geometry — no per-volume settings + if (volume_type == ModelVolumeType::NEGATIVE_VOLUME || volume_type == ModelVolumeType::SUPPORT_BLOCKER || volume_type == ModelVolumeType::SUPPORT_ENFORCER || is_precise_seam(volume_type)) return ret; } @@ -5712,7 +5752,6 @@ void ObjectList::change_part_type() return; } #endif - ModelVolumeType ObjectList::get_selected_volume_type() { ModelVolume* volume = get_selected_model_volume(); @@ -5721,13 +5760,49 @@ ModelVolumeType ObjectList::get_selected_volume_type() return ModelVolumeType::INVALID; } -void ObjectList::set_volume_type(ModelVolumeType new_type) +// ---------------- Helpers for Precise Seam group-aware type changes ---------------- +// Used by set_volume_type() and the "Precise Seam Type" subtype submenu handler. + +// Detects whether a type change crosses a Precise Seam "group boundary" that requires +// manual repositioning inside ModelObject::volumes[]: +// - between any non-PS type and any PS subtype, or +// - between strong PS (CENTER/LEFT/RIGHT) and weak PS (ENFORCED/BLOCKED/NEUTRAL). +// sort_volumes() is stable and groups strong PS before weak PS. When a volume crosses +// a group, moving it to the end of volumes[] lets the subsequent stable sort place it +// at the end of its new group. Without this, a strong→weak transition would leave the +// volume stuck in the strong segment and break the modifier-application order. +static bool precise_seam_group_changed(ModelVolumeType old_type, ModelVolumeType new_type) +{ + const bool old_is_ps = is_precise_seam(old_type); + const bool new_is_ps = is_precise_seam(new_type); + if (old_is_ps != new_is_ps) + return true; // non-PS ↔ PS transition + if (!old_is_ps) + return false; // both non-PS — regular enum ordering is enough + return is_precise_seam_strong(old_type) != is_precise_seam_strong(new_type); +} + +// Moves a volume to the end of ModelObject::volumes[]. Combined with stable sort_volumes(), +// this places it at the end of its new group while preserving relative order of others. +static void move_volume_to_end(ModelObject* obj, ModelVolume* volume) +{ + if (obj == nullptr || volume == nullptr) + return; + auto it = std::find(obj->volumes.begin(), obj->volumes.end(), volume); + if (it != obj->volumes.end()) { + obj->volumes.erase(it); + obj->volumes.push_back(volume); + } +} + +void ObjectList::set_volume_type(ModelVolumeType new_type, bool preserve_ps_subtype) { struct VolumeSelection { int object_idx; ModelVolume* volume; }; + // --- Collect selected volumes from the object tree, falling back to the 3D canvas --- std::vector volumes; auto add_volume = [&volumes](int obj_idx, ModelVolume* volume) { if (volume == nullptr) @@ -5792,25 +5867,49 @@ void ObjectList::set_volume_type(ModelVolumeType new_type) // and historically crashed (originally fixed in the now-disabled change_part_type() by hiding // Support entries in the old choice dialog; the UI-side guard for the current submenu lives // in MenuFactory::append_menu_item_change_type, see #13120). + // The same rationale applies to any Precise Seam subtype. // This block must never be reachable under a healthy UI; if it ever logs, the UI guard has // been bypassed (new entry point, refactor, plugin, etc.) and should be investigated. - if (new_type == ModelVolumeType::SUPPORT_BLOCKER || new_type == ModelVolumeType::SUPPORT_ENFORCER) { + if (new_type == ModelVolumeType::SUPPORT_BLOCKER || new_type == ModelVolumeType::SUPPORT_ENFORCER + || is_precise_seam(new_type)) { const bool has_text_or_svg = std::any_of(volumes.begin(), volumes.end(), [](const VolumeSelection& sel) { return sel.volume->is_svg() || sel.volume->is_text(); }); if (has_text_or_svg) { BOOST_LOG_TRIVIAL(error) << __FUNCTION__ - << ": blocked attempt to set SUPPORT_BLOCKER/ENFORCER on SVG/text volume; " + << ": blocked attempt to set SUPPORT_BLOCKER/ENFORCER or Precise Seam on SVG/text volume; " << "UI guard should have prevented this -- possible regression in the Change Type menu"; return; } } + // --- Subtype preservation for the generic "Precise Seam" entry --- + // The Change Type submenu uses PRECISE_SEAM_CENTER as the single "Precise Seam" entry + // and calls with preserve_ps_subtype=true: volumes that are already Precise Seam keep + // their subtype (LEFT/RIGHT/etc.); only non-PS volumes get converted to the default + // CENTER subtype. + // + // The "Precise Seam Type" subtype submenu calls with preserve_ps_subtype=false: the + // user explicitly picked CENTER and all selected PS volumes must be set to CENTER + // verbatim (otherwise CENTER would become unreachable for mixed-subtype selections, + // since preservation would keep every volume at its current subtype and any_diff would + // be false — the reason for this two-parameter split). + auto effective_new_type = [new_type, preserve_ps_subtype](const ModelVolume* v) -> ModelVolumeType { + if (preserve_ps_subtype && new_type == ModelVolumeType::PRECISE_SEAM_CENTER && v->is_precise_seam()) + return v->type(); + return new_type; + }; + + // --- Any-change check using the effective target type --- const bool any_diff = std::any_of(volumes.begin(), volumes.end(), - [new_type](const VolumeSelection& sel) { return sel.volume->type() != new_type; }); + [&effective_new_type](const VolumeSelection& sel) { + return sel.volume->type() != effective_new_type(sel.volume); + }); if (!any_diff) return; + // --- Last-solid-part guard (pre-existing behavior) --- + // When converting away from MODEL_PART, ensure at least one solid part remains per object. if (new_type != ModelVolumeType::MODEL_PART) { std::map total_part_cnt; std::map selected_part_cnt; @@ -5838,14 +5937,36 @@ void ObjectList::set_volume_type(ModelVolumeType new_type) take_snapshot(_u8L("Change part type")); + // --- Apply type changes with Precise Seam group-aware repositioning --- + // Note: `changed_volumes` / `touched_objects` track every processed volume, including + // subtype-preservation no-ops. Tracking no-ops is intentional — it ensures such + // volumes remain selected after the post-apply rebuild. Without that, a cross-object + // mixed selection like [Part, PS_LEFT] clicking "Change type → Precise Seam" would + // silently deselect the preserved PS_LEFT: only the Part's object would enter + // touched_objects, and the final SetSelections(new_selection) would drop PS_LEFT. std::set changed_volumes; std::set touched_objects; for (const auto& sel : volumes) { - sel.volume->set_type(new_type); + const ModelVolumeType target = effective_new_type(sel.volume); + const ModelVolumeType old_type = sel.volume->type(); + + // Record the volume for reselection before the no-op short-circuit (see comment above). changed_volumes.insert(sel.volume); touched_objects.insert(sel.object_idx); + + if (old_type == target) + continue; // subtype-preservation no-op — nothing to write/move + + sel.volume->set_type(target); + + // If the change crosses a PS group boundary, move the volume to the end of volumes[] + // so the stable sort in reorder_volumes_and_get_selection() places it at the end of + // its new group (see precise_seam_group_changed() for details). + if (precise_seam_group_changed(old_type, target)) + move_volume_to_end((*m_objects)[sel.object_idx], sel.volume); } + // --- Rebuild selection to follow the changed volumes --- wxDataViewItemArray new_selection; for (int obj_idx : touched_objects) { wxDataViewItemArray sel_items = reorder_volumes_and_get_selection(obj_idx, [&changed_volumes](const ModelVolume* volume) { diff --git a/src/slic3r/GUI/GUI_ObjectList.hpp b/src/slic3r/GUI/GUI_ObjectList.hpp index 5705aa2c8b..2700dbc5d8 100644 --- a/src/slic3r/GUI/GUI_ObjectList.hpp +++ b/src/slic3r/GUI/GUI_ObjectList.hpp @@ -88,6 +88,7 @@ struct MeshErrorsInfo class ObjectList : public wxDataViewCtrl { public: + enum SELECTION_MODE { smUndef = 0, @@ -430,7 +431,12 @@ public: #if 0 // ORCA: disabled alongside definition in GUI_ObjectList.cpp (see #if 0 block there) void change_part_type(); #endif - void set_volume_type(ModelVolumeType new_type); + // preserve_ps_subtype = true: when new_type is PRECISE_SEAM_CENTER, volumes that are + // already Precise Seam keep their existing subtype (LEFT/RIGHT/etc.). Used by the + // generic "Change type → Precise Seam" entry where CENTER is a default fallback. + // preserve_ps_subtype = false: no preservation — target type is applied verbatim. Used + // by the "Precise Seam Type" subtype picker where the user explicitly wants CENTER. + void set_volume_type(ModelVolumeType new_type, bool preserve_ps_subtype = true); ModelVolumeType get_selected_volume_type(); void last_volume_is_deleted(const int obj_idx); diff --git a/src/slic3r/GUI/GUI_ObjectTable.cpp b/src/slic3r/GUI/GUI_ObjectTable.cpp index c3c3295800..a7c761a06f 100644 --- a/src/slic3r/GUI/GUI_ObjectTable.cpp +++ b/src/slic3r/GUI/GUI_ObjectTable.cpp @@ -348,7 +348,8 @@ void GridCellFilamentsRenderer::Draw(wxGrid &grid, wxGridCellAttr &attr, wxDC &d if ((grid_row->model_volume_type != ModelVolumeType::NEGATIVE_VOLUME) && \ (grid_row->model_volume_type != ModelVolumeType::SUPPORT_BLOCKER) && \ (grid_row->model_volume_type != ModelVolumeType::SUPPORT_ENFORCER) && \ - (grid_row->model_volume_type != ModelVolumeType::PARAMETER_MODIFIER)) { + (grid_row->model_volume_type != ModelVolumeType::PARAMETER_MODIFIER) && \ + !is_precise_seam(grid_row->model_volume_type)) { // Precise Seam is non-printing helper geometry dc.DrawBitmap(*bitmap, wxPoint(rect.x + offset_x, rect.y + offset_y)); } else if (grid_row->model_volume_type == ModelVolumeType::PARAMETER_MODIFIER){ @@ -3011,7 +3012,8 @@ void ObjectTablePanel::load_data() if (col == ObjectGridTable::col_filaments) { if ((grid_row->model_volume_type != ModelVolumeType::NEGATIVE_VOLUME) && \ (grid_row->model_volume_type != ModelVolumeType::SUPPORT_BLOCKER) && \ - (grid_row->model_volume_type != ModelVolumeType::SUPPORT_ENFORCER)) { + (grid_row->model_volume_type != ModelVolumeType::SUPPORT_ENFORCER) && \ + !is_precise_seam(grid_row->model_volume_type)) { // Precise Seam is non-printing helper geometry GridCellFilamentsEditor* filament_editor = new GridCellFilamentsEditor(grid_col->choices, false, &m_color_bitmaps); m_object_grid->SetCellEditor(row, col, filament_editor); m_object_grid->SetCellRenderer(row, col, new GridCellFilamentsRenderer()); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp b/src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp index 1009a6e89b..da26353eb6 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoEmboss.cpp @@ -2000,7 +2000,7 @@ void GLGizmoEmboss::draw_model_type() if ((is_volume_move_inside || is_volume_move_outside)) process(); - // inspiration in ObjectList::change_part_type() + // inspiration in ObjectList::set_volume_type() // how to view correct side panel with objects ObjectList *obj_list = app.obj_list(); wxDataViewItemArray sel = obj_list->reorder_volumes_and_get_selection( diff --git a/src/slic3r/GUI/Gizmos/GLGizmoSVG.cpp b/src/slic3r/GUI/Gizmos/GLGizmoSVG.cpp index b30d399f98..94373391fa 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoSVG.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoSVG.cpp @@ -2052,7 +2052,7 @@ void GLGizmoSVG::draw_model_type() if ((is_volume_move_inside || is_volume_move_outside)) process(); - // inspiration in ObjectList::change_part_type() + // inspiration in ObjectList::set_volume_type() // how to view correct side panel with objects ObjectList *obj_list = app.obj_list(); wxDataViewItemArray sel = obj_list->reorder_volumes_and_get_selection( diff --git a/src/slic3r/GUI/NotificationManager.cpp b/src/slic3r/GUI/NotificationManager.cpp index 2ee875901c..e6172135a6 100644 --- a/src/slic3r/GUI/NotificationManager.cpp +++ b/src/slic3r/GUI/NotificationManager.cpp @@ -11,6 +11,7 @@ #include "MainFrame.hpp" #include "Tab.hpp" #include "libslic3r/Config.hpp" +#include "libslic3r/PrintBase.hpp" #include "format.hpp" #include @@ -3084,7 +3085,9 @@ bool NotificationManager::push_notification_data(std::unique_ptractivate_existing(notification.get())) { if (m_initialized && m_imgui_ready) { - if (notification->get_type() == NotificationType::SlicingWarning) { + // Precise Seam already aggregates all causes; replace it on repeated warning events. + if (notification->get_type() == NotificationType::SlicingWarning && + notification->get_data().sub_msg_id != PrintStateBase::SlicingPreciseSeamWarning) { m_pop_notifications.back()->append(notification->get_data().ori_text); } else { m_pop_notifications.back()->update(notification->get_data()); diff --git a/src/slic3r/GUI/ObjectDataViewModel.cpp b/src/slic3r/GUI/ObjectDataViewModel.cpp index b0b7e891c4..a20dc50877 100644 --- a/src/slic3r/GUI/ObjectDataViewModel.cpp +++ b/src/slic3r/GUI/ObjectDataViewModel.cpp @@ -546,7 +546,8 @@ void ObjectDataViewModel::UpdateBitmapForNode(ObjectDataViewModelNode *node) { bool is_volume_node = node->GetType() & itVolume; int vol_type = static_cast(node->GetVolumeType()); - is_volume_node &= (vol_type >= int(ModelVolumeType::MODEL_PART) && vol_type <= int(ModelVolumeType::SUPPORT_ENFORCER)); + // Extended range to include Precise Seam modifier types + is_volume_node &= (vol_type >= int(ModelVolumeType::MODEL_PART) && vol_type <= int(ModelVolumeType::PRECISE_SEAM_NEUTRAL)); if (!node->has_warning_icon() && !node->has_lock()) { node->SetBitmap(is_volume_node ? ( diff --git a/src/slic3r/GUI/Selection.cpp b/src/slic3r/GUI/Selection.cpp index 84f4a0b836..81dd074096 100644 --- a/src/slic3r/GUI/Selection.cpp +++ b/src/slic3r/GUI/Selection.cpp @@ -67,7 +67,7 @@ bool Selection::Clipboard::is_sla_compliant() const return false; for (const ModelVolume* v : o->volumes) { - if (v->is_modifier()) + if (v->is_modifier() || v->is_precise_seam()) // Precise Seam not supported in SLA return false; } } diff --git a/src/slic3r/plugin/host/PluginHostModel.cpp b/src/slic3r/plugin/host/PluginHostModel.cpp index 3b571da230..3da1c2ae6f 100644 --- a/src/slic3r/plugin/host/PluginHostModel.cpp +++ b/src/slic3r/plugin/host/PluginHostModel.cpp @@ -24,7 +24,14 @@ void host_bindings::register_model(py::module_& host) .value("NegativeVolume", ModelVolumeType::NEGATIVE_VOLUME) .value("ParameterModifier", ModelVolumeType::PARAMETER_MODIFIER) .value("SupportBlocker", ModelVolumeType::SUPPORT_BLOCKER) - .value("SupportEnforcer", ModelVolumeType::SUPPORT_ENFORCER); + .value("SupportEnforcer", ModelVolumeType::SUPPORT_ENFORCER) + // Expose every seam mode returned by ModelVolume::type() to plugins. + .value("PreciseSeamCenter", ModelVolumeType::PRECISE_SEAM_CENTER) + .value("PreciseSeamLeft", ModelVolumeType::PRECISE_SEAM_LEFT) + .value("PreciseSeamRight", ModelVolumeType::PRECISE_SEAM_RIGHT) + .value("PreciseSeamEnforced", ModelVolumeType::PRECISE_SEAM_ENFORCED) + .value("PreciseSeamBlocked", ModelVolumeType::PRECISE_SEAM_BLOCKED) + .value("PreciseSeamNeutral", ModelVolumeType::PRECISE_SEAM_NEUTRAL); py::class_>(host, "ModelVolume") .def("id", [](const ModelVolume& volume) { return volume.id().id; }) @@ -36,6 +43,10 @@ void host_bindings::register_model(py::module_& host) .def("is_support_enforcer", &ModelVolume::is_support_enforcer) .def("is_support_blocker", &ModelVolume::is_support_blocker) .def("is_support_modifier", &ModelVolume::is_support_modifier) + // Let plugins identify seam helper geometry without relying on enum ordering. + .def("is_precise_seam", &ModelVolume::is_precise_seam) + .def("is_precise_seam_strong", &ModelVolume::is_precise_seam_strong) + .def("is_precise_seam_weak", &ModelVolume::is_precise_seam_weak) // Extruder ID is 1-based for FFF, -1 for SLA or support volumes. .def("extruder_id", &ModelVolume::extruder_id) .def("offset", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_offset()); }) diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index b52694e793..a061c5be76 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -14,9 +14,12 @@ add_executable(${_TEST_NAME}_tests test_model.cpp test_multifilament.cpp test_perimeters.cpp + # Seam geometry and pipeline regressions share the existing Print fixtures. + test_precise_seam.cpp test_print.cpp test_printobject.cpp test_mixed_filament.cpp + test_seam_placer.cpp test_skirt_brim.cpp test_slicing_pipeline_hook.cpp test_support_material.cpp diff --git a/tests/fff_print/test_precise_seam.cpp b/tests/fff_print/test_precise_seam.cpp new file mode 100644 index 0000000000..d324a91626 --- /dev/null +++ b/tests/fff_print/test_precise_seam.cpp @@ -0,0 +1,279 @@ +#include + +#include "test_helpers.hpp" +#include "libslic3r/GCode/PreciseSeam.hpp" + +#include + +using namespace Slic3r; + +namespace { +Point mm(double x, double y) { return Point(scale_(x), scale_(y)); } + +Polygon rectangle(double x0, double y0, double x1, double y1) +{ + // Counterclockwise contours match the modifier-slice cache contract. + return Polygon(Points{mm(x0, y0), mm(x1, y0), mm(x1, y1), mm(x0, y1)}); +} + +struct SeamFixture { + Model model; + Print print; + Model modifiers; + Layer *layer = nullptr; + PreciseSeam::ModifierSlicesCache cache; + + SeamFixture() + { + // Only the layer/PrintObject context is needed; clipping uses explicit cached slices below. + Test::init_print({Test::cube(20)}, print, model, {{"raft_layers", "0"}}); + REQUIRE(print.objects().size() == 1); + PrintObject *object = print.get_object(0); + layer = object->add_layer(int(object->slicing_parameters().raft_layers()), 0.2, 0.2, 0.1); + modifiers.add_object(); + } + + const ModelVolume *add(ModelVolumeType type, Polygons slices) + { + // These volumes own cache keys; mesh slicing is deliberately outside this geometry fixture. + ModelVolume *volume = modifiers.objects.front()->add_volume(Test::cube(1)); + volume->set_type(type); + cache.emplace(volume, std::vector{std::move(slices)}); + return volume; + } +}; + +void check_square_boundary(const Polygon &polygon) +{ + // Wrong insertion edges can retrace a side without changing area: check length as well. + CHECK_THAT(unscale(polygon.length()), Catch::Matchers::WithinAbs(80.0, 0.00001)); + for (const Point &p : polygon.points) { + CAPTURE(p.x(), p.y()); + CHECK(p.x() >= scale_(0)); + CHECK(p.x() <= scale_(20)); + CHECK(p.y() >= scale_(0)); + CHECK(p.y() <= scale_(20)); + const bool on_boundary = p.x() == 0 || p.x() == scale_(20) || p.y() == 0 || p.y() == scale_(20); + CHECK(on_boundary); + } +} + +void require_vertex(const Polygon &polygon, const Point &point) +{ + // Integer coordinates make the micron transition helpers exact on these axis-aligned edges. + CAPTURE(point.x(), point.y()); + REQUIRE(std::find(polygon.points.begin(), polygon.points.end(), point) != polygon.points.end()); +} +} // namespace + +TEST_CASE("Strong seam modes select the requested location on a clipped side", "[PreciseSeam]") +{ + const auto mode = GENERATE(ModelVolumeType::PRECISE_SEAM_LEFT, ModelVolumeType::PRECISE_SEAM_CENTER, + ModelVolumeType::PRECISE_SEAM_RIGHT); + SeamFixture fixture; + // Clipper may collapse all three original collinear edges into one. + Polygon perimeter(Points{mm(0, 0), mm(2, 0), mm(4, 0), mm(8, 0), mm(20, 0), mm(20, 20), mm(0, 20)}); + const ModelVolume *modifier = fixture.add(mode, {rectangle(1, -2, 13, 2)}); + PreciseSeam::PreciseSeamWarnings warnings; + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, fixture.layer, fixture.cache, &warnings); + REQUIRE(seam.has_value()); + const double expected_x = mode == ModelVolumeType::PRECISE_SEAM_LEFT ? 1.0 : + mode == ModelVolumeType::PRECISE_SEAM_RIGHT ? 13.0 : 7.0; + CHECK(*seam == mm(expected_x, 0)); + require_vertex(perimeter, mm(expected_x - 0.001, 0)); + require_vertex(perimeter, mm(expected_x + 0.001, 0)); + check_square_boundary(perimeter); + CHECK_FALSE(warnings.through_body.load()); + CHECK_FALSE(warnings.full_containment.load()); + CHECK_FALSE(warnings.multiple_intersections.load()); +} + +TEST_CASE("Center seams preserve the edge order at vertices and across the contour origin", "[PreciseSeam]") +{ + const bool wrap = GENERATE(false, true); + SeamFixture fixture; + Polygon perimeter(Points{mm(0, 0), mm(4, 0), mm(20, 0), mm(20, 20), mm(0, 20)}); + // Symmetric cuts put the arc midpoint exactly on an existing vertex, including vertex zero. + const Polygon cut = wrap ? rectangle(-2, -2, 4, 4) : rectangle(1, -2, 7, 2); + const ModelVolume *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {cut}); + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, fixture.layer, fixture.cache); + REQUIRE(seam.has_value()); + CHECK(*seam == (wrap ? mm(0, 0) : mm(4, 0))); + require_vertex(perimeter, wrap ? mm(0, 0.001) : mm(3.999, 0)); + require_vertex(perimeter, wrap ? mm(0.001, 0) : mm(4.001, 0)); + check_square_boundary(perimeter); +} + +TEST_CASE("Center seams land on the closing edge", "[PreciseSeam]") +{ + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const auto *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(-2, 3, 2, 9)}); + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, fixture.layer, fixture.cache); + REQUIRE(seam.has_value()); + CHECK(*seam == mm(0, 6)); + require_vertex(perimeter, mm(0, 5.999)); + require_vertex(perimeter, mm(0, 6.001)); + check_square_boundary(perimeter); +} + +TEST_CASE("Coincident weak boundaries do not prevent later boundary refinement", "[PreciseSeam][Regression]") +{ + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + // Duplicate boundaries used to stall the reverse cursor before reaching the separate segment. + const auto *a = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(2, -2, 6, 2)}); + const auto *b = fixture.add(ModelVolumeType::PRECISE_SEAM_NEUTRAL, {rectangle(2, -2, 6, 2)}); + const auto *c = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(10, -2, 14, 2)}); + const auto segments = PreciseSeam::collect_weak_modifier_segments({c, b, a}, perimeter, fixture.layer, fixture.cache); + REQUIRE(segments.size() == 3); + for (double x : {1.999, 6.001, 9.999, 14.001}) + require_vertex(perimeter, mm(x, 0)); + check_square_boundary(perimeter); +} + +TEST_CASE("Weak boundaries sharing a vertex refine both sides", "[PreciseSeam]") +{ + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const auto *a = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(2, -2, 6, 2)}); + const auto *b = fixture.add(ModelVolumeType::PRECISE_SEAM_NEUTRAL, {rectangle(6, -2, 10, 2)}); + const auto segments = PreciseSeam::collect_weak_modifier_segments({a, b}, perimeter, fixture.layer, fixture.cache); + REQUIRE(segments.size() == 2); + require_vertex(perimeter, mm(5.999, 0)); + require_vertex(perimeter, mm(6.001, 0)); + check_square_boundary(perimeter); +} + +TEST_CASE("Unsupported modifier sections are skipped with the appropriate warning", "[PreciseSeam]") +{ + const int scenario = GENERATE(0, 1, 2, 3); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const Points original = perimeter.points; + Polygons slices; + if (scenario == 0) slices = {rectangle(30, 30, 40, 40)}; // Disjoint bounds. + if (scenario == 1) slices = {rectangle(2, 2, 4, 4)}; // Wholly inside; no common boundary. + if (scenario == 2) slices = {rectangle(-2, -2, 22, 22)}; // Contains the entire perimeter. + if (scenario == 3) { + Polygon hole = rectangle(2, 2, 4, 4); + hole.reverse(); + slices = {rectangle(-2, -2, 22, 22), hole}; + } + const auto *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, std::move(slices)); + PreciseSeam::PreciseSeamWarnings warnings; + CHECK_FALSE(PreciseSeam::insert_strong_seam_point({modifier}, perimeter, fixture.layer, fixture.cache, &warnings).has_value()); + CHECK(perimeter.points == original); + CHECK(warnings.full_containment.load() == (scenario == 2)); + CHECK(warnings.multiply_connected.load() == (scenario == 3)); + CHECK_FALSE(warnings.multiple_intersections.load()); + CHECK_FALSE(warnings.through_body.load()); +} + +TEST_CASE("Strong modifiers warn when another slice polygon also intersects the perimeter", "[PreciseSeam]") +{ + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + // One helper has two disconnected sections; only its first section supplies the seam. + const auto *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, + {rectangle(2, -2, 6, 2), rectangle(12, -2, 16, 2)}); + PreciseSeam::PreciseSeamWarnings warnings; + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, fixture.layer, fixture.cache, &warnings); + REQUIRE(seam.has_value()); + CHECK(*seam == mm(4, 0)); + CHECK(warnings.multiple_intersections.load()); + CHECK_FALSE(warnings.through_body.load()); + CHECK_FALSE(warnings.full_containment.load()); + CHECK_FALSE(warnings.multiply_connected.load()); + check_square_boundary(perimeter); +} + +TEST_CASE("Modifiers crossing the entire body raise a through body warning", "[PreciseSeam]") +{ + const bool strong = GENERATE(false, true); + CAPTURE(strong); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + // The strip exits on opposite sides, leaving two exterior pieces. None of the clipped + // vertices matches a square corner, so this also exercises the general segment-extraction path. + const auto type = strong ? ModelVolumeType::PRECISE_SEAM_CENTER : ModelVolumeType::PRECISE_SEAM_BLOCKED; + const auto *modifier = fixture.add(type, {rectangle(8, -2, 12, 22)}); + PreciseSeam::PreciseSeamWarnings warnings; + if (strong) { + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, fixture.layer, fixture.cache, &warnings); + REQUIRE(seam.has_value()); + CHECK(seam->x() == mm(10, 0).x()); + // Either boundary segment may be encountered first by the clipping traversal. + const bool on_crossed_side = seam->y() == 0 || seam->y() == mm(0, 20).y(); + CHECK(on_crossed_side); + } else { + const auto segments = PreciseSeam::collect_weak_modifier_segments({modifier}, perimeter, fixture.layer, fixture.cache, &warnings); + REQUIRE_FALSE(segments.empty()); + } + CHECK(warnings.through_body.load()); + CHECK_FALSE(warnings.multiple_intersections.load()); // The clipped strip is one polygon. + CHECK_FALSE(warnings.full_containment.load()); + CHECK_FALSE(warnings.multiply_connected.load()); + check_square_boundary(perimeter); +} + +TEST_CASE("Modifier hierarchy keeps strong order and applies the highest weak priority last", "[PreciseSeam]") +{ + const auto high_type = GENERATE(ModelVolumeType::PRECISE_SEAM_BLOCKED, ModelVolumeType::PRECISE_SEAM_NEUTRAL, + ModelVolumeType::PRECISE_SEAM_ENFORCED); + const auto expected_type = high_type == ModelVolumeType::PRECISE_SEAM_BLOCKED ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked : + high_type == ModelVolumeType::PRECISE_SEAM_NEUTRAL ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral : + SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced; + SeamFixture fixture; + const auto *strong_a = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(1, -2, 3, 2)}); + const auto *strong_b = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(11, -2, 13, 2)}); + const auto *high = fixture.add(high_type, {rectangle(2, -2, 6, 2)}); + const auto low_type = high_type == ModelVolumeType::PRECISE_SEAM_ENFORCED ? ModelVolumeType::PRECISE_SEAM_BLOCKED : + ModelVolumeType::PRECISE_SEAM_ENFORCED; + const auto *low = fixture.add(low_type, {rectangle(2, -2, 6, 2)}); + std::vector strong, weak; + bool has_strong = false; + PreciseSeam::init_precise_seam_data(strong, weak, has_strong, fixture.modifiers.objects.front()); + REQUIRE(has_strong); + CHECK(strong == std::vector{strong_a, strong_b}); + CHECK(weak == std::vector{low, high}); + Polygon perimeter = rectangle(0, 0, 20, 20); + const auto seam = PreciseSeam::insert_strong_seam_point(strong, perimeter, fixture.layer, fixture.cache); + REQUIRE(seam.has_value()); + CHECK(*seam == mm(2, 0)); + + perimeter = rectangle(0, 0, 20, 20); + const auto segments = PreciseSeam::collect_weak_modifier_segments(weak, perimeter, fixture.layer, fixture.cache); + REQUIRE(segments.size() == 2); + PrintObjectSeamData::LayerSeams result; + result.perimeters.emplace_back(); + auto &loop = result.perimeters.back(); + // Include a preceding candidate to exercise nonzero global layer indices. + result.points.emplace_back(Vec3f(-1, -1, 0), loop, 0, SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral); + loop.start_index = 1; + for (const Point &p : perimeter.points) { + // Match production's double-to-float conversion: weak boundary lookup uses exact equality. + const Vec2f position = unscale(p).cast(); + result.points.emplace_back(Vec3f(position.x(), position.y(), 0), loop, 0, + SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral); + } + loop.end_index = result.points.size(); + bool enforced = false; + PreciseSeam::apply_weak_modifiers_to_perimeter(segments, result, loop, enforced); + size_t patch_count = 0; + for (size_t i = loop.start_index; i < loop.end_index; ++i) { + const auto &candidate = result.points[i]; + // Axis-aligned input and interpolation keep y exactly zero; this classifies, rather than measures, the patch. + const bool in_patch = candidate.position.y() == 0 && candidate.position.x() >= 2 && candidate.position.x() <= 6; + CHECK(candidate.type == (in_patch ? expected_type : + SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral)); + if (in_patch) ++patch_count; + } + CHECK(patch_count >= 2); + if (high_type == ModelVolumeType::PRECISE_SEAM_ENFORCED) { + // Four millimetres of enforcement must be subdivided, not just marked at its endpoints. + CHECK(enforced); + CHECK(patch_count >= size_t(4.0f / SeamPlacer::enforcer_oversampling_distance)); + } + CHECK(result.points.front().type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral); +} diff --git a/tests/fff_print/test_seam_placer.cpp b/tests/fff_print/test_seam_placer.cpp new file mode 100644 index 0000000000..81b7cd806c --- /dev/null +++ b/tests/fff_print/test_seam_placer.cpp @@ -0,0 +1,281 @@ +#include + +#include "test_helpers.hpp" +#include "libslic3r/GCode/SeamPlacer.hpp" +#include "libslic3r/Layer.hpp" +#include "libslic3r/TriangleSelector.hpp" + +#include +#include + +using namespace Slic3r; + +namespace { +struct PipelineFixture { + Model model; + Print print; + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + + explicit PipelineFixture(bool trapezoid = false) + { + auto mesh = its_make_cube(20, 20, 0.4); + if (trapezoid) { + // Opposite painted sides have deliberately different lengths: 20 mm and 6 mm. + for (auto &vertex : mesh.vertices) + if (vertex.y() == 20.0f) vertex.x() = 7.0f + 0.3f * vertex.x(); + } + config.set_deserialize_strict("seam_position", "back"); // Rear needs no visibility ray tracing. + config.set_deserialize_strict("layer_height", "0.2"); + config.set_deserialize_strict("initial_layer_print_height", "0.2"); + config.set_deserialize_strict("outer_wall_line_width", "0.4"); + config.set_deserialize_strict("initial_layer_line_width", "0.4"); + config.set_deserialize_strict("wall_loops", "1"); + config.set_deserialize_strict("raft_layers", "0"); + config.set_deserialize_strict("gcode_comments", "1"); // Match init_print so later apply calls change only the model. + Test::init_print({TriangleMesh(std::move(mesh))}, print, model, config); + } + + void paint(bool all_faces) + { + auto &volume = *model.objects.front()->volumes.front(); + const auto &mesh = volume.mesh(); + const auto bounds = mesh.bounding_box(); + TriangleSelector selector(mesh); + size_t painted = 0; + for (size_t i = 0; i < mesh.its.indices.size(); ++i) { + const auto &face = mesh.its.indices[i]; + bool lower = true, upper = true; + for (int j = 0; j < 3; ++j) { + const auto &v = mesh.its.vertices[face[j]]; + lower = lower && std::abs(double(v.y()) - bounds.min.y()) < 1e-6; + upper = upper && std::abs(double(v.y()) - bounds.max.y()) < 1e-6; + } + if (all_faces || lower || upper) { + selector.set_facet(int(i), EnforcerBlockerType::ENFORCER); + ++painted; + } + } + REQUIRE(painted > 0); + volume.seam_facets.set(selector); + print.apply(model, config); + } + + PrintObject &prepare() + { + REQUIRE(print.objects().size() == 1); + auto &object = *print.get_object(0); + object.slice(); // Real layers/regions are sufficient: each test supplies its own perimeter loops. + REQUIRE_FALSE(object.layers().empty()); + REQUIRE_FALSE(object.layers().front()->regions().empty()); + return object; + } + + Points points_in_layer(const PrintObject &object, const std::vector &xy) const + { + const auto &volume = *object.model_object()->volumes.front(); + const auto minimum = volume.mesh().bounding_box().min; + const Transform3d transform = object.trafo_centered() * volume.get_matrix(); + Points points; + for (const auto &point : xy) { + // add_volume centers the mesh; restore its local offset before applying the slicing transform. + const Vec3d local = minimum + Vec3d(point.x(), point.y(), 0.2); + const Vec3d placed = transform * local; + points.emplace_back(scale_(placed.x()), scale_(placed.y())); + } + return points; + } +}; + +void append_loop(LayerRegion ®ion, Points points, bool separate_paths = false) +{ + // Inject deterministic external loops while keeping the real layer, region and paint-query machinery. + REQUIRE(points.size() >= 3); + points.push_back(points.front()); + ExtrusionPaths paths; + if (separate_paths) { + for (size_t i = 1; i < points.size(); ++i) { + ExtrusionPath path(erExternalPerimeter, 0.08, 0.4f, 0.2f); + path.polyline = Polyline3(Polyline(Points{points[i - 1], points[i]})); + paths.push_back(std::move(path)); + } + } else { + ExtrusionPath path(erExternalPerimeter, 0.08, 0.4f, 0.2f); + path.polyline = Polyline3(Polyline(std::move(points))); + paths.push_back(std::move(path)); + } + region.perimeters.append(ExtrusionLoop(std::move(paths))); +} + +LayerRegion &clear_first_layer(PrintObject &object) +{ + Layer &layer = *object.layers().front(); + for (LayerRegion *region : layer.regions()) region->perimeters.clear(); + return *layer.get_region(0); +} +} // namespace + +TEST_CASE("Painted seams prefer the longer candidate patch regardless of contour origin", "[SeamPlacer][Regression]") +{ + const bool clockwise = GENERATE(false, true); + const bool wrapped = GENERATE(false, true); + CAPTURE(clockwise, wrapped); + PipelineFixture fixture(true); + fixture.paint(false); + PrintObject &object = fixture.prepare(); + REQUIRE(object.model_object()->volumes.size() == 1); + CHECK_FALSE(object.model_object()->volumes.front()->is_precise_seam()); + + auto ®ion = clear_first_layer(object); + // A neutral loop ensures that patch indices are offsets in the layer, not zero-based local indices. + append_loop(region, fixture.points_in_layer(object, {{9, 8}, {11, 8}, {10, 10}})); + Points outline = fixture.points_in_layer(object, {{10, 20}, {7, 20}, {3.5, 10}, {0, 0}, {10, 0}, {20, 0}, {13, 20}}); + if (clockwise) std::reverse(outline.begin() + 1, outline.end()); // Keep the same starting vertex. + if (!wrapped) { + const Point neutral = fixture.points_in_layer(object, {{3.5, 10}}).front(); + const auto start = std::find(outline.begin(), outline.end(), neutral); + REQUIRE(start != outline.end()); + std::rotate(outline.begin(), start, outline.end()); + } + append_loop(region, std::move(outline)); + SeamPlacer placer; + placer.init(fixture.print, [] {}); + const auto &data = placer.m_seam_per_object.at(&object).layers.front(); + REQUIRE(data.perimeters.size() == 2); + const auto &perimeter = data.perimeters[1]; + REQUIRE(perimeter.start_index > 0); + REQUIRE(perimeter.end_index > perimeter.start_index); + using Type = SeamPlacerImpl::EnforcedBlockedSeamPoint; + CHECK((data.points[perimeter.start_index].type == Type::Enforced) == wrapped); + if (wrapped) CHECK(data.points[perimeter.end_index - 1].type == Type::Enforced); + + const auto extremes = fixture.points_in_layer(object, {{10, 0}, {10, 20}}); + const double bottom_y = unscale(extremes[0].y()), top_y = unscale(extremes[1].y()); + size_t bottom_count = 0, top_count = 0, centers = 0; + for (size_t i = perimeter.start_index; i < perimeter.end_index; ++i) { + const auto &candidate = data.points[i]; + if (candidate.type == Type::Enforced) { + // Include the small paint-radius fringe at the ends of each face. + const bool bottom = std::abs(candidate.position.y() - bottom_y) < 0.5; + const bool top = std::abs(candidate.position.y() - top_y) < 0.5; + const bool on_painted_face = bottom || top; + CAPTURE(candidate.position.x(), candidate.position.y()); + CHECK(on_painted_face); + bottom_count += bottom; + top_count += top; + } + if (candidate.central_enforcer) { + ++centers; + CHECK(candidate.type == Type::Enforced); + CHECK_THAT(double(candidate.position.y()), Catch::Matchers::WithinAbs(bottom_y, 0.5)); + } + } + REQUIRE(top_count > 0); + REQUIRE(bottom_count > top_count); + CHECK(centers == 1); // The old wrapped-length formula instead selected the short top patch. +} + +TEST_CASE("Entirely painted contours keep valid enforced seam candidates", "[SeamPlacer]") +{ + PipelineFixture fixture; + fixture.paint(true); + PrintObject &object = fixture.prepare(); + auto ®ion = clear_first_layer(object); + append_loop(region, fixture.points_in_layer(object, {{0, 0}, {20, 0}, {20, 20}, {0, 20}})); + SeamPlacer placer; + placer.init(fixture.print, [] {}); + const auto &data = placer.m_seam_per_object.at(&object).layers.front(); + REQUIRE(data.perimeters.size() == 1); + const auto &perimeter = data.perimeters.front(); + CHECK(perimeter.seam_index >= perimeter.start_index); + CHECK(perimeter.seam_index < perimeter.end_index); + for (const auto &candidate : data.points) { + CHECK(candidate.type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced); + CHECK_FALSE(candidate.central_enforcer); // There is no bounded patch to mark as central. + } +} + +TEST_CASE("Precise Seam removes path junction duplicates but preserves separate visits", "[SeamPlacer][PreciseSeam]") +{ + const bool enable_ps = GENERATE(false, true); + const bool self_touch = GENERATE(false, true); + PipelineFixture fixture; + if (enable_ps) { + auto *helper = fixture.model.objects.front()->add_volume(make_cube(1, 1, 1)); + helper->set_type(ModelVolumeType::PRECISE_SEAM_NEUTRAL); + helper->set_offset(Vec3d(100, 100, 0)); // Enable normalization without intersecting the synthetic loop. + fixture.print.apply(fixture.model, fixture.config); + } + PrintObject &object = fixture.prepare(); + auto ®ion = clear_first_layer(object); + const std::vector vertices = self_touch ? std::vector{{2, 2}, {10, 2}, {18, 10}, {10, 2}, {2, 18}} : + std::vector{{2, 2}, {18, 2}, {18, 18}, {2, 18}}; + const Points outline = fixture.points_in_layer(object, vertices); + append_loop(region, outline, true); + SeamPlacer placer; + placer.init(fixture.print, [] {}); + const auto &data = placer.m_seam_per_object.at(&object).layers.front(); + REQUIRE(data.perimeters.size() == 1); + // Each separate path contributes both endpoints in ordinary mode; PS removes only adjacent copies. + REQUIRE(data.points.size() == (enable_ps ? outline.size() : 2 * outline.size())); + for (size_t i = 0; i < outline.size(); ++i) { + const Vec2f target = unscale(outline[i]).cast(); + const size_t input_count = std::count(outline.begin(), outline.end(), outline[i]); + // Both paths convert the same integer coordinates to float; exact identity detects duplicate copies. + const size_t actual_count = std::count_if(data.points.begin(), data.points.end(), [&](const auto &candidate) { + return candidate.position.template head<2>() == target; + }); + CHECK(actual_count == (enable_ps ? input_count : 2 * input_count)); + } + if (enable_ps) { + for (size_t i = 0; i < data.points.size(); ++i) { + CHECK(std::isfinite(data.points[i].local_ccw_angle)); + CHECK(data.points[i].position != data.points[(i + 1) % data.points.size()].position); + } + } +} + +TEST_CASE("Print apply synchronizes support and seam helpers through type changes and restored models", "[SeamPlacer][PreciseSeam][Print]") +{ + const int changed = GENERATE(0, 1, 2); // Support only, seam only, or both including cross-family switches. + PipelineFixture fixture; + auto *model_object = fixture.model.objects.front(); + auto *support = model_object->add_volume(make_cube(1, 1, 1)); + support->set_type(ModelVolumeType::SUPPORT_BLOCKER); + auto *seam = model_object->add_volume(make_cube(1, 1, 1)); + seam->set_type(ModelVolumeType::PRECISE_SEAM_CENTER); + fixture.print.apply(fixture.model, fixture.config); + REQUIRE(fixture.print.objects().size() == 1); + const PrintObject *original_print_object = fixture.print.objects().front(); + const ModelVolume *original_part = original_print_object->model_object()->volumes.front(); + const Model before(fixture.model); // A restored model snapshot preserves IDs, as the apply path requires. + if (changed == 0 || changed == 2) { + support->set_type(changed == 2 ? ModelVolumeType::PRECISE_SEAM_LEFT : ModelVolumeType::SUPPORT_ENFORCER); + support->set_offset(Vec3d(3, 4, 0)); + } + if (changed == 1 || changed == 2) { + seam->set_type(changed == 2 ? ModelVolumeType::SUPPORT_BLOCKER : ModelVolumeType::PRECISE_SEAM_RIGHT); + seam->set_offset(Vec3d(-3, 2, 0)); + } + if (changed == 2) std::swap(model_object->volumes[1], model_object->volumes[2]); + const auto check_applied = [&](const Model &expected) { + REQUIRE(fixture.print.objects().size() == 1); + // Helper-only changes should preserve the print object and its unaffected printable volume. + CHECK(fixture.print.objects().front() == original_print_object); + const auto &actual = fixture.print.objects().front()->model_object()->volumes; + const auto &wanted = expected.objects.front()->volumes; + REQUIRE(actual.size() == wanted.size()); + CHECK(actual.front() == original_part); + for (size_t i = 0; i < wanted.size(); ++i) { + CAPTURE(changed, i); + CHECK(actual[i]->id() == wanted[i]->id()); + CHECK(actual[i]->type() == wanted[i]->type()); + CHECK(actual[i]->get_matrix().isApprox(wanted[i]->get_matrix(), 1e-9)); + } + }; + fixture.print.apply(fixture.model, fixture.config); + check_applied(fixture.model); + fixture.print.apply(before, fixture.config); + check_applied(before); + fixture.print.apply(fixture.model, fixture.config); + check_applied(fixture.model); +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 185dce37da..5ee438a0e4 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -3,6 +3,8 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) add_executable(${_TEST_NAME}_tests ${_TEST_NAME}_tests.cpp test_3mf.cpp + # Round-trip seam metadata and active/dormant volume settings in both formats. + test_precise_seam_3mf.cpp test_aabbindirect.cpp test_appconfig.cpp test_arachne_walls.cpp diff --git a/tests/libslic3r/test_precise_seam_3mf.cpp b/tests/libslic3r/test_precise_seam_3mf.cpp new file mode 100644 index 0000000000..6449236744 --- /dev/null +++ b/tests/libslic3r/test_precise_seam_3mf.cpp @@ -0,0 +1,372 @@ +#include + +#include "test_utils.hpp" +#include "libslic3r/Model.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/Preset.hpp" +#include "libslic3r/Semver.hpp" +#include "libslic3r/Format/3mf.hpp" +#include "libslic3r/Format/bbs_3mf.hpp" +#include "libslic3r/miniz_extension.hpp" +#include "libslic3r/Zipper.hpp" + +#include +#include +#include +#include + +using namespace Slic3r; + +namespace { +enum class Backend { Bbs, Prusa }; +constexpr std::array seam_types = { + ModelVolumeType::PRECISE_SEAM_CENTER, ModelVolumeType::PRECISE_SEAM_LEFT, + ModelVolumeType::PRECISE_SEAM_RIGHT, ModelVolumeType::PRECISE_SEAM_ENFORCED, + ModelVolumeType::PRECISE_SEAM_BLOCKED, ModelVolumeType::PRECISE_SEAM_NEUTRAL +}; +// Literal entities and backslashes distinguish XML escaping from config serialization. +const std::string notes = "quoted \"value\" & \tcolumn\nnext line & path\\file"; + +struct Scene { + // Keep the backup directory alive longer than its model; no project files are touched. + ScopedTemporaryDir backup{"orca_seam_3mf"}; + Model model; + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + bool share_mesh = false; + + Scene() + { + model.set_backup_path(backup.string()); + // Import normalizes extruder indices against this list; explicitly provide filament 2. + config.set_key_value("filament_settings_id", new ConfigOptionStrings(std::vector{"A", "B"})); + } + + void populate(bool all_modes, bool shared_mesh = false) + { + share_mesh = shared_mesh; + auto *object = model.add_object(); + object->name = "seam round trip"; + auto *part = object->add_volume(make_cube(20, 20, 2)); + part->name = "printable"; + ModelVolume *first_helper = nullptr; + for (size_t i = 0; i < (all_modes ? seam_types.size() : size_t(1)); ++i) { + auto *volume = shared_mesh && first_helper ? object->add_volume_with_shared_mesh(*first_helper) : + object->add_volume(make_cube(2, 3, 4)); + if (!first_helper) first_helper = volume; + volume->name = "helper_" + std::to_string(i); + volume->set_type(seam_types[i]); + Geometry::Transformation transform; + transform.set_offset(Vec3d(3.0 * double(i), -2.0, 1.0)); + transform.set_rotation(Vec3d(0.0, 0.0, 0.1 * double(i + 1))); + transform.set_scaling_factor(Vec3d(1.0, 1.2, 0.8)); + volume->set_transformation(transform); + // Simulate settings retained by conversion from a part/modifier into a seam helper. + volume->config.set_key_value("extruder", new ConfigOptionInt(2)); + volume->config.set_key_value("sparse_infill_density", new ConfigOptionPercent(100.0 - 5.0 * double(i))); + volume->config.set_key_value("notes", new ConfigOptionString(notes + std::to_string(i))); + } + object->add_instance(); + } +}; + +void save(const std::string &path, Backend backend, Scene &scene) +{ + if (backend == Backend::Prusa) { + REQUIRE(store_3mf(path.c_str(), &scene.model, &scene.config, false)); + } else { + StoreParams params; + params.path = path.c_str(); + params.model = &scene.model; + params.config = &scene.config; + params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + if (scene.share_mesh) params.strategy = params.strategy | SaveStrategy::ShareMesh; + REQUIRE(store_bbs_3mf(params)); + } +} + +void load(const std::string &path, Backend backend, Scene &scene) +{ + ConfigSubstitutionContext substitutions{ForwardCompatibilitySubstitutionRule::Enable}; + if (backend == Backend::Prusa) { + REQUIRE(load_3mf(path.c_str(), scene.config, substitutions, &scene.model, false)); + } else { + struct ImportedResources { + PlateDataPtrs plates; + std::vector presets; + // Catch failures must not leak importer-owned auxiliary objects. + ~ImportedResources() { release_PlateData_list(plates); for (auto *preset : presets) delete preset; } + } resources; + bool bbs = false, orca = false; + Semver version; + REQUIRE(load_bbs_3mf(path.c_str(), &scene.config, &substitutions, &scene.model, &resources.plates, + &resources.presets, &bbs, &orca, &version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig)); + } +} + +using Archive = std::vector>; +Archive read_archive(const std::string &path) +{ + struct Reader { + mz_zip_archive zip{}; + ~Reader() { if (zip.m_pState) close_zip_reader(&zip); } + } reader; + REQUIRE(open_zip_reader(&reader.zip, path)); + Archive entries; + for (mz_uint i = 0; i < mz_zip_reader_get_num_files(&reader.zip); ++i) { + mz_zip_archive_file_stat stat; + REQUIRE(mz_zip_reader_file_stat(&reader.zip, i, &stat)); + if (stat.m_is_directory) continue; + std::string name(stat.m_filename); + std::replace(name.begin(), name.end(), '\\', '/'); + std::string data(size_t(stat.m_uncomp_size), '\0'); + if (!data.empty()) REQUIRE(mz_zip_reader_extract_to_mem(&reader.zip, i, data.data(), data.size(), 0)); + entries.emplace_back(std::move(name), std::move(data)); + } + return entries; +} + +std::string &model_xml(Archive &archive, Backend backend) +{ + const std::string name = backend == Backend::Bbs ? "Metadata/model_settings.config" : "Metadata/Slic3r_PE_model.config"; + const auto found = std::find_if(archive.begin(), archive.end(), [&](const auto &entry) { return entry.first == name; }); + REQUIRE(found != archive.end()); + return found->second; +} + +void write_archive(const std::string &path, const Archive &archive) +{ + // Repack a separate temporary archive, preserving every entry except explicitly edited metadata. + Zipper zipper(path); + for (const auto &entry : archive) zipper.add_entry(entry.first, entry.second.data(), entry.second.size()); + zipper.finalize(); +} + +void replace_once(std::string &text, const std::string &from, const std::string &to) +{ + const auto pos = text.find(from); + REQUIRE(pos != std::string::npos); + REQUIRE(text.find(from, pos + from.size()) == std::string::npos); + text.replace(pos, from.size(), to); +} + +std::string remove_mode_metadata(std::string &xml) +{ + // Mutations must locate exactly one real metadata element; silent no-op rewrites would give false confidence. + const auto key = xml.find("key=\"precise_seam_type\""); + REQUIRE(key != std::string::npos); + REQUIRE(xml.find("key=\"precise_seam_type\"", key + 1) == std::string::npos); + const auto begin = xml.rfind("", key); + REQUIRE(begin != std::string::npos); + REQUIRE(end != std::string::npos); + const std::string result = xml.substr(begin, end + 2 - begin); + xml.erase(begin, result.size()); + return result; +} + +void check_config(const ModelVolume &volume, size_t index = 0) +{ + REQUIRE(volume.config.has("extruder")); + CHECK(volume.config.opt_int("extruder") == 2); + REQUIRE(volume.config.has("sparse_infill_density")); + CHECK_THAT(volume.config.opt_float("sparse_infill_density"), Catch::Matchers::WithinAbs(100.0 - 5.0 * double(index), 1e-9)); + REQUIRE(volume.config.has("notes")); + CHECK(volume.config.get().opt_string("notes") == notes + std::to_string(index)); +} + +void check_modifier_config(const ModelVolume &volume, Backend backend, size_t index = 0) +{ + if (backend == Backend::Bbs) { + check_config(volume, index); + } else { + // The Prusa importer whitelists extruder, but drops ordinary notes and infill settings. + REQUIRE(volume.config.has("extruder")); + CHECK(volume.config.opt_int("extruder") == 2); + CHECK_FALSE(volume.config.has("sparse_infill_density")); + CHECK_FALSE(volume.config.has("notes")); + } +} + +void check_geometry(const ModelVolume &before, const ModelVolume &after) +{ + // Importers may recenter a mesh and compensate in its transform; compare transformed vertices. + REQUIRE(after.mesh().its.vertices.size() == before.mesh().its.vertices.size()); + CHECK(after.mesh().its.indices.size() == before.mesh().its.indices.size()); + std::vector expected; + for (const auto &v : before.mesh().its.vertices) expected.push_back(before.get_matrix() * v.cast()); + for (const auto &v : after.mesh().its.vertices) { + const Vec3d actual = after.get_matrix() * v.cast(); + const auto match = std::find_if(expected.begin(), expected.end(), [&](const Vec3d &p) { return (p - actual).norm() < 1e-4; }); + REQUIRE(match != expected.end()); + expected.erase(match); // Match multiplicities, not merely membership in the vertex set. + } + CHECK(expected.empty()); +} +} // namespace + +TEST_CASE("All Precise Seam types and dormant settings survive a 3MF round trip", "[PreciseSeam3mf]") +{ + const auto backend = GENERATE(Backend::Bbs, Backend::Prusa); + const bool shared = GENERATE(false, true); + CAPTURE(int(backend), shared); + Scene source; + source.populate(true, shared); + ScopedTemporaryFile file(".3mf"); + save(file.string(), backend, source); + Archive archive = read_archive(file.string()); + const auto &xml = model_xml(archive, backend); + const std::string open = backend == Backend::Bbs ? "" : ""; + size_t pos = 0, helpers = 0; + while ((pos = xml.find(open, pos)) != std::string::npos) { + const auto end = xml.find(close, pos); + REQUIRE(end != std::string::npos); + const auto block = xml.substr(pos, end - pos); + if (block.find("key=\"precise_seam_type\"") != std::string::npos) { + ++helpers; + CHECK(block.find(backend == Backend::Bbs ? "subtype=\"modifier_part\"" : "value=\"ParameterModifier\"") != std::string::npos); + for (const std::string key : {"extruder", "sparse_infill_density", "notes"}) { + CAPTURE(key); + CHECK(block.find("key=\"" + key + "\"") == std::string::npos); + CHECK(block.find("key=\"precise_seam_config:" + key + "\"") != std::string::npos); + } + } + pos = end + close.size(); + } + REQUIRE(helpers == seam_types.size()); + Scene destination; + load(file.string(), backend, destination); + REQUIRE(destination.model.objects.size() == 1); + const auto &volumes = destination.model.objects.front()->volumes; + REQUIRE(volumes.size() == 1 + seam_types.size()); + CHECK(volumes.front()->is_model_part()); + for (size_t i = 0; i < seam_types.size(); ++i) { + CAPTURE(i); + CHECK(volumes[i + 1]->type() == seam_types[i]); + CHECK(volumes[i + 1]->name == "helper_" + std::to_string(i)); + check_geometry(*source.model.objects.front()->volumes[i + 1], *volumes[i + 1]); + check_config(*volumes[i + 1], i); + if (shared && backend == Backend::Bbs) + CHECK(volumes[i + 1]->mesh_ptr().get() == volumes[1]->mesh_ptr().get()); + volumes[i + 1]->set_type(ModelVolumeType::PARAMETER_MODIFIER); + } + + // Reverse conversion must survive another file round trip, not just retain config in memory. + destination.share_mesh = shared; + ScopedTemporaryFile converted_file(".3mf"); + save(converted_file.string(), backend, destination); + Archive converted_archive = read_archive(converted_file.string()); + const auto &converted_xml = model_xml(converted_archive, backend); + CHECK(converted_xml.find("key=\"precise_seam_type\"") == std::string::npos); + CHECK(converted_xml.find("key=\"precise_seam_config:") == std::string::npos); + Scene converted; + load(converted_file.string(), backend, converted); + REQUIRE(converted.model.objects.size() == 1); + const auto &converted_volumes = converted.model.objects.front()->volumes; + REQUIRE(converted_volumes.size() == volumes.size()); + for (size_t i = 0; i < seam_types.size(); ++i) { + CAPTURE(i); + CHECK(converted_volumes[i + 1]->type() == ModelVolumeType::PARAMETER_MODIFIER); + CHECK(converted_volumes[i + 1]->name == "helper_" + std::to_string(i)); + check_geometry(*volumes[i + 1], *converted_volumes[i + 1]); + check_modifier_config(*converted_volumes[i + 1], backend, i); + } +} + +TEST_CASE("Ordinary modifier settings are escaped once in 3MF attributes", "[PreciseSeam3mf][Regression]") +{ + const auto backend = GENERATE(Backend::Bbs, Backend::Prusa); + CAPTURE(int(backend)); + Scene source; + source.populate(false); + source.model.objects.front()->volumes[1]->set_type(ModelVolumeType::PARAMETER_MODIFIER); + ScopedTemporaryFile file(".3mf"); + save(file.string(), backend, source); + Archive archive = read_archive(file.string()); + const auto &xml = model_xml(archive, backend); + // Check the serialized value independently of the writer's escaping helper. Prusa drops + // ordinary notes on import, so successful loading alone would not detect double escaping. + const std::string encoded_notes = R"(quoted \"value\" & <tag> column\nnext line &amp; path\\file0)"; + CHECK(xml.find("key=\"notes\" value=\"" + encoded_notes + "\"") != std::string::npos); + Scene destination; + load(file.string(), backend, destination); + REQUIRE(destination.model.objects.size() == 1); + REQUIRE(destination.model.objects.front()->volumes.size() == 2); + const ModelVolume &modifier = *destination.model.objects.front()->volumes[1]; + CHECK(modifier.type() == ModelVolumeType::PARAMETER_MODIFIER); + check_modifier_config(modifier, backend); +} + +TEST_CASE("Seam metadata restores only recognized modes on compatible base types", "[PreciseSeam3mf][Regression]") +{ + const auto backend = GENERATE(Backend::Bbs, Backend::Prusa); + // Include both XML orders, fallback cases, and the previous inline type representation. + const int variant = GENERATE(0, 1, 2, 3, 4, 5); + CAPTURE(int(backend), variant); + Scene source; + source.populate(false); + ScopedTemporaryFile original(".3mf"), edited(".3mf"); + save(original.string(), backend, source); + Archive archive = read_archive(original.string()); + auto &xml = model_xml(archive, backend); + if (variant == 0 || variant == 1) { + const std::string metadata = remove_mode_metadata(xml); + const std::string tag = backend == Backend::Bbs ? "part" : "volume"; + // Locate the helper via its dormant config, then move its mode before/after base metadata. + const auto key = xml.find("key=\"precise_seam_config:extruder\""); + REQUIRE(key != std::string::npos); + const auto start = xml.rfind("<" + tag + " ", key); + REQUIRE(start != std::string::npos); + if (backend == Backend::Bbs) { + // Exercise base-type metadata too, not just the subtype attribute preceding all metadata. + const auto end = xml.find("", key); + REQUIRE(end != std::string::npos); + xml.insert(end, ""); + } + const auto opening_end = xml.find('>', start); + REQUIRE(opening_end != std::string::npos); + const auto insertion = variant == 0 ? opening_end + 1 : xml.find("", key); + REQUIRE(insertion != std::string::npos); + xml.insert(insertion, metadata); + } else if (variant == 2) { + replace_once(xml, "value=\"precise_seam_center\"", "value=\"unknown_future_seam\""); + } else if (variant == 3) { + remove_mode_metadata(xml); + } else if (variant == 4) { + // A known seam mode must not reinterpret an ordinary printable part. + if (backend == Backend::Bbs) replace_once(xml, "subtype=\"modifier_part\"", "subtype=\"normal_part\""); + else { + replace_once(xml, "key=\"modifier\" value=\"1\"", "key=\"modifier\" value=\"0\""); + replace_once(xml, "value=\"ParameterModifier\"", "value=\"ModelPart\""); + } + } else { + remove_mode_metadata(xml); + if (backend == Backend::Bbs) replace_once(xml, "subtype=\"modifier_part\"", "subtype=\"precise_seam_center\""); + else replace_once(xml, "value=\"ParameterModifier\"", "value=\"precise_seam_center\""); + // Older files stored settings as ordinary volume keys, without the new dormant namespace. + for (const std::string key : {"extruder", "sparse_infill_density", "notes"}) + replace_once(xml, "key=\"precise_seam_config:" + key + "\"", "key=\"" + key + "\""); + } + write_archive(edited.string(), archive); + Scene destination; + load(edited.string(), backend, destination); + REQUIRE(destination.model.objects.size() == 1); + REQUIRE(destination.model.objects.front()->volumes.size() == 2); + const ModelVolume &helper = *destination.model.objects.front()->volumes[1]; + if (variant == 0 || variant == 1 || variant == 5) { + CHECK(helper.type() == ModelVolumeType::PRECISE_SEAM_CENTER); + if (variant == 5 && backend == Backend::Prusa) { + // The legacy Prusa whitelist only accepted extruder among these ordinary setting keys. + REQUIRE(helper.config.has("extruder")); + CHECK(helper.config.opt_int("extruder") == 2); + CHECK_FALSE(helper.config.has("sparse_infill_density")); + CHECK_FALSE(helper.config.has("notes")); + } else check_config(helper); + } else { + CHECK(helper.type() == (variant == 4 ? ModelVolumeType::MODEL_PART : ModelVolumeType::PARAMETER_MODIFIER)); + CHECK_FALSE(helper.config.has("extruder")); + CHECK_FALSE(helper.config.has("sparse_infill_density")); + CHECK_FALSE(helper.config.has("notes")); + } +} diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 7f541f3701..58262eef99 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -12,6 +12,8 @@ add_executable(${_TEST_NAME}_tests test_network_versions.cpp test_action_source.cpp test_plugin_host_api.cpp + # Exercise seam enums and predicates through the embedded Python host API. + test_precise_seam_plugin.cpp test_plugin_capability_config.cpp test_plugin_config.cpp test_plugin_capabilities_in_use.cpp diff --git a/tests/slic3rutils/test_precise_seam_plugin.cpp b/tests/slic3rutils/test_precise_seam_plugin.cpp new file mode 100644 index 0000000000..244a4c3405 --- /dev/null +++ b/tests/slic3rutils/test_precise_seam_plugin.cpp @@ -0,0 +1,61 @@ +#include + +#include "libslic3r/Model.hpp" +#include "libslic3r/TriangleMesh.hpp" +#include "python_test_support.hpp" + +#include +#include + +namespace py = pybind11; +using namespace Slic3r; + +TEST_CASE("Python exposes every seam mode and distinguishes strong weak and ordinary volumes", "[PreciseSeamPlugin][Python]") +{ + struct Mode { + const char *name; + ModelVolumeType type; + bool strong; + bool weak; + }; + // Explicit expectations protect enum registration and both predicate groups independently. + const std::array modes = {{ + {"PreciseSeamCenter", ModelVolumeType::PRECISE_SEAM_CENTER, true, false}, + {"PreciseSeamLeft", ModelVolumeType::PRECISE_SEAM_LEFT, true, false}, + {"PreciseSeamRight", ModelVolumeType::PRECISE_SEAM_RIGHT, true, false}, + {"PreciseSeamEnforced", ModelVolumeType::PRECISE_SEAM_ENFORCED, false, true}, + {"PreciseSeamBlocked", ModelVolumeType::PRECISE_SEAM_BLOCKED, false, true}, + {"PreciseSeamNeutral", ModelVolumeType::PRECISE_SEAM_NEUTRAL, false, true}, + {"ModelPart", ModelVolumeType::MODEL_PART, false, false}, + {"NegativeVolume", ModelVolumeType::NEGATIVE_VOLUME, false, false}, + {"ParameterModifier", ModelVolumeType::PARAMETER_MODIFIER, false, false}, + {"SupportEnforcer", ModelVolumeType::SUPPORT_ENFORCER, false, false}, + {"SupportBlocker", ModelVolumeType::SUPPORT_BLOCKER, false, false} + }}; + + // Bootstrap holds the GIL on first initialization; acquire explicitly for an already-running interpreter too. + ensure_python_initialized(); + py::gil_scoped_acquire gil; + py::object host = import_orca_module().attr("host"); + REQUIRE(py::hasattr(host, "ModelVolumeType")); + py::object enumeration = host.attr("ModelVolumeType"); + for (const Mode &mode : modes) { + DYNAMIC_SECTION(mode.name) { + REQUIRE(py::hasattr(enumeration, mode.name)); + CHECK(enumeration.attr(mode.name).cast() == mode.type); + Model model; + auto *volume = model.add_object()->add_volume(make_cube(1, 1, 1)); + volume->set_type(mode.type); + // Borrow the volume: the Python reference is destroyed before its owning Model. + py::object py_volume = py::cast(volume, py::return_value_policy::reference); + REQUIRE(py::hasattr(py_volume, "is_precise_seam")); + REQUIRE(py::hasattr(py_volume, "is_precise_seam_strong")); + REQUIRE(py::hasattr(py_volume, "is_precise_seam_weak")); + CHECK(py_volume.attr("type")().cast() == mode.type); + const bool precise = mode.strong || mode.weak; + CHECK(py_volume.attr("is_precise_seam")().cast() == precise); + CHECK(py_volume.attr("is_precise_seam_strong")().cast() == mode.strong); + CHECK(py_volume.attr("is_precise_seam_weak")().cast() == mode.weak); + } + } +}