From 55b4dca9bc04e0db47febbe6f035e68951e585c8 Mon Sep 17 00:00:00 2001 From: harrierpigeon Date: Thu, 6 Aug 2026 01:08:44 -0500 Subject: [PATCH] Belt printers: brim laid onto the tilted belt, with a leading apron A belt printer slices in a rotated frame, so the belt surface is a tilted plane rather than the Z=0 bed plane. Each slicing layer touches the belt only along a narrow strip at its leading edge - about 0.2mm at 45 degrees - so a part's first layer is really a first line, with almost no contact patch to hold it down while the belt drags it forward. Brim was hard-disabled on belt printers, leaving no remedy at all. Generate the brim on the belt plane instead. The object's belt footprint is the union over layers of each slice clipped to that layer's contact band; the brim is offset from it in a "flattened" frame where the shear axis is stretched by 1/cos(tilt), so ordinary Clipper offsets measure true on-belt distance. It is emitted as cross-belt lines, one per layer band, anchored to a fixed fraction of the band so every line shares a nozzle-to-belt clearance and therefore comes out the same width; flow is matched to the resulting band pitch, keeping the sheet uniform and gap-free. Three new controls, all belt-only: * Leading brim length - extends the brim ahead of the part along the belt, on every downhill-facing edge of its contact area. This apron necessarily prints BELOW the object's first layer, since layer 0 is the part's leading contact, so it needs brim-only bands of its own. * Extra brim width - widens the brim sideways across the belt only. * Brim type "Leading edge only" - brim at the part's first belt contact and nothing after it. Appended last in BrimType so no existing value shifts; degrades to an outer brim off belt printers, with a warning. The apron bands are lightweight records rather than a Layer subclass, so no fabricated Layer::id() can leak into initial-layer temperature selection, the spiral vase probe, cooling or gradual interpolation. They are generated in posSupportMaterial because their print_z values must exist before ToolOrdering is built at psWipeTower, and they are emitted from a short dedicated branch in process_layer that runs before any layer pointer is dereferenced. The footprint is closed before offsetting outwards: a belt contact patch is often a broken-up strip, and the merged offset rings of two islands closer than 2 x brim_width would otherwise fill the space between them - space that lies under the part. Also fixes a pre-existing bug where PrintObject::get_first_layer_bbox() overwrote a valid bbox with an unassigned one on any belt printer with a brim configured, because has_brim() was true while make_brim() returned early. Belt brim is refused alongside the prime tower and spiral vase, and requires one instance per PrintObject - translating an instance along the belt axis changes its physical belt-floor Z. Untilted belt printers are unchanged: they still get no brim, since the plate brim is emitted out of skirt_brim_groups(), which _make_skirt() never builds for a belt printer. --- src/libslic3r/BeltBrim.cpp | 491 ++++++++++++++++++ src/libslic3r/BeltBrim.hpp | 169 ++++++ src/libslic3r/Brim.cpp | 13 +- src/libslic3r/CMakeLists.txt | 2 + src/libslic3r/GCode.cpp | 145 +++++- src/libslic3r/GCode.hpp | 36 +- src/libslic3r/GCode/ToolOrdering.cpp | 45 +- src/libslic3r/GCode/ToolOrdering.hpp | 4 + src/libslic3r/Preset.cpp | 2 +- src/libslic3r/Print.cpp | 119 ++++- src/libslic3r/Print.hpp | 31 ++ src/libslic3r/PrintConfig.cpp | 42 ++ src/libslic3r/PrintConfig.hpp | 6 + src/libslic3r/PrintObject.cpp | 55 ++ src/libslic3r/Support/BeltFloorContext.cpp | 2 +- src/libslic3r/Support/BeltFloorContext.hpp | 6 + src/libslic3r/Support/SupportCommon.cpp | 4 +- .../Support/SupportSpotsGenerator.cpp | 4 +- src/slic3r/GUI/ConfigManipulation.cpp | 45 +- src/slic3r/GUI/GUI_Factories.cpp | 18 +- src/slic3r/GUI/Tab.cpp | 2 + tests/fff_print/test_skirt_brim.cpp | 192 +++++++ tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_belt_brim.cpp | 341 ++++++++++++ 24 files changed, 1744 insertions(+), 31 deletions(-) create mode 100644 src/libslic3r/BeltBrim.cpp create mode 100644 src/libslic3r/BeltBrim.hpp create mode 100644 tests/libslic3r/test_belt_brim.cpp diff --git a/src/libslic3r/BeltBrim.cpp b/src/libslic3r/BeltBrim.cpp new file mode 100644 index 0000000000..40ecc60f58 --- /dev/null +++ b/src/libslic3r/BeltBrim.cpp @@ -0,0 +1,491 @@ +#include "BeltBrim.hpp" + +#include "ClipperUtils.hpp" +#include "Flow.hpp" +#include "Layer.hpp" +#include "Polygon.hpp" +#include "Print.hpp" +#include "ShortestPath.hpp" +#include "Support/BeltFloorContext.hpp" + +#include + +namespace Slic3r { + +// ---------------------------------------------------------------- scaling + +static inline Point scale_u_point(const Point &p, int from_axis, double factor) +{ + // llround, not a cast: casting truncates toward zero, so a round trip would + // walk every vertex toward the origin by up to one unit per pass. + return from_axis == 0 ? + Point(coord_t(std::llround(double(p.x()) * factor)), p.y()) : + Point(p.x(), coord_t(std::llround(double(p.y()) * factor))); +} + +static inline void scale_u_polygon(Polygon &poly, int from_axis, double factor) +{ + for (Point &p : poly.points) + p = scale_u_point(p, from_axis, factor); +} + +ExPolygons belt_scale_u(const ExPolygons &src, const BeltBrimFrame &frame, double factor) +{ + ExPolygons out = src; + for (ExPolygon &ex : out) { + scale_u_polygon(ex.contour, frame.from_axis, factor); + for (Polygon &hole : ex.holes) + scale_u_polygon(hole, frame.from_axis, factor); + } + return out; +} + +Polylines belt_scale_u(const Polylines &src, const BeltBrimFrame &frame, double factor) +{ + Polylines out = src; + for (Polyline &pl : out) + for (Point &p : pl.points) + p = scale_u_point(p, frame.from_axis, factor); + return out; +} + +// ---------------------------------------------------------------- sweep + +ExPolygons sweep_ex(const ExPolygons &src, const Point &t) +{ + if (src.empty()) + return {}; + if (t == Point(0, 0)) + return src; + + // One parallelogram per boundary edge. Together with P and P + t these + // cover the Minkowski sum exactly: for any q = p + s*t with p in P and + // s in [0, 1], let s* be the smallest lambda >= 0 with q - lambda*t in P. + // Either s* == 0 (so q is in P) or q - s* * t lies on some boundary edge e, + // putting q in that edge's parallelogram. Hole edges must be included, or + // holes narrower than t along t would wrongly survive the sweep. + Polygons quads; + for (const ExPolygon &ex : src) + for (size_t c = 0; c < ex.num_contours(); ++ c) + for (const Line &e : ex.contour_or_hole(c).lines()) { + if (e.a == e.b) + continue; + Polygon q; + q.points = { e.a, e.b, e.b + t, e.a + t }; + // The non-zero fill rule counts a clockwise ring as -1, which + // would punch a hole instead of adding material. Edges parallel + // to t give a zero-area quad; Clipper discards those harmlessly. + if (q.is_clockwise()) + q.reverse(); + quads.emplace_back(std::move(q)); + } + + ExPolygons shifted = src; + for (ExPolygon &ex : shifted) + ex.translate(t); + + // union_ex(ExPolygons, Polygons) uses pftNonZero, which is the fill rule the + // argument above relies on. + return union_ex(union_ex(src, shifted), quads); +} + +// ---------------------------------------------------------------- brim region + +ExPolygons belt_brim_region(const ExPolygons &footprint_flat, + bool has_outer, + bool has_inner, + coord_t brim_width, + coord_t object_gap, + coord_t leading, + coord_t lateral, + const BeltBrimFrame &frame) +{ + if (footprint_flat.empty() || (! has_outer && ! has_inner)) + return {}; + + ExPolygons out; + + if (has_outer) { + // Offset the outer ring from the contours only, so a hole cannot punch + // through it. Same reasoning as the plate brim in Brim.cpp. + Polygons contours; + contours.reserve(footprint_flat.size()); + for (const ExPolygon &ex : footprint_flat) + contours.emplace_back(ex.contour); + + // Inner and outer boundary offset from the same polygon, to avoid + // round-off mismatch between them. + ExPolygons inner = offset_ex(contours, float(object_gap), jtRound, SCALED_RESOLUTION); + + // Close the interior before offsetting outwards. A belt contact patch is often a + // narrow, broken-up strip, and the offset rings of two islands less than + // 2 x brim_width apart merge and fill the space between them - space that lies + // UNDER the part, which is not what "outer brim" means. Closing also swallows + // holes in the patch for the same reason. Concavity-filling only, so an apron or + // any other outward protrusion is untouched. + ExPolygons envelope = brim_width > 0 ? closing_ex(inner, float(brim_width)) : inner; + + ExPolygons base = envelope; + if (leading > 0) { + // Sweep downhill from the gapped keep-out, so the apron is contiguous with + // the ring instead of starting inside the gap. + const Point t = frame.from_axis == 0 ? + Point(frame.downhill_sign() * leading, 0) : + Point(0, frame.downhill_sign() * leading); + base = union_ex(base, sweep_ex(envelope, t)); + } + if (lateral > 0) { + // Across the belt, both ways. Swept from `base` so the apron is widened + // too, and in the flattened frame the cross-belt axis is unscaled, so this + // distance is already a true on-belt distance. + const Point t = frame.from_axis == 0 ? Point(0, lateral) : Point(lateral, 0); + ExPolygons widened = union_ex(sweep_ex(base, t), sweep_ex(base, Point(-t.x(), -t.y()))); + base = union_ex(base, to_polygons(widened)); + } + ExPolygons outer = offset_ex(base, float(brim_width), jtRound, SCALED_RESOLUTION); + expolygons_append(out, diff_ex(outer, envelope)); + } + + if (has_inner) { + // Holes reversed so a negative offset grows inward, mirroring Brim.cpp. + // No apron here: an apron growing into a hole interior is never useful. + Polygons holes; + for (const ExPolygon &ex : footprint_flat) + polygons_append(holes, ex.holes); + polygons_reverse(holes); + if (! holes.empty()) { + ExPolygons hole_inner = offset_ex(holes, - float(brim_width + object_gap)); + ExPolygons hole_outer = offset_ex(holes, - float(object_gap)); + expolygons_append(out, intersection_ex(diff_ex(hole_outer, hole_inner), holes)); + } + } + + return union_ex(out); +} + +// ---------------------------------------------------------------- line lattice + +std::vector belt_brim_line_positions(coord_t u_lo, + coord_t u_hi, + coord_t pitch_u, + coord_t u_anchor) +{ + std::vector out; + if (pitch_u <= 0 || u_hi <= u_lo) + return out; + + // Walk the lattice from just below u_lo. Integer arithmetic throughout, so + // the half-open interval needs no epsilon: a point landing exactly on u_hi + // belongs to the next band. + int64_t k = int64_t(std::floor(double(u_lo - u_anchor) / double(pitch_u))) - 1; + while (u_anchor + coord_t(k) * pitch_u < u_lo) + ++ k; + for (;; ++ k) { + const coord_t u = u_anchor + coord_t(k) * pitch_u; + if (u >= u_hi) + break; + out.emplace_back(u); + } + return out; +} + +// ---------------------------------------------------------------- pipeline + +// A band of the belt surface as an explicit box, clamped to `bounds` along the +// shear axis. Deliberately not BeltFloorContext::surface_polygon(): those +// half-planes span +-1000 mm, which is wasteful to clip against and dangerous to +// feed through the flattening scale. +static Polygon band_box(const BoundingBox &bounds, int from_axis, coordf_t u_lo, coordf_t u_hi) +{ + coord_t lo = scale_(u_lo); + coord_t hi = scale_(u_hi); + const coord_t bmin = from_axis == 0 ? bounds.min.x() : bounds.min.y(); + const coord_t bmax = from_axis == 0 ? bounds.max.x() : bounds.max.y(); + lo = std::max(lo, bmin); + hi = std::min(hi, bmax); + Polygon poly; + if (hi <= lo) + return poly; + if (from_axis == 0) + poly.points = { Point(lo, bounds.min.y()), Point(hi, bounds.min.y()), + Point(hi, bounds.max.y()), Point(lo, bounds.max.y()) }; + else + poly.points = { Point(bounds.min.x(), lo), Point(bounds.max.x(), lo), + Point(bounds.max.x(), hi), Point(bounds.min.x(), hi) }; + return poly; +} + +// Everything the per-band line generator needs, gathered once per object. +struct BeltBrimContext +{ + BeltFloorContext ctx; + BeltBrimFrame frame; + ExPolygons region; // brim region, object-local slicing XY + BoundingBox region_bbox; + Flow brim_flow; + coord_t pitch_u = 0; + coord_t u_anchor = 0; + double in_plane_pitch = 0.; // mm +}; + +// Emit the cross-belt brim lines that belong to the band [print_z - height, print_z]. +static void belt_brim_band_paths(const BeltBrimContext &bc, + coordf_t print_z, + coordf_t height, + const Polygons &obstacles, + ExtrusionEntityCollection &out, + ExPolygons &areas_out) +{ + coordf_t u_lo = bc.ctx.cutoff_u(print_z - height); + coordf_t u_hi = bc.ctx.cutoff_u(print_z); + if (u_lo > u_hi) + std::swap(u_lo, u_hi); + + // How wide this band is measured ON the belt, versus one nominal bead. + const double band_in_plane = (u_hi - u_lo) * bc.frame.u_stretch(); + + // Fraction of the layer height at which a line sits above the belt. Toward the + // downhill edge, so the sheet is reasonably thick while the nozzle stays clear of + // the belt itself. + static constexpr double BAND_CLEARANCE_FRACTION = 0.75; + + std::vector us; + double uniform_clearance = 0.; // 0 => derive per line from its own position + double line_pitch = bc.in_plane_pitch; + if (band_in_plane <= bc.in_plane_pitch + EPSILON) { + // Steep belt, which is the normal case: the band is narrower than one bead, so + // exactly one line fits. Place it at a FIXED fraction of the band rather than + // on a nominal-spacing lattice. On a lattice each line lands at an arbitrary + // point in its band, the clearance sweeps [0, height] from band to band, and the + // bead width therefore varies by 2x - visible as ragged, uneven brim lines. + // Anchoring to the band makes the clearance identical everywhere, so every bead + // is the same width. + // + // The spacing is then whatever the bands give (height / sin(tilt) on the belt) + // rather than the nominal bead spacing, so the flow below is matched to THAT + // pitch. Matched flow at the real pitch is what keeps the sheet uniform and + // gap-free; using nominal flow at band spacing would over-feed it. + us.push_back(scale_(bc.ctx.cutoff_u(print_z - BAND_CLEARANCE_FRACTION * height))); + uniform_clearance = BAND_CLEARANCE_FRACTION * height; + line_pitch = band_in_plane; + } else { + // Shallow belt: the band is wider than a bead, so it takes several lines and they + // have to sit on the nominal lattice. Their clearances then differ, and so do + // their widths - unavoidable here, but shallow belts are the rare case. + us = belt_brim_line_positions(scale_(u_lo), scale_(u_hi), bc.pitch_u, bc.u_anchor); + } + if (us.empty()) + return; + + const Polygons region_polys = to_polygons(bc.region); + + // One lattice line at a time: the clearance - and therefore the extrusion + // volume - is a property of the line's u, so the pieces of different lines + // must not be pooled before the flow is resolved. + // Overshoot the region so the clip, not the line's ends, decides the extent. + const coord_t margin = coord_t(SCALED_EPSILON) + 1; + for (const coord_t u : us) { + Polyline line; + if (bc.frame.from_axis == 0) + line.points = { Point(u, coord_t(bc.region_bbox.min.y() - margin)), + Point(u, coord_t(bc.region_bbox.max.y() + margin)) }; + else + line.points = { Point(coord_t(bc.region_bbox.min.x() - margin), u), + Point(coord_t(bc.region_bbox.max.x() + margin), u) }; + + Polylines pieces = intersection_pl(Polylines{ line }, region_polys); + if (! obstacles.empty()) + pieces = diff_pl(pieces, obstacles); + if (pieces.empty()) + continue; + + // Nozzle-to-belt clearance for this line. Constant along the line, because the + // belt height depends only on the shear-axis coordinate. Band-anchored lines + // share one clearance by construction; lattice lines (shallow belts) each get + // their own, clamped so neither end of a band yields an unprintable bead. + double clearance = uniform_clearance; + if (clearance <= 0.) { + const Point probe = bc.frame.from_axis == 0 ? Point(u, 0) : Point(0, u); + clearance = print_z - bc.ctx.floor_print_z(probe); + clearance = std::min(std::max(clearance, 0.5 * height), height); + } + + // with_cross_section, not with_height: it reaches the prescribed volume while + // KEEPING the extrusion spacing, so the bead is sized to fill exactly one + // pitch x clearance cell of the sheet. + const Flow f = bc.brim_flow.with_cross_section(float(line_pitch * clearance)); + + // Footprint of these beads, for the first-layer convex hull and bbox. + for (const Polygon &p : offset(pieces, 0.5f * float(f.scaled_width()))) + areas_out.emplace_back(ExPolygon(p)); + + extrusion_entities_append_paths(out.entities, chain_polylines(std::move(pieces)), + erBrim, f.mm3_per_mm(), f.width(), float(clearance)); + } +} + +// Union of everything actually extruded at `print_z` by any object, expressed in +// `self`'s local slicing frame. Includes `self` itself: its slice at this Z can +// overhang outside the belt footprint and land in the brim ring, which the +// flattened brim_object_gap - a belt-plane separation - does not cover. +static Polygons belt_brim_obstacles(const Print &print, const PrintObject &self, coordf_t print_z, coordf_t tol) +{ + const Point shift_self = self.instances().empty() ? Point(0, 0) + : self.instances().front().shift_without_plate_offset(); + Polygons out; + for (const PrintObject *o : print.objects()) + for (const PrintInstance &inst : o->instances()) { + const Point delta = inst.shift_without_plate_offset() - shift_self; + if (const Layer *l = o->get_layer_at_printz(print_z, tol)) { + Polygons ps = to_polygons(l->lslices); + for (Polygon &p : ps) + p.translate(delta); + polygons_append(out, std::move(ps)); + } + if (const SupportLayer *sl = o->get_support_layer_at_printz(print_z, tol)) { + Polygons ps = sl->support_fills.polygons_covered_by_spacing(); + for (Polygon &p : ps) + p.translate(delta); + polygons_append(out, std::move(ps)); + } + } + return union_(out); +} + +void make_belt_brim(PrintObject &object) +{ + object.clear_belt_brim(); + if (! object.has_belt_brim()) + return; + + const Print &print = *object.print(); + BeltBrimContext bc; + if (! bc.ctx.init(object.slicing_parameters(), print.config())) + return; + bc.frame = BeltBrimFrame{ bc.ctx.shear_factor(), bc.ctx.from_axis() }; + + const size_t nlayers = object.layers().size(); + if (nlayers == 0) + return; + + // 1. Belt footprint: the union of each layer's slice clipped to that layer's + // own contact band. This is the object's bottom face, which on a belt is + // spread over every layer instead of sitting in layer 0. + ExPolygons footprint_acc; + for (size_t i = 0; i < nlayers; ++ i) { + const Layer &layer = *object.layers()[i]; + if (layer.lslices.empty()) + continue; + // print_z - height, not the previous layer's print_z: variable layer + // heights make the latter wrong. + coordf_t u_lo = bc.ctx.cutoff_u(layer.print_z - layer.height); + coordf_t u_hi = bc.ctx.cutoff_u(layer.print_z); + if (u_lo > u_hi) + std::swap(u_lo, u_hi); + BoundingBox bb = get_extents(layer.lslices); + bb.offset(scale_(1.)); + const Polygon band = band_box(bb, bc.frame.from_axis, u_lo, u_hi); + if (band.empty()) + continue; + expolygons_append(footprint_acc, intersection_ex(layer.lslices, Polygons{ band })); + } + const ExPolygons footprint = union_ex(footprint_acc); + if (footprint.empty()) + return; + + // 2. Brim region, offset in the flattened (true on-belt) metric. + const PrintObjectConfig &cfg = object.config(); + bc.brim_flow = print.brim_flow(); + const double flow_w = bc.brim_flow.scaled_spacing() * SCALING_FACTOR; + // Quantize to an even number of lines, as the plate brim does. + const coord_t width = scale_(std::floor(cfg.brim_width.value / flow_w / 2) * flow_w * 2); + const coord_t leading = scale_(cfg.leading_brim_length.value); + const coord_t lateral = scale_(cfg.extra_brim_width.value); + const coord_t gap = scale_(cfg.brim_object_gap.value); + + // Belt printers collapse Auto / Mouse ear / Painted to outer-only: the auto width + // heuristic and flat ear discs have no meaning on a tilted plane. Leading-edge-only + // is an outer brim too; it is narrowed down to the first contact below. + const BrimType bt = cfg.brim_type.value; + const bool has_outer = bt == btOuterOnly || bt == btOuterAndInner + || bt == btAutoBrim || bt == btEar || bt == btPainted + || bt == btLeadingEdgeOnly; + const bool has_inner = bt == btInnerOnly || bt == btOuterAndInner; + + bc.region = belt_unflatten( + belt_brim_region(belt_flatten(footprint, bc.frame), has_outer, has_inner, + width, gap, leading, lateral, bc.frame), + bc.frame); + + if (bt == btLeadingEdgeOnly && ! bc.region.empty()) { + // Keep only what lies at or downhill of the object's FIRST contact with the + // belt, so the part is supported as it lands and nothing is printed alongside + // it afterwards. The cut is the uphill edge of the first layer's contact band: + // everything past it belongs to later contacts. + const coordf_t u_cut = bc.ctx.cutoff_u(object.layers().front()->print_z); + BoundingBox keep_bb = get_extents(bc.region); + keep_bb.offset(scale_(1.)); + const bool low_side = bc.frame.shear > 0.; // downhill is -u + const Polygon keep = band_box(keep_bb, bc.frame.from_axis, + low_side ? unscale(bc.frame.from_axis == 0 ? keep_bb.min.x() : keep_bb.min.y()) : u_cut, + low_side ? u_cut : unscale(bc.frame.from_axis == 0 ? keep_bb.max.x() : keep_bb.max.y())); + bc.region = keep.empty() ? ExPolygons{} : intersection_ex(bc.region, Polygons{ keep }); + } + + if (bc.region.empty()) + return; + bc.region_bbox = get_extents(bc.region); + + // 3. Line lattice. Fixed pitch in the flattened metric, anchored at the + // footprint's leading-most edge so lines stay collinear across + // disconnected islands and across the apron prologue. + bc.pitch_u = std::max(1, coord_t(bc.brim_flow.scaled_spacing() * bc.frame.cos_tilt())); + bc.in_plane_pitch = unscale(bc.pitch_u) * bc.frame.u_stretch(); + { + const BoundingBox fbb = get_extents(footprint); + const bool low_side = bc.frame.shear > 0.; + bc.u_anchor = bc.frame.from_axis == 0 ? (low_side ? fbb.min.x() : fbb.max.x()) + : (low_side ? fbb.min.y() : fbb.max.y()); + } + + // 4. Bands coincident with an object layer. + std::vector by_layer(nlayers); + std::vector areas_by_layer(nlayers); + for (size_t i = 0; i < nlayers; ++ i) { + const Layer &layer = *object.layers()[i]; + const Polygons obstacles = belt_brim_obstacles(print, object, layer.print_z, 0.5 * layer.height); + belt_brim_band_paths(bc, layer.print_z, layer.height, obstacles, by_layer[i], areas_by_layer[i]); + } + + // 5. Apron prologue: the part of the region downhill of the object's first + // layer, which has no object layer to ride on. + std::vector prologue; + { + const Layer &first = *object.layers().front(); + const coordf_t h = first.height; + const bool low_side = bc.frame.shear > 0.; + const coord_t u_lead_s = bc.frame.from_axis == 0 + ? (low_side ? bc.region_bbox.min.x() : bc.region_bbox.max.x()) + : (low_side ? bc.region_bbox.min.y() : bc.region_bbox.max.y()); + const coordf_t u_lead = unscale(u_lead_s); + // print_z at which the belt surface crosses the region's leading edge. + const coordf_t z_lead = bc.ctx.shear_factor() * u_lead + + bc.ctx.floor_offset() + bc.ctx.z_shift(); + if (h > EPSILON) + for (coordf_t z = first.print_z - h; z > z_lead - h; z -= h) { + const Polygons obstacles = belt_brim_obstacles(print, object, z, 0.5 * h); + BeltBrimBand band; + band.print_z = z; + band.height = h; + belt_brim_band_paths(bc, z, h, obstacles, band.fills, band.areas); + if (! band.fills.empty()) + prologue.emplace_back(std::move(band)); + } + // Lowest Z first, so collect_layers_to_print sees them in print order. + std::reverse(prologue.begin(), prologue.end()); + } + + object.set_belt_brim(std::move(by_layer), std::move(areas_by_layer), std::move(prologue)); +} + +} // namespace Slic3r diff --git a/src/libslic3r/BeltBrim.hpp b/src/libslic3r/BeltBrim.hpp new file mode 100644 index 0000000000..d6f0980bc8 --- /dev/null +++ b/src/libslic3r/BeltBrim.hpp @@ -0,0 +1,169 @@ +#ifndef slic3r_BeltBrim_hpp_ +#define slic3r_BeltBrim_hpp_ + +#include "ExPolygon.hpp" +#include "ExtrusionEntityCollection.hpp" +#include "Point.hpp" +#include "Polyline.hpp" + +#include +#include + +// Belt-printer brim geometry. +// +// A belt printer slices in a ROTATED frame, so the belt surface is not the +// Z=0 bed plane but a tilted plane in slicing space: +// +// z_slicing(u) = shear * u + floor_offset + z_shift, u = X or Y +// +// where `shear == tan(tilt)` (SlicingParameters::belt_floor_shear_factor) and +// the axis is selected by SlicingParameters::belt_floor_from_axis. See +// Support/BeltFloorContext.hpp for the canonical accessors. +// +// Consequences that drive everything in this file: +// +// * A horizontal slicing layer touches the belt only along a narrow strip at +// its leading edge, `layer_height / shear` wide (~0.2 mm at 45 degrees). +// The object's belt footprint - its bottom face - is therefore spread over +// every layer, not contained in layer 0. +// * Distances measured in slicing XY are NOT on-belt distances: moving `du` +// along the shear axis travels `du / cos(tilt)` across the belt. So brim +// offsets have to be taken in a "flattened" space where the shear axis is +// stretched by `1 / cos(tilt)`, then mapped back. +// * Brim ahead of the part (downhill) lies at slicing Z BELOW the object's +// first layer, because the object's layer 0 is precisely its leading +// contact with the belt. +// +// Everything here is pure geometry on ExPolygons/Polylines so it can be unit +// tested without a Print. Keep user-visible strings out of this file: it is +// not listed in localization/i18n/list.txt. + +namespace Slic3r { + +// Tilt window within which the BELT plane, not the bed plane, is the adhesion +// surface. Below ~1 degree a belt is a flat bed as far as adhesion goes, and the +// contact band would be layer_height/sin(tilt) - tens of millimetres - so the +// ordinary plate brim is both correct and cheaper. Above ~85 degrees the whole +// brim compresses into a sliver and is not worth generating. +inline constexpr double BELT_BRIM_MIN_TILT_DEG = 1.; +inline constexpr double BELT_BRIM_MAX_TILT_DEG = 85.; + +// Description of the tilted belt plane, reduced to what the brim geometry needs. +struct BeltBrimFrame +{ + // tan(tilt). Sign selects which way is downhill. + double shear = 0.; + // 0 = X, 1 = Y. Matches BeltFloorContext::from_axis(). + int from_axis = 1; + + // 1 / cos(tilt). Stretch factor that turns a projected distance along + // `from_axis` into the true distance travelled across the belt. + double u_stretch() const { return std::sqrt(1. + shear * shear); } + // cos(tilt). The inverse mapping. + double cos_tilt() const { return 1. / this->u_stretch(); } + // Downhill is where the belt surface is lower, i.e. printed earlier, i.e. + // the leading edge of the part. For shear > 0 that is -u. + int downhill_sign() const { return shear > 0. ? -1 : +1; } +}; + +// Scale only the `from_axis` component by `factor`, rounding to nearest. +// +// Deliberately not MultiPoint::scale(fx, fy) / ExPolygon::scale(fx, fy): those +// truncate toward zero, which is asymmetric about the origin and loses up to a +// full coordinate unit per vertex on every round trip. +ExPolygons belt_scale_u(const ExPolygons &src, const BeltBrimFrame &frame, double factor); +Polylines belt_scale_u(const Polylines &src, const BeltBrimFrame &frame, double factor); + +// Into / out of the space where Euclidean offsets equal true on-belt distances. +inline ExPolygons belt_flatten(const ExPolygons &src, const BeltBrimFrame &frame) + { return belt_scale_u(src, frame, frame.u_stretch()); } +inline ExPolygons belt_unflatten(const ExPolygons &src, const BeltBrimFrame &frame) + { return belt_scale_u(src, frame, frame.cos_tilt()); } + +// Minkowski sum of `src` with the segment [0, t]: the region swept by sliding +// `src` along t. Used to grow the brim downhill for "extra brim width". +// +// Implemented as union_(P, P + t, {parallelogram per boundary edge}) over ALL +// contours including holes, with every parallelogram forced counter-clockwise +// so the non-zero fill rule closes holes narrower than t along the sweep +// direction. A hole survives exactly when it is wider than |t| measured along +// t - not when it is wider in its narrowest Euclidean direction. +ExPolygons sweep_ex(const ExPolygons &src, const Point &t); + +// Brim region for one already-flattened belt footprint. All lengths are scaled +// and measured in the flattened (true on-belt) metric. +// +// `has_outer` / `has_inner` are the resolved BrimType: belt printers collapse +// Auto / Mouse ear / Painted to outer-only, so the caller does that mapping and +// this function never needs PrintConfig. +// +// Two directional extras are applied to the footprint before the outer offset, so +// each one buys reach in one direction only: +// +// `leading` (leading_brim_length) sweeps the footprint DOWNHILL along the belt, +// so every leading-facing edge gains an apron ahead of it. +// `lateral` (extra_brim_width) sweeps it BOTH WAYS across the belt, widening +// the brim sideways without pushing it further ahead or behind. +// +// Neither is applied to the inner (hole) ring. +ExPolygons belt_brim_region(const ExPolygons &footprint_flat, + bool has_outer, + bool has_inner, + coord_t brim_width, + coord_t object_gap, + coord_t leading, + coord_t lateral, + const BeltBrimFrame &frame); + +// Brim line positions for one layer band. +// +// Lines sit on a fixed lattice `u_anchor + k * pitch_u` so the on-belt spacing +// between neighbouring brim lines is constant regardless of how the lattice +// falls across layer bands. Snapping to band centres instead would quantise +// the spacing to whole bands and under-deposit by ~35% at 45 degrees. +// +// The band is half-open, [u_lo, u_hi), so every lattice point belongs to +// exactly one band: none duplicated at a boundary, none dropped. A band +// narrower than the pitch simply yields nothing; a band much wider (shallow +// tilt) yields several lines. +std::vector belt_brim_line_positions(coord_t u_lo, + coord_t u_hi, + coord_t pitch_u, + coord_t u_anchor); + +// ---------------------------------------------------------------- pipeline + +// One brim-only layer printed BEFORE the object's first layer, carrying the +// apron that has to be stuck to the belt ahead of the part. +// +// Deliberately not a Layer subclass. A synthetic Layer would inherit id() +// semantics that leak into initial-layer temperature selection, the spiral vase +// probe, gradual interpolation, avoid-crossing-perimeters and cooling, all of +// which key off Layer::id() == 0 or off a layer's regions. A plain record +// carries only what the emitter needs. +// +// `height` is the LAYER height, used for the Z move and ordering metadata only. +// Each extrusion path inside `fills` carries its own height, equal to that +// line's nozzle-to-belt clearance, which varies across the band. +struct BeltBrimBand +{ + coordf_t print_z = 0.; + coordf_t height = 0.; + // erBrim paths in the object's local slicing frame, untranslated. + ExtrusionEntityCollection fills; + // Footprint of those paths, for the first-layer convex hull / bbox. + ExPolygons areas; +}; + +class PrintObject; + +// Generate the belt brim for one object: fills its per-object-layer bands and +// its apron prologue. No-op unless PrintObject::has_belt_brim(). +// +// Runs inside posSupportMaterial rather than the brim step, because the prologue +// print_z values must exist before ToolOrdering is built at psWipeTower. +void make_belt_brim(PrintObject &object); + +} // namespace Slic3r + +#endif // slic3r_BeltBrim_hpp_ diff --git a/src/libslic3r/Brim.cpp b/src/libslic3r/Brim.cpp index 13a6aa819a..1fd71ab13a 100644 --- a/src/libslic3r/Brim.cpp +++ b/src/libslic3r/Brim.cpp @@ -453,7 +453,9 @@ static ExPolygons outer_inner_brim_area(const Print& print, const bool use_auto_brim_ears = object->config().brim_type == btEar; const bool use_brim_ears = object->config().brim_type == btPainted; const bool has_inner_brim = brim_type == btInnerOnly || brim_type == btOuterAndInner || use_auto_brim_ears || use_brim_ears; - const bool has_outer_brim = brim_type == btOuterOnly || brim_type == btOuterAndInner || brim_type == btAutoBrim || use_auto_brim_ears || use_brim_ears; + // btLeadingEdgeOnly is a belt-printer mode; on a flat bed there is no leading + // edge, so it degrades to an ordinary outer brim rather than silently to none. + const bool has_outer_brim = brim_type == btOuterOnly || brim_type == btOuterAndInner || brim_type == btAutoBrim || brim_type == btLeadingEdgeOnly || use_auto_brim_ears || use_brim_ears; coord_t ear_detection_length = scale_(object->config().brim_ears_detection_length.value); coordf_t brim_ears_max_angle = object->config().brim_ears_max_angle.value; //ORCA: Select brim base slices from EFC-compensated outline when enabled. @@ -868,7 +870,14 @@ void make_brim(const Print& print, PrintTryCancel try_cancel, Polygons& islands_ std::vector& printExtruders, std::map* objectBrimAreasByInstanceOut) { - // Belt printer: brim is not compatible with belt printing. + // Belt printers never use the flat plate brim. + // + // With a tilted belt the brim has to be laid onto the belt plane over many layers, + // which BeltBrim.cpp does during posSupportMaterial. With an untilted belt this + // could in principle fall through and produce an ordinary brim, but it would never + // reach the G-code: the plate brim is emitted out of skirt_brim_groups(), which + // _make_skirt() builds, and that returns early for every belt printer. Running the + // generator anyway would just burn time on geometry nobody prints. if (print.config().belt_printer.value) return; diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index e3f00179ff..c8796265db 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -80,6 +80,8 @@ set(lisbslic3r_sources BoundingBox.hpp BridgeDetector.cpp BridgeDetector.hpp + BeltBrim.cpp + BeltBrim.hpp BeltGCode.cpp BeltGCode.hpp BeltGCodeWriter.cpp diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index c85ed1159e..f2caca404f 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -2125,6 +2125,17 @@ std::vector GCode::collect_layers_to_print(const PrintObjec std::vector> warning_ranges; + // Belt printers: the brim apron is stuck to the belt AHEAD of the part, which + // on a tilted belt means below the object's first layer. Those bands carry no + // object or support layer, so they are emitted first and handled by + // process_layer()'s brim-only branch. Already ordered lowest print_z first. + for (const BeltBrimBand &band : object.belt_brim_prologue()) { + LayerToPrint prologue_layer; + prologue_layer.belt_brim_band = &band; + prologue_layer.original_object = &object; + layers_to_print.push_back(prologue_layer); + } + // Pair the object layers with the support layers by z. size_t idx_object_layer = 0; size_t idx_support_layer = 0; @@ -2967,6 +2978,9 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato zs.push_back(layer->print_z); for (auto layer : object->support_layers()) zs.push_back(layer->print_z); + // Belt brim apron bands each get their own change_layer() call. + for (const BeltBrimBand &band : object->belt_brim_prologue()) + zs.push_back(band.print_z); std::sort(zs.begin(), zs.end()); //BBS: merge numerically very close Z values. auto end_it = std::unique(zs.begin(), zs.end()); @@ -2986,6 +3000,9 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato zs.push_back(layer->print_z); for (auto layer : object->support_layers()) zs.push_back(layer->print_z); + // See the ByObject branch: apron bands are real printed layers. + for (const BeltBrimBand &band : object->belt_brim_prologue()) + zs.push_back(band.print_z); } if (!zs.empty()) { @@ -5163,8 +5180,39 @@ std::string GCode::generate_object_skirt_group(const Print &print, object_skirt_tools, layer, extruder_id, m_skirt_group_done[group_idx]); } -std::string GCode::generate_object_brim(const Print &print, const PrintObject &object, size_t instance_id, bool first_layer) +std::string GCode::generate_object_brim(const Print &print, const PrintObject &object, size_t instance_id, bool first_layer, + const Layer *object_layer) { + // Belt printers lay the brim onto the tilted belt over many layers, so there is + // nothing special about the first one. The bands that coincide with an object + // layer are emitted here; those below the object's first layer are apron and go + // through process_belt_brim_layer() instead. + if (object.has_belt_brim()) { + if (object_layer == nullptr) + return {}; + const std::vector &by_layer = object.belt_brim_by_layer(); + const size_t layer_idx = object_layer->id(); + if (layer_idx >= by_layer.size() || by_layer[layer_idx].empty()) + return {}; + std::string gcode; + // The band geometry is in the object's local slicing frame, exactly like its + // perimeters, so it needs this instance's origin. The caller does not set it + // until later, and the plate brim path deliberately uses (0, 0) because its + // geometry is already in plate coordinates. + m_config.apply(print.default_region_config()); + m_config.apply(object.config(), true); + const Point &offset = object.instances()[instance_id].shift; + this->set_origin(unscale(offset)); + this->on_set_origin(&object, offset); + m_avoid_crossing_perimeters.use_external_mp(); + for (const ExtrusionEntity *ee : by_layer[layer_idx].entities) + if (ee != nullptr) + gcode += this->extrude_entity(*ee, "brim", NOZZLE_CONFIG(support_speed)); + m_avoid_crossing_perimeters.use_external_mp(false); + m_avoid_crossing_perimeters.disable_once(); + return gcode; + } + if (!first_layer) return {}; @@ -5201,6 +5249,86 @@ std::string GCode::generate_object_brim(const Print &print, const PrintObject &o return {}; } +// Belt printers: emit one brim-only apron layer. On a tilted belt the brim ahead +// of the part lands at slicing Z below the object's first layer, because the +// object's layer 0 IS its leading contact with the belt. Those layers carry brim +// and nothing else. +// +// This is intentionally a short path rather than a variant of process_layer(): an +// apron band has no Layer, and giving it a synthetic one would feed a fabricated +// Layer::id() into initial-layer temperature selection, the spiral vase probe, +// gradual interpolation and cooling. Correct first-layer treatment comes from +// FirstLayerPlane in BeltAffine mode, which is evaluated per point. +LayerResult GCode::process_belt_brim_layer( + const Print &print, + const std::vector &layers, + const LayerTools &layer_tools, + const bool last_layer, + const size_t single_object_instance_idx) +{ + // layer_id 0: the apron precedes every object layer and nothing downstream + // indexes by it. spiral_vase_enable false: spiral vase is refused alongside + // belt brim in Print::validate(). cooling_buffer_flush true: an apron layer is + // a complete layer, and the default (object_layer || raft_layer || last_layer) + // would be false here, so fan and slowdown would never be applied to it. + LayerResult result { {}, 0, false, true }; + if (layer_tools.extruders.empty()) + // Nothing to extrude. + return result; + + coordf_t print_z = 0.; + for (const LayerToPrint <p : layers) + if (ltp.belt_brim_band != nullptr) { + print_z = ltp.belt_brim_band->print_z; + break; + } + + m_cur_layer_idx = m_belt_brim_layer_idx ++; + + // Publish the band's Z for _extrude()'s first-layer-plane probe, and make sure + // it cannot leak past this layer even if an extrusion throws. + struct BeltBrimZGuard { + std::optional &slot; + ~BeltBrimZGuard() { slot.reset(); } + } z_guard { m_belt_brim_z }; + m_belt_brim_z = print_z; + m_layer = nullptr; + + std::string gcode; + const unsigned int extruder_id = layer_tools.extruders.front(); + if (m_writer->filament() == nullptr || m_writer->filament()->id() != extruder_id) + gcode += this->set_extruder(extruder_id, print_z); + gcode += this->change_layer(print_z); + + for (const LayerToPrint <p : layers) { + const BeltBrimBand *band = ltp.belt_brim_band; + if (band == nullptr || band->fills.empty() || ltp.original_object == nullptr) + continue; + const PrintObject &object = *ltp.original_object; + // Speeds, flow and retraction all read m_config. + m_config.apply(print.default_region_config()); + m_config.apply(object.config(), true); + const size_t i_begin = single_object_instance_idx == size_t(-1) ? 0 : single_object_instance_idx; + const size_t i_end = single_object_instance_idx == size_t(-1) ? object.instances().size() + : single_object_instance_idx + 1; + for (size_t i = i_begin; i < i_end && i < object.instances().size(); ++ i) { + // Band geometry is object-local, like the object's own extrusions. + const Point &offset = object.instances()[i].shift; + this->set_origin(unscale(offset)); + this->on_set_origin(&object, offset); + m_avoid_crossing_perimeters.use_external_mp(); + for (const ExtrusionEntity *ee : band->fills.entities) + if (ee != nullptr) + gcode += this->extrude_entity(*ee, "brim", NOZZLE_CONFIG(support_speed)); + m_avoid_crossing_perimeters.use_external_mp(false); + m_avoid_crossing_perimeters.disable_once(); + } + } + + result.gcode = std::move(gcode); + return result; +} + // Bedslinger model. The heavier the bed load, the lower the achievable Y acceleration for a given // drive force (a = F / (bed_mass + printed_mass)). Reads machine_max_force_Y / machine_bed_mass_Y (both // default 0, i.e. absent on every existing printer), in which case it just returns the min configured Y @@ -5523,6 +5651,13 @@ LayerResult GCode::process_layer( } } + // Belt printers: a brim-only apron layer has neither an object nor a support + // layer, so it must be handled before layer_ptr is dereferenced below. + if (object_layer == nullptr && support_layer == nullptr && + std::any_of(layers.begin(), layers.end(), + [](const LayerToPrint &l) { return l.belt_brim_band != nullptr; })) + return this->process_belt_brim_layer(print, layers, layer_tools, last_layer, single_object_instance_idx); + const Layer* layer_ptr = nullptr; if (object_layer != nullptr) layer_ptr = object_layer; @@ -6481,7 +6616,8 @@ LayerResult GCode::process_layer( const LayerToPrint &layer_to_print = layers[instance_to_print.layer_id]; if (visit.first_visit && print_wipe_extrusions == (is_anything_overridden ? 1 : 0)) { gcode += generate_object_skirt_group(print, instance_to_print.print_object, instance_to_print.instance_id, layer_tools, layer, extruder_id); - gcode += generate_object_brim(print, instance_to_print.print_object, instance_to_print.instance_id, first_layer); + gcode += generate_object_brim(print, instance_to_print.print_object, instance_to_print.instance_id, first_layer, + layer_to_print.object_layer); } // To control print speed of the 1st object layer printed over raft interface. @@ -7591,10 +7727,13 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, // evaluator is inactive (non-belt printers, or belt printers without // a Z-axis shear) `path_on_first_layer` falls back to the legacy // layer-id check, so behavior is bit-identical to the pre-feature path. + // A belt brim apron band has no Layer of its own, so it publishes its Z + // through m_belt_brim_z instead; without that the plane would be probed at + // Z=0 and the apron mis-classified for fan and speed. const Vec3d path_point_mm{ unscale(path.first_point().x()), unscale(path.first_point().y()), - m_layer ? m_layer->print_z : 0.0 + m_layer ? m_layer->print_z : (m_belt_brim_z ? *m_belt_brim_z : 0.0) }; const bool path_on_first_layer = this->on_first_layer(path_point_mm); diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index 8655683727..d4c7bf9bc3 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -33,6 +33,7 @@ #include #include +#include #include #include #include @@ -290,6 +291,13 @@ public: const Layer* object_layer; const SupportLayer* support_layer; const PrintObject* original_object; //BBS: used for shared object logic + // Belt printers only: an apron band that prints BELOW the object's first + // layer, so it has no object or support layer of its own. Deliberately + // not a Layer, so it cannot leak Layer::id() semantics into initial-layer + // temperature, spiral vase, cooling or interpolation logic. When this is + // the only thing set, layer() is null and process_layer() takes its + // dedicated brim-only branch. + const BeltBrimBand* belt_brim_band { nullptr }; const Layer* layer() const { if (object_layer != nullptr) @@ -319,6 +327,12 @@ public: count++; } + // A brim-only apron band contributes no object/support layer, and + // averaging zero terms would yield NaN. Never folded into the + // average, so the non-belt result is bit-identical. + if (count == 0 && belt_brim_band != nullptr) + return belt_brim_band->print_z; + return sum_z / count; } }; @@ -389,7 +403,20 @@ protected: std::string generate_object_brim(const Print &print, const PrintObject &object, size_t instance_id, - bool first_layer); + bool first_layer, + const Layer *object_layer); + + // Belt printers: emit one brim-only apron layer. These print below the + // object's first layer, so there is no object or support layer for the normal + // process_layer() machinery to work from. Kept to the minimum a layer needs - + // tool, Z move, extrusions - so that nothing here can perturb the + // Layer::id()-based logic the ordinary path relies on. + LayerResult process_belt_brim_layer( + const Print &print, + const std::vector &layers, + const LayerTools &layer_tools, + const bool last_layer, + const size_t single_object_instance_idx); LayerResult process_layer( const Print &print, @@ -780,6 +807,13 @@ protected: // resolvers. Distinct from m_layer_index (an export progress counter starting at -1). size_t m_cur_layer_idx{0}; + // Belt brim apron layers only. They have no Layer, so the print_z that + // _extrude() needs for the first-layer-plane probe is published here instead. + // Scoped by BeltBrimZGuard in process_belt_brim_layer(), never left set. + std::optional m_belt_brim_z; + // Counter standing in for Layer::id() on apron layers, which precede layer 0. + size_t m_belt_brim_layer_idx{0}; + std::set m_initial_layer_extruders; std::vector> m_sorted_layer_filaments; // BBS diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index f37025d4e7..e0520a3694 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -468,6 +468,11 @@ ToolOrdering::ToolOrdering(const PrintObject &object, unsigned int first_extrude zs.emplace_back(layer->print_z); for (auto layer : object.support_layers()) zs.emplace_back(layer->print_z); + // Belt brim apron bands sit below the object's first layer and have no + // layer of their own, but tools_for_layer() asserts an exact Z match, so + // their print_z must be part of the ordering. + for (const BeltBrimBand &band : object.belt_brim_prologue()) + zs.emplace_back(band.print_z); this->initialize_layers(zs); } @@ -512,6 +517,10 @@ ToolOrdering::ToolOrdering(const Print &print, unsigned int first_extruder, bool zs.emplace_back(layer->print_z); for (auto layer : object->support_layers()) zs.emplace_back(layer->print_z); + // See the single-object ctor: belt brim apron bands need their own + // ordering entries or tools_for_layer() will assert. + for (const BeltBrimBand &band : object->belt_brim_prologue()) + zs.emplace_back(band.print_z); max_layer_height = std::max(max_layer_height, object->config().layer_height.value); } @@ -860,6 +869,30 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto } } + // Belt brim apron bands own their layers outright: they print below the + // object's first layer, so no object or support layer claims an extruder there + // and process_layer() would bail out at "Nothing to extrude". Claim the + // object's outer wall filament, in the same raw 1-based domain the loops above + // push. Deliberately not layer_tools.has_object, which drives skirt marking + // and wiping overrides. + if (! object.belt_brim_prologue().empty()) { + unsigned int brim_filament = 0; + for (size_t i = 0; i < object.num_printing_regions(); ++ i) { + const unsigned int f = object.printing_region(i).config().outer_wall_filament_id.value; + if (f > 0 && (brim_filament == 0 || f < brim_filament)) + brim_filament = f; + } + if (brim_filament == 0) + brim_filament = 1; + for (const BeltBrimBand &band : object.belt_brim_prologue()) { + if (band.fills.empty()) + continue; + LayerTools &layer_tools = this->tools_for_layer(band.print_z); + layer_tools.extruders.push_back(brim_filament); + layer_tools.has_belt_brim = true; + } + } + for (auto& layer : m_layer_tools) { // Sort and remove duplicates sort_remove_duplicates(layer.extruders); @@ -902,12 +935,20 @@ void ToolOrdering::fill_wipe_tower_partitions(const PrintConfig &config, coordf_ } //FIXME this is a hack to get the ball rolling. + // The `print_z < object_bottom_z` clause reads "below the object" as "raft + // gap". On a belt printer that is wrong: the brim apron legitimately prints + // below the object's first layer, and treating those layers as raft would put a + // wipe tower at negative Z. Belt brim and the prime tower are mutually + // exclusive (rejected in Print::validate()), so simply drop the clause there. + const bool belt_no_raft_gap = config.belt_printer.value; for (LayerTools < : m_layer_tools) lt.has_wipe_tower |= (lt.has_object && (config.timelapse_type == TimelapseType::tlSmooth || lt.wipe_tower_partitions > 0)) - || lt.print_z < object_bottom_z + EPSILON; + || (! belt_no_raft_gap && lt.print_z < object_bottom_z + EPSILON); // Test for a raft, insert additional wipe tower layer to fill in the raft separation gap. - for (size_t i = 0; i + 1 < m_layer_tools.size(); ++ i) { + // Skipped on belt printers for the same reason as the clause above: layers + // below the object are brim apron, not raft. + for (size_t i = 0; ! belt_no_raft_gap && i + 1 < m_layer_tools.size(); ++ i) { const LayerTools < = m_layer_tools[i]; const LayerTools <_next = m_layer_tools[i + 1]; if (lt.print_z < object_bottom_z + EPSILON && lt_next.print_z >= object_bottom_z + EPSILON) { diff --git a/src/libslic3r/GCode/ToolOrdering.hpp b/src/libslic3r/GCode/ToolOrdering.hpp index c77b152fe9..0d7526c72e 100644 --- a/src/libslic3r/GCode/ToolOrdering.hpp +++ b/src/libslic3r/GCode/ToolOrdering.hpp @@ -161,6 +161,10 @@ public: // Should a skirt be printed at this layer? // Layers are marked for infinite skirt aka draft shield. Not all the layers have to be printed. bool has_skirt = false; + // Belt printers: is this one of the brim-only apron layers below the object's + // first layer? Kept separate from has_object so skirt marking and wiping + // overrides are unaffected. + bool has_belt_brim = false; // Will there be anything extruded on this layer for the wipe tower? // Due to the support layers possibly interleaving the object layers, // wipe tower will be disabled for some support only layers. diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index dc89c2b1df..3e6e2b9aaf 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1090,7 +1090,7 @@ static std::vector s_Preset_print_options{ "top_surface_speed", "support_speed", "support_object_xy_distance", "support_object_first_layer_gap", "support_interface_speed", "bridge_speed", "internal_bridge_speed", "gap_infill_speed", "travel_speed", "travel_speed_z", "initial_layer_speed", "outer_wall_acceleration", "initial_layer_acceleration", "top_surface_acceleration", "default_acceleration", "skirt_type", "skirt_loops", "skirt_speed","min_skirt_length", "skirt_distance", "skirt_start_angle", "skirt_height","single_loop_draft_shield", "draft_shield", - "brim_width", "brim_object_gap", "brim_flow_ratio", "brim_use_efc_outline", "combine_brims", "brim_type", "brim_ears_max_angle", "brim_ears_detection_length", "enable_support", "support_type", "support_threshold_angle", "support_threshold_overlap","enforce_support_layers", + "brim_width", "leading_brim_length", "extra_brim_width", "brim_object_gap", "brim_flow_ratio", "brim_use_efc_outline", "combine_brims", "brim_type", "brim_ears_max_angle", "brim_ears_detection_length", "enable_support", "support_type", "support_threshold_angle", "support_threshold_overlap","enforce_support_layers", "raft_layers", "raft_first_layer_density", "raft_first_layer_expansion", "raft_contact_distance", "raft_expansion", "support_base_pattern", "support_base_pattern_spacing", "support_expansion", "support_style", // BBS diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index ba28f96804..189f9e178e 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -641,6 +641,24 @@ bool Print::has_brim() const return std::any_of(m_objects.begin(), m_objects.end(), [](PrintObject *object) { return object->has_brim(); }); } +bool Print::has_tilted_belt() const +{ + if (! m_config.belt_printer.value) + return false; + // A Z rotation leaves the belt floor flat (BeltTransform forces shear = 0) and no + // rotation at all means the machine is geometrically a flat bed. + const BeltRotationAxis axis = m_config.belt_slice_rotation.value; + if (axis != BeltRotationAxis::X && axis != BeltRotationAxis::Y) + return false; + const double tilt = std::abs(m_config.belt_slice_rotation_angle.value); + return tilt >= BELT_BRIM_MIN_TILT_DEG && tilt <= BELT_BRIM_MAX_TILT_DEG; +} + +bool Print::has_belt_brim() const +{ + return std::any_of(m_objects.begin(), m_objects.end(), [](PrintObject *object) { return object->has_belt_brim(); }); +} + //BBS std::vector Print::layers_sorted_for_object(float start, float end, std::vector &layers_of_objects, std::vector &boundingBox_for_objects, VecOfPoints &objects_instances_shift) { @@ -1342,6 +1360,65 @@ StringObjectException Print::validate(std::vector *warnin } if (m_config.draft_shield != dsDisabled) return { L("Draft shield is not compatible with belt printer mode.") }; + + // Belt brim spans many layers and owns the layers below the object, which + // neither the prime tower nor spiral vase can share. + if (this->has_belt_brim()) { + if (m_config.enable_prime_tower.value) + return { L("Brim is not compatible with the prime tower on a belt printer. " + "Disable one of them.") }; + if (m_config.spiral_mode.value) + return { L("Brim is not compatible with spiral vase mode on a belt printer. " + "Disable one of them.") }; + } + + for (const PrintObject *object : m_objects) { + const PrintObjectConfig &ocfg = object->config(); + const bool wants_brim = ocfg.brim_type != btNoBrim + && (ocfg.brim_width.value > 0. || ocfg.leading_brim_length.value > 0. + || ocfg.extra_brim_width.value > 0.); + if (! wants_brim) + continue; + + if (! this->has_tilted_belt()) { + if (std::abs(m_config.belt_slice_rotation_angle.value) > BELT_BRIM_MAX_TILT_DEG) + warn(L("The belt is too steep for a brim, so no brim will be generated."), + "brim_width", object->model_object()); + else + warn(L("A brim is only generated when the belt is tilted. Set a belt tilt angle, " + "or remove the brim setting."), + "brim_type", object->model_object()); + } + + if (ocfg.brim_type == btAutoBrim || ocfg.brim_type == btEar || ocfg.brim_type == btPainted) + warn(L("Belt printers support outer and inner brim only. Auto, Mouse ear and Painted " + "brim are printed as Outer brim only, using Brim width."), + "brim_type", object->model_object()); + + if (ocfg.leading_brim_length.value > 0. && ocfg.brim_object_gap.value > 0.) + warn(L("Brim-object gap separates the leading brim from the object's leading edge, " + "which is the edge it is meant to anchor. Set the gap to 0 when using leading " + "brim length."), + "brim_object_gap", object->model_object()); + + if (ocfg.leading_brim_length.value > 0. && object->instances().size() > 1) + warn(L("This object has several instances sharing one belt position, so no brim is " + "generated for it. Arrange the copies along the belt instead."), + "leading_brim_length", object->model_object()); + } + if (this->has_belt_brim() && m_objects.size() > 1) + warn(L("Leading brim length extends ahead of each object along the belt, and Arrange does " + "not reserve that space. Leave room between objects."), + "leading_brim_length"); + } else { + // "Leading edge only" describes where a part meets a moving belt, so it has no + // meaning on a fixed bed. Brim.cpp prints it as an ordinary outer brim rather + // than silently producing nothing; say so. + for (const PrintObject *object : m_objects) + if (object->config().brim_type == btLeadingEdgeOnly) + warn(L("\"Leading edge only\" brim applies to belt printers. On this printer it is " + "printed as an ordinary outer brim."), + "brim_type", object->model_object()); } if (nozzles < 2 && extruders.size() > 1) { @@ -2251,8 +2328,28 @@ BoundingBox PrintObject::get_first_layer_bbox(float& a, float& layer_height, std a += area(slice); } } - if (has_brim()) + // Guard on `defined`: make_brim() can return before assigning this (it does on + // belt printers, where has_brim() is still true but the plate brim is skipped), + // and overwriting a valid bbox with an undefined one corrupted the first-layer + // centre and the GUI's first-layer area readout. + if (has_brim() && firstLayerObjectBrimBoundingBox.defined) bbox = firstLayerObjectBrimBoundingBox; + // Belt brim: the apron reaches ahead of the object along the belt. + if (has_belt_brim()) { + const Point shift = instances().empty() ? Point(0, 0) : instances()[0].shift_without_plate_offset(); + for (const ExPolygons &areas : m_belt_brim_areas_by_layer) + for (const ExPolygon &ex : areas) { + BoundingBox bb = get_extents(ex.contour); + bb.translate(shift.x(), shift.y()); + bbox.merge(bb); + } + for (const BeltBrimBand &band : m_belt_brim_prologue) + for (const ExPolygon &ex : band.areas) { + BoundingBox bb = get_extents(ex.contour); + bb.translate(shift.x(), shift.y()); + bbox.merge(bb); + } + } return bbox; } @@ -2804,6 +2901,26 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) } + // Belt brim: bound the first-layer convex hull by the lowest apron band, so + // bed levelling and the initial purge line account for brim that reaches + // ahead of every object. + if (this->has_belt_brim()) { + for (PrintObject *object : m_objects) { + if (! object->has_belt_brim() || object->belt_brim_prologue().empty()) + continue; + const BeltBrimBand &lowest = object->belt_brim_prologue().front(); + for (const PrintInstance &instance : object->instances()) + for (const ExPolygon &ex : lowest.areas) { + Polygon poly = ex.contour; + poly.translate(instance.shift); + append(m_first_layer_convex_hull.points, std::move(poly.points)); + } + } + } + + // Unchanged for belt printers: _make_skirt() already returns early for them, and + // the belt brim does not populate m_brimMapByInstance, which is what the + // skirt/brim grouping reads. if (has_skirt() || has_infinite_skirt() || has_brim()) { // Generate skirt/brim groups after brim so per-object and draft-shield footprints // include brims when grouping and offsetting skirt loops. diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index bb61c210a7..72a12918c7 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -17,6 +17,7 @@ #include "GCode/ThumbnailData.hpp" #include "GCode/GCodeProcessor.hpp" #include "MultiMaterialSegmentation.hpp" +#include "BeltBrim.hpp" #include "BeltTransform.hpp" #include "ObjectID.hpp" #include "libslic3r.h" @@ -384,6 +385,21 @@ public: && ! this->has_raft(); } + // Belt brim. A tilted belt needs its brim laid onto the belt plane over many + // layers instead of as one flat first-layer ring, so it is generated by + // BeltBrim.cpp and stored per layer here. Deliberately separate from + // has_brim(): that predicate feeds PrintRegion extruder collection, support + // trimming and the spiral vase probe, and widening it would perturb belt + // support output. + bool has_belt_brim() const; + const std::vector& belt_brim_by_layer() const { return m_belt_brim_by_layer; } + const std::vector& belt_brim_areas_by_layer() const { return m_belt_brim_areas_by_layer; } + const std::vector& belt_brim_prologue() const { return m_belt_brim_prologue; } + void clear_belt_brim(); + void set_belt_brim(std::vector &&by_layer, + std::vector &&areas, + std::vector &&prologue); + // BBS const ExtrusionEntityCollection& object_skirt() const { return m_skirt; @@ -576,6 +592,12 @@ private: SlicingParameters m_slicing_params; LayerPtrs m_layers; SupportLayerPtrs m_support_layers; + // Belt brim, generated in posSupportMaterial by BeltBrim.cpp. Object-local + // slicing frame, one entry per object layer plus a prologue of brim-only + // bands that print below the object's first layer. + std::vector m_belt_brim_by_layer; + std::vector m_belt_brim_areas_by_layer; + std::vector m_belt_brim_prologue; // BBS std::shared_ptr m_tree_support_preview_cache; @@ -971,6 +993,15 @@ public: bool has_infinite_skirt() const; bool has_skirt() const; bool has_brim() const; + // True when the belt is tilted enough that the BELT plane, not the bed plane, is + // the adhesion surface. The flat plate brim is geometrically meaningless then + // and must not run, whatever the per-object brim settings say - in particular + // brim_type "Auto", which has_brim() reports as enabled even at width 0. + bool has_tilted_belt() const; + // True when at least one object actually gets a tilted-belt brim generated. + // Implies has_tilted_belt(), but additionally requires the object's own brim + // settings to ask for one. + bool has_belt_brim() const; //BBS bool has_auto_brim() const { return std::any_of(m_objects.begin(), m_objects.end(), [](PrintObject* object) { return object->config().brim_type == btAutoBrim; }); diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index b2984164ee..f641699d91 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -508,6 +508,7 @@ static const t_config_enum_values s_keys_map_BrimType = { {"auto_brim", btAutoBrim}, // BBS {"brim_ears", btEar}, // Orca {"painted", btPainted}, // BBS + {"leading_edge_only", btLeadingEdgeOnly}, // belt printers }; CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(BrimType) @@ -1899,6 +1900,45 @@ void PrintConfigDef::init_fff_params() def->mode = comSimple; def->set_default_value(new ConfigOptionFloat(0.)); + def = this->add("leading_brim_length", coFloat); + def->label = L("Leading brim length"); + def->category = L("Support"); + def->tooltip = L("Belt printers only. Extends the brim AHEAD of the object along the belt, on " + "every downhill-facing edge of its contact area - both the object's first " + "contact with the belt and any island that lands later. This apron is laid " + "onto the belt before the object reaches it, so the leading edge has " + "something already stuck down to hold on to.\n\n" + "Measured on the belt surface, and added on top of Brim width: the brim " + "reaches Brim-object gap + Leading brim length + Brim width ahead of the " + "object. Set Brim-object gap to 0, or the apron will not touch the object it " + "is meant to anchor.\n\n" + "On a tilted belt each layer lays one strip of the brim, so the thickness of " + "the resulting brim sheet is set by flow rather than by layer height. Use " + "Brim flow ratio to tune it.\n\n" + "Set to 0 to disable."); + def->sidetext = L("mm"); // millimeters, CIS languages need translation + def->min = 0; + def->max = 100; + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionFloat(0.)); + + def = this->add("extra_brim_width", coFloat); + def->label = L("Extra brim width"); + def->category = L("Support"); + def->tooltip = L("Belt printers only. Widens the brim SIDEWAYS, across the belt, without " + "extending it further ahead of or behind the object. Use it when a part needs " + "more grip along its length than Brim width alone gives.\n\n" + "Measured on the belt surface, and added on top of Brim width: the brim " + "reaches Brim-object gap + Brim width + Extra brim width to either side of " + "the object. To extend the brim ahead of the object instead, use Leading brim " + "length.\n\n" + "Set to 0 to disable."); + def->sidetext = L("mm"); // millimeters, CIS languages need translation + def->min = 0; + def->max = 100; + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionFloat(0.)); + def = this->add("brim_type", coEnum); def->label = L("Brim type"); def->category = L("Support"); @@ -1912,6 +1952,7 @@ void PrintConfigDef::init_fff_params() def->enum_values.emplace_back("inner_only"); def->enum_values.emplace_back("outer_and_inner"); def->enum_values.emplace_back("no_brim"); + def->enum_values.emplace_back("leading_edge_only"); def->enum_labels.emplace_back(L("Auto")); def->enum_labels.emplace_back(L("Mouse ear")); def->enum_labels.emplace_back(L("Painted")); @@ -1919,6 +1960,7 @@ void PrintConfigDef::init_fff_params() def->enum_labels.emplace_back(L("Inner brim only")); def->enum_labels.emplace_back(L("Outer and inner brim")); def->enum_labels.emplace_back(L("No-brim")); + def->enum_labels.emplace_back(L("Leading edge only")); def->mode = comSimple; def->set_default_value(new ConfigOptionEnum(btAutoBrim)); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index acc5f56105..3342a54b6d 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -391,6 +391,10 @@ enum BrimType { btInnerOnly, btOuterAndInner, btNoBrim, + // Belt printers: brim only where the part first touches the belt, nothing after + // that. Appended last so no existing value shifts. On a non-belt printer this + // has no meaning and behaves as btOuterOnly. + btLeadingEdgeOnly, }; enum TimelapseType : int { @@ -1140,6 +1144,8 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionBool, brim_use_efc_outline)) ((ConfigOptionEnum, brim_type)) ((ConfigOptionFloat, brim_width)) + ((ConfigOptionFloat, leading_brim_length)) + ((ConfigOptionFloat, extra_brim_width)) ((ConfigOptionFloat, brim_ears_detection_length)) ((ConfigOptionFloat, brim_ears_max_angle)) ((ConfigOptionFloat, skirt_start_angle)) diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index e8f7a8e08f..1b8ddf3a9e 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -969,6 +969,13 @@ void PrintObject::generate_support_material() this->_generate_support_material(); m_print->throw_if_canceled(); } + // Belt brim rides here rather than in the brim step because its apron + // prologue introduces print_z values below the object's first layer, and + // those must exist before ToolOrdering is built at psWipeTower - one step + // ahead of psSkirtBrim. The brim options already invalidate + // posSupportMaterial, so this needs no extra invalidation edges. + make_belt_brim(*this); + m_print->throw_if_canceled(); BOOST_LOG_TRIVIAL(trace) << "[BELTRACE] generate_support_material EXIT tid=" << std::this_thread::get_id() << " obj=" << this; this->set_done(posSupportMaterial); } else { @@ -1142,6 +1149,52 @@ void PrintObject::clear_support_layers() l->cantilevers.clear(); } } + // Belt brim is owned by the same step, so it must die with it or an + // invalidate-without-rerun would leave stale bands (and stale prologue Zs) + // behind. Unconditional: unlike support layers it is never shared. + this->clear_belt_brim(); +} + +// Belt brim ------------------------------------------------------------------ +// +// The tilt test is answered from the print CONFIG, not from SlicingParameters: +// invalidating posSupportMaterial clears m_slicing_params.valid, and this is +// queried from Print::process() dispatch, Brim.cpp and the G-code emitter, where +// a stale zero shear factor would silently drop the brim. BeltBrim.cpp itself +// reads the real belt floor through BeltFloorContext, where the parameters are +// guaranteed current. +bool PrintObject::has_belt_brim() const +{ + if (! m_print->has_tilted_belt()) + return false; + // Translating an instance along the belt axis changes its physical belt-floor + // Z, so one set of bands cannot serve several instances sharing a PrintObject. + // belt_force_separate() in PrintApply.cpp gives one instance per object + // whenever a global belt flag is set, which the shipped belt profiles do. + if (m_instances.size() > 1) + return false; + if (m_config.brim_type == btNoBrim) + return false; + if (m_config.brim_width.value <= 0. && m_config.leading_brim_length.value <= 0. + && m_config.extra_brim_width.value <= 0.) + return false; + return ! this->has_raft(); +} + +void PrintObject::clear_belt_brim() +{ + m_belt_brim_by_layer.clear(); + m_belt_brim_areas_by_layer.clear(); + m_belt_brim_prologue.clear(); +} + +void PrintObject::set_belt_brim(std::vector &&by_layer, + std::vector &&areas, + std::vector &&prologue) +{ + m_belt_brim_by_layer = std::move(by_layer); + m_belt_brim_areas_by_layer = std::move(areas); + m_belt_brim_prologue = std::move(prologue); } std::shared_ptr PrintObject::alloc_tree_support_preview_cache() @@ -1184,6 +1237,8 @@ bool PrintObject::invalidate_state_by_config_options( bool invalidated = false; for (const t_config_option_key &opt_key : opt_keys) { if ( opt_key == "brim_width" + || opt_key == "leading_brim_length" + || opt_key == "extra_brim_width" || opt_key == "brim_object_gap" || opt_key == "brim_use_efc_outline" || opt_key == "brim_type" diff --git a/src/libslic3r/Support/BeltFloorContext.cpp b/src/libslic3r/Support/BeltFloorContext.cpp index d2ffcfd397..eb6cf3670c 100644 --- a/src/libslic3r/Support/BeltFloorContext.cpp +++ b/src/libslic3r/Support/BeltFloorContext.cpp @@ -66,7 +66,7 @@ Polygons BeltFloorContext::half_plane(coordf_t print_z, bool belt_surface) const if (!m_active) return {}; - const double cutoff = (print_z - m_z_shift - m_floor_offset) / m_shear_factor; + const double cutoff = this->cutoff_u(print_z); const coord_t cutoff_scaled = scale_(cutoff); const coord_t large_bound = scale_(1e3); diff --git a/src/libslic3r/Support/BeltFloorContext.hpp b/src/libslic3r/Support/BeltFloorContext.hpp index 7b8b704ef6..3a45a6eba8 100644 --- a/src/libslic3r/Support/BeltFloorContext.hpp +++ b/src/libslic3r/Support/BeltFloorContext.hpp @@ -47,6 +47,12 @@ public: // Returns -infinity if not active. double floor_print_z(const Point &pos_slicing) const; + // The from_axis coordinate (unscaled, slicing frame) where the belt surface + // crosses a horizontal plane at print_z. Inverse of floor_print_z() along + // the shear axis. Only meaningful when is_active(). + coordf_t cutoff_u(coordf_t print_z) const + { return (print_z - m_z_shift - m_floor_offset) / m_shear_factor; } + // Pre-compute belt floor polygons for a range of layers. // layer_print_z(i) returns the print_z for layer index i. std::vector compute_per_layer_floors( diff --git a/src/libslic3r/Support/SupportCommon.cpp b/src/libslic3r/Support/SupportCommon.cpp index e6b93f1717..4ab571edd1 100644 --- a/src/libslic3r/Support/SupportCommon.cpp +++ b/src/libslic3r/Support/SupportCommon.cpp @@ -268,7 +268,9 @@ SupportGeneratorLayersPtr generate_raft_base( // The object does not have a raft. // Calculate the area covered by the brim. const BrimType brim_type = object.config().brim_type; - const bool brim_outer = brim_type == btOuterOnly || brim_type == btOuterAndInner; + // btLeadingEdgeOnly only means anything on a belt printer, where this code path + // does not run; elsewhere it degrades to an outer brim (see Brim.cpp). + const bool brim_outer = brim_type == btOuterOnly || brim_type == btOuterAndInner || brim_type == btLeadingEdgeOnly; const bool brim_inner = brim_type == btInnerOnly || brim_type == btOuterAndInner; // BBS: the pattern of raft and brim are the same, thus the brim can be serpated by support raft. const auto brim_object_gap = scaled(object.config().brim_object_gap.value); diff --git a/src/libslic3r/Support/SupportSpotsGenerator.cpp b/src/libslic3r/Support/SupportSpotsGenerator.cpp index 6fb4908e67..aa79fdb9b8 100644 --- a/src/libslic3r/Support/SupportSpotsGenerator.cpp +++ b/src/libslic3r/Support/SupportSpotsGenerator.cpp @@ -761,7 +761,9 @@ std::tuple build_object_part_from_slice(const size_t &slice_i // thus has lower adhesion. For now this effect will be neglected. ExPolygon slice_poly = layer->lslices[slice_idx]; ExPolygons brim; - if (params.brim_type == BrimType::btOuterAndInner || params.brim_type == BrimType::btOuterOnly) { + // btLeadingEdgeOnly degrades to an outer brim off belt printers (see Brim.cpp). + if (params.brim_type == BrimType::btOuterAndInner || params.brim_type == BrimType::btOuterOnly + || params.brim_type == BrimType::btLeadingEdgeOnly) { Polygon brim_hole = slice_poly.contour; brim_hole.reverse(); Polygons c = expand(slice_poly.contour, scale_(params.brim_width)); // For very small polygons, the expand may result in empty vector, even thought the input is correct. diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index f95260e0c2..3fc69d6e4b 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -630,11 +630,25 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in const bool gcf_is_klipper = gcflavor == GCodeFlavor::gcfKlipper; // Belt printer: detect early since it affects multiple toggle decisions below. + // `is_belt_tilted` is the stricter test that mirrors PrintObject::has_belt_brim(): + // only a tilted belt gets the belt-plane brim, while a belt printer with no + // rotation is geometrically a flat bed and keeps the ordinary plate brim. bool is_belt_printer = false; + bool is_belt_tilted = false; { - const auto *belt_opt = preset_bundle->printers.get_edited_preset().config.option("belt_printer"); + const auto &printer_cfg = preset_bundle->printers.get_edited_preset().config; + const auto *belt_opt = printer_cfg.option("belt_printer"); if (belt_opt) is_belt_printer = belt_opt->value; + const auto *axis = printer_cfg.option>("belt_slice_rotation"); + const auto *angle = printer_cfg.option("belt_slice_rotation_angle"); + if (is_belt_printer && axis != nullptr && angle != nullptr) { + // Same window as Print::has_tilted_belt(); shared constants so the GUI and + // the backend cannot drift apart. + const double tilt = std::abs(angle->value); + is_belt_tilted = (axis->value == BeltRotationAxis::X || axis->value == BeltRotationAxis::Y) + && tilt >= BELT_BRIM_MIN_TILT_DEG && tilt <= BELT_BRIM_MAX_TILT_DEG; + } } bool have_volumetric_extrusion_rate_slope = config->option("max_volumetric_extrusion_rate_slope")->value > 0; @@ -808,21 +822,34 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in toggle_field("skirt_height", false); } - bool have_brim = (config->opt_enum("brim_type") != btNoBrim) && !is_belt_printer; - if (is_belt_printer) - toggle_field("brim_type", false); + // Belt printers now get a brim too, laid onto the tilted belt by BeltBrim.cpp, + // so brim type / width / object gap all apply. A belt printer with no tilt is + // geometrically a flat bed and uses the ordinary plate brim, hence the separate + // is_belt_tilted test. + bool have_brim = config->opt_enum("brim_type") != btNoBrim; toggle_field("brim_object_gap", have_brim); - toggle_field("brim_use_efc_outline", have_brim); - toggle_field("combine_brims", have_brim); - bool have_brim_width = (config->opt_enum("brim_type") != btNoBrim) && config->opt_enum("brim_type") != btAutoBrim && + // Both are first-layer-only concepts that the belt path cannot honour. + toggle_field("brim_use_efc_outline", have_brim && !is_belt_tilted); + toggle_field("combine_brims", have_brim && !is_belt_tilted); + bool have_brim_width = have_brim && config->opt_enum("brim_type") != btAutoBrim && config->opt_enum("brim_type") != btPainted; - toggle_field("brim_width", have_brim_width); + // On a tilted belt Auto / Mouse ear / Painted all collapse to outer-only at the + // configured width, so the width field has to stay live for them too. + toggle_field("brim_width", have_brim_width || (have_brim && is_belt_tilted)); toggle_field("brim_flow_ratio", have_brim); + // Paired toggle_line + toggle_field: cb_toggle_line is null in the per-object + // override panel, so the row cannot be hidden there and greying out is the + // fallback. Both extras are belt-only: one extends the brim ahead along the belt, + // the other widens it across the belt. + for (auto el : { "leading_brim_length", "extra_brim_width" }) { + toggle_line(el, is_belt_tilted); + toggle_field(el, is_belt_tilted && have_brim); + } // Wall filament selectors use the same logic as in Print::extruders(). toggle_field("outer_wall_filament_id", have_perimeters || have_brim); toggle_field("inner_wall_filament_id", have_perimeters || have_brim); - bool have_brim_ear = (config->opt_enum("brim_type") == btEar); + bool have_brim_ear = (config->opt_enum("brim_type") == btEar) && !is_belt_tilted; const auto brim_width = config->opt_float("brim_width"); // disable brim_ears_max_angle and brim_ears_detection_length if brim_width is 0 toggle_field("brim_ears_max_angle", brim_width > 0.0f); diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index 6b51167163..19241cbd93 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -86,15 +86,15 @@ std::map> SettingsFactory::OBJECT_C {"make_overhang_printable_angle","", 8},{"make_overhang_printable_hole_size","",9}, {"wall_sequence","",10}, {"precise_z_height", "",10} }}, - { L("Support"), {{"brim_type", "",1},{"brim_width", "",2},{"brim_object_gap", "",3},{"brim_flow_ratio", "",4},{"brim_use_efc_outline", "",5}, - {"enable_support", "",6},{"support_type", "",7},{"support_threshold_angle", "",8}, {"support_threshold_overlap", "",9}, {"support_on_build_plate_only", "",10}, - {"support_filament", "",11},{"support_interface_filament", "",12},{"support_expansion", "",13},{"support_style", "",14}, - {"tree_support_brim_width", "",15}, {"tree_support_branch_angle", "",16},{"tree_support_branch_angle_organic","",17}, {"tree_support_wall_count", "",18},{"tree_support_branch_diameter_angle", "",19},//tree support - {"support_bottom_z_distance", "",20},{"support_top_z_distance", "",21},{"support_base_pattern", "",22},{"support_base_pattern_spacing", "",23}, - {"support_interface_top_layers", "",24},{"support_interface_bottom_layers", "",25},{"support_interface_spacing", "",26},{"support_bottom_interface_spacing", "",27}, - {"support_object_xy_distance", "",28}, {"bridge_no_support", "",29},{"max_bridge_length", "",30},{"support_critical_regions_only", "",31},{"support_remove_small_overhang","",32}, - {"build_plate_tilt_x","",33},{"build_plate_tilt_y","",34}, - {"support_object_first_layer_gap","",35} + { L("Support"), {{"brim_type", "",1},{"brim_width", "",2},{"leading_brim_length", "",3},{"extra_brim_width", "",4},{"brim_object_gap", "",5},{"brim_flow_ratio", "",6},{"brim_use_efc_outline", "",7}, + {"enable_support", "",8},{"support_type", "",9},{"support_threshold_angle", "",10}, {"support_threshold_overlap", "",11}, {"support_on_build_plate_only", "",12}, + {"support_filament", "",13},{"support_interface_filament", "",14},{"support_expansion", "",15},{"support_style", "",16}, + {"tree_support_brim_width", "",17}, {"tree_support_branch_angle", "",18},{"tree_support_branch_angle_organic","",19}, {"tree_support_wall_count", "",20},{"tree_support_branch_diameter_angle", "",21},//tree support + {"support_bottom_z_distance", "",22},{"support_top_z_distance", "",23},{"support_base_pattern", "",24},{"support_base_pattern_spacing", "",25}, + {"support_interface_top_layers", "",26},{"support_interface_bottom_layers", "",27},{"support_interface_spacing", "",28},{"support_bottom_interface_spacing", "",29}, + {"support_object_xy_distance", "",30}, {"bridge_no_support", "",31},{"max_bridge_length", "",32},{"support_critical_regions_only", "",33},{"support_remove_small_overhang","",34}, + {"build_plate_tilt_x","",35},{"build_plate_tilt_y","",36}, + {"support_object_first_layer_gap","",37} }}, { L("Speed"), {{"support_speed", "",12}, {"support_interface_speed", "",13} }} diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 0613467ac8..7a4cefe4fa 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -3071,6 +3071,8 @@ void TabPrint::build() optgroup = page->new_optgroup(L("Brim"), L"param_adhension"); optgroup->append_single_option_line("brim_type", "others_settings_brim#type"); optgroup->append_single_option_line("brim_width", "others_settings_brim#width"); + optgroup->append_single_option_line("leading_brim_length", "others_settings_brim#leading-length"); + optgroup->append_single_option_line("extra_brim_width", "others_settings_brim#extra-width"); optgroup->append_single_option_line("brim_object_gap", "others_settings_brim#brim-object-gap"); optgroup->append_single_option_line("brim_flow_ratio", "others_settings_brim#brim-flow-ratio"); optgroup->append_single_option_line("brim_use_efc_outline", "others_settings_brim#brim-use-efc-outline"); diff --git a/tests/fff_print/test_skirt_brim.cpp b/tests/fff_print/test_skirt_brim.cpp index 3f63d3de5f..755dec06e0 100644 --- a/tests/fff_print/test_skirt_brim.cpp +++ b/tests/fff_print/test_skirt_brim.cpp @@ -1,6 +1,8 @@ #include +#include "libslic3r/ClipperUtils.hpp" #include "libslic3r/GCodeReader.hpp" +#include "libslic3r/Layer.hpp" #include "libslic3r/Config.hpp" #include "libslic3r/Geometry.hpp" #include "libslic3r/Geometry/ConvexHull.hpp" @@ -441,3 +443,193 @@ SCENARIO("Skirt and brim generation", "[SkirtBrim]") { } } } + +// Belt printers --------------------------------------------------------------- +// +// On a tilted belt the brim is laid onto the belt PLANE rather than into the Z=0 +// bed plane, so it is spread across many layers instead of living on the first +// one. The discriminating measurement is the number of contiguous brim runs in +// the G-code: a flat plate brim gives a single run, a belt brim gives one per +// layer that carries a band. Distinct Z values are useless here, because the +// machine-frame transform couples Y into Z so every belt move has its own Z. +static DynamicPrintConfig belt_brim_config() +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "belt_printer", 1 }, + { "belt_slice_rotation", "x" }, + { "belt_slice_rotation_angle", 45 }, + { "belt_slice_rotation_global", 1 }, + { "gcode_remap_x", "rev_x" }, + { "gcode_remap_y", "pos_z" }, + { "gcode_remap_z", "pos_y" }, + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "skirt_loops", 0 }, + { "top_shell_layers", 0 }, + { "bottom_shell_layers", 1 }, + { "machine_start_gcode", "T[initial_tool]\n" }, + }); + return config; +} + +TEST_CASE("Belt brim spans many layers instead of one", "[SkirtBrim][belt]") +{ + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 5 }, + }); + const std::string gcode = slice({ cube(20) }, config); + // A plate-brim implementation would score 1 here. + CHECK(role_passes(gcode, "brim") > 10); +} + +TEST_CASE("Belt brim is absent when both widths are zero", "[SkirtBrim][belt]") +{ + // The "no effect when disabled" guard: brim_type Auto is the shipped default and + // reports has_brim() even at width 0, so this also pins the gate that keeps the + // flat plate brim from running on a tilted belt. + const char *brim_type = GENERATE("auto_brim", "outer_only", "no_brim"); + DYNAMIC_SECTION("brim_type " << brim_type) { + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", brim_type }, + { "brim_width", 0 }, + { "leading_brim_length", 0 }, + { "extra_brim_width", 0 }, + }); + const std::string gcode = slice({ cube(20) }, config); + CHECK(role_passes(gcode, "brim") == 0); + } +} + +TEST_CASE("Leading brim length alone produces a belt brim", "[SkirtBrim][belt]") +{ + // Exercises the leading_brim_length-only enablement path and the downhill sweep. + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 0 }, + { "leading_brim_length", 5 }, + { "brim_object_gap", 0 }, + }); + const std::string gcode = slice({ cube(20) }, config); + CHECK(role_passes(gcode, "brim") > 0); +} + +TEST_CASE("Leading brim length reaches further ahead of the object", "[SkirtBrim][belt]") +{ + // Compared between two runs rather than against an absolute coordinate, so the + // assertion survives any change of origin or axis remap. + auto brim_extent = [](double extra) { + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 3 }, + { "leading_brim_length", extra }, + { "brim_object_gap", 0 }, + }); + const std::string gcode = slice({ cube(20) }, config); + // The apron prints before the object reaches the belt, so it shows up as brim + // extrusion at the lowest machine Z of any brim move. + double min_z = std::numeric_limits::max(); + GCodeReader parser; + parser.parse_buffer(gcode, [&min_z](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (line.extruding(self) && line.comment().find("brim") != std::string_view::npos) + min_z = std::min(min_z, static_cast(self.z())); + }); + return min_z; + }; + const double without = brim_extent(0.); + const double with = brim_extent(10.); + REQUIRE(without < std::numeric_limits::max()); + REQUIRE(with < std::numeric_limits::max()); + CHECK(with < without); +} + +TEST_CASE("Every brim type slices on a belt printer", "[SkirtBrim][belt]") +{ + // Auto / Mouse ear / Painted collapse to outer-only rather than crashing or + // silently producing nothing. + const char *brim_type = GENERATE("auto_brim", "brim_ears", "painted", "outer_only", + "inner_only", "outer_and_inner", "no_brim"); + DYNAMIC_SECTION("brim_type " << brim_type) { + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", brim_type }, + { "brim_width", 5 }, + }); + const std::string gcode = slice({ cube(20) }, config); + REQUIRE(! gcode.empty()); + if (std::string(brim_type) == "no_brim") + CHECK(role_passes(gcode, "brim") == 0); + else if (std::string(brim_type) != "inner_only") + // A solid cube has no holes, so inner_only legitimately yields nothing. + CHECK(role_passes(gcode, "brim") > 0); + } +} + +TEST_CASE("An untilted belt printer gets no brim", "[SkirtBrim][belt]") +{ + // Belt brim needs a tilt to have a belt plane to lie on, and the flat plate brim + // cannot reach the G-code on any belt printer: it is emitted out of + // skirt_brim_groups(), which _make_skirt() builds, and that returns early for every + // belt printer. So an untilted belt printer gets nothing - unchanged by this + // feature. Making the flat brim work here would mean reopening the belt skirt gate, + // which is a separate change; Print::validate() warns instead. + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "belt_slice_rotation", "none" }, + { "brim_type", "outer_only" }, + { "brim_width", 5 }, + }); + const std::string gcode = slice({ cube(20) }, config); + CHECK(role_passes(gcode, "brim") == 0); +} + +TEST_CASE("Belt brim does not resurrect the skirt", "[SkirtBrim][belt]") +{ + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 5 }, + { "skirt_loops", 2 }, + }); + const std::string gcode = slice({ cube(20) }, config); + CHECK(role_passes(gcode, "skirt") == 0); +} + +TEST_CASE("Belt brim lines all have the same width", "[SkirtBrim][belt]") +{ + // Each brim line's extrusion volume comes from its nozzle-to-belt clearance. Anchoring + // every line to a fixed fraction of its own band gives them all the same clearance, so + // they all come out the same width. The nominal-spacing lattice this replaced let each + // line land wherever it fell inside its band, so the clearance - and the width with it - + // varied by 2x, which showed up as visibly ragged brim. + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 5 }, + { "brim_object_gap", 0 }, + }); + Print print; + init_and_process_print({ cube(20) }, print, config); + const PrintObject *obj = print.objects().front(); + + std::vector widths; + auto collect = [&widths](const ExtrusionEntityCollection &coll) { + for (const ExtrusionEntity *ee : coll.entities) + if (const auto *path = dynamic_cast(ee)) + widths.push_back(path->width); + }; + for (const ExtrusionEntityCollection &band : obj->belt_brim_by_layer()) + collect(band); + for (const BeltBrimBand &band : obj->belt_brim_prologue()) + collect(band.fills); + + REQUIRE(widths.size() > 10); + const float lo = *std::min_element(widths.begin(), widths.end()); + const float hi = *std::max_element(widths.begin(), widths.end()); + CHECK_THAT(hi, Catch::Matchers::WithinRel(lo, 1e-4)); +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 7524c27479..b564a948d7 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -8,6 +8,7 @@ add_executable(${_TEST_NAME}_tests test_arachne_walls.cpp test_arrange.cpp test_bambu_networking.cpp + test_belt_brim.cpp test_calib.cpp test_clipper_offset.cpp test_clipper_utils.cpp diff --git a/tests/libslic3r/test_belt_brim.cpp b/tests/libslic3r/test_belt_brim.cpp new file mode 100644 index 0000000000..9f645817e9 --- /dev/null +++ b/tests/libslic3r/test_belt_brim.cpp @@ -0,0 +1,341 @@ +#include + +#include "libslic3r/BeltBrim.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/ExPolygon.hpp" + +using namespace Slic3r; + +// Pure-geometry tests for the belt brim. No Print, no slicing: everything here is +// a property of the tilted-belt mapping and the sweep/lattice helpers, which is +// exactly the part that has to be right before any G-code is worth looking at. + +static ExPolygon make_box(coord_t x0, coord_t y0, coord_t x1, coord_t y1) +{ + ExPolygon out; + out.contour.points = { Point(x0, y0), Point(x1, y0), Point(x1, y1), Point(x0, y1) }; + return out; +} + +static void add_hole(ExPolygon &ex, coord_t x0, coord_t y0, coord_t x1, coord_t y1) +{ + Polygon hole; + // Holes run clockwise, opposite the contour. + hole.points = { Point(x0, y0), Point(x0, y1), Point(x1, y1), Point(x1, y0) }; + ex.holes.emplace_back(std::move(hole)); +} + +SCENARIO("sweep_ex sweeps a box", "[BeltBrim]") { + const coord_t mm = scale_(1.); + GIVEN("a 10x10 mm box") { + const ExPolygons src { make_box(0, 0, 10 * mm, 10 * mm) }; + + WHEN("swept 5 mm along -Y") { + const ExPolygons out = sweep_ex(src, Point(0, -5 * mm)); + THEN("it becomes one 10x15 mm box") { + REQUIRE(out.size() == 1); + REQUIRE(out.front().holes.empty()); + const BoundingBox bb = get_extents(out); + CHECK(bb.min.y() == -5 * mm); + CHECK(bb.max.y() == 10 * mm); + CHECK(bb.min.x() == 0); + CHECK(bb.max.x() == 10 * mm); + CHECK_THAT(unscale(unscale(out.front().area())), + Catch::Matchers::WithinRel(150., 1e-6)); + } + } + + WHEN("swept by a zero vector") { + THEN("it is unchanged") { + const ExPolygons out = sweep_ex(src, Point(0, 0)); + REQUIRE(out.size() == 1); + CHECK(out.front().area() == src.front().area()); + } + } + + WHEN("swept diagonally") { + // For a convex P, area(P + [0,t]) == area(P) + |t| * width of P + // perpendicular to t. For a square swept along (1,1)/sqrt2 the + // perpendicular width is the diagonal, 10*sqrt2. + const ExPolygons out = sweep_ex(src, Point(3 * mm, 3 * mm)); + THEN("area grows by |t| times the perpendicular width") { + const double expected = 100. + std::sqrt(2. * 9.) * (10. * std::sqrt(2.)); + CHECK_THAT(unscale(unscale(out.front().area())), + Catch::Matchers::WithinRel(expected, 1e-6)); + } + } + } +} + +SCENARIO("sweep_ex closes holes narrower than the sweep", "[BeltBrim]") { + const coord_t mm = scale_(1.); + // This is the assertion that catches the two likeliest implementation bugs: + // omitting hole boundaries from the parallelogram set, or using the wrong + // Clipper fill rule. Note hole survival depends on the hole's extent ALONG + // the sweep direction, not on its narrowest dimension. + GIVEN("a 20x20 mm box with a hole 2 mm tall in the sweep direction") { + ExPolygon ex = make_box(0, 0, 20 * mm, 20 * mm); + add_hole(ex, 5 * mm, 9 * mm, 15 * mm, 11 * mm); + WHEN("swept 5 mm along -Y") { + const ExPolygons out = sweep_ex(ExPolygons{ ex }, Point(0, -5 * mm)); + THEN("the hole is filled in") { + REQUIRE(out.size() == 1); + CHECK(out.front().holes.empty()); + } + } + } + GIVEN("a 20x20 mm box with a hole 12 mm tall in the sweep direction") { + ExPolygon ex = make_box(0, 0, 20 * mm, 20 * mm); + add_hole(ex, 5 * mm, 4 * mm, 15 * mm, 16 * mm); + WHEN("swept 5 mm along -Y") { + const ExPolygons out = sweep_ex(ExPolygons{ ex }, Point(0, -5 * mm)); + THEN("the hole survives, shrunk by the sweep") { + REQUIRE(out.size() == 1); + REQUIRE(out.front().holes.size() == 1); + const BoundingBox hb = get_extents(out.front().holes.front()); + CHECK(hb.max.y() - hb.min.y() == 7 * mm); + } + } + } + GIVEN("a box whose edges are parallel to the sweep vector") { + // Degenerate parallelograms; Clipper must simply discard them. + const ExPolygons src { make_box(0, 0, 10 * mm, 10 * mm) }; + WHEN("swept along +X") { + const ExPolygons out = sweep_ex(src, Point(4 * mm, 0)); + THEN("the result is the expected rectangle") { + REQUIRE(out.size() == 1); + const BoundingBox bb = get_extents(out); + CHECK(bb.min.x() == 0); + CHECK(bb.max.x() == 14 * mm); + } + } + } + GIVEN("a reversed (clockwise) contour") { + ExPolygon ex = make_box(0, 0, 10 * mm, 10 * mm); + ex.contour.reverse(); + WHEN("swept") { + const ExPolygons out = sweep_ex(ExPolygons{ ex }, Point(0, -5 * mm)); + THEN("material is still produced") { + REQUIRE(! out.empty()); + CHECK(get_extents(out).min.y() == -5 * mm); + } + } + } +} + +SCENARIO("Belt flattening round-trips and rescales only the shear axis", "[BeltBrim]") { + const coord_t mm = scale_(1.); + const double shear = GENERATE(0.1, 0.5, 1.0, 3.0); + const int from_axis = GENERATE(0, 1); + DYNAMIC_SECTION("shear " << shear << " axis " << from_axis) { + const BeltBrimFrame frame { shear, from_axis }; + + // An L shape with a hole, so contours and holes are both exercised. + ExPolygon ex; + ex.contour.points = { Point(0, 0), Point(20 * mm, 0), Point(20 * mm, 6 * mm), + Point(6 * mm, 6 * mm), Point(6 * mm, 20 * mm), Point(0, 20 * mm) }; + add_hole(ex, 2 * mm, 2 * mm, 4 * mm, 4 * mm); + const ExPolygons src { ex }; + + const ExPolygons round_tripped = belt_unflatten(belt_flatten(src, frame), frame); + REQUIRE(round_tripped.size() == src.size()); + REQUIRE(round_tripped.front().holes.size() == src.front().holes.size()); + for (size_t c = 0; c < src.front().num_contours(); ++ c) { + const Points &a = src.front().contour_or_hole(c).points; + const Points &b = round_tripped.front().contour_or_hole(c).points; + REQUIRE(a.size() == b.size()); + for (size_t i = 0; i < a.size(); ++ i) { + // Rounding, not truncation, so the round trip stays within a + // couple of coordinate units. + CHECK(std::abs(a[i].x() - b[i].x()) <= 2); + CHECK(std::abs(a[i].y() - b[i].y()) <= 2); + // The axis that is not stretched must come back untouched. + if (from_axis == 0) + CHECK(a[i].y() == b[i].y()); + else + CHECK(a[i].x() == b[i].x()); + } + } + } +} + +SCENARIO("Flattening makes shear-axis distances true on-belt distances", "[BeltBrim]") { + // The property the whole design rests on: an in-plane distance w projects to + // dw = w * cos(tilt) along the shear axis, so stretching that axis by + // 1/cos(tilt) makes ordinary Clipper offsets measure real on-belt distance. + const coord_t mm = scale_(1.); + const double shear = GENERATE(0.1, 0.5, 1.0, 3.0); + const int from_axis = GENERATE(0, 1); + DYNAMIC_SECTION("shear " << shear << " axis " << from_axis) { + const BeltBrimFrame frame { shear, from_axis }; + const double stretch = std::sqrt(1. + shear * shear); + CHECK_THAT(frame.u_stretch(), Catch::Matchers::WithinRel(stretch, 1e-12)); + CHECK_THAT(frame.cos_tilt() * frame.u_stretch(), Catch::Matchers::WithinRel(1., 1e-12)); + + // Two points 1 mm apart along the shear axis are stretch mm apart once + // flattened. + ExPolygon seg = make_box(0, 0, 1 * mm, 1 * mm); + const BoundingBox flat = get_extents(belt_flatten(ExPolygons{ seg }, frame)); + const coord_t span_u = from_axis == 0 ? flat.max.x() - flat.min.x() + : flat.max.y() - flat.min.y(); + CHECK_THAT(unscale(span_u), Catch::Matchers::WithinRel(stretch, 1e-5)); + } +} + +SCENARIO("belt_brim_line_positions walks an exact lattice", "[BeltBrim]") { + const coord_t pitch = 420; // arbitrary units; the point is exactness + const coord_t anchor = 1000; + + GIVEN("a band narrower than the pitch containing no lattice point") { + // Between anchor+0 and anchor+pitch, pick a window that misses both. + const std::vector us = belt_brim_line_positions(anchor + 100, anchor + 300, pitch, anchor); + THEN("nothing is emitted") { CHECK(us.empty()); } + } + GIVEN("a band containing exactly one lattice point") { + const std::vector us = belt_brim_line_positions(anchor - 10, anchor + 10, pitch, anchor); + THEN("that point is emitted") { + REQUIRE(us.size() == 1); + CHECK(us.front() == anchor); + } + } + GIVEN("a wide band, as at a shallow belt tilt") { + const std::vector us = belt_brim_line_positions(anchor, anchor + 5 * pitch, pitch, anchor); + THEN("several lines are emitted at exactly the pitch") { + REQUIRE(us.size() == 5); + for (size_t i = 1; i < us.size(); ++ i) + CHECK(us[i] - us[i - 1] == pitch); + } + } + GIVEN("two adjacent bands sharing a boundary") { + // Half-open ownership: a lattice point landing on the shared bound belongs + // to the upper band only, so no line is duplicated or dropped. + const coord_t bound = anchor + 2 * pitch; + const std::vector lower = belt_brim_line_positions(anchor, bound, pitch, anchor); + const std::vector upper = belt_brim_line_positions(bound, bound + 2 * pitch, pitch, anchor); + THEN("the boundary point appears exactly once, in the upper band") { + CHECK(std::count(lower.begin(), lower.end(), bound) == 0); + CHECK(std::count(upper.begin(), upper.end(), bound) == 1); + CHECK(lower.size() == 2); + CHECK(upper.size() == 2); + } + } + GIVEN("a lattice anchored below zero") { + THEN("negative lattice points are handled") { + const std::vector us = belt_brim_line_positions(-3 * pitch, -pitch, pitch, 0); + REQUIRE(us.size() == 2); + CHECK(us.front() == -3 * pitch); + CHECK(us.back() == -2 * pitch); + } + } + GIVEN("a degenerate pitch or band") { + THEN("nothing is emitted rather than looping forever") { + CHECK(belt_brim_line_positions(0, 1000, 0, 0).empty()); + CHECK(belt_brim_line_positions(1000, 1000, pitch, 0).empty()); + CHECK(belt_brim_line_positions(1000, 500, pitch, 0).empty()); + } + } +} + +SCENARIO("belt_brim_region reduces to the plate brim without an apron", "[BeltBrim]") { + const coord_t mm = scale_(1.); + const BeltBrimFrame frame { 1.0, 1 }; + const ExPolygons footprint { make_box(0, 0, 20 * mm, 20 * mm) }; + const coord_t width = 3 * mm; + const coord_t gap = 1 * mm; + + GIVEN("outer brim, no apron") { + const ExPolygons region = belt_brim_region(footprint, true, false, width, gap, 0, 0, frame); + THEN("it matches the plate brim ring built from the same offsets") { + const ExPolygons inner = offset_ex(Polygons{ footprint.front().contour }, float(gap), jtRound, SCALED_RESOLUTION); + const ExPolygons outer = offset_ex(inner, float(width), jtRound, SCALED_RESOLUTION); + const ExPolygons expect = diff_ex(outer, inner); + // Not exact: the region is offset from the CLOSED footprint, and closing a + // single convex island is a geometric no-op but still round-trips every + // vertex through a dilate/erode, which perturbs the area in the 8th + // significant figure. + CHECK_THAT(unscale(unscale(area(region))), + Catch::Matchers::WithinRel(unscale(unscale(area(expect))), 1e-6)); + } + } + GIVEN("no outer and no inner brim") { + THEN("the region is empty") { + CHECK(belt_brim_region(footprint, false, false, width, gap, 5 * mm, 0, frame).empty()); + } + } + GIVEN("an apron but no brim width") { + const ExPolygons region = belt_brim_region(footprint, true, false, 0, 0, 5 * mm, 0, frame); + THEN("brim appears only downhill of the footprint") { + REQUIRE(! region.empty()); + const BoundingBox rb = get_extents(region); + // shear > 0 means downhill is -u, and from_axis 1 means u is Y. + CHECK(rb.min.y() < 0); + CHECK(rb.max.y() <= 0 + 2); // nothing above the footprint's own base + } + } +} + +SCENARIO("The apron follows the sign of the shear", "[BeltBrim]") { + // Guards the one sign convention that is easiest to get backwards: which way + // is downhill, i.e. which way the belt carries the part. + const coord_t mm = scale_(1.); + const ExPolygons footprint { make_box(0, 0, 20 * mm, 20 * mm) }; + + const ExPolygons pos = belt_brim_region(footprint, true, false, 0, 0, 5 * mm, 0, BeltBrimFrame{ 1.0, 1 }); + const ExPolygons neg = belt_brim_region(footprint, true, false, 0, 0, 5 * mm, 0, BeltBrimFrame{ -1.0, 1 }); + REQUIRE(! pos.empty()); + REQUIRE(! neg.empty()); + CHECK(get_extents(pos).min.y() < 0); + CHECK(get_extents(neg).max.y() > 20 * mm); +} + +SCENARIO("Outer belt brim does not fill the space between contact islands", "[BeltBrim]") { + // A belt contact patch is often a narrow, broken-up strip. Offsetting each island + // outwards by brim_width merges the rings of any two islands closer than + // 2 x brim_width and fills the space between them - and on a belt that space is + // UNDERNEATH the part, which is not what "outer brim only" means. Closing the + // footprint before the outward offset is what prevents it. + const coord_t mm = scale_(1.); + const BeltBrimFrame frame { 1.0, 1 }; + const coord_t width = 3 * mm; + + GIVEN("two contact islands 4 mm apart, closer than 2 x brim width") { + const ExPolygons footprint { + make_box(0, 0, 10 * mm, 10 * mm), + make_box(14 * mm, 0, 24 * mm, 10 * mm), + }; + const ExPolygons region = belt_brim_region(footprint, true, false, width, 0, 0, 0, frame); + THEN("no brim is placed in the gap between them") { + REQUIRE(! region.empty()); + // Midpoint of the gap, and a point just inside either edge of it. + for (const coord_t x : { 12 * mm, coord_t(10.5 * mm), coord_t(13.5 * mm) }) { + const Point probe(x, 5 * mm); + bool covered = false; + for (const ExPolygon &ex : region) + if (ex.contains(probe)) { covered = true; break; } + CHECK(! covered); + } + } + THEN("brim is still placed outside the pair") { + bool outside_covered = false; + const Point probe(-1 * mm, 5 * mm); // 1 mm left of the left island + for (const ExPolygon &ex : region) + if (ex.contains(probe)) { outside_covered = true; break; } + CHECK(outside_covered); + } + } + + GIVEN("an apron on a fragmented footprint") { + const ExPolygons footprint { + make_box(0, 0, 10 * mm, 10 * mm), + make_box(14 * mm, 0, 24 * mm, 10 * mm), + }; + // Closing fills concavities only, so an outward protrusion such as the apron must + // survive it untouched. + const ExPolygons region = belt_brim_region(footprint, true, false, width, 0, 5 * mm, 0, frame); + THEN("the apron still reaches downhill") { + REQUIRE(! region.empty()); + CHECK(get_extents(region).min.y() <= -5 * mm); + } + } +}