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