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 & 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("" + tag + ">", 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);
+ }
+ }
+}