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); + } + } +}