diff --git a/docs/HLSD/precise-seam.md b/docs/HLSD/precise-seam.md index 2976e8c7c2..8728f0518b 100644 --- a/docs/HLSD/precise-seam.md +++ b/docs/HLSD/precise-seam.md @@ -2,238 +2,584 @@ ## 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. +Precise Seam lets a helper volume decide where the seam of an object goes. The +user attaches a mesh to an object as a Precise Seam modifier. On every layer, +the part of the external perimeter that lies inside the modifier's slice +determines where the seam must, may or must not be placed. The helper is a +persistent model object rather than paint on the surface, so it keeps working +when the design changes. A body swept along a path on the surface can guide the +seam along any trajectory. -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. +The modifier is non-printing geometry. It takes no part in object slicing, +region assignment, filament selection or brim adhesion, and it affects only seam +placement during G-code export. Objects without Precise Seam volumes follow the +regular seam placement unchanged. -## Volume types and priority +Precise Seam does not replace the seam placer. It feeds it: a modifier inserts +the points it needs into the perimeter and changes the enforced/blocked type of +seam candidates, the same typing mechanism as seam painting, and the configured +seam position then chooses among them. -Precise Seam adds six `ModelVolumeType` values after `SUPPORT_ENFORCER`. The -strong types come first and the weak types follow. `is_precise_seam()`, +## Modifier types + +Precise Seam adds six `ModelVolumeType` values after `SUPPORT_ENFORCER`, strong +types first and weak types after them. `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 | +| Type | Group | Effect on an intersected perimeter | | --- | --- | --- | -| `PRECISE_SEAM_CENTER` | strong | seam at the arc-length midpoint of the intersection | +| `PRECISE_SEAM_CENTER` | strong | seam at the midpoint, by arc length, 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. +A strong modifier fixes a single point. The perimeter gets exactly one enforced +seam candidate there, and every other candidate is blocked. A weak modifier +retypes, and where needed adds, the candidates inside its intersection, like +painting does. + +An **intersection** is a continuous part of the external perimeter's centerline +that lies inside the modifier's slice on that layer. It is a portion of the +perimeter, never a chord through the object. The centerline lies half an +extrusion width inside the model surface and depends on print settings, so a +modifier must reach clearly past the surface to cross it unambiguously. + +### Terms + +- **Segment:** an intersection as the code represents it (`PerimeterSegment`). + User-facing texts call it an intersection. +- **Fragment:** a piece of the perimeter returned by clipping, before it is tied + to the source contour. +- **Interval:** the bound part of one source edge, given by the edge index and a + parameter range on that edge. +- **Zone:** a weak segment with its type (Enforced, Blocked or Neutral). +- **Boundary:** an end of a zone, inserted into the perimeter polygon. +- **Candidate:** a seam candidate of the seam placer, built from the points of + the processed perimeter polygon (painted enforcers may add more). + +First and last are taken along the perimeter oriented counter-clockwise as seen +from above. On an outer wall seen from outside, Left is therefore the left end +of the intersection. On the wall of a hole seen from inside the hole, the two +ends are swapped. Mirroring an object does not mirror the mode: perimeters stay +counter-clockwise, so Left remains the left end seen from outside, and the seam +moves to the other end of the modifier instead of following the mirrored model. + +## Priority 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. +drag a modifier only within its own group. A type change that crosses the group +boundary moves the volume to the end of its new group, with the lowest priority +there. -## Model storage and 3MF compatibility +- **Strong:** modifiers are tried in priority order on each perimeter. The first + one that yields a usable segment decides the seam. Within that modifier the + longest segment wins; lengths are never compared across modifiers. Once a + strong point is placed, no later strong modifier and no weak modifier is + processed for that perimeter. +- **Weak:** every weak modifier applies. They are applied from the lowest + priority to the highest, so the highest one overwrites overlapping zones. A + Blocked modifier that fully contains a perimeter is the exception: it is + skipped there (see [Full containment](#full-containment)). -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 +A strong modifier without a usable segment, even one whose fragments were all +discarded, passes the turn to the next one. + +## Data flow + +1. **Invalidation.** `Print::apply()` treats a change of Precise Seam volumes as + a change of seam placement and invalidates G-code export; the object is not + resliced (see [Print invalidation](#print-invalidation)). +2. **Modifier slices.** `SeamPlacer::init()` collects each object's Precise Seam + volumes once, slices every volume separately and caches its regions with + their bounding boxes. +3. **Perimeters.** Seam candidates are gathered in parallel over the layers. + For objects with Precise Seam volumes, each external perimeter polygon is + normalized and prepared once for all modifiers. +4. **Extraction.** For each modifier, the perimeter is clipped against the + modifier's regions on that layer. The clipped fragments are bound back to the + source edges of the perimeter and assembled into segments. +5. **Strong, then weak.** Strong modifiers try to insert one seam point into the + perimeter polygon. If none succeeds, weak modifiers insert their zone + boundaries and subdivide enforced edges. +6. **Candidates.** The seam placer builds candidates from the modified polygon. + Painting assigns types first, weak zones overwrite them, and a strong point + makes its candidate the only enforced one. +7. **Selection and restoration.** The configured seam position chooses the + seams and aligns them. Afterwards the exact strong points are restored. +8. **Warnings.** After all objects are processed, `SeamPlacer::init()` prepares + one combined warning text if any problem was found; G-code export issues it. + +## Modifier slices + +`init_precise_seam_data()` collects the Precise Seam volumes of each object: +strong ones in priority order and weak ones in reverse, so that weak zones can +be applied with last-write-wins. Each volume is sliced separately with +`PrintObject::slice_single_volume_regions()`, at the object's layer heights and +with the same centered transformation as the object. The slices keep every +region's outer contour together with its holes as an `ExPolygon`. Volumes are +not merged, so each keeps its own priority, and a modifier may have several +regions on one layer. + +`prepare_modifier_slices()` moves the slices into `ModifierRegionsCache`, +pairing each region with the bounding box of its exterior. Empty layers keep +their slots, so the cache is indexed by object layer; `Layer::id()` includes raft +layers, which are subtracted. The cache is filled before candidates are gathered +and is only read afterwards, shared by both modifier kinds and all worker +threads without locking. + +## Perimeter preparation + +The seam placer works on external perimeter loops, including the walls of +holes. For objects with Precise Seam volumes, consecutive duplicate points and +the repeated closing point of each extrusion loop are removed: adjacent +extrusion paths share endpoints, and the resulting zero-length edges would +prevent point insertion at their junctions. Distinct visits to one point of a +self-touching contour are kept. Objects without Precise Seam volumes keep their +original points, so ordinary seam candidates are unaffected. + +Each polygon is made counter-clockwise. A single `PreparedPerimeter` is then +built for all modifiers of that perimeter. It holds a validity check (at least +three points, no consecutive or closing duplicates), the bounding box, and the +clipping line: the polygon as an open polyline with its first point repeated at +the end. The preparation borrows the polygon and is used only while the polygon +is unchanged: strong processing returns immediately after inserting its point, +and weak processing collects all segments before it inserts anything. An +invalid perimeter receives no Precise Seam processing. + +## Segment extraction + +`extract_perimeter_segments()` turns one modifier's regions on one layer into +segments of the perimeter, each with its geometry and its position on the +source contour. Both modifier kinds consume these segments; the extractor is +told the modifier type so that it prepares only the data that type needs. + +### Clipping + +Regions whose bounding box does not overlap the perimeter's are skipped. The +clipping line is intersected with each remaining region by `intersection_pl()`, +which clips an open path against an `ExPolygon` with its holes attached, using +the nonzero rule. Clipping an open line yields only pieces of the perimeter, so +a modifier crossing the whole object produces two separate pieces rather than a +chord through the body. Holes in a modifier and several regions of one modifier +simply produce more pieces. The line is cut at vertex zero, so a piece crossing +that vertex arrives as two fragments. A border that only touches the line can +come back as a single point; such fragments carry no coverage and are dropped +before binding. + +### Binding fragments to source edges + +Clipper returns coordinates only. Insertion needs the source edge of every +point, and coordinates alone are ambiguous where a contour visits the same +point twice. Each fragment is therefore bound to the source edges it covers, +producing intervals: an edge index with a parameter range on that edge. + +- **Exact path.** For fragments with interior points, the second point is used + as an anchor that must equal a source vertex exactly. Clipping keeps the + vertices of an open path unchanged, including collinear ones. The following + points must match successive source vertices in either direction; later + occurrences of the anchor are tried if a sequence does not match. Only the two + end cuts are projected onto their edges. +- **Projection path.** Two-point fragments, and fragments the exact path cannot + match, are bound by projection. The first source edge that holds both points + of the first pair, with distinct parameters, establishes the edge and + direction. Every following pair must continue on the same edge or cross to the + neighboring edge at their actual shared vertex, in the same direction. A pair + continuing on the same edge reuses the previous pair's parameter for their + shared point, so the two projections of one point cannot differ. +- **Failure.** A fragment that cannot be bound continuously is rolled back and + discarded. Earlier fragments and other fragments are unaffected. The failure + is counted, logged and reported to the user (see + [Diagnostics](#diagnostics-and-warnings)). + +Two rare rounding cases are handled only after both paths have failed, so the +normal path never pays for them: + +- **Cut beside a vertex.** When a modifier boundary crosses within about one + coordinate unit of a source vertex, Clipper can place the cut at the vertex's + height but a few units beside it. The end pair then collapses to the vertex's + parameter or misses both neighboring edges. An end cut closer than the + snapping radius to a vertex of the fragment's own chain is snapped to that + vertex: either its neighbor in the fragment (the cut is a rounded copy of it + and is dropped) or a vertex that shares a source edge with that neighbor. The + neighbor wins whenever it is within the radius. Ends that are themselves source + vertices and ambiguous choices are left unchanged. Binding is then retried + once with the same strict rules, so a wrong candidate can only fail again. +- **Contact.** A fragment that still fails but is shorter than the snapping + radius is accepted as a contact and binds nothing. Insertion would collapse it + onto one point anyway. + +Both outcomes are recoveries, not failures: they show no user warning but leave +a log marker. + +### Assembling segments + +The intervals are sorted by edge and parameter. Intervals on the same occurrence +of an edge are united when they overlap or meet, by parameter or at the same +integer point; equal coordinates on different edges are never united. A +parameter of 1 is stored as parameter 0 of the next edge, so intervals on +adjacent edges meet exactly at their shared vertex. Consecutive intervals that +meet form one `PerimeterSegment`, and the last segment is joined with the first +when they meet at vertex zero, undoing the artificial cut of the clipping line. + +Each segment keeps its polyline, the source edge of every polyline edge, and its +begin and end positions on the source contour. + +### Full containment + +A modifier that covers the whole perimeter has no boundaries on it. The policy +follows seam painting, where painting a whole perimeter green is a meaningful +choice and forbidding the seam all round is not: + +- **Seam Enforced** types the whole perimeter, like a perimeter painted green all + round, with subdivision applied as described under [Weak modifiers](#weak-modifiers). +- **Seam Neutral** types the whole perimeter Neutral, like an unmarked perimeter, + clearing painting and lower zones. +- **Seam Blocked** is skipped for the perimeter, with the full-containment + warning. The seam cannot avoid the whole perimeter, so the modifier does not + override anything below it: lower zones and painting stay in effect. +- **Seam Center, Left and Right** are skipped with the same warning: there is no + intersection to place the point on. + +Enforced and Neutral take part in the usual priority order (see +[Weak modifiers](#weak-modifiers)). + +The perimeter is fully contained when the united intervals cover every source +edge from parameter 0 to 1. A modifier boundary that merely touches the +perimeter counts as well: + +- At a vertex or on an axis-aligned edge, clipping splits the line exactly at the + touch, the pieces meet at one point, and the coverage is complete. +- On an inclined edge the touching point is usually not representable on the + integer grid. The boundary pokes a few units across and leaves a real gap, so + a single segment covers everything except that gap. + +Weak insertion would collapse such a segment's boundaries onto one vertex and +turn the intended zone into a single candidate, and strong would put the seam at +the touch. A single segment is therefore also full containment in the cases +where insertion collapses it, exactly up to edges shorter than 2 µm: + +- the uncovered length from its end to its begin is below 1 µm, or +- the gap spans one vertex, or starts at a vertex and ends on the next edge, and + both ends lie within 1 µm of the vertex that ends the first gap edge, since + each end then snaps onto it from its own edge. + +A cheap filter runs first: both cases bring the segment's ends within 2 µm of +each other. + +## Strong modifiers + +For a strong modifier, the extractor prepares each segment's target point +before anything is inserted, together with the source edge it lies on: + +- **Left:** the segment's first point. +- **Right:** the segment's last point. +- **Center:** the point at half the segment's arc length. + +Arc length is the sum of Euclidean edge lengths, not the chord or a vertex count. + +`insert_strong_seam_point()` selects the longest segment of the first modifier +that has one. Exactly equal lengths are resolved by the prepared target points: +greater bed Y first, then smaller X; a complete tie keeps the first segment. +Slice coordinates already include instance rotation and have the bed axes; +centering and XY translation do not change this order. Nearly equal lengths are +not treated as equal, so exact ties occur mainly on axis-aligned geometry. +Geometrically equal segments, such as a symmetric modifier crossing both faces +of a thin wall, differ only by rounding noise that varies between layers, so +the chosen face may alternate. This is accepted deliberately: such a modifier is +ambiguous by itself: more than one segment raises the "multiple intersections" +warning. The user should make the modifier cross the perimeter once. + +The selected point is inserted on its source edge. A point within 1 µm of an +existing vertex is snapped to that vertex. Helper points are added 1 µm on both +sides of it, except on an adjacent edge shorter than 2 µm, which already bounds +the distance. + +When the candidates are built, the candidate at the inserted point is the only +enforced one and becomes the central enforcer; every other candidate is blocked. +Every seam position mode therefore selects it. Alignment and random placement +can still move the final position, so after alignment +`restore_precise_seam_positions()` writes the exact point and its index back +into every perimeter that has a strong seam. + +## Weak modifiers + +`collect_weak_modifier_segments()` extracts the segments of every weak modifier +before the polygon is modified, so all positions refer to the same contour. Each +segment becomes a zone with a type and two boundaries, kept in application +order, lowest priority first. Full containment of an Enforced or Neutral +modifier becomes a whole-perimeter zone at its place in that order: it has no +boundaries and takes part in no insertion or helper step below. The boundaries +carry their positions on the source contour; these remain as provenance after +insertion and are not indices into the modified polygon. + +`prepare_weak_modifier_segments()` then changes the polygon: + +1. **Boundary insertion.** Insertion events are sorted by decreasing source edge + and parameter, and the polygon is modified from its end towards its start. A + pending boundary's source index therefore stays valid. Vertex zero has the + canonical position `(0, 0)` and is + processed last, and a point on the closing edge is appended rather than + inserted at index zero. A boundary within 1 µm of either endpoint of its + current edge, an original vertex or a boundary inserted earlier, is snapped to + that point, so coincident boundaries share a vertex. A zone narrower than + 1 µm collapses into a single vertex. +2. **Helper points.** A helper point is added 1 µm outside every boundary, + unless the edge there is shorter than 2 µm, which already bounds it. The + helpers keep the edges at a boundary short, so a seam placed along such an + edge stays close to the boundary. Coincident boundaries share their helpers. +3. **Enforced subdivision.** Zone types are resolved for the polygon's edges in + priority order. The edges of a zone are those from its left boundary up to, + but not including, its right boundary; a whole-perimeter zone types every + edge. Enforced edges longer than `SeamPlacer::enforcer_oversampling_distance` + (0.2 mm) are subdivided into steps of at most that length; shorter edges and + existing vertices are kept. + The regular seam placer then chooses the seam as for painted seams. + +When candidates are built, painting assigns their types first. +`apply_weak_modifiers_to_perimeter()` then overwrites the types of the +candidates between the boundaries of each zone, both boundaries included, +lowest priority first; a whole-perimeter zone types every candidate. Blocked +and Enforced zones therefore take precedence over painting, and Neutral clears +painting inside its zone. + +## Numeric tolerances + +Coordinates are integers in scaled units: 1 nm by default, and 10 nm when a bed +larger than 2147 mm switches `SCALING_FACTOR`. Both Precise Seam tolerances are +deliberately defined in units rather than physical distances. Clipper truncates +cuts to whole units at any scale, so the on-edge tolerance must follow the unit; the +snapping radius scales with it to keep its margin over single-precision +candidate coordinates, which are coarser on large beds. Distances quoted in +this document in nanometers and +micrometers assume the default unit; on large printers they are ten times +larger. The enforced subdivision step is a physical distance and stays 0.2 mm. + +| Value | Role | +| --- | --- | +| `MACHINE_PRECISION_SQUARED` (2.5 units², about 1.6 nm) | A point lies on an edge if it is this close. It absorbs Clipper's truncation of cuts to whole units (under √2 units from the edge) and never bridges a real gap: a one-unit uncovered gap stays a gap. | +| `TOLERANCE_LINEAR` (1000 units, 1 µm) | Insertion snaps points this close to an existing vertex, and helper points are placed this far from boundaries. The same radius bounds the rounding fallback, contacts and the sub-micron full-containment rule, so those decisions match what insertion would produce anyway. | +| `enforcer_oversampling_distance` (0.2 mm) | Maximum step of enforced subdivision. | + +Raising the on-edge tolerance would not help with cuts beside a vertex: more +points past a vertex would be clamped to its parameter and collapse. Lowering it +would reject ordinary rounded cuts. The snapping radius is kept far above +clipping precision for robustness: seam candidates hold single-precision +coordinates, whose step is about 8 to 15 nm at typical object coordinates +(about 0.25 µm 3 m from the object's centre, on large beds only), and +weak boundaries and the strong point are located among the candidates by those +coordinates, so distinct points must stay clearly distinct. 1 µm is also far +below printing precision. + +## Diagnostics and warnings + +One `PreciseSeamWarnings` instance is shared by all objects and layers of a +`SeamPlacer::init()` call. After all objects are processed, `SeamPlacer::init()` +prepares at most one warning text, available through `precise_seam_warning()`. +G-code export issues it as one non-critical warning with the ID +`SlicingPreciseSeamWarning`. It is a single line, "Precise Seam: . Seam +placement may differ from expected.", because the export warnings dialog shows +only the first line of each warning. Repeated warning events replace the +notification instead of appending to it. Except for the "had no effect" cause, +the causes name the modifier types involved, as the menu names them, in menu +order and each type once, for example "(Seam Left, Seam Enforced)". +The causes are: + +- **failed to process some intersections (types):** at least one fragment was + discarded by binding. Other segments remain usable. +- **multiple intersections with a perimeter, only one was used (types):** + a Seam Center, Left or Right modifier had more than one segment on a + perimeter (see [Strong modifiers](#strong-modifiers)). +- **a perimeter is fully inside a modifier, the modifier was not applied to it + (types):** a Seam Center, Left, Right or Blocked modifier was skipped for a + perimeter (see [Full containment](#full-containment)). +- **modifier "" of "" had no effect on the seam (it might not reach + the centerline of the printed perimeter):** a modifier was evaluated on at + least one perimeter and never gave a segment, full containment or a discarded + fragment. Only the first such modifier in print and volume order is named, + followed by "(N in total)" when there are several. + + Only the effect is certain, so the cause is given as a hint. A modifier is + evaluated only when its turn comes: on a perimeter where a higher strong + modifier placed the seam, lower strong and all weak modifiers are not + evaluated. A modifier that was never evaluated is not reported, since nothing + is known about it. A point contact gives no segment and does not count as + reaching the perimeter. + +The log records the following diagnostic markers: + +- `[PreciseSeamIntersectionFailed]` for a discarded fragment, with object, + modifier, layer, height, fragment and failing pair, the failure reason and + point counts. +- `[PreciseSeamFragmentRecovered]` for a recovery, with `outcome=bound` or + `outcome=contact`, the same location fields and the original failure reason. +- `[PreciseSeamNoEffect]` for every modifier of the "had no effect" cause, with + the object and modifier names. Unlike the user warning, the log lists all of + them. + +Failures and recoveries are counted separately. The first 10 of each per +`init()` call are logged in detail, in parallel processing order; if a limit is +exceeded, one summary marker reports the total and the number omitted. + +## Known limitations + +- **The modifier must reach the perimeter centerline.** Contacts are taken as + clipping returns them, without offsets or tangency rules, so boundaries that + only graze the centerline are the user's responsibility. Several near-touches + on inclined edges can leave several segments separated by gaps of a few units; + their zones then cover nearly the whole perimeter instead of being treated as + full containment. +- **Self-touching perimeters.** Extraction keeps distinct visits of one + coordinate apart through its source-edge bindings, but the consumers locate + inserted points by coordinates. A weak zone is typed and subdivided from the + first vertex with its boundary coordinate, while boundary helpers are added at + every such vertex. A strong point marks every candidate at its coordinate as + enforced, and the last one is restored after alignment. If a boundary or a + strong point falls exactly on a repeated coordinate, a zone may therefore start + from another visit, or the seam may start at another visit of the same point. + Carrying visit identity through insertion, refinement, candidates and + restoration would touch the whole pipeline, so it is not done for this rare + geometry. Overlapping source visits are likewise outside the binding contract. + +## Integration with the application + +### Other seam settings + +- Precise Seam takes part only in outer and hole perimeter seam placement. In + spiral vase mode the seam placer is not used for perimeters, so the modifiers + have no effect. +- Scarf seams, the seam gap and wiping start from the chosen point exactly as + they would from an ordinary seam. +- Seam painting acts only from model parts, the volumes the seam gizmo shows and + edits, and from negative volumes. Painting retained on a volume after a change + from part to a Precise Seam, ordinary or support modifier is ignored. A type + change back to a model part reactivates any retained painting. + Negative volumes keep it on purpose: painting a + part and turning it into a negative volume is the only way to paint the wall + of the hole it cuts. That painting still affects the seam but is invisible in + the gizmo and cannot be edited there; this is known technical debt. + If painting them is ever made editable, G-code invalidation must track it too: + `model_custom_seam_data_changed()` checks model parts only. + +### Model storage and 3MF compatibility + +Projects must stay readable by earlier releases, and a Precise Seam volume must +not change a print there. Both 3MF writers therefore store it 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. +On load, the mode is applied after all other volume metadata, regardless of XML +key order, and only when the base type is a modifier. A missing or unknown mode +leaves an ordinary modifier, and seam metadata on any other base type is +ignored. Files that stored the seam mode directly as the volume type still load. +A project saved again by an earlier release loses the seam mode for good: the +volumes stay ordinary modifiers without settings. 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. +reader drops them as unknown options and loads a modifier without settings, +which 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. -## Print invalidation +### 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. +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 these two families, since neither +affects object slicing; such a switch also changes the support volumes, so the +support step is invalidated as well. -## Modifier slices +A conversion to or from a part or an ordinary modifier changes the solid and +modifier volume lists and reslices the object as before. The volume keeps its +ID across the type change, so the region cache treats a former support or +Precise Seam volume that became a part or modifier as new, since it was never +cached. -`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. +Removing the last helper of a single-part object reslices it, as removing any +last modifier would. -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 +### 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()` + modifiers: the menu does not offer it, 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. + *Precise Seam Type* submenu appears only when every selected item is a Precise + Seam volume, including settings rows that resolve to one, and sets the chosen + mode on all of them. - 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. + view, at 60% opacity: warm orange, gold and dark orange for Center, Left and + Right; green, red and gray for Enforced, Blocked and Neutral. The three strong + colors are close shades of one orange because all three mark strong + modifiers; the object list icons tell the modes apart. +- Precise Seam volumes have no filament and cannot be pasted into SLA objects. + Python plugins see them as `ModelVolumeType` values and through 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. +- [PreciseSeam.cpp](../../src/libslic3r/GCode/PreciseSeam.cpp) implements the + modifier cache, perimeter preparation, segment extraction and binding, strong + selection and insertion, weak-zone preparation and application, and position + restoration. [PreciseSeam.hpp](../../src/libslic3r/GCode/PreciseSeam.hpp) + declares the contracts; [PreciseSeamInternal.hpp](../../src/libslic3r/GCode/PreciseSeamInternal.hpp) + exposes the binding internals to tests. +- [SeamPlacer.cpp](../../src/libslic3r/GCode/SeamPlacer.cpp) fills the cache, + normalizes perimeters, calls both consumers while gathering candidates, + restores strong positions after alignment and prepares the warning text, which + [GCode.cpp](../../src/libslic3r/GCode.cpp) issues during G-code export. - [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. + [PrintObjectSlice.cpp](../../src/libslic3r/PrintObjectSlice.cpp) slices single + volumes into structured regions. [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. + type changes and ordering; [3DScene.cpp](../../src/slic3r/GUI/3DScene.cpp) + defines the colors. +- [Segment extraction tests](../../tests/libslic3r/test_precise_seam.cpp) cover + clipping and binding: holes and components, contour origin and reversal, + repeated coordinates, collinear vertices and rounding, rollback and the + diagnostic limits, the rounding fallback on synthetic and real Clipper + fragments, contacts, and full containment including touches and sub-micron + gaps on inclined edges and around vertices. - [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. + consumers: strong targets in every mode, including a midpoint on an existing + vertex or the closing edge, longest-arc selection and tie order in bed axes, + priorities, weak boundaries that coincide or share an edge, enforced + subdivision, whole-perimeter weak zones with painting and priorities, weak + zones over painting's oversampled candidates, the warning type masks, usage + tracking for the "had no effect" warning, volume sorting of strong and weak + groups, restoration of strong points after alignment, raft layer indexing and + structured slices. End-to-end tests slice a real object with Precise Seam + volumes and check the outer wall starts in the exported G-code: every strong + mode under several seam positions and with a raft, Enforced and Blocked zones, + a modifier with a hole, and the user warning. - [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. + contours, duplicate removal, and `Print::apply()` synchronization through type + changes and restored model snapshots. The duplicate-removal test also checks + the "had no effect" warning text prepared by `init()` for a helper that never + reaches the loop. Further tests check that adding, moving, retyping or + removing a Precise Seam volume invalidates only G-code export, and that seam + painting acts only from model parts and negative volumes, including after a + type change back to part. - [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. + trip of every mode and of inactive settings, attribute escaping, and the + metadata combinations that restore a seam mode. [Plugin tests](../../tests/slic3rutils/test_precise_seam_plugin.cpp) cover the Python bindings. diff --git a/localization/i18n/OrcaSlicer.pot b/localization/i18n/OrcaSlicer.pot index fa09e82104..92130b9482 100644 --- a/localization/i18n/OrcaSlicer.pot +++ b/localization/i18n/OrcaSlicer.pot @@ -22855,3 +22855,28 @@ msgstr "" #: resources/data/hints.ini: [hint:Avoid warping] msgid "Avoid warping\nDid you know that when printing materials that are prone to warping such as ABS, appropriately increasing the heatbed temperature can reduce the probability of warping?" msgstr "" + +#: src/libslic3r/GCode/SeamPlacer.cpp +#, possible-boost-format +msgid "failed to process some intersections (%1%)" +msgstr "" + +#: src/libslic3r/GCode/SeamPlacer.cpp +#, possible-boost-format +msgid "multiple intersections with a perimeter, only one was used (%1%)" +msgstr "" + +#: src/libslic3r/GCode/SeamPlacer.cpp +#, possible-boost-format +msgid "a perimeter is fully inside a modifier, the modifier was not applied to it (%1%)" +msgstr "" + +#: src/libslic3r/GCode/SeamPlacer.cpp +#, possible-boost-format +msgid "modifier \"%1%\" of \"%2%\" had no effect on the seam (it might not reach the centerline of the printed perimeter)" +msgstr "" + +#: src/libslic3r/GCode/SeamPlacer.cpp +#, possible-boost-format +msgid "modifier \"%1%\" of \"%2%\" (%3% in total) had no effect on the seam (it might not reach the centerline of the printed perimeter)" +msgstr "" diff --git a/localization/i18n/ru/OrcaSlicer_ru.po b/localization/i18n/ru/OrcaSlicer_ru.po index 043aa5b476..df1af45a03 100644 --- a/localization/i18n/ru/OrcaSlicer_ru.po +++ b/localization/i18n/ru/OrcaSlicer_ru.po @@ -27362,17 +27362,30 @@ msgstr "" msgid "Precise Seam" msgstr "Точный шов" -msgid "multiple intersections with a perimeter detected" -msgstr "обнаружены множественные пересечения с периметром" +#: src/libslic3r/GCode/SeamPlacer.cpp +#, possible-boost-format +msgid "failed to process some intersections (%1%)" +msgstr "не удалось обработать некоторые пересечения (%1%)" -msgid "modifier fully crosses the printable perimeter" -msgstr "модификатор пересекает печатаемый периметр насквозь" +#: src/libslic3r/GCode/SeamPlacer.cpp +#, possible-boost-format +msgid "multiple intersections with a perimeter, only one was used (%1%)" +msgstr "несколько пересечений с периметром, использовано только одно (%1%)" -msgid "modifier shape is not solid (has holes inside) and was ignored" -msgstr "форма модификатора не сплошная (имеет отверстия) и была проигнорирована" +#: src/libslic3r/GCode/SeamPlacer.cpp +#, possible-boost-format +msgid "a perimeter is fully inside a modifier, the modifier was not applied to it (%1%)" +msgstr "периметр целиком внутри модификатора, модификатор для него не применён (%1%)" -msgid "perimeter is fully contained inside modifier and was ignored" -msgstr "периметр полностью содержится внутри модификатора и был проигнорирован" +#: src/libslic3r/GCode/SeamPlacer.cpp +#, possible-boost-format +msgid "modifier \"%1%\" of \"%2%\" had no effect on the seam (it might not reach the centerline of the printed perimeter)" +msgstr "модификатор «%1%» модели «%2%» не повлиял на шов (возможно, он не достигает осевой линии печатаемого периметра)" + +#: src/libslic3r/GCode/SeamPlacer.cpp +#, possible-boost-format +msgid "modifier \"%1%\" of \"%2%\" (%3% in total) had no effect on the seam (it might not reach the centerline of the printed perimeter)" +msgstr "модификатор «%1%» модели «%2%» (всего: %3%) не повлиял на шов (возможно, он не достигает осевой линии печатаемого периметра)" msgid "Seam placement may differ from expected." msgstr "Размещение шва может отличаться от ожидаемого." diff --git a/resources/images/menu_precise_seam_center.svg b/resources/images/menu_precise_seam_center.svg index f7efb2feb0..6d18cb6bc2 100644 --- a/resources/images/menu_precise_seam_center.svg +++ b/resources/images/menu_precise_seam_center.svg @@ -1,8 +1,8 @@ - + - + @@ -11,9 +11,9 @@ - - + + - - + + diff --git a/resources/images/menu_precise_seam_left.svg b/resources/images/menu_precise_seam_left.svg index 737d422373..e4fa8e8e96 100644 --- a/resources/images/menu_precise_seam_left.svg +++ b/resources/images/menu_precise_seam_left.svg @@ -1,16 +1,16 @@ - + - - - - - - - - + + + + + + + + - - + + diff --git a/resources/images/menu_precise_seam_right.svg b/resources/images/menu_precise_seam_right.svg index 202895cac2..e8c010e5e1 100644 --- a/resources/images/menu_precise_seam_right.svg +++ b/resources/images/menu_precise_seam_right.svg @@ -1,16 +1,16 @@ - + - - - - - - - - + + + + + + + + - - + + diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 8985c8c7d9..6cfb813227 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -260,6 +260,7 @@ set(lisbslic3r_sources GCode/SeamPlacer.hpp GCode/PreciseSeam.cpp GCode/PreciseSeam.hpp + GCode/PreciseSeamInternal.hpp #GCodeSender.cpp #GCodeSender.hpp GCode/SmallAreaInfillFlowCompensator.cpp diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index a440bb87a0..8add372abd 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -3888,6 +3888,11 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato // Collect custom seam data from all objects. std::function throw_if_canceled_func = [&print]() { print.throw_if_canceled(); }; m_seam_placer.init(print, throw_if_canceled_func); + // Precise Seam: init() only prepares its warning; issue it here, inside the active export step. + if (!m_seam_placer.precise_seam_warning().empty()) + print.active_step_add_warning(PrintStateBase::WarningLevel::NON_CRITICAL, + m_seam_placer.precise_seam_warning(), + PrintStateBase::SlicingPreciseSeamWarning); // BBS: get path for change filament if (m_writer.multiple_extruders) { diff --git a/src/libslic3r/GCode/PreciseSeam.cpp b/src/libslic3r/GCode/PreciseSeam.cpp index b8eb5ea270..4cf7dc0978 100644 --- a/src/libslic3r/GCode/PreciseSeam.cpp +++ b/src/libslic3r/GCode/PreciseSeam.cpp @@ -1,18 +1,22 @@ +// Precise Seam implementation. Design: docs/HLSD/precise-seam.md #include "PreciseSeam.hpp" +#include "PreciseSeamInternal.hpp" #include "libslic3r/Polygon.hpp" +#include "libslic3r/Polyline.hpp" #include "libslic3r/Point.hpp" #include "libslic3r/Model.hpp" -#include "libslic3r/ClipperUtils.hpp" #include "SeamPlacer.hpp" #include "libslic3r/libslic3r.h" #include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/Layer.hpp" +#include "libslic3r/Print.hpp" #include #include #include #include #include -#include -#include +#include #include #include #include @@ -22,501 +26,509 @@ namespace Slic3r { namespace PreciseSeam { +ModifierRegions prepare_modifier_regions(ExPolygons regions) +{ + ModifierRegions cached; + cached.reserve(regions.size()); + // Each exterior is measured once; its holes remain in the same cached region. + for (ExPolygon ®ion : regions) + cached.emplace_back(std::move(region)); + return cached; +} + +ModifierSlices prepare_modifier_slices(std::vector slices) +{ + ModifierSlices cached; + cached.reserve(slices.size()); + // Keep empty layers so callers can index by the original object layer number. + for (ExPolygons &layer : slices) + cached.push_back(prepare_modifier_regions(std::move(layer))); + return cached; +} + +PreparedPerimeter::PreparedPerimeter(const Polygon &perimeter) : polygon(perimeter) +{ + const size_t count = polygon.size(); + if (count < 3) + return; + for (size_t edge = 0; edge < count; ++edge) + if (polygon.points[edge] == polygon.points[(edge + 1) % count]) + return; + bounds = BoundingBox(polygon.points); + // Close an open polyline explicitly; prepare its storage once for all modifier queries. + line.points.reserve(count + 1); + line.points.insert(line.points.end(), polygon.points.begin(), polygon.points.end()); + line.points.push_back(polygon.points.front()); + valid = true; +} + // 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) +// Squared on-edge tolerance in coordinate units: Clipper truncates cuts to whole units, under 1 unit +// per axis, so under sqrt(2) from the edge (squared < 2); 2.5 keeps a margin. Independent of SCALING_FACTOR. 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; +// Snapping radius for point insertion, shared by every rule that must match insertion. Deliberately in +// scaled units (1 um by default, 10 um on large beds), well above single-precision candidate steps. +static constexpr coord_t TOLERANCE_LINEAR = 1000; +static constexpr 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) +namespace detail { + +// Use the existing clipping tolerance only to recover rounded coordinates, not to +// bridge gaps between intervals: even a small uncovered interval must remain a gap. +static std::optional parameter_on_edge(const Point &point, const Point &a, const Point &b) { - 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) { + // Original vertices need no floating-point projection. + if (point == a) return 0.; + if (point == b) return 1.; + const Vec2d direction = b.cast() - a.cast(); + const Vec2d offset = point.cast() - a.cast(); + const double squared_length = direction.squaredNorm(); + if (squared_length == 0.) return std::nullopt; - } - - // Minimum 2 points required to form segment - if (isect_n < 2) { + const double parameter = std::clamp(offset.dot(direction) / squared_length, 0., 1.); + if ((offset - parameter * direction).squaredNorm() > MACHINE_PRECISION_SQUARED) 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; + return parameter; } -// 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) +static std::optional interval_on_edge( + const Point &first, const Point &last, size_t edge, const Polygon &perimeter) { - 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) { + const Point &a = perimeter.points[edge]; + const Point &b = perimeter.points[(edge + 1) % perimeter.size()]; + const auto t0 = parameter_on_edge(first, a, b); + // Reject this edge before projecting the second point when the first is outside. + if (!t0) return std::nullopt; - } - - if (isect_n < 2) { + const auto t1 = parameter_on_edge(last, a, b); + if (!t1 || *t0 == *t1) return std::nullopt; + ClippedEdgeInterval interval{edge, *t0, *t1, first, last}; + if (interval.begin > interval.end) { + std::swap(interval.begin, interval.end); + std::swap(interval.first, interval.last); } + // Canonical endpoints make adjacent original edges join at their shared vertex. + if (interval.begin == 0.) interval.first = a; + if (interval.end == 1.) interval.last = b; + return interval; +} - // ============================================================ - // 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; +// Interior clipping vertices normally retain the exact source coordinates. Only +// the two cut endpoints need projection; a failed sequence tries the next anchor. +static bool append_exact_fragment(const Polyline &fragment, const Polygon &perimeter, + std::vector &intervals) +{ + const size_t size = fragment.size(); + const size_t count = perimeter.size(); + assert(size >= 3); // bind_fragment() sends only fragments with interior points here. + // A longer sequence cannot be one exact pass over this contour. + if (size > count + 2) + return false; + for (size_t anchor = 0; anchor < count; ++anchor) { + if (fragment.points[1] != perimeter.points[anchor]) continue; + // Clipping may return either direction, independent of contour winding. + for (bool forward : {true, false}) { + const auto next = [count, forward](size_t index) { + return forward ? (index + 1) % count : (index + count - 1) % count; + }; + size_t last = anchor; + bool matches = true; + for (size_t i = 2; i + 1 < size; ++i) { + last = next(last); + if (fragment.points[i] != perimeter.points[last]) { + matches = false; + break; + } + } + if (!matches) + continue; + const size_t first_edge = forward ? (anchor + count - 1) % count : anchor; + const size_t last_edge = forward ? last : (last + count - 1) % count; + const auto first = interval_on_edge(fragment.points.front(), fragment.points[1], first_edge, perimeter); + const auto end = interval_on_edge(fragment.points[size - 2], fragment.points.back(), last_edge, perimeter); + if (!first || !end) + continue; + // Commit only a complete match, so rejected anchors leave no intervals. + intervals.push_back(*first); + size_t vertex = anchor; + for (size_t i = 2; i + 1 < size; ++i) { + const size_t adjacent = next(vertex); + const size_t edge = forward ? vertex : adjacent; + intervals.push_back({edge, 0., 1., perimeter.points[edge], perimeter.points[(edge + 1) % count]}); + vertex = adjacent; + } + intervals.push_back(*end); + return true; } - - // 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; } + return false; +} - // ============================================================ - // 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; +// Find the first edge once, then follow the contour without restarting a global search. +bool append_projected_fragment(const Polyline &fragment, const Polygon &perimeter, + std::vector &intervals, + FragmentBindingFailure &failure) +{ + const size_t original_size = intervals.size(); + bool forward = true; + const size_t count = perimeter.size(); + for (size_t i = 1; i < fragment.size(); ++i) { + if (fragment.points[i - 1] == fragment.points[i]) continue; + failure.pair_index = i - 1; + std::optional matched; + const auto is_forward = [&](const ClippedEdgeInterval &candidate) { + const Vec2d movement = fragment.points[i].cast() - fragment.points[i - 1].cast(); + const Vec2d edge = perimeter.points[(candidate.edge + 1) % count].cast() - + perimeter.points[candidate.edge].cast(); + return movement.dot(edge) > 0.; + }; + if (intervals.size() == original_size) { + failure.reason = "initial edge not found"; + for (size_t edge = 0; edge < count; ++edge) { + matched = interval_on_edge(fragment.points[i - 1], fragment.points[i], edge, perimeter); + if (matched) { + // The first suitable edge defines orientation; overlapping visits are unsupported. + forward = is_forward(*matched); + break; + } + } + } else { + failure.reason = "non-continuous binding"; + const ClippedEdgeInterval &previous = intervals.back(); + const double end = forward ? previous.end : previous.begin; + matched = interval_on_edge(fragment.points[i - 1], fragment.points[i], previous.edge, perimeter); + if (matched && is_forward(*matched) != forward) + matched.reset(); + // The shared point is the previous pair's last point on the same edge, so continuity holds by + // construction. Reuse its parameter rather than comparing a recomputed one: the two projections + // may be compiled differently (e.g. FMA contraction) and differ by an ulp on some platforms. + if (matched) + (forward ? matched->begin : matched->end) = end; + // Crossing to the neighbor is allowed only at their actual shared vertex. + const size_t vertex = forward ? (previous.edge + 1) % count : previous.edge; + if (!matched && end == (forward ? 1. : 0.) && fragment.points[i - 1] == perimeter.points[vertex]) { + const size_t edge = forward ? vertex : (previous.edge + count - 1) % count; + matched = interval_on_edge(fragment.points[i - 1], fragment.points[i], edge, perimeter); + if (matched && is_forward(*matched) != forward) + matched.reset(); + // The shared point is exactly the neighbor's start (forward) or end (backward) vertex, + // so parameter_on_edge() returns exactly 0 or 1 for it. + assert(!matched || (forward ? matched->begin == 0. : matched->end == 1.)); + } } - - // 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; + if (!matched) { + // Never leave a partial fragment or discard earlier successful fragments. + intervals.resize(original_size); + return false; } - - // 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; + intervals.push_back(*matched); } + return intervals.size() > original_size; +} - // 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); +// Exact binding first, projection as the general path; both leave intervals unchanged on failure. +bool bind_fragment(const Polyline &fragment, const Polygon &perimeter, + std::vector &intervals, FragmentBindingFailure &failure) +{ + if (fragment.size() > 2 && append_exact_fragment(fragment, perimeter, intervals)) + return true; + return append_projected_fragment(fragment, perimeter, intervals, failure); +} + +// Rare-case repair, run only after binding has failed: Clipper may place an end cut a unit or two beside +// a vertex, so it lies on neither edge. Move such an end onto that vertex, taken from the fragment's own +// chain, and let the caller retry with the same strict rules, so a wrong move can only fail again. +// Returns false when nothing was changed. +static bool snap_cuts_to_adjacent_vertices(Polyline &fragment, const Polygon &perimeter) +{ + const size_t count = perimeter.size(); + // Strict comparison matches the vertex snapping in insert_point_into_perimeter. + const auto close = [](const Point &a, const Point &b) { + return (a - b).cast().squaredNorm() < double(TOLERANCE_SQUARED); + }; + const auto snap_target = [&](const Point &cut, const Point &neighbour) -> std::optional { + if (cut == neighbour) + return std::nullopt; + bool neighbour_is_vertex = false; + bool ambiguous = false; + std::optional target; + // Scan the whole perimeter before deciding: a cut that is a real vertex must be detected anywhere, + // and an ambiguity must not pre-empt the neighbour rule below. + for (size_t j = 0; j < count; ++j) { + if (perimeter[j] == cut) + return std::nullopt; // A real vertex, not a rounded cut. + if (perimeter[j] != neighbour) + continue; + neighbour_is_vertex = true; + for (const Point &candidate : {perimeter[(j + count - 1) % count], perimeter[(j + 1) % count]}) { + if (!close(cut, candidate)) + continue; + if (target && *target != candidate) + ambiguous = true; + target = candidate; + } + } + // A cut between two arbitrary points of one edge is not this rounding case. + if (!neighbour_is_vertex) + return std::nullopt; + // The neighbour itself takes precedence, even when both of its chain vertices are close too + // (edges shorter than the radius on both sides): the cut is then just a rounded copy of it. + if (close(cut, neighbour)) + return neighbour; + // Two different edge-start candidates: keep the failure rather than guess. + if (ambiguous) + return std::nullopt; + return target; + }; + bool changed = false; + if (fragment.size() >= 2) + if (const auto target = snap_target(fragment.points.front(), fragment.points[1])) { + if (*target == fragment.points[1]) + fragment.points.erase(fragment.points.begin()); + else + fragment.points.front() = *target; + changed = true; + } + if (fragment.size() >= 2) + if (const auto target = snap_target(fragment.points.back(), fragment.points[fragment.size() - 2])) { + if (*target == fragment.points[fragment.size() - 2]) + fragment.points.pop_back(); + else + fragment.points.back() = *target; + changed = true; + } + return changed; +} + +// Location fields shared by the failure and recovery markers, enough to find the layer in a saved project. +static std::string fragment_location(const ExtractionContext &context, size_t fragment_index) +{ + const Layer *layer = context.layer; + const ModelObject *object = layer && layer->object() ? layer->object()->model_object() : nullptr; + return " object=" + std::to_string(object ? object->id().id : 0) + + " modifier=" + std::to_string(context.modifier ? context.modifier->id().id : 0) + + " layer=" + (layer ? std::to_string(layer->id()) : std::string("unknown")) + + " z=" + (layer ? std::to_string(layer->slice_z) : std::string("unknown")) + + " fragment=" + std::to_string(fragment_index); +} + +bool append_fragment(const Polyline &fragment, const Polygon &perimeter, + std::vector &intervals, + const ExtractionContext &context, size_t fragment_index) +{ + FragmentBindingFailure failure; + if (bind_fragment(fragment, perimeter, intervals, failure)) + return true; + // Rare-case fallback: repair the end cuts and retry once. A recovery is logged but is not a failure + // (no failure count, no user warning). + const char *outcome = nullptr; + Polyline cleaned = fragment; + if (snap_cuts_to_adjacent_vertices(cleaned, perimeter)) { + FragmentBindingFailure retry_failure; + // Only a contact shorter than the snapping distance may remain: nothing to bind, nothing lost. + if (cleaned.size() < 2) + outcome = "contact"; + else if (bind_fragment(cleaned, perimeter, intervals, retry_failure)) + outcome = "bound"; } - - // ============================================================ - // 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() - ); + // A failed fragment shorter than the snapping radius is a contact: insertion would collapse it to one + // point anyway, so accept it instead of reporting a failure. + if (outcome == nullptr && fragment.length() < double(TOLERANCE_LINEAR)) + outcome = "contact"; + if (outcome != nullptr) { + // Same log budget as failures, counted separately; SeamPlacer::init() reports the total. + if (context.warnings == nullptr || + context.warnings->recovered_fragments.fetch_add(1, std::memory_order_relaxed) < failed_fragment_log_limit) + BOOST_LOG_TRIVIAL(warning) << "[PreciseSeamFragmentRecovered] Rare case resolved after a binding failure" + << " outcome=" << outcome << fragment_location(context, fragment_index) + << " original_pair=" << failure.pair_index << " original_reason=" << failure.reason + << " fragment_points=" << fragment.size() << " cleaned_points=" << cleaned.size() + << " perimeter_points=" << perimeter.size(); + return true; } + // Reserve a log slot atomically before formatting; callers without shared state log every failure. + if (context.warnings && + context.warnings->failed_fragments.fetch_add(1, std::memory_order_relaxed) >= failed_fragment_log_limit) + return false; + // Keep a small marker for investigating a saved project, not a full geometry dump. + BOOST_LOG_TRIVIAL(warning) << "[PreciseSeamIntersectionFailed] Unable to process intersection" + << fragment_location(context, fragment_index) << " pair=" << failure.pair_index + << " reason=" << failure.reason << " fragment_points=" << fragment.size() + << " perimeter_points=" << perimeter.size(); + return false; +} +} // namespace detail - // ============================================================ - // STEP 6: Check minimum size and special cases - // ============================================================ - - if (continuous_intersection_indices.size() < 2) { - return std::nullopt; +// Finalize one joined strong segment; only Center needs temporary per-edge lengths. +static void prepare_strong_segment(PerimeterSegment &segment, ModelVolumeType mode, bool compare_lengths) +{ + const bool center = mode == ModelVolumeType::PRECISE_SEAM_CENTER; + std::vector edge_lengths; + if (center) + edge_lengths.reserve(segment.edge_indices.size()); + // A single Left/Right segment needs only its endpoint; Center always needs arc length. + if (center || compare_lengths) { + for (size_t i = 0; i < segment.edge_indices.size(); ++i) { + const double length = (segment.polyline.points[i + 1].cast() - + segment.polyline.points[i].cast()).norm(); + segment.length += length; + if (center) + edge_lengths.push_back(length); + } } - - // ============================================================ - // 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]); + if (mode == ModelVolumeType::PRECISE_SEAM_LEFT) { + segment.strong_target = StrongSeamTarget{segment.polyline.points.front(), segment.begin.edge_index}; + return; } + segment.strong_target = StrongSeamTarget{segment.polyline.points.back(), segment.end.edge_index}; + if (!center) + return; + // The total is now known; reuse local lengths to locate its half without new square roots. + double remaining = segment.length * 0.5; + for (size_t i = 0; i < edge_lengths.size(); ++i) { + const double length = edge_lengths[i]; + if (remaining <= length && length > 0.) { + const Point &a = segment.polyline.points[i]; + const Point &b = segment.polyline.points[i + 1]; + segment.strong_target = StrongSeamTarget{ + a + ((remaining / length) * (b - a).cast()).cast(), segment.edge_indices[i]}; + return; + } + remaining -= length; + } +} + +SegmentExtraction extract_perimeter_segments(const PreparedPerimeter &prepared, const ModifierRegions &modifier, + ModelVolumeType mode, const ExtractionContext &context) +{ + using detail::ClippedEdgeInterval; + SegmentExtraction result; + if (!prepared.valid) { + result.valid = false; + return result; + } + const Polygon &perimeter = prepared.polygon; + const size_t count = perimeter.size(); + std::vector nearby_regions; + for (const ModifierRegion ®ion : modifier) + if (!region.polygon.empty() && prepared.bounds.overlap(region.bounds)) + nearby_regions.push_back(®ion.polygon); + if (nearby_regions.empty()) + return result; + + // Clip as an open line, without offsets or contact rules: modifiers should cross the perimeter + // clearly, and the open path keeps self-touching vertices apart. + std::vector intervals; + Polylines fragments; + // Clip only accepted regions, with their holes still attached to the exterior. + for (const ExPolygon *region : nearby_regions) { + Polylines clipped = intersection_pl(prepared.line, *region); + for (Polyline &fragment : clipped) + // Clipper returns a single point where a border only touches the line: nothing to bind. + if (fragment.size() >= 2) + fragments.push_back(std::move(fragment)); + } + for (size_t i = 0; i < fragments.size(); ++i) + if (!detail::append_fragment(fragments[i], perimeter, intervals, context, i)) + ++result.discarded_fragments; + + std::sort(intervals.begin(), intervals.end(), [](const auto &a, const auto &b) { + if (a.edge != b.edge) return a.edge < b.edge; + if (a.begin != b.begin) return a.begin < b.begin; + return a.end < b.end; + }); + // Union overlaps on the same occurrence of an edge, never across equal coordinates. + std::vector merged; + for (const ClippedEdgeInterval &interval : intervals) { + // Fragments touching on an edge meet at the same integer point. Comparing that point as well keeps + // them joined even if its two projections differ by an ulp on some platforms (e.g. FMA contraction). + if (!merged.empty() && merged.back().edge == interval.edge && + (interval.begin <= merged.back().end || interval.first == merged.back().last)) { + if (interval.end > merged.back().end) { + merged.back().end = interval.end; + merged.back().last = interval.last; + } + } else + merged.push_back(interval); + } + const auto position = [count](size_t edge, double parameter) { + return parameter == 1. ? PerimeterPosition{(edge + 1) % count, 0.} + : PerimeterPosition{edge, parameter}; + }; + const auto same_position = [](const PerimeterPosition &a, const PerimeterPosition &b) { + return a.edge_index == b.edge_index && a.parameter == b.parameter; + }; + for (const ClippedEdgeInterval &interval : merged) { + const auto begin = position(interval.edge, interval.begin); + const auto end = position(interval.edge, interval.end); + if (result.segments.empty() || !same_position(result.segments.back().end, begin)) { + PerimeterSegment segment; + segment.begin = begin; + segment.polyline.points.push_back(interval.first); + result.segments.push_back(std::move(segment)); + } + PerimeterSegment &segment = result.segments.back(); + segment.polyline.points.push_back(interval.last); + segment.edge_indices.push_back(interval.edge); + segment.end = end; + } + if (result.segments.size() > 1 && same_position(result.segments.back().end, result.segments.front().begin)) { + // Only the artificial cut at vertex zero can join the last and first intervals. + PerimeterSegment tail = std::move(result.segments.back()); + result.segments.pop_back(); + PerimeterSegment &head = result.segments.front(); + tail.polyline.points.insert(tail.polyline.points.end(), head.polyline.points.begin() + 1, head.polyline.points.end()); + tail.edge_indices.insert(tail.edge_indices.end(), head.edge_indices.begin(), head.edge_indices.end()); + tail.end = head.end; + head = std::move(tail); + } + // A full loop must cover every original edge, not merely have equal endpoint coordinates. + result.full_containment = merged.size() == count; + for (size_t i = 0; result.full_containment && i < count; ++i) + result.full_containment = merged[i].edge == i && merged[i].begin == 0. && merged[i].end == 1.; + // A touch on an inclined edge leaves a sub-micron gap. Treat the single segment as full containment + // when the gap is shorter than the snapping radius, or spans one vertex with both ends within it + // (exact for edges of 2 um and longer). The 2 um end distance is a cheap pre-filter. + if (!result.full_containment && result.segments.size() == 1) { + const PerimeterSegment &only = result.segments.front(); + const Point &first = only.polyline.points.front(); + const Point &last = only.polyline.points.back(); + const double limit = double(TOLERANCE_LINEAR); + if ((first - last).cast().squaredNorm() < 4. * limit * limit) { + const auto edge_length = [&perimeter, count](size_t edge) { + return (perimeter[(edge + 1) % count] - perimeter[edge]).cast().norm(); + }; + // Uncovered length from the segment's end forward to its begin; stops early once it exceeds the limit. + const auto gap_length = [&](const PerimeterPosition &from, const PerimeterPosition &to) { + if (from.edge_index == to.edge_index && from.parameter <= to.parameter) + return (to.parameter - from.parameter) * edge_length(from.edge_index); + double gap = (1. - from.parameter) * edge_length(from.edge_index); + for (size_t edge = (from.edge_index + 1) % count; gap < limit && edge != to.edge_index; edge = (edge + 1) % count) + gap += edge_length(edge); + return gap + to.parameter * edge_length(to.edge_index); + }; + // Strict comparisons match the vertex snapping in insert_point_into_perimeter. + const auto within_snap = [limit](const Point &a, const Point &b) { + return (a - b).cast().squaredNorm() < limit * limit; + }; + const Point &vertex = perimeter[only.begin.edge_index]; + const bool around_one_vertex = (only.end.edge_index + 1) % count == only.begin.edge_index && + within_snap(last, vertex) && within_snap(first, vertex); + if (around_one_vertex || gap_length(only.end, only.begin) < limit) + result.full_containment = true; + } + } + // Strong skips full containment and weak handles it by type; neither needs strong data for it. + if (!result.full_containment && is_precise_seam_strong(mode)) + for (PerimeterSegment &segment : result.segments) + prepare_strong_segment(segment, mode, result.segments.size() > 1); return result; } @@ -538,16 +550,11 @@ void init_precise_seam_data( // 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 (Center/Left/Right) and weak (Enforced/Blocked/Neutral) go to separate lists. + if (volume->is_precise_seam_strong()) strong_volumes_out.push_back(volume); - } else { - // ENFORCED, BLOCKED, NEUTRAL - weak modifiers (processed later) + else weak_volumes_out.push_back(volume); - } } } @@ -558,137 +565,15 @@ void init_precise_seam_data( 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( +// Inserts a point on edge `edge_start_idx`, or reuses a vertex closer than TOLERANCE_LINEAR. +// Returns {final coordinates, index}. Requires at least three vertices (callers ensure it). +static std::pair 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 - } - + assert(perimeter_polygon.points.size() >= 3); size_t perim_max = perimeter_polygon.points.size(); // Determine edge start and end @@ -709,11 +594,8 @@ static std::optional> insert_point_into_perimeter( 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. + // On the closing edge, append instead of inserting at index 0: callers insert in descending source + // order and rely on earlier indices staying valid. size_t insert_pos; if (edge_start_idx == perim_max - 1) { // Last edge: add to end of vector @@ -731,25 +613,16 @@ static std::optional> insert_point_into_perimeter( 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( +// Adds a helper point TOLERANCE_LINEAR after (+1) or before (-1) vertex `point_idx`; skips edges shorter +// than 2 * TOLERANCE_LINEAR. Requires at least three vertices. +static void 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 - } + assert(perimeter_polygon.points.size() >= 3); + assert(direction == 1 || direction == -1); size_t perim_max = perimeter_polygon.points.size(); @@ -773,11 +646,9 @@ static bool refine_at_vertex( 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 + // The helper needs TOLERANCE_LINEAR of edge on both sides. if (edge_length < 2.0 * TOLERANCE_LINEAR) { - return false; // Edge too short - new point would be too close to end + return; // Edge too short - new point would be too close to end } // Calculate new point coordinates: edge_start + TOLERANCE_LINEAR * direction_normalized @@ -788,11 +659,8 @@ static bool refine_at_vertex( ? 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. + // On the closing edge, append instead of inserting at index 0: callers insert in descending source + // order and rely on earlier indices staying valid. if (edge_start_idx == perim_max - 1) { // Last edge: add to end of vector perimeter_polygon.points.push_back(new_point); @@ -803,162 +671,91 @@ static bool refine_at_vertex( new_point ); } - - return true; } -// Insert strong seam point into perimeter polygon +// Records one extraction for the warnings: the type of a modifier with a discarded fragment, and the +// evaluated/reached flags for "had no effect" (a discarded fragment counts as reached, a contact not). +static void record_extraction(PreciseSeamWarnings *warnings, const ModelVolume *modifier, + const SegmentExtraction &extracted) +{ + if (warnings == nullptr || !extracted.valid) + return; + if (extracted.discarded_fragments > 0) + PreciseSeamWarnings::mark(warnings->failed_types, modifier->type()); + const auto it = warnings->modifier_usage.find(modifier); + if (it == warnings->modifier_usage.end()) + return; + // Load before store: most calls find the flag already set, so shared cache lines stay clean. + PreciseSeamWarnings::ModifierUsage &usage = it->second; + if (!usage.checked.load(std::memory_order_relaxed)) + usage.checked.store(true, std::memory_order_relaxed); + const bool reached = !extracted.segments.empty() || extracted.full_containment || extracted.discarded_fragments > 0; + if (reached && !usage.reached.load(std::memory_order_relaxed)) + usage.reached.store(true, std::memory_order_relaxed); +} + +// Strong priority is per modifier, never a global maximum across different modifiers. std::optional insert_strong_seam_point( const std::vector &strong_volumes, Polygon &polygon, + const PreparedPerimeter &prepared, const Layer *layer, - const ModifierSlicesCache &slices_cache, + const ModifierRegionsCache &slices_cache, PreciseSeamWarnings* warnings) { - if (strong_volumes.empty() || layer == nullptr) { + assert(&prepared.polygon == &polygon); + if (strong_volumes.empty() || layer == nullptr || !prepared.valid) 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) { + const size_t layer_id = layer->id() - layer->object()->slicing_parameters().raft_layers(); + for (const ModelVolume *modifier : strong_volumes) { + const auto it = slices_cache.find(modifier); + if (it == slices_cache.end() || layer_id >= it->second.size()) + continue; + const SegmentExtraction extracted = extract_perimeter_segments( + prepared, it->second[layer_id], modifier->type(), {layer, modifier, warnings}); + record_extraction(warnings, modifier, extracted); + // Full containment leaves no intersection to place the point on: skipped with a warning. + if (extracted.full_containment) { if (warnings) - warnings->multiply_connected.store(true, std::memory_order_relaxed); + PreciseSeamWarnings::mark(warnings->full_containment, modifier->type()); continue; } + // No usable segment, even if all fragments were discarded: pass the turn to the next modifier. + if (extracted.segments.empty()) + continue; + if (warnings && extracted.segments.size() > 1) + PreciseSeamWarnings::mark(warnings->multiple_intersections, modifier->type()); - // 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; + // Lengths are compared exactly: for a symmetric modifier the chosen face may alternate between + // layers, which is accepted (the user gets the multiple-intersections warning). + StrongSeamTarget target{Point(0, 0), 0}; + double longest = -1.; + for (const PerimeterSegment &segment : extracted.segments) { + assert(segment.strong_target.has_value()); + const StrongSeamTarget &candidate = *segment.strong_target; + // Slice coordinates already include object rotation and retain bed axes. + // Centering/instance translation cannot change rear (+Y), then left (-X) ordering. + const bool farther_or_left = candidate.point.y() > target.point.y() || + (candidate.point.y() == target.point.y() && candidate.point.x() < target.point.x()); + if (segment.length > longest || (segment.length == longest && farther_or_left)) { + longest = segment.length; + target = candidate; } } + const auto [seam_point, seam_index] = insert_point_into_perimeter(target.point, target.edge_index, polygon); + // Preserve the insertion order: refining before first would shift the seam index. + refine_at_vertex(seam_index, +1, polygon); + refine_at_vertex(seam_index, -1, polygon); + return seam_point; // No later strong modifier or weak processing for this perimeter. } - - // 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()). +// Precondition: a weak precise-seam type; init_precise_seam_data() puts only those in the weak list. // Exhaustive switch (no default) so -Wswitch flags any future PRECISE_SEAM_* additions. static EnforcedBlockedSeamPoint convert_weak_modifier_type(ModelVolumeType type) { + assert(is_precise_seam_weak(type)); switch (type) { case ModelVolumeType::PRECISE_SEAM_ENFORCED: return EnforcedBlockedSeamPoint::Enforced; @@ -982,223 +779,57 @@ static EnforcedBlockedSeamPoint convert_weak_modifier_type(ModelVolumeType 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) +// Consume ready boundaries in modifier priority order; geometry extraction is separate. +static std::vector prepare_weak_modifier_segments( + std::vector result, Polygon &polygon) { - 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} + // Source positions have the same order as arc length, without measuring the perimeter. 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 + size_t segment_idx; + bool is_left; + PerimeterPosition position; }; 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}); + // A whole-perimeter zone has no boundaries to insert. + if (seg.whole_perimeter) + continue; + points_to_insert.push_back({seg_idx, true, seg.left_position}); + points_to_insert.push_back({seg_idx, false, seg.right_position}); } - // 3. Sort by descending arc length (insert distant points first) + // Descending order preserves pending source indices; vertex zero is canonical (0, 0). + // Only insertion events are reordered: modifier priority in result remains unchanged. std::sort(points_to_insert.begin(), points_to_insert.end(), [](const PointToInsert &a, const PointToInsert &b) { - return a.arc_length > b.arc_length; + if (a.position.edge_index != b.position.edge_index) + return a.position.edge_index > b.position.edge_index; + return a.position.parameter > b.position.parameter; }); - // 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; - } + // Take the final coordinates: a boundary close to an existing vertex snaps to it. + point_coords = insert_point_into_perimeter(point_coords, pt.position.edge_index, polygon).first; } - // 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. + // Add helpers at boundary vertices, scanning original vertices backwards so insertions do not shift + // pending indices. for (size_t poly_idx = polygon.size(); poly_idx-- > 0; ) { bool refine_before = false; bool refine_after = false; for (const WeakModifierSegment &segment : result) { + // Its placeholder points must not match a real vertex. + if (segment.whole_perimeter) + continue; refine_before |= polygon[poly_idx] == segment.left_point; refine_after |= polygon[poly_idx] == segment.right_point; } @@ -1209,39 +840,45 @@ std::vector collect_weak_modifier_segments( refine_at_vertex(poly_idx, -1, polygon); } - // 7. Split edges in enforced zones into segments ≤ enforcer_oversampling_distance + // 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; + // Every boundary was inserted or snapped above and later steps only add vertices, so it is found. + // A linear scan is fine: there are only a few zones per perimeter. + auto find_point_index = [&](const Point &pt) -> size_t { + const auto it = std::find(polygon.points.begin(), polygon.points.end(), pt); + assert(it != polygon.points.end()); + return size_t(it - polygon.points.begin()); }; - // 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. + // Last write wins: zones are sorted lowest priority first. 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"; + // A whole-perimeter zone types every edge, so Enforced subdivides the whole perimeter, as + // painting it green all round would. + if (segment.whole_perimeter) { + std::fill(edge_types.begin(), edge_types.end(), segment.type); + continue; + } + const size_t left_idx = find_point_index(segment.left_point); + const size_t right_idx = find_point_index(segment.right_point); + // Practically unreachable, but a miss would write past edge_types in Release builds: skip the zone. + if (left_idx == polygon.size() || right_idx == polygon.size()) { + BOOST_LOG_TRIVIAL(error) << "PreciseSeam: weak boundary not found in perimeter, skipping zone"; continue; } - // Set type for edges [left_idx, right_idx] - for (size_t idx = left_idx.value(); ; idx = (idx + 1) % polygon.size()) { + // A zone covers edges [left_idx, right_idx): the edge from the right boundary is outside and must + // not be subdivided. Edge types only drive subdivision; candidates are typed per point later. + for (size_t idx = left_idx; idx != right_idx; idx = (idx + 1) % polygon.size()) edge_types[idx] = segment.type; - if (idx == right_idx.value()) break; - } } + // Boundaries and helpers are already in place; without enforced edges nothing is subdivided, + // so skip rebuilding the polygon. + if (std::find(edge_types.begin(), edge_types.end(), EnforcedBlockedSeamPoint::Enforced) == edge_types.end()) + return result; + // Split enforced edges into small segments const double STEP_SCALED = scale_(SeamPlacer::enforcer_oversampling_distance); @@ -1291,9 +928,53 @@ std::vector collect_weak_modifier_segments( 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. +// Collect every modifier's ready segments before insertions can shift source edge indices. +std::vector collect_weak_modifier_segments( + const std::vector &weak_volumes, + Polygon &polygon, + const PreparedPerimeter &prepared, + const Layer *layer, + const ModifierRegionsCache &slices_cache, + PreciseSeamWarnings* warnings) +{ + assert(&prepared.polygon == &polygon); + std::vector result; + if (weak_volumes.empty() || layer == nullptr || !prepared.valid) + return result; + + // Modifier cache indices exclude raft layers, unlike Layer::id(). + const size_t raft_layers = layer->object()->slicing_parameters().raft_layers(); + const size_t layer_id = layer->id() - raft_layers; + for (const ModelVolume *modifier_volume : weak_volumes) { + const auto it = slices_cache.find(modifier_volume); + if (it == slices_cache.end() || layer_id >= it->second.size()) + continue; + const ExtractionContext context{layer, modifier_volume, warnings}; + const SegmentExtraction extracted = extract_perimeter_segments(prepared, it->second[layer_id], modifier_volume->type(), context); + record_extraction(warnings, modifier_volume, extracted); + const auto type = convert_weak_modifier_type(modifier_volume->type()); + // Full containment works like seam painting: Enforced and Neutral type the whole perimeter; + // Blocked is skipped with a warning, since the seam cannot avoid the whole perimeter. + if (extracted.full_containment) { + if (type == EnforcedBlockedSeamPoint::Blocked) { + if (warnings) + PreciseSeamWarnings::mark(warnings->full_containment, modifier_volume->type()); + continue; + } + WeakModifierSegment whole{type, Point(), PerimeterPosition{0, 0.}, Point(), PerimeterPosition{0, 0.}}; + whole.whole_perimeter = true; + result.push_back(whole); + continue; + } + for (const PerimeterSegment &segment : extracted.segments) { + result.push_back({type, segment.polyline.points.front(), segment.begin, + segment.polyline.points.back(), segment.end}); + } + } + // Boundary insertion, refinement and last-write-wins priority use the established path. + return prepare_weak_modifier_segments(std::move(result), polygon); +} + void apply_weak_modifiers_to_perimeter( const std::vector &weak_segments, PrintObjectSeamData::LayerSeams &result, @@ -1320,6 +1001,15 @@ void apply_weak_modifiers_to_perimeter( for (size_t seg_idx = 0; seg_idx < weak_segments.size(); ++seg_idx) { const auto &segment = weak_segments[seg_idx]; + // A whole-perimeter zone types every candidate, including painting's oversampled points. + if (segment.whole_perimeter) { + for (size_t i = perimeter.start_index; i < perimeter.end_index; ++i) + result.points[i].type = segment.type; + if (segment.type == EnforcedBlockedSeamPoint::Enforced) + some_point_enforced = true; + continue; + } + // 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); diff --git a/src/libslic3r/GCode/PreciseSeam.hpp b/src/libslic3r/GCode/PreciseSeam.hpp index f4f75c37bf..bbb4d98532 100644 --- a/src/libslic3r/GCode/PreciseSeam.hpp +++ b/src/libslic3r/GCode/PreciseSeam.hpp @@ -2,47 +2,22 @@ #define slic3r_PreciseSeam_hpp_ #include +#include #include #include #include #include +#include +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ExPolygon.hpp" #include "libslic3r/Point.hpp" #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). +// Precise Seam: helper volumes that decide where the seam goes on external perimeters. +// Design: docs/HLSD/precise-seam.md namespace Slic3r { namespace PreciseSeam { @@ -50,88 +25,158 @@ 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>; +// Geometry and its exterior bounds are prepared together, then treated as read-only. +struct ModifierRegion { + ExPolygon polygon; + BoundingBox bounds; -// Warning flags set during Precise Seam processing (thread-safe) + explicit ModifierRegion(ExPolygon region) + : polygon(std::move(region)), bounds(polygon.contour.points) {} +}; + +using ModifierRegions = std::vector; +using ModifierSlices = std::vector; +// Per-volume slices with cached bounds, shared read-only by both modifier kinds. +using ModifierRegionsCache = std::unordered_map; + +// Move sliced geometry into the cache without detaching holes or changing layer indices. +ModifierRegions prepare_modifier_regions(ExPolygons regions); +ModifierSlices prepare_modifier_slices(std::vector slices); + +// Bound diagnostic volume only; every failed or recovered fragment is still counted and handled. +// The same limit applies separately to failure and recovery markers. +inline constexpr size_t failed_fragment_log_limit = 10; + +// Shared by all layers and objects in one SeamPlacer::init(); a new pass starts fresh. 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 + // Masks of the Precise Seam types that caused each warning reason, one bit per type (type_bit()). + // The user warning lists the types instead of naming modifiers. + std::atomic multiple_intersections{0}; // Center/Left/Right with several segments on a perimeter. + std::atomic full_containment{0}; // Skipped for a perimeter fully inside: Center/Left/Right, Blocked. + std::atomic failed_types{0}; // Types with at least one discarded fragment. + std::atomic failed_fragments{0}; // Total discarded fragments, for the log summary. + // Fragments saved by the rare-case fallback or accepted as contacts; log only, no user warning. + // Clipper is deterministic, so a prismatic model can repeat the same case on every layer. + std::atomic recovered_fragments{0}; + + // Per-modifier flags for the "had no effect" warning. Modifiers are registered before the parallel + // phase, so workers only set flags; unregistered ones (e.g. in tests) are not tracked. + struct ModifierUsage { + std::atomic checked{false}; // Extracted on at least one perimeter. + std::atomic reached{false}; // Gave a segment, full containment or a discarded fragment. + }; + std::unordered_map modifier_usage; + + // Bit of a Precise Seam type in the masks above, in menu order (Center is bit 0). + static unsigned type_bit(ModelVolumeType type) + { + assert(is_precise_seam(type)); + return 1u << (int(type) - int(ModelVolumeType::PRECISE_SEAM_CENTER)); + } + // Load before fetch_or: most calls find the bit already set, so shared cache lines stay clean. + static void mark(std::atomic &mask, ModelVolumeType type) + { + const unsigned bit = type_bit(type); + if ((mask.load(std::memory_order_relaxed) & bit) == 0) + mask.fetch_or(bit, std::memory_order_relaxed); + } }; -// 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 +// Optional caller identity for concise diagnostics when an intersection is discarded. +struct ExtractionContext { + const Layer *layer = nullptr; + const ModelVolume *modifier = nullptr; + PreciseSeamWarnings *warnings = nullptr; }; +// Borrows the source polygon; use only until insertion/refinement changes that polygon. +struct PreparedPerimeter { + const Polygon &polygon; + BoundingBox bounds; + Polyline line; + bool valid = false; + + explicit PreparedPerimeter(const Polygon &perimeter); +}; + +// A vertex is represented by its outgoing edge and parameter zero, including vertex 0. +struct PerimeterPosition { + size_t edge_index; + double parameter; +}; + +// Prepared against the immutable perimeter, before insertion shifts its edge indices. +struct StrongSeamTarget { + Point point; + size_t edge_index; +}; + +struct PerimeterSegment { + Polyline polyline; + // One bound source edge per polyline interval. + std::vector edge_indices; + PerimeterPosition begin; + PerimeterPosition end; + std::optional strong_target; // Absent for weak modifiers and full containment. + // Scaled arc length, calculated only for Center or comparison of multiple strong segments. + double length = 0.; // Zero means unmeasured for weak, full containment, and a single Left/Right segment. +}; + +struct SegmentExtraction { + std::vector segments; + bool full_containment = false; + bool valid = true; // Invalid perimeter input; discarded fragments do not invalidate other segments. + // Clipped fragments whose binding failed; one segment may consist of several fragments. + size_t discarded_fragments = 0; // Failed bindings are ignored, with a warning and diagnostic marker. +}; + +// Clips a prepared, unchanged perimeter (>= 3 vertices, no consecutive duplicates, either direction) +// against a modifier's regions and returns its segments; strong targets are prepared only where needed. +SegmentExtraction extract_perimeter_segments(const PreparedPerimeter &prepared, const ModifierRegions &modifier, + ModelVolumeType mode, const ExtractionContext &context = {}); + // 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 + PerimeterPosition left_position; // Position on the source perimeter before insertion/refinement. Point right_point; // Coordinates of right (last) point of segment - size_t right_idx; // Perimeter vertex index for right_point + PerimeterPosition right_position; // Retained provenance, not an index into the modified polygon. + // Full containment of an Enforced or Neutral modifier: the zone is the whole perimeter, without + // boundaries (the points and positions above are unused and nothing is inserted for it). + bool whole_perimeter = false; }; -// 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 +// Collects the object's Precise Seam volumes: strong ones in priority order, weak ones in application +// order. Call once per object in SeamPlacer::init() before gathering candidates. 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 +// Inserts the seam point of the first strong modifier with a usable segment on this perimeter and returns +// it, or nullopt. `prepared` must describe `polygon` before any change. std::optional insert_strong_seam_point( const std::vector &strong_volumes, Polygon &polygon, + const PreparedPerimeter &prepared, const Layer *layer, - const ModifierSlicesCache &slices_cache, + const ModifierRegionsCache &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) +// Collects weak zones, inserts their boundaries into `polygon` and subdivides enforced edges. Pass +// modifiers lowest priority first; `prepared` must describe the unchanged `polygon`. std::vector collect_weak_modifier_segments( const std::vector &weak_volumes, Polygon &polygon, + const PreparedPerimeter &prepared, const Layer *layer, - const ModifierSlicesCache &slices_cache, + const ModifierRegionsCache &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 +// Retypes the candidates inside each zone in the given order (pass zones lowest priority first); +// sets some_point_enforced when an Enforced zone applies. void apply_weak_modifiers_to_perimeter( const std::vector &weak_segments, PrintObjectSeamData::LayerSeams &result, diff --git a/src/libslic3r/GCode/PreciseSeamInternal.hpp b/src/libslic3r/GCode/PreciseSeamInternal.hpp new file mode 100644 index 0000000000..bfd230ba3d --- /dev/null +++ b/src/libslic3r/GCode/PreciseSeamInternal.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "PreciseSeam.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/Polygon.hpp" +#include "libslic3r/Polyline.hpp" +#include +#include + +namespace Slic3r::PreciseSeam::detail { + +// Binding intermediates retain source edge identity until segment assembly. +struct ClippedEdgeInterval { + size_t edge; + double begin; + double end; + Point first; + Point last; +}; + +struct FragmentBindingFailure { + size_t pair_index = 0; + const char *reason = "empty fragment"; +}; + +// Failure rolls back this fragment only; earlier bindings remain intact. +bool append_projected_fragment(const Polyline &fragment, const Polygon &perimeter, + std::vector &intervals, + FragmentBindingFailure &failure); + +// Exact path, then projection path, without the fallback; intervals are unchanged on failure. +// Exposed so tests can show that a fragment needs the fallback in append_fragment(). +bool bind_fragment(const Polyline &fragment, const Polygon &perimeter, + std::vector &intervals, FragmentBindingFailure &failure); + +// Binds one fragment. After a failure it tries the rare-case repair and the contact rule, both logged +// as recoveries. Returns false when the fragment is discarded. +bool append_fragment(const Polyline &fragment, const Polygon &perimeter, + std::vector &intervals, + const ExtractionContext &context, size_t fragment_index); + +} // namespace Slic3r::PreciseSeam::detail diff --git a/src/libslic3r/GCode/SeamPlacer.cpp b/src/libslic3r/GCode/SeamPlacer.cpp index 720339f746..07e1cc8882 100644 --- a/src/libslic3r/GCode/SeamPlacer.cpp +++ b/src/libslic3r/GCode/SeamPlacer.cpp @@ -17,6 +17,7 @@ #include "tbb/parallel_reduce.h" #include #include +#include #include #include #include @@ -334,10 +335,8 @@ struct GlobalModelInfo { // 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; + // Slice each modifier once; both consumers share structured regions and source provenance. + PreciseSeam::ModifierRegionsCache precise_seam_slices; bool is_enforced(const Vec3f &position, float radius) const { if (enforcers.empty()) { @@ -518,22 +517,26 @@ void process_perimeter_polygon(const Polygon &orig_polygon, float z_coord, const 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); + std::optional seam_point; + std::vector weak_segments; + if (layer != nullptr && (!strong_volumes.empty() || !weak_volumes.empty())) { + // Share validation, bounds and clipping line across all modifiers while the polygon is unchanged. + // A strong insertion ends processing; otherwise weak reads the same preparation before inserting. + const PreciseSeam::PreparedPerimeter prepared(polygon); + seam_point = PreciseSeam::insert_strong_seam_point( + strong_volumes, polygon, prepared, layer, global_model_info.precise_seam_slices, warnings); + if (!seam_point.has_value()) + weak_segments = PreciseSeam::collect_weak_modifier_segments( + weak_volumes, polygon, prepared, layer, global_model_info.precise_seam_slices, warnings); + } - // Store the inserted point position for marking as central_enforcer later + // 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 { }; @@ -627,7 +630,8 @@ void process_perimeter_polygon(const Polygon &orig_polygon, float z_coord, const patches_starts_ends.push_back(next_index(i)); } } - //if patches_starts_ends are empty, it means that the whole perimeter is enforced.. don't do anything in that case + // If patches_starts_ends are empty, the whole perimeter is enforced, or no point is enforced any more + // (Precise Seam weak zones retyped every painted enforcer); don't do anything in either case. if (!patches_starts_ends.empty()) { //if the first point in the patches is not enforced, it marks a patch end. in that case, put it to the end and start on next // to simplify the processing @@ -795,6 +799,12 @@ void gather_enforcers_blockers(GlobalModelInfo &result, const PrintObject *po) { auto obj_transform = po->trafo_centered(); for (const ModelVolume *mv : po->model_object()->volumes) { + // Collect painting only from model parts (what the gizmo edits) and negative volumes (the only way + // to paint a hole's wall); painting left on modifiers and helpers after a type change is ignored. + // TODO: painting on negative volumes still affects the seam, but the gizmo neither shows nor edits it; + // making it editable also needs model_custom_seam_data_changed() to track it. + if (!mv->is_model_part() && !mv->is_negative_volume()) + continue; if (mv->is_seam_painted()) { auto model_transformation = obj_transform * mv->get_matrix(); @@ -1508,9 +1518,10 @@ void SeamPlacer::align_seam_points(const PrintObject *po, const SeamPlacerImpl:: } -void SeamPlacer::init(Print &print, std::function throw_if_canceled_func) { +void SeamPlacer::init(const Print &print, std::function throw_if_canceled_func) { using namespace SeamPlacerImpl; m_seam_per_object.clear(); + m_precise_seam_warning.clear(); // Warning flags for Precise Seam processing — shared across all objects PreciseSeam::PreciseSeamWarnings precise_seam_warnings; @@ -1529,13 +1540,17 @@ void SeamPlacer::init(Print &print, std::function throw_if_canceled_ 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. + // Slice each Precise Seam modifier once per object; both consumers read the cache. for (const ModelVolume* vol : global_model_info.precise_seam_strong_volumes) - global_model_info.precise_seam_slices[vol] = po->slice_single_volume(vol); + global_model_info.precise_seam_slices[vol] = PreciseSeam::prepare_modifier_slices(po->slice_single_volume_regions(vol)); for (const ModelVolume* vol : global_model_info.precise_seam_weak_volumes) - global_model_info.precise_seam_slices[vol] = po->slice_single_volume(vol); + global_model_info.precise_seam_slices[vol] = PreciseSeam::prepare_modifier_slices(po->slice_single_volume_regions(vol)); + // Register usage tracking before the parallel phase; workers only set its flags. Several print + // objects of one model object share volumes, and try_emplace keeps what earlier ones recorded. + for (const ModelVolume* vol : global_model_info.precise_seam_strong_volumes) + precise_seam_warnings.modifier_usage.try_emplace(vol); + for (const ModelVolume* vol : global_model_info.precise_seam_weak_volumes) + precise_seam_warnings.modifier_usage.try_emplace(vol); throw_if_canceled_func(); if (configured_seam_preference == spAligned || configured_seam_preference == spNearest || configured_seam_preference == spAlignedBack) { @@ -1603,24 +1618,74 @@ void SeamPlacer::init(Print &print, std::function throw_if_canceled_ #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. + // Prepare one combined Precise Seam warning; G-code export issues it. Keep it single: separate + // warnings would each re-push all warnings and duplicate text in the notification. { - 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); + const unsigned failed_types = precise_seam_warnings.failed_types.load(std::memory_order_relaxed); + const unsigned mi = precise_seam_warnings.multiple_intersections.load(std::memory_order_relaxed); + const unsigned fc = precise_seam_warnings.full_containment.load(std::memory_order_relaxed); + const size_t failed = precise_seam_warnings.failed_fragments.load(std::memory_order_relaxed); + // All workers have finished; cancellation before this point may omit the summary. + if (failed > PreciseSeam::failed_fragment_log_limit) + BOOST_LOG_TRIVIAL(warning) << "[PreciseSeamIntersectionFailed] " << failed + << " fragments discarded; first " << PreciseSeam::failed_fragment_log_limit + << " logged (parallel processing order), " << (failed - PreciseSeam::failed_fragment_log_limit) + << " omitted"; + // Recoveries are log-only: no user warning, but the same bounded detail and a total. + const size_t recovered = precise_seam_warnings.recovered_fragments.load(std::memory_order_relaxed); + if (recovered > PreciseSeam::failed_fragment_log_limit) + BOOST_LOG_TRIVIAL(warning) << "[PreciseSeamFragmentRecovered] " << recovered + << " fragments recovered; first " << PreciseSeam::failed_fragment_log_limit + << " logged (parallel processing order), " << (recovered - PreciseSeam::failed_fragment_log_limit) + << " omitted"; + // Reasons name the modifier types, as the menu does, not individual modifiers: "Seam Left, Seam + // Enforced" in menu order, each type once. The same msgids as the menu share its translations. + const auto type_list = [](unsigned mask) { + const std::pair types[] = { + {ModelVolumeType::PRECISE_SEAM_CENTER, _u8L("Seam Center")}, + {ModelVolumeType::PRECISE_SEAM_LEFT, _u8L("Seam Left")}, + {ModelVolumeType::PRECISE_SEAM_RIGHT, _u8L("Seam Right")}, + {ModelVolumeType::PRECISE_SEAM_ENFORCED, _u8L("Seam Enforced")}, + {ModelVolumeType::PRECISE_SEAM_BLOCKED, _u8L("Seam Blocked")}, + {ModelVolumeType::PRECISE_SEAM_NEUTRAL, _u8L("Seam Neutral")}}; + std::string list; + for (const auto &[type, name] : types) + if (mask & PreciseSeam::PreciseSeamWarnings::type_bit(type)) + list += (list.empty() ? "" : ", ") + name; + return list; + }; 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 (failed_types != 0) + parts.push_back((boost::format(_u8L("failed to process some intersections (%1%)")) % type_list(failed_types)).str()); + if (mi != 0) + parts.push_back((boost::format(_u8L("multiple intersections with a perimeter, only one was used (%1%)")) % type_list(mi)).str()); + if (fc != 0) + parts.push_back((boost::format(_u8L("a perimeter is fully inside a modifier, the modifier was not applied to it (%1%)")) % type_list(fc)).str()); + // Modifiers evaluated somewhere that never reached a perimeter; never-evaluated ones are not reported. + // Print and volume order make the named one deterministic; the log lists them all. + std::vector no_effect; + for (const PrintObject *po : print.objects()) + for (const ModelVolume *volume : po->model_object()->volumes) { + const auto it = precise_seam_warnings.modifier_usage.find(volume); + if (it != precise_seam_warnings.modifier_usage.end() && + it->second.checked.load(std::memory_order_relaxed) && + !it->second.reached.load(std::memory_order_relaxed) && + std::find(no_effect.begin(), no_effect.end(), volume) == no_effect.end()) + no_effect.push_back(volume); + } + // The user warning names only the first one; the log lists them all. + for (const ModelVolume *volume : no_effect) + BOOST_LOG_TRIVIAL(warning) << "[PreciseSeamNoEffect] object=\"" << volume->get_object()->name + << "\" modifier=\"" << volume->name << "\""; + if (!no_effect.empty()) { + const ModelVolume *first = no_effect.front(); + if (no_effect.size() == 1) + parts.push_back((boost::format(_u8L("modifier \"%1%\" of \"%2%\" had no effect on the seam (it might not reach the centerline of the printed perimeter)")) + % first->name % first->get_object()->name).str()); + else + parts.push_back((boost::format(_u8L("modifier \"%1%\" of \"%2%\" (%3% in total) had no effect on the seam (it might not reach the centerline of the printed perimeter)")) + % first->name % first->get_object()->name % no_effect.size()).str()); + } if (!parts.empty()) { // One line: the export warnings dialog shows only the first line of each warning. std::string warning_text = _u8L("Precise Seam") + ": "; @@ -1630,10 +1695,7 @@ void SeamPlacer::init(Print &print, std::function throw_if_canceled_ } warning_text += ". "; warning_text += _u8L("Seam placement may differ from expected."); - print.active_step_add_warning( - PrintStateBase::WarningLevel::NON_CRITICAL, - warning_text, - PrintStateBase::SlicingPreciseSeamWarning); + m_precise_seam_warning = std::move(warning_text); } } } diff --git a/src/libslic3r/GCode/SeamPlacer.hpp b/src/libslic3r/GCode/SeamPlacer.hpp index cbf2c73fbe..ab3dc8089d 100644 --- a/src/libslic3r/GCode/SeamPlacer.hpp +++ b/src/libslic3r/GCode/SeamPlacer.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include "libslic3r/Point.hpp" #include "libslic3r/libslic3r.h" @@ -154,10 +155,16 @@ public: //The following data structures hold all perimeter points for all PrintObject. std::unordered_map m_seam_per_object; - void init(Print &print, std::function throw_if_canceled_func); + void init(const Print &print, std::function throw_if_canceled_func); + + // Precise Seam user warning prepared by the last init(), empty if there is none. init() does not + // change the Print: the caller issues the warning where a print step is active (G-code export). + const std::string &precise_seam_warning() const { return m_precise_seam_warning; } void place_seam(const Layer *layer, ExtrusionLoop &loop, const Point &last_pos, float& overhang) const; private: + std::string m_precise_seam_warning; + void gather_seam_candidates(const PrintObject *po, const SeamPlacerImpl::GlobalModelInfo &global_model_info, PreciseSeam::PreciseSeamWarnings* warnings = nullptr); void calculate_candidates_visibility(const PrintObject *po, diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 24a77ce716..ee341f2d78 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -1272,7 +1272,6 @@ 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) { @@ -1311,7 +1310,6 @@ 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(); @@ -1435,9 +1433,7 @@ ModelVolume* ModelObject::add_volume_with_shared_mesh(const ModelVolume &other, void ModelObject::delete_volume(size_t idx) { ModelVolumePtrs::iterator i = this->volumes.begin() + idx; - ModelVolume* volume_to_delete = *i; - - delete volume_to_delete; + delete *i; this->volumes.erase(i); if (this->volumes.size() == 1) @@ -1530,6 +1526,7 @@ void ModelObject::sort_volumes(bool full_sort) return vl_type < vr_type; }); } + ModelInstance* ModelObject::add_instance() { ModelInstance* i = new ModelInstance(this); @@ -3889,7 +3886,6 @@ 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 6ef00c8587..da700268c0 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -370,7 +370,6 @@ enum class ModelVolumeType : int { }; // 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; } @@ -1007,13 +1006,13 @@ public: 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; } + bool is_precise_seam() const { return Slic3r::is_precise_seam(m_type); } // 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; } + bool is_precise_seam_strong() const { return Slic3r::is_precise_seam_strong(m_type); } // 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_precise_seam_weak() const { return Slic3r::is_precise_seam_weak(m_type); } 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 diff --git a/src/libslic3r/Polygon.cpp b/src/libslic3r/Polygon.cpp index d7591ae27c..ea5c2ea96e 100644 --- a/src/libslic3r/Polygon.cpp +++ b/src/libslic3r/Polygon.cpp @@ -331,13 +331,10 @@ Points Polygon::concave_points(double angle_threshold) const } // Projection of a point onto the polygon. -Point Polygon::point_projection(const Point &point, size_t *edge_index) const +Point Polygon::point_projection(const Point &point) 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]; @@ -346,15 +343,11 @@ Point Polygon::point_projection(const Point &point, size_t *edge_index) 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(); @@ -367,8 +360,6 @@ Point Polygon::point_projection(const Point &point, size_t *edge_index) 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 ae312fd830..8e7ca2ed91 100644 --- a/src/libslic3r/Polygon.hpp +++ b/src/libslic3r/Polygon.hpp @@ -92,9 +92,7 @@ public: Points convex_points(double angle_threshold = 0.) const; Points concave_points(double angle_threshold = 0.) const; // Projection of a point onto the polygon. - // 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; + Point point_projection(const Point &point) const; std::vector parameter_by_length() const; //BBS diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 2fe135a24c..b4b64a0cd2 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -492,10 +492,8 @@ 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}); } + // Preserve each connected region and its holes for perimeter clipping. + std::vector slice_single_volume_regions(const ModelVolume* volume) const; // 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 6b9a559f03..926a6fbca6 100644 --- a/src/libslic3r/PrintApply.cpp +++ b/src/libslic3r/PrintApply.cpp @@ -740,7 +740,10 @@ void print_objects_regions_invalidate_keep_some_volumes(PrintObjectRegions &prin for (; i_old < old_volumes.size(); ++ i_old) if (old_volumes[i_old]->id() >= new_volumes[i_new]->id()) break; - if (i_old != old_volumes.size() && old_volumes[i_old]->id() == new_volumes[i_new]->id()) { + // IDs survive type changes: an old volume that was not a solid or modifier was never cached, + // so treat it as new instead of looking it up. + if (i_old != old_volumes.size() && old_volumes[i_old]->id() == new_volumes[i_new]->id() && + model_volume_solid_or_modifier(*old_volumes[i_old])) { if (old_volumes[i_old]->get_matrix().isApprox(new_volumes[i_new]->get_matrix())) { // Reuse the volume. for (; print_object_regions.cached_volume_ids[i_cached_volume] < old_volumes[i_old]->id(); ++ i_cached_volume) diff --git a/src/libslic3r/PrintObjectSlice.cpp b/src/libslic3r/PrintObjectSlice.cpp index 43a9b703a0..0581e9c6fd 100644 --- a/src/libslic3r/PrintObjectSlice.cpp +++ b/src/libslic3r/PrintObjectSlice.cpp @@ -1577,19 +1577,12 @@ ExPolygons PrintObject::_shrink_contour_holes(double contour_delta, double hole_ std::vector PrintObject::slice_support_volumes(const ModelVolumeType model_volume_type) const { - // 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 -{ + 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) ; std::vector slices; - if (!volumes.empty()) { - // Share layer heights, transforms and cancellation handling across the selected volumes. + if (it_volume != it_volume_end) { + // Found at least a single support volume of model_volume_type. std::vector zs = zs_from_layers(this->layers()); std::vector merge_layers; bool merge = false; @@ -1597,26 +1590,27 @@ std::vector PrintObject::slice_modifier_volumes(const std::vector([print](){ print->throw_if_canceled(); }); MeshSlicingParamsEx params; params.trafo = this->trafo_centered(); - 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; + 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; + } } } } - } if (merge) { std::vector to_merge; to_merge.reserve(zs.size()); @@ -1634,4 +1628,16 @@ std::vector PrintObject::slice_modifier_volumes(const std::vector PrintObject::slice_single_volume_regions(const ModelVolume* volume) const +{ + if (volume == nullptr) + return {}; + // Match the existing slicing heights and centered transform without flattening holes. + const std::vector zs = zs_from_layers(this->layers()); + MeshSlicingParamsEx params; + params.trafo = this->trafo_centered(); + const Print *print = this->print(); + return slice_volume(*volume, zs, params, [print]() { print->throw_if_canceled(); }); +} + } // namespace Slic3r diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index a46707ff98..597b960710 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -193,7 +193,9 @@ 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 +// Precise Seam modifier colors. Center, Left and Right are deliberately close shades of one orange: +// all three are strong modifiers, and distinct hues per mode would turn the scene into a rainbow. +// The object list icons tell the modes apart. 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 diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index 38633dc86a..59e26663a2 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -5804,6 +5804,7 @@ void ObjectList::change_part_type() return; } #endif + ModelVolumeType ObjectList::get_selected_volume_type() { ModelVolume* volume = get_selected_model_volume(); diff --git a/src/slic3r/GUI/GUI_ObjectList.hpp b/src/slic3r/GUI/GUI_ObjectList.hpp index 4aff69534e..1f2d970cef 100644 --- a/src/slic3r/GUI/GUI_ObjectList.hpp +++ b/src/slic3r/GUI/GUI_ObjectList.hpp @@ -98,7 +98,6 @@ struct MeshErrorsInfo class ObjectList : public wxDataViewCtrl { public: - enum SELECTION_MODE { smUndef = 0, diff --git a/tests/fff_print/test_precise_seam.cpp b/tests/fff_print/test_precise_seam.cpp index 0dd422f0f5..2f48c626cc 100644 --- a/tests/fff_print/test_precise_seam.cpp +++ b/tests/fff_print/test_precise_seam.cpp @@ -6,19 +6,25 @@ #include #include #include "test_helpers.hpp" +#include "libslic3r/BoundingBox.hpp" #include "libslic3r/GCode/PreciseSeam.hpp" +#include "libslic3r/Layer.hpp" +#include "libslic3r/Print.hpp" #include +#include #include "libslic3r/Point.hpp" #include "libslic3r/Polygon.hpp" #include "libslic3r/Model.hpp" -#include "libslic3r/Print.hpp" -#include "libslic3r/Layer.hpp" #include #include #include "libslic3r/libslic3r.h" #include "libslic3r/GCode/SeamPlacer.hpp" +#include "libslic3r/GCodeReader.hpp" +#include "libslic3r/TriangleMesh.hpp" #include +#include +#include using namespace Slic3r; @@ -36,15 +42,17 @@ struct SeamFixture { Print print; Model modifiers; Layer *layer = nullptr; - PreciseSeam::ModifierSlicesCache cache; + PreciseSeam::ModifierRegionsCache cache; - SeamFixture() + explicit SeamFixture(int raft_layers = 0) { // Only the layer/PrintObject context is needed; clipping uses explicit cached slices below. - Test::init_print({Test::cube(20)}, print, model, {{"raft_layers", "0"}}); + Test::init_print({Test::cube(20)}, print, model, {{"raft_layers", std::to_string(raft_layers)}}); 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); + const auto &slicing = object->slicing_parameters(); + // IDs and print heights include the raft; mesh slicing heights remain object-relative. + layer = object->add_layer(int(slicing.raft_layers()), 0.2, slicing.object_print_z_min + 0.2, 0.1); modifiers.add_object(); } @@ -53,7 +61,19 @@ struct SeamFixture { // 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)}); + ExPolygons regions; + for (Polygon &slice : slices) + regions.emplace_back(std::move(slice)); + cache.emplace(volume, PreciseSeam::prepare_modifier_slices({std::move(regions)})); + return volume; + } + + const ModelVolume *add_regions(ModelVolumeType type, ExPolygons regions) + { + // Supply structured slices directly, without reconstructing holes from flat contours. + const ModelVolume *volume = add(type, {}); + REQUIRE(volume->is_precise_seam()); + cache.at(volume) = PreciseSeam::prepare_modifier_slices({std::move(regions)}); return volume; } }; @@ -79,18 +99,196 @@ void require_vertex(const Polygon &polygon, const Point &point) CAPTURE(point.x(), point.y()); REQUIRE(std::find(polygon.points.begin(), polygon.points.end(), point) != polygon.points.end()); } + +std::vector weak_candidate_types( + const Polygon &polygon, const std::vector &segments, + const std::function &painted = {}) +{ + // Use the same coordinate conversion as production when applying prepared boundaries. + // Optional painted types stand in for seam painting, which production assigns before weak zones. + PrintObjectSeamData::LayerSeams candidates; + candidates.perimeters.emplace_back(); + auto &loop = candidates.perimeters.back(); + loop.start_index = 0; + for (const Point &point : polygon.points) { + const Vec2f position = unscale(point).cast(); + candidates.points.emplace_back(Vec3f(position.x(), position.y(), 0), loop, 0, + painted ? painted(point) : SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral); + } + loop.end_index = candidates.points.size(); + bool enforced = false; + PreciseSeam::apply_weak_modifiers_to_perimeter(segments, candidates, loop, enforced); + std::vector types; + for (const auto &candidate : candidates.points) + types.push_back(candidate.type); + return types; +} } // namespace +TEST_CASE("A simple clipped interval has consistent geometry before and after weak boundary insertion", "[PreciseSeam][SegmentExtraction]") +{ + const bool corner = GENERATE(false, true); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const Polygon cut = corner ? rectangle(-2, -2, 4, 4) : rectangle(2, -2, 8, 2); + const Point expected_begin = corner ? mm(0, 4) : mm(2, 0); + const Point expected_end = corner ? mm(4, 0) : mm(8, 0); + const auto extracted = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions(ExPolygons{ExPolygon(cut)}), ModelVolumeType::PRECISE_SEAM_ENFORCED); + REQUIRE(extracted.valid); + REQUIRE(extracted.segments.size() == 1); + CHECK(extracted.segments.front().polyline.points.front() == expected_begin); + CHECK(extracted.segments.front().polyline.points.back() == expected_end); + // Boundary insertion must preserve the extractor endpoints on this analytic fixture. + const auto *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {cut}); + const auto applied = PreciseSeam::collect_weak_modifier_segments({modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(applied.size() == 1); + CHECK(applied.front().left_point == expected_begin); + CHECK(applied.front().right_point == expected_end); + check_square_boundary(perimeter); +} + +TEST_CASE("Unsuccessful strong modifiers share perimeter preparation with weak processing", "[PreciseSeam][Regression]") +{ + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const Points original = perimeter.points; + const auto *empty = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {}); + const auto *distant = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(30, 30, 40, 40)}); + const auto *covering = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(-2, -2, 22, 22)}); + const auto *blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(2, -2, 8, 2)}); + const auto *neutral = fixture.add(ModelVolumeType::PRECISE_SEAM_NEUTRAL, {rectangle(4, -2, 6, 2)}); + const PreciseSeam::PreparedPerimeter prepared(perimeter); + REQUIRE(prepared.valid); + CHECK(prepared.bounds.min == mm(0, 0)); + CHECK(prepared.bounds.max == mm(20, 20)); + REQUIRE(prepared.line.size() == original.size() + 1); + CHECK(prepared.line.points.front() == prepared.line.points.back()); + const Point *line_storage = prepared.line.points.data(); + PreciseSeam::PreciseSeamWarnings warnings; + CHECK_FALSE(PreciseSeam::insert_strong_seam_point( + {empty, distant, covering}, perimeter, prepared, fixture.layer, fixture.cache, &warnings).has_value()); + CHECK(perimeter.points == original); + CHECK(prepared.line.points.data() == line_storage); + CHECK(warnings.full_containment.load()); + + // Weak receives the same preparation; neither consumer needs to reconstruct the clipping line. + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {blocked, neutral}, perimeter, prepared, fixture.layer, fixture.cache, &warnings); + REQUIRE(segments.size() == 2); + // Insertion has now changed the polygon: do not use prepared for any further extraction. + const auto types = weak_candidate_types(perimeter, segments); + for (size_t i = 0; i < perimeter.size(); ++i) { + const Point &p = perimeter[i]; + const bool in_outer = p.y() == 0 && p.x() >= scale_(2.) && p.x() <= scale_(8.); + const bool in_inner = p.x() >= scale_(4.) && p.x() <= scale_(6.); + const auto expected = in_outer && !in_inner ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked : + SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral; + CHECK(types[i] == expected); + } + CHECK(warnings.failed_fragments.load() == 0); + check_square_boundary(perimeter); +} + +TEST_CASE("Structured modifier slices keep holes with their component and preserve sliced area", "[PreciseSeam][SegmentExtraction]") +{ + SeamFixture fixture; + TriangleMesh shell = Test::cube(10); + TriangleMesh cavity = Test::cube(6); + cavity.translate(2., 2., -1.); + cavity.flip_triangles(); + shell.merge(cavity); + TriangleMesh island = Test::cube(2); + island.translate(30., 0., 0.); + shell.merge(island); + // The layer intersects an annulus and a separate island, with areas 100-36 and 4 mm^2. + ModelVolume *volume = fixture.modifiers.objects.front()->add_volume(shell); + const bool mirrored = GENERATE(false, true); + // A negative determinant must preserve exterior/hole winding and the sliced area. + if (mirrored) + volume->set_mirror(Vec3d(-1., 1., 1.)); + PrintObject *object = fixture.print.get_object(0); + PreciseSeam::ModifierRegionsCache cache; + cache.emplace(volume, PreciseSeam::prepare_modifier_slices(object->slice_single_volume_regions(volume))); + const auto &layers = cache.at(volume); + REQUIRE(layers.size() == 1); + REQUIRE(layers.front().size() == 2); + size_t holes = 0; + double area = 0.; + for (const auto &cached_region : layers.front()) { + const ExPolygon ®ion = cached_region.polygon; + holes += region.holes.size(); + CHECK(region.contour.is_counter_clockwise()); + for (const Polygon &hole : region.holes) + CHECK(hole.is_clockwise()); + area += region.area(); + } + CHECK(holes == 1); + CHECK_THAT(area / double(scale_(1.)) / double(scale_(1.)), Catch::Matchers::WithinAbs(68., 1e-4)); +} + +TEST_CASE("Modifier slices above a raft use object layer indices for strong and weak seams", "[PreciseSeam][Regression]") +{ + const auto mode = GENERATE(ModelVolumeType::PRECISE_SEAM_LEFT, ModelVolumeType::PRECISE_SEAM_CENTER, + ModelVolumeType::PRECISE_SEAM_RIGHT, ModelVolumeType::PRECISE_SEAM_BLOCKED); + SeamFixture fixture(3); + PrintObject *object = fixture.print.get_object(0); + const auto &slicing = object->slicing_parameters(); + REQUIRE(slicing.raft_layers() > 0); + REQUIRE(fixture.layer->id() == slicing.raft_layers()); + // The real modifier mesh intersects the lower sampled object layer, but not the upper one. + Layer *upper = object->add_layer(int(slicing.raft_layers() + 1), 0.2, slicing.object_print_z_min + 4.2, 4.1); + ModelVolume *modifier = fixture.modifiers.objects.front()->add_volume(Test::cube(4)); + modifier->set_type(mode); + const auto &slices = fixture.cache.emplace(modifier, PreciseSeam::prepare_modifier_slices(object->slice_single_volume_regions(modifier))).first->second; + REQUIRE(slices.size() == 2); + REQUIRE(slices[0].size() == 1); + CHECK(slices[1].empty()); + + // Position the test perimeter relative to the transformed slice to isolate layer indexing. + const BoundingBox &bounds = slices[0].front().bounds; + const Point origin = bounds.min; + const Polygon original(Points{origin + mm(-2, 2), origin + mm(6, 2), + origin + mm(6, 10), origin + mm(-2, 10)}); + Polygon perimeter = original; + if (is_precise_seam_strong(mode)) { + const auto seam = PreciseSeam::insert_strong_seam_point( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(seam.has_value()); + const double x = mode == ModelVolumeType::PRECISE_SEAM_LEFT ? 0. : + mode == ModelVolumeType::PRECISE_SEAM_RIGHT ? 4. : 2.; + CHECK(*seam == origin + mm(x, 2)); + perimeter = original; + CHECK_FALSE(PreciseSeam::insert_strong_seam_point({modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), upper, fixture.cache).has_value()); + } else { + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(segments.size() == 1); + CHECK(segments.front().left_point == origin + mm(0, 2)); + CHECK(segments.front().right_point == origin + mm(4, 2)); + const auto types = weak_candidate_types(perimeter, segments); + for (size_t i = 0; i < perimeter.size(); ++i) { + const Point local = perimeter[i] - origin; + const bool inside = local.y() == scale_(2.) && local.x() >= 0 && local.x() <= scale_(4.); + CHECK(types[i] == (inside ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked : + SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral)); + } + perimeter = original; + CHECK(PreciseSeam::collect_weak_modifier_segments({modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), upper, fixture.cache).empty()); + } + CHECK(perimeter.points == original.points); +} + 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. + // Center must retain the correct source edge across several collinear edges. 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); + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, PreciseSeam::PreparedPerimeter(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; @@ -98,7 +296,6 @@ TEST_CASE("Strong seam modes select the requested location on a clipped side", " 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()); } @@ -111,7 +308,7 @@ TEST_CASE("Center seams preserve the edge order at vertices and across the conto // 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); + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, PreciseSeam::PreparedPerimeter(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)); @@ -124,7 +321,7 @@ 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); + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); REQUIRE(seam.has_value()); CHECK(*seam == mm(0, 6)); require_vertex(perimeter, mm(0, 5.999)); @@ -132,35 +329,521 @@ TEST_CASE("Center seams land on the closing edge", "[PreciseSeam]") check_square_boundary(perimeter); } +TEST_CASE("Weak boundaries on the closing edge preserve candidate types", "[PreciseSeam][Regression]") +{ + const bool reverse = GENERATE(false, true); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + // Keep vertex zero fixed so the selected side remains the closing edge in both directions. + if (reverse) + std::reverse(perimeter.points.begin() + 1, perimeter.points.end()); + const Polygon cut = reverse ? rectangle(3, -2, 9, 2) : rectangle(-2, 3, 2, 9); + const auto *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {cut}); + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(segments.size() == 1); + const auto types = weak_candidate_types(perimeter, segments); + for (size_t i = 0; i < perimeter.size(); ++i) { + const Point &p = perimeter[i]; + const coord_t along = reverse ? p.x() : p.y(); + const coord_t across = reverse ? p.y() : p.x(); + const bool inside = across == 0 && along >= scale_(3.) && along <= scale_(9.); + CHECK(types[i] == (inside ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked : + SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral)); + } + check_square_boundary(perimeter); +} + +TEST_CASE("Strong intersection warnings count joined segments across vertex zero", "[PreciseSeam][Regression]") +{ + const bool extra_segment = GENERATE(false, true); + const auto mode = GENERATE(ModelVolumeType::PRECISE_SEAM_LEFT, ModelVolumeType::PRECISE_SEAM_CENTER, + ModelVolumeType::PRECISE_SEAM_RIGHT); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + ExPolygons regions{ExPolygon(rectangle(-2, -2, 4, 4))}; + if (extra_segment) + regions.emplace_back(rectangle(8, -2, 12, 2)); + // The two fragments at vertex zero form one eight-millimeter segment, longer than the extra one. + const auto extracted = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions(regions), mode); + REQUIRE(extracted.segments.size() == (extra_segment ? 2 : 1)); + const auto *modifier = fixture.add_regions(mode, std::move(regions)); + PreciseSeam::PreciseSeamWarnings warnings; + const auto seam = PreciseSeam::insert_strong_seam_point( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + REQUIRE(seam.has_value()); + CHECK(*seam == (mode == ModelVolumeType::PRECISE_SEAM_LEFT ? mm(0, 4) : + mode == ModelVolumeType::PRECISE_SEAM_RIGHT ? mm(4, 0) : mm(0, 0))); + CHECK(warnings.multiple_intersections.load() == (extra_segment ? PreciseSeam::PreciseSeamWarnings::type_bit(mode) : 0u)); + CHECK(warnings.failed_fragments.load() == 0); + CHECK_FALSE(warnings.full_containment.load()); + check_square_boundary(perimeter); +} + +TEST_CASE("A collinear contour origin preserves strong targets and weak candidate types", "[PreciseSeam][Regression]") +{ + const bool reverse = GENERATE(false, true); + const auto mode = GENERATE(ModelVolumeType::PRECISE_SEAM_LEFT, ModelVolumeType::PRECISE_SEAM_CENTER, + ModelVolumeType::PRECISE_SEAM_RIGHT, ModelVolumeType::PRECISE_SEAM_BLOCKED); + SeamFixture fixture; + Polygon perimeter(Points{mm(10, 0), mm(20, 0), mm(20, 20), mm(0, 20), mm(0, 0)}); + // Both clipping fragments have two points, with the artificial cut inside a straight side. + if (reverse) + std::reverse(perimeter.points.begin() + 1, perimeter.points.end()); + const auto *modifier = fixture.add(mode, {rectangle(8, -2, 12, 2)}); + PreciseSeam::PreciseSeamWarnings warnings; + if (is_precise_seam_strong(mode)) { + const auto seam = PreciseSeam::insert_strong_seam_point( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + REQUIRE(seam.has_value()); + const double x = mode == ModelVolumeType::PRECISE_SEAM_CENTER ? 10. : + ((mode == ModelVolumeType::PRECISE_SEAM_LEFT) != reverse ? 8. : 12.); + CHECK(*seam == mm(x, 0)); + } else { + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + REQUIRE(segments.size() == 1); + const auto types = weak_candidate_types(perimeter, segments); + for (size_t i = 0; i < perimeter.size(); ++i) { + const Point &p = perimeter[i]; + const bool inside = p.y() == 0 && p.x() >= scale_(8.) && p.x() <= scale_(12.); + CHECK(types[i] == (inside ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked : + SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral)); + } + } + CHECK_FALSE(warnings.multiple_intersections.load()); + CHECK_FALSE(warnings.full_containment.load()); + CHECK(warnings.failed_fragments.load() == 0); + check_square_boundary(perimeter); +} + +TEST_CASE("A gap on the closing edge preserves the complementary seam segment", "[PreciseSeam][Regression]") +{ + const auto mode = GENERATE(ModelVolumeType::PRECISE_SEAM_LEFT, ModelVolumeType::PRECISE_SEAM_CENTER, + ModelVolumeType::PRECISE_SEAM_RIGHT, ModelVolumeType::PRECISE_SEAM_BLOCKED); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + ExPolygon region(rectangle(-2, -2, 22, 22)); + region.holes.push_back(rectangle(-1, 8, 1, 12)); + region.holes.back().reverse(); + // The retained 76 mm arc starts and ends on edge n-1, with a four-millimeter gap between them. + const auto *modifier = fixture.add_regions(mode, {region}); + PreciseSeam::PreciseSeamWarnings warnings; + if (is_precise_seam_strong(mode)) { + const auto seam = PreciseSeam::insert_strong_seam_point( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + REQUIRE(seam.has_value()); + CHECK(*seam == (mode == ModelVolumeType::PRECISE_SEAM_LEFT ? mm(0, 8) : + mode == ModelVolumeType::PRECISE_SEAM_RIGHT ? mm(0, 12) : mm(20, 10))); + } else { + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + REQUIRE(segments.size() == 1); + const auto types = weak_candidate_types(perimeter, segments); + for (size_t i = 0; i < perimeter.size(); ++i) { + const Point &p = perimeter[i]; + const bool gap = p.x() == 0 && p.y() > scale_(8.) && p.y() < scale_(12.); + CHECK(types[i] == (gap ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral : + SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked)); + } + } + CHECK_FALSE(warnings.multiple_intersections.load()); + CHECK_FALSE(warnings.full_containment.load()); + CHECK(warnings.failed_fragments.load() == 0); + check_square_boundary(perimeter); +} + +TEST_CASE("Enforced oversampling stays inside a zone that wraps around a gap", "[PreciseSeam][Regression]") +{ + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + ExPolygon region(rectangle(-2, -2, 22, 22)); + region.holes.push_back(rectangle(-1, 8, 1, 12)); + region.holes.back().reverse(); + // The zone runs from (0, 8) around the square to (0, 12); the edge after its right boundary is the gap. + // Boundary helpers already keep that edge too short to split, so this guards the invariant, not a visible bug. + const auto *modifier = fixture.add_regions(ModelVolumeType::PRECISE_SEAM_ENFORCED, {region}); + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(segments.size() == 1); + CHECK(segments[0].left_point == mm(0, 8)); + CHECK(segments[0].right_point == mm(0, 12)); + + const auto types = weak_candidate_types(perimeter, segments); + Points gap_points; + size_t enforced_count = 0; + for (size_t i = 0; i < perimeter.size(); ++i) { + const Point &p = perimeter[i]; + const bool gap = p.x() == 0 && p.y() > scale_(8.) && p.y() < scale_(12.); + if (gap) + gap_points.push_back(p); + else + ++enforced_count; + CHECK(types[i] == (gap ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral : + SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced)); + } + // Only the two boundary refinement helpers may lie in the gap; no oversampling points. + std::sort(gap_points.begin(), gap_points.end(), [](const Point &a, const Point &b) { return a.y() < b.y(); }); + CHECK(gap_points == Points{mm(0, 8.001), mm(0, 11.999)}); + // The 76 mm zone itself is oversampled: far more candidates than its corners and boundaries. + CHECK(enforced_count > 76. / SeamPlacer::enforcer_oversampling_distance - 10); + 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. + // Duplicate boundaries must not stop refinement before the separate segment is reached. 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); + const auto segments = PreciseSeam::collect_weak_modifier_segments({c, b, a}, perimeter, PreciseSeam::PreparedPerimeter(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 on one source edge retain insertion order and modifier priority", "[PreciseSeam][Regression]") +{ + const bool closing_edge = GENERATE(false, true); + const bool coincident = GENERATE(false, true); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const Polygon outer = closing_edge ? rectangle(-2, 2, 2, 8) : rectangle(2, -2, 8, 2); + const Polygon inner = coincident ? outer : + (closing_edge ? rectangle(-2, 4, 2, 6) : rectangle(4, -2, 6, 2)); + const auto *blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {outer}); + const auto *neutral = fixture.add(ModelVolumeType::PRECISE_SEAM_NEUTRAL, {inner}); + // Priority order is not geometric insertion order; Neutral must win in the overlap. + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {blocked, neutral}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(segments.size() == 2); + CHECK(segments[0].type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked); + CHECK(segments[1].type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral); + CHECK(segments[0].left_position.edge_index == (closing_edge ? 3 : 0)); + CHECK(segments[0].right_position.edge_index == (closing_edge ? 3 : 0)); + CHECK_THAT(segments[0].left_position.parameter, Catch::Matchers::WithinAbs(closing_edge ? 0.6 : 0.1, 1e-12)); + CHECK_THAT(segments[0].right_position.parameter, Catch::Matchers::WithinAbs(closing_edge ? 0.9 : 0.4, 1e-12)); + + const auto types = weak_candidate_types(perimeter, segments); + for (size_t i = 0; i < perimeter.size(); ++i) { + const Point &p = perimeter[i]; + const coord_t along = closing_edge ? p.y() : p.x(); + const coord_t across = closing_edge ? p.x() : p.y(); + const bool in_outer = across == 0 && along >= scale_(2.) && along <= scale_(8.); + const bool in_inner = coincident ? in_outer : + (across == 0 && along >= scale_(4.) && along <= scale_(6.)); + const auto expected = in_outer && !in_inner ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked : + SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral; + CHECK(types[i] == expected); + } + // Ascending insertion on one edge would retrace the contour or lose pending boundaries. + check_square_boundary(perimeter); +} + +TEST_CASE("Weak zones overwrite painted candidate types inside their boundaries only", "[PreciseSeam][Regression]") +{ + using Type = SeamPlacerImpl::EnforcedBlockedSeamPoint; + // The bottom side is painted Enforced or Blocked, the top side Blocked; the vertical sides stay Neutral. + const Type painted_bottom = GENERATE(Type::Enforced, Type::Blocked); + const auto zone_type = GENERATE(ModelVolumeType::PRECISE_SEAM_ENFORCED, ModelVolumeType::PRECISE_SEAM_BLOCKED, + ModelVolumeType::PRECISE_SEAM_NEUTRAL); + CAPTURE(int(painted_bottom), int(zone_type)); + const Type expected_zone = zone_type == ModelVolumeType::PRECISE_SEAM_ENFORCED ? Type::Enforced : + zone_type == ModelVolumeType::PRECISE_SEAM_BLOCKED ? Type::Blocked : Type::Neutral; + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + // The zone covers x in [6, 14] on the bottom side only. + const auto *modifier = fixture.add(zone_type, {rectangle(6, -2, 14, 2)}); + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(segments.size() == 1); + const auto painted = [painted_bottom](const Point &p) { + return p.y() == 0 ? painted_bottom : p.y() == mm(0, 20).y() ? Type::Blocked : Type::Neutral; + }; + const auto types = weak_candidate_types(perimeter, segments, painted); + for (size_t i = 0; i < perimeter.size(); ++i) { + const Point &p = perimeter[i]; + CAPTURE(p.x(), p.y()); + const bool inside = p.y() == 0 && p.x() >= mm(6, 0).x() && p.x() <= mm(14, 0).x(); + // Inside the zone the weak type wins over painting, Neutral clearing it; outside, painting stays. + CHECK(types[i] == (inside ? expected_zone : painted(p))); + } +} + 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); + const auto segments = PreciseSeam::collect_weak_modifier_segments({a, b}, perimeter, PreciseSeam::PreparedPerimeter(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]") +TEST_CASE("Weak processing applies every ready interval and leaves gaps unchanged", "[PreciseSeam][SegmentExtraction]") +{ + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + ExPolygon area(rectangle(1, -3, 10, 3)); + area.holes.push_back(rectangle(4, -1, 7, 1)); + area.holes.back().reverse(); + const auto *modifier = fixture.add_regions(ModelVolumeType::PRECISE_SEAM_BLOCKED, + {area, ExPolygon(rectangle(14, -3, 18, 3))}); + PreciseSeam::PreciseSeamWarnings warnings; + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + REQUIRE(segments.size() == 3); + const Points starts{mm(1, 0), mm(7, 0), mm(14, 0)}; + const Points ends{mm(4, 0), mm(10, 0), mm(18, 0)}; + for (size_t i = 0; i < segments.size(); ++i) { + CHECK(segments[i].left_point == starts[i]); + CHECK(segments[i].right_point == ends[i]); + require_vertex(perimeter, starts[i]); + require_vertex(perimeter, ends[i]); + } + const auto types = weak_candidate_types(perimeter, segments); + for (size_t i = 0; i < perimeter.size(); ++i) { + const Point &point = perimeter[i]; + // Classify the three independent intervals, including their boundary vertices. + const bool inside = point.y() == 0 && + ((point.x() >= starts[0].x() && point.x() <= ends[0].x()) || + (point.x() >= starts[1].x() && point.x() <= ends[1].x()) || + (point.x() >= starts[2].x() && point.x() <= ends[2].x())); + CHECK(types[i] == (inside ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked : + SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral)); + } + CHECK_FALSE(warnings.multiple_intersections.load()); + CHECK_FALSE(warnings.full_containment.load()); + CHECK(warnings.failed_fragments.load() == 0); + check_square_boundary(perimeter); +} + +TEST_CASE("Weak priority and enforcement refinement apply on both crossed sides", "[PreciseSeam][SegmentExtraction]") +{ + const auto high_type = GENERATE(ModelVolumeType::PRECISE_SEAM_BLOCKED, ModelVolumeType::PRECISE_SEAM_NEUTRAL); + const auto expected_high = high_type == ModelVolumeType::PRECISE_SEAM_BLOCKED ? + SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked : SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral; + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const auto *low = fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, {rectangle(8, -2, 12, 22)}); + const auto *high = fixture.add(high_type, {rectangle(10, -2, 14, 22)}); + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {low, high}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(segments.size() == 4); + CHECK(segments[0].type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced); + CHECK(segments[1].type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced); + CHECK(segments[2].type == expected_high); + CHECK(segments[3].type == expected_high); + const auto types = weak_candidate_types(perimeter, segments); + size_t enforced_counts[2] = {0, 0}; + for (size_t i = 0; i < perimeter.size(); ++i) { + const Point &point = perimeter[i]; + const bool side = point.y() == 0 || point.y() == mm(0, 20).y(); + auto expected = SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral; + if (side && point.x() >= mm(8, 0).x() && point.x() <= mm(12, 0).x()) + expected = SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced; + if (side && point.x() >= mm(10, 0).x() && point.x() <= mm(14, 0).x()) + expected = expected_high; + CHECK(types[i] == expected); + if (expected == SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced) + ++enforced_counts[point.y() == 0 ? 0 : 1]; + } + // Both surviving enforced patches must contain interior candidates, not just boundaries. + CHECK(enforced_counts[0] > 2); + CHECK(enforced_counts[1] > 2); + check_square_boundary(perimeter); +} + +TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the whole perimeter, Blocked is skipped", "[PreciseSeam]") +{ + const auto type = GENERATE(ModelVolumeType::PRECISE_SEAM_ENFORCED, ModelVolumeType::PRECISE_SEAM_BLOCKED, + ModelVolumeType::PRECISE_SEAM_NEUTRAL); + CAPTURE(type); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const Points original = perimeter.points; + const auto *modifier = fixture.add(type, {rectangle(-2, -2, 22, 22)}); + PreciseSeam::PreciseSeamWarnings warnings; + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + // Painting of both kinds, so that each type's effect on it is visible. + const auto painted = [](const Point &p) { + return p.x() < scale_(10.) ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced : + SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked; + }; + const auto types = weak_candidate_types(perimeter, segments, painted); + CHECK_FALSE(warnings.multiple_intersections.load()); + CHECK(warnings.failed_fragments.load() == 0); + if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED) { + // Forbidding the seam all round cannot be honoured: skipped with a warning, painting stays. + CHECK(segments.empty()); + CHECK(perimeter.points == original); + CHECK(warnings.full_containment.load() == PreciseSeam::PreciseSeamWarnings::type_bit(type)); + for (size_t i = 0; i < perimeter.size(); ++i) + CHECK(types[i] == painted(perimeter[i])); + return; + } + REQUIRE(segments.size() == 1); + CHECK(segments.front().whole_perimeter); + CHECK(warnings.full_containment.load() == 0); + // Every candidate takes the zone's type, overriding painting. + const auto expected = type == ModelVolumeType::PRECISE_SEAM_ENFORCED ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced : + SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral; + for (size_t i = 0; i < perimeter.size(); ++i) + CHECK(types[i] == expected); + if (type == ModelVolumeType::PRECISE_SEAM_ENFORCED) { + // As if painted green all round: every 20 mm edge is subdivided into steps of at most 0.2 mm. + CHECK(perimeter.size() >= size_t(80.f / SeamPlacer::enforcer_oversampling_distance)); + check_square_boundary(perimeter); + } else + CHECK(perimeter.points == original); // Like an unmarked perimeter: no subdivision. +} + +TEST_CASE("Whole-perimeter weak zones take part in the usual priority order", "[PreciseSeam]") +{ + // Volumes are listed low priority first; a later zone overwrites an earlier one. + const int scenario = GENERATE(0, 1, 2); + CAPTURE(scenario); + SeamFixture fixture; + const auto *whole_enforced = fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, {rectangle(-2, -2, 22, 22)}); + const auto *whole_neutral = fixture.add(ModelVolumeType::PRECISE_SEAM_NEUTRAL, {rectangle(-2, -2, 22, 22)}); + const auto *whole_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(-2, -2, 22, 22)}); + const auto *band_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(8, -2, 12, 2)}); + const auto *band_enforced = fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, {rectangle(8, -2, 12, 2)}); + std::vector volumes; + if (scenario == 0) volumes = {whole_enforced, band_blocked}; // A band forbidden inside a green perimeter. + if (scenario == 1) volumes = {band_enforced, whole_neutral}; // A whole Neutral clears the lower band. + if (scenario == 2) volumes = {band_enforced, whole_blocked}; // A whole Blocked is skipped; the band stays. + Polygon perimeter = rectangle(0, 0, 20, 20); + PreciseSeam::PreciseSeamWarnings warnings; + const auto segments = PreciseSeam::collect_weak_modifier_segments( + volumes, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + const auto types = weak_candidate_types(perimeter, segments); + using Type = SeamPlacerImpl::EnforcedBlockedSeamPoint; + for (size_t i = 0; i < perimeter.size(); ++i) { + const Point &p = perimeter[i]; + CAPTURE(p.x(), p.y()); + const bool in_band = p.y() == 0 && p.x() >= scale_(8.) && p.x() <= scale_(12.); + const Type expected = scenario == 0 ? (in_band ? Type::Blocked : Type::Enforced) : + scenario == 1 ? Type::Neutral : + (in_band ? Type::Enforced : Type::Neutral); + CHECK(types[i] == expected); + } + CHECK(warnings.full_containment.load() == + (scenario == 2 ? PreciseSeam::PreciseSeamWarnings::type_bit(ModelVolumeType::PRECISE_SEAM_BLOCKED) : 0u)); + check_square_boundary(perimeter); +} + +TEST_CASE("Coverage short by a gap under 1 um is full containment while a narrow band keeps its point", "[PreciseSeam][Regression]") +{ + // A tip pokes through the bottom edge at x = 10 mm by `depth` nm, so the perimeter line crosses it + // over about 2 * depth. As a hole in a large region it leaves that much uncovered (a touch from + // inside); as a separate triangle it is that much covered (a narrow band or an outside contact). + const auto type = GENERATE(ModelVolumeType::PRECISE_SEAM_ENFORCED, ModelVolumeType::PRECISE_SEAM_CENTER, + ModelVolumeType::PRECISE_SEAM_LEFT, ModelVolumeType::PRECISE_SEAM_RIGHT); + const bool gap = GENERATE(true, false); + const coord_t depth = GENERATE(coord_t(2), coord_t(2000)); + CAPTURE(type, gap, depth); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const Points original = perimeter.points; + const Point tip(mm(10, 0).x(), gap ? -depth : depth); + ExPolygon region; + if (gap) { + region = ExPolygon(rectangle(-2, -2, 22, 22)); + region.holes.push_back(Polygon(Points{tip, mm(11, 1), mm(9, 1)})); + region.holes.back().reverse(); + } else { + region = ExPolygon(Polygon(Points{mm(9, -1), mm(11, -1), tip})); + } + const auto *modifier = fixture.add_regions(type, {region}); + const bool micro = depth < scale_(0.001); + PreciseSeam::PreciseSeamWarnings warnings; + if (is_precise_seam_strong(type)) { + const auto seam = PreciseSeam::insert_strong_seam_point( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + if (gap && micro) { + // Skipped like an exact touch, instead of a seam at the touch or on the opposite side. + CHECK_FALSE(seam.has_value()); + CHECK(perimeter.points == original); + } else { + REQUIRE(seam.has_value()); + if (!gap) // The band is processed normally: the seam lands on it. + CHECK((*seam - mm(10, 0)).cast().norm() <= double(depth) + 1.); + } + } else { + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + const auto types = weak_candidate_types(perimeter, segments); + const size_t enforced = size_t(std::count(types.begin(), types.end(), SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced)); + if (gap && micro) { + // Full containment like an exact touch, instead of collapsing the intended zone into one forced + // point: Enforced then types the whole perimeter, subdivided as if painted green all round. + REQUIRE(segments.size() == 1); + CHECK(segments.front().whole_perimeter); + CHECK(enforced == types.size()); + CHECK(enforced > 300); + } else if (gap) { + REQUIRE(segments.size() == 1); + CHECK(enforced > 300); // A 4 um gap is real: nearly the whole 80 mm perimeter, oversampled. + } else { + // The band is processed normally; below 1 um its boundaries snap into a single candidate. + REQUIRE(segments.size() == 1); + CHECK(enforced == (micro ? 1u : 2u)); + } + } + // Only the sub-micron gap is full containment, and only strong types skip it with a warning; + // nothing here is a binding failure. + CHECK((warnings.full_containment.load() != 0) == (gap && micro && is_precise_seam_strong(type))); + CHECK(warnings.failed_fragments.load() == 0); + check_square_boundary(perimeter); +} + +TEST_CASE("A modifier touching the perimeter at one point keeps the full containment policy", "[PreciseSeam][Regression]") +{ + const auto type = GENERATE(ModelVolumeType::PRECISE_SEAM_CENTER, ModelVolumeType::PRECISE_SEAM_LEFT, + ModelVolumeType::PRECISE_SEAM_RIGHT, ModelVolumeType::PRECISE_SEAM_ENFORCED, + ModelVolumeType::PRECISE_SEAM_BLOCKED, ModelVolumeType::PRECISE_SEAM_NEUTRAL); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const Points original = perimeter.points; + // The hole touches the perimeter only at (10, 0): no gap, so the contact must not create segments. + ExPolygon region(rectangle(-2, -2, 22, 22)); + region.holes.push_back(Polygon(Points{mm(10, 0), mm(12, 2), mm(10, 4), mm(8, 2)})); + region.holes.back().reverse(); + const auto *modifier = fixture.add_regions(type, {region}); + PreciseSeam::PreciseSeamWarnings warnings; + if (is_precise_seam_strong(type)) + CHECK_FALSE(PreciseSeam::insert_strong_seam_point( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings).has_value()); + else { + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + // Enforced and Neutral type the whole perimeter; Blocked is skipped. + if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED) + CHECK(segments.empty()); + else { + REQUIRE(segments.size() == 1); + CHECK(segments.front().whole_perimeter); + } + } + // Only a whole-perimeter Enforced zone subdivides the edges. + if (type != ModelVolumeType::PRECISE_SEAM_ENFORCED) + CHECK(perimeter.points == original); + const bool skipped = is_precise_seam_strong(type) || type == ModelVolumeType::PRECISE_SEAM_BLOCKED; + CHECK(warnings.full_containment.load() == (skipped ? PreciseSeam::PreciseSeamWarnings::type_bit(type) : 0u)); + CHECK_FALSE(warnings.multiple_intersections.load()); + CHECK(warnings.failed_fragments.load() == 0); +} + +TEST_CASE("Unsupported strong modifier sections are skipped with the appropriate warning", "[PreciseSeam]") { const int scenario = GENERATE(0, 1, 2, 3); SeamFixture fixture; @@ -175,35 +858,149 @@ TEST_CASE("Unsupported modifier sections are skipped with the appropriate warnin hole.reverse(); slices = {rectangle(-2, -2, 22, 22), hole}; } - const auto *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, std::move(slices)); + const auto *modifier = scenario == 3 ? + fixture.add_regions(ModelVolumeType::PRECISE_SEAM_CENTER, {ExPolygon(slices[0], slices[1])}) : + 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_FALSE(PreciseSeam::insert_strong_seam_point({modifier}, perimeter, PreciseSeam::PreparedPerimeter(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((warnings.full_containment.load() != 0) == (scenario == 2 || scenario == 3)); CHECK_FALSE(warnings.multiple_intersections.load()); - CHECK_FALSE(warnings.through_body.load()); +} + +TEST_CASE("Strong selects the longest arc rather than the longest chord", "[PreciseSeam][SegmentExtraction]") +{ + const auto mode = GENERATE(ModelVolumeType::PRECISE_SEAM_LEFT, ModelVolumeType::PRECISE_SEAM_CENTER, + ModelVolumeType::PRECISE_SEAM_RIGHT); + const size_t origin = GENERATE(size_t(0), size_t(2)); + const bool reverse_regions = GENERATE(false, true); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + std::rotate(perimeter.points.begin(), perimeter.points.begin() + origin, perimeter.points.end()); + // The corner arc is 8 mm with a shorter chord than the other 6 mm interval. + ExPolygons regions{ExPolygon(rectangle(10, -2, 16, 2)), ExPolygon(rectangle(-2, -2, 4, 4))}; + if (reverse_regions) std::reverse(regions.begin(), regions.end()); + const auto *modifier = fixture.add_regions(mode, std::move(regions)); + PreciseSeam::PreciseSeamWarnings warnings; + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + REQUIRE(seam.has_value()); + CHECK(*seam == (mode == ModelVolumeType::PRECISE_SEAM_LEFT ? mm(0, 4) : + mode == ModelVolumeType::PRECISE_SEAM_RIGHT ? mm(4, 0) : mm(0, 0))); + CHECK(warnings.multiple_intersections.load()); + check_square_boundary(perimeter); +} + +TEST_CASE("Equal strong lengths compare the selected mode points", "[PreciseSeam][SegmentExtraction]") +{ + const auto mode = GENERATE(ModelVolumeType::PRECISE_SEAM_LEFT, ModelVolumeType::PRECISE_SEAM_CENTER, + ModelVolumeType::PRECISE_SEAM_RIGHT); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + // Both arcs are 6 mm: Left/Center prefer the corner; Right prefers the rear straight segment. + const auto *modifier = fixture.add(mode, {rectangle(10, 18, 16, 22), rectangle(-2, 17, 3, 22)}); + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(seam.has_value()); + CHECK(*seam == (mode == ModelVolumeType::PRECISE_SEAM_LEFT ? mm(3, 20) : + mode == ModelVolumeType::PRECISE_SEAM_RIGHT ? mm(10, 20) : mm(0, 20))); + check_square_boundary(perimeter); +} + +TEST_CASE("Strong length comparison does not merge nearly equal lengths", "[PreciseSeam][SegmentExtraction]") +{ + const coord_t extra = GENERATE(coord_t(0), coord_t(1)); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + Polygon second = rectangle(12, -2, 16, 2); + second.points[1].x() += extra; + second.points[2].x() += extra; + const auto *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_LEFT, {rectangle(2, -2, 6, 2), second}); + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(seam.has_value()); + CHECK(*seam == (extra == 0 ? mm(2, 0) : mm(12, 0))); +} + +TEST_CASE("Strong priority wins over a longer segment in another modifier", "[PreciseSeam][SegmentExtraction]") +{ + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const auto *high = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(2, -2, 4, 2)}); + const auto *low = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(1, 18, 19, 22)}); + const auto seam = PreciseSeam::insert_strong_seam_point({high, low}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(seam.has_value()); + CHECK(*seam == mm(3, 0)); + CHECK(std::find(perimeter.points.begin(), perimeter.points.end(), mm(10, 20)) == perimeter.points.end()); +} + +TEST_CASE("Strong continues past modifiers without usable segments", "[PreciseSeam][SegmentExtraction]") +{ + const bool contained = GENERATE(false, true); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const auto *first = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, + {contained ? rectangle(-2, -2, 22, 22) : rectangle(30, 30, 40, 40)}); + const auto *second = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(2, -2, 6, 2)}); + PreciseSeam::PreciseSeamWarnings warnings; + const auto seam = PreciseSeam::insert_strong_seam_point({first, second}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + REQUIRE(seam.has_value()); + CHECK(*seam == mm(4, 0)); + CHECK((warnings.full_containment.load() != 0) == contained); +} + +TEST_CASE("Strong compares all segments of structured modifier regions", "[PreciseSeam][SegmentExtraction]") +{ + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + ExPolygon area(rectangle(1, -3, 10, 3)); + area.holes.push_back(rectangle(4, -1, 7, 1)); + area.holes.back().reverse(); + const auto *modifier = fixture.add_regions(ModelVolumeType::PRECISE_SEAM_CENTER, + {area, ExPolygon(rectangle(14, -3, 18, 3))}); + PreciseSeam::PreciseSeamWarnings warnings; + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + REQUIRE(seam.has_value()); + CHECK(*seam == mm(16, 0)); + CHECK(warnings.multiple_intersections.load()); + CHECK_FALSE(warnings.full_containment.load()); + CHECK(warnings.failed_fragments.load() == 0); + check_square_boundary(perimeter); +} + +TEST_CASE("Strong tie order uses bed axes after rotating and translating the input", "[PreciseSeam][SegmentExtraction]") +{ + const bool rotated = GENERATE(false, true); + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + Polygon modifier_region = rectangle(8, -2, 12, 22); + const auto transform = [rotated](Point &p) { + // Rotation is already baked into slice coordinates; translation cannot change ordering. + if (rotated) p = Point(-p.y(), p.x()); + p += mm(100, 200); + }; + for (Point &p : perimeter.points) transform(p); + for (Point &p : modifier_region.points) transform(p); + const auto *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {modifier_region}); + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(seam.has_value()); + CHECK(*seam == (rotated ? mm(80, 210) : mm(110, 220))); } 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. + // Equal lengths on the same side select the leftmost mode point, independent of slice order. 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); + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, PreciseSeam::PreparedPerimeter(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]") +TEST_CASE("Crossing modifiers choose the rear strong segment and keep both weak segments", "[PreciseSeam]") { const bool strong = GENERATE(false, true); CAPTURE(strong); @@ -215,20 +1012,19 @@ TEST_CASE("Modifiers crossing the entire body raise a through body warning", "[P 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); + const auto seam = PreciseSeam::insert_strong_seam_point({modifier}, perimeter, PreciseSeam::PreparedPerimeter(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); + CHECK(*seam == mm(10, 20)); } else { - const auto segments = PreciseSeam::collect_weak_modifier_segments({modifier}, perimeter, fixture.layer, fixture.cache, &warnings); - REQUIRE_FALSE(segments.empty()); + const auto segments = PreciseSeam::collect_weak_modifier_segments({modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings); + REQUIRE(segments.size() == 2); + CHECK(segments[0].left_point == mm(8, 0)); + CHECK(segments[0].right_point == mm(12, 0)); + CHECK(segments[1].left_point == mm(12, 20)); + CHECK(segments[1].right_point == mm(8, 20)); } - CHECK(warnings.through_body.load()); - CHECK_FALSE(warnings.multiple_intersections.load()); // The clipped strip is one polygon. + CHECK((warnings.multiple_intersections.load() != 0) == strong); // Warn only strong, after collecting all segments. CHECK_FALSE(warnings.full_containment.load()); - CHECK_FALSE(warnings.multiply_connected.load()); check_square_boundary(perimeter); } @@ -253,12 +1049,12 @@ TEST_CASE("Modifier hierarchy keeps strong order and applies the highest weak pr 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); + const auto seam = PreciseSeam::insert_strong_seam_point(strong, perimeter, PreciseSeam::PreparedPerimeter(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); + const auto segments = PreciseSeam::collect_weak_modifier_segments(weak, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); REQUIRE(segments.size() == 2); PrintObjectSeamData::LayerSeams result; result.perimeters.emplace_back(); @@ -292,3 +1088,341 @@ TEST_CASE("Modifier hierarchy keeps strong order and applies the highest weak pr } CHECK(result.points.front().type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral); } + +TEST_CASE("Volume sorting keeps strong before weak Precise Seam modifiers and the user order inside each group", "[PreciseSeam][Model]") +{ + Model model; + ModelObject *object = model.add_object(); + // Enum order inside a group (Center < Right, Enforced < Neutral) is deliberately reversed here: + // only the group may move a volume, the user's order inside it must survive. + const auto add = [object](ModelVolumeType type) { + ModelVolume *volume = object->add_volume(Test::cube(1)); + volume->set_type(type); + return volume; + }; + ModelVolume *part = add(ModelVolumeType::MODEL_PART); + ModelVolume *modifier = add(ModelVolumeType::PARAMETER_MODIFIER); + ModelVolume *second_part = add(ModelVolumeType::MODEL_PART); + ModelVolume *weak_neutral = add(ModelVolumeType::PRECISE_SEAM_NEUTRAL); + ModelVolume *strong_right = add(ModelVolumeType::PRECISE_SEAM_RIGHT); + ModelVolume *weak_enforced = add(ModelVolumeType::PRECISE_SEAM_ENFORCED); + ModelVolume *strong_center = add(ModelVolumeType::PRECISE_SEAM_CENTER); + const ModelVolumePtrs original = object->volumes; + + // A full sort orders parts before modifiers; a partial one keeps parts and modifiers as they are. + const bool full_sort = GENERATE(false, true); + CAPTURE(full_sort); + object->volumes = original; + object->sort_volumes(full_sort); + const ModelVolumePtrs expected_head = full_sort ? ModelVolumePtrs{part, second_part, modifier} + : ModelVolumePtrs{part, modifier, second_part}; + ModelVolumePtrs expected = expected_head; + for (ModelVolume *volume : {strong_right, strong_center, weak_neutral, weak_enforced}) + expected.push_back(volume); + CHECK(object->volumes == expected); +} + +TEST_CASE("Restoring Precise Seam positions overrides alignment only on perimeters with a strong point", "[PreciseSeam]") +{ + std::vector layers(1); + PrintObjectSeamData::LayerSeams &layer = layers.front(); + // Two perimeters of three candidates each; alignment has already finalized both. + for (size_t loop_idx = 0; loop_idx < 2; ++loop_idx) { + layer.perimeters.emplace_back(); + SeamPlacerImpl::Perimeter &loop = layer.perimeters.back(); + loop.start_index = layer.points.size(); + for (size_t i = 0; i < 3; ++i) + layer.points.emplace_back(Vec3f(float(i), float(loop_idx), 0.f), loop, 0.f, + SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral); + loop.end_index = layer.points.size(); + loop.finalized = true; + loop.seam_index = loop.start_index; + loop.final_seam_position = Vec3f(0.5f, float(loop_idx), 0.f); + } + SeamPlacerImpl::Perimeter &strong = layer.perimeters[0]; + SeamPlacerImpl::Perimeter &plain = layer.perimeters[1]; + strong.precise_seam_point = Vec3f(2.f, 0.f, 0.f); + strong.precise_seam_index = 2; + + PreciseSeam::restore_precise_seam_positions(layers); + + // The strong point and its candidate index replace the aligned position. + CHECK(strong.final_seam_position == Vec3f(2.f, 0.f, 0.f)); + CHECK(strong.seam_index == 2); + // A perimeter without a strong point keeps whatever alignment chose. + CHECK(plain.final_seam_position == Vec3f(0.5f, 1.f, 0.f)); + CHECK(plain.seam_index == plain.start_index); +} + +TEST_CASE("Modifier usage marks evaluated modifiers that never reach a perimeter", "[PreciseSeam]") +{ + SeamFixture fixture; + PreciseSeam::PreciseSeamWarnings warnings; + const auto checked = [&warnings](const ModelVolume *volume) { return warnings.modifier_usage.at(volume).checked.load(); }; + const auto reached = [&warnings](const ModelVolume *volume) { return warnings.modifier_usage.at(volume).reached.load(); }; + + // Weak: all modifiers are evaluated. One stops 1 mm short of the wall, one crosses it, one contains it. + const ModelVolume *short_weak = fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, {rectangle(21, 5, 25, 10)}); + const ModelVolume *crossing_weak = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(18, 5, 25, 10)}); + const ModelVolume *containing_weak = fixture.add(ModelVolumeType::PRECISE_SEAM_NEUTRAL, {rectangle(-1, -1, 21, 21)}); + for (const ModelVolume *volume : {short_weak, crossing_weak, containing_weak}) + warnings.modifier_usage.try_emplace(volume); + Polygon weak_perimeter = rectangle(0, 0, 20, 20); + PreciseSeam::collect_weak_modifier_segments({short_weak, crossing_weak, containing_weak}, weak_perimeter, + PreciseSeam::PreparedPerimeter(weak_perimeter), fixture.layer, fixture.cache, &warnings); + CHECK(checked(short_weak)); + CHECK_FALSE(reached(short_weak)); + CHECK(reached(crossing_weak)); + // Full containment reached the perimeter even though it is skipped. + CHECK(reached(containing_weak)); + + // Strong: evaluation stops at the first modifier with a segment. The one ending 0.5 mm before the + // wall is evaluated without reaching it; the one after the winner is never evaluated, so nothing is + // known about it and it must not be reported. + const ModelVolume *short_strong = fixture.add(ModelVolumeType::PRECISE_SEAM_LEFT, {rectangle(5, -2, 10, -0.5)}); + const ModelVolume *winner = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(5, -2, 10, 2)}); + const ModelVolume *shadowed = fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, {rectangle(12, -2, 15, 2)}); + for (const ModelVolume *volume : {short_strong, winner, shadowed}) + warnings.modifier_usage.try_emplace(volume); + Polygon strong_perimeter = rectangle(0, 0, 20, 20); + REQUIRE(PreciseSeam::insert_strong_seam_point({short_strong, winner, shadowed}, strong_perimeter, + PreciseSeam::PreparedPerimeter(strong_perimeter), fixture.layer, fixture.cache, &warnings).has_value()); + CHECK(checked(short_strong)); + CHECK_FALSE(reached(short_strong)); + CHECK(reached(winner)); + CHECK_FALSE(checked(shadowed)); + + // Unregistered modifiers are not tracked. + const ModelVolume *unregistered = fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, {rectangle(18, 5, 25, 10)}); + Polygon other_perimeter = rectangle(0, 0, 20, 20); + PreciseSeam::collect_weak_modifier_segments({unregistered}, other_perimeter, + PreciseSeam::PreparedPerimeter(other_perimeter), fixture.layer, fixture.cache, &warnings); + CHECK(warnings.modifier_usage.count(unregistered) == 0); +} + +TEST_CASE("Weak zones type painting's oversampled candidates between their boundaries", "[PreciseSeam]") +{ + SeamFixture fixture; + Polygon perimeter = rectangle(0, 0, 20, 20); + const auto *modifier = fixture.add(ModelVolumeType::PRECISE_SEAM_NEUTRAL, {rectangle(8, -2, 12, 2)}); + const auto segments = PreciseSeam::collect_weak_modifier_segments( + {modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache); + REQUIRE(segments.size() == 1); + + // Candidates as production builds them for a bottom edge painted green: every polygon point, then + // oversampled points every enforcer_oversampling_distance towards the next one, in float arithmetic. + using Type = SeamPlacerImpl::EnforcedBlockedSeamPoint; + PrintObjectSeamData::LayerSeams candidates; + candidates.perimeters.emplace_back(); + auto &loop = candidates.perimeters.back(); + loop.start_index = 0; + size_t oversampled = 0; + for (size_t i = 0; i < perimeter.size(); ++i) { + const Vec2f a = unscale(perimeter[i]).cast(); + const Vec2f b = unscale(perimeter[(i + 1) % perimeter.size()]).cast(); + const Vec3f position(a.x(), a.y(), 0.f); + candidates.points.emplace_back(position, loop, 0.f, a.y() == 0.f ? Type::Enforced : Type::Neutral); + if (a.y() != 0.f || b.y() != 0.f) + continue; + const Vec3f next(b.x(), b.y(), 0.f); + const float distance = (next - position).norm(); + const Vec3f direction = (next - position).normalized(); + for (float step = SeamPlacer::enforcer_oversampling_distance; step < distance; step += SeamPlacer::enforcer_oversampling_distance) { + candidates.points.emplace_back(position + direction * step, loop, 0.f, Type::Enforced); + ++oversampled; + } + } + loop.end_index = candidates.points.size(); + bool enforced = false; + PreciseSeam::apply_weak_modifiers_to_perimeter(segments, candidates, loop, enforced); + + // Boundaries are found among the interleaved points by exact float identity; every candidate + // between them, oversampled ones included, is cleared, and painting stays outside. + size_t cleared_oversampled = 0; + for (const auto &candidate : candidates.points) { + const Vec3f &p = candidate.position; + CAPTURE(p.x(), p.y()); + const bool in_zone = p.y() == 0.f && p.x() >= 8.f && p.x() <= 12.f; + const Type expected = in_zone ? Type::Neutral : p.y() == 0.f ? Type::Enforced : Type::Neutral; + CHECK(candidate.type == expected); + if (in_zone && p.x() > 8.001f && p.x() < 11.999f) + ++cleared_oversampled; + } + CHECK(oversampled > 0); + CHECK(cleared_oversampled >= 15); // About 4 mm of 0.2 mm steps inside the zone. +} + +namespace { +// A 20 x 20 x 2 mm cube sliced at 0.2 mm, so ten layers, each with one outer wall loop. +struct ExportFixture { + Model model; + Print print; + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + Vec3d cube_min; + + explicit ExportFixture(const std::string &seam_position, int raft_layers = 0) + { + config.set_deserialize_strict("seam_position", seam_position); + config.set_deserialize_strict("seam_slope_type", "none"); // The loop then starts exactly at the seam. + 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("raft_layers", std::to_string(raft_layers)); + Test::init_print({make_cube(20, 20, 2)}, print, model, config); + const ModelVolume *cube = model.objects.front()->volumes.front(); + cube_min = cube->mesh().transformed_bounding_box(cube->get_matrix()).min; + } + + // Places the helper's minimum corner at `min`, relative to the cube's minimum corner. + void add(ModelVolumeType type, TriangleMesh mesh, const Vec3d &min, const std::string &name = "helper") + { + ModelVolume *volume = model.objects.front()->add_volume(std::move(mesh)); + volume->set_type(type); + volume->name = name; + const Vec3d current = volume->mesh().transformed_bounding_box(volume->get_matrix()).min; + volume->set_offset(volume->get_offset() + cube_min + min - current); + } + + std::string export_gcode() + { + print.apply(model, config); + return Test::gcode(print); + } + + std::string warning() const + { + std::string text; + for (const auto &warning : print.step_state_with_warnings(psGCodeExport).warnings) + text += warning.message; + return text; + } +}; + +struct OuterLoop { + Vec2d seam; // Relative to the loop's centre, which is the cube's centre. + BoundingBoxf bounds; +}; + +std::vector outer_wall_loops(const std::string &gcode) +{ + std::vector loops; + bool outer_wall = false; + bool in_loop = false; + GCodeReader parser; + parser.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + const std::string_view comment = line.comment(); + if (comment.find("TYPE:") != std::string_view::npos) { + outer_wall = comment.find("Outer wall") != std::string_view::npos; + in_loop = false; + } + if (!outer_wall || !line.extruding(self) || line.dist_XY(self) == 0.f) + return; + if (!in_loop) { + loops.push_back({Vec2d(self.x(), self.y()), {}}); + loops.back().bounds.merge(loops.back().seam); + in_loop = true; + } + loops.back().bounds.merge(Vec2d(line.new_X(self), line.new_Y(self))); + }); + for (OuterLoop &loop : loops) + loop.seam -= loop.bounds.center(); + return loops; +} + +TriangleMesh helper_with_hole() +{ + // 8 x 6 x 4 mm with a 4 x 3 mm hole through every layer of the cube. + TriangleMesh helper = make_cube(8, 6, 4); + TriangleMesh cavity = make_cube(4, 3, 3); + cavity.translate(2., 1.5, 0.5); + cavity.flip_triangles(); + helper.merge(cavity); + return helper; +} +} // namespace + +TEST_CASE("Strong modifiers place the exported seam on the side they cross", "[PreciseSeam]") +{ + // The helper crosses the front face (y = 0) at x = 4..6 mm. The loop runs counterclockwise, so on + // the front face Left is the x = 4 mm edge and Right the x = 6 mm edge. + const auto [type, x] = GENERATE(table({ + {ModelVolumeType::PRECISE_SEAM_CENTER, 5.}, + {ModelVolumeType::PRECISE_SEAM_LEFT, 4.}, + {ModelVolumeType::PRECISE_SEAM_RIGHT, 6.}})); + const std::string seam_position = GENERATE(as{}, "back", "aligned", "nearest"); + const int raft_layers = GENERATE(0, 2); + CAPTURE(seam_position, raft_layers); + ExportFixture fixture(seam_position, raft_layers); + fixture.add(type, make_cube(2, 6, 4), Vec3d(4, -3, -1)); + const auto loops = outer_wall_loops(fixture.export_gcode()); + REQUIRE(loops.size() == 10); // Raft layers have no outer wall. + for (const OuterLoop &loop : loops) { + CHECK_THAT(loop.seam.x(), Catch::Matchers::WithinAbs(x - 10., 0.01)); + CHECK_THAT(loop.seam.y(), Catch::Matchers::WithinAbs(loop.bounds.min.y() - loop.bounds.center().y(), 0.01)); + } + CHECK(fixture.warning().empty()); +} + +TEST_CASE("Weak modifiers move the exported seam into or out of their zone", "[PreciseSeam]") +{ + // INVALID adds no helper: a back seam then lies on the rear face (y = 20 mm). + const auto type = GENERATE(ModelVolumeType::INVALID, ModelVolumeType::PRECISE_SEAM_ENFORCED, + ModelVolumeType::PRECISE_SEAM_BLOCKED); + ExportFixture fixture("back"); + if (type == ModelVolumeType::PRECISE_SEAM_ENFORCED) + fixture.add(type, make_cube(2, 6, 4), Vec3d(4, -3, -1)); // Front face, x = 4..6 mm. + if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED) + fixture.add(type, make_cube(30, 6, 4), Vec3d(-5, 17, -1)); // Everything from y = 17 mm back. + const auto loops = outer_wall_loops(fixture.export_gcode()); + REQUIRE(loops.size() == 10); + for (const OuterLoop &loop : loops) { + CAPTURE(loop.seam.x(), loop.seam.y()); + if (type == ModelVolumeType::PRECISE_SEAM_ENFORCED) { + CHECK_THAT(loop.seam.y(), Catch::Matchers::WithinAbs(loop.bounds.min.y() - loop.bounds.center().y(), 0.01)); + CHECK(loop.seam.x() >= -6.01); + CHECK(loop.seam.x() <= -3.99); + } else if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED) + CHECK(loop.seam.y() < 7.); + else + CHECK(loop.seam.y() > 7.); + } + CHECK(fixture.warning().empty()); +} + +TEST_CASE("A strong modifier with a hole uses the left of two equal crossings", "[PreciseSeam]") +{ + // The hole splits the front face crossing into x = 2..4 and x = 8..10 mm. The lengths tie, both + // points lie equally far back, so the left one wins. + ExportFixture fixture("back"); + fixture.add(ModelVolumeType::PRECISE_SEAM_CENTER, helper_with_hole(), Vec3d(2, -3, -1)); + const auto loops = outer_wall_loops(fixture.export_gcode()); + REQUIRE(loops.size() == 10); + for (const OuterLoop &loop : loops) { + CHECK_THAT(loop.seam.x(), Catch::Matchers::WithinAbs(3. - 10., 0.01)); + CHECK_THAT(loop.seam.y(), Catch::Matchers::WithinAbs(loop.bounds.min.y() - loop.bounds.center().y(), 0.01)); + } + // The multiple-intersections reason names the type. + CHECK(fixture.warning().find("Seam Center") != std::string::npos); +} + +TEST_CASE("The export warning names a modifier that misses the outer wall centreline", "[PreciseSeam]") +{ + // The helper reaches 0.1 mm into the cube, short of the centreline 0.2 mm inside a 0.4 mm outer wall. + ExportFixture fixture("back"); + fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, make_cube(2, 3.1, 4), Vec3d(4, -3, -1), "short helper"); + fixture.export_gcode(); + CHECK(fixture.warning().find("short helper") != std::string::npos); +} + +TEST_CASE("Full containment warns for Blocked but not for Enforced", "[PreciseSeam]") +{ + const auto type = GENERATE(ModelVolumeType::PRECISE_SEAM_ENFORCED, ModelVolumeType::PRECISE_SEAM_BLOCKED); + ExportFixture fixture("back"); + fixture.add(type, make_cube(30, 30, 4), Vec3d(-5, -5, -1)); + fixture.export_gcode(); + if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED) + CHECK(fixture.warning().find("Seam Blocked") != std::string::npos); + else + CHECK(fixture.warning().empty()); +} diff --git a/tests/fff_print/test_seam_placer.cpp b/tests/fff_print/test_seam_placer.cpp index 40bbbdd117..73b3e986d2 100644 --- a/tests/fff_print/test_seam_placer.cpp +++ b/tests/fff_print/test_seam_placer.cpp @@ -209,6 +209,61 @@ TEST_CASE("Entirely painted contours keep valid enforced seam candidates", "[Sea } } +TEST_CASE("Seam painting acts only from model parts and negative volumes", "[SeamPlacer]") +{ + // Painting survives a type change, but only model parts expose it in the seam gizmo. A helper + // painted while it was a part must not affect the seam once it is a modifier or support volume. + // Negative volumes keep it on purpose: it is the only way to paint the wall of a hole they cut. + const auto helper_type = GENERATE(ModelVolumeType::MODEL_PART, ModelVolumeType::NEGATIVE_VOLUME, + ModelVolumeType::PARAMETER_MODIFIER, ModelVolumeType::SUPPORT_BLOCKER, + ModelVolumeType::SUPPORT_ENFORCER); + // Precise Seam helpers are left out: on the loop they would retype the candidates themselves. + const std::string type_name = ModelVolume::type_to_string(helper_type); + CAPTURE(type_name); + PipelineFixture fixture; + // A 2 mm strip along the front side: its front face lies on the loop's front edge, while its back + // face stays farther than the paint radius. As a negative volume it cuts only the strip. + ModelVolume *helper = fixture.model.objects.front()->add_volume(TriangleMesh(its_make_cube(20, 2, 0.4))); + const ObjectID helper_id = helper->id(); + { + // Paint every face of the helper while it is still a part, then change its type. + TriangleSelector selector(helper->mesh()); + for (size_t i = 0; i < helper->mesh().its.indices.size(); ++i) + selector.set_facet(int(i), EnforcerBlockerType::ENFORCER); + helper->seam_facets.set(selector); + } + REQUIRE(helper->is_seam_painted()); + const auto count_enforced = [&]() { + fixture.print.apply(fixture.model, fixture.config); + 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); + // The helper volume in the print's model copy keeps its painting whatever its type. + const auto &volumes = object.model_object()->volumes; + const auto it = std::find_if(volumes.begin(), volumes.end(), [&](const ModelVolume *v) { return v->id() == helper_id; }); + REQUIRE(it != volumes.end()); + CHECK((*it)->is_seam_painted()); + return size_t(std::count_if(data.points.begin(), data.points.end(), [](const auto &candidate) { + return candidate.type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced; + })); + }; + helper->set_type(helper_type); + const bool acts = helper_type == ModelVolumeType::MODEL_PART || helper_type == ModelVolumeType::NEGATIVE_VOLUME; + if (acts) + CHECK(count_enforced() > 0); + else + CHECK(count_enforced() == 0); + if (!acts) { + // The painting was ignored, not lost: as a part again the helper enforces candidates. + helper->set_type(ModelVolumeType::MODEL_PART); + CHECK(count_enforced() > 0); + } +} + TEST_CASE("Precise Seam removes path junction duplicates but preserves separate visits", "[SeamPlacer][PreciseSeam]") { const bool enable_ps = GENERATE(false, true); @@ -227,7 +282,14 @@ TEST_CASE("Precise Seam removes path junction duplicates but preserves separate const Points outline = fixture.points_in_layer(object, vertices); append_loop(region, outline, true); SeamPlacer placer; + // A direct call outside G-code export: init() must not need an active print step. placer.init(fixture.print, [] {}); + // The helper never reaches the loop, so it is reported, named with its object; no helper, no warning. + if (enable_ps) { + CHECK(placer.precise_seam_warning().find("had no effect on the seam") != std::string::npos); + CHECK(placer.precise_seam_warning().find("\"object.stl\"") != std::string::npos); + } else + CHECK(placer.precise_seam_warning().empty()); 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. @@ -294,3 +356,48 @@ TEST_CASE("Print apply synchronizes support and seam helpers through type change fixture.print.apply(fixture.model, fixture.config); check_applied(fixture.model); } + +TEST_CASE("Precise Seam volume changes invalidate only G-code export", "[SeamPlacer][PreciseSeam][Print]") +{ + const int change = GENERATE(0, 1, 2, 3); // Add, move, retype, remove. + CAPTURE(change); + PipelineFixture fixture; + ModelObject *model_object = fixture.model.objects.front(); + // A second helper stays in the object throughout: deleting down to a single volume makes + // ModelObject::delete_volume() fold the volume transform into the instances and renew the volume + // ID, which legitimately reslices the object regardless of Precise Seam. + ModelVolume *keeper = model_object->add_volume(make_cube(1, 1, 1)); + keeper->set_type(ModelVolumeType::PRECISE_SEAM_NEUTRAL); + if (change != 0) { + ModelVolume *seam = model_object->add_volume(make_cube(1, 1, 1)); + seam->set_type(ModelVolumeType::PRECISE_SEAM_CENTER); + } + fixture.print.apply(fixture.model, fixture.config); + // A full export marks every step done, so an invalidated step is visible afterwards. + Test::gcode(fixture.print); + REQUIRE(fixture.print.objects().size() == 1); + const PrintObject *object = fixture.print.objects().front(); + REQUIRE(fixture.print.is_step_done(psGCodeExport)); + REQUIRE(object->is_step_done(posSlice)); + REQUIRE(object->is_step_done(posPerimeters)); + + if (change == 0) { + ModelVolume *seam = model_object->add_volume(make_cube(1, 1, 1)); + seam->set_type(ModelVolumeType::PRECISE_SEAM_CENTER); + } else { + ModelVolume *seam = model_object->volumes.back(); + REQUIRE(seam->is_precise_seam()); + if (change == 1) seam->set_offset(Vec3d(2, 3, 0)); + if (change == 2) seam->set_type(ModelVolumeType::PRECISE_SEAM_ENFORCED); + if (change == 3) model_object->delete_volume(model_object->volumes.size() - 1); + } + fixture.print.apply(fixture.model, fixture.config); + + // The helper takes no part in slicing: the object and its layers are kept, only export reruns. + // REQUIRE, not CHECK: a recreated PrintObject means the old one was freed and must not be read. + REQUIRE(fixture.print.objects().size() == 1); + REQUIRE(fixture.print.objects().front() == object); + CHECK_FALSE(fixture.print.is_step_done(psGCodeExport)); + CHECK(object->is_step_done(posSlice)); + CHECK(object->is_step_done(posPerimeters)); +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index c1fbe4408d..dbcb23e015 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -5,6 +5,8 @@ add_executable(${_TEST_NAME}_tests test_3mf.cpp # Round-trip seam metadata and active/dormant volume settings in both formats. test_precise_seam_3mf.cpp + # Pure perimeter extraction is independent of Print/Layer fixtures. + test_precise_seam.cpp test_aabbindirect.cpp test_appconfig.cpp test_arachne_walls.cpp diff --git a/tests/libslic3r/test_precise_seam.cpp b/tests/libslic3r/test_precise_seam.cpp new file mode 100644 index 0000000000..d8a1130192 --- /dev/null +++ b/tests/libslic3r/test_precise_seam.cpp @@ -0,0 +1,1043 @@ +#include +#include "libslic3r/GCode/PreciseSeam.hpp" +#include "libslic3r/GCode/PreciseSeamInternal.hpp" +#include "libslic3r/ClipperUtils.hpp" + +#include +#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) +{ + // Modifier exteriors are CCW; holes are explicitly reversed in their fixtures. + return Polygon(Points{mm(x0, y0), mm(x1, y0), mm(x1, y1), mm(x0, y1)}); +} + +void check_provenance(const Polygon &perimeter, const PreciseSeam::SegmentExtraction &result, bool measured = true) +{ + REQUIRE(result.valid); + CHECK(result.discarded_fragments == 0); + for (const auto &segment : result.segments) { + REQUIRE(segment.polyline.size() >= 2); + REQUIRE(segment.edge_indices.size() + 1 == segment.polyline.size()); + // Full coverage retains geometry, but consumers skip it before using strong data. + if (result.full_containment) + CHECK_FALSE(segment.strong_target.has_value()); + else { + REQUIRE(segment.strong_target.has_value()); + REQUIRE(segment.strong_target->edge_index < perimeter.size()); + } + if (measured && !result.full_containment) { + CHECK_THAT(segment.polyline.length(), Catch::Matchers::WithinRel(segment.length, 1e-12)); + CHECK(segment.length > 0.); + } else + CHECK_THAT(segment.length, Catch::Matchers::WithinAbs(0., 1e-12)); + const std::pair endpoints[] = { + {segment.begin, segment.polyline.points.front()}, {segment.end, segment.polyline.points.back()}}; + for (const auto &[position, point] : endpoints) { + REQUIRE(position.edge_index < perimeter.size()); + CHECK(position.parameter >= 0.); + CHECK(position.parameter < 1.); + const Vec2d a = perimeter.points[position.edge_index].cast(); + const Vec2d b = perimeter.points[(position.edge_index + 1) % perimeter.size()].cast(); + const Vec2d reconstructed = a + position.parameter * (b - a); + CHECK((point.cast() - reconstructed).squaredNorm() <= 2.5); + } + for (size_t i = 0; i < segment.edge_indices.size(); ++i) { + const size_t edge = segment.edge_indices[i]; + REQUIRE(edge < perimeter.size()); + const Vec2d a = perimeter.points[edge].cast(); + const Vec2d direction = perimeter.points[(edge + 1) % perimeter.size()].cast() - a; + double previous = -1.; + for (const Point &point : {segment.polyline.points[i], segment.polyline.points[i + 1]}) { + const Vec2d offset = point.cast() - a; + const double t = offset.dot(direction) / direction.squaredNorm(); + CHECK(t >= 0.); + CHECK(t <= 1.); + CHECK(t >= previous); + CHECK((offset - t * direction).squaredNorm() <= 2.5); + previous = t; + } + } + } +} +} // namespace + +TEST_CASE("Cached modifier bounds preserve holes and layer slots across perimeter queries", "[PreciseSeam][SegmentExtraction]") +{ + ExPolygon nearby(rectangle(2, -2, 8, 2)); + nearby.holes.push_back(rectangle(4, -1, 6, 1)); + nearby.holes.back().reverse(); + const ExPolygon distant(rectangle(102, -2, 108, 2)); + const auto cached = PreciseSeam::prepare_modifier_slices({ + {ExPolygon{}, nearby, distant}, {}, {ExPolygon(rectangle(2, 30, 8, 32))}}); + REQUIRE(cached.size() == 3); + REQUIRE(cached[0].size() == 3); + CHECK_FALSE(cached[0][0].bounds.defined); + CHECK(cached[1].empty()); + REQUIRE(cached[2].size() == 1); + CHECK(cached[2][0].bounds.min == mm(2, 30)); + CHECK(cached[0][1].bounds.min == mm(2, -2)); + CHECK(cached[0][1].bounds.max == mm(8, 2)); + REQUIRE(cached[0][1].polygon.holes.size() == 1); + CHECK(cached[0][1].polygon.holes.front().points == nearby.holes.front().points); + + // Reuse one immutable layer cache; each perimeter sees only its nearby region. + const Polygon first = rectangle(0, 0, 20, 20); + const Polygon second = rectangle(100, 0, 120, 20); + const PreciseSeam::PreparedPerimeter first_prepared(first), second_prepared(second); + const auto left = PreciseSeam::extract_perimeter_segments(first_prepared, cached[0], ModelVolumeType::PRECISE_SEAM_CENTER); + const auto right = PreciseSeam::extract_perimeter_segments(second_prepared, cached[0], ModelVolumeType::PRECISE_SEAM_CENTER); + const auto repeated = PreciseSeam::extract_perimeter_segments(first_prepared, cached[0], ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(first, left); + check_provenance(second, right); + REQUIRE(left.segments.size() == 2); + REQUIRE(right.segments.size() == 1); + REQUIRE(repeated.segments.size() == left.segments.size()); + CHECK(left.segments[0].polyline.points.front() == mm(2, 0)); + CHECK(left.segments[0].polyline.points.back() == mm(4, 0)); + CHECK(left.segments[1].polyline.points.front() == mm(6, 0)); + CHECK(left.segments[1].polyline.points.back() == mm(8, 0)); + CHECK(right.segments[0].polyline.points.front() == mm(102, 0)); + CHECK(right.segments[0].polyline.points.back() == mm(108, 0)); + for (size_t i = 0; i < left.segments.size(); ++i) { + CHECK(repeated.segments[i].polyline.points == left.segments[i].polyline.points); + CHECK(repeated.segments[i].edge_indices == left.segments[i].edge_indices); + } + CHECK(cached[0][1].polygon.contour.points == nearby.contour.points); +} + +TEST_CASE("Weak extraction preserves geometry and bindings without preparing strong data", "[PreciseSeam][SegmentExtraction]") +{ + const int scenario = GENERATE(0, 1, 2); + const Polygon perimeter = rectangle(0, 0, 20, 20); + // Cover separate intervals, joining across vertex zero, and full containment. + const ExPolygons modifier{ExPolygon(scenario == 0 ? rectangle(8, -2, 12, 22) : + scenario == 1 ? rectangle(-2, -2, 4, 4) : rectangle(-2, -2, 22, 22))}; + const auto measured = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions(modifier), ModelVolumeType::PRECISE_SEAM_CENTER); + const auto weak_mode = GENERATE(ModelVolumeType::PRECISE_SEAM_ENFORCED, + ModelVolumeType::PRECISE_SEAM_BLOCKED, + ModelVolumeType::PRECISE_SEAM_NEUTRAL); + const auto unmeasured = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions(modifier), weak_mode); + check_provenance(perimeter, measured); + CHECK(unmeasured.valid == measured.valid); + CHECK(unmeasured.full_containment == measured.full_containment); + CHECK(unmeasured.discarded_fragments == measured.discarded_fragments); + REQUIRE(unmeasured.segments.size() == measured.segments.size()); + for (size_t i = 0; i < measured.segments.size(); ++i) { + const auto &expected = measured.segments[i]; + const auto &actual = unmeasured.segments[i]; + CHECK(actual.polyline.points == expected.polyline.points); + CHECK(actual.edge_indices == expected.edge_indices); + CHECK(actual.begin.edge_index == expected.begin.edge_index); + CHECK(actual.end.edge_index == expected.end.edge_index); + CHECK_THAT(actual.begin.parameter, Catch::Matchers::WithinAbs(expected.begin.parameter, 1e-12)); + CHECK_THAT(actual.end.parameter, Catch::Matchers::WithinAbs(expected.end.parameter, 1e-12)); + CHECK_FALSE(actual.strong_target.has_value()); + CHECK_THAT(actual.length, Catch::Matchers::WithinAbs(0., 1e-12)); + } +} + +TEST_CASE("Strong extraction prepares the mode point on the complete joined segment", "[PreciseSeam][SegmentExtraction]") +{ + const auto mode = GENERATE(ModelVolumeType::PRECISE_SEAM_LEFT, + ModelVolumeType::PRECISE_SEAM_RIGHT, + ModelVolumeType::PRECISE_SEAM_CENTER); + const bool reverse = GENERATE(false, true); + const double width = GENERATE(4., 6.); + Polygon perimeter = rectangle(0, 0, 20, 20); + if (reverse) + std::reverse(perimeter.points.begin(), perimeter.points.end()); + // The forward contour crosses vertex zero; Center is either that vertex or inside an edge. + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({ExPolygon(rectangle(-2, -2, width, 4))}), mode); + const bool center = mode == ModelVolumeType::PRECISE_SEAM_CENTER; + check_provenance(perimeter, result, center); + REQUIRE(result.segments.size() == 1); + const auto &segment = result.segments.front(); + CHECK_THAT(segment.length, Catch::Matchers::WithinAbs(center ? scale_(width + 4.) : 0., 1e-6)); + + Point expected_point = mm((width - 4.) / 2., 0); + size_t expected_edge = reverse ? 2 : (width == 4. ? 3 : 0); + if (mode != ModelVolumeType::PRECISE_SEAM_CENTER) { + const bool vertical = (mode == ModelVolumeType::PRECISE_SEAM_LEFT) != reverse; + expected_point = vertical ? mm(0, 4) : mm(width, 0); + expected_edge = vertical ? 3 : (reverse ? 2 : 0); + } + REQUIRE(segment.strong_target.has_value()); + CHECK(segment.strong_target->point == expected_point); + CHECK(segment.strong_target->edge_index == expected_edge); +} + +TEST_CASE("Strong extraction measures competing segments but skips fully contained targets", "[PreciseSeam][SegmentExtraction]") +{ + const auto mode = GENERATE(ModelVolumeType::PRECISE_SEAM_LEFT, + ModelVolumeType::PRECISE_SEAM_RIGHT, + ModelVolumeType::PRECISE_SEAM_CENTER); + const bool full = GENERATE(false, true); + const Polygon perimeter = rectangle(0, 0, 20, 20); + // Left/Right still require lengths when two segments compete; full coverage skips every mode. + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({ExPolygon(full ? rectangle(-2, -2, 22, 22) : rectangle(8, -2, 12, 22))}), mode); + CHECK(result.full_containment == full); + REQUIRE(result.segments.size() == (full ? 1 : 2)); + check_provenance(perimeter, result); + for (const auto &segment : result.segments) { + CHECK_THAT(segment.length, Catch::Matchers::WithinAbs(full ? 0. : scale_(4.), 1e-6)); + if (full) + CHECK(segment.polyline.points.front() == segment.polyline.points.back()); + else { + REQUIRE(segment.strong_target.has_value()); + if (mode == ModelVolumeType::PRECISE_SEAM_LEFT) + CHECK(segment.strong_target->point == segment.polyline.points.front()); + else if (mode == ModelVolumeType::PRECISE_SEAM_RIGHT) + CHECK(segment.strong_target->point == segment.polyline.points.back()); + } + } +} + +TEST_CASE("Projection binding follows the same edge and its neighbor in either direction", "[PreciseSeam][SegmentExtraction]") +{ + const bool reverse = GENERATE(false, true); + const bool wrap = GENERATE(false, true); + const Polygon perimeter = rectangle(0, 0, 10, 10); + Polyline fragment; + fragment.points = wrap ? Points{mm(0, 3), mm(0, 0), mm(3, 0), mm(6, 0)} + : Points{mm(2, 0), mm(4, 0), mm(7, 0), mm(10, 0), mm(10, 3)}; + std::vector expected = wrap ? std::vector{3, 0, 0} : std::vector{0, 0, 0, 1}; + if (reverse) { + std::reverse(fragment.points.begin(), fragment.points.end()); + std::reverse(expected.begin(), expected.end()); + } + std::vector intervals; + PreciseSeam::detail::FragmentBindingFailure failure; + REQUIRE(PreciseSeam::detail::append_projected_fragment(fragment, perimeter, intervals, failure)); + REQUIRE(intervals.size() == expected.size()); + for (size_t i = 0; i < intervals.size(); ++i) { + CHECK(intervals[i].edge == expected[i]); + CHECK(intervals[i].first == fragment.points[reverse ? i + 1 : i]); + CHECK(intervals[i].last == fragment.points[reverse ? i : i + 1]); + } +} + +TEST_CASE("Projection binding rolls back a fragment that reverses or leaves the contour", "[PreciseSeam][SegmentExtraction]") +{ + const int scenario = GENERATE(0, 1, 2); + const Polygon perimeter = rectangle(0, 0, 10, 10); + Polyline fragment; + fragment.points = {mm(2, 0), mm(7, 0)}; + // Every failure follows a successful pair, exercising rollback rather than an empty result. + fragment.points.push_back(scenario == 0 ? mm(4, 0) : scenario == 1 ? mm(7, 3) : mm(10, 3)); + std::vector intervals{{2, 0., 1., mm(10, 10), mm(0, 10)}}; + PreciseSeam::detail::FragmentBindingFailure failure; + CHECK_FALSE(PreciseSeam::detail::append_projected_fragment(fragment, perimeter, intervals, failure)); + REQUIRE(intervals.size() == 1); + CHECK(intervals[0].edge == 2); + CHECK(intervals[0].first == mm(10, 10)); + CHECK(intervals[0].last == mm(0, 10)); + CHECK(failure.pair_index == 1); +} + +TEST_CASE("Projection binding does not jump to a distant edge at a repeated vertex", "[PreciseSeam][SegmentExtraction]") +{ + const Polygon perimeter(Points{mm(0, 0), mm(4, 0), mm(4, 4), mm(0, 0), mm(-4, 0), mm(-4, -4)}); + Polyline fragment; + // The last pair belongs to edge zero, but edge three is the required continuation. + fragment.points = {mm(4, 1), mm(4, 4), mm(0, 0), mm(2, 0)}; + std::vector intervals; + PreciseSeam::detail::FragmentBindingFailure failure; + CHECK_FALSE(PreciseSeam::detail::append_projected_fragment(fragment, perimeter, intervals, failure)); + CHECK(intervals.empty()); + CHECK(failure.pair_index == 2); +} + +TEST_CASE("A rejected intersection warns without removing successful fragments", "[PreciseSeam][SegmentExtraction]") +{ + const Polygon perimeter = rectangle(0, 0, 10, 10); + PreciseSeam::PreciseSeamWarnings warnings; + PreciseSeam::ExtractionContext context; + context.warnings = &warnings; + std::vector intervals; + Polyline fragment; + fragment.points = {mm(1, 0), mm(2, 0)}; + REQUIRE(PreciseSeam::detail::append_fragment(fragment, perimeter, intervals, context, 0)); + CHECK(warnings.failed_fragments.load() == 0); + // An out-and-back path must be discarded, not salvaged by clipping source edges again. + fragment.points = {mm(3, 0), mm(7, 0), mm(4, 0)}; + CHECK_FALSE(PreciseSeam::detail::append_fragment(fragment, perimeter, intervals, context, 1)); + REQUIRE(intervals.size() == 1); + CHECK(intervals[0].first == mm(1, 0)); + CHECK(intervals[0].last == mm(2, 0)); + CHECK(warnings.failed_fragments.load() == 1); + fragment.points = {mm(10, 2), mm(10, 4)}; + REQUIRE(PreciseSeam::detail::append_fragment(fragment, perimeter, intervals, context, 2)); + REQUIRE(intervals.size() == 2); + CHECK(intervals.back().edge == 1); +} + +TEST_CASE("A cut rounded past its adjacent source vertex is snapped instead of discarding the fragment", "[PreciseSeam][SegmentExtraction]") +{ + const Polygon perimeter = rectangle(0, 0, 20, 20); + const Point corner = mm(20, 0); + const bool at_start = GENERATE(false, true); + const bool reversed = GENERATE(false, true); + // 1 nm: the cut projects to the corner's parameter, giving a zero-length pair. + // 5 nm: the cut lies on neither neighbouring edge within clipping precision. + const coord_t offset = GENERATE(coord_t(1), coord_t(5)); + CAPTURE(at_start, reversed, offset); + Polyline fragment; + if (at_start) + fragment.points = {Point(corner.x() + offset, corner.y()), corner, mm(20, 20), mm(10, 20)}; + else + fragment.points = {mm(10, 0), corner, Point(corner.x(), corner.y() - offset)}; + if (reversed) + fragment.reverse(); + PreciseSeam::PreciseSeamWarnings warnings; + PreciseSeam::ExtractionContext context; + context.warnings = &warnings; + std::vector intervals; + REQUIRE(PreciseSeam::detail::append_fragment(fragment, perimeter, intervals, context, 0)); + CHECK(warnings.failed_fragments.load() == 0); + CHECK(warnings.recovered_fragments.load() == 1); + // The rest of the fragment is kept, and the dropped cut leaves the boundary exactly at the corner. + // Canonical vertex parameters 0 and 1 are exact by contract, hence a zero margin. + std::sort(intervals.begin(), intervals.end(), [](const auto &a, const auto &b) { return a.edge < b.edge; }); + if (at_start) { + REQUIRE(intervals.size() == 2); + CHECK(intervals[0].edge == 1); + CHECK_THAT(intervals[0].begin, Catch::Matchers::WithinAbs(0., 0.)); + CHECK_THAT(intervals[0].end, Catch::Matchers::WithinAbs(1., 0.)); + CHECK(intervals[0].first == corner); + CHECK(intervals[1].edge == 2); + CHECK_THAT(intervals[1].begin, Catch::Matchers::WithinAbs(0., 0.)); + CHECK_THAT(intervals[1].end, Catch::Matchers::WithinAbs(0.5, 1e-12)); + } else { + REQUIRE(intervals.size() == 1); + CHECK(intervals[0].edge == 0); + CHECK_THAT(intervals[0].begin, Catch::Matchers::WithinAbs(0.5, 1e-12)); + CHECK_THAT(intervals[0].end, Catch::Matchers::WithinAbs(1., 0.)); + CHECK(intervals[0].last == corner); + } + + // A two-point contact shorter than the snapping distance leaves nothing to bind and is not a failure. + Polyline contact; + contact.points = {Point(corner.x() + offset, corner.y()), corner}; + if (reversed) + contact.reverse(); + intervals.clear(); + CHECK(PreciseSeam::detail::append_fragment(contact, perimeter, intervals, context, 1)); + CHECK(intervals.empty()); + CHECK(warnings.failed_fragments.load() == 0); + + // Beyond the 1 um snapping distance the cleanup does not apply: the fragment is still a failure. + Polyline distant; + distant.points = {Point(corner.x() + coord_t(scale_(0.002)), corner.y()), corner, mm(20, 20), mm(10, 20)}; + if (reversed) + distant.reverse(); + CHECK_FALSE(PreciseSeam::detail::append_fragment(distant, perimeter, intervals, context, 2)); + CHECK(intervals.empty()); + CHECK(warnings.failed_fragments.load() == 1); +} + +TEST_CASE("A cut beside the start of its chain's edge is replaced by that vertex only", "[PreciseSeam][SegmentExtraction]") +{ + const Polygon perimeter = rectangle(0, 0, 20, 20); + const Point corner = mm(20, 0); + const bool reversed = GENERATE(false, true); + CAPTURE(reversed); + PreciseSeam::PreciseSeamWarnings warnings; + PreciseSeam::ExtractionContext context; + context.warnings = &warnings; + std::vector intervals; + + // The cut stands 2 nm beside the corner, off both edges, while the fragment continues from the + // next vertex (20, 20): the corner shares an edge with that neighbour, so the cut becomes the corner. + Polyline fragment; + fragment.points = {Point(corner.x() + 2, corner.y() - 2), mm(20, 20), mm(10, 20)}; + if (reversed) + fragment.reverse(); + REQUIRE(PreciseSeam::detail::append_fragment(fragment, perimeter, intervals, context, 0)); + CHECK(warnings.failed_fragments.load() == 0); + std::sort(intervals.begin(), intervals.end(), [](const auto &a, const auto &b) { return a.edge < b.edge; }); + REQUIRE(intervals.size() == 2); + CHECK(intervals[0].edge == 1); + CHECK_THAT(intervals[0].begin, Catch::Matchers::WithinAbs(0., 0.)); + CHECK_THAT(intervals[0].end, Catch::Matchers::WithinAbs(1., 0.)); + CHECK(intervals[0].first == corner); + CHECK(intervals[1].edge == 2); + + // The same cut next to a vertex that is not on the fragment's chain is never snapped there: + // (0, 20) does not share an edge with the corner, so the fragment stays a failure. + Polyline detached; + detached.points = {Point(corner.x() + 2, corner.y() - 2), mm(0, 20), mm(0, 10)}; + if (reversed) + detached.reverse(); + intervals.clear(); + CHECK_FALSE(PreciseSeam::detail::append_fragment(detached, perimeter, intervals, context, 1)); + CHECK(intervals.empty()); + CHECK(warnings.failed_fragments.load() == 1); +} + +TEST_CASE("A cut close to its neighbour and to both of the neighbour's chain vertices snaps to the neighbour", "[PreciseSeam][SegmentExtraction]") +{ + // Edges P -> N and N -> Q are 0.5 um long, so a cut 2 nm beside N is within 1 um of P, N and Q. + // P and Q are ambiguous edge-start candidates, but the cut is a rounded copy of N, which wins. + const Point n = mm(20, 0); + const coord_t half_um = coord_t(scale_(0.0005)); + const Point p(n.x() - half_um, n.y()); + const Point q(n.x(), n.y() + half_um); + const Polygon perimeter(Points{mm(0, 0), p, n, q, mm(20, 20), mm(0, 20)}); + const bool reversed = GENERATE(false, true); + CAPTURE(reversed); + Polyline fragment; + fragment.points = {Point(n.x() + 2, n.y() - 2), n, q, mm(20, 20), mm(10, 20)}; + if (reversed) + fragment.reverse(); + PreciseSeam::PreciseSeamWarnings warnings; + PreciseSeam::ExtractionContext context; + context.warnings = &warnings; + std::vector intervals; + REQUIRE(PreciseSeam::detail::append_fragment(fragment, perimeter, intervals, context, 0)); + CHECK(warnings.failed_fragments.load() == 0); + CHECK(warnings.recovered_fragments.load() == 1); + // The dropped cut leaves the boundary exactly at N: edges N -> Q and Q -> (20, 20) are whole. + std::sort(intervals.begin(), intervals.end(), [](const auto &a, const auto &b) { return a.edge < b.edge; }); + REQUIRE(intervals.size() == 3); + CHECK(intervals[0].edge == 2); + CHECK(intervals[0].first == n); + CHECK_THAT(intervals[0].begin, Catch::Matchers::WithinAbs(0., 0.)); + CHECK_THAT(intervals[0].end, Catch::Matchers::WithinAbs(1., 0.)); + CHECK(intervals[1].edge == 3); + CHECK_THAT(intervals[1].begin, Catch::Matchers::WithinAbs(0., 0.)); + CHECK_THAT(intervals[1].end, Catch::Matchers::WithinAbs(1., 0.)); + CHECK(intervals[2].edge == 4); + CHECK_THAT(intervals[2].begin, Catch::Matchers::WithinAbs(0., 0.)); + CHECK_THAT(intervals[2].end, Catch::Matchers::WithinAbs(0.5, 1e-12)); +} + +TEST_CASE("A fragment end that is itself a source vertex is never treated as a rounded cut", "[PreciseSeam][SegmentExtraction]") +{ + // The closing edge v4 -> v0 is only 0.5 um long. The fragment starts with a cut 2 nm beside v3 (so + // the fallback runs) and ends at the real vertex v0, the start of the open clipping line. v0 lies + // within 1 um of its neighbour v4, but it is no rounded cut and must keep the closing edge. + const Point v3 = mm(0, 20); + const Point v4(coord_t(0), coord_t(scale_(0.0005))); + const Polygon perimeter(Points{mm(0, 0), mm(20, 0), mm(20, 20), v3, v4}); + const bool reversed = GENERATE(false, true); + CAPTURE(reversed); + Polyline fragment; + fragment.points = {Point(v3.x() + 2, v3.y() + 2), v4, mm(0, 0)}; + if (reversed) + fragment.reverse(); + PreciseSeam::PreciseSeamWarnings warnings; + PreciseSeam::ExtractionContext context; + context.warnings = &warnings; + std::vector intervals; + REQUIRE(PreciseSeam::detail::append_fragment(fragment, perimeter, intervals, context, 0)); + CHECK(warnings.failed_fragments.load() == 0); + CHECK(warnings.recovered_fragments.load() == 1); + // Both whole edges v3 -> v4 and v4 -> v0 are bound; v0 was not dropped. + std::sort(intervals.begin(), intervals.end(), [](const auto &a, const auto &b) { return a.edge < b.edge; }); + REQUIRE(intervals.size() == 2); + for (size_t i = 0; i < 2; ++i) { + CHECK(intervals[i].edge == 3 + i); + CHECK_THAT(intervals[i].begin, Catch::Matchers::WithinAbs(0., 0.)); + CHECK_THAT(intervals[i].end, Catch::Matchers::WithinAbs(1., 0.)); + } +} + +TEST_CASE("A failed fragment shorter than the snapping distance is accepted as a contact", "[PreciseSeam][SegmentExtraction]") +{ + // Two cuts in the middle of a 45-degree edge, 1.4 nm apart and within clipping precision of it, + // project to the same parameter. No vertex is near, so only the contact rule applies. + const Polygon diagonal(Points{Point(0, 0), Point(1000000, 1000000), Point(0, 1000000)}); + Polyline contact; + contact.points = {Point(500000, 500000), Point(500001, 499999)}; + std::vector intervals; + PreciseSeam::detail::FragmentBindingFailure failure; + REQUIRE_FALSE(PreciseSeam::detail::bind_fragment(contact, diagonal, intervals, failure)); + PreciseSeam::PreciseSeamWarnings warnings; + PreciseSeam::ExtractionContext context; + context.warnings = &warnings; + CHECK(PreciseSeam::detail::append_fragment(contact, diagonal, intervals, context, 0)); + CHECK(intervals.empty()); + CHECK(warnings.failed_fragments.load() == 0); + CHECK(warnings.recovered_fragments.load() == 1); + + // A failed fragment longer than 1 um stays a failure. + Polyline longer; + longer.points = {Point(500000, 500000), Point(502000, 498000)}; + CHECK_FALSE(PreciseSeam::detail::append_fragment(longer, diagonal, intervals, context, 1)); + CHECK(warnings.failed_fragments.load() == 1); +} + +TEST_CASE("Recovered fragments are counted beyond the diagnostic limit", "[PreciseSeam][SegmentExtraction]") +{ + const Polygon perimeter = rectangle(0, 0, 20, 20); + const Point corner = mm(20, 0); + PreciseSeam::PreciseSeamWarnings warnings; + PreciseSeam::ExtractionContext context; + context.warnings = &warnings; + std::vector intervals; + // The same recoverable fragment repeats, as it would on every layer of a prismatic model. + Polyline fragment; + fragment.points = {Point(corner.x() + 1, corner.y()), corner, mm(20, 20), mm(10, 20)}; + const size_t repeats = PreciseSeam::failed_fragment_log_limit + 2; + for (size_t i = 0; i < repeats; ++i) { + std::vector layer; + CHECK(PreciseSeam::detail::append_fragment(fragment, perimeter, layer, context, i)); + CHECK(layer.size() == 2); + } + // Every recovery is counted past the log budget; none is a failure. + CHECK(warnings.recovered_fragments.load() == repeats); + CHECK(warnings.failed_fragments.load() == 0); +} + +TEST_CASE("Real clipping that rounds a cut beside a vertex is recovered on the fragment's own chain", "[PreciseSeam][SegmentExtraction]") +{ + // Inputs found by a randomized search against Clipper2: random perimeters clipped as open + // lines against a half-plane whose border passes a few nanometres from a vertex, keeping fragments + // that fail bind_fragment() but are bound by append_fragment(). Here the border crosses the + // perimeter at a vertex, and Clipper places the cut at the vertex height but 1-2 nm beside it. + // If a Clipper change stops producing these cuts, the case only warns that it no longer exercises + // the fallback; the general extraction checks below remain valid and still apply. + const auto nm = [](coord_t x, coord_t y) { return Point(x, y); }; + struct Case { + Points perimeter; + Points modifier; + Point cut; + Point vertex; + }; + const Case cases[] = { + // Cut 1 nm beside its neighbour in the fragment: dropped as a rounded copy of that vertex. + {{nm(-24603214, 112634156), nm(-24432144, 112478995), nm(-24498846, 112431640), nm(-24506812, 112403974), + nm(-24547188, 111960677), nm(-24140867, 112210640)}, + {nm(-264026532, 293042572), nm(215012909, -68234624), nm(395651507, 171285096), nm(-83387934, 532562293)}, + nm(-24506811, 112403974), nm(-24506812, 112403974)}, + // Cut 2 nm beside the vertex before its neighbour: replaced by that chain vertex. + {{nm(42606838, 119780952), nm(42638884, 119963549), nm(42573013, 119810910), nm(42578384, 120014121), + nm(42272085, 119667500), nm(42436134, 119614299), nm(42534337, 119521867), nm(42780062, 119646533)}, + {nm(331854259, 41295522), nm(-247310081, 198039476), nm(-325682058, -91542694), nm(253482282, -248286648)}, + nm(42272083, 119667500), nm(42272085, 119667500)}, + }; + const size_t index = GENERATE(size_t(0), size_t(1)); + CAPTURE(index); + const Case &c = cases[index]; + const Polygon perimeter(c.perimeter); + const ExPolygon modifier{Polygon(c.modifier)}; + const PreciseSeam::PreparedPerimeter prepared(perimeter); + REQUIRE(prepared.valid); + + // The case is relevant only while Clipper still produces the special cut (beside the vertex, not + // on it) and both regular binding paths still reject that fragment. Otherwise warn instead of + // failing: the input went stale, while the synthetic tests above still cover the fallback. + const Polylines fragments = intersection_pl(prepared.line, modifier); + const auto special = std::find_if(fragments.begin(), fragments.end(), [&c](const Polyline &fragment) { + return fragment.points.front() == c.cut || fragment.points.back() == c.cut; + }); + const bool cut_present = special != fragments.end(); + bool reproduces = cut_present; + if (reproduces) { + std::vector intervals; + PreciseSeam::detail::FragmentBindingFailure failure; + reproduces = !PreciseSeam::detail::bind_fragment(*special, perimeter, intervals, failure); + } + if (reproduces) { + // Deterministic proof that the fallback itself binds this real fragment. + std::vector intervals; + PreciseSeam::PreciseSeamWarnings fallback_warnings; + PreciseSeam::ExtractionContext fallback_context; + fallback_context.warnings = &fallback_warnings; + CHECK(PreciseSeam::detail::append_fragment(*special, perimeter, intervals, fallback_context, 0)); + CHECK_FALSE(intervals.empty()); + CHECK(fallback_warnings.recovered_fragments.load() == 1); + CHECK(fallback_warnings.failed_fragments.load() == 0); + } else if (!cut_present) + WARN("Case " << index << ": Clipper no longer returns the rounded cut beside a vertex, so the snapping " + "fallback is not exercised here. Refresh the inputs by clipping perimeters against half-planes " + "whose border passes a few nanometers from a vertex."); + else + WARN("Case " << index << ": Clipper still returns the rounded cut, but regular binding now accepts it, " + "so the snapping fallback is not exercised here. Check whether binding changed on purpose."); + + PreciseSeam::PreciseSeamWarnings warnings; + PreciseSeam::ExtractionContext context; + context.warnings = &warnings; + const auto result = PreciseSeam::extract_perimeter_segments( + prepared, PreciseSeam::prepare_modifier_regions({modifier}), ModelVolumeType::PRECISE_SEAM_CENTER, context); + check_provenance(perimeter, result); + CHECK(warnings.failed_fragments.load() == 0); + // Nothing is lost: the extracted coverage equals Clipper's within the snapping distance. + double clipped = 0., extracted = 0.; + for (const Polyline &fragment : fragments) + clipped += fragment.length(); + for (const auto &segment : result.segments) + extracted += segment.polyline.length(); + CHECK(std::abs(clipped - extracted) < scale_(0.001)); + // While the case reproduces, the recovered boundary lies exactly on the vertex of the fragment's chain. + if (reproduces) { + const bool on_vertex = std::any_of(result.segments.begin(), result.segments.end(), [&c](const auto &segment) { + return segment.polyline.points.front() == c.vertex || segment.polyline.points.back() == c.vertex; + }); + CHECK(on_vertex); + } +} + +TEST_CASE("Rejected fragments remain counted beyond the diagnostic limit", "[PreciseSeam][SegmentExtraction]") +{ + const Polygon perimeter = rectangle(0, 0, 10, 10); + PreciseSeam::PreciseSeamWarnings warnings; + PreciseSeam::ExtractionContext context; + context.warnings = &warnings; + std::vector intervals; + Polyline fragment; + // Keep a valid fragment intact while repeated out-and-back failures exhaust the log budget. + fragment.points = {mm(1, 0), mm(2, 0)}; + REQUIRE(PreciseSeam::detail::append_fragment(fragment, perimeter, intervals, context, 0)); + fragment.points = {mm(3, 0), mm(7, 0), mm(4, 0)}; + const size_t failures = PreciseSeam::failed_fragment_log_limit + 2; + for (size_t i = 0; i < failures; ++i) + CHECK_FALSE(PreciseSeam::detail::append_fragment(fragment, perimeter, intervals, context, i + 1)); + CHECK(warnings.failed_fragments.load() == failures); + REQUIRE(intervals.size() == 1); + CHECK(intervals.front().first == mm(1, 0)); + CHECK(intervals.front().last == mm(2, 0)); + + // Standalone callers still reject safely, and a new processing pass gets its own budget. + CHECK_FALSE(PreciseSeam::detail::append_fragment(fragment, perimeter, intervals, {}, 0)); + CHECK(warnings.failed_fragments.load() == failures); + PreciseSeam::PreciseSeamWarnings next_pass; + context.warnings = &next_pass; + CHECK_FALSE(PreciseSeam::detail::append_fragment(fragment, perimeter, intervals, context, 0)); + CHECK(next_pass.failed_fragments.load() == 1); +} + +TEST_CASE("A crossing modifier extracts both perimeter intervals without a body chord", "[PreciseSeam][SegmentExtraction]") +{ + const bool reverse = GENERATE(false, true); + Polygon perimeter = rectangle(0, 0, 20, 20); + if (reverse) perimeter.reverse(); + const Points original = perimeter.points; + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({ExPolygon(rectangle(8, -2, 12, 22))}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + CHECK(perimeter.points == original); + REQUIRE(result.segments.size() == 2); + CHECK_FALSE(result.full_containment); + std::vector sides; + for (const auto &segment : result.segments) { + CHECK_THAT(unscale(segment.length), Catch::Matchers::WithinAbs(4., 1e-6)); + CHECK(segment.polyline.points.front().y() == segment.polyline.points.back().y()); + sides.push_back(segment.polyline.points.front().y()); + } + std::sort(sides.begin(), sides.end()); + CHECK(sides == std::vector{mm(0, 0).y(), mm(0, 20).y()}); +} + +TEST_CASE("A corner interval remains connected when the contour origin changes", "[PreciseSeam][SegmentExtraction]") +{ + const size_t origin = GENERATE(size_t(0), size_t(1), size_t(2), size_t(3)); + Polygon perimeter = rectangle(0, 0, 20, 20); + std::rotate(perimeter.points.begin(), perimeter.points.begin() + origin, perimeter.points.end()); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({ExPolygon(rectangle(-2, -2, 4, 4))}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 1); + const auto &segment = result.segments.front(); + CHECK(segment.polyline.points.front() == mm(0, 4)); + CHECK(segment.polyline.points.back() == mm(4, 0)); + CHECK_THAT(unscale(segment.length), Catch::Matchers::WithinAbs(8., 1e-6)); + CHECK_FALSE(result.full_containment); +} + +TEST_CASE("Collinear perimeter vertices retain their original edge provenance", "[PreciseSeam][SegmentExtraction]") +{ + const 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 auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({ExPolygon(rectangle(1, -2, 13, 2))}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 1); + const auto &segment = result.segments.front(); + CHECK(segment.polyline.points.front() == mm(1, 0)); + CHECK(segment.polyline.points.back() == mm(13, 0)); + CHECK(segment.edge_indices == std::vector{0, 1, 2, 3}); + CHECK_THAT(unscale(segment.length), Catch::Matchers::WithinAbs(12., 1e-6)); +} + +TEST_CASE("Interior vertices retain their sequence between two cut endpoints", "[PreciseSeam][SegmentExtraction]") +{ + const bool reverse = GENERATE(false, true); + const size_t origin = GENERATE(size_t(0), size_t(5)); + Polygon perimeter; + // A zigzag makes skipped or misbound interior edges observable in the arc length. + for (int x = 0; x <= 10; ++x) + perimeter.points.push_back(mm(x, x % 2)); + perimeter.points.push_back(mm(10, 10)); + perimeter.points.push_back(mm(0, 10)); + if (reverse) perimeter.reverse(); + std::rotate(perimeter.points.begin(), perimeter.points.begin() + origin, perimeter.points.end()); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({ExPolygon(rectangle(2.5, -2, 8.5, 3))}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 1); + const auto &segment = result.segments.front(); + Points expected{mm(2.5, .5)}; + for (int x = 3; x <= 8; ++x) + expected.push_back(mm(x, x % 2)); + expected.push_back(mm(8.5, .5)); + if (reverse) std::reverse(expected.begin(), expected.end()); + CHECK(segment.polyline.points == expected); + CHECK_THAT(unscale(segment.length), Catch::Matchers::WithinAbs(6. * std::sqrt(2.), 1e-6)); +} + +TEST_CASE("Neighboring vertices distinguish repeated anchors on different lobes", "[PreciseSeam][SegmentExtraction]") +{ + const bool reverse = GENERATE(false, true); + // Both lobes visit the origin, but their adjacent edges lead to different vertices. + Polygon perimeter(Points{mm(0, 0), mm(4, 0), mm(4, 4), mm(0, 4), + mm(0, 0), mm(-4, 0), mm(-4, -4), mm(0, -4)}); + if (reverse) perimeter.reverse(); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({ExPolygon(rectangle(-1, -5, 1, 5))}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 2); + std::vector edges; + for (const auto &segment : result.segments) { + CHECK_THAT(unscale(segment.length), Catch::Matchers::WithinAbs(6., 1e-6)); + edges.insert(edges.end(), segment.edge_indices.begin(), segment.edge_indices.end()); + } + std::sort(edges.begin(), edges.end()); + CHECK(edges == std::vector{0, 2, 3, 4, 6, 7}); +} + +TEST_CASE("Modifier holes subtract coverage while separate components add intervals", "[PreciseSeam][SegmentExtraction]") +{ + const Polygon perimeter = rectangle(0, 0, 20, 20); + ExPolygon area(rectangle(1, -3, 10, 3)); + area.holes.push_back(rectangle(4, -1, 7, 1)); + area.holes.back().reverse(); + const auto result = PreciseSeam::extract_perimeter_segments(PreciseSeam::PreparedPerimeter(perimeter), + PreciseSeam::prepare_modifier_regions({area, ExPolygon(rectangle(14, -3, 18, 3))}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 3); + const std::vector starts{mm(1, 0), mm(7, 0), mm(14, 0)}; + const std::vector ends{mm(4, 0), mm(10, 0), mm(18, 0)}; + for (size_t i = 0; i < 3; ++i) { + CHECK(result.segments[i].polyline.points.front() == starts[i]); + CHECK(result.segments[i].polyline.points.back() == ends[i]); + } + CHECK_FALSE(result.full_containment); +} + +TEST_CASE("Full coverage is distinct from an empty or point-only intersection", "[PreciseSeam][SegmentExtraction]") +{ + const bool reverse = GENERATE(false, true); + const size_t origin = GENERATE(size_t(0), size_t(1), size_t(2), size_t(3)); + Polygon perimeter = rectangle(0, 0, 20, 20); + // Coverage depends on traversed edges, not winding or the arbitrary contour origin. + if (reverse) + perimeter.reverse(); + std::rotate(perimeter.points.begin(), perimeter.points.begin() + origin, perimeter.points.end()); + const int scenario = GENERATE(0, 1, 2, 3, 4); + ExPolygons modifier; + if (scenario == 0) modifier = {ExPolygon(rectangle(-2, -2, 22, 22))}; + if (scenario == 1) modifier = {ExPolygon(rectangle(2, 2, 4, 4))}; + if (scenario == 2) modifier = {ExPolygon(rectangle(30, 30, 40, 40))}; + if (scenario == 3) modifier = {ExPolygon(rectangle(20, 20, 25, 25))}; + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions(modifier), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + CHECK(result.full_containment == (scenario == 0)); + if (scenario == 0) { + REQUIRE(result.segments.size() == 1); + CHECK_THAT(unscale(result.segments.front().polyline.length()), Catch::Matchers::WithinAbs(80., 1e-6)); + CHECK(result.segments.front().edge_indices.size() == 4); + } else + CHECK(result.segments.empty()); +} + +TEST_CASE("Full coverage survives a modifier boundary touching the perimeter at one point", "[PreciseSeam][SegmentExtraction]") +{ + const bool reverse = GENERATE(false, true); + const size_t origin = GENERATE(size_t(0), size_t(1), size_t(2), size_t(3)); + const int scenario = GENERATE(0, 1, 2); + CAPTURE(reverse, origin, scenario); + Polygon perimeter = rectangle(0, 0, 20, 20); + if (reverse) + perimeter.reverse(); + std::rotate(perimeter.points.begin(), perimeter.points.begin() + origin, perimeter.points.end()); + ExPolygon modifier(rectangle(-2, -2, 22, 22)); + if (scenario == 0) { + // A hole vertex touches the middle of an edge from inside the body. + modifier.holes.push_back(Polygon(Points{mm(10, 0), mm(12, 2), mm(10, 4), mm(8, 2)})); + modifier.holes.back().reverse(); + } else if (scenario == 1) { + // A notch in the exterior touches the same point from outside the body. + modifier = ExPolygon(Polygon(Points{mm(-2, -2), mm(8, -2), mm(10, 0), mm(12, -2), + mm(22, -2), mm(22, 22), mm(-2, 22)})); + } else { + // A hole vertex touches a corner, which is vertex zero for some origins. + modifier.holes.push_back(Polygon(Points{mm(0, 0), mm(3, 1), mm(1, 3)})); + modifier.holes.back().reverse(); + } + // A single contact point leaves no uncovered length, even if clipping splits the line there: + // the split pieces meet at one source position and merge back into complete edges. + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({modifier}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + CHECK(result.full_containment); + REQUIRE(result.segments.size() == 1); + CHECK_THAT(unscale(result.segments.front().polyline.length()), Catch::Matchers::WithinAbs(80., 1e-6)); + CHECK(result.segments.front().edge_indices.size() >= 4); +} + +TEST_CASE("A touch poking nanometres through an inclined edge is full containment", "[PreciseSeam][SegmentExtraction]") +{ + // Square of side 20 mm rotated by atan(3/4), integer vertices. A hole tip near the middle of the first + // side pokes outward along the normal (0.6, -0.8): by about 2.2 nm (a touch the integer grid cannot + // represent) or 2.2 um (a real gap). Contour origin and winding place the gap inside edge 0, inside + // the closing edge or in between. + const bool micro = GENERATE(true, false); + const bool reverse = GENERATE(false, true); + const size_t origin = GENERATE(size_t(0), size_t(1), size_t(2), size_t(3)); + CAPTURE(micro, reverse, origin); + Polygon perimeter(Points{mm(0, 0), mm(16, 12), mm(4, 28), mm(-12, 16)}); + if (reverse) + perimeter.reverse(); + std::rotate(perimeter.points.begin(), perimeter.points.begin() + origin, perimeter.points.end()); + const coord_t step = micro ? 1 : 1000; + const Point tip(mm(8, 6).x() + step, mm(8, 6).y() - 2 * step); + ExPolygon modifier(rectangle(-20, -10, 25, 40)); + modifier.holes.push_back(Polygon(Points{tip, mm(8.2, 7.4), mm(6.6, 6.2)})); + modifier.holes.back().reverse(); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({modifier}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 1); + CHECK(result.full_containment == micro); +} + +TEST_CASE("A gap around one vertex is full containment exactly when both ends snap to it", "[PreciseSeam][SegmentExtraction]") +{ + // A hole bounded by x + y = cut takes the corner (0, 0) off the square. Each end lies `cut` from the + // vertex and the uncovered length is 2 * cut: below 1 um at 300 nm; 1.4 um at 700 nm, yet both ends + // still snap onto the vertex on insertion; at 1200 nm neither does. + const coord_t cut = GENERATE(coord_t(300), coord_t(700), coord_t(1200)); + CAPTURE(cut); + const Polygon perimeter = rectangle(0, 0, 20, 20); + ExPolygon modifier(rectangle(-2, -2, 22, 22)); + const coord_t reach = coord_t(scale_(1.)); + modifier.holes.push_back(Polygon(Points{Point(-reach, -reach), Point(reach + cut, -reach), Point(-reach, reach + cut)})); + modifier.holes.back().reverse(); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({modifier}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 1); + CHECK(result.full_containment == (cut < 1000)); +} + +TEST_CASE("A sub-micron gap that contains a short edge is full containment", "[PreciseSeam][SegmentExtraction]") +{ + // The closing edge v4 -> v0 is 500 nm long. The hole boundary through (100, 0) and (0, 600) leaves + // 100 + 500 + 100 = 700 nm uncovered across two vertices; the segment does not touch edge 4. + const Polygon perimeter(Points{mm(0, 0), mm(20, 0), mm(20, 20), mm(0, 20), Point(coord_t(0), coord_t(500))}); + ExPolygon modifier(rectangle(-2, -2, 22, 22)); + modifier.holes.push_back(Polygon(Points{Point(coord_t(100100), coord_t(-600000)), Point(coord_t(-100000), coord_t(600600)), + Point(coord_t(-1000000), coord_t(-1000000))})); + modifier.holes.back().reverse(); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({modifier}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 1); + CHECK(result.full_containment); +} + +TEST_CASE("Segments whose ends meet only in space stay ordinary segments", "[PreciseSeam][SegmentExtraction]") +{ + const bool reverse = GENERATE(false, true); + const bool slit = GENERATE(false, true); + CAPTURE(reverse, slit); + Polygon perimeter; + ExPolygon modifier; + if (slit) { + // A slit 500 nm wide enters the contour from the left; the hole leaves its inner 9 mm uncovered, + // so the ends face each other across the slit while about 18 mm of perimeter lies between them. + const coord_t half = 250; + perimeter = Polygon(Points{mm(0, 0), mm(20, 0), mm(20, 10), mm(0, 10), Point(coord_t(0), mm(0, 5).y() + half), + Point(mm(10, 0).x(), mm(0, 5).y() + half), Point(mm(10, 0).x(), mm(0, 5).y() - half), + Point(coord_t(0), mm(0, 5).y() - half)}); + modifier = ExPolygon(rectangle(-2, -2, 22, 12)); + modifier.holes.push_back(rectangle(1, 4, 11, 6)); + modifier.holes.back().reverse(); + } else { + // A sharp spike 800 nm wide at its base: cutting off its 1 mm tip brings the ends within about + // 40 nm while the uncovered tip is about 2 mm long. + perimeter = Polygon(Points{mm(20, 0), mm(0, 0), Point(coord_t(0), coord_t(800))}); + modifier = ExPolygon(rectangle(-1, -1, 19, 1)); + } + if (reverse) + perimeter.reverse(); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({modifier}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 1); + const auto &segment = result.segments.front(); + CHECK((segment.polyline.points.front() - segment.polyline.points.back()).cast().norm() < scale_(0.001)); + CHECK_FALSE(result.full_containment); +} + +TEST_CASE("Repeated visits to a coordinate stay on their original perimeter edges", "[PreciseSeam][SegmentExtraction]") +{ + // Two visits to the origin belong to different lobes, not to one shared vertex. + const Polygon perimeter(Points{mm(0, 0), mm(4, 0), mm(4, 4), mm(0, 0), mm(-4, 0), mm(-4, -4)}); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({ExPolygon(rectangle(-1, -1, 1, 1))}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 2); + std::vector edges; + for (const auto &segment : result.segments) + edges.insert(edges.end(), segment.edge_indices.begin(), segment.edge_indices.end()); + std::sort(edges.begin(), edges.end()); + CHECK(edges == std::vector{0, 2, 3, 5}); +} + +TEST_CASE("Two-point fragments accept the first matching perimeter edge", "[PreciseSeam][SegmentExtraction]") +{ + // Policy: do not search for duplicate bindings on overlapping source edges. + const Polygon perimeter(Points{mm(0, 0), mm(10, 0), mm(0, 0), mm(0, 10)}); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({ExPolygon(rectangle(2, -1, 8, 1))}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 1); + CHECK(result.segments[0].edge_indices == std::vector{0}); + CHECK(result.segments[0].polyline.points.front() == mm(2, 0)); + CHECK(result.segments[0].polyline.points.back() == mm(8, 0)); + CHECK_THAT(unscale(result.segments[0].length), Catch::Matchers::WithinAbs(6., 1e-6)); +} + +TEST_CASE("Unnormalized perimeter input is reported instead of silently losing coverage", "[PreciseSeam][SegmentExtraction]") +{ + const int scenario = GENERATE(0, 1, 2, 3); + Polygon perimeter; + if (scenario == 1) perimeter.points = {mm(0, 0), mm(20, 0)}; + if (scenario == 2) perimeter.points = {mm(0, 0), mm(20, 0), mm(20, 0), mm(0, 20)}; + if (scenario == 3) perimeter.points = {mm(0, 0), mm(20, 0), mm(0, 20), mm(0, 0)}; + // Invalid geometry is rejected once, before allocating its clipping line. + const PreciseSeam::PreparedPerimeter prepared(perimeter); + CHECK_FALSE(prepared.valid); + CHECK(prepared.line.points.empty()); + CHECK_FALSE(prepared.bounds.defined); + const auto result = PreciseSeam::extract_perimeter_segments( + prepared, PreciseSeam::prepare_modifier_regions({ExPolygon(rectangle(-2, -2, 22, 22))}), ModelVolumeType::PRECISE_SEAM_CENTER); + CHECK_FALSE(result.valid); + CHECK(result.segments.empty()); + CHECK_FALSE(result.full_containment); +} + +TEST_CASE("Segment lengths measure diagonal arcs rather than squared distances", "[PreciseSeam][SegmentExtraction]") +{ + const Polygon perimeter(Points{mm(0, 0), mm(10, 10), mm(0, 10)}); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({ExPolygon(rectangle(2, -1, 6, 11))}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 2); + // The cuts lie on the grid, but Clipper computes them on the inclined edge in floating point and + // may land one unit off; allow one grid step per axis. + const auto &diagonal = result.segments[0]; + CAPTURE(diagonal.polyline.points.front(), diagonal.polyline.points.back(), diagonal.length); + CHECK((diagonal.polyline.points.front() - mm(2, 2)).cast().squaredNorm() < 2.); + CHECK((diagonal.polyline.points.back() - mm(6, 6)).cast().squaredNorm() < 2.); + CHECK(diagonal.edge_indices == std::vector{0}); + CHECK_THAT(unscale(diagonal.length), Catch::Matchers::WithinAbs(std::sqrt(32.), 3e-6)); + CHECK_THAT(unscale(result.segments[1].length), Catch::Matchers::WithinAbs(4., 1e-6)); +} + +TEST_CASE("An endpoint at an original vertex uses its outgoing edge including vertex zero", "[PreciseSeam][SegmentExtraction]") +{ + const size_t origin = GENERATE(size_t(0), size_t(1)); + Polygon perimeter(Points{mm(0, 0), mm(5, 5), mm(0, 10)}); + std::rotate(perimeter.points.begin(), perimeter.points.begin() + origin, perimeter.points.end()); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({ExPolygon(rectangle(-1, -1, 6, 5))}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 1); + const auto &segment = result.segments.front(); + CHECK(segment.polyline.points.back() == mm(5, 5)); + CHECK(segment.end.edge_index == 1 - origin); + CHECK_THAT(segment.end.parameter, Catch::Matchers::WithinAbs(0., 1e-12)); +} + +TEST_CASE("A one-unit uncovered gap is not bridged by the projection tolerance", "[PreciseSeam][SegmentExtraction]") +{ + // These are scaled integer units, deliberately smaller than the projection tolerance. + const Polygon perimeter(Points{Point(0, 0), Point(20, 0), Point(20, 20), Point(0, 20)}); + const ExPolygons modifier{ + ExPolygon(Polygon(Points{Point(3, -2), Point(8, -2), Point(8, 2), Point(3, 2)})), + ExPolygon(Polygon(Points{Point(9, -2), Point(14, -2), Point(14, 2), Point(9, 2)}))}; + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions(modifier), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 2); + CHECK(result.segments[0].polyline.points.back() == Point(8, 0)); + CHECK(result.segments[1].polyline.points.front() == Point(9, 0)); +} + +TEST_CASE("Distant modifier areas do not change nearby coverage or detach its holes", "[PreciseSeam][SegmentExtraction]") +{ + const bool reverse_areas = GENERATE(false, true); + const Polygon perimeter = rectangle(0, 0, 20, 20); + ExPolygon nearby(rectangle(1, -3, 10, 3)); + nearby.holes.push_back(rectangle(4, -1, 7, 1)); + nearby.holes.back().reverse(); + // A rejected area keeps its own hole; neither may affect the nearby area. + ExPolygon distant(rectangle(100, 100, 120, 120)); + distant.holes.push_back(rectangle(105, 105, 115, 115)); + distant.holes.back().reverse(); + ExPolygons modifier{distant, nearby, ExPolygon(rectangle(14, -3, 18, 3))}; + if (reverse_areas) std::reverse(modifier.begin(), modifier.end()); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions(modifier), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 3); + const Points starts{mm(1, 0), mm(7, 0), mm(14, 0)}; + const Points ends{mm(4, 0), mm(10, 0), mm(18, 0)}; + for (size_t i = 0; i < starts.size(); ++i) { + CHECK(result.segments[i].polyline.points.front() == starts[i]); + CHECK(result.segments[i].polyline.points.back() == ends[i]); + CHECK(result.segments[i].edge_indices == std::vector{0}); + } + CHECK_FALSE(result.full_containment); +} + +TEST_CASE("Rounded intersections on an inclined edge retain their original edge", "[PreciseSeam][SegmentExtraction]") +{ + const bool reverse = GENERATE(false, true); + // The exact cuts (3, 0.9) and (7, 2.1) are off the integer grid, so Clipper moves them to a nearby + // grid point; how it rounds is the library's business. The far vertices keep the contour + // realistically long: a contour shorter than 1 um would be below the snapping distance as a whole, + // and its remaining uncovered part would count as full containment. + Polygon perimeter(Points{Point(0, 0), Point(10, 3), Point(coord_t(10), mm(0, 20).y()), Point(coord_t(0), mm(0, 20).y())}); + if (reverse) perimeter.reverse(); + const ExPolygon area(Polygon(Points{Point(3, -2), Point(7, -2), Point(7, 5), Point(3, 5)})); + const auto result = PreciseSeam::extract_perimeter_segments( + PreciseSeam::PreparedPerimeter(perimeter), PreciseSeam::prepare_modifier_regions({area}), ModelVolumeType::PRECISE_SEAM_CENTER); + check_provenance(perimeter, result); + REQUIRE(result.segments.size() == 1); + const auto &segment = result.segments.front(); + // The rounded cuts must stay bound to the original inclined edge, within one grid step of the exact + // cuts on each axis, whatever rounding the clipping library uses. + const Vec2d first = reverse ? Vec2d(7., 2.1) : Vec2d(3., 0.9); + const Vec2d last = reverse ? Vec2d(3., 0.9) : Vec2d(7., 2.1); + CAPTURE(segment.polyline.points.front(), segment.polyline.points.back(), segment.length); + CHECK((segment.polyline.points.front().cast() - first).squaredNorm() < 2.); + CHECK((segment.polyline.points.back().cast() - last).squaredNorm() < 2.); + CHECK(segment.edge_indices == std::vector{reverse ? size_t(2) : size_t(0)}); + CHECK_THAT(segment.length, Catch::Matchers::WithinAbs((last - first).norm(), 2. * std::sqrt(2.))); + CHECK_FALSE(result.full_containment); +}