diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml index ab4cd57f5f..b849979de0 100644 --- a/.github/workflows/build_all.yml +++ b/.github/workflows/build_all.yml @@ -5,7 +5,6 @@ on: branches: - main - release/* - - belt-printer paths: - 'deps/**' - 'src/**' @@ -262,9 +261,6 @@ jobs: date: ver: ver_pure: - # Belt-printer nightlies share the main nightly release but carry a `_belt` - # suffix so they never overwrite the main assets. - nightly_suffix: ${{ github.ref == 'refs/heads/belt-printer' && '_belt' || '' }} steps: - name: "Remove unneeded stuff to free disk space" run: @@ -439,13 +435,13 @@ jobs: name: OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak path: '/__w/OrcaSlicer/OrcaSlicer/OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak' - name: Deploy Flatpak to nightly release - if: github.repository == 'OrcaSlicer/OrcaSlicer' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/belt-printer') + if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' uses: WebFreak001/deploy-nightly@v3.2.0 with: upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} release_id: 137995723 asset_path: /__w/OrcaSlicer/OrcaSlicer/OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak - asset_name: OrcaSlicer-Linux-flatpak_nightly${{ env.nightly_suffix }}_${{ matrix.variant.arch }}.flatpak + asset_name: OrcaSlicer-Linux-flatpak_nightly_${{ matrix.variant.arch }}.flatpak asset_content_type: application/octet-stream max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted # The asset is /app (the exes link it at runtime) plus the build tree diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index 7b001a8c48..7115efab09 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -33,11 +33,6 @@ jobs: ubuntu-ver: '2404' ubuntu-ver-str: '_Ubuntu2404' ORCA_UPDATER_SIG_KEY: ${{ secrets.ORCA_UPDATER_SIG_KEY }} - # Branches whose builds are published to the nightly release. The - # belt-printer branch ships alongside main but its assets carry a `_belt` - # suffix (nightly_suffix) so they never overwrite the main nightly assets. - deploy_nightly: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/belt-printer' }} - nightly_suffix: ${{ github.ref == 'refs/heads/belt-printer' && '_belt' || '' }} steps: - name: Checkout @@ -260,7 +255,7 @@ jobs: # Thanks to RaySajuuk, it's working now - name: Sign app and notary - if: github.repository == 'OrcaSlicer/OrcaSlicer' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/belt-printer' || startsWith(github.ref, 'refs/heads/release/')) && runner.os == 'macOS' && inputs.macos-combine-only + if: github.repository == 'OrcaSlicer/OrcaSlicer' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) && runner.os == 'macOS' && inputs.macos-combine-only timeout-minutes: 30 working-directory: ${{ github.workspace }} env: @@ -348,7 +343,7 @@ jobs: fi - name: Create DMG without notary - if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/belt-printer' && runner.os == 'macOS' && inputs.macos-combine-only + if: github.ref != 'refs/heads/main' && runner.os == 'macOS' && inputs.macos-combine-only working-directory: ${{ github.workspace }} run: | # Load the `retry` helper (retries flaky commands such as `hdiutil create`). @@ -394,13 +389,13 @@ jobs: if-no-files-found: ignore - name: Deploy Mac release - if: github.repository == 'OrcaSlicer/OrcaSlicer' && env.deploy_nightly == 'true' && runner.os == 'macOS' && inputs.macos-combine-only && !vars.SELF_HOSTED + if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'macOS' && inputs.macos-combine-only && !vars.SELF_HOSTED uses: WebFreak001/deploy-nightly@v3.2.0 with: upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} release_id: 137995723 asset_path: ${{ github.workspace }}/OrcaSlicer_Mac_universal_${{ env.ver }}.dmg - asset_name: OrcaSlicer_Mac_universal_nightly${{ env.nightly_suffix }}.dmg + asset_name: OrcaSlicer_Mac_universal_nightly.dmg asset_content_type: application/octet-stream max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted @@ -543,24 +538,24 @@ jobs: path: ${{ github.workspace }}/build/src/Release/OrcaSlicer_profile_validator.exe - name: Deploy Windows release portable - if: github.repository == 'OrcaSlicer/OrcaSlicer' && env.deploy_nightly == 'true' && runner.os == 'Windows' && !vars.SELF_HOSTED + if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'Windows' && !vars.SELF_HOSTED uses: WebFreak001/deploy-nightly@v3.2.0 with: upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} release_id: 137995723 asset_path: ${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer_Windows_${{ env.ver }}${{ env.ARCH_SUFFIX }}_portable.zip - asset_name: OrcaSlicer_Windows${{ env.ARCH_SUFFIX }}_nightly${{ env.nightly_suffix }}_portable.zip + asset_name: OrcaSlicer_Windows${{ env.ARCH_SUFFIX }}_nightly_portable.zip asset_content_type: application/x-zip-compressed max_releases: 1 - name: Deploy Windows release installer - if: github.repository == 'OrcaSlicer/OrcaSlicer' && env.deploy_nightly == 'true' && runner.os == 'Windows' && !vars.SELF_HOSTED + if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' && runner.os == 'Windows' && !vars.SELF_HOSTED uses: WebFreak001/deploy-nightly@v3.2.0 with: upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} release_id: 137995723 asset_path: ${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer_Windows_Installer_${{ env.ver }}${{ env.ARCH_SUFFIX }}.exe - asset_name: OrcaSlicer_Windows_Installer${{ env.ARCH_SUFFIX }}_nightly${{ env.nightly_suffix }}.exe + asset_name: OrcaSlicer_Windows_Installer${{ env.ARCH_SUFFIX }}_nightly.exe asset_content_type: application/x-msdownload max_releases: 1 @@ -701,13 +696,13 @@ jobs: path: './build/src/dev-utils/Release/generate_system_cache' - name: Deploy Ubuntu release - if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && env.deploy_nightly == 'true' && runner.os == 'Linux' && !vars.SELF_HOSTED }} + if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' && ! env.ACT && github.ref == 'refs/heads/main' && runner.os == 'Linux' && !vars.SELF_HOSTED }} uses: WebFreak001/deploy-nightly@v3.2.0 with: upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} release_id: 137995723 asset_path: ./build/OrcaSlicer_Linux_AppImage${{ env.ubuntu-ver-str }}${{ env.arch_suffix }}_${{ env.ver }}.AppImage - asset_name: OrcaSlicer_Linux_AppImage${{ env.ubuntu-ver-str }}${{ env.arch_suffix }}_nightly${{ env.nightly_suffix }}.AppImage + asset_name: OrcaSlicer_Linux_AppImage${{ env.ubuntu-ver-str }}${{ env.arch_suffix }}_nightly.AppImage asset_content_type: application/octet-stream max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted - name: Deploy Ubuntu release diff --git a/.github/workflows/check_profiles.yml b/.github/workflows/check_profiles.yml index 2a6ed8f0a4..422513442e 100644 --- a/.github/workflows/check_profiles.yml +++ b/.github/workflows/check_profiles.yml @@ -74,12 +74,26 @@ jobs: set +e ./OrcaSlicer_profile_validator -p ${{ github.workspace }}/resources/profiles -l 2 2>&1 | tee ${{ runner.temp }}/validate_system.log exit ${PIPESTATUS[0]} + # The validator above is the nightly build of main, so it cannot slice profiles that use + # settings a PR adds to the engine: it reports their placeholders as undefined. A PR that + # changes src/ also runs Build all, whose Slice check runs this same sweep with the + # validator built from the PR, so the sweep below only runs for the other PRs. + - name: Detect engine changes + id: engine_changes + if: ${{ github.event_name == 'pull_request' }} + run: | + base=${{ github.event.pull_request.base.sha }} + if git fetch --no-tags --depth=1 origin "$base" && ! git diff --quiet "$base" HEAD -- src/; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "::notice::This PR changes src/, so Build all's Slice check slices the profiles with the PR-built validator." + fi # Slice a two-colour cube through every printer, and through every system process/filament whose # templates no printer's own slice reaches, so every custom g-code and filename_format shipped is # expanded (names in {if} branches not taken included) - catches undefined-placeholder / # invalid-flow bugs the static checks above cannot see. - name: validate slice (expand custom g-code) id: validate_slice + if: ${{ steps.engine_changes.outputs.changed != 'true' }} continue-on-error: true run: | set +e diff --git a/README.md b/README.md index 40ba7637b9..408efb57b7 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ If you come across any of these in search results, please report them as Regular updates fueled by continuous community contributions. - **Wide Printer Compatibility** Supports a broad range of printers: Bambu Lab, Prusa, Creality, Voron, and more. +- **[Belt Printer Support](https://www.orcaslicer.com/wiki/belt_printing)** + Slice for belt / conveyor (infinite-Z) printers, with belt-aware supports and a tilted-bed preview. Contributed by [Joseph Robertson (@HarrierPigeon)](https://github.com/HarrierPigeon). - Additional features can be found in the [change notes](https://github.com/OrcaSlicer/OrcaSlicer/releases/). # Wiki @@ -89,17 +91,6 @@ Visit our GitHub Releases page for the latest stable version of OrcaSlicer, reco 🌙 **[Download the Latest Nightly Build](https://github.com/OrcaSlicer/OrcaSlicer/releases/tag/nightly-builds)** Explore the latest developments in OrcaSlicer with our nightly builds. Feedback on these versions is highly appreciated. -### Belt Printer Builds - -The [nightly release](https://github.com/OrcaSlicer/OrcaSlicer/releases/tag/nightly-builds) ships **two parallel builds**: the standard build and a belt-printer build. Both are attached to the same release — tell them apart by the filename suffix: - -- **Standard** — no suffix (e.g. `OrcaSlicer_Windows_Installer_x64_nightly.exe`) -- **Belt** — `_belt` suffix (e.g. `OrcaSlicer_Windows_Installer_x64_nightly_belt.exe`) - -The `_belt` builds add **experimental support for belt / conveyor (infinite-Z) printers**, where the model is sliced against a tilted belt surface instead of a flat horizontal bed. They include ready-to-use belt printer profiles, the full belt slicing pipeline (mesh rotation and G-code transforms), belt-aware support generation, and a tilted-bed preview. - -> ⚠️ Belt printer support is under active development and is **not yet merged into `main`** — it currently ships only in these parallel `_belt` builds, produced from the [`belt-printer`](https://github.com/OrcaSlicer/OrcaSlicer/tree/belt-printer) branch. See tracking PR [#14394](https://github.com/OrcaSlicer/OrcaSlicer/pull/14394) and the original documentation in [#12998](https://github.com/OrcaSlicer/OrcaSlicer/pull/12998). - # How to install ## Windows diff --git a/deps_src/libnest2d/include/libnest2d/placers/nfpplacer.hpp b/deps_src/libnest2d/include/libnest2d/placers/nfpplacer.hpp index dc7733ebd6..62d231e375 100644 --- a/deps_src/libnest2d/include/libnest2d/placers/nfpplacer.hpp +++ b/deps_src/libnest2d/include/libnest2d/placers/nfpplacer.hpp @@ -88,6 +88,18 @@ struct NfpPConfig { */ bool explore_holes = false; + /** + * @brief Keep the final pile on the bin. + * + * The final alignment centres the pile on the alignment target. A target + * near an edge (a belt printer starts its parts at the leading end of the + * belt) would push part of a pile that is larger than the room around that + * point off the bed; with this set the pile stops at the edge instead, and a + * pile that does not fit along an axis is centred on it. Off by default, so + * the alignment of every other printer is unchanged. + */ + bool clamp_to_bin = false; + /** * @brief If true, use all CPUs available. Run on a single core otherwise. */ @@ -1111,7 +1123,24 @@ private: default: ; // DONT_ALIGN } - auto d = cb - ci; + auto d = cb - ci; + + // Keep the pile on the bin (see Config::clamp_to_bin). The items' boxes carry + // their inflation, which is the margin left at the edge. + if (config_.clamp_to_bin) { + auto on_bin = [](Coord lo, Coord hi, Coord bin_lo, Coord bin_hi, Coord shift) { + if (hi - lo >= bin_hi - bin_lo) + return (bin_lo + bin_hi) / 2 - (lo + hi) / 2; + if (lo + shift < bin_lo) + shift = bin_lo - lo; + if (hi + shift > bin_hi) + shift = bin_hi - hi; + return shift; + }; + setX(d, on_bin(getX(bb.minCorner()), getX(bb.maxCorner()), getX(bbin.minCorner()), getX(bbin.maxCorner()), getX(d))); + setY(d, on_bin(getY(bb.minCorner()), getY(bb.maxCorner()), getY(bbin.minCorner()), getY(bbin.maxCorner()), getY(d))); + cb = ci + d; + } // BBS make sure the item won't clash with excluded regions // do we have wipe tower after arranging? diff --git a/resources/calib/temperature_tower/belt_temp_provino_unit.stl b/resources/calib/temperature_tower/belt_temp_provino_unit.stl new file mode 100644 index 0000000000..556cf313d2 Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_provino_unit.stl differ diff --git a/resources/calib/temperature_tower/belt_temp_tower_230_190.stl b/resources/calib/temperature_tower/belt_temp_tower_230_190.stl new file mode 100644 index 0000000000..0496fd1522 Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_tower_230_190.stl differ diff --git a/resources/calib/temperature_tower/belt_temp_tower_240_210.stl b/resources/calib/temperature_tower/belt_temp_tower_240_210.stl new file mode 100644 index 0000000000..31801c1744 Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_tower_240_210.stl differ diff --git a/resources/calib/temperature_tower/belt_temp_tower_250_230.stl b/resources/calib/temperature_tower/belt_temp_tower_250_230.stl new file mode 100644 index 0000000000..b03f057884 Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_tower_250_230.stl differ diff --git a/resources/calib/temperature_tower/belt_temp_tower_270_230.stl b/resources/calib/temperature_tower/belt_temp_tower_270_230.stl new file mode 100644 index 0000000000..7a0bfbd1da Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_tower_270_230.stl differ diff --git a/resources/calib/temperature_tower/belt_temp_tower_280_240.stl b/resources/calib/temperature_tower/belt_temp_tower_280_240.stl new file mode 100644 index 0000000000..1276d5ce04 Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_tower_280_240.stl differ diff --git a/resources/calib/temperature_tower/belt_temp_tower_320_280.stl b/resources/calib/temperature_tower/belt_temp_tower_320_280.stl new file mode 100644 index 0000000000..957e038067 Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_tower_320_280.stl differ diff --git a/resources/calib/temperature_tower/gen_belt_temp_tower.py b/resources/calib/temperature_tower/gen_belt_temp_tower.py new file mode 100644 index 0000000000..9182647acb --- /dev/null +++ b/resources/calib/temperature_tower/gen_belt_temp_tower.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Belt temperature-tower asset generator (discrete-provini design). + +A vertical temperature tower cannot be sliced on a belt printer, so lay a row of +DISCRETE provini (one per temperature) along the belt (designed Y) with a fixed +surface gap. Each provino is the chevron+arc unit (belt_temp_provino_unit.stl, +keel-first); its temperature is ENGRAVED upright into the 50 mm face — a raised +number would be an unsupported overhang on the belt. The C++ calib_temp belt branch +(Plater.cpp) injects one M104 per zone 70 layers INTO provino i: + print_z[i] = i * PITCH * cos(theta) + 70 * layer_height (theta = 45) +inside the body, not in the empty inter-provino gap (which has no sliced layers for +the event to attach to). PITCH below is the shared geometry contract with that code — +keep them in sync. + +Generates one STL per filament temp range used by Temp_Calibration_Dlg. +""" +import numpy as np, trimesh, os +from matplotlib.textpath import TextPath +from matplotlib.font_manager import FontProperties +from shapely.geometry import Polygon as ShPoly +from shapely.ops import unary_union + +HERE = os.path.dirname(os.path.abspath(__file__)) +UNIT = os.path.join(HERE, 'belt_temp_provino_unit.stl') # single provino, keel-first +SURF_GAP = 25.0 # surface-to-surface gap between provini (mm) — user spec +TEXT_H = 9.0 +TEXT_DEPTH = 0.8 # engraving depth (numbers are CUT into the face, not raised: + # a raised number is an unsupported Y-overhang on the belt) +TEXT_OVERSHOOT = 0.6 # extra height poking out of the face for a clean boolean cut + +# Temperature ranges (start, end) per filament family, 5 C step. File name encodes them. +RANGES = [(230,190),(270,230),(250,230),(280,240),(240,210),(320,280)] + +unit = trimesh.load(UNIT) +dY = unit.bounds[1,1] - unit.bounds[0,1] +PITCH = dY + SURF_GAP # designed-Y pitch == C++ contract constant +print(f"unit dY={dY:.2f} PITCH={PITCH:.3f} (C++ contract: print_z[i]=i*{PITCH:.3f}*cos45)") + +# 50 mm face normal (0,-1,1)/sqrt2 ; UPRIGHT basis u=+X det(+1) (verified non-mirrored) +n = np.array([0,-1,1.])/np.sqrt(2) +u = np.array([1,0,0.]); v = np.array([0,1,1.])/np.sqrt(2) +R = np.column_stack([u,v,n]) +fn = unit.face_normals; fc = unit.triangles_center; fa = unit.area_faces +sel = (fn@n) > 0.9 +face_c = (fc[sel]*fa[sel,None]).sum(0)/fa[sel].sum() + +def text_mesh(s): + tp = TextPath((0,0), s, size=TEXT_H, prop=FontProperties(family='DejaVu Sans')) + rings = [ShPoly(p) for p in tp.to_polygons() if len(p)>=3] + rings.sort(key=lambda r:r.area, reverse=True) + used=[False]*len(rings); parts=[] + for i,o in enumerate(rings): + if used[i]: continue + holes=[] + for j in range(i+1,len(rings)): + if not used[j] and o.contains(rings[j]): holes.append(rings[j].exterior.coords); used[j]=True + parts.append(ShPoly(o.exterior.coords,holes)); used[i]=True + poly = unary_union(parts) + geoms = list(poly.geoms) if poly.geom_type=='MultiPolygon' else [poly] + m = trimesh.util.concatenate([trimesh.creation.extrude_polygon(g,height=TEXT_DEPTH+TEXT_OVERSHOOT) for g in geoms]) + c = m.bounds.mean(axis=0); m.apply_translation([-c[0],-c[1],0]); return m + +for t_start, t_end in RANGES: + temps = list(range(t_start, t_end-1, -5)) + parts=[] + for i,T in enumerate(temps): + c = unit.copy(); c.apply_translation([0, i*PITCH, 0]) + t = text_mesh(str(T)); M=np.eye(4); M[:3,:3]=R; t.apply_transform(M) + # place the text spanning from TEXT_DEPTH inside the face to TEXT_OVERSHOOT outside, + # then CUT it out of the provino (engrave) — no raised material, no Y-overhang. + t.apply_translation(face_c - n*TEXT_DEPTH + np.array([0,i*PITCH,0])) + c = trimesh.boolean.difference([c, t], engine='manifold') + parts.append(c) + asset = trimesh.util.concatenate(parts) + out = os.path.join(HERE, f"belt_temp_tower_{t_start}_{t_end}.stl") + asset.export(out) + dims = np.round(asset.bounds[1]-asset.bounds[0],1) + wt = all(p.is_watertight for p in parts) + print(f" {t_start}->{t_end}: {len(temps)} zones bbox={dims} watertight={wt} -> {os.path.basename(out)}") diff --git a/resources/images/delete_all_filaments.svg b/resources/images/delete_all_filaments.svg new file mode 100644 index 0000000000..e2f3641300 --- /dev/null +++ b/resources/images/delete_all_filaments.svg @@ -0,0 +1 @@ + diff --git a/resources/images/toolbar_tooltip.svg b/resources/images/toolbar_tooltip.svg index 96c5684cb0..49279b8ed9 100644 --- a/resources/images/toolbar_tooltip.svg +++ b/resources/images/toolbar_tooltip.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/resources/images/toolbar_tooltip_hover.svg b/resources/images/toolbar_tooltip_hover.svg deleted file mode 100644 index c8d606829e..0000000000 --- a/resources/images/toolbar_tooltip_hover.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/images/toolbar_video_guide.svg b/resources/images/toolbar_video_guide.svg new file mode 100644 index 0000000000..b39e485af2 --- /dev/null +++ b/resources/images/toolbar_video_guide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/images/toolbar_wiki.svg b/resources/images/toolbar_wiki.svg new file mode 100644 index 0000000000..b10b1dcb17 --- /dev/null +++ b/resources/images/toolbar_wiki.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/profiles/BBL/machine/Bambu Lab P2S 0.4 nozzle template machine_start_gcode.json b/resources/profiles/BBL/machine/Bambu Lab P2S 0.4 nozzle template machine_start_gcode.json index a7dee998fb..5abab59c71 100644 --- a/resources/profiles/BBL/machine/Bambu Lab P2S 0.4 nozzle template machine_start_gcode.json +++ b/resources/profiles/BBL/machine/Bambu Lab P2S 0.4 nozzle template machine_start_gcode.json @@ -2,5 +2,5 @@ "type": "machine", "name": "Bambu Lab P2S 0.4 nozzle template machine_start_gcode", "instantiation": "false", - "machine_start_gcode": ";M1002 set_flag extrude_cali_flag=1\n;M1002 set_flag g29_before_print_flag=1\n;M1002 set_flag auto_cali_toolhead_offset_flag=1\n;M1002 set_flag build_plate_detect_flag=1\n\n;======== P2S start gcode==========\n;===== 2026/05/18 =====\n\n M140 S[bed_temperature_initial_layer_single] ; heat heatbed first\n M993 A0 B0 C0 ; nozzle cam detection not allowed.\n M400\n\n;=====printer start sound ===================\nM17\nM400 S1\nM1006 S1\nM1006 A53 B9 L50 C53 D9 M50 E53 F9 N50\nM1006 A56 B9 L50 C56 D9 M50 E56 F9 N50\nM1006 A61 B9 L50 C61 D9 M50 E61 F9 N50\nM1006 A53 B9 L50 C53 D9 M50 E53 F9 N50\nM1006 A56 B9 L50 C56 D9 M50 E56 F9 N50\nM1006 A61 B18 L50 C61 D18 M50 E61 F18 N50\nM1006 W\n;=====printer start sound ===================\n\n M620 M ;enable remap\n G389\n\n;===== avoid end stop =================\n G91\n G380 S2 Z22 F1200\n G380 S2 Z-12 F1200\n G90\n;===== avoid end stop =================\n\n;===== reset machine status =================\n M204 S10000\n M630 S0 P1\n G90\n M17 D ; reset motor current to default\n M960 S5 P1 ; turn on logo lamp\n G90\n M220 S100 ;Reset Feedrate\n M1002 set_gcode_claim_speed_level: 5\n M221 S100 ;Reset Flowrate\n M73.2 R1.0 ;Reset left time magnitude\n G29.1 Z{+0.0} ; clear z-trim value first\n M983.1 M1\n M982.2 S1 ; turn on cog noise reduction\n M983.4 S0\n;===== reset machine status =================\n\n;==== set airduct mode ====\n;==== if Chamber Cooling is necessary ====\n{if (overall_chamber_temperature >= 40)}\nM145 P1 ; set airduct mode to heating mode for heating\nM106 P2 S255 ; turn on filter fan\nM622.1 S0\nM1002 judge_flag ventobox_replace_aux1_fan_flag\nM622 J0\nM106 P10 S0 ; turn off left aux fan\nM623\n{else}\n{if (min_vitrification_temperature <= 50)}\nM145 P0 ; set airduct mode to cooling mode for cooling\nM106 P2 S255 ; turn on auxiliary fan for cooling\nM106 P3 S127 ; turn on chamber fan for cooling\nM1002 gcode_claim_action : 29\nM191 S0 ; wait for chamber temp\nM106 P2 S102 ; turn on chamber cooling fan\nM622.1 S0\nM1002 judge_flag ventobox_replace_aux1_fan_flag\nM622 J0\nM106 P10 S0 ; turn off left aux fan\nM623\nM142 P6 R30 S40 U0.3 V0.8 ; set PETG exhaust chamber autocooling\n{else}\nM145 P1 ; set airduct mode to heating mode for heating\nM106 P2 S127 ; turn on 50% filter fan\nM142 P6 R30 S40 U0.3 V0.8 ; set PLA/TPU exhaust chamber autocooling\n{endif}\n{endif}\n;==== set airduct mode ====\n\n;===== start to heat heatbed & hotend==========\n M1002 gcode_claim_action : 2\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M104 S140 A\n\n G29.2 S0 ; avoid invalid abl data\n\n;===== first homing start =====\n M1002 gcode_claim_action : 13\n G28 X T300\n G150.1 F8000 ; wipe mouth to avoid filament stick to heatbed\n G150.3\n M972 S24 P0\n M972 S26 P0 C0\n M972 S42 P0 T5000\n G150.1 F8000 ; wipe mouth to avoid filament stick to heatbed\n G90\n G1 X128 Y128 F30000\n G28 Z P0 T400\n M400\n;===== first homign end =====\n\n;===== detection start =====\n M1002 gcode_claim_action : 11\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]-80} A ; rise temp in advance\n M972 S19 P0 T5000 ;plate type detection\n\n {if max_print_z >= 145}\n M1002 gcode_claim_action : 75 ; Detect obstacles at the botton of the heated bed\n G150.3\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]} ; rise temp in advance\n G3811 Z{max_print_z} ; Detect obstacles at the bottom of the heated bed\n {endif}\n;===== detection end =====\n\n;===== prepare print temperature and material ==========\n M400\n M211 X0 Y0 Z0 ;turn off soft endstop\n M975 S1 ; turn on input shaping\n\n G29.2 S0 ; avoid invalid abl data\n G150.3\n{if ((filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PLA-CF\") || (filament_type[initial_no_support_filament_id] == \"PETG\")) && (nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2)}\nM620.10 A0 F74.8347 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\nM620.10 A1 F74.8347 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\n{else}\nM620.10 A0 F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60} H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\nM620.10 A1 F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60} H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\n{endif}\n\n M620.11 P0 L0 I[initial_no_support_filament_id] E0\n M620.11 K0 I[initial_no_support_filament_id] R0\n\n M620 S[initial_no_support_filament_id]A ; switch material if AMS exist\n M1002 gcode_claim_action : 4\n M1002 set_filament_type:UNKNOWN\n M400\n T[initial_no_support_filament_id]\n M400\n M628 S0\n M629\n M400\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M621 S[initial_no_support_filament_id]A\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M400\n M106 P1 S0\n M400\n G29.2 S1\n;===== prepare print temperature and material ==========\n\n\n;===== auto extrude cali start =========================\n M975 S1\n M1002 judge_flag extrude_cali_flag\n M622 J0\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n M623\n\n M622 J1\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 8\n M109 S{nozzle_temperature[initial_no_support_filament_id]}\n G90\n M83\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n M400\n M106 P1 S255\n M400 S5\n M106 P1 S0\n G150.3\n M623\n\n M622 J2\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 8\n M109 S{nozzle_temperature[initial_no_support_filament_id]}\n G90\n M83\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n M400\n M106 P1 S255\n M400 S5\n M106 P1 S0\n G150.3\n M623\n;===== auto extrude cali end =========================\n\n {if hold_chamber_temp_for_flat_print}\n M1002 gcode_claim_action : 58\n M104 S{first_layer_temperature[initial_no_support_filament_id]}\n {if bed_temperature_initial_layer_single > 89}\n M1030 S1800\n SYNC R0 T1800\n {else}\n M1030 S300\n SYNC R0 T300\n {endif}\n M1030 C\n {endif}\n\n {if filament_type[initial_filament_id] == \"TPU\" || filament_type[initial_filament_id] == \"PVA\"}\n {else}\n M83\n G1 E-3 F1800\n M400 P500\n {endif}\n G150.2\n G150.1 F8000\n G150.2\n G150.1 F8000\n\n G91\n G1 Y-16 F12000 ; move away from the trash bin\n G90\n M400\n\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]-80} A\n\n;===== wipe right nozzle start =====\n M1002 gcode_claim_action : 14\n G150 T{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M400\n\n{if filament_type[initial_filament_id] == \"PC\"}\n M109 S170 A\n{else}\n M109 S140 A\n{endif}\n G91\n G1 Z5 F1200\n G90\n M400\n G150.1\n;===== wipe left nozzle end =====\n\n\n;===== mech mode sweep start =====\n M1002 gcode_claim_action : 3\n G90\n G1 X128 Y128 F20000\n G1 Z5 F1200\n M400 P200\n M970.3 Q1 A5 K0 O1\n M970.2 Q1 K1 W74 Z0.01\n M974 Q1 S2 P0\n M970.3 Q0 A7 K0 O1\n M970.2 Q0 K1 W74 Z0.01\n M974 Q0 S2 P0\n M975 S1\n M400\n;===== mech mode sweep end =====\n\n;===== bed leveling ==================================\n M1002 gcode_claim_action : 54\n M190 S[bed_temperature_initial_layer_single]; ensure bed temp\n M109 S140 A\n M106 S0 ; turn off fan , too noisy\n M1002 judge_flag g29_before_print_flag\n M622 J1\n M1002 gcode_claim_action : 1\n {if hold_chamber_temp_for_flat_print}\n G29 H\n {else}\n G29 A1 X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]}\n {endif}\n M400\n M623\n\n M622 J2\n M1002 gcode_claim_action : 1\n {if hold_chamber_temp_for_flat_print}\n G29 H\n {else}\n G29 A2 X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]}\n {endif}\n M400\n M623\n\n M622 J0\n G28\n M623\n G29.2 S1\n G28\n;===== bed leveling end ================================\n\n M985.1 U0 E2\n M985.1 U1 E2\n\n M104 S{nozzle_temperature_initial_layer[initial_filament_id]} A\n G150.3 ; move to garbage can to wait for temp\n\n;===== wait temperature reaching the reference value =======\n M190 S[bed_temperature_initial_layer_single]\n\n ;========turn off light and fans =============\n M960 S1 P0 ; turn off laser\n M960 S2 P0 ; turn off laser\n M106 S0 ; turn off cooling fan\n\n;===== wait temperature reaching the reference value =======\n\n M1002 gcode_claim_action : 255\n M400\n M975 S1 ; turn on mech mode supression\n\n;============switch again==================\n M211 X0 Y0 Z0 ;turn off soft endstop\n G91\n G1 Z6 F1200\n G90\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M620 S[initial_no_support_filament_id]A\n M400\n T[initial_no_support_filament_id]\n M400\n M628 S0\n M629\n M400\n M621 S[initial_no_support_filament_id]A\n;============switch again==================\n\n;===== for Textured PEI Plate , lower the nozzle as the nozzle was touching topmost of the texture when homing ==\n {if bed_temperature_initial_layer_single > 89}\n {if curr_bed_type==\"Textured PEI Plate\"}\n G29.1 Z{-0.02} ; for Textured PEI Plate\n {else}\n G29.1 Z{0.0}\n {endif}\n {else}\n {if curr_bed_type==\"Textured PEI Plate\"}\n G29.1 Z{0.01} ; for Textured PEI Plate\n {else}\n G29.1 Z{0.03}\n {endif}\n {endif}\n\n\n;===== nozzle load line ===============================\nM1002 gcode_claim_action : 51\n G29.2 S1 ; ensure z comp turn on\n G90\n M83\n M400 P50\n M500 D1\n M400 S3\n M109 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n G0 X100 Y0 F24000\n M400\n ;G130 O0 X100 Y-0.4 Z0.8 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2/2.4053} L40 E20 D5\n G130 O0 X100 Y-0.2 Z0.6 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2/2.4053} L40 E12 D4\n G90\n M83\n G1 Z1\n M400\n;===== noozle load line end ===========================\nM1002 gcode_claim_action : 0\n G29.99\n\n{if (filament_type[initial_no_support_filament_id] == \"TPU\") ||\n(filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PETG\")}\nM1015.3 S1 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]};enable tpu, pla and petg clog detect\n{else}\nM1015.3 S0;disable clog detect\n{endif}\n\n{if (filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PETG\")\n || (filament_type[initial_no_support_filament_id] == \"PLA-CF\") || (filament_type[initial_no_support_filament_id] == \"PETG-CF\")}\nM1015.4 S1 K1 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} ;enable E air printing detect\n{else}\nM1015.4 S0 K0 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} ;disable E air printing detect\n{endif}\n\nM620.6 I[initial_no_support_filament_id] W1 ;enable ams air printing detect\n\nM1010 Q0 B0.023 S0.01\nM1010 Q1 B0.005 S0.01\nM1010.1 S1\n" + "machine_start_gcode": ";M1002 set_flag extrude_cali_flag=1\n;M1002 set_flag g29_before_print_flag=1\n;M1002 set_flag auto_cali_toolhead_offset_flag=1\n;M1002 set_flag build_plate_detect_flag=1\n\n;======== P2S start gcode==========\n;===== Based on official 2026/05/18 start gcode =====\n\n M140 S[bed_temperature_initial_layer_single] ; heat heatbed first\n M993 A0 B0 C0 ; nozzle cam detection not allowed.\n M400\n\n;=====printer start sound ===================\nM17\nM400 S1\nM1006 S1\nM1006 A53 B9 L50 C53 D9 M50 E53 F9 N50\nM1006 A56 B9 L50 C56 D9 M50 E56 F9 N50\nM1006 A61 B9 L50 C61 D9 M50 E61 F9 N50\nM1006 A53 B9 L50 C53 D9 M50 E53 F9 N50\nM1006 A56 B9 L50 C56 D9 M50 E56 F9 N50\nM1006 A61 B18 L50 C61 D18 M50 E61 F18 N50\nM1006 W\n;=====printer start sound ===================\n\n M620 M ;enable remap\n G389\n\n;===== avoid end stop =================\n G91\n G380 S2 Z22 F1200\n G380 S2 Z-12 F1200\n G90\n;===== avoid end stop =================\n\n;===== reset machine status =================\n M204 S10000\n M630 S0 P1\n G90\n M17 D ; reset motor current to default\n M960 S5 P1 ; turn on logo lamp\n G90\n M220 S100 ;Reset Feedrate\n M1002 set_gcode_claim_speed_level: 5\n M221 S100 ;Reset Flowrate\n M73.2 R1.0 ;Reset left time magnitude\n G29.1 Z{+0.0} ; clear z-trim value first\n M983.1 M1\n M975 S1 ; turn on input shaping\n M982.2 S1 ; turn on cog noise reduction\n M983.4 S0\n;===== reset machine status =================\n\n;==== set airduct mode ====\n;==== if Chamber Cooling is necessary ====\n{if (overall_chamber_temperature >= 40)}\nM145 P1 ; set airduct mode to heating mode for heating\nM106 P2 S255 ; turn on filter fan\nM622.1 S0\nM1002 judge_flag ventobox_replace_aux1_fan_flag\nM622 J0\nM106 P10 S0 ; turn off left aux fan\nM623\n{else}\n{if (min_vitrification_temperature <= 50)}\nM145 P0 ; set airduct mode to cooling mode for cooling\nM106 P2 S255 ; turn on auxiliary fan for cooling\nM106 P3 S127 ; turn on chamber fan for cooling\nM1002 gcode_claim_action : 29\nM191 S0 ; wait for chamber temp\nM106 P2 S102 ; turn on chamber cooling fan\nM622.1 S0\nM1002 judge_flag ventobox_replace_aux1_fan_flag\nM622 J0\nM106 P10 S0 ; turn off left aux fan\nM623\nM142 P6 R30 S40 U0.3 V0.8 ; set PETG exhaust chamber autocooling\n{else}\nM145 P1 ; set airduct mode to heating mode for heating\nM106 P2 S127 ; turn on 50% filter fan\nM142 P6 R30 S40 U0.3 V0.8 ; set PLA/TPU exhaust chamber autocooling\n{endif}\n{endif}\n;==== set airduct mode ====\n\n;===== start to heat heatbed & hotend==========\n M1002 gcode_claim_action : 2\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M104 S140 A\n\n G29.2 S0 ; avoid invalid abl data\n\n;===== first homing start =====\n M1002 gcode_claim_action : 13\n G28 X T300\n G150.1 F8000 ; wipe mouth to avoid filament stick to heatbed\n G150.3\n M972 S24 P0\n M972 S26 P0 C0\n M972 S42 P0 T5000\n G150.1 F8000 ; wipe mouth to avoid filament stick to heatbed\n G90\n G1 X128 Y128 F30000\n G28 Z P0 T400\n M400\n;===== first homing end =====\n\n;===== detection start =====\n M1002 gcode_claim_action : 11\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]-80} A ; rise temp in advance\n M972 S19 P0 T5000 ;plate type detection\n\n {if max_print_z >= 145}\n M1002 gcode_claim_action : 75 ; Detect obstacles at the botton of the heated bed\n G150.3\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]} ; rise temp in advance\n G3811 Z{max_print_z} ; Detect obstacles at the bottom of the heated bed\n {endif}\n;===== detection end =====\n\n;===== prepare print temperature and material ==========\n M400\n M211 X0 Y0 Z0 ;turn off soft endstop\n G29.2 S0 ; avoid invalid abl data\n G150.3\n{if ((filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PLA-CF\") || (filament_type[initial_no_support_filament_id] == \"PETG\")) && (nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2)}\nM620.10 A0 F74.8347 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\nM620.10 A1 F74.8347 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\n{else}\nM620.10 A0 F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60} H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\nM620.10 A1 F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60} H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\n{endif}\n\n M620.11 P0 L0 I[initial_no_support_filament_id] E0\n M620.11 K0 I[initial_no_support_filament_id] R0\n\n M620 S[initial_no_support_filament_id]A ; switch material if AMS exist\n M1002 gcode_claim_action : 4\n M1002 set_filament_type:UNKNOWN\n M400\n T[initial_no_support_filament_id]\n M400\n M628 S0\n M629\n M400\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M621 S[initial_no_support_filament_id]A\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M400\n M106 P1 S0\n M400\n G29.2 S1\n;===== prepare print temperature and material ==========\n\n\n;===== auto extrude cali start =========================\n M975 S1\n M1002 judge_flag extrude_cali_flag\n M622 J0\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n M623\n\n M622 J1\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 8\n M109 S{nozzle_temperature[initial_no_support_filament_id]}\n G90\n M83\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n M400\n M106 P1 S255\n M400 S5\n M106 P1 S0\n G150.3\n M623\n\n M622 J2\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 8\n M109 S{nozzle_temperature[initial_no_support_filament_id]}\n G90\n M83\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n M400\n M106 P1 S255\n M400 S5\n M106 P1 S0\n G150.3\n M623\n;===== auto extrude cali end =========================\n\n {if hold_chamber_temp_for_flat_print}\n M1002 gcode_claim_action : 58\n M104 S{first_layer_temperature[initial_no_support_filament_id]}\n {if bed_temperature_initial_layer_single > 89}\n M1030 S1800\n SYNC R0 T1800\n {else}\n M1030 S300\n SYNC R0 T300\n {endif}\n M1030 C\n {endif}\n\n {if filament_type[initial_filament_id] == \"TPU\" || filament_type[initial_filament_id] == \"PVA\"}\n {else}\n M83\n G1 E-3 F1800\n M400 P500\n {endif}\n\n ; series of wipes to ensure the nozzle is as clean as possible to avoid potential z-drift issues.\n M1002 gcode_claim_action : 14\n M104 S180\n G150.2\n G150.1 F8000\n G150.2\n G150.1 F8000\n M106 P1 S255\n M109 S180 A\n G150.1 F8000 \n M109 S140 A\n M106 P1 S0\n G150.1 F8000\n G91\n G1 Y-16 F12000\n G1 X100 F6000\n G90\n M400\n\n;===== wipe right nozzle start =====\n G150 T{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M400\n\n{if filament_type[initial_filament_id] == \"PC\"}\n M109 S170 A\n{else}\n M109 S140 A\n{endif}\n G91\n G1 Z5 F1200\n G90\n M400\n G150.1\n;===== wipe left nozzle end =====\n\n\n;===== mech mode sweep start =====\n M1002 gcode_claim_action : 3\n G90\n G1 X128 Y128 F20000\n G1 Z5 F1200\n M400 P200\n M970.3 Q1 A5 K0 O1\n M970.2 Q1 K1 W74 Z0.01\n M974 Q1 S2 P0\n M970.3 Q0 A7 K0 O1\n M970.2 Q0 K1 W74 Z0.01\n M974 Q0 S2 P0\n M975 S1\n M400\n;===== mech mode sweep end =====\n\n;===== bed leveling ==================================\n M1002 gcode_claim_action : 54\n M190 S[bed_temperature_initial_layer_single]; ensure bed temp\n M109 S140 A\n M106 S0 ; turn off fan , too noisy\n M1002 judge_flag g29_before_print_flag\n M622 J1\n M1002 gcode_claim_action : 1\n {if hold_chamber_temp_for_flat_print}\n G29 H\n {else}\n G29 A1 X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]}\n {endif}\n M400\n M623\n\n M622 J2\n M1002 gcode_claim_action : 1\n {if hold_chamber_temp_for_flat_print}\n G29 H\n {else}\n G29 A2 X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]}\n {endif}\n M400\n M623\n\n M622 J0\n ;skip to homing\n M623\n G29.2 S1\n M1002 gcode_claim_action : 13\n G28\n;===== bed leveling end ================================\n\n M985.1 U0 E2\n M985.1 U1 E2\n\n M104 S{nozzle_temperature_initial_layer[initial_filament_id]} A\n G150.3 ; move to garbage can to wait for temp\n\n;===== wait temperature reaching the reference value =======\n M190 S[bed_temperature_initial_layer_single]\n\n ;========turn off light and fans =============\n M960 S1 P0 ; turn off laser\n M960 S2 P0 ; turn off laser\n M106 S0 ; turn off cooling fan\n\n;===== wait temperature reaching the reference value =======\n\n M1002 gcode_claim_action : 255\n M400\n M975 S1 ; turn on mech mode supression\n\n;============switch again==================\n M211 X0 Y0 Z0 ;turn off soft endstop\n G91\n G1 Z6 F1200\n G90\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M620 S[initial_no_support_filament_id]A\n M400\n T[initial_no_support_filament_id]\n M400\n M628 S0\n M629\n M400\n M621 S[initial_no_support_filament_id]A\n;============switch again==================\n\n;===== for Textured PEI Plate , lower the nozzle as the nozzle was touching topmost of the texture when homing ==\n {if bed_temperature_initial_layer_single > 89}\n {if curr_bed_type==\"Textured PEI Plate\"}\n G29.1 Z{-0.02} ; for Textured PEI Plate\n {else}\n G29.1 Z{0.0}\n {endif}\n {else}\n {if curr_bed_type==\"Textured PEI Plate\"}\n G29.1 Z{0.01} ; for Textured PEI Plate\n {else}\n G29.1 Z{0.03}\n {endif}\n {endif}\n\n\n;===== nozzle load line ===============================\n M1002 gcode_claim_action : 7\n G29.2 S1 ; ensure z comp turn on\n G90\n M83\n M400 P50\n M500 D1\n M400 S3\n M109 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 51\n G0 X100 Y0 F24000\n M400\n ;G130 O0 X100 Y-0.4 Z0.8 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2/2.4053} L40 E20 D5\n G130 O0 X100 Y-0.2 Z0.6 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2/2.4053} L40 E12 D4\n G90\n M83\n G1 Z1\n M400\n;===== nozzle load line end ===========================\nM1002 gcode_claim_action : 0\n G29.99\n\n{if (filament_type[initial_no_support_filament_id] == \"TPU\") ||\n(filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PETG\")}\nM1015.3 S1 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]};enable tpu, pla and petg clog detect\n{else}\nM1015.3 S0;disable clog detect\n{endif}\n\n{if (filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PETG\")\n || (filament_type[initial_no_support_filament_id] == \"PLA-CF\") || (filament_type[initial_no_support_filament_id] == \"PETG-CF\")}\nM1015.4 S1 K1 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} ;enable E air printing detect\n{else}\nM1015.4 S0 K0 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} ;disable E air printing detect\n{endif}\n\nM620.6 I[initial_no_support_filament_id] W1 ;enable ams air printing detect\n\nM1010 Q0 B0.023 S0.01\nM1010 Q1 B0.005 S0.01\nM1010.1 S1\n" } diff --git a/resources/profiles/Custom.json b/resources/profiles/Custom.json index afbcdacbe8..ab2084d898 100644 --- a/resources/profiles/Custom.json +++ b/resources/profiles/Custom.json @@ -1,9 +1,13 @@ { "name": "Custom Printer", - "version": "02.04.00.07", + "version": "02.04.00.08", "force_update": "0", "description": "My configurations", "machine_model_list": [ + { + "name": "Generic Belt Printer", + "sub_path": "machine/MyBeltPrinter.json" + }, { "name": "Generic Klipper Printer", "sub_path": "machine/MyKlipper.json" @@ -50,6 +54,10 @@ "name": "0.08mm Extra Fine @MyKlipper", "sub_path": "process/0.08mm Extra Fine @MyKlipper.json" }, + { + "name": "0.12mm Fine @MyBeltPrinter", + "sub_path": "process/0.12mm Fine @MyBeltPrinter.json" + }, { "name": "0.12mm Fine @MyKlipper", "sub_path": "process/0.12mm Fine @MyKlipper.json" @@ -62,6 +70,10 @@ "name": "0.16mm Optimal @MyKlipper", "sub_path": "process/0.16mm Optimal @MyKlipper.json" }, + { + "name": "0.20mm Standard @MyBeltPrinter", + "sub_path": "process/0.20mm Standard @MyBeltPrinter.json" + }, { "name": "0.20mm Standard @MyKlipper", "sub_path": "process/0.20mm Standard @MyKlipper.json" @@ -262,6 +274,10 @@ "name": "MyKlipper 0.8 nozzle", "sub_path": "machine/MyKlipper 0.8 nozzle.json" }, + { + "name": "fdm_belt_common", + "sub_path": "machine/fdm_belt_common.json" + }, { "name": "fdm_toolchanger_common", "sub_path": "machine/fdm_toolchanger_common.json" @@ -274,6 +290,22 @@ "name": "MyRRF 0.4 nozzle", "sub_path": "machine/MyRRF 0.4 nozzle.json" }, + { + "name": "MyBeltPrinter 0.2 nozzle", + "sub_path": "machine/MyBeltPrinter 0.2 nozzle.json" + }, + { + "name": "MyBeltPrinter 0.4 nozzle", + "sub_path": "machine/MyBeltPrinter 0.4 nozzle.json" + }, + { + "name": "MyBeltPrinter 0.6 nozzle", + "sub_path": "machine/MyBeltPrinter 0.6 nozzle.json" + }, + { + "name": "MyBeltPrinter 0.8 nozzle", + "sub_path": "machine/MyBeltPrinter 0.8 nozzle.json" + }, { "name": "MyToolChanger 0.2 nozzle", "sub_path": "machine/MyToolChanger 0.2 nozzle.json" diff --git a/resources/profiles/Custom/Generic Belt Printer_cover.png b/resources/profiles/Custom/Generic Belt Printer_cover.png new file mode 100644 index 0000000000..d42f2ef228 Binary files /dev/null and b/resources/profiles/Custom/Generic Belt Printer_cover.png differ diff --git a/resources/profiles/Custom/machine/MyBeltPrinter 0.2 nozzle.json b/resources/profiles/Custom/machine/MyBeltPrinter 0.2 nozzle.json new file mode 100644 index 0000000000..1226f4dd68 --- /dev/null +++ b/resources/profiles/Custom/machine/MyBeltPrinter 0.2 nozzle.json @@ -0,0 +1,27 @@ +{ + "type": "machine", + "name": "MyBeltPrinter 0.2 nozzle", + "inherits": "fdm_belt_common", + "from": "system", + "setting_id": "3w1uyJdmm14QhDnH", + "instantiation": "true", + "printer_model": "Generic Belt Printer", + "default_print_profile": "0.12mm Fine @MyBeltPrinter", + "nozzle_diameter": [ + "0.2" + ], + "max_layer_height": [ + "0.16" + ], + "min_layer_height": [ + "0.04" + ], + "printer_variant": "0.2", + "printable_area": [ + "0x0", + "350x0", + "350x350", + "0x350" + ], + "printable_height": "300" +} diff --git a/resources/profiles/Custom/machine/MyBeltPrinter 0.4 nozzle.json b/resources/profiles/Custom/machine/MyBeltPrinter 0.4 nozzle.json new file mode 100644 index 0000000000..e8149509ca --- /dev/null +++ b/resources/profiles/Custom/machine/MyBeltPrinter 0.4 nozzle.json @@ -0,0 +1,20 @@ +{ + "type": "machine", + "name": "MyBeltPrinter 0.4 nozzle", + "inherits": "fdm_belt_common", + "from": "system", + "setting_id": "6nRHUtvJOUffocbu", + "instantiation": "true", + "printer_model": "Generic Belt Printer", + "nozzle_diameter": [ + "0.4" + ], + "printer_variant": "0.4", + "printable_area": [ + "0x0", + "350x0", + "350x350", + "0x350" + ], + "printable_height": "300" +} diff --git a/resources/profiles/Custom/machine/MyBeltPrinter 0.6 nozzle.json b/resources/profiles/Custom/machine/MyBeltPrinter 0.6 nozzle.json new file mode 100644 index 0000000000..b8ca999dca --- /dev/null +++ b/resources/profiles/Custom/machine/MyBeltPrinter 0.6 nozzle.json @@ -0,0 +1,26 @@ +{ + "type": "machine", + "name": "MyBeltPrinter 0.6 nozzle", + "inherits": "fdm_belt_common", + "from": "system", + "setting_id": "K0m9HbUNwKT4UCJV", + "instantiation": "true", + "printer_model": "Generic Belt Printer", + "nozzle_diameter": [ + "0.6" + ], + "max_layer_height": [ + "0.4" + ], + "min_layer_height": [ + "0.12" + ], + "printer_variant": "0.6", + "printable_area": [ + "0x0", + "350x0", + "350x350", + "0x350" + ], + "printable_height": "300" +} diff --git a/resources/profiles/Custom/machine/MyBeltPrinter 0.8 nozzle.json b/resources/profiles/Custom/machine/MyBeltPrinter 0.8 nozzle.json new file mode 100644 index 0000000000..b546b5b288 --- /dev/null +++ b/resources/profiles/Custom/machine/MyBeltPrinter 0.8 nozzle.json @@ -0,0 +1,26 @@ +{ + "type": "machine", + "name": "MyBeltPrinter 0.8 nozzle", + "inherits": "fdm_belt_common", + "from": "system", + "setting_id": "rHAweDz4eNwttPNA", + "instantiation": "true", + "printer_model": "Generic Belt Printer", + "nozzle_diameter": [ + "0.8" + ], + "max_layer_height": [ + "0.6" + ], + "min_layer_height": [ + "0.2" + ], + "printer_variant": "0.8", + "printable_area": [ + "0x0", + "350x0", + "350x350", + "0x350" + ], + "printable_height": "300" +} diff --git a/resources/profiles/Custom/machine/MyBeltPrinter.json b/resources/profiles/Custom/machine/MyBeltPrinter.json new file mode 100644 index 0000000000..0f6e0c5891 --- /dev/null +++ b/resources/profiles/Custom/machine/MyBeltPrinter.json @@ -0,0 +1,12 @@ +{ + "type": "machine_model", + "name": "Generic Belt Printer", + "model_id": "my_belt_01", + "nozzle_diameter": "0.4;0.2;0.6;0.8", + "machine_tech": "FFF", + "family": "MyPrinter", + "bed_model": "Custom_350_bed.stl", + "bed_texture": "orcaslicer_bed_texture.svg", + "hotend_model": "", + "default_materials": "Generic PLA @System;Generic PLA-CF @System;Generic PETG @System;Generic TPU @System;Generic PC @System;Generic PVA @System;Generic PA @System;Generic PA-CF @System" +} diff --git a/resources/profiles/Custom/machine/fdm_belt_common.json b/resources/profiles/Custom/machine/fdm_belt_common.json new file mode 100644 index 0000000000..96b3138832 --- /dev/null +++ b/resources/profiles/Custom/machine/fdm_belt_common.json @@ -0,0 +1,95 @@ +{ + "type": "machine", + "name": "fdm_belt_common", + "inherits": "fdm_klipper_common", + "from": "system", + "instantiation": "false", + "gcode_flavor": "klipper", + "single_extruder_multi_material": "0", + "default_filament_profile": [ + "Generic PLA @System" + ], + "default_print_profile": "0.20mm Standard @MyBeltPrinter", + "max_layer_height": [ + "0.32" + ], + "min_layer_height": [ + "0.08" + ], + "deretraction_speed": [ + "30" + ], + "extruder_colour": [ + "#FCE94F" + ], + "extruder_offset": [ + "0x0" + ], + "long_retractions_when_cut": [ + "0" + ], + "nozzle_diameter": [ + "0.4" + ], + "retract_before_wipe": [ + "70%" + ], + "retract_length_toolchange": [ + "2" + ], + "retract_lift_above": [ + "0" + ], + "retract_lift_below": [ + "0" + ], + "retract_lift_enforce": [ + "All Surfaces" + ], + "retract_restart_extra": [ + "0" + ], + "retract_restart_extra_toolchange": [ + "0" + ], + "retract_when_changing_layer": [ + "1" + ], + "retraction_distances_when_cut": [ + "18" + ], + "retraction_length": [ + "0.8" + ], + "retraction_minimum_travel": [ + "1" + ], + "retraction_speed": [ + "30" + ], + "travel_slope": [ + "3" + ], + "wipe": [ + "1" + ], + "wipe_distance": [ + "1" + ], + "z_hop": [ + "0" + ], + "z_hop_types": [ + "Normal Lift" + ], + "gcode_remap_x": "rev_x", + "gcode_remap_y": "pos_z", + "gcode_remap_z": "pos_y", + "belt_printer": "1", + "belt_slice_rotation": "x", + "belt_slice_rotation_angle": "45", + "build_plate_tilt_x": "45", + "purge_in_prime_tower": "0", + "scan_first_layer": "0", + "auxiliary_fan": "0" +} diff --git a/resources/profiles/Custom/process/0.12mm Fine @MyBeltPrinter.json b/resources/profiles/Custom/process/0.12mm Fine @MyBeltPrinter.json new file mode 100644 index 0000000000..15814e6725 --- /dev/null +++ b/resources/profiles/Custom/process/0.12mm Fine @MyBeltPrinter.json @@ -0,0 +1,20 @@ +{ + "type": "process", + "name": "0.12mm Fine @MyBeltPrinter", + "inherits": "fdm_process_klipper_common", + "from": "system", + "setting_id": "EugqqdLJ423bgEwN", + "instantiation": "true", + "layer_height": "0.12", + "initial_layer_print_height": "0.12", + "bottom_shell_layers": "5", + "top_shell_layers": "6", + "support_top_z_distance": "0.08", + "support_bottom_z_distance": "0.08", + "skirt_loops": "0", + "skirt_distance": "0", + "compatible_printers": [ + "MyBeltPrinter 0.2 nozzle", + "MyBeltPrinter 0.4 nozzle" + ] +} diff --git a/resources/profiles/Custom/process/0.20mm Standard @MyBeltPrinter.json b/resources/profiles/Custom/process/0.20mm Standard @MyBeltPrinter.json new file mode 100644 index 0000000000..6c10b250d6 --- /dev/null +++ b/resources/profiles/Custom/process/0.20mm Standard @MyBeltPrinter.json @@ -0,0 +1,17 @@ +{ + "type": "process", + "name": "0.20mm Standard @MyBeltPrinter", + "inherits": "fdm_process_klipper_common", + "from": "system", + "setting_id": "YzCDAgH3uLOM53pF", + "instantiation": "true", + "layer_height": "0.2", + "initial_layer_print_height": "0.2", + "skirt_loops": "0", + "skirt_distance": "0", + "compatible_printers": [ + "MyBeltPrinter 0.4 nozzle", + "MyBeltPrinter 0.6 nozzle", + "MyBeltPrinter 0.8 nozzle" + ] +} diff --git a/resources/profiles/IdeaFormer.json b/resources/profiles/IdeaFormer.json new file mode 100644 index 0000000000..ebed0bc9e0 --- /dev/null +++ b/resources/profiles/IdeaFormer.json @@ -0,0 +1,54 @@ +{ + "name": "IdeaFormer", + "version": "02.00.00.06", + "force_update": "0", + "description": "IdeaFormer belt printer configurations", + "machine_model_list": [ + { + "name": "IdeaFormer IR3 V2", + "sub_path": "machine/IdeaFormer IR3 V2.json" + } + ], + "process_list": [ + { + "name": "fdm_process_common", + "sub_path": "process/fdm_process_common.json" + }, + { + "name": "0.20mm Standard @IdeaFormer IR3 V2", + "sub_path": "process/0.20mm Standard @IdeaFormer IR3 V2.json" + } + ], + "filament_list": [ + { + "name": "Generic PLA @IdeaFormer IR3 V2", + "sub_path": "filament/Generic PLA @IdeaFormer IR3 V2.json" + }, + { + "name": "eSUN PLA @IdeaFormer IR3 V2", + "sub_path": "filament/eSUN PLA @IdeaFormer IR3 V2.json" + }, + { + "name": "Generic PETG @IdeaFormer IR3 V2", + "sub_path": "filament/Generic PETG @IdeaFormer IR3 V2.json" + } + ], + "machine_list": [ + { + "name": "fdm_machine_common", + "sub_path": "machine/fdm_machine_common.json" + }, + { + "name": "fdm_klipper_common", + "sub_path": "machine/fdm_klipper_common.json" + }, + { + "name": "fdm_belt_common", + "sub_path": "machine/fdm_belt_common.json" + }, + { + "name": "IdeaFormer IR3 V2 0.4 nozzle", + "sub_path": "machine/IdeaFormer IR3 V2 0.4 nozzle.json" + } + ] +} \ No newline at end of file diff --git a/resources/profiles/IdeaFormer/IdeaFormer IR3 V2_cover.png b/resources/profiles/IdeaFormer/IdeaFormer IR3 V2_cover.png new file mode 100644 index 0000000000..71485f54f5 Binary files /dev/null and b/resources/profiles/IdeaFormer/IdeaFormer IR3 V2_cover.png differ diff --git a/resources/profiles/IdeaFormer/filament/Generic PETG @IdeaFormer IR3 V2.json b/resources/profiles/IdeaFormer/filament/Generic PETG @IdeaFormer IR3 V2.json new file mode 100644 index 0000000000..28da3959f4 --- /dev/null +++ b/resources/profiles/IdeaFormer/filament/Generic PETG @IdeaFormer IR3 V2.json @@ -0,0 +1,77 @@ +{ + "type": "filament", + "name": "Generic PETG @IdeaFormer IR3 V2", + "inherits": "Generic PETG @System", + "from": "system", + "setting_id": "n4zaXcUUzTqAxq5f", + "instantiation": "true", + "filament_extruder_variant": [ + "Direct Drive Standard" + ], + "compatible_printers": [ + "IdeaFormer IR3 V2 0.4 nozzle" + ], + "filament_type": [ + "PETG" + ], + "filament_vendor": [ + "Generic" + ], + "filament_settings_id": [ + "Generic PETG @IdeaFormer IR3 V2" + ], + "filament_flow_ratio": [ + "0.95" + ], + "filament_cost": [ + "25" + ], + "nozzle_temperature": [ + "240" + ], + "nozzle_temperature_initial_layer": [ + "245" + ], + "cool_plate_temp": [ + "80" + ], + "cool_plate_temp_initial_layer": [ + "80" + ], + "fan_min_speed": [ + "40" + ], + "fan_max_speed": [ + "60" + ], + "overhang_fan_threshold": [ + "25%" + ], + "overhang_fan_speed": [ + "80" + ], + "full_fan_speed_layer": [ + "8" + ], + "slow_down_min_speed": [ + "20" + ], + "slow_down_layer_time": [ + "4" + ], + "fan_cooling_layer_time": [ + "100" + ], + "filament_retraction_length": [ + "2" + ], + "filament_retraction_speed": [ + "40" + ], + "filament_deretraction_speed": [ + "40" + ], + "filament_start_gcode": [ + "; Generic PETG @IdeaFormer IR3 V2 — belt PETG, bed 80C" + ] +} diff --git a/resources/profiles/IdeaFormer/filament/Generic PLA @IdeaFormer IR3 V2.json b/resources/profiles/IdeaFormer/filament/Generic PLA @IdeaFormer IR3 V2.json new file mode 100644 index 0000000000..da5d4af50a --- /dev/null +++ b/resources/profiles/IdeaFormer/filament/Generic PLA @IdeaFormer IR3 V2.json @@ -0,0 +1,65 @@ +{ + "type": "filament", + "name": "Generic PLA @IdeaFormer IR3 V2", + "inherits": "Generic PLA @System", + "from": "system", + "setting_id": "1xjycsEAFh6KQIhp", + "instantiation": "true", + "filament_extruder_variant": [ + "Direct Drive Standard" + ], + "compatible_printers": [ + "IdeaFormer IR3 V2 0.4 nozzle" + ], + "filament_type": [ + "PLA" + ], + "filament_vendor": [ + "Generic" + ], + "filament_settings_id": [ + "Generic PLA @IdeaFormer IR3 V2" + ], + "nozzle_temperature": [ + "215" + ], + "hot_plate_temp": [ + "75" + ], + "hot_plate_temp_initial_layer": [ + "75" + ], + "cool_plate_temp": [ + "75" + ], + "cool_plate_temp_initial_layer": [ + "75" + ], + "textured_plate_temp": [ + "75" + ], + "textured_plate_temp_initial_layer": [ + "75" + ], + "close_fan_the_first_x_layers": [ + "3" + ], + "full_fan_speed_layer": [ + "8" + ], + "slow_down_min_speed": [ + "20" + ], + "filament_retraction_length": [ + "1.5" + ], + "filament_retraction_speed": [ + "35" + ], + "filament_deretraction_speed": [ + "30" + ], + "filament_start_gcode": [ + "; Generic PLA @IdeaFormer IR3 V2 — belt PLA, bed 75C" + ] +} diff --git a/resources/profiles/IdeaFormer/filament/eSUN PLA @IdeaFormer IR3 V2.json b/resources/profiles/IdeaFormer/filament/eSUN PLA @IdeaFormer IR3 V2.json new file mode 100644 index 0000000000..b39a4c22fc --- /dev/null +++ b/resources/profiles/IdeaFormer/filament/eSUN PLA @IdeaFormer IR3 V2.json @@ -0,0 +1,36 @@ +{ + "type": "filament", + "name": "eSUN PLA @IdeaFormer IR3 V2", + "inherits": "Generic PLA @IdeaFormer IR3 V2", + "from": "system", + "setting_id": "XqkviBmFHEglXueX", + "filament_id": "OFkrxQC4", + "instantiation": "true", + "compatible_printers": [ + "IdeaFormer IR3 V2 0.4 nozzle" + ], + "filament_type": [ + "PLA" + ], + "filament_vendor": [ + "eSUN" + ], + "filament_settings_id": [ + "eSUN PLA @IdeaFormer IR3 V2" + ], + "nozzle_temperature_initial_layer": [ + "200" + ], + "nozzle_temperature": [ + "200" + ], + "enable_pressure_advance": [ + "1" + ], + "pressure_advance": [ + "0.12" + ], + "filament_max_volumetric_speed": [ + "20" + ] +} diff --git a/resources/profiles/IdeaFormer/machine/IdeaFormer IR3 V2 0.4 nozzle.json b/resources/profiles/IdeaFormer/machine/IdeaFormer IR3 V2 0.4 nozzle.json new file mode 100644 index 0000000000..f993631921 --- /dev/null +++ b/resources/profiles/IdeaFormer/machine/IdeaFormer IR3 V2 0.4 nozzle.json @@ -0,0 +1,98 @@ +{ + "type": "machine", + "name": "IdeaFormer IR3 V2 0.4 nozzle", + "inherits": "fdm_belt_common", + "from": "system", + "setting_id": "MDQZgwRgg72lmjtu", + "instantiation": "true", + "printer_model": "IdeaFormer IR3 V2", + "printer_variant": "0.4", + "nozzle_diameter": [ + "0.4" + ], + "printable_area": [ + "0x0", + "250x0", + "250x2000", + "0x2000" + ], + "printable_height": "250", + "belt_printer_infinite_y": "1", + "thumbnails": [ + "48x48/PNG", + "300x300/PNG" + ], + "default_filament_profile": [ + "Generic PLA @IdeaFormer IR3 V2" + ], + "default_print_profile": "0.20mm Standard @IdeaFormer IR3 V2", + "use_relative_e_distances": "1", + "machine_max_acceleration_extruding": [ + "5000", + "5000" + ], + "machine_max_acceleration_retracting": [ + "1000", + "1000" + ], + "machine_max_acceleration_travel": [ + "9000", + "9000" + ], + "machine_max_acceleration_x": [ + "5000", + "5000" + ], + "machine_max_acceleration_y": [ + "5000", + "5000" + ], + "machine_max_acceleration_z": [ + "100", + "100" + ], + "machine_max_jerk_x": [ + "10", + "10" + ], + "machine_max_jerk_y": [ + "10", + "10" + ], + "machine_max_jerk_z": [ + "0.4", + "0.4" + ], + "machine_max_speed_e": [ + "60", + "60" + ], + "machine_max_speed_x": [ + "500", + "500" + ], + "machine_max_speed_y": [ + "500", + "500" + ], + "machine_max_speed_z": [ + "20", + "20" + ], + "retraction_length": [ + "2" + ], + "retraction_speed": [ + "40" + ], + "deretraction_speed": [ + "40" + ], + "retract_lift_below": [ + "300" + ], + "machine_start_gcode": "; === IdeaFormer IR3 V2 Belt Printer Start ===\n; Axes: X=lateral, Y=gantry height (probe), Z=belt\nG90 ; absolute positioning\nM82 ; absolute extruder\nG21 ; millimeters\nG28 ; home all axes\nG1 Y20 F500 ; lift nozzle 20mm from belt\n; Bed + hotend temps come from the active filament profile. Belt PLA requires 75 C bed — use Generic/eSun PLA @IdeaFormer IR3 V2 filament presets to get it automatically.\nM140 S[hot_plate_temp_initial_layer] ; set bed temp\nM104 S[nozzle_temperature_initial_layer] ; hotend temp\nM109 S[nozzle_temperature_initial_layer] ; wait hotend\nM190 S[hot_plate_temp_initial_layer] ; wait bed\n; --- Purge blob ---\nG92 E0 ; zero extruder\nG1 Y.1 ; nozzle 0.1mm above belt\nG1 E15 F1000 ; purge 15mm blob\nG1 Z20 E25 F800 ; belt advance 20mm + extrude\nG1 E23 ; retract 2mm\nG28 Y ; re-probe belt surface\nG1 E25 ; de-retract\n; --- Prime lines (full 250mm bed width) ---\nFMS_on ; filament motion sensor\nG1 X250 E50 F2000 ; prime line 1\nG92 Z0 ; reset belt origin\nG1 Z.4 ; belt advance 0.4mm\nG1 X0 E75 ; prime line 2\nG1 F1000 ; default feedrate\nG92 E0 Z0 ; zero extruder + belt = print origin\n", + "machine_end_gcode": "; === IdeaFormer IR3 V2 Belt Printer End ===\nM400 ; wait for moves to finish\nM104 S0 ; heater off\nM140 S0 ; bed off\nG92 E0 ; zero extruder\nG1 E-5 F300 ; retract 5mm\nG4 P5000 ; wait for ooze\nG91 ; relative mode - keep every end move relative on a belt\nG1 Y20 F1000 ; raise gantry 20mm for clearance over the part\nG1 Z676 F3000 ; advance belt one full machine-depth to eject the part and clean the belt\nG90 ; back to absolute\nG28 X ; home X only - NEVER 'G28' all: that homes Z/belt and reverses the whole print back into the gantry\nFMS_off ; filament motion sensor off\nBED_MESH_CLEAR\nM84 ; disable motors\n", + "machine_pause_gcode": "PAUSE", + "layer_change_gcode": "G92 E0 ; belt: reset extruder at layer change (relative E)" +} diff --git a/resources/profiles/IdeaFormer/machine/IdeaFormer IR3 V2.json b/resources/profiles/IdeaFormer/machine/IdeaFormer IR3 V2.json new file mode 100644 index 0000000000..bc6d1a6e77 --- /dev/null +++ b/resources/profiles/IdeaFormer/machine/IdeaFormer IR3 V2.json @@ -0,0 +1,12 @@ +{ + "type": "machine_model", + "name": "IdeaFormer IR3 V2", + "model_id": "IdeaFormer_IR3_V2", + "nozzle_diameter": "0.4", + "machine_tech": "FFF", + "family": "IdeaFormer", + "bed_model": "", + "bed_texture": "", + "hotend_model": "", + "default_materials": "Generic PLA @IdeaFormer IR3 V2;Generic PETG @IdeaFormer IR3 V2" +} diff --git a/resources/profiles/IdeaFormer/machine/fdm_belt_common.json b/resources/profiles/IdeaFormer/machine/fdm_belt_common.json new file mode 100644 index 0000000000..aacd36e118 --- /dev/null +++ b/resources/profiles/IdeaFormer/machine/fdm_belt_common.json @@ -0,0 +1,98 @@ +{ + "type": "machine", + "name": "fdm_belt_common", + "inherits": "fdm_klipper_common", + "from": "system", + "instantiation": "false", + "gcode_flavor": "klipper", + "single_extruder_multi_material": "0", + "default_filament_profile": [ + "Generic PLA @System" + ], + "default_print_profile": "0.20mm Standard @IdeaFormer IR3 V2", + "max_layer_height": [ + "0.32" + ], + "min_layer_height": [ + "0.08" + ], + "deretraction_speed": [ + "30" + ], + "extruder_colour": [ + "#FCE94F" + ], + "extruder_offset": [ + "0x0" + ], + "long_retractions_when_cut": [ + "0" + ], + "nozzle_diameter": [ + "0.4" + ], + "retract_before_wipe": [ + "70%" + ], + "retract_length_toolchange": [ + "2" + ], + "retract_lift_above": [ + "0" + ], + "retract_lift_below": [ + "0" + ], + "retract_lift_enforce": [ + "All Surfaces" + ], + "retract_restart_extra": [ + "0" + ], + "retract_restart_extra_toolchange": [ + "0" + ], + "retract_when_changing_layer": [ + "1" + ], + "retraction_distances_when_cut": [ + "18" + ], + "retraction_length": [ + "0.8" + ], + "retraction_minimum_travel": [ + "1" + ], + "retraction_speed": [ + "30" + ], + "travel_slope": [ + "3" + ], + "wipe": [ + "1" + ], + "wipe_distance": [ + "1" + ], + "z_hop": [ + "0" + ], + "z_hop_types": [ + "Normal Lift" + ], + "gcode_remap_x": "rev_x", + "gcode_remap_y": "pos_z", + "gcode_remap_z": "pos_y", + "printer_extruder_id": [ + "1" + ], + "belt_printer": "1", + "belt_slice_rotation": "x", + "belt_slice_rotation_angle": "45", + "build_plate_tilt_x": "45", + "purge_in_prime_tower": "0", + "scan_first_layer": "0", + "auxiliary_fan": "0" +} diff --git a/resources/profiles/IdeaFormer/machine/fdm_klipper_common.json b/resources/profiles/IdeaFormer/machine/fdm_klipper_common.json new file mode 100644 index 0000000000..5297f646ff --- /dev/null +++ b/resources/profiles/IdeaFormer/machine/fdm_klipper_common.json @@ -0,0 +1,140 @@ +{ + "type": "machine", + "name": "fdm_klipper_common", + "inherits": "fdm_machine_common", + "from": "system", + "instantiation": "false", + "gcode_flavor": "klipper", + "machine_max_acceleration_e": [ + "5000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "20000", + "20000" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "20000", + "20000" + ], + "machine_max_acceleration_x": [ + "20000", + "20000" + ], + "machine_max_acceleration_y": [ + "20000", + "20000" + ], + "machine_max_acceleration_z": [ + "500", + "200" + ], + "machine_max_speed_e": [ + "25", + "25" + ], + "machine_max_speed_x": [ + "500", + "200" + ], + "machine_max_speed_y": [ + "500", + "200" + ], + "machine_max_speed_z": [ + "12", + "12" + ], + "machine_max_jerk_e": [ + "2.5", + "2.5" + ], + "machine_max_jerk_x": [ + "9", + "9" + ], + "machine_max_jerk_y": [ + "9", + "9" + ], + "machine_max_jerk_z": [ + "0.2", + "0.4" + ], + "machine_min_extruding_rate": [ + "0", + "0" + ], + "machine_min_travel_rate": [ + "0", + "0" + ], + "max_layer_height": [ + "0.32" + ], + "min_layer_height": [ + "0.08" + ], + "printable_height": "250", + "extruder_clearance_radius": "65", + "extruder_clearance_height_to_rod": "36", + "extruder_clearance_height_to_lid": "140", + "printer_settings_id": "", + "printer_technology": "FFF", + "printer_variant": "0.4", + "retraction_minimum_travel": [ + "1" + ], + "retract_before_wipe": [ + "70%" + ], + "retract_when_changing_layer": [ + "1" + ], + "retraction_length": [ + "0.8" + ], + "retract_length_toolchange": [ + "2" + ], + "z_hop": [ + "0.4" + ], + "retract_restart_extra": [ + "0" + ], + "retract_restart_extra_toolchange": [ + "0" + ], + "retraction_speed": [ + "30" + ], + "deretraction_speed": [ + "30" + ], + "z_hop_types": "Normal Lift", + "single_extruder_multi_material": "1", + "change_filament_gcode": "", + "wipe": [ + "1" + ], + "default_filament_profile": [ + "Generic PLA @System" + ], + "default_print_profile": "0.20mm Standard @MyKlipper", + "bed_exclude_area": [ + "0x0" + ], + "machine_start_gcode": "M190 S[bed_temperature_initial_layer_single]\nM109 S[nozzle_temperature_initial_layer]\nPRINT_START EXTRUDER=[nozzle_temperature_initial_layer] BED=[bed_temperature_initial_layer_single]\n", + "machine_end_gcode": "PRINT_END", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\nG92 E0\n", + "machine_pause_gcode": "PAUSE", + "scan_first_layer": "0", + "nozzle_type": "undefine", + "auxiliary_fan": "0" +} diff --git a/resources/profiles/IdeaFormer/machine/fdm_machine_common.json b/resources/profiles/IdeaFormer/machine/fdm_machine_common.json new file mode 100644 index 0000000000..bf8f2249cf --- /dev/null +++ b/resources/profiles/IdeaFormer/machine/fdm_machine_common.json @@ -0,0 +1,118 @@ +{ + "type": "machine", + "name": "fdm_machine_common", + "from": "system", + "instantiation": "false", + "printer_technology": "FFF", + "deretraction_speed": [ + "40" + ], + "extruder_colour": [ + "#FCE94F" + ], + "extruder_offset": [ + "0x0" + ], + "gcode_flavor": "marlin", + "machine_max_acceleration_e": [ + "5000" + ], + "machine_max_acceleration_extruding": [ + "10000" + ], + "machine_max_acceleration_retracting": [ + "1000" + ], + "machine_max_acceleration_x": [ + "10000" + ], + "machine_max_acceleration_y": [ + "10000" + ], + "machine_max_acceleration_z": [ + "500" + ], + "machine_max_speed_e": [ + "60" + ], + "machine_max_speed_x": [ + "500" + ], + "machine_max_speed_y": [ + "500" + ], + "machine_max_speed_z": [ + "10" + ], + "machine_max_jerk_e": [ + "5" + ], + "machine_max_jerk_x": [ + "8" + ], + "machine_max_jerk_y": [ + "8" + ], + "machine_max_jerk_z": [ + "0.4" + ], + "machine_min_extruding_rate": [ + "0" + ], + "machine_min_travel_rate": [ + "0" + ], + "max_layer_height": [ + "0.32" + ], + "min_layer_height": [ + "0.08" + ], + "printable_height": "250", + "extruder_clearance_radius": "65", + "extruder_clearance_height_to_rod": "36", + "extruder_clearance_height_to_lid": "140", + "nozzle_diameter": [ + "0.4" + ], + "printer_settings_id": "", + "printer_variant": "0.4", + "retraction_minimum_travel": [ + "2" + ], + "retract_before_wipe": [ + "70%" + ], + "retract_when_changing_layer": [ + "1" + ], + "retraction_length": [ + "1" + ], + "retract_length_toolchange": [ + "1" + ], + "z_hop": [ + "0" + ], + "retract_restart_extra": [ + "0" + ], + "retract_restart_extra_toolchange": [ + "0" + ], + "retraction_speed": [ + "60" + ], + "single_extruder_multi_material": "1", + "change_filament_gcode": "", + "wipe": [ + "1" + ], + "default_print_profile": "", + "machine_start_gcode": "G0 Z20 F9000\nG92 E0; G1 E-10 F1200\nG28\nM970 Q1 A10 B10 C130 K0\nM970 Q1 A10 B131 C250 K1\nM974 Q1 S1 P0\nM970 Q0 A10 B10 C130 H20 K0\nM970 Q0 A10 B131 C250 K1\nM974 Q0 S1 P0\nM220 S100 ;Reset Feedrate\nM221 S100 ;Reset Flowrate\nG29 ;Home\nG90;\nG92 E0 ;Reset Extruder \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 X10.1 Y20 Z0.28 F5000.0 ;Move to start position\nM109 S205;\nG1 X10.1 Y200.0 Z0.28 F1500.0 E15 ;Draw the first line\nG1 X10.4 Y200.0 Z0.28 F5000.0 ;Move to side a little\nG1 X10.4 Y20 Z0.28 F1500.0 E30 ;Draw the second line\nG92 E0 ;Reset Extruder \nG1 X110 Y110 Z2.0 F3000 ;Move Z Axis up", + "machine_end_gcode": "M400 ; wait for buffer to clear\nG92 E0 ; zero the extruder\nG1 E-4.0 F3600; retract \nG91\nG1 Z3;\nM104 S0 ; turn off hotend\nM140 S0 ; turn off bed\nM106 S0 ; turn off fan\nG90 \nG0 X110 Y200 F3600 \nprint_end", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\nG92 E0\n", + "machine_pause_gcode": "M601" +} diff --git a/resources/profiles/IdeaFormer/process/0.20mm Standard @IdeaFormer IR3 V2.json b/resources/profiles/IdeaFormer/process/0.20mm Standard @IdeaFormer IR3 V2.json new file mode 100644 index 0000000000..c2e46e4154 --- /dev/null +++ b/resources/profiles/IdeaFormer/process/0.20mm Standard @IdeaFormer IR3 V2.json @@ -0,0 +1,23 @@ +{ + "type": "process", + "name": "0.20mm Standard @IdeaFormer IR3 V2", + "inherits": "fdm_process_common", + "from": "system", + "setting_id": "91atcIwv5728phqX", + "instantiation": "true", + "layer_height": "0.2", + "initial_layer_print_height": "0.2", + "initial_layer_line_width": "0.42", + "wall_loops": "2", + "reduce_infill_retraction": "1", + "detect_overhang_wall": "1", + "skirt_loops": "0", + "skirt_distance": "0", + "sparse_infill_pattern": "grid", + "sparse_infill_speed": "200", + "support_base_pattern": "rectilinear", + "support_interface_pattern": "rectilinear", + "compatible_printers": [ + "IdeaFormer IR3 V2 0.4 nozzle" + ] +} diff --git a/resources/profiles/IdeaFormer/process/fdm_process_common.json b/resources/profiles/IdeaFormer/process/fdm_process_common.json new file mode 100644 index 0000000000..0d558129af --- /dev/null +++ b/resources/profiles/IdeaFormer/process/fdm_process_common.json @@ -0,0 +1,106 @@ +{ + "type": "process", + "name": "fdm_process_common", + "from": "system", + "instantiation": "false", + "reduce_crossing_wall": "0", + "max_travel_detour_distance": "0", + "bottom_surface_pattern": "monotonic", + "bottom_shell_thickness": "0", + "bridge_speed": "50", + "brim_width": "5", + "brim_object_gap": "0.1", + "compatible_printers": [], + "compatible_printers_condition": "", + "print_sequence": "by layer", + "default_acceleration": "1000", + "initial_layer_acceleration": "500", + "top_surface_acceleration": "1000", + "travel_acceleration": "1000", + "inner_wall_acceleration": "1000", + "outer_wall_acceleration": "700", + "bridge_no_support": "0", + "draft_shield": "disabled", + "elefant_foot_compensation": "0", + "enable_arc_fitting": "0", + "wall_infill_order": "inner wall/outer wall/infill", + "infill_direction": "45", + "sparse_infill_density": "15%", + "sparse_infill_pattern": "crosshatch", + "initial_layer_print_height": "0.2", + "infill_combination": "0", + "infill_wall_overlap": "25%", + "interface_shells": "0", + "ironing_flow": "10%", + "ironing_spacing": "0.15", + "ironing_speed": "30", + "ironing_type": "no ironing", + "reduce_infill_retraction": "1", + "filename_format": "{input_filename_base}_{layer_height}mm_{filament_type[initial_tool]}_{printer_model}_{print_time}.gcode", + "detect_overhang_wall": "1", + "slowdown_for_curled_perimeters": "1", + "overhang_1_4_speed": "0", + "overhang_2_4_speed": "50", + "overhang_3_4_speed": "30", + "overhang_4_4_speed": "10", + "line_width": "110%", + "inner_wall_line_width": "110%", + "outer_wall_line_width": "100%", + "top_surface_line_width": "93.75%", + "sparse_infill_line_width": "110%", + "initial_layer_line_width": "120%", + "internal_solid_infill_line_width": "120%", + "support_line_width": "96%", + "wall_loops": "3", + "print_settings_id": "", + "raft_layers": "0", + "seam_position": "aligned", + "skirt_distance": "2", + "skirt_height": "3", + "min_skirt_length": "4", + "skirt_loops": "0", + "minimum_sparse_infill_area": "15", + "spiral_mode": "0", + "standby_temperature_delta": "-5", + "enable_support": "0", + "resolution": "0.012", + "support_type": "normal(auto)", + "support_on_build_plate_only": "0", + "support_top_z_distance": "0.2", + "support_bottom_z_distance": "0.2", + "support_filament": "0", + "support_interface_loop_pattern": "0", + "support_interface_filament": "0", + "support_interface_top_layers": "2", + "support_interface_bottom_layers": "2", + "support_interface_spacing": "0.5", + "support_interface_speed": "80", + "support_base_pattern": "default", + "support_base_pattern_spacing": "2.5", + "support_speed": "150", + "support_threshold_angle": "30", + "support_object_xy_distance": "0.35", + "tree_support_branch_angle": "30", + "tree_support_wall_count": "0", + "detect_thin_wall": "0", + "top_surface_pattern": "monotonicline", + "top_shell_thickness": "0.8", + "enable_prime_tower": "1", + "wipe_tower_no_sparse_layers": "0", + "prime_tower_width": "60", + "xy_hole_compensation": "0", + "xy_contour_compensation": "0", + "layer_height": "0.2", + "bottom_shell_layers": "3", + "top_shell_layers": "4", + "bridge_flow": "1", + "initial_layer_speed": "45", + "initial_layer_infill_speed": "45", + "outer_wall_speed": "45", + "inner_wall_speed": "80", + "sparse_infill_speed": "150", + "internal_solid_infill_speed": "150", + "top_surface_speed": "50", + "gap_infill_speed": "30", + "travel_speed": "200" +} diff --git a/resources/profiles/Printcepts.json b/resources/profiles/Printcepts.json new file mode 100644 index 0000000000..70338174d6 --- /dev/null +++ b/resources/profiles/Printcepts.json @@ -0,0 +1,54 @@ +{ + "name": "Printcepts", + "version": "01.00.00.04", + "force_update": "0", + "description": "Printcepts belt printer configurations", + "machine_model_list": [ + { + "name": "BabyBelt Pro", + "sub_path": "machine/BabyBelt Pro.json" + } + ], + "process_list": [ + { + "name": "fdm_process_common", + "sub_path": "process/fdm_process_common.json" + }, + { + "name": "0.20mm Standard @BabyBelt Pro", + "sub_path": "process/0.20mm Standard @BabyBelt Pro.json" + } + ], + "filament_list": [ + { + "name": "Generic PLA @BabyBelt Pro", + "sub_path": "filament/Generic PLA @BabyBelt Pro.json" + }, + { + "name": "eSUN PLA @BabyBelt Pro", + "sub_path": "filament/eSUN PLA @BabyBelt Pro.json" + }, + { + "name": "Generic PETG @BabyBelt Pro", + "sub_path": "filament/Generic PETG @BabyBelt Pro.json" + } + ], + "machine_list": [ + { + "name": "fdm_machine_common", + "sub_path": "machine/fdm_machine_common.json" + }, + { + "name": "fdm_klipper_common", + "sub_path": "machine/fdm_klipper_common.json" + }, + { + "name": "fdm_belt_common", + "sub_path": "machine/fdm_belt_common.json" + }, + { + "name": "BabyBelt Pro 0.4 nozzle", + "sub_path": "machine/BabyBelt Pro 0.4 nozzle.json" + } + ] +} diff --git a/resources/profiles/Printcepts/BabyBelt Pro_bed_texture.svg b/resources/profiles/Printcepts/BabyBelt Pro_bed_texture.svg new file mode 100644 index 0000000000..3193c6a284 --- /dev/null +++ b/resources/profiles/Printcepts/BabyBelt Pro_bed_texture.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/profiles/Printcepts/BabyBelt Pro_cover.png b/resources/profiles/Printcepts/BabyBelt Pro_cover.png new file mode 100644 index 0000000000..aaf5dd266f Binary files /dev/null and b/resources/profiles/Printcepts/BabyBelt Pro_cover.png differ diff --git a/resources/profiles/Printcepts/filament/Generic PETG @BabyBelt Pro.json b/resources/profiles/Printcepts/filament/Generic PETG @BabyBelt Pro.json new file mode 100644 index 0000000000..9b71a420b6 --- /dev/null +++ b/resources/profiles/Printcepts/filament/Generic PETG @BabyBelt Pro.json @@ -0,0 +1,77 @@ +{ + "type": "filament", + "name": "Generic PETG @BabyBelt Pro", + "inherits": "Generic PETG @System", + "from": "system", + "setting_id": "gCzHpDNgVwQR6tgk", + "instantiation": "true", + "filament_extruder_variant": [ + "Direct Drive Standard" + ], + "compatible_printers": [ + "BabyBelt Pro 0.4 nozzle" + ], + "filament_type": [ + "PETG" + ], + "filament_vendor": [ + "Generic" + ], + "filament_settings_id": [ + "Generic PETG @BabyBelt Pro" + ], + "filament_flow_ratio": [ + "0.95" + ], + "filament_cost": [ + "25" + ], + "nozzle_temperature": [ + "240" + ], + "nozzle_temperature_initial_layer": [ + "245" + ], + "cool_plate_temp": [ + "80" + ], + "cool_plate_temp_initial_layer": [ + "80" + ], + "fan_min_speed": [ + "40" + ], + "fan_max_speed": [ + "60" + ], + "overhang_fan_threshold": [ + "25%" + ], + "overhang_fan_speed": [ + "80" + ], + "full_fan_speed_layer": [ + "8" + ], + "slow_down_min_speed": [ + "20" + ], + "slow_down_layer_time": [ + "4" + ], + "fan_cooling_layer_time": [ + "100" + ], + "filament_retraction_length": [ + "2" + ], + "filament_retraction_speed": [ + "40" + ], + "filament_deretraction_speed": [ + "40" + ], + "filament_start_gcode": [ + "; Generic PETG @BabyBelt Pro — belt PETG, bed 80C" + ] +} diff --git a/resources/profiles/Printcepts/filament/Generic PLA @BabyBelt Pro.json b/resources/profiles/Printcepts/filament/Generic PLA @BabyBelt Pro.json new file mode 100644 index 0000000000..3345b5f509 --- /dev/null +++ b/resources/profiles/Printcepts/filament/Generic PLA @BabyBelt Pro.json @@ -0,0 +1,65 @@ +{ + "type": "filament", + "name": "Generic PLA @BabyBelt Pro", + "inherits": "Generic PLA @System", + "from": "system", + "setting_id": "24PpcnhVx9v5f4fD", + "instantiation": "true", + "filament_extruder_variant": [ + "Direct Drive Standard" + ], + "compatible_printers": [ + "BabyBelt Pro 0.4 nozzle" + ], + "filament_type": [ + "PLA" + ], + "filament_vendor": [ + "Generic" + ], + "filament_settings_id": [ + "Generic PLA @BabyBelt Pro" + ], + "nozzle_temperature": [ + "215" + ], + "hot_plate_temp": [ + "75" + ], + "hot_plate_temp_initial_layer": [ + "75" + ], + "cool_plate_temp": [ + "75" + ], + "cool_plate_temp_initial_layer": [ + "75" + ], + "textured_plate_temp": [ + "75" + ], + "textured_plate_temp_initial_layer": [ + "75" + ], + "close_fan_the_first_x_layers": [ + "3" + ], + "full_fan_speed_layer": [ + "8" + ], + "slow_down_min_speed": [ + "20" + ], + "filament_retraction_length": [ + "1.5" + ], + "filament_retraction_speed": [ + "35" + ], + "filament_deretraction_speed": [ + "30" + ], + "filament_start_gcode": [ + "; Generic PLA @BabyBelt Pro — belt PLA, bed 75C" + ] +} diff --git a/resources/profiles/Printcepts/filament/eSUN PLA @BabyBelt Pro.json b/resources/profiles/Printcepts/filament/eSUN PLA @BabyBelt Pro.json new file mode 100644 index 0000000000..1f93703e1c --- /dev/null +++ b/resources/profiles/Printcepts/filament/eSUN PLA @BabyBelt Pro.json @@ -0,0 +1,36 @@ +{ + "type": "filament", + "name": "eSUN PLA @BabyBelt Pro", + "inherits": "Generic PLA @BabyBelt Pro", + "from": "system", + "setting_id": "EH3X7oE0DU5tSpjW", + "filament_id": "OFkrxQC4", + "instantiation": "true", + "compatible_printers": [ + "BabyBelt Pro 0.4 nozzle" + ], + "filament_type": [ + "PLA" + ], + "filament_vendor": [ + "eSUN" + ], + "filament_settings_id": [ + "eSUN PLA @BabyBelt Pro" + ], + "nozzle_temperature_initial_layer": [ + "200" + ], + "nozzle_temperature": [ + "200" + ], + "enable_pressure_advance": [ + "1" + ], + "pressure_advance": [ + "0.12" + ], + "filament_max_volumetric_speed": [ + "20" + ] +} diff --git a/resources/profiles/Printcepts/machine/BabyBelt Pro 0.4 nozzle.json b/resources/profiles/Printcepts/machine/BabyBelt Pro 0.4 nozzle.json new file mode 100644 index 0000000000..84218476cb --- /dev/null +++ b/resources/profiles/Printcepts/machine/BabyBelt Pro 0.4 nozzle.json @@ -0,0 +1,88 @@ +{ + "type": "machine", + "name": "BabyBelt Pro 0.4 nozzle", + "inherits": "fdm_belt_common", + "from": "system", + "setting_id": "34OWINlJpJgA9DwQ", + "instantiation": "true", + "printer_model": "BabyBelt Pro", + "printer_variant": "0.4", + "nozzle_diameter": [ + "0.4" + ], + "default_filament_profile": [ + "Generic PLA @BabyBelt Pro" + ], + "default_print_profile": "0.20mm Standard @BabyBelt Pro", + "printable_area": [ + "0x0", + "95x0", + "95x500", + "0x500" + ], + "printable_height": "100", + "best_object_pos": "0.5,0.05", + "nozzle_type": [ + "hardened_steel" + ], + "printer_extruder_id": [ + "1" + ], + "printer_extruder_variant": [ + "Direct Drive Standard" + ], + "thumbnails": [ + "48x48/PNG", + "300x300/PNG" + ], + "machine_max_acceleration_e": [ + "500", + "5000" + ], + "machine_max_acceleration_extruding": [ + "500", + "20000" + ], + "machine_max_acceleration_retracting": [ + "500", + "5000" + ], + "machine_max_acceleration_x": [ + "500", + "20000" + ], + "machine_max_acceleration_y": [ + "500", + "20000" + ], + "machine_max_junction_deviation": [ + "0.01", + "0.01" + ], + "machine_max_speed_x": [ + "50", + "200" + ], + "machine_max_speed_y": [ + "50", + "200" + ], + "machine_max_speed_z": [ + "5", + "12" + ], + "retraction_length": [ + "1.5" + ], + "retraction_speed": [ + "20" + ], + "deretraction_speed": [ + "25" + ], + "retract_lift_enforce": [ + "Top and Bottom" + ], + "support_chamber_temp_control": "0", + "machine_start_gcode": ";Start GCode\nPRINT_START ANGLE=[belt_slice_rotation_angle] EXTRUDER=[nozzle_temperature_initial_layer] BED=[hot_plate_temp_initial_layer] MATERIAL=[filament_type]\n" +} diff --git a/resources/profiles/Printcepts/machine/BabyBelt Pro.json b/resources/profiles/Printcepts/machine/BabyBelt Pro.json new file mode 100644 index 0000000000..eb711541bd --- /dev/null +++ b/resources/profiles/Printcepts/machine/BabyBelt Pro.json @@ -0,0 +1,12 @@ +{ + "type": "machine_model", + "name": "BabyBelt Pro", + "model_id": "Printcepts_BabyBelt_Pro", + "nozzle_diameter": "0.4", + "machine_tech": "FFF", + "family": "Printcepts", + "bed_model": "", + "bed_texture": "BabyBelt Pro_bed_texture.svg", + "hotend_model": "", + "default_materials": "Generic PLA @BabyBelt Pro;Generic PETG @BabyBelt Pro" +} diff --git a/resources/profiles/Printcepts/machine/fdm_belt_common.json b/resources/profiles/Printcepts/machine/fdm_belt_common.json new file mode 100644 index 0000000000..96d38ac31a --- /dev/null +++ b/resources/profiles/Printcepts/machine/fdm_belt_common.json @@ -0,0 +1,98 @@ +{ + "type": "machine", + "name": "fdm_belt_common", + "inherits": "fdm_klipper_common", + "from": "system", + "instantiation": "false", + "gcode_flavor": "klipper", + "single_extruder_multi_material": "0", + "default_filament_profile": [ + "Generic PLA @System" + ], + "default_print_profile": "0.20mm Standard @BabyBelt Pro", + "max_layer_height": [ + "0.32" + ], + "min_layer_height": [ + "0.08" + ], + "deretraction_speed": [ + "30" + ], + "extruder_colour": [ + "#FCE94F" + ], + "extruder_offset": [ + "0x0" + ], + "long_retractions_when_cut": [ + "0" + ], + "nozzle_diameter": [ + "0.4" + ], + "retract_before_wipe": [ + "70%" + ], + "retract_length_toolchange": [ + "2" + ], + "retract_lift_above": [ + "0" + ], + "retract_lift_below": [ + "0" + ], + "retract_lift_enforce": [ + "All Surfaces" + ], + "retract_restart_extra": [ + "0" + ], + "retract_restart_extra_toolchange": [ + "0" + ], + "retract_when_changing_layer": [ + "1" + ], + "retraction_distances_when_cut": [ + "18" + ], + "retraction_length": [ + "0.8" + ], + "retraction_minimum_travel": [ + "1" + ], + "retraction_speed": [ + "30" + ], + "travel_slope": [ + "3" + ], + "wipe": [ + "1" + ], + "wipe_distance": [ + "1" + ], + "z_hop": [ + "0" + ], + "z_hop_types": [ + "Normal Lift" + ], + "gcode_remap_x": "rev_x", + "gcode_remap_y": "pos_z", + "gcode_remap_z": "pos_y", + "printer_extruder_id": [ + "1" + ], + "belt_printer": "1", + "belt_slice_rotation": "x", + "belt_slice_rotation_angle": "45", + "build_plate_tilt_x": "45", + "purge_in_prime_tower": "0", + "scan_first_layer": "0", + "auxiliary_fan": "0" +} diff --git a/resources/profiles/Printcepts/machine/fdm_klipper_common.json b/resources/profiles/Printcepts/machine/fdm_klipper_common.json new file mode 100644 index 0000000000..5297f646ff --- /dev/null +++ b/resources/profiles/Printcepts/machine/fdm_klipper_common.json @@ -0,0 +1,140 @@ +{ + "type": "machine", + "name": "fdm_klipper_common", + "inherits": "fdm_machine_common", + "from": "system", + "instantiation": "false", + "gcode_flavor": "klipper", + "machine_max_acceleration_e": [ + "5000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "20000", + "20000" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "20000", + "20000" + ], + "machine_max_acceleration_x": [ + "20000", + "20000" + ], + "machine_max_acceleration_y": [ + "20000", + "20000" + ], + "machine_max_acceleration_z": [ + "500", + "200" + ], + "machine_max_speed_e": [ + "25", + "25" + ], + "machine_max_speed_x": [ + "500", + "200" + ], + "machine_max_speed_y": [ + "500", + "200" + ], + "machine_max_speed_z": [ + "12", + "12" + ], + "machine_max_jerk_e": [ + "2.5", + "2.5" + ], + "machine_max_jerk_x": [ + "9", + "9" + ], + "machine_max_jerk_y": [ + "9", + "9" + ], + "machine_max_jerk_z": [ + "0.2", + "0.4" + ], + "machine_min_extruding_rate": [ + "0", + "0" + ], + "machine_min_travel_rate": [ + "0", + "0" + ], + "max_layer_height": [ + "0.32" + ], + "min_layer_height": [ + "0.08" + ], + "printable_height": "250", + "extruder_clearance_radius": "65", + "extruder_clearance_height_to_rod": "36", + "extruder_clearance_height_to_lid": "140", + "printer_settings_id": "", + "printer_technology": "FFF", + "printer_variant": "0.4", + "retraction_minimum_travel": [ + "1" + ], + "retract_before_wipe": [ + "70%" + ], + "retract_when_changing_layer": [ + "1" + ], + "retraction_length": [ + "0.8" + ], + "retract_length_toolchange": [ + "2" + ], + "z_hop": [ + "0.4" + ], + "retract_restart_extra": [ + "0" + ], + "retract_restart_extra_toolchange": [ + "0" + ], + "retraction_speed": [ + "30" + ], + "deretraction_speed": [ + "30" + ], + "z_hop_types": "Normal Lift", + "single_extruder_multi_material": "1", + "change_filament_gcode": "", + "wipe": [ + "1" + ], + "default_filament_profile": [ + "Generic PLA @System" + ], + "default_print_profile": "0.20mm Standard @MyKlipper", + "bed_exclude_area": [ + "0x0" + ], + "machine_start_gcode": "M190 S[bed_temperature_initial_layer_single]\nM109 S[nozzle_temperature_initial_layer]\nPRINT_START EXTRUDER=[nozzle_temperature_initial_layer] BED=[bed_temperature_initial_layer_single]\n", + "machine_end_gcode": "PRINT_END", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\nG92 E0\n", + "machine_pause_gcode": "PAUSE", + "scan_first_layer": "0", + "nozzle_type": "undefine", + "auxiliary_fan": "0" +} diff --git a/resources/profiles/Printcepts/machine/fdm_machine_common.json b/resources/profiles/Printcepts/machine/fdm_machine_common.json new file mode 100644 index 0000000000..bf8f2249cf --- /dev/null +++ b/resources/profiles/Printcepts/machine/fdm_machine_common.json @@ -0,0 +1,118 @@ +{ + "type": "machine", + "name": "fdm_machine_common", + "from": "system", + "instantiation": "false", + "printer_technology": "FFF", + "deretraction_speed": [ + "40" + ], + "extruder_colour": [ + "#FCE94F" + ], + "extruder_offset": [ + "0x0" + ], + "gcode_flavor": "marlin", + "machine_max_acceleration_e": [ + "5000" + ], + "machine_max_acceleration_extruding": [ + "10000" + ], + "machine_max_acceleration_retracting": [ + "1000" + ], + "machine_max_acceleration_x": [ + "10000" + ], + "machine_max_acceleration_y": [ + "10000" + ], + "machine_max_acceleration_z": [ + "500" + ], + "machine_max_speed_e": [ + "60" + ], + "machine_max_speed_x": [ + "500" + ], + "machine_max_speed_y": [ + "500" + ], + "machine_max_speed_z": [ + "10" + ], + "machine_max_jerk_e": [ + "5" + ], + "machine_max_jerk_x": [ + "8" + ], + "machine_max_jerk_y": [ + "8" + ], + "machine_max_jerk_z": [ + "0.4" + ], + "machine_min_extruding_rate": [ + "0" + ], + "machine_min_travel_rate": [ + "0" + ], + "max_layer_height": [ + "0.32" + ], + "min_layer_height": [ + "0.08" + ], + "printable_height": "250", + "extruder_clearance_radius": "65", + "extruder_clearance_height_to_rod": "36", + "extruder_clearance_height_to_lid": "140", + "nozzle_diameter": [ + "0.4" + ], + "printer_settings_id": "", + "printer_variant": "0.4", + "retraction_minimum_travel": [ + "2" + ], + "retract_before_wipe": [ + "70%" + ], + "retract_when_changing_layer": [ + "1" + ], + "retraction_length": [ + "1" + ], + "retract_length_toolchange": [ + "1" + ], + "z_hop": [ + "0" + ], + "retract_restart_extra": [ + "0" + ], + "retract_restart_extra_toolchange": [ + "0" + ], + "retraction_speed": [ + "60" + ], + "single_extruder_multi_material": "1", + "change_filament_gcode": "", + "wipe": [ + "1" + ], + "default_print_profile": "", + "machine_start_gcode": "G0 Z20 F9000\nG92 E0; G1 E-10 F1200\nG28\nM970 Q1 A10 B10 C130 K0\nM970 Q1 A10 B131 C250 K1\nM974 Q1 S1 P0\nM970 Q0 A10 B10 C130 H20 K0\nM970 Q0 A10 B131 C250 K1\nM974 Q0 S1 P0\nM220 S100 ;Reset Feedrate\nM221 S100 ;Reset Flowrate\nG29 ;Home\nG90;\nG92 E0 ;Reset Extruder \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 X10.1 Y20 Z0.28 F5000.0 ;Move to start position\nM109 S205;\nG1 X10.1 Y200.0 Z0.28 F1500.0 E15 ;Draw the first line\nG1 X10.4 Y200.0 Z0.28 F5000.0 ;Move to side a little\nG1 X10.4 Y20 Z0.28 F1500.0 E30 ;Draw the second line\nG92 E0 ;Reset Extruder \nG1 X110 Y110 Z2.0 F3000 ;Move Z Axis up", + "machine_end_gcode": "M400 ; wait for buffer to clear\nG92 E0 ; zero the extruder\nG1 E-4.0 F3600; retract \nG91\nG1 Z3;\nM104 S0 ; turn off hotend\nM140 S0 ; turn off bed\nM106 S0 ; turn off fan\nG90 \nG0 X110 Y200 F3600 \nprint_end", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\nG92 E0\n", + "machine_pause_gcode": "M601" +} diff --git a/resources/profiles/Printcepts/process/0.20mm Standard @BabyBelt Pro.json b/resources/profiles/Printcepts/process/0.20mm Standard @BabyBelt Pro.json new file mode 100644 index 0000000000..f592cfe87e --- /dev/null +++ b/resources/profiles/Printcepts/process/0.20mm Standard @BabyBelt Pro.json @@ -0,0 +1,23 @@ +{ + "type": "process", + "name": "0.20mm Standard @BabyBelt Pro", + "inherits": "fdm_process_common", + "from": "system", + "setting_id": "JGfGtqX6CWjCt437", + "instantiation": "true", + "layer_height": "0.2", + "initial_layer_print_height": "0.2", + "initial_layer_line_width": "0.42", + "wall_loops": "2", + "reduce_infill_retraction": "1", + "detect_overhang_wall": "1", + "skirt_loops": "0", + "skirt_distance": "0", + "sparse_infill_pattern": "grid", + "sparse_infill_speed": "200", + "support_base_pattern": "rectilinear", + "support_interface_pattern": "rectilinear", + "compatible_printers": [ + "BabyBelt Pro 0.4 nozzle" + ] +} diff --git a/resources/profiles/Printcepts/process/fdm_process_common.json b/resources/profiles/Printcepts/process/fdm_process_common.json new file mode 100644 index 0000000000..0d558129af --- /dev/null +++ b/resources/profiles/Printcepts/process/fdm_process_common.json @@ -0,0 +1,106 @@ +{ + "type": "process", + "name": "fdm_process_common", + "from": "system", + "instantiation": "false", + "reduce_crossing_wall": "0", + "max_travel_detour_distance": "0", + "bottom_surface_pattern": "monotonic", + "bottom_shell_thickness": "0", + "bridge_speed": "50", + "brim_width": "5", + "brim_object_gap": "0.1", + "compatible_printers": [], + "compatible_printers_condition": "", + "print_sequence": "by layer", + "default_acceleration": "1000", + "initial_layer_acceleration": "500", + "top_surface_acceleration": "1000", + "travel_acceleration": "1000", + "inner_wall_acceleration": "1000", + "outer_wall_acceleration": "700", + "bridge_no_support": "0", + "draft_shield": "disabled", + "elefant_foot_compensation": "0", + "enable_arc_fitting": "0", + "wall_infill_order": "inner wall/outer wall/infill", + "infill_direction": "45", + "sparse_infill_density": "15%", + "sparse_infill_pattern": "crosshatch", + "initial_layer_print_height": "0.2", + "infill_combination": "0", + "infill_wall_overlap": "25%", + "interface_shells": "0", + "ironing_flow": "10%", + "ironing_spacing": "0.15", + "ironing_speed": "30", + "ironing_type": "no ironing", + "reduce_infill_retraction": "1", + "filename_format": "{input_filename_base}_{layer_height}mm_{filament_type[initial_tool]}_{printer_model}_{print_time}.gcode", + "detect_overhang_wall": "1", + "slowdown_for_curled_perimeters": "1", + "overhang_1_4_speed": "0", + "overhang_2_4_speed": "50", + "overhang_3_4_speed": "30", + "overhang_4_4_speed": "10", + "line_width": "110%", + "inner_wall_line_width": "110%", + "outer_wall_line_width": "100%", + "top_surface_line_width": "93.75%", + "sparse_infill_line_width": "110%", + "initial_layer_line_width": "120%", + "internal_solid_infill_line_width": "120%", + "support_line_width": "96%", + "wall_loops": "3", + "print_settings_id": "", + "raft_layers": "0", + "seam_position": "aligned", + "skirt_distance": "2", + "skirt_height": "3", + "min_skirt_length": "4", + "skirt_loops": "0", + "minimum_sparse_infill_area": "15", + "spiral_mode": "0", + "standby_temperature_delta": "-5", + "enable_support": "0", + "resolution": "0.012", + "support_type": "normal(auto)", + "support_on_build_plate_only": "0", + "support_top_z_distance": "0.2", + "support_bottom_z_distance": "0.2", + "support_filament": "0", + "support_interface_loop_pattern": "0", + "support_interface_filament": "0", + "support_interface_top_layers": "2", + "support_interface_bottom_layers": "2", + "support_interface_spacing": "0.5", + "support_interface_speed": "80", + "support_base_pattern": "default", + "support_base_pattern_spacing": "2.5", + "support_speed": "150", + "support_threshold_angle": "30", + "support_object_xy_distance": "0.35", + "tree_support_branch_angle": "30", + "tree_support_wall_count": "0", + "detect_thin_wall": "0", + "top_surface_pattern": "monotonicline", + "top_shell_thickness": "0.8", + "enable_prime_tower": "1", + "wipe_tower_no_sparse_layers": "0", + "prime_tower_width": "60", + "xy_hole_compensation": "0", + "xy_contour_compensation": "0", + "layer_height": "0.2", + "bottom_shell_layers": "3", + "top_shell_layers": "4", + "bridge_flow": "1", + "initial_layer_speed": "45", + "initial_layer_infill_speed": "45", + "outer_wall_speed": "45", + "inner_wall_speed": "80", + "sparse_infill_speed": "150", + "internal_solid_infill_speed": "150", + "top_surface_speed": "50", + "gap_infill_speed": "30", + "travel_speed": "200" +} diff --git a/resources/shaders/110/gouraud.fs b/resources/shaders/110/gouraud.fs index c5efeef277..50789327c5 100644 --- a/resources/shaders/110/gouraud.fs +++ b/resources/shaders/110/gouraud.fs @@ -26,6 +26,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform vec4 uniform_color; diff --git a/resources/shaders/110/gouraud.vs b/resources/shaders/110/gouraud.vs index 37be529b09..5ee685c070 100644 --- a/resources/shaders/110/gouraud.vs +++ b/resources/shaders/110/gouraud.vs @@ -23,6 +23,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform mat4 view_model_matrix; @@ -77,8 +78,8 @@ void main() // Point in homogenous coordinates. world_pos = volume_world_matrix * vec4(v_position, 1.0); - // z component of normal vector in world coordinate used for slope shading - world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0; + // dot product of world normal with up direction, used for slope shading + world_normal_z = slope.actived ? dot(normalize(slope.volume_world_normal_matrix * v_normal), slope.up_direction) : 0.0; gl_Position = projection_matrix * position; if (is_outline) { diff --git a/resources/shaders/110/mm_gouraud.fs b/resources/shaders/110/mm_gouraud.fs index 821af13f03..431c936e9c 100644 --- a/resources/shaders/110/mm_gouraud.fs +++ b/resources/shaders/110/mm_gouraud.fs @@ -37,6 +37,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform SlopeDetection slope; @@ -85,7 +86,7 @@ void main() color = LightBlue; alpha = 1.0; } - else if( transformed_normal.z < slope.normal_z - EPSILON) + else if( dot(transformed_normal, slope.up_direction) < slope.normal_z - EPSILON) { color = color * 0.5 + LightRed * 0.5; alpha = 1.0; diff --git a/resources/shaders/110/mm_gouraud.vs b/resources/shaders/110/mm_gouraud.vs index b0cea9cfd6..c54ae4bff5 100644 --- a/resources/shaders/110/mm_gouraud.vs +++ b/resources/shaders/110/mm_gouraud.vs @@ -24,6 +24,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform SlopeDetection slope; void main() diff --git a/resources/shaders/110/phong.fs b/resources/shaders/110/phong.fs index a47a24fdea..7d1e7bfe8c 100644 --- a/resources/shaders/110/phong.fs +++ b/resources/shaders/110/phong.fs @@ -41,6 +41,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform vec4 uniform_color; diff --git a/resources/shaders/110/phong.vs b/resources/shaders/110/phong.vs index 10d36e233f..d857147c28 100644 --- a/resources/shaders/110/phong.vs +++ b/resources/shaders/110/phong.vs @@ -7,6 +7,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform mat4 view_model_matrix; @@ -46,8 +47,8 @@ void main() // Point in homogenous coordinates. world_pos = volume_world_matrix * vec4(v_position, 1.0); - // z component of normal vector in world coordinate used for slope shading - world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0; + // dot product of world normal with up direction, used for slope shading + world_normal_z = slope.actived ? dot(normalize(slope.volume_world_normal_matrix * v_normal), slope.up_direction) : 0.0; gl_Position = projection_matrix * position; if (is_outline) { diff --git a/resources/shaders/110/texture_displacement_shaded.fs b/resources/shaders/110/texture_displacement_shaded.fs index 4a2980cd64..44ea89b844 100644 --- a/resources/shaders/110/texture_displacement_shaded.fs +++ b/resources/shaders/110/texture_displacement_shaded.fs @@ -29,19 +29,13 @@ uniform vec3 palette_lab[64]; uniform vec3 palette_rgb[64]; uniform int palette_count; uniform bool pure_only; // match against single filaments only (flat-colour image) -// How each entry prints. A pure entry is one filament (a == b); a mix interleaves filaments a and b, -// num parts of a in every den, and the print shows that interleave rather than the entry's average -// colour. The fragment resolves it exactly as GLGizmoTextureDisplacement::make_mix_resolver() does -// per triangle on the CPU, so the preview shows the pattern the bake will print. +// How each entry prints. Every entry names a single filament: a mix is given its own mixed filament +// slot, whose components the slicer alternates per print layer, so the fragment just looks that slot's +// colour up. uniform int palette_a[64]; uniform int palette_b[64]; -uniform int palette_num[64]; -uniform int palette_den[64]; uniform vec3 filament_rgb[16]; uniform int filament_count; -uniform int mix_mode; // ColorMixMode: 0 Z bands, 1 XY dither, 2 auto -uniform float layer_height; // mm; one Z band per print layer -uniform float dither_cell; // mm; one XY dither cell uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height uniform bool has_color_tex; uniform bool volume_mirrored; @@ -229,63 +223,16 @@ int nearest_palette_entry(vec3 rgb) } // One 2x2 Bayer cell, {0, 2; 3, 1}, for x and y in {0, 1}. -float bayer2(float x, float y) { return 2.0 * x + 3.0 * y - 4.0 * x * y; } -// The colour the printer lays down at world point `pos` for palette entry `index`: its filament, or -// for a mix whichever of its two filaments this point falls on. Mirrors make_mix_resolver() on the -// CPU, floors on the band/cell size included. All the modular arithmetic is done in floats with -// mod(), which wraps negative coordinates the way the CPU's ((v % n) + n) % n does and needs no -// integer % (not available on every GLSL 1.10 target). -vec3 printed_color(int index, vec3 pos, vec3 normal, vec3 footprint) +// The colour the printer lays down at world point `pos` for palette entry `index`. Every entry names a +// single filament: a mix is given its own mixed filament slot, whose components the slicer alternates +// per print layer, so there is nothing left to interleave here. +vec3 printed_color(int index) { int a = palette_a[index]; - int b = palette_b[index]; - if (a < 0 || a >= filament_count || b < 0 || b >= filament_count) + if (a < 0 || a >= filament_count) return palette_rgb[index]; // no filament to resolve to: the entry's own colour - if (a == b) - return filament_rgb[a]; - float num = float(palette_num[index]); - float den = float(palette_den[index]); - // Auto: bands where the surface is steeper than ~45 degrees, the dominant filament elsewhere. - if (mix_mode == 2 && abs(normal.z) >= 0.7) - return filament_rgb[(num * 2.0 >= den) ? a : b]; - - // Pre-filter. The interleave is an ordered dither the eye is meant to blend away, and no dither - // blends when it is drawn at less than a few pixels per period - it aliases, which is what turned - // every upright wall into horizontal streaks: the Z band cycle is den * layer_height (around a - // millimetre), and every pixel of a row on a vertical wall shares one z, so each row came out as a - // 1-bit threshold of the image at that row's phase. `footprint` is mm of world position per pixel, - // so this is zoom- and resolution-correct rather than a tuned constant: where the print's own - // pattern is finer than this view can resolve, show what the print looks like from here, which is - // the entry's perceptual average. The Normal view remains where the per-facet truth lives. - float period = (mix_mode == 1) ? 2.0 * max(dither_cell, 0.01) : den * max(layer_height, 0.01); - float px = (mix_mode == 1) ? max(footprint.x, footprint.y) : footprint.z; - float sharp = clamp(period / max(4.0 * px, 1e-6) - 0.5, 0.0, 1.0); - if (sharp <= 0.0) - return palette_rgb[index]; - - vec3 picked; - if (mix_mode == 1) { - // Ordered 4x4 Bayer over floor(x / cell), floor(y / cell). The CPU's table - // 0 8 2 10 - // 12 4 14 6 - // 3 11 1 9 - // 15 7 13 5 - // is 4 * bayer2(x % 2, y % 2) + bayer2(x / 2, y / 2), which needs no array (GLSL 1.10 has - // no constant arrays). - float cell = max(dither_cell, 0.01); - float gx = mod(floor(pos.x / cell), 4.0); - float gy = mod(floor(pos.y / cell), 4.0); - float bayer = 4.0 * bayer2(mod(gx, 2.0), mod(gy, 2.0)) + bayer2(floor(gx / 2.0), floor(gy / 2.0)); - picked = filament_rgb[(num / den > (bayer + 0.5) / 16.0) ? a : b]; - } else { - // Z bands: one per band height, the band's phase in the a/b cycle picks the filament. Both - // operands are integer-valued, so the half keeps "phase < num" exact under float rounding. - float slot = floor(pos.z / max(layer_height, 0.01)); - float phase = mod(slot, den); - picked = filament_rgb[(phase < num - 0.5) ? a : b]; - } - return mix(palette_rgb[index], picked, sharp); + return filament_rgb[a]; } void main() @@ -296,9 +243,6 @@ void main() // World millimetres throughout, like the bake - see the 140 variant. vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz))); vec3 tex_pos = world_pos.xyz - tex_anchor; // the frame the texture is projected in, as the bake does - // World mm per pixel, for pre-filtering the interleave in printed_color(). Taken here because the - // albedo branch at the end of main() is non-uniform control flow, where derivatives are undefined. - vec3 pos_fwidth = fwidth(world_pos.xyz); if (volume_mirrored) triangle_normal = -triangle_normal; @@ -420,6 +364,6 @@ void main() // orientation and scale about the volume's origin, see texture_displacement_bake_frame()), so // measuring z from the bed instead shifted the band phase by the volume origin's height - a // different filament in the same place than the bake produces. - albedo = printed_color(nearest_palette_entry(texture2D(color_tex, color_uv).rgb), tex_pos, triangle_normal, pos_fwidth); + albedo = printed_color(nearest_palette_entry(texture2D(color_tex, color_uv).rgb)); gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); } diff --git a/resources/shaders/140/gouraud.fs b/resources/shaders/140/gouraud.fs index 7b315f90c0..f609495597 100644 --- a/resources/shaders/140/gouraud.fs +++ b/resources/shaders/140/gouraud.fs @@ -29,6 +29,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform vec4 uniform_color; diff --git a/resources/shaders/140/gouraud.vs b/resources/shaders/140/gouraud.vs index 11fc4b70c8..7f753a3059 100644 --- a/resources/shaders/140/gouraud.vs +++ b/resources/shaders/140/gouraud.vs @@ -23,6 +23,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform mat4 view_model_matrix; @@ -77,8 +78,8 @@ void main() // Point in homogenous coordinates. world_pos = volume_world_matrix * vec4(v_position, 1.0); - // z component of normal vector in world coordinate used for slope shading - world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0; + // dot product of world normal with up direction, used for slope shading + world_normal_z = slope.actived ? dot(normalize(slope.volume_world_normal_matrix * v_normal), slope.up_direction) : 0.0; gl_Position = projection_matrix * position; if (is_outline) { diff --git a/resources/shaders/140/mm_gouraud.fs b/resources/shaders/140/mm_gouraud.fs index c5fe86efc5..7c77d5565e 100644 --- a/resources/shaders/140/mm_gouraud.fs +++ b/resources/shaders/140/mm_gouraud.fs @@ -37,6 +37,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform SlopeDetection slope; @@ -87,7 +88,7 @@ void main() color = LightBlue; alpha = 1.0; } - else if( transformed_normal.z < slope.normal_z - EPSILON) + else if( dot(transformed_normal, slope.up_direction) < slope.normal_z - EPSILON) { color = color * 0.5 + LightRed * 0.5; alpha = 1.0; diff --git a/resources/shaders/140/mm_gouraud.vs b/resources/shaders/140/mm_gouraud.vs index b191e35fa8..191613f957 100644 --- a/resources/shaders/140/mm_gouraud.vs +++ b/resources/shaders/140/mm_gouraud.vs @@ -24,6 +24,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform SlopeDetection slope; void main() diff --git a/resources/shaders/140/phong.fs b/resources/shaders/140/phong.fs index 809621b76d..4cfae1e03e 100644 --- a/resources/shaders/140/phong.fs +++ b/resources/shaders/140/phong.fs @@ -44,6 +44,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform vec4 uniform_color; diff --git a/resources/shaders/140/phong.vs b/resources/shaders/140/phong.vs index c7570edb95..7120b62c66 100644 --- a/resources/shaders/140/phong.vs +++ b/resources/shaders/140/phong.vs @@ -7,6 +7,7 @@ struct SlopeDetection bool actived; float normal_z; mat3 volume_world_normal_matrix; + vec3 up_direction; }; uniform mat4 view_model_matrix; @@ -46,8 +47,8 @@ void main() // Point in homogenous coordinates. world_pos = volume_world_matrix * vec4(v_position, 1.0); - // z component of normal vector in world coordinate used for slope shading - world_normal_z = slope.actived ? (normalize(slope.volume_world_normal_matrix * v_normal)).z : 0.0; + // dot product of world normal with up direction, used for slope shading + world_normal_z = slope.actived ? dot(normalize(slope.volume_world_normal_matrix * v_normal), slope.up_direction) : 0.0; gl_Position = projection_matrix * position; if (is_outline) { diff --git a/resources/shaders/140/texture_displacement_shaded.fs b/resources/shaders/140/texture_displacement_shaded.fs index 1d8bee10fa..b2936598cd 100644 --- a/resources/shaders/140/texture_displacement_shaded.fs +++ b/resources/shaders/140/texture_displacement_shaded.fs @@ -88,19 +88,13 @@ uniform vec3 palette_lab[64]; uniform vec3 palette_rgb[64]; uniform int palette_count; uniform bool pure_only; // match against single filaments only (flat-colour image) -// How each entry prints. A pure entry is one filament (a == b); a mix interleaves filaments a and b, -// num parts of a in every den, and the print shows that interleave rather than the entry's average -// colour. The fragment resolves it exactly as GLGizmoTextureDisplacement::make_mix_resolver() does -// per triangle on the CPU, so the preview shows the pattern the bake will print. +// How each entry prints. Every entry names a single filament: a mix is given its own mixed filament +// slot, whose components the slicer alternates per print layer, so the fragment just looks that slot's +// colour up. uniform int palette_a[64]; uniform int palette_b[64]; -uniform int palette_num[64]; -uniform int palette_den[64]; uniform vec3 filament_rgb[16]; uniform int filament_count; -uniform int mix_mode; // ColorMixMode: 0 Z bands, 1 XY dither, 2 auto -uniform float layer_height; // mm; one Z band per print layer -uniform float dither_cell; // mm; one XY dither cell uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height uniform bool has_color_tex; uniform bool volume_mirrored; @@ -295,63 +289,16 @@ int nearest_palette_entry(vec3 rgb) } // One 2x2 Bayer cell, {0, 2; 3, 1}, for x and y in {0, 1}. -float bayer2(float x, float y) { return 2.0 * x + 3.0 * y - 4.0 * x * y; } -// The colour the printer lays down at world point `pos` for palette entry `index`: its filament, or -// for a mix whichever of its two filaments this point falls on. Mirrors make_mix_resolver() on the -// CPU, floors on the band/cell size included. All the modular arithmetic is done in floats with -// mod(), which wraps negative coordinates the way the CPU's ((v % n) + n) % n does and needs no -// integer % (not available on every GLSL 1.10 target). -vec3 printed_color(int index, vec3 pos, vec3 normal, vec3 footprint) +// The colour the printer lays down at world point `pos` for palette entry `index`. Every entry names a +// single filament: a mix is given its own mixed filament slot, whose components the slicer alternates +// per print layer, so there is nothing left to interleave here. +vec3 printed_color(int index) { int a = palette_a[index]; - int b = palette_b[index]; - if (a < 0 || a >= filament_count || b < 0 || b >= filament_count) + if (a < 0 || a >= filament_count) return palette_rgb[index]; // no filament to resolve to: the entry's own colour - if (a == b) - return filament_rgb[a]; - float num = float(palette_num[index]); - float den = float(palette_den[index]); - // Auto: bands where the surface is steeper than ~45 degrees, the dominant filament elsewhere. - if (mix_mode == 2 && abs(normal.z) >= 0.7) - return filament_rgb[(num * 2.0 >= den) ? a : b]; - - // Pre-filter. The interleave is an ordered dither the eye is meant to blend away, and no dither - // blends when it is drawn at less than a few pixels per period - it aliases, which is what turned - // every upright wall into horizontal streaks: the Z band cycle is den * layer_height (around a - // millimetre), and every pixel of a row on a vertical wall shares one z, so each row came out as a - // 1-bit threshold of the image at that row's phase. `footprint` is mm of world position per pixel, - // so this is zoom- and resolution-correct rather than a tuned constant: where the print's own - // pattern is finer than this view can resolve, show what the print looks like from here, which is - // the entry's perceptual average. The Normal view remains where the per-facet truth lives. - float period = (mix_mode == 1) ? 2.0 * max(dither_cell, 0.01) : den * max(layer_height, 0.01); - float px = (mix_mode == 1) ? max(footprint.x, footprint.y) : footprint.z; - float sharp = clamp(period / max(4.0 * px, 1e-6) - 0.5, 0.0, 1.0); - if (sharp <= 0.0) - return palette_rgb[index]; - - vec3 picked; - if (mix_mode == 1) { - // Ordered 4x4 Bayer over floor(x / cell), floor(y / cell). The CPU's table - // 0 8 2 10 - // 12 4 14 6 - // 3 11 1 9 - // 15 7 13 5 - // is 4 * bayer2(x % 2, y % 2) + bayer2(x / 2, y / 2), which needs no array (GLSL 1.10 has - // no constant arrays). - float cell = max(dither_cell, 0.01); - float gx = mod(floor(pos.x / cell), 4.0); - float gy = mod(floor(pos.y / cell), 4.0); - float bayer = 4.0 * bayer2(mod(gx, 2.0), mod(gy, 2.0)) + bayer2(floor(gx / 2.0), floor(gy / 2.0)); - picked = filament_rgb[(num / den > (bayer + 0.5) / 16.0) ? a : b]; - } else { - // Z bands: one per band height, the band's phase in the a/b cycle picks the filament. Both - // operands are integer-valued, so the half keeps "phase < num" exact under float rounding. - float slot = floor(pos.z / max(layer_height, 0.01)); - float phase = mod(slot, den); - picked = filament_rgb[(phase < num - 0.5) ? a : b]; - } - return mix(palette_rgb[index], picked, sharp); + return filament_rgb[a]; } void main() @@ -364,9 +311,6 @@ void main() // world position and perturb the world normal. vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz))); vec3 tex_pos = world_pos.xyz - tex_anchor; // the frame the texture is projected in, as the bake does - // World mm per pixel, for pre-filtering the interleave in printed_color(). Taken here because the - // albedo branch at the end of main() is non-uniform control flow, where derivatives are undefined. - vec3 pos_fwidth = fwidth(world_pos.xyz); if (volume_mirrored) triangle_normal = -triangle_normal; @@ -508,6 +452,6 @@ void main() // orientation and scale about the volume's origin, see texture_displacement_bake_frame()), so // measuring z from the bed instead shifted the band phase by the volume origin's height - a // different filament in the same place than the bake produces. - albedo = printed_color(nearest_palette_entry(texture(color_tex, color_uv).rgb), tex_pos, triangle_normal, pos_fwidth); + albedo = printed_color(nearest_palette_entry(texture(color_tex, color_uv).rgb)); out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); } diff --git a/scripts/orca_profile_tool.py b/scripts/orca_profile_tool.py index f7f622c8c0..ec94eb7653 100755 --- a/scripts/orca_profile_tool.py +++ b/scripts/orca_profile_tool.py @@ -167,6 +167,11 @@ OBSOLETE_KEYS = { "filament_load_time", "filament_unload_time", "smooth_coefficient", "overhang_totally_speed", "silent_mode", "overhang_speed_classic", "anisotropic_surfaces", + # Belt printer options retired before the feature shipped (#16236). + "belt_slice_rotation_global", "preslice_remap_x", "preslice_remap_y", "preslice_remap_z", + "preslice_remap_global", "belt_support_z_offset_mode", "first_layer_plane", + "first_layer_plane_offset", "belt_preslice_global", "gcode_back_transform", + "belt_support_floor_mode", "first_layer_plane_thickness", } # Keys renamed at some point, whose old and new spellings must never co-exist: diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f0de57f12c..0acbd610f8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -91,6 +91,12 @@ if (SLIC3R_GUI) # list(REMOVE_ITEM wxWidgets_LIBRARIES oleacc) find_package(wxInspector REQUIRED) + # wxInspector 1.0.0 installs its headers but accidentally declares the + # INSTALL_INTERFACE include directory PRIVATE, so its imported target does + # not expose them to consumers. Restore the package prefix include path until + # the upstream export is fixed. + get_filename_component(WXINSPECTOR_PREFIX "${wxInspector_DIR}/../../.." ABSOLUTE) + target_include_directories(wxInspector::wxInspector INTERFACE "${WXINSPECTOR_PREFIX}/include") # wxInspector's exported interface names the release wxWidgets import # libraries, which a Debug build cannot link. wx is linked above instead. @@ -186,7 +192,7 @@ endif () # Add the Slic3r GUI library, libcurl, OpenGL and GLU libraries. if (SLIC3R_GUI) # target_link_libraries(OrcaSlicer ws2_32 uxtheme setupapi libslic3r_gui ${wxWidgets_LIBRARIES}) -target_link_libraries(OrcaSlicer libslic3r_gui) +target_link_libraries(OrcaSlicer libslic3r_gui wxInspector::wxInspector) if (MSVC) # Generate debug symbols even in release mode. target_link_options(OrcaSlicer PUBLIC "$<$:/DEBUG>") diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index b895c25e89..1b764fe468 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -1820,6 +1820,8 @@ int CLI::run(int argc, char **argv) old_printable_width = static_cast(old_printable_bbox.size().x()); old_printable_depth = static_cast(old_printable_bbox.size().y()); } + // A 3mf can carry an empty project_settings.config - the models in + // resources/handy_models do - and opt_float() dereferences without checking. if (config.option("printable_height")) old_printable_height = (int)(config.opt_float("printable_height")); @@ -2505,7 +2507,8 @@ int CLI::run(int argc, char **argv) orig_printable_width = static_cast(orig_printable_bbox.size().x()); orig_printable_depth = static_cast(orig_printable_bbox.size().y()); } - orig_printable_height = (int)(config.opt_float("printable_height")); + if (config.option("printable_height")) + orig_printable_height = (int)(config.opt_float("printable_height")); BOOST_LOG_TRIVIAL(info) << __FUNCTION__<< boost::format(":%1%, check printable size: old_printable_width=%2%, orig_printable_width=%3%, old_printable_depth=%4%, orig_printable_depth=%5%, old_printable_height=%6%, orig_printable_height=%7%") %__LINE__ %old_printable_width %orig_printable_width %old_printable_depth %orig_printable_depth %old_printable_height %orig_printable_height; if ((orig_printable_width > 0) && (orig_printable_depth > 0) && (orig_printable_height > 0)) @@ -3432,9 +3435,14 @@ int CLI::run(int argc, char **argv) max_self_index = std::max(max_self_index, v); min_self_index = std::min(min_self_index, v); } - if (max_self_index > filament_count || min_self_index < 1) { - BOOST_LOG_TRIVIAL(warning) << boost::format("filament_self_index range [%1%, %2%] is invalid for filament_count %3%, regenerating") - % min_self_index % max_self_index % filament_count; + // And a project saved with FEWER filaments than are now loaded (a + // one-filament project sliced with two --load-filaments) leaves the tables half filled: + // the variant matching below then reads past filament_extruder_variant and + // set_with_restore_2 throws an uncaught size error. Regenerate in that case too. + if (max_self_index > filament_count || min_self_index < 1 || max_self_index < filament_count + || (int) filament_self_index_opt->values.size() < filament_count) { + BOOST_LOG_TRIVIAL(warning) << boost::format("filament_self_index range [%1%, %2%] (size %4%) is invalid for filament_count %3%, regenerating") + % min_self_index % max_self_index % filament_count % filament_self_index_opt->values.size(); need_regenerate_self_index = true; } } @@ -3525,6 +3533,10 @@ int CLI::run(int argc, char **argv) std::vector& filament_variants = curr_variant_opt->values; filament_variants.resize(filament_count, get_extruder_variant_string(etDirectDrive, nvtStandard)); } + // See the filament_self_index note above: one variant per filament for + // the filaments the project did not know about. + if ((int) curr_variant_opt->values.size() < filament_count) + curr_variant_opt->values.resize(filament_count, get_extruder_variant_string(etDirectDrive, nvtStandard)); const ConfigOptionStrings *new_variant_opt = dynamic_cast(config.option("filament_extruder_variant", true)); std::vector new_variant_indice; @@ -3533,7 +3545,7 @@ int CLI::run(int argc, char **argv) for (int i = 0; i < new_variant_count; i++) { - for (int j = old_start_indice[filament_index - 1]; j < old_start_indice[filament_index - 1] + old_variant_count; j++) + for (int j = old_start_indice[filament_index - 1]; j < old_start_indice[filament_index - 1] + old_variant_count && j < (int) curr_variant_opt->values.size(); j++) { if (curr_variant_opt->values[j] == new_variant_opt->values[i]) { new_variant_indice[i] = j; @@ -3585,7 +3597,18 @@ int CLI::run(int argc, char **argv) ConfigOptionVectorBase* opt_vec_dst = static_cast(opt); const ConfigOptionVectorBase* opt_vec_src = static_cast(source_opt); //set with index - opt_vec_dst->set_with_restore_2(opt_vec_src, new_variant_indice, old_start_indice[filament_index - 1], old_variant_count); + try { + // A project with fewer filaments than are loaded: grow the + // destination to the filament's slot first (set_with_restore_2 only restores). + if (opt_vec_src->size() > 0 && opt_vec_dst->size() < size_t(old_start_indice[filament_index - 1] + old_variant_count)) + opt_vec_dst->resize(size_t(old_start_indice[filament_index - 1] + old_variant_count), opt_vec_src); + opt_vec_dst->set_with_restore_2(opt_vec_src, new_variant_indice, old_start_indice[filament_index - 1], old_variant_count); + } catch (const std::exception &ex) { // Was an uncaught abort + BOOST_LOG_TRIVIAL(error) << boost::format("filament %1%: option %2% could not be applied: %3%") % filament_index % opt_key % ex.what(); + boost::nowide::cerr << "filament " << filament_index << ": option " << opt_key << " could not be applied: " << ex.what() << std::endl; + record_exit_reson(outfile_dir, CLI_CONFIG_FILE_ERROR, 0, cli_errors[CLI_CONFIG_FILE_ERROR], sliced_info); + flush_and_exit(CLI_CONFIG_FILE_ERROR); + } } continue; @@ -3634,7 +3657,16 @@ int CLI::run(int argc, char **argv) if (filament_options_with_variant.find(opt_key) != filament_options_with_variant.end()) { std::vector temp_variant_indice; temp_variant_indice.resize(new_variant_count, -1); - opt_vec_dst->set_with_restore_2(opt_vec_src, temp_variant_indice, old_start_indice[filament_index - 1], old_variant_count, true); + try { + if (opt_vec_src->size() > 0 && opt_vec_dst->size() < size_t(old_start_indice[filament_index - 1] + old_variant_count)) // See above + opt_vec_dst->resize(size_t(old_start_indice[filament_index - 1] + old_variant_count), opt_vec_src); + opt_vec_dst->set_with_restore_2(opt_vec_src, temp_variant_indice, old_start_indice[filament_index - 1], old_variant_count, true); + } catch (const std::exception &ex) { // Was an uncaught abort + BOOST_LOG_TRIVIAL(error) << boost::format("filament %1%: option %2% could not be applied: %3%") % filament_index % opt_key % ex.what(); + boost::nowide::cerr << "filament " << filament_index << ": option " << opt_key << " could not be applied: " << ex.what() << std::endl; + record_exit_reson(outfile_dir, CLI_CONFIG_FILE_ERROR, 0, cli_errors[CLI_CONFIG_FILE_ERROR], sliced_info); + flush_and_exit(CLI_CONFIG_FILE_ERROR); + } if (opt_key == "filament_extruder_variant") new_variant_counts[filament_index - 1] = opt_vec_src->size(); @@ -4150,6 +4182,10 @@ int CLI::run(int argc, char **argv) BOOST_LOG_TRIVIAL(info) << boost::format("%1%, set disable_wipe_tower_after_mapping back to false due to wrapping detect")%__LINE__; } + // Belt printers never get the classic wipe tower (see Print::has_wipe_tower()), so reserve no space for it. + const ConfigOptionBool* belt_printer_opt = m_print_config.option("belt_printer"); + const bool is_belt_printer = belt_printer_opt && belt_printer_opt->value; + auto timelapse_type_opt = m_print_config.option("timelapse_type"); bool is_smooth_timelapse = false; if (enable_timelapse && timelapse_type_opt && (timelapse_type_opt->getInt() == TimelapseType::tlSmooth)) @@ -4387,11 +4423,11 @@ int CLI::run(int argc, char **argv) } }; - auto check_plate_wipe_tower = [get_print_sequence, is_smooth_timelapse](Slic3r::GUI::PartPlate* plate, int plate_index, DynamicPrintConfig& print_config, plate_obj_size_info_t &plate_obj_size_info) { + auto check_plate_wipe_tower = [get_print_sequence, is_smooth_timelapse, is_belt_printer](Slic3r::GUI::PartPlate* plate, int plate_index, DynamicPrintConfig& print_config, plate_obj_size_info_t &plate_obj_size_info) { plate_obj_size_info.obj_bbox= plate->get_objects_bounding_box(); BOOST_LOG_TRIVIAL(info) << boost::format("plate %1%, object bbox: min {%2%, %3%, %4%} - max {%5%, %6%, %7%}") %(plate_index+1) %plate_obj_size_info.obj_bbox.min.x() % plate_obj_size_info.obj_bbox.min.y() % plate_obj_size_info.obj_bbox.min.z() %plate_obj_size_info.obj_bbox.max.x() % plate_obj_size_info.obj_bbox.max.y() % plate_obj_size_info.obj_bbox.max.z(); - if (!print_config.has("wipe_tower_x")) { + if (is_belt_printer || !print_config.has("wipe_tower_x")) { plate_obj_size_info.has_wipe_tower = false; BOOST_LOG_TRIVIAL(info) << boost::format("can not found wipe_tower_x in config, set to no wipe tower"); return; @@ -4617,7 +4653,8 @@ int CLI::run(int argc, char **argv) BoundingBoxf temp_printable_bbox(temp_printable_area); printer_plate.printable_width = static_cast(temp_printable_bbox.size().x()); printer_plate.printable_depth = static_cast(temp_printable_bbox.size().y()); - printer_plate.printable_height = (int)(config.opt_float("printable_height")); + if (config.option("printable_height")) + printer_plate.printable_height = (int)(config.opt_float("printable_height")); } if (temp_exclude_area.size() >= 4) { printer_plate.exclude_width = (int)(temp_exclude_area[2].x() - temp_exclude_area[0].x()); @@ -5261,7 +5298,7 @@ int CLI::run(int argc, char **argv) } } - if ((!arrange_cfg.is_seq_print && (assemble_plate.filaments_count > 1))||(enable_wrapping_detect && !current_wrapping_exclude_area.empty())) + if (!is_belt_printer && ((!arrange_cfg.is_seq_print && (assemble_plate.filaments_count > 1)) || (enable_wrapping_detect && !current_wrapping_exclude_area.empty()))) { //prepare the wipe tower int plate_count = partplate_list.get_plate_count(); @@ -5413,7 +5450,7 @@ int CLI::run(int argc, char **argv) bool is_seq_print = false; get_print_sequence(cur_plate, m_print_config, is_seq_print); - if (!is_seq_print && (assemble_plate.filaments_count > 1) && !has_wipe_tower_position) + if (!is_belt_printer && !is_seq_print && (assemble_plate.filaments_count > 1) && !has_wipe_tower_position) { //prepare the wipe tower auto printer_structure_opt = m_print_config.option>("printer_structure"); @@ -5581,7 +5618,7 @@ int CLI::run(int argc, char **argv) }; const int max_filament_count = plate_count > 0 ? *std::max_element(plate_filament_counts.begin(), plate_filament_counts.end()) : 0; - if (plate_needs_wipe_tower(max_filament_count)) + if (!is_belt_printer && plate_needs_wipe_tower(max_filament_count)) { //prepare the wipe tower auto printer_structure_opt = m_print_config.option>("printer_structure"); @@ -5688,7 +5725,7 @@ int CLI::run(int argc, char **argv) BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": found single object mode"); } - if (m_print_config.has("wipe_tower_x") && (is_smooth_timelapse || !arrange_cfg.is_seq_print || (selected.size() <= 1))) { + if (!is_belt_printer && m_print_config.has("wipe_tower_x") && (is_smooth_timelapse || !arrange_cfg.is_seq_print || (selected.size() <= 1))) { float x; float y; if (duplicate_count > 0) { @@ -6313,7 +6350,7 @@ int CLI::run(int argc, char **argv) // The stored (or default) tower position may not fit the tower these plates // need, and no CLI placement site runs on a plain slice - mirror the GUI's // reload clamp and fit every plate's tower into the printable area first. - if (m_print_config.option("enable_prime_tower", true)->value) { + if (!is_belt_printer && m_print_config.option("enable_prime_tower", true)->value) { for (int index = 0; index < partplate_list.get_plate_count(); index++) { if ((plate_to_slice != 0) && (plate_to_slice != (index + 1))) continue; diff --git a/src/dev-utils/CMakeLists.txt b/src/dev-utils/CMakeLists.txt index e718da9e83..19fa1c3ce3 100644 --- a/src/dev-utils/CMakeLists.txt +++ b/src/dev-utils/CMakeLists.txt @@ -28,6 +28,12 @@ if (ORCA_TOOLS) target_link_libraries(generate_system_cache libslic3r boost_headeronly) target_compile_definitions(generate_system_cache PRIVATE ${_DEV_DEFS}) + # texture_unwrap_dump: reports the LSCM unwrap of a saved project's texture displacement layers, + # chart by chart, so a defect can be reproduced from the project file instead of from a screenshot. + add_executable(texture_unwrap_dump texture_unwrap_dump.cpp) + target_link_libraries(texture_unwrap_dump libslic3r boost_headeronly nanosvg) + target_compile_definitions(texture_unwrap_dump PRIVATE ${_DEV_DEFS}) + # profile_include_dump: prints what included templates contribute to a vendor's presets, # to diff against the same tool built in BambuStudio. Built only on request. add_executable(profile_include_dump EXCLUDE_FROM_ALL profile_include_dump.cpp) diff --git a/src/dev-utils/OrcaSlicer_profile_validator.cpp b/src/dev-utils/OrcaSlicer_profile_validator.cpp index 687292b8c7..208b950cfb 100644 --- a/src/dev-utils/OrcaSlicer_profile_validator.cpp +++ b/src/dev-utils/OrcaSlicer_profile_validator.cpp @@ -202,6 +202,7 @@ Vec2d place_wipe_tower(DynamicPrintConfig &cfg, const Vec2d ¢er) std::string slice_two_color_cube_and_export(DynamicPrintConfig cfg, bool is_bbl, bool by_object) { const Vec2d center = printable_area_center(cfg); + const bool belt = cfg.opt_bool("belt_printer"); std::vector cube_mins; if (by_object) { // By-object printing fires the hook only without a wipe tower, and rules out clumping detection and @@ -212,6 +213,12 @@ std::string slice_two_color_cube_and_export(DynamicPrintConfig cfg, bool is_bbl, cfg.set_key_value("timelapse_type", new ConfigOptionEnum(tlTraditional)); cfg.set_key_value("skirt_loops", new ConfigOptionInt(0)); cube_mins = {center + Vec2d(-20., -5.), center + Vec2d(10., -5.)}; + } else if (belt) { + // A belt object's slicing Z starts at the belt below its leading end, well below its first + // printed layer, so a height range in slicing Z does not map onto the part. Two cubes one + // behind the other along the belt, the second on filament 2, give the one filament change + // instead (the purge prism is an object the GUI adds, so there is no tower to place). + cube_mins = {center - Vec2d(5., 15.), center + Vec2d(-5., 5.)}; } else { // Clumping detection changes the tower footprint, so turn it on before placing the tower. if (!cfg.opt_string("wrapping_detection_gcode").empty()) @@ -229,14 +236,20 @@ std::string slice_two_color_cube_and_export(DynamicPrintConfig cfg, bool is_bbl, obj->name = "cube"; // populates [input_filename_base] the way a loaded model does obj->add_volume(m); obj->add_instance(); - // Filament 2 is used only above z=4, so the upper layers carry a single filament change. - DynamicPrintConfig range_config; - range_config.set_key_value("extruder", new ConfigOptionInt(2)); - // Every range must carry a layer_height; use the process's own so a fine nozzle (e.g. 0.15 mm - // printing ~0.1 mm layers) isn't forced to a height its extrusion width can't support - that - // trips Flow::with_spacing. - range_config.set_key_value("layer_height", new ConfigOptionFloat(cfg.opt_float("layer_height"))); - obj->layer_config_ranges[{4.0, 10.0}].assign_config(std::move(range_config)); + if (belt && !by_object) { + // The second cube along the belt is on filament 2 (see cube_mins above). + if (&cube_min == &cube_mins.back()) + obj->config.set_key_value("extruder", new ConfigOptionInt(2)); + } else { + // Filament 2 is used only above z=4, so the upper layers carry a single filament change. + DynamicPrintConfig range_config; + range_config.set_key_value("extruder", new ConfigOptionInt(2)); + // Every range must carry a layer_height; use the process's own so a fine nozzle (e.g. 0.15 mm + // printing ~0.1 mm layers) isn't forced to a height its extrusion width can't support - that + // trips Flow::with_spacing. + range_config.set_key_value("layer_height", new ConfigOptionFloat(cfg.opt_float("layer_height"))); + obj->layer_config_ranges[{4.0, 10.0}].assign_config(std::move(range_config)); + } obj->ensure_on_bed(); print.auto_assign_extruders(obj); } @@ -521,11 +534,16 @@ int slice_all_printers(const std::string &vendor, const std::string &outdir) const std::string filament_name = bundle.filaments.get_selected_preset_name(); const std::string what = "Printer \"" + printer + "\""; const std::string file_base = sanitize_filename(vendor_name) + "__" + sanitize_filename(printer); + // A belt printer has no wipe tower (it purges into a prism object on the belt), so its + // filament change is the plain tool change set_extruder() emits rather than the tower's block. + const bool belt = bundle.printers.get_selected_preset().config.opt_bool("belt_printer"); + const std::string marker = belt ? "\nT1\n" : "CP TOOLCHANGE START"; if (const std::string out = slice_selection(bundle, what, false, outdir, file_base); out.empty()) ++failures; - else if (out.find("CP TOOLCHANGE START") == std::string::npos) { - // The filament change never rode the tower, so change_filament_gcode was not exercised. - BOOST_LOG_TRIVIAL(error) << what << " sliced but the filament change never fired (no CP TOOLCHANGE START)"; + else if (out.find(marker) == std::string::npos) { + // The filament change never fired, so change_filament_gcode was not exercised. + BOOST_LOG_TRIVIAL(error) << what << " sliced but the filament change never fired (no " + << (belt ? "T1" : "CP TOOLCHANGE START") << ")"; ++failures; } cover(bundle.prints.get_selected_preset()); diff --git a/src/dev-utils/texture_unwrap_dump.cpp b/src/dev-utils/texture_unwrap_dump.cpp new file mode 100644 index 0000000000..5c94f4b12b --- /dev/null +++ b/src/dev-utils/texture_unwrap_dump.cpp @@ -0,0 +1,292 @@ +// Diagnostic for the LSCM unwrap of a texture displacement layer. +// +// It exists because the defect it hunts only shows up on a real painted patch: the paint mask is built +// by TriangleSelector splitting base triangles, so the patch topology cannot be written down by hand, +// and reasoning about it from a screenshot of the 3D view had already produced three wrong diagnoses. +// This loads a saved project, rebuilds exactly the patch the bake would act on, runs the same unwrap, +// and reports what came out - per chart, so a bad one can be pointed at rather than guessed at. +// +// texture_unwrap_dump + +// nanosvg is header-only and libslic3r's 3mf import references it without carrying the implementation, +// so every executable that links libslic3r has to supply it. Must precede any include that pulls the +// header in, or its include guard suppresses the implementation. Same pattern as the other dev tools. +#define NANOSVG_IMPLEMENTATION +#include "nanosvg/nanosvg.h" +#define NANOSVGRAST_IMPLEMENTATION +#include "nanosvg/nanosvgrast.h" + +#include +#include +#include +#include +#include +#include + +#include "libslic3r/Model.hpp" +#include "libslic3r/TextureDisplacement.hpp" +#include "libslic3r/Format/bbs_3mf.hpp" +#include "libslic3r/Utils.hpp" + +#include + +using namespace Slic3r; + +namespace { + +uint64_t edge_key(int a, int b) +{ + if (a > b) + std::swap(a, b); + return (uint64_t(uint32_t(a)) << 32) | uint32_t(b); +} + +// Boundary loops and the Euler characteristic of a face set, which together say whether a chart is the +// topological disk LSCM needs (one loop, V - E + F == 1). +void chart_topology(const indexed_triangle_set &mesh, const std::vector &faces, int &loops, int &euler) +{ + std::unordered_map edge_use; + std::unordered_map local; + for (const int f : faces) { + const stl_triangle_vertex_indices &t = mesh.indices[size_t(f)]; + for (int i = 0; i < 3; ++i) { + ++edge_use[edge_key(t[i], t[(i + 1) % 3])]; + local.emplace(t[i], int(local.size())); + } + } + euler = int(local.size()) - int(edge_use.size()) + int(faces.size()); + + std::unordered_map parent; + const std::function find = [&](int x) { + while (parent[x] != x) + x = parent[x] = parent[parent[x]]; + return x; + }; + for (const auto &[key, uses] : edge_use) + if (uses == 1) + for (const int v : { int(key >> 32), int(uint32_t(key)) }) + parent.emplace(v, v); + for (const auto &[key, uses] : edge_use) + if (uses == 1) { + const int a = find(int(key >> 32)), b = find(int(uint32_t(key))); + if (a != b) + parent[b] = a; + } + std::unordered_map roots; + for (const auto &[v, p] : parent) + roots[find(v)] = 1; + loops = int(roots.size()); +} + +float signed_area_2d(const Vec2f &a, const Vec2f &b, const Vec2f &c) +{ + return 0.5f * ((b.x() - a.x()) * (c.y() - a.y()) - (c.x() - a.x()) * (b.y() - a.y())); +} + +} // namespace + +int main(int argc, char **argv) +{ + if (argc < 2) { + std::printf("usage: texture_unwrap_dump \n"); + return 2; + } + + Model model; + DynamicPrintConfig config; + ConfigSubstitutionContext ctx(ForwardCompatibilitySubstitutionRule::Enable); + PlateDataPtrs plate_data; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + // The importer writes a backup copy under the data dir and silently loses objects without one. + const boost::filesystem::path tmp = boost::filesystem::temp_directory_path() / "texture_unwrap_dump"; + boost::filesystem::create_directories(tmp); + set_data_dir(tmp.string()); + + // LoadModel so the meshes come through; AddDefaultInstances because an object with no instance is + // dropped by the plate mapping, which is what "skip this object" in the log means. + if (!load_bbs_3mf(argv[1], &config, &ctx, &model, &plate_data, &project_presets, &is_bbl_3mf, &is_orca_3mf, + &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig | LoadStrategy::AddDefaultInstances | + LoadStrategy::Silence)) { + std::printf("failed to load %s\n", argv[1]); + return 1; + } + + std::printf("loaded: %zu object(s)\n", model.objects.size()); + + for (const ModelObject *object : model.objects) + for (const ModelVolume *volume : object->volumes) { + if (volume->texture_displacement_layers.empty()) { + std::printf("volume \"%s\": no texture displacement layers; paint masks per slot:", + volume->name.c_str()); + for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) + std::printf(" %zu", volume->texture_displacement_facet(i).get_data().triangles_to_split.size()); + std::printf("\n"); + continue; + } + std::printf("volume \"%s\": %zu base triangles, %zu layer(s)\n", volume->name.c_str(), + volume->mesh().its.indices.size(), volume->texture_displacement_layers.size()); + + for (const TextureDisplacementLayer &layer : volume->texture_displacement_layers) { + std::printf("\n layer %d \"%s\" mapping=%d seam_angle=%.1f connect=%d islands_stored=%zu\n", + layer.slot, layer.name.c_str(), int(layer.projection_method), + layer.lscm_seam_angle_deg, int(layer.auto_connect_islands), layer.islands.size()); + if (layer.projection_method != TextureProjectionMethod::LSCM) + continue; + + const indexed_triangle_set patch = + extract_painted_patch(volume->mesh().its, volume->texture_displacement_facet(layer.slot).get_data()); + std::printf(" patch: %zu vertices, %zu triangles\n", patch.vertices.size(), patch.indices.size()); + if (patch.indices.empty()) + continue; + + const auto t0 = std::chrono::steady_clock::now(); + const PatchUnwrap unwrap = compute_patch_unwrap(patch, layer.lscm_seam_angle_deg, 0.f, + layer.lscm_seam_edges); + const auto t1 = std::chrono::steady_clock::now(); + std::printf(" TIMING compute_patch_unwrap: %.0f ms\n", + std::chrono::duration(t1 - t0).count()); + std::printf(" unwrap: %d charts, %zu unwrapped triangles\n", unwrap.chart_count, + unwrap.indices.size()); + + // Group the patch's faces by chart so each can be examined on its own. + std::vector> chart_faces(size_t(std::max(unwrap.chart_count, 0))); + for (size_t i = 0; i < unwrap.indices.size(); ++i) { + const int chart = unwrap.vertex_chart[size_t(unwrap.indices[i][0])]; + if (chart >= 0 && size_t(chart) < chart_faces.size()) + chart_faces[size_t(chart)].push_back(unwrap.source_face[i]); + } + + int bad_charts = 0; + for (size_t c = 0; c < chart_faces.size(); ++c) { + int loops = 0, euler = 0; + chart_topology(patch, chart_faces[c], loops, euler); + + // Flipped triangles: the unwrap folded over itself, which is what a planar fallback + // does to a chart that is not flat. Measured on the unwrap's own triangles. + int pos = 0, neg = 0; + for (size_t i = 0; i < unwrap.indices.size(); ++i) { + const stl_triangle_vertex_indices &t = unwrap.indices[i]; + if (unwrap.vertex_chart[size_t(t[0])] != int(c)) + continue; + const float a = signed_area_2d(unwrap.uvs[size_t(t[0])], unwrap.uvs[size_t(t[1])], + unwrap.uvs[size_t(t[2])]); + if (a > 0.f) ++pos; else if (a < 0.f) ++neg; + } + const int flipped = std::min(pos, neg); + const bool disk = loops == 1 && euler == 1; + if (!disk || flipped > 0) { + ++bad_charts; + std::printf(" chart %2zu: %4zu faces loops=%d euler=%d%s flipped=%d/%d%s\n", c, + chart_faces[c].size(), loops, euler, disk ? "" : " NOT A DISK", flipped, + pos + neg, flipped ? " FOLDED" : ""); + } + } + std::printf(" charts with a defect: %d / %d\n", bad_charts, unwrap.chart_count); + + // What the eye actually sees. Every patch edge shared by two charts should carry the same + // UV on both sides once the islands are laid out as a connected net; where it does not, + // the texture jumps across that seam. Measured through compute_lscm_uvs(), i.e. the exact + // coordinates the bake and the checker overlay sample. + { + const auto n0 = std::chrono::steady_clock::now(); + const std::vector net = compute_connected_net(unwrap); + const auto n1 = std::chrono::steady_clock::now(); + std::printf(" TIMING compute_connected_net: %.0f ms (%zu islands)\n", + std::chrono::duration(n1 - n0).count(), net.size()); + } + const auto t2 = std::chrono::steady_clock::now(); + const std::vector uv = compute_lscm_uvs(patch, layer); + const auto t3 = std::chrono::steady_clock::now(); + std::printf(" TIMING compute_lscm_uvs: %.0f ms (called on every preview, overlay and bake)\n", + std::chrono::duration(t3 - t2).count()); + if (uv.size() != patch.vertices.size()) { + std::printf(" compute_lscm_uvs returned %zu uvs for %zu vertices\n", uv.size(), + patch.vertices.size()); + continue; + } + // Per-corner UVs carry each chart's own placement, so an edge shared by two charts shows + // the jump directly: the same mesh vertex lands at two different UVs. That is exactly what + // the eye reads as the texture breaking. + const auto t4 = std::chrono::steady_clock::now(); + const std::vector corner = compute_lscm_corner_uvs(patch, layer); + const auto t5 = std::chrono::steady_clock::now(); + std::printf(" TIMING compute_lscm_corner_uvs: %.0f ms\n", + std::chrono::duration(t5 - t4).count()); + // Keyed by edge, holding the UV each incident face gives to the edge's *lower-numbered* + // endpoint. Comparing that same vertex on both sides is the point: indexing by corner + // position instead compares opposite ends of the edge, because the two faces wind it in + // opposite directions. + std::unordered_map> edge_seen; + if (corner.size() == patch.indices.size() * 3) + for (size_t f = 0; f < patch.indices.size(); ++f) { + const stl_triangle_vertex_indices &t = patch.indices[f]; + for (int k = 0; k < 3; ++k) { + const int a = t[k], b = t[(k + 1) % 3]; + const int probe = std::min(a, b); + const int local = (a == probe) ? k : (k + 1) % 3; + edge_seen[edge_key(a, b)].push_back(corner[f * 3 + size_t(local)]); + } + } + // Which chart each patch face belongs to, so a broken edge can be attributed to a pair. + std::vector chart_of_face(patch.indices.size(), -1); + for (size_t i = 0; i < unwrap.indices.size(); ++i) + chart_of_face[size_t(unwrap.source_face[i])] = unwrap.vertex_chart[size_t(unwrap.indices[i][0])]; + + std::unordered_map> edge_faces; + for (size_t f = 0; f < patch.indices.size(); ++f) { + const stl_triangle_vertex_indices &t = patch.indices[f]; + for (int k = 0; k < 3; ++k) + edge_faces[edge_key(t[k], t[(k + 1) % 3])].push_back(int(f)); + } + + int adjacent = 0, broken = 0, broken_same_chart = 0; + float worst = 0.f; + std::map, std::pair> by_pair; + for (const auto &[key, seen] : edge_seen) { + if (seen.size() != 2) + continue; + ++adjacent; + const float d = (seen[0] - seen[1]).norm(); + if (d <= 1e-4f) + continue; + ++broken; + worst = std::max(worst, d); + const auto &faces_here = edge_faces[key]; + int c1 = -1, c2 = -1; + if (faces_here.size() == 2) { + c1 = chart_of_face[size_t(faces_here[0])]; + c2 = chart_of_face[size_t(faces_here[1])]; + } + if (c1 == c2) + ++broken_same_chart; + auto &slot = by_pair[{ std::min(c1, c2), std::max(c1, c2) }]; + ++slot.first; + slot.second = std::max(slot.second, d); + } + std::printf(" broken edges inside a single chart: %d\n", broken_same_chart); + std::printf(" broken by chart pair:"); + for (const auto &[pk, v] : by_pair) + std::printf(" (%d,%d)x%d/%.1f", pk.first, pk.second, v.first, v.second); + std::printf("\n"); + // Total length of the seams left broken, in mm: how much visibly torn edge the layout has, + // which is what the eye adds up. A count alone hides whether the breaks are hairlines or + // whole sides of an island. + float seam_mm = 0.f; + for (const auto &[key, seen] : edge_seen) { + if (seen.size() != 2 || (seen[0] - seen[1]).norm() <= 1e-4f) + continue; + seam_mm += (patch.vertices[size_t(key >> 32)] - patch.vertices[size_t(uint32_t(key))]).norm(); + } + std::printf(" interior edges: %d, discontinuous: %d, total torn seam: %.2f mm (worst jump %.3f)\n", + adjacent, broken, seam_mm, worst); + std::printf(" stored islands %zu vs charts %d -> %s\n", layer.islands.size(), + unwrap.chart_count, + layer.islands.size() == size_t(unwrap.chart_count) ? "stored placements used" + : "net rebuilt"); + } + } + return 0; +} diff --git a/src/libslic3r/Arrange.cpp b/src/libslic3r/Arrange.cpp index b79450a535..eacb122d05 100644 --- a/src/libslic3r/Arrange.cpp +++ b/src/libslic3r/Arrange.cpp @@ -299,7 +299,15 @@ Points get_shrink_bedpts(const DynamicPrintConfig* print_cfg, const ArrangeParam template void fill_config(PConf& pcfg, const ArrangeParams ¶ms) { - if (params.is_seq_print) { + if (params.is_belt) { + // Pack from the end of the belt that prints first, and keep the pile on the + // bed when it is larger than the room around that end. + pcfg.starting_point = !params.belt_reversed ? PConf::Alignment::BOTTOM_LEFT : + params.belt_axis == 1 ? PConf::Alignment::TOP_LEFT : + PConf::Alignment::BOTTOM_RIGHT; + pcfg.clamp_to_bin = true; + } + else if (params.is_seq_print) { // Start placing the items from the center of the print bed pcfg.starting_point = PConf::Alignment::BOTTOM_LEFT; } @@ -444,6 +452,50 @@ protected: return bindist; } + // Belt printers pack from the end of the belt that prints first, and a corner + // packer's checks (pile inside the bin, pack origin) apply to them as well. + bool corner_packing() const { return params.is_belt || m_pconf.starting_point == PConfig::Alignment::BOTTOM_LEFT; } + + static double at(const Box::PointType &pt, int i) { return double(i == 0 ? getX(pt) : getY(pt)); } + + // Position along the belt in print order: increasing from the end that prints first. + double belt_pos(const Box::PointType &pt) const { return params.belt_reversed ? -at(pt, params.belt_axis) : at(pt, params.belt_axis); } + double belt_start(const Box &bb) const { return belt_pos(params.belt_reversed ? bb.maxCorner() : bb.minCorner()); } + double belt_end(const Box &bb) const { return belt_pos(params.belt_reversed ? bb.minCorner() : bb.maxCorner()); } + + // The corner of the bin the belt pile grows from. + Box::PointType belt_origin() const + { + const Box bb = sl::boundingBox(m_bin); + auto o = bb.minCorner(); + if (params.belt_reversed) { + if (params.belt_axis == 0) setX(o, getX(bb.maxCorner())); + else setY(o, getY(bb.maxCorner())); + } + return o; + } + + // An item's far edge in print order is what it costs (so a row fills across the + // belt before the pile advances), with a slight pull toward the near lateral + // edge and the same penalty as the bottom-left heuristic for sitting outside + // the corner. + double dist_along_belt(const Box &ibb) + { + const Box bin = sl::boundingBox(m_bin); + const int l = 1 - params.belt_axis; + const double lat = at(ibb.minCorner(), l) - at(bin.minCorner(), l); + double d = belt_end(ibb) - belt_start(bin); + d += lat < 0 ? 10 * -lat : 0.1 * lat; + if (double behind = belt_start(ibb) - belt_start(bin); behind < 0) + d += 10 * -behind; + return norm(d); + } + + double corner_bindist(const Box &ibb, const Slic3r::Point &origin_pack) + { + return params.is_belt ? dist_along_belt(ibb) : dist_for_BOTTOM_LEFT(ibb, origin_pack); + } + double dist_to_bin(const Box& ibb, const Slic3r::Point& origin_pack, typename Packer::PlacementConfig::Alignment starting_point_alignment) { double bindist = 0; @@ -533,8 +585,8 @@ protected: // The smalles distance from the arranged pile center: double dist = norm(*(std::min_element(dists.begin(), dists.end()))); - if (m_pconf.starting_point == PConfig::Alignment::BOTTOM_LEFT) { - double bindist = dist_for_BOTTOM_LEFT(ibb, origin_pack); + if (corner_packing()) { + double bindist = corner_bindist(ibb, origin_pack); score = 0.2 * dist + 0.8 * bindist; } else { @@ -591,8 +643,8 @@ protected: break; } case LAST_BIG_ITEM: { - if (m_pconf.starting_point == PConfig::Alignment::BOTTOM_LEFT) { - score = dist_for_BOTTOM_LEFT(ibb, origin_pack); + if (corner_packing()) { + score = corner_bindist(ibb, origin_pack); } else { if (m_pilebb.defined) @@ -607,8 +659,8 @@ protected: // already processed bigger items. // No need to play around with the anchor points, the center will be // just fine for small items - if (m_pconf.starting_point == PConfig::Alignment::BOTTOM_LEFT) - score = dist_for_BOTTOM_LEFT(ibb, origin_pack); + if (corner_packing()) + score = corner_bindist(ibb, origin_pack); else { // Align mainly around existing items score = 0.8 * norm(pl::distance(ibb.center(), bigbb.center()))+ 0.2*norm(pl::distance(ibb.center(), origin_pack)); @@ -709,6 +761,28 @@ protected: score += 1 * (new_extruder_cnt-last_extruder_cnt); } + // On a belt the parts print in belt order, so every colour change between + // parts is a filament change. Items arrive sorted by extruder and the pile + // grows from the leading end; keep each colour's run contiguous by charging + // an item for every packed item of another colour it does not fully follow, + // counting the tilted layers that reach belt_tilt_slope * height past that + // item's far edge. + if (params.is_belt && !params.is_seq_print) { + const std::set item_colours(item.extrude_ids.begin(), item.extrude_ids.end()); + const double item_start = belt_start(ibb); + for (Item &p : m_items) { + if (p.is_virt_object) + continue; + const std::set p_colours(p.extrude_ids.begin(), p.extrude_ids.end()); + const bool same_colour = std::includes(item_colours.begin(), item_colours.end(), p_colours.begin(), p_colours.end()) + || std::includes(p_colours.begin(), p_colours.end(), item_colours.begin(), item_colours.end()); + if (same_colour) + continue; + if (item_start < belt_end(p.boundingBox()) + scaled(p.height * params.belt_tilt_slope)) + score += 10.; + } + } + return std::make_tuple(score, fullbb); } @@ -785,7 +859,8 @@ public: auto binbb = sl::boundingBox(m_bin); - auto starting_point = cfg.starting_point == PConfig::Alignment::BOTTOM_LEFT ? binbb.minCorner() : binbb.center(); + auto starting_point = this->params.is_belt ? belt_origin() : + cfg.starting_point == PConfig::Alignment::BOTTOM_LEFT ? binbb.minCorner() : binbb.center(); // if we have wipe tower, items should be arranged around wipe tower for (Item itm : items) { if (itm.is_wipe_tower) { @@ -936,7 +1011,7 @@ std::function AutoArranger::g auto mp = m_merged_pile; mp.emplace_back(itm.transformedShape()); auto chull = sl::convexHull(mp); - if (m_pconf.starting_point == PConfig::Alignment::BOTTOM_LEFT) + if (corner_packing()) { if (!sl::isInside(chull, m_bin)) score += LARGE_COST_TO_REJECT; diff --git a/src/libslic3r/Arrange.hpp b/src/libslic3r/Arrange.hpp index ea89640a7b..b63b8c938b 100644 --- a/src/libslic3r/Arrange.hpp +++ b/src/libslic3r/Arrange.hpp @@ -146,6 +146,13 @@ struct ArrangeParams { float nozzle_height = 0; float printable_height = 256.0; Vec2d align_center{ 0.5,0.5 }; + // Belt printer: items print in the order they lie along the belt axis, from + // its low end unless belt_reversed, and a part's top prints + // belt_tilt_slope * height further along it than its base. + bool is_belt = false; + int belt_axis = 1; // 0 = X, 1 = Y + bool belt_reversed = false; + float belt_tilt_slope = 1.f; // cot(belt tilt angle), 0 when the belt is not tilted ArrangePolygons excluded_regions; // regions cant't be used ArrangePolygons nonprefered_regions; // regions can be used but not prefered diff --git a/src/libslic3r/BeltBrim.cpp b/src/libslic3r/BeltBrim.cpp new file mode 100644 index 0000000000..d51f1248de --- /dev/null +++ b/src/libslic3r/BeltBrim.cpp @@ -0,0 +1,562 @@ +#include +#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 "BoundingBox.hpp" +#include "ExPolygon.hpp" +#include "ExtrusionEntity.hpp" +#include "ExtrusionEntityCollection.hpp" +#include "Point.hpp" +#include "Polyline.hpp" +#include "PrintConfig.hpp" +#include "libslic3r.h" + +#include +#include +#include +#include +#include +#include +#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; +} + +ExPolygons belt_brim_clip_leading_edge(const ExPolygons ®ion, const BeltBrimFrame &frame, coordf_t u_cut) +{ + if (region.empty()) + return region; + BoundingBox keep_bb = get_extents(region); + keep_bb.offset(scale_(1.)); + const bool low_side = frame.downhill_sign() < 0; // downhill is -u + const Polygon keep = band_box(keep_bb, frame.from_axis, + low_side ? unscale(frame.from_axis == 0 ? keep_bb.min.x() : keep_bb.min.y()) : u_cut, + low_side ? u_cut : unscale(frame.from_axis == 0 ? keep_bb.max.x() : keep_bb.max.y())); + return keep.empty() ? ExPolygons{} : intersection_ex(region, Polygons{ keep }); +} + +// 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; + // One line also serves a band up to half a bead wider than the nominal pitch (a 0.3 mm + // first layer at 45 degrees): its flow is matched to the band, so the bead is that much + // wider. Two lattice lines in such a band would land almost on top of each other. + if (band_in_plane <= 1.5 * 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; + coord_t u_prev = std::numeric_limits::min(); + for (coord_t u : us) { + // 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, or a first + // layer thick enough that the band is wider than a bead) each get their own. + // + // A lattice line can fall where the belt is only a hair below the band's print_z. + // The bead there would be laid scraping the belt while its flow is sized for a + // taller cell, so it is moved uphill to the same fraction of the band the + // single-line case uses. (The clearance is along slice Z; the real gap under the + // nozzle is clearance x cos(tilt), 0.53 h at 45 degrees for the 0.75 fraction.) + 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); + if (clearance < BAND_CLEARANCE_FRACTION * height) { + clearance = BAND_CLEARANCE_FRACTION * height; + u = scale_(bc.ctx.cutoff_u(print_z - clearance)); + } + clearance = std::min(clearance, height); + } + // A line moved uphill can land on, or almost on, its neighbour; two beads closer + // than half a pitch would be laid into the same cell. + if (u_prev != std::numeric_limits::min() && std::abs(u - u_prev) < bc.pitch_u / 2) + continue; + u_prev = u; + + 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; + + // 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 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. +// +// SEQUENCING: this reads every object's layers and this object's own support layers. +// Another object's support step shifts that object's layer Z into the object frame for +// the duration of the run (PrintObject::_generate_support_material()), so the brims must +// not overlap with the parallel support step: Print::process() generates them one object +// after the other once that step is over (PrintObject::generate_belt_brim()), and an +// object that arrives on or leaves the plate invalidates the other brim owners' support +// step (PrintApply.cpp) so their brims are clipped against what is there now. Only this +// object's supports are dodged; another object's support at the same Z is not. +// `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()) { + 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)) { + 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) + p.translate(delta); + polygons_append(out, std::move(ps)); + } + } + } + if (out.size() < 2) + return out; // union_() of 0 or 1 polygons is pure overhead + 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; + // The first layer that touches the belt: where the leading-edge brim is cut. + const Layer *first_contact = nullptr; + 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 contact = intersection_ex(layer.lslices, Polygons{ band }); + if (contact.empty()) + continue; + if (first_contact == nullptr) + first_contact = &layer; + expolygons_append(footprint_acc, std::move(contact)); + } + 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 && first_contact != nullptr && ! bc.region.empty()) + // The cut is the uphill edge of the first contact's band: everything past it + // belongs to later contacts. The first contact is the first layer that touches + // the belt (step 1), neither layers().front(), an empty lead-in layer, nor the + // first layer with geometry, which is an overhang's tip when the part overhangs + // its leading end: both lie ahead of the part. + bc.region = belt_brim_clip_leading_edge(bc.region, bc.frame, bc.ctx.cutoff_u(first_contact->print_z)); + + 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, 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]); + } + + // 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, bc.region_bbox, 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..3f4fcc97de --- /dev/null +++ b/src/libslic3r/BeltBrim.hpp @@ -0,0 +1,178 @@ +#ifndef slic3r_BeltBrim_hpp_ +#define slic3r_BeltBrim_hpp_ + +#include "ExPolygon.hpp" +#include "ExtrusionEntityCollection.hpp" +#include "Point.hpp" +#include "Polyline.hpp" +#include "libslic3r.h" + +#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); + +// "Leading edge only": keep the part of a brim region (unflattened, slicing XY) +// 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. `u_cut` +// is the uphill edge of the first layer's contact band along `frame.from_axis`, +// in mm (BeltFloorContext::cutoff_u of the first layer); downhill is the side +// `frame.downhill_sign()` points to. +ExPolygons belt_brim_clip_leading_edge(const ExPolygons ®ion, const BeltBrimFrame &frame, coordf_t u_cut); + +// 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/BeltGCode.cpp b/src/libslic3r/BeltGCode.cpp new file mode 100644 index 0000000000..e76e76b84f --- /dev/null +++ b/src/libslic3r/BeltGCode.cpp @@ -0,0 +1,50 @@ +#include "BeltGCode.hpp" +#include "GCodeWriter.hpp" +#include "GCode/BeltKinematics.hpp" +#include "BeltTransform.hpp" +#include "Print.hpp" +#include "Point.hpp" +#include "PrintConfig.hpp" +#include "libslic3r.h" +#include + +namespace Slic3r { + +void BeltGCode::init_belt_writer(Print &print) +{ + // Axis remap and build volume max are set by base GCode after init_belt_writer + // returns; set_kinematics() replays them, so install order does not matter. + install_belt_kinematics(m_writer, print.config()); + m_writer.set_force_normal_lift(true); +} + +void BeltGCode::write_belt_header(GCodeOutputStream &file, const Print &print) +{ + const auto &full_cfg = print.full_print_config(); + // Slicing rotation: the belt tilt (axis + angle) and the single source of truth + // for the physical tilt the G-code viewer uses to enable belt view. + file.write_format("; belt_slice_rotation = %s\n", full_cfg.opt_serialize("belt_slice_rotation").c_str()); + file.write_format("; belt_slice_rotation_angle = %.1f\n", print.config().belt_slice_rotation_angle.value); + // Machine-frame transform: shear (cot) + scale (1/|sin|) derived from the belt + // tilt angle (or belt_frame_tilt_angle when decoupled). + file.write_format("; belt_frame_tilt_decouple = %d\n", print.config().belt_frame_tilt_decouple.value ? 1 : 0); + file.write_format("; belt_frame_tilt_angle = %.1f\n", print.config().belt_frame_tilt_angle.value); +} + +void BeltGCode::on_set_origin(const PrintObject * /*obj*/, const Point & /*inst_shift*/) +{ + // Matches the per-instance Z-offset added in PrintObjectSlice.cpp: transform + // the origin through the belt pipeline so that back_transform(T * origin) = + // origin (correct machine position). The back_transform applied during + // G-code emission is the inverse of the forward transform. + + // Adjust origin: transform through belt forward pipeline so that + // the back-transform correctly recovers model-space positions. + Transform3d T = BeltTransformPipeline::build_forward_transform(m_config); + Vec2d cur_origin = this->origin(); + Vec3d origin3d(cur_origin.x(), cur_origin.y(), 0.); + Vec3d adjusted = T.linear() * origin3d; + this->set_origin(Vec2d(adjusted.x(), adjusted.y())); +} + +} // namespace Slic3r diff --git a/src/libslic3r/BeltGCode.hpp b/src/libslic3r/BeltGCode.hpp new file mode 100644 index 0000000000..1631a7dc9c --- /dev/null +++ b/src/libslic3r/BeltGCode.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "GCode.hpp" +#include "Point.hpp" +#include "Print.hpp" + +namespace Slic3r { + +// Belt-printer-specific GCode export. +// +// Inherits from GCode and overrides virtual hooks to: +// - Install a BeltKinematics on the GCodeWriter +// - Write belt configuration to the G-code header +// - Adjust the origin for global pre-slice transforms when switching instances +// (Arc fitting is disabled for belt printers by BeltKinematics::supports_arc_moves(), +// which the base GCode::should_disable_arc_fitting() consults -- no override needed.) +class BeltGCode : public GCode +{ +protected: + void init_belt_writer(Print &print) override; + void write_belt_header(GCodeOutputStream &file, const Print &print) override; + void on_set_origin(const PrintObject *obj, const Point &inst_shift) override; +}; + +} // namespace Slic3r diff --git a/src/libslic3r/BeltPurge.cpp b/src/libslic3r/BeltPurge.cpp new file mode 100644 index 0000000000..5fb925f9aa --- /dev/null +++ b/src/libslic3r/BeltPurge.cpp @@ -0,0 +1,433 @@ +// ORCA-Belt: backend of the belt purge tower (the belt replacement for the +// classic wipe/prime tower). +// +// Kept in its own translation unit so the belt-purge logic stays out of the way +// of unrelated upstream changes to Print.cpp / PrintObjectSlice.cpp and carries +// no regression risk for normal printers: none of these methods do anything +// unless the print is a belt printer with the belt purge tower enabled. +// +// Print::has_belt_purge_tower() - is the belt purge tower active? +// Print::_align_belt_purge_layers() - snap the prism's layer grid onto the +// printed objects' grid +// Print::_plan_belt_purge() - route filament-change purging into the +// prism (flush-into-objects), no wipe tower +// PrintObject::belt_shift_layer_grid() - shift a sliced layer grid +// PrintObject::belt_truncate_layers_above() - cancel the prism past the last swap +// +// (Declarations live in Print.hpp alongside the rest of the Print interface.) + +#include "Print.hpp" +#include "PrintConfig.hpp" +#include "Exception.hpp" +#include "GCode/ToolOrdering.hpp" +#include "Layer.hpp" +#include "ExtrusionEntity.hpp" +#include "ExtrusionEntityCollection.hpp" +#include "I18N.hpp" +#include "format.hpp" +#include "LocalesUtils.hpp" +#include "libslic3r.h" +#include "BeltBrim.hpp" +#include "PrintBase.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +// Belt purge prism: purging after filament changes is routed into a sliced +// prism object via the flush-into-objects machinery instead of a wipe tower. +bool Print::has_belt_purge_tower() const +{ + // Its own purge-tower "type", gated by the belt-only enable_belt_purge_tower + // option (not the classic enable_prime_tower). + if (!(m_config.belt_printer.value + && m_config.enable_belt_purge_tower.value + && !m_config.spiral_mode.value + && m_config.filament_diameter.values.size() > 1)) + return false; + + return std::any_of(m_objects.begin(), m_objects.end(), [](const PrintObject *object) { + return object->config().belt_purge_tower_object.value; + }); +} + +// Belt mode: align ALL objects on the plate (the printed objects AND the purge +// prism) onto one common layer grid, so the prism can absorb every toolchange. +// +// After belt slicing each object's layer print_z carries a per-object global z +// offset (mesh-vertex-scan belt_z_shift + instance-Y-dependent terms), so +// objects at different belt-Y positions get layer grids with DIFFERENT residues +// (mod layer height). Purge marking looks absorbers up with +// get_layer_at_printz(lt.print_z, EPSILON), so a toolchange on object B only +// absorbs into the prism if the prism has a layer at B's print_z. Snapping only +// the prism to one object therefore worked for a single (assembled) multi-color +// object but failed with multiple separate objects — the prism could follow only +// one grid, and toolchanges on the others went unabsorbed ("multiple layer +// grids" warning). +// +// Fix: pick one reference grid (the tallest object) and shift every object onto +// it. Each shift is at most half a layer height — a sub-100µm move along the +// belt, the very same mechanism the per-object global_z_offset already uses, and +// it keeps each object internally consistent (belt_shift_layer_grid moves the +// object's layers, its support layers, and its belt floor together). Equal layer +// height across objects is enforced by Print::validate(), so once residues match +// every object steps on the same lattice {ref_offset + k*h} and every toolchange +// layer coincides with a prism layer. +void Print::_align_belt_purge_layers() +{ + PrintObject *prism = nullptr; + for (PrintObject *po : m_objects) + if (po->config().belt_purge_tower_object.value && !po->layers().empty()) { + prism = po; + break; + } + if (prism == nullptr || prism->layers().empty()) + return; + + const double h = prism->config().layer_height.value; + if (h <= EPSILON) + return; + + // Grid residue of an object's layer grid: identical for all of an object's + // layers above the first since they step by h. + auto grid_offset = [h](const PrintObject *po) -> double { + if (po->layers().empty()) + return 0.; + const double z = po->layers().front()->print_z; + return z - std::floor(z / h) * h; // in [0, h) + }; + + // Reference grid: the tallest non-prism object (proxy for the object with + // the most toolchange layers — minimizes how far the rest must move). + const PrintObject *ref = nullptr; + double ref_top = -std::numeric_limits::max(); + for (const PrintObject *po : m_objects) { + if (po->config().belt_purge_tower_object.value || po->layers().empty()) + continue; + const double top = po->layers().back()->print_z; + if (top > ref_top) { + ref_top = top; + ref = po; + } + } + if (ref == nullptr) + return; + + const double ref_offset = grid_offset(ref); + + // Snap every object (printed objects AND the prism) onto the reference grid. + for (PrintObject *po : m_objects) { + if (po->layers().empty()) + continue; + double delta = ref_offset - grid_offset(po); + if (delta > 0.5 * h) + delta -= h; + else if (delta <= -0.5 * h) + delta += h; + po->belt_shift_layer_grid(delta); // no-op for the reference object (delta ~ 0) + } +} + +// Belt mode replacement for _make_wipe_tower(): plan filament-change purging +// into the belt purge prism (and any other flush_into_* object) using the +// flush-into-objects machinery, without generating classic wipe tower G-code. +// The toolchange itself is emitted by GCode::set_extruder() via the +// change_filament_gcode macro; the overrides marked here make the new +// filament's first extrusions land in the purge prism. +void Print::_plan_belt_purge() +{ + m_wipe_tower_data.clear(); + + // psWipeTower may be invalidated without posSlice (for example after a + // filament-map or tool-ordering change). Restore a prism shortened by the + // previous plan so a newly higher toolchange can use its original layers. + for (PrintObject *po : m_objects) + if (po->config().belt_purge_tower_object.value) + po->belt_undo_purge_plan(); + + // Must run before ToolOrdering is built: LayerTools merge per-object layer + // print_z values, and the prism only absorbs purge where its (snapped) + // layers coincide with the toolchange layers. + this->_align_belt_purge_layers(); + + const unsigned int number_of_extruders = (unsigned int) m_config.filament_colour.values.size(); + + // No initial priming extrusions: there is no tower to prime on. + m_wipe_tower_data.tool_ordering = ToolOrdering(*this, (unsigned int) -1, false); + m_wipe_tower_data.tool_ordering.sort_and_build_data(*this, (unsigned int) -1, false); + + if (m_wipe_tower_data.tool_ordering.empty() || m_wipe_tower_data.tool_ordering.last_extruder() == unsigned(-1)) + throw Slic3r::SlicingError("The print is empty. The model is not printable with current print settings."); + + // Is there any filament change at all? Not ToolOrdering::has_wipe_tower(): that reads the + // FIRST layer's flag, and on a belt the first layer may be a brim apron band, which carries + // neither object nor support and so never gets the flag even when the print changes filament. + { + bool any_change = false; + unsigned int cur = m_wipe_tower_data.tool_ordering.first_extruder(); + for (const auto < : m_wipe_tower_data.tool_ordering.layer_tools()) + for (const unsigned int e : lt.extruders) + if (e != cur) { any_change = true; cur = e; } + if (! any_change) + return; + } + + this->throw_if_canceled(); + + // Flush volumes per filament pair, mirroring the generic wipe tower path: + // full flush matrix for single extruder multi material with purging enabled, + // plain prime volume otherwise. + std::vector flush_matrix(cast( + get_flush_volumes_matrix(m_config.flush_volumes_matrix.values, 0, m_config.nozzle_diameter.values.size()))); + std::vector> wipe_volumes; + for (unsigned int i = 0; i < number_of_extruders; ++i) + wipe_volumes.push_back(std::vector(flush_matrix.begin() + i * number_of_extruders, + flush_matrix.begin() + (i + 1) * number_of_extruders)); + const bool use_flush_matrix = m_config.purge_in_prime_tower && m_config.single_extruder_multi_material; + const float flush_multiplier = (float) m_config.flush_multiplier.get_at(0); + + // Cancel the purge prism early: pre-scan the tool ordering for the highest + // print_z that actually has a toolchange, then drop the prism's layers above + // it so the tower stops at the last color swap (saves filament/time). This + // MUST happen before the marking loop below: ensure_perimeters_infills_order + // force-overrides the prism's extrusions on every layer (it is a dedicated + // flush object), so truncating afterwards would leave dangling overrides + // pointing into deleted layers. + { + // The tool ordering covers the WHOLE print, and the prism is a printed + // object in it. Left unbounded, the scan below sees the prism's own + // toolchanges on layers above every model object -- the prism runs past + // them by design (ramp/height compensation at the tilted ends) -- so + // last_tc_z lands at the prism's own top and the truncation cancels + // nothing. The tower ends up justifying its own existence. + // + // Nothing above the tallest printed object can require a color change, + // so bound the scan there. On MCTEST5 that is 197 toolchanges spanning + // z=154.00..193.20 with the tallest object topping out at 153.80, i.e. + // 39.4 mm of tower that no swap ever needed. + // Support layers count too: on a belt they can extend above the object's + // own top, and a toolchange there is a real one. + double obj_top_z = -1.; + for (const PrintObject *po : m_objects) { + if (po->config().belt_purge_tower_object.value) + continue; + if (!po->layers().empty()) + obj_top_z = std::max(obj_top_z, po->layers().back()->print_z); + if (!po->support_layers().empty()) + obj_top_z = std::max(obj_top_z, po->support_layers().back()->print_z); + } + + double last_tc_z = -1.; + unsigned int cur_ext = m_wipe_tower_data.tool_ordering.first_extruder(); + for (const auto < : m_wipe_tower_data.tool_ordering.layer_tools()) { + // layer_tools() is ordered by print_z ascending. + if (obj_top_z >= 0. && lt.print_z > obj_top_z + EPSILON) + break; + for (const unsigned int e : lt.extruders) + if (e != cur_ext) { last_tc_z = lt.print_z; cur_ext = e; } + } + // Deliberately NOT cancelling the prism outright when no object toolchange + // exists: belt_truncate_layers_above(0.) empties m_layers, and an object + // with zero layers is not something the rest of the pipeline expects. The + // GUI already declines to create a prism unless more than one filament is + // in use, so this case is a stale prism, not a hot path -- leave it whole + // rather than risk a zero-layer object. + if (last_tc_z >= 0.) + for (PrintObject *po : m_objects) + if (po->config().belt_purge_tower_object.value && !po->layers().empty()) { + po->belt_truncate_layers_above(last_tc_z); + break; + } + } + + // The prism only absorbs purge at toolchange layers whose print_z coincides + // with one of its own layers. + PrintObject *prism_po = nullptr; + for (PrintObject *po : m_objects) + if (po->config().belt_purge_tower_object.value && !po->layers().empty()) { prism_po = po; break; } + + float total_leftover = 0.f; + float worst_layer_leftover = 0.f; + double worst_layer_z = 0.; + + unsigned int current_extruder_id = m_wipe_tower_data.tool_ordering.first_extruder(); + for (auto &layer_tools : m_wipe_tower_data.tool_ordering.layer_tools()) { + float layer_leftover = 0.f; + for (const unsigned int extruder_id : layer_tools.extruders) { + if (extruder_id == current_extruder_id) + continue; + float volume_to_wipe = use_flush_matrix ? + wipe_volumes[current_extruder_id][extruder_id] * flush_multiplier : + (float) m_config.prime_volume; + float leftover = layer_tools.wiping_extrusions().mark_wiping_extrusions(*this, current_extruder_id, extruder_id, + volume_to_wipe); + layer_leftover += leftover; + current_extruder_id = extruder_id; + } + + // Plastic saving: drop the prism's fills that no toolchange on this layer + // claimed. At this point the prism's OVERRIDDEN fills are exactly the + // purge; the rest would print as solid infill in the prism's own filament + // for nothing -- which is the whole prism on a layer with no toolchange + // (141 of 692 layers on MCTEST5 before the truncation fix). Perimeters are + // left alone so the bar keeps a continuous wall along the belt. + // + // Non-destructive: the entities are stashed with their positions and put + // back by belt_restore_dropped_fills() at the top of the next plan. An + // earlier version deleted them outright, which broke replanning when a + // later tool ordering needed what this one had not claimed -- that is why + // it was removed rather than kept. + if (prism_po != nullptr) { + const auto &we = layer_tools.wiping_extrusions(); + prism_po->belt_drop_unclaimed_fills( + prism_po->get_layer_at_printz(layer_tools.print_z, EPSILON), + [&we, prism_po](const ExtrusionEntity *e) { return we.is_entity_overridden(e, prism_po, 0); }); + } + + layer_tools.wiping_extrusions().ensure_perimeters_infills_order(*this); + if (layer_leftover > 0.f) { + total_leftover += layer_leftover; + if (layer_leftover > worst_layer_leftover) { + worst_layer_leftover = layer_leftover; + worst_layer_z = layer_tools.print_z; + } + } + this->throw_if_canceled(); + } + + if (total_leftover > 1.f) { + this->active_step_add_warning( + PrintStateBase::WarningLevel::CRITICAL, + Slic3r::format(_u8L("The belt purge tower cannot absorb the full purge volume: %1% mm³ in total could not " + "be purged (worst layer: %2% mm³ at height %3%). The print may show color bleeding. " + "Increase the belt purge tower width, or reduce flushing volumes."), + int(std::ceil(total_leftover)), int(std::ceil(worst_layer_leftover)), + Slic3r::float_to_string_decimal_point(worst_layer_z, 2))); + } +} + +// Belt mode: shift the sliced layer grid by delta. Mirrors the global_z_offset +// application in slice() — layer print_z and belt_floor_z_shift move together +// so belt floor clipping stays consistent with the shifted grid. Used by +// Print::_align_belt_purge_layers() to snap the purge prism onto the printed +// objects' layer grid; |delta| <= half a layer height, i.e. a sub-layer shift +// of the prism along the belt. +void PrintObject::belt_shift_layer_grid(double delta) +{ + if (std::abs(delta) < EPSILON) + return; + for (Layer *layer : m_layers) + layer->print_z += delta; + for (SupportLayer *layer : m_support_layers) + layer->print_z += delta; + // The brim's apron bands below the first layer carry their own print_z. + for (BeltBrimBand &band : m_belt_brim_prologue) + band.print_z += delta; + m_slicing_params.belt_floor_z_shift += delta; + // The grid stays shifted across a support-only or brim-only change (posSlice does + // not rerun), so everything slice() derived from it has to follow: the cached floor + // that update_slicing_parameters() restores, and the global offset the organic + // support layers and the adaptive infill octree are placed with. Left alone, the + // next alignment finds a delta of 0 and the floor and the supports sit up to half + // a layer off the grid, unlike a fresh slice. + if (m_belt_floor_z_shift_cache_valid) + m_belt_floor_z_shift_cached += delta; + m_belt_global_z_offset += delta; +} + +// Belt mode: drop layers strictly above z (used to cancel the purge prism early +// once there are no more toolchanges above z, so the tower stops at the last +// color swap instead of wasting filament up the rest of the belt). Each layer's +// cross-section is already sliced, so removing upper layers does not affect the +// last toolchange's coverage. Deletes the Layer objects and clears the new top +// layer's upper-layer link. Returns the number of layers removed. +size_t PrintObject::belt_truncate_layers_above(coordf_t z) +{ + // A repeated plan always starts from the restored full layer set. + assert(m_belt_truncated_layers.empty()); + size_t keep = m_layers.size(); + while (keep > 0 && m_layers[keep - 1]->print_z > z + EPSILON) + --keep; + if (keep >= m_layers.size()) + return 0; + const size_t removed = m_layers.size() - keep; + m_belt_truncated_layers.assign(m_layers.begin() + keep, m_layers.end()); + m_layers.resize(keep); + if (!m_layers.empty()) + m_layers.back()->upper_layer = nullptr; + return removed; +} + +// Plastic saving on the purge prism: keep only the fills a toolchange claimed. +// +// Called per layer from _plan_belt_purge(), after the real-purge marking and +// BEFORE ensure_perimeters_infills_order() -- that pass force-overrides every +// remaining fill on the prism (it is a dedicated flush object), so afterwards +// everything looks claimed and nothing could be distinguished. +size_t PrintObject::belt_drop_unclaimed_fills(Layer *layer, const std::function &claimed) +{ + if (layer == nullptr) + return 0; + size_t dropped = 0; + for (size_t ri = 0; ri < layer->regions().size(); ++ri) { + LayerRegion *lr = layer->get_region(ri); + auto &ents = lr->fills.entities; + ExtrusionEntitiesPtr keep; + keep.reserve(ents.size()); + for (size_t i = 0; i < ents.size(); ++i) { + if (claimed(ents[i])) { + keep.emplace_back(ents[i]); + } else { + // Stash with its original index so the restore is exact. + m_belt_dropped_fills.push_back(BeltDroppedFill{ layer, ri, i, ents[i] }); + ++dropped; + } + } + ents = std::move(keep); + } + return dropped; +} + +void PrintObject::belt_restore_dropped_fills() +{ + if (m_belt_dropped_fills.empty()) + return; + // Ascending index per (layer, region): inserting in that order lands every + // entity back at its original position, because each insertion shifts only + // the entries after it, which are themselves still to be inserted. + std::stable_sort(m_belt_dropped_fills.begin(), m_belt_dropped_fills.end(), + [](const BeltDroppedFill &a, const BeltDroppedFill &b) { + if (a.layer != b.layer) return a.layer < b.layer; + if (a.region_idx != b.region_idx) return a.region_idx < b.region_idx; + return a.index < b.index; + }); + for (const BeltDroppedFill &d : m_belt_dropped_fills) { + auto &ents = d.layer->get_region(d.region_idx)->fills.entities; + ents.insert(ents.begin() + std::min(d.index, ents.size()), d.entity); + } + m_belt_dropped_fills.clear(); +} + +void PrintObject::belt_restore_truncated_layers() +{ + if (m_belt_truncated_layers.empty()) + return; + + m_layers.insert(m_layers.end(), m_belt_truncated_layers.begin(), m_belt_truncated_layers.end()); + m_belt_truncated_layers.clear(); + for (size_t i = 0; i < m_layers.size(); ++i) { + m_layers[i]->lower_layer = i == 0 ? nullptr : m_layers[i - 1]; + m_layers[i]->upper_layer = i + 1 < m_layers.size() ? m_layers[i + 1] : nullptr; + } +} + +} // namespace Slic3r diff --git a/src/libslic3r/BeltSliceStrategy.cpp b/src/libslic3r/BeltSliceStrategy.cpp new file mode 100644 index 0000000000..7b25ac53ec --- /dev/null +++ b/src/libslic3r/BeltSliceStrategy.cpp @@ -0,0 +1,82 @@ +#include "BeltSliceStrategy.hpp" +#include "Model.hpp" +#include "BeltTransform.hpp" +#include "Point.hpp" +#include "PrintConfig.hpp" + +#include +#include + +namespace Slic3r { + +void BeltSliceStrategy::apply_preslice_transforms(Transform3d &trafo, + const PrintConfig &config, + const ModelVolumePtrs &model_volumes, + double *out_belt_min_z) +{ + // 1. Belt rotation — the sole mesh-side belt transform (matching + // BeltTransformPipeline::build_forward_transform). Only active in + // belt-printer mode. + bool has_rotation = false; + if (config.belt_printer.value) { + const Matrix3d rot = BeltTransformPipeline::build_rotation_matrix(config, &has_rotation); + if (has_rotation) { + Transform3d belt_xform = Transform3d::Identity(); + belt_xform.linear() = rot; + trafo = belt_xform * trafo; + } + } + + if (!has_rotation) + return; + + // 2. Z-shift — detect if the mesh clips below the build plate after the + // transforms and lift it. Each mesh vertex must be brought into object space + // via mv->get_matrix() before applying the full trafo (which is in object + // space). Missing this on assemblies (where per-volume get_matrix() positions + // each volume within the object) would compute min_z against mesh-local vertex + // coordinates rather than object-space coordinates, so volumes translated along + // the slicer's Z axis would be silently excluded from the bound check. + + // + // The lift is measured to the lowest point of the SUPPORT region, not of the + // mesh: the belt floor (z = shear * u in this rotated frame, u the from-axis + // coordinate) runs below every vertex, and under the leading end of an + // overhang it lies below the lowest vertex by up to the overhang's length + // times the shear. Supports have to reach that floor, and every support + // generator works in layers at z >= 0, so z = 0 has to be the lowest floor + // point under the footprint. The layers between it and the first vertex + // come out empty, which belt slicing already tolerates (the bottom corner + // of a tilted part is a point). Vertices on the belt have z == floor, so + // for a part resting on the belt this is simply the floor at its leading + // extreme, less the frame margin (see BeltTransformPipeline::frame_margin). + BeltTransformPipeline::BeltFloorParams floor; + const bool has_floor = BeltTransformPipeline::floor_shear(config, floor); + double min_z = std::numeric_limits::max(); + for (const ModelVolume *mv : model_volumes) { + if (!mv->is_model_part()) continue; + Transform3d vol_trafo = trafo * mv->get_matrix(); + const auto &its = mv->mesh().its; + for (const stl_vertex &v : its.vertices) { + Vec3d vm = v.cast(); + Vec3d pt = vol_trafo * vm; + min_z = std::min(min_z, pt.z()); + if (has_floor) + min_z = std::min(min_z, floor.shear_factor * (floor.from_axis == 0 ? pt.x() : pt.y())); + } + } + if (has_floor && min_z != std::numeric_limits::max()) + min_z -= BeltTransformPipeline::frame_margin(floor); + const double z_shift_val = (min_z < 0. && min_z != std::numeric_limits::max()) ? -min_z : 0.; + if (z_shift_val > 0.) { + Transform3d z_shift = Transform3d::Identity(); + z_shift.matrix()(2, 3) = z_shift_val; + trafo = z_shift * trafo; + } + // out_belt_min_z is only meaningful in belt mode. + if (out_belt_min_z && config.belt_printer.value) { + *out_belt_min_z = (min_z != std::numeric_limits::max()) ? min_z : 0.; + } +} + +} // namespace Slic3r diff --git a/src/libslic3r/BeltSliceStrategy.hpp b/src/libslic3r/BeltSliceStrategy.hpp new file mode 100644 index 0000000000..1385818b5c --- /dev/null +++ b/src/libslic3r/BeltSliceStrategy.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "libslic3r.h" +#include "Point.hpp" +#include "BeltTransform.hpp" +#include "PrintConfig.hpp" +#include "Model.hpp" + +namespace Slic3r { + +// Belt printer pre-slice transform strategy. +// +// Composes, in order, the mesh transforms applied before slicing on a belt printer: +// 1. Belt rotation (the sole mesh-side belt transform; shear & scale are a +// g-code-side stage, see MachineFrameTransform) +// 2. Per-object Z-shift that lifts the mesh so its slicing frame starts at the +// belt below its footprint +// +// Isolates this belt-specific logic from the generic slicing pipeline in +// PrintObjectSlice.cpp. +class BeltSliceStrategy +{ +public: + // Apply the belt rotation + Z-shift to `trafo` in place. No-op when no belt + // rotation is configured. + // + // out_belt_min_z (if non-null) receives the minimum mesh Z after the transforms. + static void apply_preslice_transforms(Transform3d &trafo, + const PrintConfig &config, + const ModelVolumePtrs &model_volumes, + double *out_belt_min_z = nullptr); +}; + +} // namespace Slic3r diff --git a/src/libslic3r/BeltTransform.cpp b/src/libslic3r/BeltTransform.cpp new file mode 100644 index 0000000000..9863e80831 --- /dev/null +++ b/src/libslic3r/BeltTransform.cpp @@ -0,0 +1,153 @@ +#include "BeltTransform.hpp" +#include "Model.hpp" +#include "BoundingBox.hpp" +#include "Config.hpp" +#include "Geometry.hpp" +#include "Point.hpp" +#include "PrintConfig.hpp" +#include "libslic3r.h" + +#include +#include +#include +#include + +namespace Slic3r { + +// ---- Matrix builders ------------------------------------------------------ + +Matrix3d BeltTransformPipeline::build_rotation_matrix(const PrintConfig &config, bool *has_rot_out) +{ + BeltRotationAxis axis = config.belt_slice_rotation.value; + double angle_deg = config.belt_slice_rotation_angle.value; + bool active = axis != BeltRotationAxis::None && std::abs(angle_deg) > EPSILON; + if (has_rot_out) *has_rot_out = active; + if (!active) + return Matrix3d::Identity(); + double angle_rad = Geometry::deg2rad(angle_deg); + Vec3d unit_axis; + switch (axis) { + case BeltRotationAxis::X: unit_axis = Vec3d::UnitX(); break; + case BeltRotationAxis::Y: unit_axis = Vec3d::UnitY(); break; + case BeltRotationAxis::Z: unit_axis = Vec3d::UnitZ(); break; + default: return Matrix3d::Identity(); + } + return Eigen::AngleAxisd(angle_rad, unit_axis).toRotationMatrix(); +} + +Transform3d BeltTransformPipeline::build_forward_transform(const PrintConfig &config) +{ + // Mesh-side belt transform: the rotation. (Shear & scale are a g-code-side + // stage, not part of the mesh transform.) + Transform3d combined = Transform3d::Identity(); + combined.linear() = build_rotation_matrix(config); + return combined; +} + +// ---- Belt floor parameters ------------------------------------------------ + +namespace { + +// Belt floor in the rotated slicer frame: the image of z_machine = 0 under R. +// R(+α, X): point (·, y, 0) → (·, cos α · y, sin α · y) ⇒ z = tan(α) · y_s +// R(+α, Y): point (x, ·, 0) → (cos α · x, ·, -sin α · x) ⇒ z = -tan(α) · x_s +// R(+α, Z): point (·, ·, 0) → (·, ·, 0); no tilt → no floor +void belt_floor_shear(BeltRotationAxis rot_axis, double angle_rad, BeltTransformPipeline::BeltFloorParams &out) +{ + double sin_a = std::sin(angle_rad), cos_a = std::cos(angle_rad); + switch (rot_axis) { + case BeltRotationAxis::X: + out.shear_factor = (std::abs(cos_a) > EPSILON) ? sin_a / cos_a : 0.; + out.from_axis = 1; // Y + break; + case BeltRotationAxis::Y: + out.shear_factor = (std::abs(cos_a) > EPSILON) ? -sin_a / cos_a : 0.; + out.from_axis = 0; // X + break; + case BeltRotationAxis::Z: + default: + out.shear_factor = 0.0; + out.from_axis = 1; + break; + } +} + +// Z of the belt floor directly under a point of the rotated (unshifted) frame. +inline double belt_floor_z(const BeltTransformPipeline::BeltFloorParams &fp, const Vec3d &pt) +{ + return fp.shear_factor * (fp.from_axis == 0 ? pt.x() : pt.y()); +} + + +BeltTransformPipeline::BeltHeightResult compute_belt_height_and_floor_impl( + const PrintConfig &config, const BoundingBoxf3 &bb, double original_height) +{ + BeltTransformPipeline::BeltHeightResult result; + result.object_height = original_height; + + // The mesh rotation (the sole mesh-side belt transform). + const BeltRotationAxis rot_axis = config.belt_slice_rotation.value; + const double rot_angle = config.belt_slice_rotation_angle.value; + + bool has_rotation = rot_axis != BeltRotationAxis::None && std::abs(rot_angle) > EPSILON; + if (!has_rotation) + return result; + + // Rotation path: sweep the 8 bbox corners through R to get the rotated height, + // then derive the belt floor (the image of machine-Z = 0 under R). + double angle_rad = Geometry::deg2rad(rot_angle); + Vec3d unit_axis; + switch (rot_axis) { + case BeltRotationAxis::X: unit_axis = Vec3d::UnitX(); break; + case BeltRotationAxis::Y: unit_axis = Vec3d::UnitY(); break; + case BeltRotationAxis::Z: unit_axis = Vec3d::UnitZ(); break; + default: unit_axis = Vec3d::UnitX(); break; + } + Matrix3d R = Eigen::AngleAxisd(angle_rad, unit_axis).toRotationMatrix(); + belt_floor_shear(rot_axis, angle_rad, result.floor_params); + // The slicing frame starts at the lowest point of the support region: the + // lowest belt-floor point under the footprint, not the lowest vertex. The + // belt under the leading end of an overhang lies below every vertex of the + // part, and supports have to be able to reach it (see + // BeltSliceStrategy::apply_preslice_transforms for the exact vertex-scan + // counterpart of this bbox estimate). + double min_rz = std::numeric_limits::max(); + double max_rz = std::numeric_limits::lowest(); + for (int i = 0; i < 8; ++i) { + Vec3d c((i & 1) ? bb.max.x() : bb.min.x(), + (i & 2) ? bb.max.y() : bb.min.y(), + (i & 4) ? bb.max.z() : bb.min.z()); + Vec3d rc = R * c; + double z = rc.z(); + min_rz = std::min(min_rz, z); + max_rz = std::max(max_rz, z); + min_rz = std::min(min_rz, belt_floor_z(result.floor_params, rc)); + } + min_rz -= BeltTransformPipeline::frame_margin(result.floor_params); + result.object_height = max_rz - min_rz; + + result.floor_params.z_shift = bb.min.z() + ((min_rz < 0.) ? -min_rz : 0.); + + return result; +} + +} // anonymous namespace + +BeltTransformPipeline::BeltHeightResult BeltTransformPipeline::compute_belt_height_and_floor( + const PrintConfig &config, const BoundingBoxf3 &bbox, double original_height) +{ + return compute_belt_height_and_floor_impl(config, bbox, original_height); +} + +bool BeltTransformPipeline::floor_shear(const PrintConfig &config, BeltFloorParams &out) +{ + out = BeltFloorParams{}; + const BeltRotationAxis rot_axis = config.belt_slice_rotation.value; + const double rot_angle = config.belt_slice_rotation_angle.value; + if (rot_axis == BeltRotationAxis::None || std::abs(rot_angle) <= EPSILON) + return false; + belt_floor_shear(rot_axis, Geometry::deg2rad(rot_angle), out); + return std::abs(out.shear_factor) > EPSILON; +} + +} // namespace Slic3r diff --git a/src/libslic3r/BeltTransform.hpp b/src/libslic3r/BeltTransform.hpp new file mode 100644 index 0000000000..1aae8ba39b --- /dev/null +++ b/src/libslic3r/BeltTransform.hpp @@ -0,0 +1,132 @@ +#pragma once + +#include "libslic3r.h" +#include "Point.hpp" +#include "BoundingBox.hpp" +#include "PrintConfig.hpp" +#include "Geometry.hpp" +#include "Config.hpp" + +#include + +namespace Slic3r { + +class ModelObject; + +// Shared belt-printer transform math. +// +// The pre-slice pipeline applied in PrintObjectSlice.cpp is: +// trafo_out = z_shift * rotation * trafo_in +// +// Rotation is the sole mesh-side belt transform; shear & scale are applied +// to the g-code instead (see MachineFrameTransform). This class provides the +// building blocks so every call site uses the same implementation. z_shift is +// object-dependent (computed from mesh vertex bounds) and is NOT included in +// build_forward_transform(). The machine-frame shear/scale is derived directly +// from the tilt angle in MachineFrameTransform and no longer lives here. +// +// Design note: this mesh-rotation approach replaced an earlier pre-shear +// method (now removed). While that initial pre-shear method was instrumental +// in getting belt printer slicing off the ground in the first place, its place is +// in the past. A big thank you goes to the Unlayered3D team, who recommended +// switching to a pre-slice rotation stage instead. Doing so keeps the slicing +// operation isometric — no distortion of the sliced geometry — while the +// non-orthogonal machine-axis compensation is confined to a g-code-side shear/scale +// derived from the same tilt angle. +// +// This fixed a number of issues, including several issues noticed by hotcubcar +// regarding adaptive infills not working, gyroid becoming anisotropic, and more +// that were all mostly resolved as a result of the switch. +// +// This also means that the pre-slice rotation transform methodology can be used +// more cleanly on non-belt printers. +// - HarrierPigeon (Joseph Robertson) + +class BeltTransformPipeline +{ +public: + // ---- Identity checks -------------------------------------------------- + + // Whether the G-code axis remap applies at all. The remap fields are only + // offered in the belt printer group, so a value left in a profile must not + // change a non-belt print: with belt mode off every belt-only key is a no-op. + // This is the one place to widen if a non-belt use ever needs them. + static bool axis_remap_enabled(const PrintConfig &config) { return config.belt_printer.value; } + + static bool has_rotation(const PrintConfig &config) + { + return config.belt_slice_rotation.value != BeltRotationAxis::None && + std::abs(config.belt_slice_rotation_angle.value) > EPSILON; + } + + // Physical belt tilt derived from the slicing rotation — the single source of + // truth for bed rendering, support gravity tilt and the bed-exclusion + // projection. Returns the tilt magnitude in degrees split onto the X and Y + // build-plate tilt axes according to the rotation axis: + // rotation about X → tilt_x = angle (gantry tilts in the YZ plane) + // rotation about Y → tilt_y = angle (gantry tilts in the XZ plane) + // rotation about Z / None → no tilt (in-plane spin doesn't tilt the belt) + // The magnitude uses abs(angle) so a negative rotation still reports a positive + // physical tilt. + struct PhysicalTilt { double tilt_x_deg = 0.; double tilt_y_deg = 0.; }; + + static PhysicalTilt physical_tilt(BeltRotationAxis axis, double angle_deg) + { + PhysicalTilt t; + double mag = std::abs(angle_deg); + switch (axis) { + case BeltRotationAxis::X: t.tilt_x_deg = mag; break; + case BeltRotationAxis::Y: t.tilt_y_deg = mag; break; + default: break; // Z / None: no physical tilt + } + return t; + } + + // ---- Matrix builders -------------------------------------------------- + + // Build the 3x3 rotation matrix from belt_slice_rotation* config. + // Returns Identity if rotation axis is None or angle is ~0. + // Also sets has_rot_out if non-null. + static Matrix3d build_rotation_matrix(const PrintConfig &config, bool *has_rot_out = nullptr); + + // Forward transform (the rotation) — the mesh-side belt transform that + // BeltSliceStrategy applies and BeltBackTransform inverts. + // Does NOT include the per-object Z-shift. + static Transform3d build_forward_transform(const PrintConfig &config); + + // ---- Belt floor parameters -------------------------------------------- + + struct BeltFloorParams { + double shear_factor = 0.0; + int from_axis = 1; + double z_shift = 0.0; + }; + + // Shear factor and from-axis of the belt floor in the rotated slicer frame + // (z_floor = shear_factor * u, u = the from-axis coordinate), for the + // rotation the config selects. z_shift is left at 0. Returns false (and + // zero shear) when the config has no tilt. + static bool floor_shear(const PrintConfig &config, BeltFloorParams &out); + + // How far below the lowest belt-floor point under the footprint the slicing + // frame starts, in slicing Z. A support column meeting the belt is wider at + // its base than at its tip, so under a leading overhang the base reaches ahead + // of the part along the belt, and the layers that trim it to the belt plane + // lie below that lowest point: 10 mm along the belt. + static double frame_margin(const BeltFloorParams &fp) { return 10. * std::abs(fp.shear_factor); } + + // Result of computing belt height + floor params. + struct BeltHeightResult { + double object_height; // Effective object height after shear/scale + BeltFloorParams floor_params; + }; + + // Compute effective object height and belt floor parameters from config + // and the object's bounding box. original_height is the input height + // (bb.size().z() or model_object.max_z()). + static BeltHeightResult compute_belt_height_and_floor( + const PrintConfig &config, const BoundingBoxf3 &bbox, + double original_height); +}; + +} // namespace Slic3r diff --git a/src/libslic3r/Brim.cpp b/src/libslic3r/Brim.cpp index d5707ca0de..23b515647b 100644 --- a/src/libslic3r/Brim.cpp +++ b/src/libslic3r/Brim.cpp @@ -474,7 +474,9 @@ static ExPolygons outer_inner_brim_area(const Print& print, const bool use_brim_ears = object->config().brim_type == btPainted; const bool use_inner_brim_ears = (use_auto_brim_ears || use_brim_ears) && !object->config().brim_ears_outer_only.value; const bool has_inner_brim = brim_type == btInnerOnly || brim_type == btOuterAndInner || use_inner_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. @@ -889,6 +891,17 @@ void make_brim(const Print& print, PrintTryCancel try_cancel, Polygons& islands_ std::vector& printExtruders, std::map* objectBrimAreasByInstanceOut) { + // 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; + std::map brimAreaMap; Flow flow = print.brim_flow(); ExPolygons islands_area_ex = outer_inner_brim_area(print, diff --git a/src/libslic3r/BuildVolume.hpp b/src/libslic3r/BuildVolume.hpp index 8c53242fe8..9c48363cf8 100644 --- a/src/libslic3r/BuildVolume.hpp +++ b/src/libslic3r/BuildVolume.hpp @@ -84,7 +84,7 @@ public: indexed_triangle_set bounding_mesh(bool scale=true) const; // Center of the print bed, unscaled. - Vec2d bed_center() const { return to_2d(m_bboxf.center()); } + Vec2d bed_center() const { return get_extents(m_bed_shape).center(); } // Convex hull of polygon(), scaled. const Polygon& convex_hull() const { return m_convex_hull; } // Smallest enclosing circle of polygon(), scaled. diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 421f3aeeec..5ee53c9547 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -85,6 +85,15 @@ set(lisbslic3r_sources BoundingBox.hpp BridgeDetector.cpp BridgeDetector.hpp + BeltBrim.cpp + BeltBrim.hpp + BeltGCode.cpp + BeltGCode.hpp + BeltPurge.cpp + BeltSliceStrategy.cpp + BeltSliceStrategy.hpp + BeltTransform.cpp + BeltTransform.hpp Brim.cpp BrimEarsPoint.hpp Brim.hpp @@ -232,6 +241,14 @@ set(lisbslic3r_sources GCode/AdaptivePAProcessor.hpp GCode/AvoidCrossingPerimeters.cpp GCode/AvoidCrossingPerimeters.hpp + GCode/BeltBackTransform.cpp + GCode/BeltBackTransform.hpp + GCode/MachineFrameTransform.cpp + GCode/MachineFrameTransform.hpp + GCode/BeltKinematics.cpp + GCode/BeltKinematics.hpp + GCode/MachineKinematics.cpp + GCode/MachineKinematics.hpp GCode/ConflictChecker.cpp GCode/ConflictChecker.hpp GCode/CoolingBuffer.cpp @@ -457,6 +474,8 @@ set(lisbslic3r_sources SlicingAdaptive.hpp Slicing.cpp Slicing.hpp + Support/BeltFloorContext.cpp + Support/BeltFloorContext.hpp Support/SupportCommon.cpp Support/SupportCommon.hpp Support/SupportLayer.hpp diff --git a/src/libslic3r/ExPolygon.hpp b/src/libslic3r/ExPolygon.hpp index d695171d1e..5df348281a 100644 --- a/src/libslic3r/ExPolygon.hpp +++ b/src/libslic3r/ExPolygon.hpp @@ -403,6 +403,11 @@ inline void translate(ExPolygons &expolys, const Point &p) { expoly.translate(p); } +inline void translate(Polygons &polys, const Point &p) { + for (Polygon &poly : polys) + poly.translate(p); +} + inline void polygons_append(Polygons &dst, const ExPolygon &src) { dst.reserve(dst.size() + src.holes.size() + 1); diff --git a/src/libslic3r/Format/AMF.cpp b/src/libslic3r/Format/AMF.cpp index fa28d93f09..42583419b4 100644 --- a/src/libslic3r/Format/AMF.cpp +++ b/src/libslic3r/Format/AMF.cpp @@ -314,8 +314,13 @@ void AMFParserContext::startElement(const char *name, const char **atts) case 2: if (strcmp(name, "metadata") == 0) { if (m_path[1] == NODE_TYPE_MATERIAL || m_path[1] == NODE_TYPE_OBJECT) { - m_value[0] = get_attribute(atts, "type"); - node_type_new = NODE_TYPE_METADATA; + const char *type = get_attribute(atts, "type"); + if (type == nullptr) + this->stop(); + else { + m_value[0] = type; + node_type_new = NODE_TYPE_METADATA; + } } }/* else if (strcmp(name, "layer_config_ranges") == 0 && m_path[1] == NODE_TYPE_OBJECT) node_type_new = NODE_TYPE_LAYER_CONFIG;*/ diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index ebebb066b8..8a22201c51 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -1048,10 +1048,10 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) void _stop_object_xml_parser(const std::string& msg = std::string()) { assert(! obj_parse_error); - assert(obj_parse_error_message.empty()); assert(object_xml_parser != nullptr); obj_parse_error = true; - obj_parse_error_message = msg; + if (! msg.empty() || obj_parse_error_message.empty()) // a handler may have set the message already + obj_parse_error_message = msg; XML_StopParser(object_xml_parser, false); } @@ -1402,7 +1402,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) bool _handle_start_relationship(const char** attributes, unsigned int num_attributes); - void _generate_current_object_list(std::vector &sub_objects, Id object_id, IdToCurrentObjectMap& current_objects); + bool _generate_current_object_list(std::vector &sub_objects, Id object_id, IdToCurrentObjectMap& current_objects); bool _generate_volumes_new(ModelObject& object, const std::vector &sub_objects, const ObjectMetadata::VolumeMetadataList& volumes, ConfigSubstitutionContext& config_substitutions); //bool _generate_volumes(ModelObject& object, const Geometry& geometry, const ObjectMetadata::VolumeMetadataList& volumes, ConfigSubstitutionContext& config_substitutions); @@ -2124,7 +2124,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) return false; } std::vector object_id_list; - _generate_current_object_list(object_id_list, object.first, m_current_objects); + if (!_generate_current_object_list(object_id_list, object.first, m_current_objects)) + return false; ObjectMetadata::VolumeMetadataList volumes; ObjectMetadata::VolumeMetadataList* volumes_ptr = nullptr; @@ -2223,7 +2224,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) }*/ std::vector object_id_list; - _generate_current_object_list(object_id_list, object.first, m_current_objects); + if (!_generate_current_object_list(object_id_list, object.first, m_current_objects)) + return false; ObjectMetadata::VolumeMetadataList volumes; ObjectMetadata::VolumeMetadataList* volumes_ptr = nullptr; @@ -3908,11 +3910,18 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) { // appends the vertex coordinates // missing values are set equal to ZERO - if (m_curr_object) - m_curr_object->geometry.vertices.emplace_back( - m_unit_factor * bbs_get_attribute_value_float(attributes, num_attributes, X_ATTR), - m_unit_factor * bbs_get_attribute_value_float(attributes, num_attributes, Y_ATTR), - m_unit_factor * bbs_get_attribute_value_float(attributes, num_attributes, Z_ATTR)); + if (m_curr_object) { + const Vec3f v(m_unit_factor * bbs_get_attribute_value_float(attributes, num_attributes, X_ATTR), + m_unit_factor * bbs_get_attribute_value_float(attributes, num_attributes, Y_ATTR), + m_unit_factor * bbs_get_attribute_value_float(attributes, num_attributes, Z_ATTR)); + // A non-finite coordinate ("nan", "inf") used to be accepted and crashed + // qhull in ModelVolume's convex hull while the file was still loading. Refuse the file. + if (! v.allFinite()) { + _stop_xml_parser("Invalid vertex coordinate: not a finite number"); + return true; // the parser is stopped; returning false would overwrite the message + } + m_curr_object->geometry.vertices.emplace_back(v); + } return true; } @@ -5077,11 +5086,18 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) return true; } - void _BBS_3MF_Importer::_generate_current_object_list(std::vector &sub_objects, Id object_id, IdToCurrentObjectMap ¤t_objects) + bool _BBS_3MF_Importer::_generate_current_object_list(std::vector &sub_objects, Id object_id, IdToCurrentObjectMap ¤t_objects) { + // A cycle in the component graph would expand forever, and an acyclic graph can still expand + // exponentially, so bound the number of component references queued. Checking before they are + // queued bounds the work list itself, whatever the fan-out. A valid file over the budget is + // rejected too, but the budget is way above the component references of any real object. + static constexpr size_t max_components = 100000; + std::list> id_list; id_list.push_back(std::make_pair(Component(object_id, Transform3d::Identity()), Transform3d::Identity())); + size_t num_components = 0; while (!id_list.empty()) { auto current_item = id_list.front(); @@ -5091,6 +5107,12 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) if (current_object != current_objects.end()) { //found one if (!current_object->second.components.empty()) { + num_components += current_object->second.components.size(); + if (num_components > max_components) { + add_error("invalid 3mf: cyclic or too many component references"); + sub_objects.clear(); + return false; + } for (const Component &comp : current_object->second.components) { id_list.push_back(std::pair(comp, current_item.second * comp.transform)); } @@ -5102,6 +5124,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) } } } + return true; } bool _BBS_3MF_Importer::_generate_volumes_new(ModelObject& object, const std::vector &sub_objects, const ObjectMetadata::VolumeMetadataList& volumes, ConfigSubstitutionContext& config_substitutions) @@ -5208,6 +5231,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) } } + for (const Vec3f &v : sub_object->geometry.vertices) + if (! v.allFinite()) { // Qhull cannot take a NaN vertex + add_error("invalid (non-finite) vertex in object " + std::to_string(sub_object->id)); + return false; + } its.vertices.assign(sub_object->geometry.vertices.begin(), sub_object->geometry.vertices.end()); // BBS @@ -5721,11 +5749,18 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) { // appends the vertex coordinates // missing values are set equal to ZERO - if (current_object) - current_object->geometry.vertices.emplace_back( - object_unit_factor * bbs_get_attribute_value_float(attributes, num_attributes, X_ATTR), - object_unit_factor * bbs_get_attribute_value_float(attributes, num_attributes, Y_ATTR), - object_unit_factor * bbs_get_attribute_value_float(attributes, num_attributes, Z_ATTR)); + if (current_object) { + const Vec3f v(object_unit_factor * bbs_get_attribute_value_float(attributes, num_attributes, X_ATTR), + object_unit_factor * bbs_get_attribute_value_float(attributes, num_attributes, Y_ATTR), + object_unit_factor * bbs_get_attribute_value_float(attributes, num_attributes, Z_ATTR)); + // See _BBS_3MF_Importer::_handle_start_vertex: a non-finite coordinate + // crashed qhull while the file loaded. The dispatcher stops this parser on `false`. + if (! v.allFinite()) { + obj_parse_error_message = "Invalid vertex coordinate: not a finite number"; + return false; + } + current_object->geometry.vertices.emplace_back(v); + } return true; } diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 03f955f3e1..8e8084bbe3 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -30,10 +30,13 @@ #include "I18N.hpp" #include "IMEXHelpers.hpp" #include "GCode.hpp" +#include #include "Exception.hpp" #include "LifecycleEvents.hpp" #include "ExtrusionEntity.hpp" #include "EdgeGrid.hpp" +#include "BeltTransform.hpp" +#include "Geometry.hpp" #include "Geometry/ConvexHull.hpp" #include "GCode/PrintExtents.hpp" #include "GCode/Thumbnails.hpp" @@ -56,6 +59,7 @@ #include #include #include +#include #include #include #include @@ -103,6 +107,7 @@ #include #include "calib.hpp" #include "libslic3r_version.h" +#include "GCode/BeltKinematics.hpp" // Intel redesigned some TBB interface considerably when merging TBB with their oneAPI set of libraries, see GH #7332. // We are using quite an old TBB 2017 U7. Before we update our build servers, let's use the old API, which is deprecated in up to date TBB. #if ! defined(TBB_VERSION_MAJOR) @@ -2166,7 +2171,23 @@ void GCode::PlaceholderParserIntegration::validate_output_vector_variables() // Collect pairs of object_layer + support_layer sorted by print_z. // object_layer & support_layer are considered to be on the same print_z, if they are not further than EPSILON. -std::vector GCode::collect_layers_to_print(const PrintObject& object) +// Belt printers: whether an object layer writes anything, its own extrusions or a belt +// brim band riding on it. Shared by collect_layers_to_print() (which drops the layers +// that do not) and the layer count. +static bool belt_object_layer_prints_something(const PrintObject &object, const Layer &layer) +{ + if (layer.has_extrusions()) + return true; + if (object.has_belt_brim()) { + const auto &by_layer = object.belt_brim_by_layer(); + const size_t id = layer.id(); + if (id < by_layer.size() && ! by_layer[id].empty()) + return true; + } + return false; +} + +std::vector GCode::collect_layers_to_print(const PrintObject& object, bool skip_empty_first_layer) { std::vector layers_to_print; layers_to_print.reserve(object.layers().size() + object.support_layers().size()); @@ -2190,10 +2211,19 @@ std::vector GCode::collect_layers_to_print(const PrintObjec std::vector> warning_ranges; // 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()) { @@ -2206,6 +2236,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; @@ -2216,16 +2251,29 @@ 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. + // Skip this check for belt printers. The shear transform tilts the + // model so the first horizontal layer plane intersects only a thin + // sliver of the model (width ≈ first_layer_height / shear_factor). + // This sliver is often narrower than the nozzle diameter, producing + // zero perimeters and an empty first layer — which is expected, not + // an error. In global shear mode the object may also start above + // Z=0 on the tilted belt surface. if (layers_to_print.size() == 1u) { - if (!has_extrusions) + if (!has_extrusions && !skip_empty_first_layer) throw Slic3r::SlicingError(_(L("One object has an empty first layer and can't be printed. Please Cut the bottom or enable supports.")), object.id().id); } @@ -2256,14 +2304,32 @@ std::vector GCode::collect_layers_to_print(const PrintObjec + std::max(0., extra_gap); // Negative support_contact_z is not taken into account, it can result in false positives in cases - if (has_extrusions && layer_to_print.print_z() > maximal_print_z + 2. * EPSILON) - warning_ranges.emplace_back(std::make_pair((last_extrusion_layer ? last_extrusion_layer->print_z() : 0.), layers_to_print.back().print_z())); + if (has_extrusions && layer_to_print.print_z() > maximal_print_z + 2. * EPSILON) { + // Belt printers: a *leading* empty range (no prior extrusion layer, so the + // gap starts at Z=0) is not a floating object — it is just the belt lead-in. + // The part rests on the conveyor as it advances, so the first material can + // legitimately appear well above Z=0. This empty-layer check assumes a fixed + // bed, where material with nothing below it is unprintable; that assumption + // does not hold on a belt for the lead-in. Suppress only this leading case, + // and keep flagging genuine *internal* gaps (which on a belt may still be an + // over-angle overhang that would print into air). + const bool belt_leading_gap = object.print()->config().belt_printer.value + && last_extrusion_layer == nullptr; + if (!belt_leading_gap) + warning_ranges.emplace_back(std::make_pair((last_extrusion_layer ? last_extrusion_layer->print_z() : 0.), layers_to_print.back().print_z())); + } } // Remember last layer with extrusions. if (has_extrusions) last_extrusion_layer = &layers_to_print.back(); } + // ORCA-Belt: objects print at their position along the belt, so the first + // extrusions legitimately start far above Z=0. Drop the spurious + // "empty layers from the bed" range while keeping genuine mid-print gaps. + if (skip_empty_first_layer && !warning_ranges.empty() && warning_ranges.front().first == 0.) + warning_ranges.erase(warning_ranges.begin()); + if (! warning_ranges.empty()) { std::string warning; size_t i = 0; @@ -2277,6 +2343,22 @@ std::vector GCode::collect_layers_to_print(const PrintObjec PrintStateBase::WarningLevel::CRITICAL, warning, PrintStateBase::SlicingEmptyGcodeLayers); } + // Belt printers: drop the layers that print nothing at all. An object's slicing + // frame starts at the belt below its leading end, so its first layers are empty, + // and with several objects along the belt those empty layers fall between other + // objects' printing layers. A layer change with no moves is noise in the file, and + // the preview (libvgcode) numbers its layers from the moves it sees, so a gap folds + // every later layer into the one before it. Both print sequences collect their + // layers here, so neither writes such a layer. + if (object.print()->config().belt_printer.value) + layers_to_print.erase( + std::remove_if(layers_to_print.begin(), layers_to_print.end(), [&object](const LayerToPrint <p) { + return ! ((ltp.object_layer != nullptr && belt_object_layer_prints_something(object, *ltp.object_layer)) || + (ltp.support_layer != nullptr && ltp.support_layer->has_extrusions()) || + (ltp.belt_brim_band != nullptr && ! ltp.belt_brim_band->fills.empty())); + }), + layers_to_print.end()); + return layers_to_print; } @@ -2298,11 +2380,14 @@ std::vector>> GCode::collec for (size_t i = 0; i < print.objects().size(); ++i) { try { - per_object[i] = collect_layers_to_print(*print.objects()[i]); + per_object[i] = collect_layers_to_print(*print.objects()[i], print.config().belt_printer.value); } catch (const Slic3r::SlicingError &e) { errors.push_back(e); continue; } + // On a belt an object may be left without a layer to print at all. + if (per_object[i].empty()) + continue; OrderingItem ordering_item; ordering_item.object_idx = i; ordering.reserve(ordering.size() + per_object[i].size()); @@ -3060,21 +3145,71 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato m_fan_mover.release(); m_ordering_cache.clear(); - + + // Belt printer: initialize belt-specific writer via virtual hook. + this->init_belt_writer(print); m_writer.set_is_bbl_machine(is_bbl_printers); + // G-code axis remap. Only belt printers get one (see + // BeltTransformPipeline::axis_remap_enabled): a remap left in a profile must + // not change a non-belt print. Sync the writer's remap state to the current + // export UNCONDITIONALLY — even at the identity mapping (0,1,2) — so a reused + // writer never retains a stale non-identity mapping from a prior export. + // has_axis_remap() returns false at identity, so identity/default output stays + // unchanged. + { + const bool remap = BeltTransformPipeline::axis_remap_enabled(print.config()); + int rx = remap ? int(print.config().gcode_remap_x.value) : int(RemapAxis::PosX); + int ry = remap ? int(print.config().gcode_remap_y.value) : int(RemapAxis::PosY); + int rz = remap ? int(print.config().gcode_remap_z.value) : int(RemapAxis::PosZ); + m_writer.set_axis_remap(rx, ry, rz); + BoundingBoxf bbox_bed(print.config().printable_area.values); + m_writer.set_build_volume_max(Vec3d(bbox_bed.max.x(), bbox_bed.max.y(), + print.config().printable_height.value)); + } + + // Belt writers only: travel-speed selection becomes per-point (see + // GCodeWriter::uses_pointwise_travel_speed()), which must not change for + // non-belt printers. The writer gets the same test the extrusions use, so a + // travel is judged against the belt surface (belt_height_above_floor) exactly + // like the path it leads to. Writer points carry the G-code origin and + // extruder offset that point_to_gcode() added; the belt surface is described + // in the object's own frame. + if (print.config().belt_printer.value) { + m_writer.set_first_layer_point_test([this](const Vec3d &point_logical) { + const Vec2d extruder_offset = m_writer.filament() != nullptr ? EXTRUDER_CONFIG(extruder_offset) : Vec2d::Zero(); + // Undo what point_to_gcode() added (m_origin, minus the extruder offset) and + // what the writer then took off (its XY offset, the plate origin). + const Vec2d plate_offset = m_writer.get_xy_offset().cast(); + return this->on_first_layer(Vec3d(point_logical.x() - (m_origin.x() - plate_offset.x()) + extruder_offset.x(), + point_logical.y() - (m_origin.y() - plate_offset.y()) + extruder_offset.y(), + point_logical.z())); + }); + } + // How many times will be change_layer() called? // change_layer() in turn increments the progress bar status. m_layer_count = 0; + // On a belt, collect_layers_to_print() drops the layers that print nothing (an + // object's empty lead-in), so they must not be counted here either or the layer + // count in the file disagrees with its layer changes. + const bool belt = print.config().belt_printer.value; if (print.config().print_sequence == PrintSequence::ByObject) { // Add each of the object's layers separately. for (auto object : print.objects()) { std::vector zs; zs.reserve(object->layers().size() + object->support_layers().size()); for (auto layer : object->layers()) - zs.push_back(layer->print_z); + if (! belt || belt_object_layer_prints_something(*object, *layer)) + zs.push_back(layer->print_z); for (auto layer : object->support_layers()) - zs.push_back(layer->print_z); + if (! belt || layer->has_extrusions()) + 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); + if (zs.empty()) + continue; std::sort(zs.begin(), zs.end()); //BBS: merge numerically very close Z values. auto end_it = std::unique(zs.begin(), zs.end()); @@ -3091,9 +3226,14 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato for (auto object : print.objects()) { zs.reserve(zs.size() + object->layers().size() + object->support_layers().size()); for (auto layer : object->layers()) - zs.push_back(layer->print_z); + if (! belt || belt_object_layer_prints_something(*object, *layer)) + zs.push_back(layer->print_z); for (auto layer : object->support_layers()) - zs.push_back(layer->print_z); + if (! belt || layer->has_extrusions()) + 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()) { @@ -3868,6 +4008,10 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato } } + // Belt printer: the tilt and transform settings the G-code viewer reads back. They + // are comments outside the config block, so they go after the thumbnails that a + // BTT TFT firmware needs first, and are written whether or not that header block is. + this->write_belt_header(file, print); // Write some terse information on the slicing parameters. const PrintObject *first_object = print.objects().front(); @@ -4128,7 +4272,16 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato pa_test.set_speed(fast_speed, slow_speed); pa_test.draw_numbers() = print.calib_params().print_numbers; + + // ORCA-Belt: the PA line test draws directly on the build surface in + // logical bed coordinates — on a belt printer that surface is the + // belt plane, not the slicing plane. + const bool belt_world_coords = print.config().belt_printer.value; + if (belt_world_coords) + install_belt_kinematics(m_writer, print.config(), /*world_coordinates=*/true); gcode += pa_test.generate_test(params.start, params.step, std::llround(std::ceil((params.end - params.start) / params.step)) + 1); + if (belt_world_coords) + install_belt_kinematics(m_writer, print.config(), /*world_coordinates=*/false); file.write(gcode); } else { @@ -4165,6 +4318,8 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato } print.throw_if_canceled(); this->set_origin(unscale((*print_object_instance_sequential_active)->shift)); + this->on_set_origin((*print_object_instance_sequential_active)->print_object, + (*print_object_instance_sequential_active)->shift); // BBS: prime extruder if extruder change happens before this object instance bool prime_extruder = false; @@ -4205,12 +4360,15 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato // Reset the cooling buffer internal state (the current position, feed rate, accelerations). m_cooling_buffer->set_current_extruder(initial_extruder_id, get_extruder_id(initial_extruder_id)); m_cooling_buffer->reset(this->writer().get_position()); + // The belt first-layer band is tracked per object as well: if the previous + // object ended inside the band, this one has to open its own. + m_belt_in_band = false; // Process all layers of a single object instance (sequential mode) with a parallel pipeline: // Generate G-code, run the filters (vase mode, cooling buffer), run the G-code analyser // and export G-code into file. tool_ordering.cal_most_used_extruder(print.config()); m_printed_objects.emplace_back(&object); - this->process_layers(print, tool_ordering, collect_layers_to_print(object), *print_object_instance_sequential_active - object.instances().data(), file, + this->process_layers(print, tool_ordering, collect_layers_to_print(object, print.config().belt_printer.value), *print_object_instance_sequential_active - object.instances().data(), file, prime_extruder); { // save the flush statitics stored in tool ordering by object @@ -5513,8 +5671,43 @@ 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); + // m_layer is not switched to this object until after brim emission, so name + // the belt-floor owner explicitly or the classification borrows whichever + // object was visited last. + BeltFloorObjectGuard floor_owner{ m_belt_floor_object, &object }; + 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 {}; @@ -5551,6 +5744,158 @@ 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 +// the height above the belt, 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 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; + } + + // Apron bands precede object layer 0 and have no layer id of their own; they take the + // filament and nozzle assignment in effect at the first object layer. + m_cur_layer_idx = 0; + + // 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); + + // 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]; + gcode += ";" + GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Layer_Change) + "\n"; + 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"; + } + + // Objects sharing this apron Z may use different brim filaments; print each in its own tool. + for (const unsigned int brim_extruder : layer_tools.extruders) { + if (m_writer.filament() == nullptr || m_writer.filament()->id() != brim_extruder) + gcode += this->set_extruder(brim_extruder, print_z); + gcode += this->emit_belt_brim_bands(print, layers, single_object_instance_idx, brim_extruder); + } + + 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, + const unsigned int extruder_id) +{ + 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) + continue; + const PrintObject &object = *ltp.original_object; + // belt_brim_filament() is 1-based. + if (! object.has_belt_brim() || static_cast(object.belt_brim_filament() - 1) != extruder_id) + continue; + // Speeds, flow and retraction all read m_config. + m_config.apply(print.default_region_config()); + m_config.apply(object.config(), true); + // Apron bands have no Layer at all (m_layer is null here), so the belt + // floor owner has to be named the same way the object brim names it. + BeltFloorObjectGuard floor_owner{ m_belt_floor_object, &object }; + 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(); + } + } + return gcode; +} + // 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 @@ -5874,6 +6219,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; @@ -6038,7 +6390,29 @@ LayerResult GCode::process_layer( //BBS: set layer time fan speed after layer change gcode gcode += ";_SET_FAN_SPEED_CHANGING_LAYER\n"; + // Belt printers: ordinary-layer apron bands (a band whose print_z coincides with an + // object/support layer, so it takes this path rather than the brim-only branch) are + // NOT emitted here anymore. They used to be laid down with whatever tool happened to + // be active; instead they are now emitted inside the extruder loop below, in their + // own brim-filament pass and before that pass's object extrusion, so the brim goes + // down first with the correct tool. See the emit_belt_brim_for_extruder call. + //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 + // layer starts above Z=0 (at its position along the belt) with support-only + // layers below it. Reference the per-height calibration bands to the bottom + // of the object so they keep their designed meaning; on regular printers + // the object base is at Z=0 and calib_z == print_z. + double calib_z = print_z; + if (m_config.belt_printer.value && print.calib_mode() != CalibMode::Calib_None) { + // Skip empty ghost layers the grid may produce below the object. + for (const Layer* l : layer.object()->layers()) + if (!l->lslices.empty()) { + calib_z = print_z - (l->print_z - l->height); + break; + } + } switch (print.calib_mode()) { case CalibMode::Calib_PA_Tower: { gcode += writer().set_pressure_advance(this->interpolate_value_across_layers(static_cast(print.calib_params().start), @@ -6047,7 +6421,18 @@ LayerResult GCode::process_layer( break; } case CalibMode::Calib_Temp_Tower: { - gcode += writer().set_temperature(this->interpolate_value_across_layers(static_cast(print.calib_params().start), static_cast(print.calib_params().end), 5.0f)); + // ORCA-Belt: the sectioned variant prints each temperature as its + // own object in native belt orientation, with the temperature + // encoded in the object name ("temp_230") — step per object + // instead of ramping per layer band. + int sectioned_temp = 0; + if (m_config.belt_printer.value && + sscanf(layer.object()->model_object()->name.c_str(), "temp_%d", §ioned_temp) == 1 && + sectioned_temp > 0) { + gcode += writer().set_temperature(static_cast(sectioned_temp)); + } else { + gcode += writer().set_temperature(this->interpolate_value_across_layers(static_cast(print.calib_params().start), static_cast(print.calib_params().end), 5.0f)); + } break; } case CalibMode::Calib_VFA_Tower: { @@ -6061,16 +6446,16 @@ LayerResult GCode::process_layer( break; } case CalibMode::Calib_Vol_speed_Tower: { - auto _speed = print.calib_params().start + print_z * print.calib_params().step; + auto _speed = print.calib_params().start + std::max(0.0, calib_z) * print.calib_params().step; m_calib_config.set_key_value("outer_wall_speed", new ConfigOptionFloatsNullable({std::round(_speed)})); break; } case CalibMode::Calib_Retraction_tower: { - auto _length = print.calib_params().start + std::floor(std::max(0.0,print_z-0.4)) * print.calib_params().step; + auto _length = print.calib_params().start + std::floor(std::max(0.0,calib_z-0.4)) * print.calib_params().step; DynamicConfig _cfg; _cfg.set_key_value("retraction_length", new ConfigOptionFloats{_length}); writer().config.apply(_cfg); - sprintf(buf, "; Calib_Retraction_tower: Z_HEIGHT: %g, length:%g\n", print_z, _length); + sprintf(buf, "; Calib_Retraction_tower: Z_HEIGHT: %g, length:%g\n", calib_z, _length); gcode += buf; break; } @@ -6132,7 +6517,15 @@ LayerResult GCode::process_layer( } } - if (!first_layer && !m_second_layer_things_done) { + // Belt printers: defer the temperature/PLR transition until the entire layer + // is past the first-layer band above the belt. Elsewhere (non-belt printers, + // support-only layers) the legacy `!first_layer` predicate applies, so + // behavior is bit-identical to the pre-feature path. + bool past_first_layer_band = !first_layer; + if (int past = this->belt_layer_past_first_layer_band(object_layer); past >= 0) + past_first_layer_band = past > 0; + + if (past_first_layer_band && !m_second_layer_things_done) { // Orca: set power loss recovery const auto plr_mode = print.config().enable_power_loss_recovery.value; gcode += m_writer.enable_power_loss_recovery(plr_mode); @@ -6564,6 +6957,7 @@ LayerResult GCode::process_layer( std::vector &objects_by_extruder = objects_by_extruder_it->second; std::vector &instances = filament_plan.first; std::vector nodes; + std::vector> layout; // Per instance, see IslandOrderCacheEntry std::vector node_instances; auto quantize_to_mm = [](const Point &pt) -> Point { const coord_t grid = coord_t(scale_(1.)); @@ -6588,6 +6982,7 @@ LayerResult GCode::process_layer( const size_t instance_idx = instances.size(); instances.emplace_back(object_by_extruder, layer_id, *print_object, instance_id, print_object->instances()[instance_id].model_instance->get_labeled_id()); + layout.emplace_back(islands.size(), ! islands.empty() && ! islands.back().by_region.empty()); const Point &shift = print_object->instances()[instance_id].shift; const size_t first_node = nodes.size(); if (islands_chainable) @@ -6607,8 +7002,9 @@ LayerResult GCode::process_layer( // Reuse the cached tour while this filament's island layout is unchanged. auto &cache_entry = m_ordering_cache[filament_id]; - if (!(cache_entry.first == nodes)) { - cache_entry.first = nodes; + if (! (cache_entry.nodes == nodes && cache_entry.layout == layout)) { + cache_entry.nodes = nodes; + cache_entry.layout = layout; Points node_points; node_points.reserve(nodes.size()); for (const IslandOrderNode &node : nodes) @@ -6641,12 +7037,12 @@ LayerResult GCode::process_layer( // A visit without explicit islands already prints everything. continue; std::vector &islands = instances[i].object_by_extruder.islands; - if (!islands.back().by_region.empty()) + if (! islands.empty() && ! islands.back().by_region.empty()) last_visit.islands.emplace_back(islands.size() - 1); } - cache_entry.second = std::move(visits); + cache_entry.visits = std::move(visits); } - filament_plan.second = cache_entry.second; + filament_plan.second = cache_entry.visits; } } @@ -6706,6 +7102,21 @@ LayerResult GCode::process_layer( // Extrude the skirt, brim, support, perimeters, infill ordered by the extruders. m_skirt_group_done.resize(print.skirt_brim_groups().size()); + + // Belt brim bookkeeping. A coincident belt_brim_by_layer band must be emitted + // exactly once, in its object's brim-filament pass; this records which have gone + // down so the in-visit emit and the end-of-layer orphan sweep never double it. + // Key = (LayerToPrint index, instance_id). + std::set> belt_brim_emitted; + + // Emit every ORDINARY-layer apron band (belt_brim_prologue band coinciding with an + // object/support layer) whose brim filament is this pass's extruder, so each band + // prints in the correct tool's pass (Finding B). extruder_id is 0-based (the + // reindexed tool domain). + auto emit_belt_brim_for_extruder = [this, &print, &layers, single_object_instance_idx](unsigned int extruder_id) -> std::string { + return this->emit_belt_brim_bands(print, layers, single_object_instance_idx, extruder_id); + }; + for (unsigned int extruder_id : layer_tools.extruders) { if (print.config().skirt_type == stCombined && !print.skirt_brim_groups().empty()) { @@ -6822,6 +7233,16 @@ LayerResult GCode::process_layer( if (layer_tools.has_wipe_tower && m_wipe_tower) m_last_processor_extrusion_role = erWipeTower; + // Belt printers: now that this pass's tool is selected, lay down any ordinary-layer + // apron band whose brim filament is this extruder, before the object extrusion at + // this Z (brim goes down first, with the correct tool). Restore the origin so the + // object-setup code below is unaffected. + if (print.has_belt_brim()) { + const Vec2d saved_origin = m_origin; + gcode += emit_belt_brim_for_extruder(extruder_id); + this->set_origin(saved_origin); + } + auto &filament_plan = filament_to_print_instances[extruder_id]; std::vector &instances_to_print = filament_plan.first; const std::vector &instance_visits = filament_plan.second; @@ -6838,7 +7259,20 @@ 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); + const PrintObject &vobj = instance_to_print.print_object; + if (vobj.has_belt_brim()) { + // Coincident belt brim: emit once, only in this object's brim-filament + // pass (extruder_id and belt_brim_filament()-1 are both 0-based here), + // and dedup on the LayerToPrint index (not Layer::id()) so the orphan + // sweep below never re-emits it. + if (extruder_id == (unsigned int)(vobj.belt_brim_filament() - 1) && + belt_brim_emitted.insert({ instance_to_print.layer_id, instance_to_print.instance_id }).second) + gcode += generate_object_brim(print, vobj, instance_to_print.instance_id, first_layer, + layer_to_print.object_layer); + } else { + gcode += generate_object_brim(print, vobj, 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. @@ -6889,6 +7323,7 @@ LayerResult GCode::process_layer( m_avoid_crossing_perimeters.use_external_mp_once(); m_last_obj_copy = this_object_copy; this->set_origin(unscale(offset)); + this->on_set_origin(&instance_to_print.print_object, offset); if (visit.first_visit && instance_to_print.object_by_extruder.support != nullptr) { m_layer = layers[instance_to_print.layer_id].support_layer; m_object_layer_over_raft = false; @@ -6900,6 +7335,7 @@ LayerResult GCode::process_layer( m_avoid_crossing_perimeters.use_external_mp_once(); m_last_obj_copy = this_object_copy; this->set_origin(unscale(offset)); + this->on_set_origin(&instance_to_print.print_object, offset); ExtrusionEntityCollection support_eec; // BBS @@ -6929,7 +7365,13 @@ LayerResult GCode::process_layer( // in this instance's frame after set_origin() above). Empty islands are skipped; // the trailing catch-all island has no centroid to chain by and always goes last. std::vector &islands = instance_to_print.object_by_extruder.islands; - std::vector island_order = visit.islands; + std::vector island_order; + island_order.reserve(visit.islands.size()); + for (size_t idx : visit.islands) // Never index past the islands (see IslandOrderCacheEntry) + if (idx < islands.size()) + island_order.emplace_back(idx); + else + BOOST_LOG_TRIVIAL(error) << "island tour refers to island " << idx << " of " << islands.size() << ", skipped"; if (island_order.empty()) { island_order.reserve(islands.size()); if (layer_to_print.object_layer != nullptr && islands.size() == layer_to_print.object_layer->lslices.size() + 1) { @@ -6967,6 +7409,7 @@ LayerResult GCode::process_layer( m_avoid_crossing_perimeters.use_external_mp_once(); m_last_obj_copy = this_object_copy; this->set_origin(unscale(offset)); + this->on_set_origin(&instance_to_print.print_object, offset); //FIXME the following code prints regions in the order they are defined, the path is not optimized in any way. auto has_infill = [](const std::vector &by_region) { @@ -7106,6 +7549,9 @@ LayerResult GCode::process_layer( m_avoid_crossing_perimeters.use_external_mp_once(); m_last_obj_copy = this_object_copy; this->set_origin(unscale(offset)); + // Same as the main instance loop: a belt printer rotates the origin through + // the belt transform (BeltGCode::on_set_origin). + this->on_set_origin(&instance_to_print.print_object, offset); // --- Build emission plan --- // Each entry represents one travel_to_z + extrude pass. Per-object mode produces @@ -7338,6 +7784,37 @@ LayerResult GCode::process_layer( } } + + // Belt brim orphan sweep (Finding C). A coincident belt_brim_by_layer band lives on + // an object layer, but that layer can yield no InstanceVisit above - a zero-extrusion + // lead-in slice with no coinciding support - so the in-visit emit never fired and the + // band would be dropped. Emit any such band exactly once here, keyed the same way as + // the in-visit emit so already-printed bands are skipped. These orphan layers carry + // no object material, so ending on the brim's position is harmless; we still save and + // restore m_origin, and only toolchange when the brim filament differs from the active + // one - a no-op on single-extruder prints, keeping their output unchanged. + if (print.has_belt_brim()) { + const Vec2d saved_origin = m_origin; + for (const LayerToPrint <p : layers) { + const PrintObject *obj = ltp.original_object; + if (obj == nullptr || ! obj->has_belt_brim() || ltp.object_layer == nullptr) + continue; + const size_t ltp_idx = size_t(<p - layers.data()); + const unsigned int brim0 = (unsigned int)(obj->belt_brim_filament() - 1); + 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) ? obj->instances().size() + : single_object_instance_idx + 1; + for (size_t instance_id = i_begin; instance_id < i_end && instance_id < obj->instances().size(); ++ instance_id) { + if (! belt_brim_emitted.insert({ ltp_idx, instance_id }).second) + continue; + if (m_writer.filament() == nullptr || m_writer.filament()->id() != brim0) + gcode += this->set_extruder(brim0, print_z); + gcode += generate_object_brim(print, *obj, instance_id, first_layer, ltp.object_layer); + } + } + this->set_origin(saved_origin); + } + if (first_layer) { for (auto iter = by_extruder.begin(); iter != by_extruder.end(); ++iter) { if (!iter->second.empty()) @@ -7691,8 +8168,13 @@ std::string GCode::extrude_loop(const ExtrusionLoop& loop_ loop.split_at(last_pos, false); const auto seam_scarf_type = m_config.seam_slope_type.value; + // Belt printers never get a scarf joint. The scarf starts one layer height + // below the layer, which on a tilted belt is a step backwards along the belt + // axis into the previous layer's wall at the seam (0.28 mm at 45 degrees per + // 0.2 mm layer); with an aligned seam that ram repeats at the same spot on + // every layer and knocks the part loose. bool enable_seam_slope = ((seam_scarf_type == SeamScarfType::External && !is_hole) || seam_scarf_type == SeamScarfType::All) && - !m_config.spiral_mode && + !m_config.spiral_mode && !m_config.belt_printer.value && (loop.role() == erExternalPerimeter || (loop.role() == erPerimeter && m_config.seam_slope_inner_walls)) && layer_id() > 0; const auto nozzle_diameter = EXTRUDER_CONFIG(nozzle_diameter); @@ -8341,6 +8823,21 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d const std::string bridge_description = is_bridge(path.role()) ? path_description + " (bridge)" : std::string(); const std::string &description = bridge_description.empty() ? path_description : bridge_description; + // First-layer plane evaluation: compute the path's slicing-frame point + // once and reuse for every per-path call site below. When the plane + // 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 : (m_belt_brim_z ? *m_belt_brim_z : 0.0) + }; + const bool path_on_first_layer = this->on_first_layer(path_point_mm); + const ExtrusionPathSloped* sloped = dynamic_cast(&path); const auto get_sloped_z = [&sloped, this](double z_ratio) { @@ -8415,7 +8912,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d const double internal_solid_infill_acceleration = role == erSolidInfill ? m_config.internal_solid_infill_acceleration.get_at(nozzle).get_abs_value(m_config.default_acceleration.get_at(nozzle)) : 0.; double acceleration; - if (this->on_first_layer() && m_config.initial_layer_acceleration.get_at(nozzle) > 0) { + if (path_on_first_layer && m_config.initial_layer_acceleration.get_at(nozzle) > 0) { acceleration = m_config.initial_layer_acceleration.get_at(nozzle); #if 0 } else if (this->object_layer_over_raft() && m_config.first_layer_acceleration_over_raft.value > 0) { @@ -8441,7 +8938,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d // adjust X Y jerk if (NOZZLE_CONFIG(default_jerk) > 0) { - if (this->on_first_layer() && NOZZLE_CONFIG(initial_layer_jerk) > 0) { + if (path_on_first_layer && NOZZLE_CONFIG(initial_layer_jerk) > 0) { jerk = NOZZLE_CONFIG(initial_layer_jerk); } else if (NOZZLE_CONFIG(outer_wall_jerk) > 0 && is_external_perimeter(path.role())) { jerk = NOZZLE_CONFIG(outer_wall_jerk); @@ -8503,7 +9000,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d } // Additionally, adjust the value if we are on the first layer (except for brims and skirts) - if (this->on_first_layer() && (path.role() != erBrim && path.role() != erSkirt)) { + if (path_on_first_layer && (path.role() != erBrim && path.role() != erSkirt)) { _mm3_per_mm *= m_config.first_layer_flow_ratio; } } @@ -8570,9 +9067,25 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d if (speed == 0) speed = filament_max_volumetric_speed / _mm3_per_mm; - - const auto _layer = layer_id(); - if (this->on_first_layer() || object_layer_over_raft()) { + // Use the belt-aware effective layer index when on a belt printer so + // the speed fade tracks perpendicular distance from the plane on + // belt printers; otherwise this falls back to the slicing layer id. + const int _layer = this->effective_layer_index_for_point(path_point_mm); + // Belt printers: a tilted layer runs from the belt to the top of the part, so the + // "first layers" the fan stays off for are a band along the belt. Mark where the + // extrusion enters and leaves it, per segment, for the cooling buffer. + const bool belt_band_tags = m_enable_cooling_markers && m_config.belt_printer.value; + const int belt_band_layers = belt_band_tags ? m_config.close_fan_the_first_x_layers.get_at(m_writer.filament()->id()) : 0; + auto tag_belt_band = [this, &gcode, belt_band_tags, belt_band_layers, z = path_point_mm.z()](coord_t x, coord_t y) { + if (! belt_band_tags) + return; + const bool in_band = this->effective_layer_index_for_point(Vec3d(unscale(x), unscale(y), z)) < belt_band_layers; + if (in_band != m_belt_in_band) { + gcode += in_band ? ";_BELT_BAND_START\n" : ";_BELT_BAND_END\n"; + m_belt_in_band = in_band; + } + }; + if (path_on_first_layer || object_layer_over_raft()) { //BBS: for solid infill of first layer, speed can be higher as long as //wall lines have be attached if (path.role() != erBottomSurface) { @@ -8581,7 +9094,6 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d NOZZLE_CONFIG(initial_layer_infill_speed); } } else if (m_config.slow_down_layers > 1 && m_config.raft_layers == 0) { - if (_layer > 0 && _layer < m_config.slow_down_layers) { const auto first_layer_speed = is_perimeter(path.role()) @@ -8668,7 +9180,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d const bool need_overhang_detection = NOZZLE_CONFIG(enable_overhang_speed) || (FILAMENT_CONFIG(enable_overhang_bridge_fan) && m_enable_cooling_markers); - if (need_overhang_detection && !this->on_first_layer() && !object_layer_over_raft() && + if (need_overhang_detection && !path_on_first_layer && !object_layer_over_raft() && (is_bridge(path.role()) || is_perimeter(path.role()))) { bool is_external = is_external_perimeter(path.role()); double ref_speed = is_external ? NOZZLE_CONFIG(outer_wall_speed) : NOZZLE_CONFIG(inner_wall_speed); @@ -9028,7 +9540,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d } // BBS: use G1 if not enable arc fitting or has no arc fitting result or in spiral_mode mode or we are doing sloped extrusion // Attention: G2 and G3 is not supported in spiral_mode mode - if (!m_config.enable_arc_fitting || path.polyline.fitting_result.empty() || m_config.spiral_mode || sloped != nullptr || path.z_contoured) { + if (!m_config.enable_arc_fitting || path.polyline.fitting_result.empty() || m_config.spiral_mode || sloped != nullptr || path.z_contoured || this->should_disable_arc_fitting()) { double path_length = 0.; double total_length = sloped == nullptr ? 0. : path.polyline.length() * SCALING_FACTOR; double saved_z = m_writer.get_position().z(); @@ -9048,6 +9560,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d flow_description = description + Slic3r::format(" | Old Flow Value: %0.5f Length: %0.5f",oldE, line_length); } } + tag_belt_band((line.a.x() + line.b.x()) / 2, (line.a.y() + line.b.y()) / 2); if (path.z_contoured) { // ZAA: Z anti-aliased extrusion with variable Z per point Vec2d dest2d = this->point_to_gcode(line.b.to_point()); @@ -9178,6 +9691,7 @@ std::string GCode::_extrude(const ExtrusionPath &path, const std::string &path_d const ProcessedPoint &processed_point = new_points[i]; const ProcessedPoint &pre_processed_point = new_points[i-1]; Vec3d p = this->point_to_gcode_quantized(processed_point.p); + tag_belt_band((pre_processed_point.p.x() + processed_point.p.x()) / 2, (pre_processed_point.p.y() + processed_point.p.y()) / 2); if (m_enable_cooling_markers) { if (enable_overhang_bridge_fan) { cur_fan_enabled = check_overhang_fan(processed_point.overlap, path.role()); @@ -9337,10 +9851,26 @@ std::string GCode::extrusion_role_to_string_for_parser(const ExtrusionRole & rol // Step = 0 means gradual interpolation finishing at last value. float GCode::interpolate_value_across_layers(float start_value, float end_value, float step) const { - if (m_layer_index <= 1) { + float ratio; + // ORCA-Belt: counter-rotated calibration objects stand on a support wedge, + // so support-only layers below the object would stretch a layer-index + // interpolation. Use the object's own Z span instead, so the value ramps + // across the test geometry only. + if (m_config.belt_printer.value && m_layer != nullptr && !m_layer->object()->layers().empty()) { + const auto& layers = m_layer->object()->layers(); + // Skip empty ghost layers the grid may produce below the object. + double z_min = layers.front()->print_z; + for (const Layer* l : layers) + if (!l->lslices.empty()) { z_min = l->print_z; break; } + const double z_max = layers.back()->print_z; + if (m_layer->print_z <= z_min + EPSILON || z_max - z_min <= EPSILON) + return start_value; + ratio = float(std::min(1.0, (m_layer->print_z - z_min) / (z_max - z_min))); + } else if (m_layer_index <= 1) { return start_value; + } else { + ratio = m_layer_index / (m_layer_count - 1.f); } - const float ratio = m_layer_index / (m_layer_count - 1.f); if (step > 0.f) { // Discrete equal-width bands. band is clamped to the last band so the result can't overshoot the range: // at the top layer ratio * n_bands == n_bands, which would otherwise index one band past the end. @@ -9453,6 +9983,7 @@ std::string GCode::travel_to(const Point& point, ExtrusionRole role, std::string // multi-hop travel path inside the configuration space if (m_config.reduce_crossing_wall && !m_avoid_crossing_perimeters.disabled_once() + && m_layer != nullptr // A brim apron layer has no Layer to avoid crossing && m_writer.is_current_position_clear()) //BBS: don't generate detour travel paths when current position is unclea { @@ -9477,7 +10008,8 @@ std::string GCode::travel_to(const Point& point, ExtrusionRole role, std::string // When "Wipe while retracting" is enabled, then extruder moves to another position, and travel from this position can cross perimeters. // Because of it, it is necessary to call avoid crossing perimeters again with new starting point after calling retraction() // FIXME Lukas H.: Try to predict if this second calling of avoid crossing perimeters will be needed or not. It could save computations. - if (last_post_before_retract != this->last_pos() && m_config.reduce_crossing_wall) { + if (last_post_before_retract != this->last_pos() && m_config.reduce_crossing_wall + && m_layer != nullptr) { // A brim apron layer has no Layer to avoid crossing // If in the previous call of m_avoid_crossing_perimeters.travel_to was use_external_mp_once set to true restore this value for next call. if (used_external_mp_once) m_avoid_crossing_perimeters.use_external_mp_once(); @@ -10374,6 +10906,10 @@ std::string GCode::set_object_info(Print *print) { for (PrintInstance& inst : object->instances()) { inst.unique_id = unique_id++; inst.id = inst_id++; + // Outlines are in plate coordinates. On a belt printer that is the frame after + // the slicing rotation has been undone and before the G-code axis remap and + // machine-frame shear: where the object stands on the belt, which is what an + // object picker shows. Klipper cancels by name, so nothing depends on more. auto bbox = inst.get_bounding_box(); auto center = print->translate_to_print_space(Vec2d(bbox.center().x(), bbox.center().y())); const std::string &inst_name = instance_name(inst); @@ -10396,6 +10932,64 @@ std::string GCode::set_object_info(Print *print) { return gcode.str(); } +// Whether an object layer lies entirely past the first-layer band above the belt: +// 1 when its lowest point is at least one band thickness above the belt, 0 when +// any of it is inside the band, -1 when the belt surface is not known for this +// layer (not a belt print, or no object layer), in which case the caller falls +// back to the slicing layer index. +int GCode::belt_layer_past_first_layer_band(const Layer *object_layer) const +{ + if (object_layer == nullptr) + return -1; + // The belt surface is linear in the sliced XY, so a bbox's lowest point above + // it is at one of its corners. + double min_height = std::numeric_limits::max(); + bool known = false; + for (const BoundingBox &bb : object_layer->lslices_bboxes) { + const double xs[2] = { unscale(bb.min.x()), unscale(bb.max.x()) }; + const double ys[2] = { unscale(bb.min.y()), unscale(bb.max.y()) }; + for (double x : xs) + for (double y : ys) { + double h; + if (! this->belt_height_above_floor(Vec3d(x, y, object_layer->print_z), h)) + return -1; + known = true; + min_height = std::min(min_height, h); + } + } + if (! known) + return -1; + return min_height >= this->first_layer_band_mm() - EPSILON ? 1 : 0; +} + +bool GCode::belt_height_above_floor(const Vec3d &point_slicing_mm, double &height_mm) const +{ + // The owning object, which is what carries the belt description. During + // object-brim and coincident-apron emission m_layer still points at whichever + // object was visited last (or at nothing at all), so those paths publish the + // owner explicitly -- otherwise a brim's speed would depend on plate order. + const PrintObject *object = m_belt_floor_object != nullptr ? m_belt_floor_object + : (m_layer != nullptr ? m_layer->object() : nullptr); + if (object == nullptr) + return false; + const SlicingParameters &sp = object->slicing_parameters(); + // Deliberately NOT BeltFloorContext: its init() folds in + // belt_support_floor_offset, a support-generator diagnostic. Letting that + // option move the model's first-layer speed band would be a surprising + // coupling -- a negative value would switch the slowdown off entirely. + // The belt surface itself is just shear * u + z_shift. + if (std::abs(sp.belt_floor_shear_factor) < EPSILON) + return false; + const double u = sp.belt_floor_from_axis == 0 ? point_slicing_mm.x() : point_slicing_mm.y(); + const double floor_z = sp.belt_floor_shear_factor * u + sp.belt_floor_z_shift; + // Measured along the slicing Z, not perpendicular to the belt: layers are + // horizontal slabs in the sliced frame, so the slab holding the material that + // rests on the belt at this point is the one within one layer height of it. + // A perpendicular measure would shrink the band by 1/cos(tilt). + height_mm = point_slicing_mm.z() - floor_z; + return true; +} + // convert a model-space scaled point into G-code coordinates Vec2d GCode::point_to_gcode(const Point &point) const { diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index 5e2dd671b7..c89b646ae2 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -7,6 +7,7 @@ #include "Print.hpp" #include "libslic3r.h" #include "GCodeWriter.hpp" +#include "GCode/BeltKinematics.hpp" #include "Layer.hpp" #include "Point.hpp" #include "PlaceholderParser.hpp" @@ -40,13 +41,16 @@ #include #include #include +#include #include #include #include #include #include +#include #include "BoundingBox.hpp" #include "Polyline.hpp" +#include "BeltBrim.hpp" namespace Slic3r { class ExtrusionEntityCollection; } @@ -249,8 +253,9 @@ public: m_toolchange_count(0), m_nominal_z(0.) {} - ~GCode() = default; + virtual ~GCode() = default; +public: // throws std::runtime_exception on error, // throws CanceledException through print->throw_if_canceled(). void do_export(Print* print, const char* path, GCodeProcessorResult* result = nullptr, ThumbnailsGeneratorCallback thumbnail_cb = nullptr); @@ -355,6 +360,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) @@ -384,11 +396,17 @@ 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; } }; -private: +protected: class GCodeOutputStream { public: GCodeOutputStream(FILE *f, GCodeProcessor &processor) : f(f), m_processor(processor) {} @@ -416,9 +434,21 @@ private: FILE *f = nullptr; GCodeProcessor &m_processor; }; + + // Virtual hooks for belt printer subclass (BeltGCode). + // No-ops in base GCode; overridden in BeltGCode. + virtual void init_belt_writer(Print &print) {} + virtual void write_belt_header(GCodeOutputStream &file, const Print &print) {} + virtual void on_set_origin(const PrintObject *obj, const Point &inst_shift) {} + // Arc fitting is suppressed whenever the writer's machine mapping cannot + // represent a G2/G3 arc. Belt printers get this through BeltKinematics + // rather than through an override of their own. + virtual bool should_disable_arc_fitting() const + { return ! m_writer.kinematics().supports_arc_moves(); } + void _do_export(Print &print, GCodeOutputStream &file, ThumbnailsGeneratorCallback thumbnail_cb); - static std::vector collect_layers_to_print(const PrintObject &object); + static std::vector collect_layers_to_print(const PrintObject &object, bool skip_empty_first_layer = false); static std::vector>> collect_layers_to_print(const Print &print); std::string generate_skirt(const Print &print, @@ -438,7 +468,29 @@ private: 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); + + // Emit the apron bands carried by these layers whose brim filament is extruder_id + // (0-based). 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, + const unsigned int extruder_id); LayerResult process_layer( const Print &print, @@ -642,9 +694,21 @@ private: }; // Cache the per-filament island tour to avoid recomputing while the layer's island layout is - // unchanged. Key: filament_id. Value: {nodes the tour was computed from, resulting visits}. - std::map, std::vector>> - m_ordering_cache; + // unchanged. Key: filament_id. Value: the nodes the tour was computed from, the per-instance + // island layout (count and whether the trailing catch-all island has anything to print), and + // the resulting visits. + // The layout is part of the key. Nodes only cover the chainable islands, so two + // layers with the same centroids but a different number of islands (thin walls, negative + // volumes come and go) matched the cache and the visit's catch-all index -- islands.size() - 1 + // of the OLD layer -- ran past the new layer's islands (found by fuzzing: segfault in + // extrude_perimeters on multi-part objects). + struct IslandOrderCacheEntry + { + std::vector nodes; + std::vector> layout; + std::vector visits; + }; + std::map m_ordering_cache; ExtrusionQualityEstimator m_extrusion_quality_estimator; @@ -781,7 +845,6 @@ private: std::unique_ptr m_cooling_buffer; std::unique_ptr m_spiral_vase; - std::unique_ptr m_pressure_equalizer; std::unique_ptr m_pa_processor; @@ -844,6 +907,25 @@ private: mutable ConfigIndexCache m_filament_index_cache; mutable ConfigIndexCache m_nozzle_index_cache; + // 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; + // Belt brim only. Brim and coincident apron bands are emitted before m_layer + // is switched to their object, so belt_height_above_floor() would otherwise + // read the previously visited object's belt description -- making a brim's + // classification depend on plate visiting order. Those paths publish the + // owner here for the duration of the emission. Never left set. + const PrintObject *m_belt_floor_object{nullptr}; + struct BeltFloorObjectGuard { + const PrintObject *&slot; + BeltFloorObjectGuard(const PrintObject *&s, const PrintObject *o) : slot(s) { slot = o; } + ~BeltFloorObjectGuard() { slot = nullptr; } + }; + + // The last extrusion segment was inside the belt's first-layer fan band (see _extrude()). + bool m_belt_in_band{false}; + std::set m_initial_layer_extruders; std::vector> m_sorted_layer_filaments; // BBS @@ -861,6 +943,46 @@ private: // On the first printing layer. This flag triggers first layer speeds. //BBS bool on_first_layer() const { return m_layer != nullptr && m_layer->id() == 0 && abs(m_layer->bottom_z()) < EPSILON; } + // Per-point first-layer test. On a belt printer the result depends on the + // supplied slicing-frame point (its height above the belt); otherwise we + // delegate to the legacy per-layer test. This is the entry point used by + // per-path call sites in _extrude. + bool on_first_layer(const Vec3d &point_slicing_mm) const { + double h; + if (this->belt_height_above_floor(point_slicing_mm, h)) + return h <= m_config.initial_layer_print_height.value + EPSILON; + return on_first_layer(); + } + // "Effective layer index" used to drive layer-count thresholds like + // slow_down_layers. On a belt printer this is the height above the belt in + // first_layer_band_mm() units; otherwise it is the legacy slicing layer index. + int effective_layer_index_for_point(const Vec3d &point_slicing_mm) const { + double h; + if (this->belt_height_above_floor(point_slicing_mm, h)) { + const double lh = this->first_layer_band_mm(); + return h <= 0. ? 0 : int(std::floor(h / lh)); + } + return on_first_layer() ? 0 : layer_id(); + } + + // Band thickness for the *effective layer index*: one first layer height, so + // "the first N layers" means the same height above the belt as on a flat bed. + double first_layer_band_mm() const { + const double band = m_config.initial_layer_print_height.value; + return band > 0. ? band : 0.2; + } + + // Height of a slicing-frame point above the belt surface, or false when this + // is not a belt print. + // + // The belt surface is known exactly in the slicing frame from the slicing + // parameters (belt_floor_shear_factor / _from_axis / _z_shift) -- the same + // description the support generator uses, independent of every remap and + // back-transform. + bool belt_height_above_floor(const Vec3d &point_slicing_mm, double &height_mm) const; + // 1 / 0 / -1: the object layer is entirely past the first-layer band above the + // belt / reaches into it / the belt surface is not known for it. + int belt_layer_past_first_layer_band(const Layer *object_layer) const; int layer_id() const { if (m_layer == nullptr) return -1; diff --git a/src/libslic3r/GCode/BeltBackTransform.cpp b/src/libslic3r/GCode/BeltBackTransform.cpp new file mode 100644 index 0000000000..3d2cabc718 --- /dev/null +++ b/src/libslic3r/GCode/BeltBackTransform.cpp @@ -0,0 +1,33 @@ +#include "BeltBackTransform.hpp" +#include "../BeltTransform.hpp" +#include "../Point.hpp" +#include "../PrintConfig.hpp" + +namespace Slic3r { + +bool BeltBackTransform::init_from_config(const PrintConfig &config) +{ + m_active = false; + m_inverse = Transform3d::Identity(); + + if (!config.belt_printer.value) + return false; + + // Build the forward pipeline (the rotation) and store its inverse. + Transform3d forward = BeltTransformPipeline::build_forward_transform(config); + if (forward.isApprox(Transform3d::Identity())) + return false; + + m_inverse = forward.inverse(); + m_active = true; + return true; +} + +Vec3d BeltBackTransform::apply(const Vec3d &pos) const +{ + if (!m_active) + return pos; + return m_inverse * pos; +} + +} // namespace Slic3r diff --git a/src/libslic3r/GCode/BeltBackTransform.hpp b/src/libslic3r/GCode/BeltBackTransform.hpp new file mode 100644 index 0000000000..236ad84f51 --- /dev/null +++ b/src/libslic3r/GCode/BeltBackTransform.hpp @@ -0,0 +1,38 @@ +#ifndef slic3r_BeltBackTransform_hpp_ +#define slic3r_BeltBackTransform_hpp_ + +#include "../libslic3r.h" +#include "../Point.hpp" +#include "../PrintConfig.hpp" + +namespace Slic3r { + +// Reverses the pre-slice rotation that PrintObjectSlice.cpp applies to belt +// printer geometry, converting G-code coordinates from the sliced (rotated) +// frame back to the machine's real coordinate space. +// +// Initialized once from PrintConfig, then applied per-point in +// BeltKinematics::to_machine() before axis remapping. +// +// Active on belt printers with a non-identity pre-slice rotation. +class BeltBackTransform +{ +public: + BeltBackTransform() = default; + + // Initialize from belt printer config. Rebuilds the same pre-slice rotation + // as PrintObjectSlice.cpp and precomputes the affine inverse. Returns true if a non-identity back-transform was computed. + bool init_from_config(const PrintConfig &config); + + // Apply the inverse transform to a point. Returns pos unchanged if + // no back-transform is active. + Vec3d apply(const Vec3d &pos) const; + +private: + bool m_active = false; + Transform3d m_inverse = Transform3d::Identity(); +}; + +} // namespace Slic3r + +#endif // slic3r_BeltBackTransform_hpp_ diff --git a/src/libslic3r/GCode/BeltKinematics.cpp b/src/libslic3r/GCode/BeltKinematics.cpp new file mode 100644 index 0000000000..27d5dad105 --- /dev/null +++ b/src/libslic3r/GCode/BeltKinematics.cpp @@ -0,0 +1,29 @@ +#include "BeltKinematics.hpp" +#include "../BeltTransform.hpp" +#include "../PrintConfig.hpp" +#include "../GCodeWriter.hpp" +#include "../Point.hpp" +#include + +namespace Slic3r { + +BeltKinematics::BeltKinematics(const PrintConfig &config, bool world_coordinates) + : m_world_coordinates(world_coordinates) +{ + m_back_transform.init_from_config(config); + m_machine_frame.init_from_config(config); +} + +Vec3d BeltKinematics::to_machine(const Vec3d &p) const +{ + const Vec3d after_back = m_world_coordinates ? p : m_back_transform.apply(p); + const Vec3d after_remap = this->apply_axis_remap(after_back); + return m_machine_frame.apply(after_remap); +} + +void install_belt_kinematics(GCodeWriter &writer, const PrintConfig &config, bool world_coordinates) +{ + writer.set_kinematics(std::make_unique(config, world_coordinates)); +} + +} // namespace Slic3r diff --git a/src/libslic3r/GCode/BeltKinematics.hpp b/src/libslic3r/GCode/BeltKinematics.hpp new file mode 100644 index 0000000000..7dc08635ab --- /dev/null +++ b/src/libslic3r/GCode/BeltKinematics.hpp @@ -0,0 +1,59 @@ +#ifndef slic3r_BeltKinematics_hpp_ +#define slic3r_BeltKinematics_hpp_ + +#include "MachineKinematics.hpp" +#include "BeltBackTransform.hpp" +#include "MachineFrameTransform.hpp" +#include "../Point.hpp" + +namespace Slic3r { + +class PrintConfig; +class GCodeWriter; + +// Belt-printer machine frame. +// +// Forward order, as applied per emitted point: +// machine = MachineFrameTransform( axis_remap( BeltBackTransform( logical ) ) ) +// +// i.e. the slicer->world back-transform runs FIRST and the machine-frame +// shear/scale LAST, so the latter acts as a global linear transform on the +// already-placed coordinates. +// +// world_coordinates mode (the PA line / PA pattern calibration generators) +// treats the incoming point as already relative to the belt surface -- X across, +// Y along the belt, Z above it -- and therefore skips the back-transform while +// keeping the remap and the machine frame. It is a different coordinate map, not +// a writer mode, which is why it is fixed at construction. +class BeltKinematics : public CartesianKinematics +{ +public: + explicit BeltKinematics(const PrintConfig &config, bool world_coordinates = false); + + Vec3d to_machine(const Vec3d &p) const override; + + // A belt writer has always emitted full XYZ on every move, whether or not any + // individual stage reports itself active. Making this conditional would change + // emitted G-code for an identity-transform belt configuration. + bool must_emit_all_axes() const override { return true; } + bool suppress_lift_at_unknown_position() const override { return true; } + // The machine frame shears and scales, so a circle is an ellipse in machine + // coordinates and G2/G3 cannot describe it. + bool supports_arc_moves() const override { return false; } + +private: + BeltBackTransform m_back_transform; + MachineFrameTransform m_machine_frame; + bool m_world_coordinates { false }; +}; + +// Install a belt machine frame on any GCodeWriter. Any axis remap and build +// volume already configured on the writer are carried over, so this may be +// called before or after those setters. Re-calling it with a different +// world_coordinates value swaps the map (used around the PA line generator). +void install_belt_kinematics(GCodeWriter &writer, const PrintConfig &config, + bool world_coordinates = false); + +} // namespace Slic3r + +#endif // slic3r_BeltKinematics_hpp_ diff --git a/src/libslic3r/GCode/CoolingBuffer.cpp b/src/libslic3r/GCode/CoolingBuffer.cpp index 9fe7bbbd20..03ef4b3441 100644 --- a/src/libslic3r/GCode/CoolingBuffer.cpp +++ b/src/libslic3r/GCode/CoolingBuffer.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -46,10 +47,12 @@ CoolingBuffer::CoolingBuffer(GCode &gcodegen) : m_config(gcodegen.config()), m_g m_num_extruders = std::max(ex.id() + 1, m_num_extruders); m_extruder_ids.emplace_back(ex.id()); } + } void CoolingBuffer::reset(const Vec3d &position) { + m_belt_band_active = false; // BBS: add I and J axis to store center of arc m_current_pos.assign(7, 0.f); m_current_pos[0] = float(position.x()); @@ -89,6 +92,9 @@ struct CoolingLine // ORCA: Add support for ironing fan speed control TYPE_IRONING_FAN_START = 1 << 19, TYPE_IRONING_FAN_END = 1 << 20, + // Belt printers: extrusions within the first-layer band above the belt. + TYPE_BELT_BAND_START = 1 << 21, + TYPE_BELT_BAND_END = 1 << 22, }; CoolingLine(unsigned int type, size_t line_start, size_t line_end) : @@ -549,6 +555,10 @@ std::vector CoolingBuffer::parse_layer_gcode(const std:: line.type = CoolingLine::TYPE_IRONING_FAN_START; } else if (boost::starts_with(sline, ";_IRONING_FAN_END")) { // ORCA: Add support for ironing fan speed control line.type = CoolingLine::TYPE_IRONING_FAN_END; + } else if (boost::starts_with(sline, ";_BELT_BAND_START")) { + line.type = CoolingLine::TYPE_BELT_BAND_START; + } else if (boost::starts_with(sline, ";_BELT_BAND_END")) { + line.type = CoolingLine::TYPE_BELT_BAND_END; } else if (boost::starts_with(sline, "G4 ")) { // Parse the wait time. line.type = CoolingLine::TYPE_G4; @@ -891,7 +901,9 @@ std::string CoolingBuffer::apply_layer_cooldown( {CoolingLine::TYPE_SUPPORT_INTERFACE_FAN_START, false}, {CoolingLine::TYPE_IRONING_FAN_START, false}, // ORCA: Add support for ironing fan speed control {CoolingLine::TYPE_FORCE_RESUME_FAN, false}}; - bool need_set_fan = false; + // Belt printers: a band still open from the previous layer has to take the fan back from + // the layer-level speed issued just above. + bool need_set_fan = m_belt_band_active; for (const CoolingLine *line : lines) { const char *line_start = gcode.c_str() + line->line_start; @@ -905,6 +917,8 @@ std::string CoolingBuffer::apply_layer_cooldown( if (new_extruder != m_current_extruder) { m_current_extruder = new_extruder; change_extruder_set_fan(true); + if (m_belt_band_active) + need_set_fan = true; } } new_gcode.append(line_start, line_end - line_start); @@ -957,6 +971,13 @@ std::string CoolingBuffer::apply_layer_cooldown( if (m_additional_fan_speed != -1 && m_config.auxiliary_fan.value) new_gcode += GCodeWriter::set_additional_fan(m_additional_fan_speed); } + else if (line->type & CoolingLine::TYPE_BELT_BAND_START) { + m_belt_band_active = true; + need_set_fan = true; + } else if (line->type & CoolingLine::TYPE_BELT_BAND_END) { + m_belt_band_active = false; + need_set_fan = true; + } else if (line->type & CoolingLine::TYPE_EXTRUDE_END) { // Just remove this comment. } else if (line->type & (CoolingLine::TYPE_ADJUSTABLE | CoolingLine::TYPE_EXTERNAL_PERIMETER | CoolingLine::TYPE_WIPE | CoolingLine::TYPE_HAS_F)) { @@ -1049,7 +1070,15 @@ std::string CoolingBuffer::apply_layer_cooldown( m_current_fan_speed = speed; } }; - if (fan_speed_change_requests[CoolingLine::TYPE_OVERHANG_FAN_START]){ + if (m_belt_band_active) { + // Belt printers: a tilted layer runs from the belt to the top of the part, so + // "the first layers" are a band along the belt rather than the first slicing + // layers. Extrusions GCode::_extrude() marks as inside that band print with the + // fan off, whatever overhang, bridge or resume request is pending, as the first + // layers of a flat bed do. Leaving the band falls through to the branches below. + set_fan(0); + fan_speed_change_requests[CoolingLine::TYPE_FORCE_RESUME_FAN] = false; + } else if (fan_speed_change_requests[CoolingLine::TYPE_OVERHANG_FAN_START]){ set_fan(overhang_fan_speed); } else if (fan_speed_change_requests[CoolingLine::TYPE_INTERNAL_BRIDGE_FAN_START]){ // ORCA: Add support for separate internal bridge fan speed control set_fan(internal_bridge_fan_speed); diff --git a/src/libslic3r/GCode/CoolingBuffer.hpp b/src/libslic3r/GCode/CoolingBuffer.hpp index c2323dfc01..7c54d2f5ec 100644 --- a/src/libslic3r/GCode/CoolingBuffer.hpp +++ b/src/libslic3r/GCode/CoolingBuffer.hpp @@ -21,7 +21,7 @@ struct PerExtruderAdjustments; // // The simple it sounds, the actual implementation is significantly more complex. // Namely, for a multi-extruder print, each material may require a different cooling logic. -// For example, some materials may not like to print too slowly, while with some materials +// For example, some materials may not like to print too slowly, while with some materials // we may slow down significantly. // class CoolingBuffer { @@ -63,6 +63,9 @@ private: unsigned int m_current_nozzle; //BBS: current fan speed int m_current_fan_speed; + // Belt printers: the extrusion being processed lies in the first-layer band above the + // belt (between a ";_BELT_BAND_START" and a ";_BELT_BAND_END"). Kept across layers. + bool m_belt_band_active = false; }; } diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index 17f55905f9..ac5a435bcd 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -2616,6 +2616,9 @@ void GCodeProcessorResult::reset() { long_retraction_when_cut = false; timelapse_warning_code = 0; printable_height = 0.0f; + machine_frame_transform_active = false; + belt_tilt_angle = 0.f; + belt_z_origin = 0.f; settings_ids.reset(); filaments_count = 0; backtrace_enabled = false; @@ -2852,6 +2855,32 @@ bool GCodeProcessor::check_multi_extruder_gcode_valid(const int return ps; }; + // Belt-printer post-gcode shear/scale/post_remap is applied as the final + // step of BeltKinematics::to_machine, so MoveVertex.position is + // in the printer's machine frame. Undo it here so the XY area check + // operates in the build-volume frame that printable_area is defined in + // (the height checks below are skipped on belt printers). For non-belt printers + // (is_active() == false) apply_inverse is identity and behaviour is + // unchanged from before. + const bool machine_frame_active = m_machine_frame_transform.is_active(); + auto compare_pos = [&](const GCodeProcessorResult::MoveVertex &move) -> Vec3d { + Vec3d pos = move.position.cast(); + if (!machine_frame_active) + return pos; + Vec3d extruder_off = Vec3d::Zero(); + if (size_t(move.extruder_id) < m_extruder_offsets.size()) + extruder_off = m_extruder_offsets[move.extruder_id].cast(); + // Strip plate + extruder offsets to recover the raw machine-frame + // coordinate that was emitted into the G-code (see store_move_vertex). + Vec3d machine(pos.x() - m_x_offset - extruder_off.x(), + pos.y() - m_y_offset - extruder_off.y(), + pos.z() - extruder_off.z() + m_z_offset); + Vec3d build = m_machine_frame_transform.apply_inverse(machine); + // Re-apply plate offset so the result matches plate_printable_poly, + // which is translated by plate_offset below. + return Vec3d(build.x() + m_x_offset, build.y() + m_y_offset, build.z()); + }; + struct GCodePosInfo { Points pos; @@ -2863,26 +2892,20 @@ bool GCodeProcessor::check_multi_extruder_gcode_valid(const int for (const GCodeProcessorResult::MoveVertex &move : m_result.moves) { // sometimes, the start line extrude was outside the edge of plate a little, this is allowed, so do not include into the gcode_path_pos if (move.type == EMoveType::Extrude /* && move.extrusion_role != ExtrusionRole::erFlush || move.type == EMoveType::Travel*/) { + const Vec3d cp = compare_pos(move); + // For belt printers we read Z from the inverse-transformed position + // (post-origin-snap, pre-machine-frame). Otherwise keep the + // original print_z source (the slicer's layer-Z comment) so + // non-belt behaviour is bit-for-bit unchanged. + const float z_for_height = machine_frame_active ? float(cp.z()) : move.print_z; if (move.extrusion_role == ExtrusionRole::erCustom) { - /*if (move.is_arc_move_with_interpolation_points()) { - for (int i = 0; i < move.interpolation_points.size(); i++) { - gcode_path_pos[move.object_label_id][int(move.extruder_id)].pos_custom.emplace_back(to_2d(move.interpolation_points[i].cast())); - } - } else {*/ - gcode_path_pos[move.object_label_id][int(move.extruder_id)].pos_custom.emplace_back(to_2d(move.position.cast())); - //} + gcode_path_pos[move.object_label_id][int(move.extruder_id)].pos_custom.emplace_back(to_2d(cp)); gcode_path_pos[move.object_label_id][int(move.extruder_id)].max_print_z_custom = - std::max(gcode_path_pos[move.object_label_id][int(move.extruder_id)].max_print_z_custom, move.print_z); + std::max(gcode_path_pos[move.object_label_id][int(move.extruder_id)].max_print_z_custom, z_for_height); } else { - /*if (move.is_arc_move_with_interpolation_points()) { - for (int i = 0; i < move.interpolation_points.size(); i++) { - gcode_path_pos[move.object_label_id][int(move.extruder_id)].pos.emplace_back(to_2d(move.interpolation_points[i].cast())); - } - } else {*/ - gcode_path_pos[move.object_label_id][int(move.extruder_id)].pos.emplace_back(to_2d(move.position.cast())); - //} + gcode_path_pos[move.object_label_id][int(move.extruder_id)].pos.emplace_back(to_2d(cp)); gcode_path_pos[move.object_label_id][int(move.extruder_id)].max_print_z = std::max(gcode_path_pos[move.object_label_id][int(move.extruder_id)].max_print_z, - move.print_z); + z_for_height); } } } @@ -2917,7 +2940,12 @@ bool GCodeProcessor::check_multi_extruder_gcode_valid(const int valid = false; } } - if ( iter->second.max_print_z > plate_printable_height ) { //over height + // Belt printers: the Z recorded here grows with belt travel (machine Z with the + // frame transform, the slicing-frame Z without it), while printable_height is the + // clearance above the belt; the two are not comparable, so the over-height check + // is skipped, as the preview's ToolHeightOutside warning already is. + // Print::validate() checks the object's height against the clearance. + if ( !m_belt_printer && iter->second.max_print_z > plate_printable_height ) { //over height m_result.gcode_check_result.error_code |= (1 << 3); std::pair filament_to_object_id; filament_to_object_id.first = iter->first; @@ -2958,7 +2986,7 @@ bool GCodeProcessor::check_multi_extruder_gcode_valid(const int } // check printable height - if ((extruder_id < printable_heights.size()) && (iter->second.max_print_z > printable_heights[extruder_id])) { + if (!m_belt_printer && (extruder_id < printable_heights.size()) && (iter->second.max_print_z > printable_heights[extruder_id])) { m_result.gcode_check_result.error_code |= (1 << 1); std::pair filament_to_object_id; filament_to_object_id.first = iter->first; @@ -3124,6 +3152,13 @@ void GCodeProcessor::apply_config(const PrintConfig& config) m_result.printable_height = config.printable_height; + // Belt printer: cache the post-gcode machine-frame transform so the + // multi-extruder validator can undo it and compare against build-volume + // bounds rather than machine-frame positions. + m_machine_frame_transform.init_from_config(config); + m_result.machine_frame_transform_active = m_machine_frame_transform.is_active(); + m_belt_printer = config.belt_printer.value; + auto filament_maps = config.option("filament_map"); if (filament_maps != nullptr) { m_filament_maps = filament_maps->values; @@ -3154,6 +3189,32 @@ void GCodeProcessor::apply_config(const DynamicPrintConfig& config) { m_parser.apply_config(config); + // Belt printer: remember the file's belt keys for export_config_for_render(). The + // config block lists belt_printer for every printer, so a non-belt file loaded while + // a belt printer is selected switches the preview's belt view off, and a belt file + // loaded on another printer brings its own tilt, remaps and bed along. + m_belt_render_config.clear(); + { + const auto *belt = config.option("belt_printer"); + if (belt != nullptr) { + static const char *belt_keys[] = { + "belt_printer", "belt_slice_rotation", "belt_slice_rotation_angle", + "gcode_remap_x", "gcode_remap_y", "gcode_remap_z", + "belt_frame_tilt_decouple", "belt_frame_tilt_angle", + }; + for (const char *key : belt_keys) + if (const ConfigOption *opt = config.option(key); opt != nullptr) + m_belt_render_config.set_key_value(key, opt->clone()); + // The Rev remaps mirror inside the build volume, so the designed view needs + // the bed the file was sliced for. Only a belt file may override it. + static const char *bed_keys[] = { "printable_area", "printable_height" }; + if (belt->value) + for (const char *key : bed_keys) + if (const ConfigOption *opt = config.option(key); opt != nullptr) + m_belt_render_config.set_key_value(key, opt->clone()); + } + } + //BBS const ConfigOptionFloatsNullable* nozzle_volume = config.option("nozzle_volume"); if (nozzle_volume != nullptr) { @@ -3637,6 +3698,7 @@ void GCodeProcessor::reset() m_zero_layer_height = 0.0f; m_first_layer_height = 0.0f; m_processing_start_custom_gcode = false; + m_in_config_block = false; m_g1_line_id = 0; m_layer_id = 0; m_cp_color.reset(); @@ -3677,6 +3739,7 @@ DynamicConfig GCodeProcessor::export_config_for_render() const config.set_key_value("filament_is_support", new ConfigOptionBools(m_parser.get_config().filament_is_support.values)); config.set_key_value("filament_type", new ConfigOptionStrings(m_parser.get_config().filament_type.values)); config.set_key_value("filament_map", new ConfigOptionInts(m_parser.get_config().filament_map.values)); + config.apply(m_belt_render_config); return config; } @@ -4242,6 +4305,34 @@ void GCodeProcessor::process_tags(const std::string_view comment, bool producers return; } + // ;Z: -- the layer Z tag non-BBL printers write. Only read on a belt printer, + // where the preview labels its layers with it (GCodeViewer::load_as_gcode); + // elsewhere print_z stays unset, as it always was, so nothing downstream of + // it changes for other printers. + if (m_belt_printer && boost::starts_with(comment, "Z:")) { + m_print_z = get_z_height(comment); + return; + } + + if (boost::starts_with(comment, " CONFIG_BLOCK_START")) { + m_in_config_block = true; + return; + } + if (boost::starts_with(comment, " CONFIG_BLOCK_END")) { + m_in_config_block = false; + return; + } + + // Belt printer: derive the physical tilt magnitude from the slicing-rotation + // angle header comment (used to enable the preview's belt view). Only the belt + // header carries it outside the config block; the config block lists the key + // for every printer, belt or not. + if (!m_in_config_block && boost::starts_with(comment, " belt_slice_rotation_angle = ")) { + try { + m_result.belt_tilt_angle = std::abs(std::stof(std::string(comment.substr(29)))); + } catch (...) {} + return; + } // wipe start tag if (boost::starts_with(comment, reserved_tag(ETags::Wipe_Start))) { m_wiping = true; @@ -6138,6 +6229,13 @@ void GCodeProcessor::process_G92(const GCodeReader::GCodeLine& line) if (line.has_z()) { m_origin[Z] = m_end_position[Z] - line.z() * lengths_scale_factor; any_found = true; + // Belt only: the start G-code's purge-blob advance + G92 Z0 resets leave a constant + // machine-Z origin offset here; the designed-view back-transform subtracts it so + // toolpaths map to the model's belt coordinate (gcode Z). Gated on belt_tilt_angle + // (set from the belt header, parsed before the body) so non-belt G-code processing + // is byte-identical — no unconditional work on the shared path. + if (m_result.belt_tilt_angle != 0.f) + m_result.belt_z_origin = m_origin[Z]; } if (line.has_e()) { @@ -7116,6 +7214,22 @@ void GCodeProcessor::store_move_vertex(EMoveType type, EMovePathType path_type, m_result.print_statistics.total_travel_distance += m_travel_dist; } + // During the start G-code "prepare" stage the toolhead Z is not yet a real + // print height on a normal printer, so it is pinned to the first-layer height + // to keep the preview tidy. Belt printers are the exception: there the Z is + // written explicitly by the belt kinematics and the designed-view back-transform + // couples machine Z into the rendered model Y (the belt tilt mixes the height + // and belt-feed axes). Overriding Z therefore back-transforms the last + // prepare-stage move (the unretract before the first extrusion) to model + // Y ~= 0, and the libvgcode path builder then draws a phantom extrusion + // segment from Y ~= 0 to the first real toolpath. Keep the real Z for belt + // printers so prepare-stage moves map correctly. Gated on belt_tilt_angle (set + // from the G-code header before the body is processed) so non-belt processing + // is byte-identical. + const float store_z = (m_processing_start_custom_gcode && m_result.belt_tilt_angle == 0.f) + ? m_first_layer_height + : m_end_position[Z] - m_z_offset; + m_result.moves.push_back({ m_last_line_id, type, @@ -7123,7 +7237,7 @@ void GCodeProcessor::store_move_vertex(EMoveType type, EMovePathType path_type, static_cast(filament_id), m_cp_color.current, //BBS: add plate's offset to the rendering vertices - Vec3f(m_end_position[X] + m_x_offset, m_end_position[Y] + m_y_offset, m_processing_start_custom_gcode ? m_first_layer_height : m_end_position[Z]- m_z_offset) + m_extruder_offsets[filament_id], + Vec3f(m_end_position[X] + m_x_offset, m_end_position[Y] + m_y_offset, store_z) + m_extruder_offsets[filament_id], static_cast(m_end_position[E] - m_start_position[E]), m_feedrate, 0.0f, // actual feedrate diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index 919315d00c..9b3571223c 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -12,6 +12,7 @@ #include "libslic3r/PrintConfig.hpp" #include "libslic3r/CustomGCode.hpp" #include "libslic3r/MultiNozzleUtils.hpp" +#include "libslic3r/GCode/MachineFrameTransform.hpp" #include #include @@ -290,6 +291,19 @@ class Print; bool support_traditional_timelapse{true}; float printable_height; float z_offset; + // Belt printer: physical tilt magnitude (deg) parsed from the slicing-rotation + // header comment; used to enable the preview's belt view. + float belt_tilt_angle{ 0.f }; + // Belt printer: machine-Z origin offset (mm) left in m_origin[Z] by the start + // G-code (purge-blob belt advance + G92 Z0 resets). Move positions are stored + // as gcode_Z + this offset, so the designed-view back-transform must subtract it + // to recover the model's belt coordinate. + float belt_z_origin{ 0.f }; + // Belt printer: post-gcode shear/scale/post_remap is configured and + // non-identity. When set, the layer Z values in `moves` are in the + // machine frame and should not be compared against `printable_height` + // (which lives in the build-volume frame). + bool machine_frame_transform_active{ false }; SettingsIds settings_ids; size_t filaments_count; bool backtrace_enabled; @@ -385,6 +399,9 @@ class Print; // Keep the SKIPPABLE per-type time on a copied result. skippable_part_time = std::forward(other).skippable_part_time; initial_layer_time = std::forward(other).initial_layer_time; + belt_tilt_angle = std::forward(other).belt_tilt_angle; + belt_z_origin = std::forward(other).belt_z_origin; + machine_frame_transform_active = std::forward(other).machine_frame_transform_active; #if ENABLE_GCODE_VIEWER_STATISTICS time = std::forward(other).time; #endif @@ -1085,6 +1102,10 @@ class Print; private: CommandProcessor m_command_processor; GCodeReader m_parser; + // Belt printer: the belt keys of the loaded file's config block (plus the bed they + // are relative to), handed to the preview through export_config_for_render() so the + // belt view and its back-transform follow the file, not the selected printer. + DynamicConfig m_belt_render_config; EUnits m_units; EPositioningType m_global_positioning_type; EPositioningType m_e_local_positioning_type; @@ -1160,6 +1181,13 @@ class Print; double m_x_offset{ 0 }; double m_y_offset{ 0 }; + // Belt-printer post-gcode shear/scale/post_remap. Used by + // check_multi_extruder_gcode_valid to undo the machine-frame + // transform on move positions so bounds checks operate in the + // pre-machine-frame (build-volume) frame. + MachineFrameTransform m_machine_frame_transform; + bool m_belt_printer{ false }; + unsigned int m_line_id; unsigned int m_last_line_id; float m_feedrate; // mm/s @@ -1189,6 +1217,7 @@ class Print; float m_first_layer_height; // mm float m_zero_layer_height; // mm bool m_processing_start_custom_gcode; + bool m_in_config_block; unsigned int m_g1_line_id; unsigned int m_layer_id; CpColor m_cp_color; diff --git a/src/libslic3r/GCode/MachineFrameTransform.cpp b/src/libslic3r/GCode/MachineFrameTransform.cpp new file mode 100644 index 0000000000..9159dbca67 --- /dev/null +++ b/src/libslic3r/GCode/MachineFrameTransform.cpp @@ -0,0 +1,89 @@ +#include "MachineFrameTransform.hpp" +#include "../Geometry.hpp" +#include "../Point.hpp" +#include "../PrintConfig.hpp" +#include "../libslic3r.h" + +#include + +namespace Slic3r { + +bool MachineFrameTransform::init_from_config(const PrintConfig &config) +{ + m_active = false; + m_transform = Transform3d::Identity(); + m_transform_inverse = Transform3d::Identity(); + + if (!config.belt_printer.value) + return false; + + // The machine-frame transform is derived from the single belt tilt (axis + + // angle) that also drives the pre-slice mesh rotation. Expert decouple lets + // the machine-frame angle differ from the slicing rotation; otherwise both + // use belt_slice_rotation_angle. + const BeltRotationAxis axis = config.belt_slice_rotation.value; + if (axis == BeltRotationAxis::None || axis == BeltRotationAxis::Z) + return false; // Z is an in-plane spin: no machine-frame tilt. + + const double angle_deg = config.belt_frame_tilt_decouple.value + ? config.belt_frame_tilt_angle.value + : config.belt_slice_rotation_angle.value; + if (std::abs(angle_deg) <= EPSILON) + return false; + + const double angle_rad = Geometry::deg2rad(angle_deg); + const double sin_a = std::sin(angle_rad); + if (std::abs(sin_a) <= EPSILON) + return false; + const double cot_a = std::cos(angle_rad) / sin_a; + const double inv_sin = 1.0 / std::abs(sin_a); + + // This stage runs after the conventional belt axis swap. For an X-axis + // slicing rotation, remapped Y is model height and remapped Z is travel + // along the belt. Convert those Cartesian coordinates to machine axes with + // the established belt-printer convention: + // machine gantry = model height / sin(a) + // machine belt = model belt + model height * cot(a) + // The Y-rotation case is the same mapping on X/Z, with the rotation sign. + // At 45 degrees tan/cot and sin/cos are equal, which previously hid the + // incorrect complementary-angle formulas used by this unified transform. + Matrix3d shear = Matrix3d::Identity(); + Matrix3d scale = Matrix3d::Identity(); + if (axis == BeltRotationAxis::X) { + shear(2, 1) = cot_a; // Z from Y + scale(1, 1) = inv_sin; // Y + } else { // BeltRotationAxis::Y + shear(2, 0) = -cot_a; // Z from X + scale(0, 0) = inv_sin; // X + } + + // Apply shear first, then scale (the historical default ShearThenScale order: + // result = scale * shear * p). For the canonical 45°/X belt this maps + // (x,y,z) -> (x, y/sin, y + z), matching the previous per-axis config. + Transform3d combined = Transform3d::Identity(); + combined.linear() = scale * shear; + + if (combined.isApprox(Transform3d::Identity())) + return false; + + m_transform = combined; + m_transform_inverse = combined.inverse(); + m_active = true; + return true; +} + +Vec3d MachineFrameTransform::apply(const Vec3d &pos) const +{ + if (!m_active) + return pos; + return m_transform * pos; +} + +Vec3d MachineFrameTransform::apply_inverse(const Vec3d &pos) const +{ + if (!m_active) + return pos; + return m_transform_inverse * pos; +} + +} // namespace Slic3r diff --git a/src/libslic3r/GCode/MachineFrameTransform.hpp b/src/libslic3r/GCode/MachineFrameTransform.hpp new file mode 100644 index 0000000000..cdae28ca1a --- /dev/null +++ b/src/libslic3r/GCode/MachineFrameTransform.hpp @@ -0,0 +1,54 @@ +#ifndef slic3r_MachineFrameTransform_hpp_ +#define slic3r_MachineFrameTransform_hpp_ + +#include "../libslic3r.h" +#include "../Point.hpp" +#include "../PrintConfig.hpp" + +namespace Slic3r { + +// Post-stage machine-frame transform for belt printers. +// +// Applied in BeltKinematics::to_machine AFTER the back-transform and +// the gcode_remap_* axis remap. Maps Cartesian (axis-permuted) G-code +// coordinates into the printer's physical machine frame. +// +// Derived entirely from the single belt tilt (belt_slice_rotation axis + +// belt_slice_rotation_angle): a shear coupling the height axis to the belt-feed +// axis (factor cot a) plus a 1/sin a scale on the gantry-height axis. The expert +// belt_frame_tilt_decouple flag lets the machine-frame angle differ from the +// pre-slice rotation angle via belt_frame_tilt_angle. +class MachineFrameTransform +{ +public: + MachineFrameTransform() = default; + + // Initialize from belt printer config. Returns true if a non-identity + // transform was computed. Inactive when belt_printer is disabled or + // both shear and scale are identity. + bool init_from_config(const PrintConfig &config); + + // Apply the transform to a point. Returns pos unchanged if not active. + Vec3d apply(const Vec3d &pos) const; + + // Apply the inverse transform. Returns pos unchanged if not active. + // Used by validators that need to compare emitted machine-frame + // coordinates against build-volume bounds. + Vec3d apply_inverse(const Vec3d &pos) const; + + bool is_active() const { return m_active; } + + // The composed shear*scale transform (identity when inactive). Exposed so the + // G-code viewer can build the machine->model back-transform for the upright + // ("designed") belt preview. + const Transform3d& transform() const { return m_transform; } + +private: + bool m_active = false; + Transform3d m_transform = Transform3d::Identity(); + Transform3d m_transform_inverse = Transform3d::Identity(); +}; + +} // namespace Slic3r + +#endif // slic3r_MachineFrameTransform_hpp_ diff --git a/src/libslic3r/GCode/MachineKinematics.cpp b/src/libslic3r/GCode/MachineKinematics.cpp new file mode 100644 index 0000000000..d9b4b65513 --- /dev/null +++ b/src/libslic3r/GCode/MachineKinematics.cpp @@ -0,0 +1,25 @@ +#include "MachineKinematics.hpp" +#include "../Point.hpp" + +namespace Slic3r { + +// Moved verbatim from GCodeWriter::apply_axis_remap(). +Vec3d CartesianKinematics::apply_axis_remap(const Vec3d &pos) const +{ + if (!has_axis_remap()) + return pos; + auto remap = [this, &pos](int r) -> double { + int axis = r % 3; + if (r < 3) return pos[axis]; + if (r < 6) return -pos[axis]; + return m_build_vol_max[axis] - pos[axis]; + }; + return { remap(m_remap_x), remap(m_remap_y), remap(m_remap_z) }; +} + +Vec3d CartesianKinematics::to_machine(const Vec3d &p) const +{ + return this->apply_axis_remap(p); +} + +} // namespace Slic3r diff --git a/src/libslic3r/GCode/MachineKinematics.hpp b/src/libslic3r/GCode/MachineKinematics.hpp new file mode 100644 index 0000000000..fc13282998 --- /dev/null +++ b/src/libslic3r/GCode/MachineKinematics.hpp @@ -0,0 +1,92 @@ +#ifndef slic3r_MachineKinematics_hpp_ +#define slic3r_MachineKinematics_hpp_ + +#include "../Point.hpp" + +namespace Slic3r { + +// The frame contract for emitted movement. +// +// GCodeWriter produces points in the *logical placed* frame: plate offsets have +// already been subtracted, but no machine-specific mapping has been applied. +// A MachineKinematics turns that into the coordinates actually written to +// G-code, and answers the two structural questions the writer needs in order to +// decide which axis words it may omit. +// +// This is a seam for writer-generated movement only. Start/end/custom G-code, +// classic wipe-tower output and GCodeWriter::extrude_arc_to_xy() do NOT pass +// through it: they write machine coordinates directly. +class MachineKinematics +{ +public: + virtual ~MachineKinematics() = default; + + // Logical placed point -> emitted machine point. + virtual Vec3d to_machine(const Vec3d &p) const = 0; + + // True when a move must emit X, Y and Z because omitting a word would be + // wrong under this mapping. Deliberately not called "couples_axes": a pure + // axis permutation forces full emission without physically coupling axes. + virtual bool must_emit_all_axes() const = 0; + + // True when a lift must be suppressed while the current position is unknown, + // because _travel_to_z() re-emits the logical X/Y through this mapping and an + // uninitialised position would map to a bogus machine point -- for a reverse + // mapping, the far corner of the bed. + virtual bool suppress_lift_at_unknown_position() const = 0; + + // True when a G2/G3 arc in the logical XY plane is still the same arc in the + // machine frame. Arc moves emit only X, Y, I and J, so this asks a narrower + // question than must_emit_all_axes(): whether logical X and Y reach the + // machine unchanged. A mapping that only negates or reverses Z keeps its + // arcs; one that permutes X or Y moves the arc out of the plane that I/J + // describes, and a shear turns the circle into an ellipse G2/G3 cannot + // express at all. + virtual bool supports_arc_moves() const = 0; + + // Configuration. GCodeWriter forwards its setters here so that the state + // lives with the strategy and a strategy installed before the setters run + // still receives it. + virtual void set_axis_remap(int rx, int ry, int rz) = 0; + virtual void set_build_volume_max(const Vec3d &max) = 0; +}; + +// Axis remap only -- the historical GCodeWriter behaviour, moved verbatim. +// +// The remap encodes, per output axis, which source axis feeds it and how: +// r < 3 : source axis r, unchanged +// r < 6 : source axis r-3, negated +// else : source axis r-6, reversed within the build volume +class CartesianKinematics : public MachineKinematics +{ +public: + Vec3d to_machine(const Vec3d &p) const override; + + bool must_emit_all_axes() const override { return this->has_axis_remap(); } + bool suppress_lift_at_unknown_position() const override { return this->has_axis_remap(); } + + // X and Y must reach the machine untouched. Because the remap is a + // permutation, pinning those two also pins Z to Z, so a mapping that only + // negates or reverses Z still supports arcs -- every word a G2/G3 emits is + // unchanged by it. + bool supports_arc_moves() const override { return m_remap_x == 0 && m_remap_y == 1; } + + void set_axis_remap(int rx, int ry, int rz) override + { m_remap_x = rx; m_remap_y = ry; m_remap_z = rz; } + void set_build_volume_max(const Vec3d &max) override { m_build_vol_max = max; } + + bool has_axis_remap() const + { return m_remap_x != 0 || m_remap_y != 1 || m_remap_z != 2; } + +protected: + Vec3d apply_axis_remap(const Vec3d &pos) const; + + int m_remap_x { 0 }; + int m_remap_y { 1 }; + int m_remap_z { 2 }; + Vec3d m_build_vol_max { Vec3d::Zero() }; +}; + +} // namespace Slic3r + +#endif // slic3r_MachineKinematics_hpp_ diff --git a/src/libslic3r/GCode/SeamPlacer.cpp b/src/libslic3r/GCode/SeamPlacer.cpp index c591dd1c6a..24fed0493c 100644 --- a/src/libslic3r/GCode/SeamPlacer.cpp +++ b/src/libslic3r/GCode/SeamPlacer.cpp @@ -706,7 +706,7 @@ void compute_global_occlusion(GlobalModelInfo &result, const PrintObject *po, SeamPosition seam_position = spAligned) { BOOST_LOG_TRIVIAL(debug) << "SeamPlacer: gather occlusion meshes: start"; - auto obj_transform = po->trafo_centered(); + auto obj_transform = po->trafo_sliced(); indexed_triangle_set triangle_set; indexed_triangle_set negative_volumes_set; //add all parts @@ -796,7 +796,7 @@ void gather_enforcers_blockers(GlobalModelInfo &result, const PrintObject *po) { BOOST_LOG_TRIVIAL(debug) << "SeamPlacer: build AABB trees for raycasting enforcers/blockers: start"; - auto obj_transform = po->trafo_centered(); + auto obj_transform = po->trafo_sliced(); for (const ModelVolume *mv : po->model_object()->volumes) { // Collect painting only from model parts (what the gizmo edits) and negative volumes (the only way diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index c0f43f1888..f61587d562 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -18,6 +18,7 @@ #include "Utils.hpp" #include "format.hpp" #include "I18N.hpp" +#include "../BeltBrim.hpp" #include #include @@ -419,6 +420,10 @@ bool ToolOrdering::insert_wipe_tower_extruder() { if (!m_print_config_ptr || !m_print_config_ptr->enable_prime_tower) return false; + // Belt mode has no classic wipe tower; the dedicated wipe tower filament + // must not inject extra toolchanges into the purge prism planning. + if (m_print_config_ptr->belt_printer) + return false; if (m_print_config_ptr->wipe_tower_filament == 0) return false; @@ -516,6 +521,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); } @@ -560,6 +570,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); } @@ -994,6 +1008,42 @@ 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()) { + // 1-based, same domain the object/support pushes above use; reindexed to 0-based + // with the rest of the list later. + const unsigned int brim_filament = object.belt_brim_filament(); + 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); + } + } + + // Coincident brim bands (belt_brim_by_layer) print ON an object layer rather than + // below it, but that layer can produce no InstanceVisit in process_layer - a + // zero-extrusion lead-in slice with no coinciding support - and the band would then + // be silently dropped. Register the brim filament on every layer that carries a + // coincident band, in the same 1-based domain as the prologue push above, so a brim + // pass always exists there. + if (object.has_belt_brim()) { + const unsigned int brim_filament = object.belt_brim_filament(); + const auto &by_layer = object.belt_brim_by_layer(); + const size_t n = std::min(by_layer.size(), object.layers().size()); + for (size_t i = 0; i < n; ++ i) { + if (by_layer[i].empty()) + continue; + LayerTools &layer_tools = this->tools_for_layer(object.layers()[i]->print_z); + layer_tools.extruders.push_back(brim_filament); + } + } + for (auto& layer : m_layer_tools) { // Sort and remove duplicates sort_remove_duplicates(layer.extruders); @@ -1036,12 +1086,28 @@ 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. A belt printer never prints the classic + // prime tower (Print::has_wipe_tower()), so simply drop the clause there. + // + // Gate on config.belt_printer, NOT on has_belt_brim: every layer below the + // object bottom on a belt printer is legitimately a sub-object stream - brim + // apron, belt support printed below Z0, or the object's own lead-in - and none of + // them is ever raft, because Print::validate() rejects raft_layers>0 on a belt + // printer outright. Narrowing this to has_belt_brim would reclassify + // belt-support-below-floor layers as raft on brim-less belt prints and reintroduce + // the negative-Z wipe tower, so the broad belt_printer gate is correct. + const bool belt_no_raft_gap = config.belt_printer.value; for (LayerTools < : m_layer_tools) lt.has_wipe_tower |= ((lt.has_object || lt.has_support) && (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 5cb3d4e977..5c11f482cd 100644 --- a/src/libslic3r/GCode/ToolOrdering.hpp +++ b/src/libslic3r/GCode/ToolOrdering.hpp @@ -84,7 +84,17 @@ public: void set_layer_tools_ptr(const LayerTools* lt) { m_layer_tools = lt; } + // Returns true if entity is not printed with its usual extruder for a given + // copy -- i.e. it was claimed as a wiping/purge extrusion. Public because the + // belt purge prism uses it to tell which of its fills actually carry purge + // from the ones that are unclaimed waste (Print::_plan_belt_purge()). + bool is_entity_overridden(const ExtrusionEntity* entity, const PrintObject *object, size_t copy_id) const { + auto it = entity_map.find(std::make_tuple(entity, object)); + return it != entity_map.end() && copy_id < it->second.size() && it->second[copy_id] != -1; + } + private: + int first_nonsoluble_extruder_on_layer(const PrintConfig& print_config) const; int last_nonsoluble_extruder_on_layer(const PrintConfig& print_config) const; @@ -94,12 +104,6 @@ private: void set_support_extruder_override(const PrintObject* object, size_t copy_id, int extruder, size_t num_of_copies); void set_support_interface_extruder_override(const PrintObject* object, size_t copy_id, int extruder, size_t num_of_copies); - // Returns true in case that entity is not printed with its usual extruder for a given copy: - bool is_entity_overridden(const ExtrusionEntity* entity, const PrintObject *object, size_t copy_id) const { - auto it = entity_map.find(std::make_tuple(entity, object)); - return it == entity_map.end() ? false : it->second[copy_id] != -1; - } - std::map, ExtruderPerCopy> entity_map; // to keep track of who prints what // BBS std::map support_map; diff --git a/src/libslic3r/GCodeWriter.cpp b/src/libslic3r/GCodeWriter.cpp index b97ed8c857..62382f8214 100644 --- a/src/libslic3r/GCodeWriter.cpp +++ b/src/libslic3r/GCodeWriter.cpp @@ -1,6 +1,7 @@ #include "GCodeWriter.hpp" #include "Config.hpp" #include "Extruder.hpp" +#include "Geometry.hpp" #include "I18N.hpp" #include "Point.hpp" #include "Polygon.hpp" @@ -32,6 +33,7 @@ #include #include #include +#include #ifdef __APPLE__ #include @@ -44,6 +46,57 @@ namespace Slic3r { bool GCodeWriter::full_gcode_comment = true; +// A lift emitted through _travel_to_z() re-emits the stored logical X/Y under a +// mapping that must emit every axis. While the position is unknown that X/Y is +// the uninitialised origin, which maps to a real but wrong machine point, so the +// lift has to be skipped rather than commanded. +bool GCodeWriter::must_skip_lift_now() const +{ + return m_kinematics->suppress_lift_at_unknown_position() && ! this->is_current_position_clear(); +} + +bool GCodeWriter::point_on_first_layer(const Vec3d &point_logical) const +{ + if (m_first_layer_point_test) + return m_first_layer_point_test(point_logical); + return m_is_first_layer; +} + +void GCodeWriter::set_axis_remap(int rx, int ry, int rz) +{ + m_remap_x = rx; + m_remap_y = ry; + m_remap_z = rz; + m_kinematics->set_axis_remap(rx, ry, rz); +} + +void GCodeWriter::set_build_volume_max(const Vec3d &max) +{ + m_build_vol_max = max; + m_kinematics->set_build_volume_max(max); +} + +void GCodeWriter::set_kinematics(std::unique_ptr kinematics) +{ + assert(kinematics); + m_kinematics = std::move(kinematics); + // Replay whatever was configured on the previous strategy so callers may + // install the kinematics before or after set_axis_remap/set_build_volume_max. + m_kinematics->set_axis_remap(m_remap_x, m_remap_y, m_remap_z); + m_kinematics->set_build_volume_max(m_build_vol_max); +} + +// Kept as the writer-facing name for "this move must emit every axis word". +bool GCodeWriter::has_axis_remap() const +{ + return m_kinematics->must_emit_all_axes(); +} + +Vec3d GCodeWriter::apply_axis_remap(const Vec3d &pos) const +{ + return m_kinematics->to_machine(pos); +} + bool GCodeWriter::supports_separate_travel_acceleration(GCodeFlavor flavor) { return (flavor == gcfRepetier || flavor == gcfMarlinFirmware || flavor == gcfRepRapFirmware); @@ -822,8 +875,14 @@ std::string GCodeWriter::travel_to_xy(const Vec2d &point, const std::string &com Vec2d point_on_plate = { point(0) - m_x_offset, point(1) - m_y_offset }; GCodeG1Formatter w; - w.emit_xy(point_on_plate); - auto speed = m_is_first_layer + if (has_axis_remap()) { + // Axis remap may couple XY with Z; emit full XYZ in machine coordinates. + Vec3d machine = apply_axis_remap(Vec3d(point_on_plate.x(), point_on_plate.y(), m_pos.z())); + w.emit_xyz(machine); + } else { + w.emit_xy(point_on_plate); + } + auto speed = this->point_on_first_layer(Vec3d(point_on_plate.x(), point_on_plate.y(), m_pos.z())) ? this->config.get_abs_value_at("initial_layer_travel_speed", m_cached_extruder_idx) : this->config.travel_speed.get_at(m_cached_extruder_idx); w.emit_f(speed * 60.0); //BBS @@ -836,6 +895,8 @@ it will not perform subsequent lifts, even if Z was raised manually (i.e. with travel_to_z()) and thus _lifted was reduced. */ std::string GCodeWriter::lazy_lift(LiftType lift_type, bool spiral_vase) { + if (m_force_normal_lift) + lift_type = LiftType::NormalLift; // check whether the above/below conditions are met double target_lift = 0; { @@ -850,6 +911,10 @@ std::string GCodeWriter::lazy_lift(LiftType lift_type, bool spiral_vase) // BBS if (m_lifted == 0 && m_to_lift == 0 && target_lift > 0) { if (spiral_vase) { + if (this->must_skip_lift_now()) + // Record no lift, so a later unlift() does not descend from a + // height that was never commanded. + return ""; m_lifted = target_lift; return this->_travel_to_z(m_pos(2) + target_lift, "lift Z"); } @@ -862,8 +927,9 @@ std::string GCodeWriter::lazy_lift(LiftType lift_type, bool spiral_vase) } // BBS: immediately execute an undelayed lift move with a spiral lift pattern -// designed specifically for subsequent gcode injection (e.g. timelapse) +// designed specifically for subsequent gcode injection (e.g. timelapse) std::string GCodeWriter::eager_lift(const LiftType type) { + const LiftType effective_type = m_force_normal_lift ? LiftType::NormalLift : type; std::string lift_move; double target_lift = 0; { @@ -877,7 +943,7 @@ std::string GCodeWriter::eager_lift(const LiftType type) { } // BBS: spiral lift only safe with known position - if (type == LiftType::SpiralLift && this->is_current_position_clear()) { + if (effective_type == LiftType::SpiralLift && this->is_current_position_clear()) { double radius = target_lift / (2 * PI * atan(filament()->travel_slope())); // static spiral alignment when no move in x,y plane. // spiral centra is a radius distance to the right (y=0) @@ -894,7 +960,12 @@ std::string GCodeWriter::eager_lift(const LiftType type) { } //BBS: if position is unknown use normal lift else if (target_lift > 0) { - lift_move = _travel_to_z(m_pos(2) + target_lift, "normal lift Z"); + if (this->must_skip_lift_now()) + // Skipped, not deferred: leave m_lifted at zero below so unlift() + // does not descend from a height that was never commanded. + target_lift = 0.; + else + lift_move = _travel_to_z(m_pos(2) + target_lift, "normal lift Z"); } m_lifted = target_lift; m_to_lift = 0; @@ -915,7 +986,12 @@ std::string GCodeWriter::travel_to_xyz(const Vec3d &point, const std::string &co // BBS Vec3d dest_point = point; auto travel_speed = - m_is_first_layer ? this->config.get_abs_value_at("initial_layer_travel_speed", m_cached_extruder_idx) : this->config.travel_speed.get_at(m_cached_extruder_idx); + this->point_on_first_layer(Vec3d(point.x() - m_x_offset, point.y() - m_y_offset, point.z())) ? this->config.get_abs_value_at("initial_layer_travel_speed", m_cached_extruder_idx) : this->config.travel_speed.get_at(m_cached_extruder_idx); + // See uses_pointwise_travel_speed(): the historical path deliberately emits the + // raw configured speed in the final branch below, ignoring travel_speed. + const double final_travel_speed = this->uses_pointwise_travel_speed() + ? travel_speed + : this->config.travel_speed.get_at(m_cached_extruder_idx); //BBS: a z_hop need to be handle when travel if (std::abs(m_to_lift) > EPSILON) { assert(std::abs(m_lifted) < EPSILON); @@ -964,13 +1040,23 @@ std::string GCodeWriter::travel_to_xyz(const Vec3d &point, const std::string &co Vec2d temp = delta_no_z.normalized() * delta(2) / tan(this->filament()->travel_slope()); Vec3d slope_top_point = Vec3d(temp(0), temp(1), delta(2)) + source; GCodeG1Formatter w0; - w0.emit_xyz(slope_top_point); + // A slope lift is a straight (linear) diagonal move, so remapping its + // endpoint is exact. Route the destination through apply_axis_remap() + // when a remap is active (no-op at identity). + w0.emit_xyz(has_axis_remap() ? apply_axis_remap(slope_top_point) : slope_top_point); w0.emit_f(travel_speed * 60.0); //BBS w0.emit_comment(GCodeWriter::full_gcode_comment, comment); slop_move = w0.string(); } - else if (m_to_lift_type == LiftType::NormalLift) { + else if (m_to_lift_type == LiftType::NormalLift && ! this->must_skip_lift_now()) { + // Only lift in place when the current position is known, for a mapping + // that makes _travel_to_z re-emit logical X/Y: at print start (and after + // custom gcode) m_pos.xy is still the uninitialised origin, which would + // map to a bogus machine point. The xy_z_move below then travels straight + // to the destination with full XYZ and establishes the correct position. + // Mappings that do not need this (the historical Cartesian behaviour) + // report false and keep lifting unconditionally. slop_move = _travel_to_z(target.z(), "normal lift Z"); } } @@ -978,7 +1064,14 @@ std::string GCodeWriter::travel_to_xyz(const Vec3d &point, const std::string &co std::string xy_z_move; { GCodeG1Formatter w0; - if (this->is_current_position_clear()) { + if (has_axis_remap()) { + // Remap may couple XY with Z; emit full XYZ in machine coordinates. + w0.emit_xyz(apply_axis_remap(target)); + w0.emit_f(travel_speed * 60.0); + w0.emit_comment(GCodeWriter::full_gcode_comment, comment); + xy_z_move = w0.string(); + } + else if (this->is_current_position_clear()) { w0.emit_xyz(target); w0.emit_f(travel_speed * 60.0); w0.emit_comment(GCodeWriter::full_gcode_comment, comment); @@ -1016,17 +1109,23 @@ std::string GCodeWriter::travel_to_xyz(const Vec3d &point, const std::string &co Vec3d point_on_plate = { dest_point(0) - m_x_offset, dest_point(1) - m_y_offset, dest_point(2) }; std::string out_string; GCodeG1Formatter w; - if (!this->is_current_position_clear()) + if (has_axis_remap()) { + // Remap may couple XY with Z; emit full XYZ in machine coordinates. + w.emit_xyz(apply_axis_remap(point_on_plate)); + w.emit_f(final_travel_speed * 60.0); + w.emit_comment(GCodeWriter::full_gcode_comment, comment); + out_string = w.string(); + } else if (!this->is_current_position_clear()) { //force to move xy first then z after filament change w.emit_xy(Vec2d(point_on_plate.x(), point_on_plate.y())); - w.emit_f(this->config.travel_speed.get_at(m_cached_extruder_idx) * 60.0); + w.emit_f(final_travel_speed * 60.0); w.emit_comment(GCodeWriter::full_gcode_comment, comment); out_string = w.string() + _travel_to_z(point_on_plate.z(), comment); } else { GCodeG1Formatter w; w.emit_xyz(point_on_plate); - w.emit_f(this->config.travel_speed.get_at(m_cached_extruder_idx) * 60.0); + w.emit_f(final_travel_speed * 60.0); w.emit_comment(GCodeWriter::full_gcode_comment, comment); out_string = w.string(); } @@ -1061,12 +1160,19 @@ std::string GCodeWriter::_travel_to_z(double z, const std::string &comment) double speed = this->config.travel_speed_z.get_at(m_cached_extruder_idx); if (speed == 0.) { - speed = m_is_first_layer ? this->config.get_abs_value_at("initial_layer_travel_speed", m_cached_extruder_idx) - : this->config.travel_speed.get_at(m_cached_extruder_idx); + speed = this->point_on_first_layer(Vec3d(m_pos.x() - m_x_offset, m_pos.y() - m_y_offset, z)) + ? this->config.get_abs_value_at("initial_layer_travel_speed", m_cached_extruder_idx) + : this->config.travel_speed.get_at(m_cached_extruder_idx); } GCodeG1Formatter w; - w.emit_z(z); + if (has_axis_remap()) { + // Remap may couple Z with other axes; emit full XYZ. + Vec3d machine = apply_axis_remap(Vec3d(m_pos.x() - m_x_offset, m_pos.y() - m_y_offset, z)); + w.emit_xyz(machine); + } else { + w.emit_z(z); + } w.emit_f(speed * 60.0); //BBS w.emit_comment(GCodeWriter::full_gcode_comment, comment); @@ -1075,6 +1181,14 @@ std::string GCodeWriter::_travel_to_z(double z, const std::string &comment) std::string GCodeWriter::_spiral_travel_to_z(double z, const Vec2d &ij_offset, const std::string &comment) { + // A circular XY arc / spiral lift cannot be correctly axis-remapped by + // transforming only its endpoint: the arc plane (G17/XY) and the I-J center + // would change under the remap. When an axis remap is active, fall back to a + // plain linear lift instead of emitting a possibly-wrong spiral/arc. This + // single guard covers every spiral call site (lazy/eager lift and travel_to_xyz). + if (has_axis_remap()) + return _travel_to_z(z, comment); + std::string output; double speed = this->config.travel_speed_z.get_at(m_cached_extruder_idx); @@ -1181,7 +1295,12 @@ void GCodeWriter::extrude_to_xy(std::string &out, const Vec2d &point, double dE, Vec2d point_on_plate = { point(0) - m_x_offset, point(1) - m_y_offset }; GCodeG1Formatter w; - w.emit_xy(point_on_plate); + if (has_axis_remap()) { + Vec3d machine = apply_axis_remap(Vec3d(point_on_plate.x(), point_on_plate.y(), m_pos.z())); + w.emit_xyz(machine); + } else { + w.emit_xy(point_on_plate); + } if (!force_no_extrusion) w.emit_e(filament()->E()); //BBS @@ -1189,6 +1308,42 @@ void GCodeWriter::extrude_to_xy(std::string &out, const Vec2d &point, double dE, w.append_to(out); } +// Approximate an arc with linear extrusions, for machine mappings that cannot +// express a G2/G3 (see extrude_arc_to_xy). center_offset is I/J: the centre +// relative to the CURRENT position, which is why this must run before m_pos is +// updated. +void GCodeWriter::extrude_arc_as_polyline(std::string &out, const Vec2d &point, const Vec2d ¢er_offset, + double dE, const bool is_ccw, + const std::string &comment, bool force_no_extrusion) +{ + const Vec2d start = Vec2d(m_pos.x(), m_pos.y()); + const Vec2d centre = start + center_offset; + const double r = (start - centre).norm(); + if (r < EPSILON) { + // Degenerate: no arc to speak of, so a single move is exact. + this->extrude_to_xy(out, point, dE, comment, force_no_extrusion); + return; + } + + double a0 = std::atan2(start.y() - centre.y(), start.x() - centre.x()); + double a1 = std::atan2(point.y() - centre.y(), point.x() - centre.x()); + double sweep = a1 - a0; + if (is_ccw) { while (sweep <= 0.) sweep += 2. * PI; } + else { while (sweep >= 0.) sweep -= 2. * PI; } + + // Segment count from a chord-deviation bound: r*(1-cos(dtheta/2)) <= tol. + const double tol = 0.005; // mm + const double dmax = (tol >= r) ? PI : 2. * std::acos(1. - tol / r); + const int n = std::max(2, int(std::ceil(std::abs(sweep) / std::max(dmax, EPSILON)))); + + for (int i = 1; i <= n; ++ i) { + const double a = a0 + sweep * (double(i) / double(n)); + const Vec2d p = (i == n) ? point + : Vec2d(centre.x() + r * std::cos(a), centre.y() + r * std::sin(a)); + this->extrude_to_xy(out, p, dE / double(n), i == n ? comment : std::string(), force_no_extrusion); + } +} + //BBS: generate G2 or G3 extrude which moves by arc //point is end point which means X and Y axis //center_offset is I and J axis @@ -1201,6 +1356,23 @@ std::string GCodeWriter::extrude_arc_to_xy(const Vec2d& point, const Vec2d& cent void GCodeWriter::extrude_arc_to_xy(std::string &out, const Vec2d& point, const Vec2d& center_offset, double dE, const bool is_ccw, const std::string& comment, bool force_no_extrusion) { + // Arcs emit only X/Y/I/J, so a mapping that moves logical X or Y cannot be + // expressed as a G2/G3. GCode::should_disable_arc_fitting() normally stops + // arcs being generated at all for such a mapping, but this is public API, so + // define the behaviour rather than asserting. + // + // This check MUST precede every state mutation below: falling through to + // extrude_to_xy() after filament()->extrude(dE) would advance E twice. + // + // A single chord is not a safe substitute either -- a semicircle would become + // its diameter and a full circle a stationary blob -- so approximate the arc + // with linear segments bounded by a chord tolerance, splitting dE between + // them in proportion to arc length. + if (! m_kinematics->supports_arc_moves()) { + this->extrude_arc_as_polyline(out, point, center_offset, dE, is_ccw, comment, force_no_extrusion); + return; + } + m_pos(0) = point(0); m_pos(1) = point(1); if (!force_no_extrusion) @@ -1241,10 +1413,18 @@ void GCodeWriter::extrude_to_xyz(std::string &out, const Vec3d &point, double dE Vec3d point_on_plate = { point(0) - m_x_offset, point(1) - m_y_offset, point(2) }; GCodeG1Formatter w; - if (z_changed) + if (has_axis_remap()) { + // z_changed was computed from the ORIGINAL slicing Z, but an axis remap can + // make machine-Z depend on slicing X/Y. An X/Y-only move (slicing-Z + // unchanged) would then drop the required machine-Z word, so always emit + // full XYZ whenever a remap is active. + point_on_plate = apply_axis_remap(point_on_plate); w.emit_xyz(point_on_plate); - else + } else if (z_changed) { + w.emit_xyz(point_on_plate); + } else { w.emit_xy(Vec2d(point_on_plate.x(), point_on_plate.y())); + } if (!force_no_extrusion) w.emit_e(filament()->E()); //BBS diff --git a/src/libslic3r/GCodeWriter.hpp b/src/libslic3r/GCodeWriter.hpp index d6aa4744bd..3c90a4e9e1 100644 --- a/src/libslic3r/GCodeWriter.hpp +++ b/src/libslic3r/GCodeWriter.hpp @@ -10,31 +10,38 @@ #include #include #include +#include +#include #include "Extruder.hpp" #include "Point.hpp" #include "Polygon.hpp" #include "PrintConfig.hpp" #include "Config.hpp" +#include "GCode/MachineKinematics.hpp" +#include namespace Slic3r { + class GCodeWriter { public: GCodeConfig config; bool multiple_extruders; GCodeWriter() : - multiple_extruders(false), m_curr_filament_extruder(MAXIMUM_EXTRUDER_NUMBER, nullptr), - m_curr_extruder_id (-1), - m_cached_extruder_idx(0), - m_single_extruder_multi_material(false), - m_last_acceleration(0), m_max_acceleration(0),m_last_travel_acceleration(0), m_max_travel_acceleration(0), - m_last_jerk(0), m_max_jerk_x(0), m_max_jerk_y(0), - m_last_bed_temperature(0), m_last_bed_temperature_reached(true), + multiple_extruders(false), m_lifted(0), m_to_lift(0), m_to_lift_type(LiftType::NormalLift), - m_current_speed(3600), m_is_first_layer(true) + m_is_first_layer(true), m_current_speed(3600), + m_kinematics(std::make_unique()), + m_cached_extruder_idx(0), + m_curr_filament_extruder(MAXIMUM_EXTRUDER_NUMBER, nullptr), + m_curr_extruder_id (-1), + m_single_extruder_multi_material(false), + m_last_acceleration(0), m_max_acceleration(0),m_last_travel_acceleration(0), m_max_travel_acceleration(0), + m_last_jerk(0), m_max_jerk_x(0), m_max_jerk_y(0), + m_last_bed_temperature(0), m_last_bed_temperature_reached(true) {} Extruder* filament(size_t extruder_id) { assert(extruder_id < m_curr_filament_extruder.size()); return m_curr_filament_extruder[extruder_id]; } const Extruder* filament(size_t extruder_id) const { assert(extruder_id < m_curr_filament_extruder.size()); return m_curr_filament_extruder[extruder_id]; } @@ -91,6 +98,10 @@ public: std::string extrude_to_xy(const Vec2d &point, double dE, const std::string &comment = std::string(), bool force_no_extrusion = false); //BBS: generate G2 or G3 extrude which moves by arc std::string extrude_arc_to_xy(const Vec2d &point, const Vec2d ¢er_offset, double dE, const bool is_ccw, const std::string &comment = std::string(), bool force_no_extrusion = false); + // Linear approximation of an arc, used when the machine mapping cannot + // express a G2/G3. Must be called before m_pos is updated: center_offset is + // relative to the current position. + void extrude_arc_as_polyline(std::string &out, const Vec2d &point, const Vec2d ¢er_offset, double dE, const bool is_ccw, const std::string &comment = std::string(), bool force_no_extrusion = false); std::string extrude_to_xyz(const Vec3d &point, double dE, const std::string &comment = std::string(), bool force_no_extrusion = false); // Each appends its line to `out`. void set_speed(std::string &out, double F, const std::string &comment = std::string(), const std::string &cooling_marker = std::string()); @@ -147,16 +158,94 @@ public: void invalidate_acceleration() { m_last_acceleration = 0; m_last_travel_acceleration = 0; } void invalidate_jerk() { m_last_jerk = 0; } + // Axis remap: permute/negate/reverse axes in G-code output. + // Works standalone (without belt mode) for printers with non-standard axis conventions. + void set_axis_remap(int rx, int ry, int rz); + void set_build_volume_max(const Vec3d &max); + bool has_axis_remap() const; + + // Install the machine frame mapping. Any axis remap / build volume already + // configured is carried over, so install order does not matter. + void set_kinematics(std::unique_ptr kinematics); + const MachineKinematics& kinematics() const { return *m_kinematics; } + + // Per-point first-layer test. When set, travel speed selection asks it per + // destination point (in the writer's logical placed frame) instead of using + // the layer-coarse m_is_first_layer flag. GCode installs it on belt printers + // with the same test its extrusions use (GCode::on_first_layer(point)), so a + // travel is judged against the belt surface exactly as the path it leads to. + using FirstLayerPointTest = std::function; + void set_first_layer_point_test(FirstLayerPointTest test) { m_first_layer_point_test = std::move(test); } + + // Force every lift to a plain vertical lift. Spiral and slope lifts compute + // their slope in the logical frame and do not account for a machine mapping + // that couples axes. + void set_force_normal_lift(bool force) { m_force_normal_lift = force; } + // Returns whether this flavor supports separate print and travel acceleration. static bool supports_separate_travel_acceleration(GCodeFlavor flavor); - private: +protected: + // Position/lift/offset state. + Vec3d m_pos = Vec3d::Zero(); + double m_x_offset{ 0 }; + double m_y_offset{ 0 }; + double m_lifted; + double m_to_lift; + LiftType m_to_lift_type; + bool m_is_first_layer = true; + bool m_is_current_pos_clear = false; + double m_current_speed; + + std::string _travel_to_z(double z, const std::string &comment); + + // Whether a destination gets first-layer treatment. With a point test + // installed it decides; otherwise the layer-coarse m_is_first_layer flag does. + bool point_on_first_layer(const Vec3d &point_logical) const; + + // True when a lift must be skipped because this mapping would emit the + // stored logical X/Y and that position is not yet known. + bool must_skip_lift_now() const; + + // True when travel speed is selected per destination point rather than per + // layer. Set for writers that install a first-layer point test. The + // historical path emits the raw configured travel speed in the final branch + // of travel_to_xyz(), ignoring the first-layer selection computed at the top + // of that function; a point-test-driven writer uses the first-layer-aware + // value throughout. Both are preserved exactly -- unifying them would change + // emitted feedrates and belongs in its own commit. + bool uses_pointwise_travel_speed() const { return bool(m_first_layer_point_test); } + + FirstLayerPointTest m_first_layer_point_test; + bool m_force_normal_lift = false; + + // The machine frame mapping. Owns the axis-remap state that used to live + // here as m_remap_* / m_build_vol_max; the setters above forward to it. + // Never null: a CartesianKinematics at the identity remap reproduces the + // historical behaviour exactly. + std::unique_ptr m_kinematics; + + // Last configured remap / build volume, replayed onto a newly installed + // kinematics so set_kinematics() and the setters are order-independent. + int m_remap_x = 0; // RemapAxis: 0=+X, 1=+Y, 2=+Z, 3=-X, etc. + int m_remap_y = 1; + int m_remap_z = 2; + Vec3d m_build_vol_max = Vec3d::Zero(); + + // Apply the machine frame mapping to a point. Returns pos unchanged when the + // mapping is the identity. + Vec3d apply_axis_remap(const Vec3d &pos) const; + + // Motion uses the global/base process variant until a filament becomes active. + // Indexes the per-extruder speed options (travel_speed, travel_speed_z, + // initial_layer_travel_speed). + size_t m_cached_extruder_idx; + +private: // Extruders are sorted by their ID, so that binary search is possible. std::vector m_filament_extruders; bool m_single_extruder_multi_material; std::vector m_curr_filament_extruder; int m_curr_extruder_id; - // Motion uses the global/base process variant until a filament becomes active. - size_t m_cached_extruder_idx; unsigned int m_last_acceleration; unsigned int m_last_travel_acceleration; std::vector m_max_travel_acceleration; @@ -178,19 +267,6 @@ public: //BBS int m_last_bed_temperature; bool m_last_bed_temperature_reached; - double m_lifted; - - // BBS - double m_to_lift; - LiftType m_to_lift_type; - Vec3d m_pos = Vec3d::Zero(); - //BBS: this flag is used to indicate whether the m_pos is real. - //A example that of the first move, the m_pos is zero, but the real position of extruder doesn't - //Pos must be clear after the first xyz travel move - bool m_is_current_pos_clear = false; - //BBS: x, y offset for gcode generated - double m_x_offset{ 0 }; - double m_y_offset{ 0 }; // Orca: slicing resolution in mm double m_resolution = 0.01; @@ -202,21 +278,18 @@ public: // non-rectangular beds such as delta/circular printers. Polygon m_bed_printable_area; std::vector m_extruder_printable_areas; - + std::string m_gcode_label_objects_start; std::string m_gcode_label_objects_end; //SoftFever bool m_is_bbl_printers = false; - double m_current_speed; - bool m_is_first_layer = true; enum class Acceleration { Travel, Print }; - std::string _travel_to_z(double z, const std::string &comment); std::string _spiral_travel_to_z(double z, const Vec2d &ij_offset, const std::string &comment); // Orca: printable area of the active extruder (per-extruder when configured, otherwise the bed). Null when unknown. const Polygon *active_printable_area() const; diff --git a/src/libslic3r/MultiMaterialSegmentation.cpp b/src/libslic3r/MultiMaterialSegmentation.cpp index d2f76e7e66..19dbee88dc 100644 --- a/src/libslic3r/MultiMaterialSegmentation.cpp +++ b/src/libslic3r/MultiMaterialSegmentation.cpp @@ -1260,7 +1260,7 @@ static inline std::vector> segmentation_top_and_bottom_l // project downards pointing painted triangles over bottom surfaces. std::vector> top_raw(num_facets_states), bottom_raw(num_facets_states); std::vector zs = zs_from_layers(layers); - Transform3d object_trafo = print_object.trafo_centered(); + Transform3d object_trafo = print_object.trafo_sliced(); #ifdef MM_SEGMENTATION_DEBUG_TOP_BOTTOM static int iRun = 0; @@ -1289,10 +1289,16 @@ static inline std::vector> segmentation_top_and_bottom_l slicing_params.trafo = volume_trafo; Polygons bottom_slice = slice_mesh(painted, zs[0], slicing_params); - top.erase(top.begin()); - bottom.erase(bottom.begin()); - - bottom[0] = union_(bottom[0], bottom_slice); + // Only the requested projections exist: with + // top_shell_layers = 0 `top` is empty and erasing its begin() was + // undefined (found by fuzzing: a sunk, painted object crashed here). + if (! top.empty()) + top.erase(top.begin()); + if (! bottom.empty()) { + bottom.erase(bottom.begin()); + if (! bottom.empty()) + bottom[0] = union_(bottom[0], bottom_slice); + } } else slice_mesh_slabs(painted, zs, volume_trafo, max_top_layers > 0 ? &top : nullptr, max_bottom_layers > 0 ? &bottom : nullptr, nullptr, throw_on_cancel_callback); auto merge = [](std::vector &&src, std::vector &dst) { @@ -2088,17 +2094,19 @@ std::vector> segmentation_by_painting(const PrintObject } BOOST_LOG_TRIVIAL(debug) << "Print object segmentation - Projection of painted triangles - Begin"; + // The layers were sliced in this frame (belt rotation, remap and Z lift included), and it already centers the object. + const Transform3d object_trafo = print_object.trafo_sliced(); for (const ModelVolume *mv : print_object.model_object()->volumes) { const ModelVolumeFacetsInfo facets_info = extract_facets_info(*mv); - tbb::parallel_for(tbb::blocked_range(1, num_facets_states), [&mv, &print_object, &facets_info, &layers, &edge_grids, &painted_lines, &painted_lines_mutex, &input_expolygons, &throw_on_cancel_callback](const tbb::blocked_range &range) { + tbb::parallel_for(tbb::blocked_range(1, num_facets_states), [&mv, &object_trafo, &facets_info, &layers, &edge_grids, &painted_lines, &painted_lines_mutex, &input_expolygons, &throw_on_cancel_callback](const tbb::blocked_range &range) { for (size_t extruder_idx = range.begin(); extruder_idx < range.end(); ++extruder_idx) { throw_on_cancel_callback(); const indexed_triangle_set custom_facets = facets_info.facets_annotation.get_facets(*mv, EnforcerBlockerType(extruder_idx)); if (!mv->is_model_part() || custom_facets.indices.empty()) continue; - const Transform3f tr = print_object.trafo().cast() * mv->get_matrix().cast(); - tbb::parallel_for(tbb::blocked_range(0, custom_facets.indices.size()), [&tr, &custom_facets, &print_object, &layers, &edge_grids, &input_expolygons, &painted_lines, &painted_lines_mutex, &extruder_idx](const tbb::blocked_range &range) { + const Transform3f tr = (object_trafo * mv->get_matrix()).cast(); + tbb::parallel_for(tbb::blocked_range(0, custom_facets.indices.size()), [&tr, &custom_facets, &layers, &edge_grids, &input_expolygons, &painted_lines, &painted_lines_mutex, &extruder_idx](const tbb::blocked_range &range) { for (size_t facet_idx = range.begin(); facet_idx < range.end(); ++facet_idx) { float min_z = std::numeric_limits::max(); float max_z = std::numeric_limits::lowest(); @@ -2151,7 +2159,6 @@ std::vector> segmentation_by_painting(const PrintObject Line line_to_test(Point(scale_(line_start_f.x()), scale_(line_start_f.y())), Point(scale_(line_end_f.x()), scale_(line_end_f.y()))); - line_to_test.translate(-print_object.center_offset()); // BoundingBoxes for EdgeGrids are computed from printable regions. It is possible that the painted line (line_to_test) could // be outside EdgeGrid's BoundingBox, for example, when the negative volume is used on the painted area (GH #7618). diff --git a/src/libslic3r/PerimeterGenerator.cpp b/src/libslic3r/PerimeterGenerator.cpp index 41db0ed9db..b1c6ed21fa 100644 --- a/src/libslic3r/PerimeterGenerator.cpp +++ b/src/libslic3r/PerimeterGenerator.cpp @@ -1839,8 +1839,10 @@ void PerimeterGenerator::process_classic() bool is_outer_wall_first = this->config->wall_sequence == WallSequence::OuterInner; if (is_outer_wall_first || //BBS: always print outer wall first when there indeed has brim. + // btLeadingEdgeOnly is an outer brim too (a belt brim at the part's first contact). (this->layer_id == 0 && - this->object_config->brim_type == BrimType::btOuterOnly && + (this->object_config->brim_type == BrimType::btOuterOnly || + this->object_config->brim_type == BrimType::btLeadingEdgeOnly) && this->object_config->brim_width.value > 0)) entities.reverse(); // Orca: sandwich mode. Apply after 1st layer. diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 290a9714e1..6d6327b575 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1238,7 +1238,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", "brim_ears_outer_only", "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", "brim_ears_outer_only", "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 @@ -1308,6 +1308,8 @@ static std::vector s_Preset_print_options{ "prime_volume", "prime_tower_infill_gap", "prime_tower_flat_ironing", + "belt_purge_tower_width", + "belt_purge_tower_object", "enable_tower_interface_features", "enable_tower_interface_cooldown_during_tower", "wipe_tower_no_sparse_layers", @@ -1561,8 +1563,14 @@ static std::vector s_Preset_machine_limits_options { static std::vector s_Preset_printer_options { "printer_technology", - "printable_area", "extruder_printable_area", "support_parallel_printheads", "parallel_printheads_count", "parallel_printheads_bed_exclude_areas", "bed_exclude_area","bed_custom_texture", "bed_custom_model", "gcode_flavor", - "gcode_skip_config_block", "fan_kickstart", "part_cooling_fan_min_pwm", "fan_speedup_time", "fan_speedup_overhangs", + "printable_area", "extruder_printable_area", "support_parallel_printheads", "parallel_printheads_count", "parallel_printheads_bed_exclude_areas", "bed_exclude_area","bed_custom_texture", "bed_custom_model", "build_plate_tilt_x", "build_plate_tilt_y", "belt_printer", "belt_printer_infinite_y", + "belt_slice_rotation", "belt_slice_rotation_angle", + "gcode_remap_x", "gcode_remap_y", "gcode_remap_z", + "belt_frame_tilt_decouple", "belt_frame_tilt_angle", + "belt_support_floor_offset", + "enable_belt_purge_tower", + "gcode_flavor", "gcode_skip_config_block", + "fan_kickstart", "part_cooling_fan_min_pwm", "fan_speedup_time", "fan_speedup_overhangs", "single_extruder_multi_material", "manual_filament_change", "file_start_gcode", "machine_start_gcode", "machine_end_gcode", "before_layer_change_gcode", "printing_by_object_gcode", "layer_change_gcode", "time_lapse_gcode", "wrapping_detection_gcode", "change_filament_gcode", "change_extrusion_role_gcode", "printer_model", "printer_variant", "printer_extruder_id", "printer_extruder_variant", "extruder_variant_list", "default_nozzle_volume_type", "printable_height", "extruder_printable_height", "extruder_clearance_radius", "extruder_clearance_height_to_lid", "extruder_clearance_height_to_rod", "extruder_clearance_dist_to_rod", diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 381502058f..21abec1c0a 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -71,6 +71,8 @@ #include "Thread.hpp" #include "Time.hpp" #include "GCode.hpp" +#include "BeltGCode.hpp" +#include "BeltTransform.hpp" #include "GCode/WipeTower.hpp" #include "GCode/WipeTower2.hpp" #include "GCode/WipeTowerEstimate.hpp" @@ -79,6 +81,7 @@ #include "MaterialType.hpp" #include "Model.hpp" #include "format.hpp" +#include "LocalesUtils.hpp" #include #include @@ -105,6 +108,7 @@ #include "Format/STEP.hpp" #include "PlaceholderParser.hpp" #include "SurfaceCollection.hpp" +#include "BeltBrim.hpp" namespace fs = boost::filesystem; @@ -168,6 +172,14 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n // Cache the plenty of parameters, which influence the G-code generator only, // or they are only notes not influencing the generated G-code. static std::unordered_set steps_gcode = { + // Belt printer G-code axis remap (only affects G-code output, not slicing). + "gcode_remap_x", + "gcode_remap_y", + "gcode_remap_z", + // Machine-frame transform (derived from belt tilt; only affects G-code output). + "belt_frame_tilt_decouple", "belt_frame_tilt_angle", + // Only inflates the GUI bed volume, like printable_area. + "belt_printer_infinite_y", //BBS "additional_cooling_fan_speed", "reduce_crossing_wall", @@ -369,8 +381,18 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n // Spiral Vase forces different kind of slicing than the normal model: // In Spiral Vase mode, holes are closed and only the largest area contour is kept at each layer. // Therefore toggling the Spiral Vase on / off requires complete reslicing. - || opt_key == "spiral_mode") { + || opt_key == "spiral_mode" + // Build plate tilt changes slicing plane orientation. + || opt_key == "build_plate_tilt_x" + || opt_key == "build_plate_tilt_y" + // Belt printer transform options change the mesh geometry before slicing. + || opt_key == "belt_printer" + || opt_key == "belt_slice_rotation" + || opt_key == "belt_slice_rotation_angle") { osteps.emplace_back(posSlice); + } else if ( + opt_key == "belt_support_floor_offset") { + osteps.emplace_back(posSupportMaterial); } else if ( opt_key == "print_sequence" || opt_key == "filament_type" @@ -405,6 +427,7 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n || opt_key == "hot_plate_temp" || opt_key == "textured_plate_temp" || opt_key == "enable_prime_tower" + || opt_key == "enable_belt_purge_tower" || opt_key == "enable_wrapping_detection" || opt_key == "prime_tower_enable_framework" || opt_key == "prime_tower_width" @@ -439,6 +462,7 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n || opt_key == "prime_volume" || opt_key == "flush_into_infill" || opt_key == "flush_into_support" + || opt_key == "belt_purge_tower_width" || opt_key == "initial_layer_infill_speed" || opt_key == "travel_speed" || opt_key == "travel_speed_z" @@ -676,6 +700,9 @@ std::vector Print::print_object_ids() const bool Print::has_infinite_skirt() const { + // Belt printer: no skirt support. + if (m_config.belt_printer.value) + return false; // Orca: unclear why (m_config.ooze_prevention && this->extruders().size() > 1) logic is here, removed. // return (m_config.draft_shield == dsEnabled && m_config.skirt_loops > 0) || (m_config.ooze_prevention && this->extruders().size() > 1); @@ -684,6 +711,9 @@ bool Print::has_infinite_skirt() const bool Print::has_skirt() const { + // Belt printer: no skirt support. + if (m_config.belt_printer.value) + return false; return (m_config.skirt_height > 0); } @@ -692,6 +722,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) { @@ -1937,6 +1985,75 @@ StringObjectException Print::validate(std::vector *warnin } } + // Belt printer validation: incompatible features. + if (m_config.belt_printer.value) { + for (const PrintObject *object : m_objects) { + if (object->config().raft_layers > 0) + return { L("Raft is not compatible with belt printer mode.") }; + } + 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 + // spiral vase cannot share. The prime tower setting is no obstacle: belt + // printers never print the classic tower, and the belt purge prism is an + // ordinary object that never takes a brim. + if (this->has_belt_brim()) { + 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(); + // Mirror PrintObject::has_belt_brim(): an inner-only brim needs a positive + // brim_width (leading/extra widen only the outer ring), so keep this + // predicate in step or the belt-brim warnings below would fire for a brim + // that has_belt_brim() rejects. + const bool wants_brim = ocfg.brim_type != btNoBrim + && (ocfg.brim_type == btInnerOnly + ? ocfg.brim_width.value > 0. + : (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 (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()); + } + // Orca: a gradient mixed filament only renders its gradient with "Mixed color sublayer" on; // without it ToolOrdering::resolve_mixed_filaments prints one whole component per layer and // the gradient is dropped silently. extruders() already covers painting, height ranges, @@ -2002,6 +2119,40 @@ StringObjectException Print::validate(std::vector *warnin } } + if (m_config.belt_printer.value && m_config.enable_belt_purge_tower.value + && m_config.print_sequence == PrintSequence::ByObject + && extruders.size() > 1) { + StringObjectException warningtemp; + warningtemp.string = L("The belt purge tower is not generated in \"By object\" print sequence; " + "filament changes will not be purged."); + warningtemp.opt_key = "enable_belt_purge_tower"; + warningtemp.is_warning = true; + add_warning(warningtemp); + } + + // The purge tower is a model object the GUI creates and sizes; libslic3r only purges + // into one that exists. A project sliced without it (the CLI on a project saved before + // the tower was generated) changes filament with nowhere to purge. + if (m_config.belt_printer.value && m_config.enable_belt_purge_tower.value + && m_config.print_sequence != PrintSequence::ByObject + && ! m_config.spiral_mode.value && this->object_extruders().size() > 1 && ! this->has_belt_purge_tower()) { + StringObjectException warningtemp; + warningtemp.string = L("The belt purge tower is enabled but the project has no purge tower object; " + "filament changes will not be purged. Open the project in the application " + "to generate the tower."); + warningtemp.opt_key = "enable_belt_purge_tower"; + warningtemp.is_warning = true; + add_warning(warningtemp); + } + + if (m_config.belt_printer.value && m_config.enable_belt_purge_tower.value) { + const size_t prism_count = std::count_if(m_objects.begin(), m_objects.end(), [](const PrintObject *object) { + return object->config().belt_purge_tower_object.value; + }); + if (prism_count > 1) + return {L("The project contains multiple managed belt purge towers. Reload the plate or toggle the belt purge tower off and on to regenerate it.")}; + } + if (m_config.enable_prime_tower) { for (const PrintObject* object : m_objects) { if (object->config().precise_z_height.value) { @@ -2055,35 +2206,57 @@ StringObjectException Print::validate(std::vector *warnin return profile; }; - // Checks that the print does not exceed the max print height + // Checks that the print does not exceed the max print height. + // For belt printers the slicing-frame Z spans the sheared X-length and + // is not comparable to printable_height (which is gantry clearance in the + // build-volume frame). Compare against the model's pre-shear Z instead, + // mirroring the bbox computed in PrintObject::update_slicing_parameters. + // The machine-frame transform only changes how that height is written to + // G-code, not how much room there is under the gantry. + const bool belt_printer = this->config().belt_printer.value; + const double shrinkage_compensation_z = this->shrinkage_compensation().z(); for (size_t print_object_idx = 0; print_object_idx < m_objects.size(); ++ print_object_idx) { const PrintObject &print_object = *m_objects[print_object_idx]; - //FIXME It is quite expensive to generate object layers just to get the print height! - if (auto layers = generate_object_layers(print_object.slicing_parameters(), layer_height_profile(print_object_idx), print_object.config().precise_z_height.value); - !layers.empty()) { - Vec3d test =this->shrinkage_compensation(); - const double shrinkage_compensation_z = this->shrinkage_compensation().z(); - - if (shrinkage_compensation_z != 1. && layers.back() > (this->config().printable_height / shrinkage_compensation_z + EPSILON)) { - // The object exceeds the maximum build volume height because of shrinkage compensation. - return StringObjectException{ - Slic3r::format(_u8L("While the object %1% itself fits the build volume, it exceeds the maximum build volume height because of material shrinkage compensation."), print_object.model_object()->name), - print_object.model_object(), - "" - }; - } else if (layers.back() > this->config().printable_height + EPSILON) { - // Test whether the last slicing plane is below or above the print volume. - return StringObjectException{ - 0.5 * (layers[layers.size() - 2] + layers.back()) > this->config().printable_height + EPSILON ? - Slic3r::format(_u8L("The object %1% exceeds the maximum build volume height."), print_object.model_object()->name) : - Slic3r::format(_u8L("While the object %1% itself fits the build volume, its last layer exceeds the maximum build volume height."), print_object.model_object()->name) + - " " + _u8L("You might want to reduce the size of your model or change current print settings and retry."), - print_object.model_object(), - "" - }; + double effective_max_z = 0; + bool last_layer_below_max = false; + bool have_height = false; + + if (belt_printer) { + const double raw_z = print_object.model_object()->max_z(); + effective_max_z = raw_z; + have_height = raw_z > 0; + } else { + //FIXME It is quite expensive to generate object layers just to get the print height! + auto layers = generate_object_layers(print_object.slicing_parameters(), layer_height_profile(print_object_idx), print_object.config().precise_z_height.value); + if (!layers.empty()) { + effective_max_z = layers.back(); + last_layer_below_max = layers.size() >= 2 && + 0.5 * (layers[layers.size() - 2] + layers.back()) <= this->config().printable_height + EPSILON; + have_height = true; } } + + if (!have_height) + continue; + + if (shrinkage_compensation_z != 1. && effective_max_z > (this->config().printable_height / shrinkage_compensation_z + EPSILON)) { + // The object exceeds the maximum build volume height because of shrinkage compensation. + return StringObjectException{ + Slic3r::format(_u8L("While the object %1% itself fits the build volume, it exceeds the maximum build volume height because of material shrinkage compensation."), print_object.model_object()->name), + print_object.model_object(), + "" + }; + } else if (effective_max_z > this->config().printable_height + EPSILON) { + return StringObjectException{ + last_layer_below_max ? + Slic3r::format(_u8L("While the object %1% itself fits the build volume, its last layer exceeds the maximum build volume height."), print_object.model_object()->name) + + " " + _u8L("You might want to reduce the size of your model or change current print settings and retry.") : + Slic3r::format(_u8L("The object %1% exceeds the maximum build volume height."), print_object.model_object()->name), + print_object.model_object(), + "" + }; + } } // Some of the objects has variable layer height applied by painting or by a table. @@ -2103,12 +2276,12 @@ StringObjectException Print::validate(std::vector *warnin return {_u8L("Variable layer height is not supported with Organic supports.") }; } - if (this->has_wipe_tower() && ! m_objects.empty()) { + if ((this->has_wipe_tower() || this->has_belt_purge_tower()) && ! m_objects.empty()) { // Orca: wipe_tower_filament (issue #10971) is inserted into the tool order after // resolve_mixed_filaments has expanded every mixed (virtual) slot, so a mixed slot here // would reach the G-code as a tool change to a slot no nozzle carries. The GUI hides // mixed slots from the option; this guards loaded projects and the CLI. - if (m_config.wipe_tower_filament > 0) { + if (this->has_wipe_tower() && m_config.wipe_tower_filament > 0) { const auto &is_mixed = m_config.filament_is_mixed.values; const size_t wipe_idx = size_t(m_config.wipe_tower_filament - 1); if (wipe_idx < is_mixed.size() && is_mixed[wipe_idx]) @@ -2130,12 +2303,17 @@ StringObjectException Print::validate(std::vector *warnin } } - if (! m_config.use_relative_e_distances) - return { L("The Wipe Tower is currently only supported with the relative extruder addressing (use_relative_e_distances=1).") }; + // The following two constraints come from the classic wipe tower G-code + // generator; purging into the belt purge prism uses normal object + // extrusions and does not need them. + if (this->has_wipe_tower()) { + if (! m_config.use_relative_e_distances) + return { L("The Wipe Tower is currently only supported with the relative extruder addressing (use_relative_e_distances=1).") }; + + if (m_config.ooze_prevention && m_config.single_extruder_multi_material) + return {L("Ooze prevention is only supported with the wipe tower when 'single_extruder_multi_material' is off.")}; + } - if (m_config.ooze_prevention && m_config.single_extruder_multi_material) - return {L("Ooze prevention is only supported with the wipe tower when 'single_extruder_multi_material' is off.")}; - #if 0 if (m_config.gcode_flavor != gcfRepRapSprinter && m_config.gcode_flavor != gcfRepRapFirmware && m_config.gcode_flavor != gcfRepetier && m_config.gcode_flavor != gcfMarlinLegacy && m_config.gcode_flavor != gcfMarlinFirmware) @@ -2862,8 +3040,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; } @@ -2914,6 +3112,19 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) if (m_objects.empty()) return; + // Belt purge prism: _plan_belt_purge() (psWipeTower) truncates the prism's + // layers and drops its unclaimed fills, stashing both so a replan can undo + // them. The object steps below regenerate per-layer content over m_layers + // ONLY, so if any of them is about to rerun the stashes must go back first; + // otherwise truncated layers keep stale perimeters/fills and dropped fills + // are re-inserted next to freshly generated ones. Every object-step + // invalidation also invalidates psWipeTower, so "psWipeTower not done" is + // exactly "some object step may rerun" -- and when it IS done nothing below + // regenerates, and the plan's edits have to stay. + if (!this->is_step_done(psWipeTower)) + for (PrintObject *obj : m_objects) + obj->belt_undo_purge_plan(); + { LifecycleEventContext ctx; ctx.id = std::to_string(m_model.id().id); @@ -2975,15 +3186,20 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) int object_count = m_objects.size(); std::set need_slicing_objects; std::set re_slicing_objects; + // Belt global modes couple each object's bed position into its layer Z values, + // so sharing layers between "identical" objects is wrong. + bool belt_no_share = m_config.belt_printer.value; if (!use_cache) { for (int index = 0; index < object_count; index++) { PrintObject *obj = m_objects[index]; - for (PrintObject *slicing_obj : need_slicing_objects) - { - if (is_print_object_the_same(obj, slicing_obj)) { - obj->set_shared_object(slicing_obj); - break; + if (!belt_no_share) { + for (PrintObject *slicing_obj : need_slicing_objects) + { + if (is_print_object_the_same(obj, slicing_obj)) { + obj->set_shared_object(slicing_obj); + break; + } } } if (!obj->get_shared_object()) @@ -3002,12 +3218,14 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) PrintObject *obj = m_objects[index]; bool found_shared = false; if (need_slicing_objects.find(obj) == need_slicing_objects.end()) { - for (PrintObject *slicing_obj : need_slicing_objects) - { - if (is_print_object_the_same(obj, slicing_obj)) { - obj->set_shared_object(slicing_obj); - found_shared = true; - break; + if (!belt_no_share) { + for (PrintObject *slicing_obj : need_slicing_objects) + { + if (is_print_object_the_same(obj, slicing_obj)) { + obj->set_shared_object(slicing_obj); + found_shared = true; + break; + } } } if (!found_shared) { @@ -3130,7 +3348,8 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) for (int i = range.begin(); i < range.end(); i++) { PrintObject* obj = m_objects[i]; if (need_slicing_objects.count(obj) != 0) { - obj->generate_support_material(); + // The belt brim follows sequentially below. + obj->generate_support_material(false); } else { if (obj->set_started(posSupportMaterial)) @@ -3139,6 +3358,10 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) } } ); + // The belt brim keeps clear of every object's layers and support layers, + // so it runs once no support step is rebuilding them any more. + for (PrintObject *obj : m_objects) + obj->generate_belt_brim(); if (m_pipeline_plugin_active) for (size_t i = 0; i < m_objects.size(); ++i) @@ -3215,7 +3438,10 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) m_wipe_tower_data.clear(); m_tool_ordering.clear(); - if (this->has_wipe_tower()) { + if (this->has_belt_purge_tower() && this->config().print_sequence != PrintSequence::ByObject) { + this->_plan_belt_purge(); + } + else if (this->has_wipe_tower()) { this->_make_wipe_tower(); } else if (this->config().print_sequence != PrintSequence::ByObject) { @@ -3469,6 +3695,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. @@ -3572,7 +3818,12 @@ std::string Print::export_gcode(const std::string& path_template, GCodeProcessor this->set_status(80, message); // The following line may die for multiple reasons. - GCode gcode; + // Factory: use BeltGCode for belt printers, plain GCode otherwise. + std::unique_ptr gcode; + if (m_config.belt_printer.value) + gcode = std::make_unique(); + else + gcode = std::make_unique(); //BBS: compute plate offset for gcode-generator const Vec3d origin = this->get_plate_origin(); // IMEX firmware-managed zones: writer offset gets the IMEX shift so emitted gcode is @@ -3584,9 +3835,9 @@ std::string Print::export_gcode(const std::string& path_template, GCodeProcessor // still emits in the frame the current config asks for. this->update_imex_slice_offset(); const Vec2d imex_off = this->get_imex_slice_offset(); - gcode.set_gcode_offset_with_imex_shift(origin(0), origin(1), imex_off.x(), imex_off.y()); - gcode.do_export(this, path.c_str(), result, thumbnail_cb); - gcode.export_layer_filaments(result); + gcode->set_gcode_offset_with_imex_shift(origin(0), origin(1), imex_off.x(), imex_off.y()); + gcode->do_export(this, path.c_str(), result, thumbnail_cb); + gcode->export_layer_filaments(result); //BBS if (result != nullptr) { result->conflict_result = m_conflict_result; @@ -3601,6 +3852,10 @@ std::string Print::export_gcode(const std::string& path_template, GCodeProcessor void Print::_make_skirt() { + // Belt printer: skirt is not compatible. + if (m_config.belt_printer.value) + return; + const bool generate_skirt = this->has_skirt() || this->has_infinite_skirt(); // First off we need to decide how tall the skirt must be. @@ -4695,6 +4950,13 @@ int Print::get_config_index(int filament_id, int layer_id, const std::vector 2) return true; @@ -4707,6 +4969,7 @@ bool Print::has_wipe_tower() const return false; } + const WipeTowerData &Print::wipe_tower_data(size_t filaments_cnt) const { // Until the tower is generated, size it with the estimate the GUI/CLI placement uses, so @@ -4736,6 +4999,7 @@ bool Print::enable_timelapse_print() const return m_config.timelapse_type.value == TimelapseType::tlSmooth; } + void Print::_make_wipe_tower() { m_wipe_tower_data.clear(); diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 118eaf7132..341110b3c5 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -27,6 +27,8 @@ #include "GCode/ThumbnailData.hpp" #include "GCode/GCodeProcessor.hpp" #include "MultiMaterialSegmentation.hpp" +#include "BeltBrim.hpp" +#include "BeltTransform.hpp" #include "ObjectID.hpp" #include "TriangleSelector.hpp" #include "libslic3r.h" @@ -374,6 +376,9 @@ public: // Trafo with the center_offset() applied after the transformation, to center the object in XY before slicing. Transform3d trafo_centered() const { Transform3d t = this->trafo(); t.pretranslate(Vec3d(- unscale(m_center_offset.x()), - unscale(m_center_offset.y()), 0)); return t; } + // trafo_centered() with the belt pre-slice transforms applied: the frame the layers were sliced in (Layer::slice_z). + // Equal to trafo_centered() unless a belt rotation or pre-slice remap is active. + Transform3d trafo_sliced() const; const PrintInstances& instances() const { return m_instances; } // Orca: Bounding box of each connected body, indexed by Layer::lslices_separated_component_ids. const std::vector& separated_body_bboxes() const { return m_separated_body_bboxes; } @@ -415,6 +420,26 @@ 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; + // Brim filament for this object's belt brim, returned in the 1-based domain of + // PrintRegion::outer_wall_filament_id and the raw values pushed into + // LayerTools::extruders in ToolOrdering::collect_extruders (ToolOrdering reindexes + // the whole list to 0-based afterwards). Lowest positive outer_wall_filament_id + // over the printing regions, 1 if none is explicitly set. + unsigned int belt_brim_filament() const; + const std::vector& belt_brim_by_layer() const { return m_belt_brim_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; @@ -550,7 +575,13 @@ private: void ironing(); bool need_z_contouring() const; void contour_z(); - void generate_support_material(); + // with_belt_brim = false leaves the belt brim to generate_belt_brim(), for a + // caller that runs the support step of several objects in parallel. + void generate_support_material(bool with_belt_brim = true); + // The belt brim keeps clear of every object's layers and support layers, so + // it has to run after all support steps finished. A no-op unless a support + // step left it pending. + void generate_belt_brim(); void estimate_curled_extrusions(); void simplify_extrusion_path(); @@ -566,6 +597,39 @@ private: std::vector> detect_extruder_geometric_unprintables() const; void slice_volumes(); + // Belt mode: shift the sliced layer grid (layer print_z and the belt floor + // clipping shift) by delta, used by Print::_align_belt_purge_layers() to + // snap the purge prism's layers onto the printed objects' layer grid. + void belt_shift_layer_grid(double delta); + // Belt mode: remove layers above z (cancel the purge prism past the last + // toolchange). Returns how many layers were dropped. + size_t belt_truncate_layers_above(coordf_t z); + // Restore layers removed by belt_truncate_layers_above() before replanning. + // Wipe-tower-only invalidations do not necessarily reslice the object, so + // truncation must be reversible when later toolchanges move upward. + void belt_restore_truncated_layers(); + // Belt purge prism, plastic saving: drop the fills on one layer that no + // toolchange claimed. `claimed` reports whether an entity was overridden as + // purge; everything else on that layer would otherwise print as solid infill + // in the prism's own filament for nothing. Perimeters are never touched, so + // the bar keeps a continuous wall along the belt. + // + // Entities are STASHED, not deleted, with their original positions -- the + // same reversibility contract belt_truncate_layers_above() has, and the + // reason the original version of this had to be removed: psWipeTower can + // rerun without regenerating infill, and a later tool ordering may claim what + // this one did not. Returns the number of entities dropped. + size_t belt_drop_unclaimed_fills(Layer *layer, const std::function &claimed); + // Put every stashed fill back at its original index. Must run before a replan. + void belt_restore_dropped_fills(); + // Undo every edit _plan_belt_purge() made to this object's layers, leaving + // m_layers exactly as the object steps produced it. Fills first: they point + // into layers that are still live, and truncated layers were stashed whole + // with their own fills untouched, so the two stashes never share an entity. + // Print::process() calls this before any object step may rerun (those steps + // regenerate per-layer content over m_layers only, so a stale stash would + // otherwise be restored on top of fresh content); the plan calls it too. + void belt_undo_purge_plan() { belt_restore_dropped_fills(); belt_restore_truncated_layers(); } //BBS ExPolygons _shrink_contour_holes(double contour_delta, double hole_delta, const ExPolygons& polys) const; // BBS @@ -608,7 +672,24 @@ private: SlicingParameters m_slicing_params; LayerPtrs m_layers; + LayerPtrs m_belt_truncated_layers; + // Fills removed by belt_drop_unclaimed_fills(), owned by this vector until + // restored or until clear_layers() deletes them. An entity is in exactly one + // of the live collection or this stash, never both. + struct BeltDroppedFill { + Layer *layer { nullptr }; + size_t region_idx { 0 }; + size_t index { 0 }; // position in the original fills.entities + ExtrusionEntity *entity { nullptr }; + }; + std::vector m_belt_dropped_fills; 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; @@ -628,7 +709,25 @@ private: PrintObject* m_shared_object{ nullptr }; - + // Belt printer: global Z offset applied to this object's layers for shear positioning. + double m_belt_global_z_offset { 0.0 }; + // generate_support_material(false) finished and generate_belt_brim() has not run yet. + bool m_belt_brim_pending { false }; + // Belt printer: min_z of mesh after belt shear (before Z-shift), for z_offset calc. + double m_belt_min_z { 0.0 }; + // Belt printer: exact belt_floor_z_shift computed during posSlice from a + // vertex-level scan of the post-transform mesh. Cached separately from + // m_slicing_params so that rebuilding m_slicing_params on a non-belt-affecting + // invalidation (e.g. support config change) doesn't replace the exact value + // with the bbox approximation seeded by create_from_config(). Cleared when + // posSlice is invalidated. + double m_belt_floor_z_shift_cached { 0.0 }; + bool m_belt_floor_z_shift_cache_valid { false }; +public: + double belt_global_z_offset() const { return m_belt_global_z_offset; } +private: + + // SoftFever // // object id @@ -991,6 +1090,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; }); @@ -1067,6 +1175,8 @@ public: // Wipe tower support. bool has_wipe_tower() const; + // Belt purge prism (belt-mode replacement for the wipe tower). + bool has_belt_purge_tower() const; const WipeTowerData& wipe_tower_data(size_t filaments_cnt = 0) const; const ToolOrdering& tool_ordering() const { return m_tool_ordering; } @@ -1337,6 +1447,12 @@ private: void _make_skirt(); void _make_wipe_tower(); + // Belt mode: route filament-change purging into the belt purge prism + // (flush-into-objects) without generating a classic wipe tower. + void _plan_belt_purge(); + // Belt mode: shift the purge prism's layer grid to match the printed + // objects' grid so toolchange layers find prism layers to purge into. + void _align_belt_purge_layers(); void finalize_first_layer_convex_hull(); void update_filament_self_index_cache(); // Deduplicates, per filament, the (extruder type x volume type) variants the grouping diff --git a/src/libslic3r/PrintApply.cpp b/src/libslic3r/PrintApply.cpp index 4c58ffd84e..5fed82211d 100644 --- a/src/libslic3r/PrintApply.cpp +++ b/src/libslic3r/PrintApply.cpp @@ -7,6 +7,7 @@ #include "Point.hpp" #include "Polygon.hpp" #include "Print.hpp" +#include "BeltTransform.hpp" #include "FilamentMixer.hpp" #include "Slicing.hpp" #include "libslic3r.h" @@ -187,22 +188,29 @@ struct PrintObjectTrafoAndInstances // Generate a list of trafos and XY offsets for instances of a ModelObject // Orca: Updated to include XYZ filament shrinkage compensation -static std::vector print_objects_from_model_object(const ModelObject &model_object, const Vec3d &shrinkage_compensation) +static std::vector print_objects_from_model_object(const ModelObject &model_object, const Vec3d &shrinkage_compensation, bool force_separate_instances = false) { std::set trafos; PrintObjectTrafoAndInstances trafo; //BBS: add useful logs for debug int index = 0; + int unique_counter = 0; for (ModelInstance *model_instance : model_object.instances) { if (model_instance->is_printable()) { // Orca: Updated with XYZ filament shrinkage compensation Geometry::Transformation model_instance_transformation = model_instance->get_transformation(); trafo.trafo = model_instance_transformation.get_matrix_with_applied_shrinkage_compensation(shrinkage_compensation); - + auto shift = Point::new_scale(trafo.trafo.data()[12], trafo.trafo.data()[13]); // Reset the XY axes of the transformation. trafo.trafo.data()[12] = 0; trafo.trafo.data()[13] = 0; + // Belt printer global mode: prevent instance grouping so each + // copy gets its own PrintObject with independent layer Z values. + // Add a tiny unique perturbation to the existing Z (don't replace + // it — the Z translation from ensure_on_bed must be preserved). + if (force_separate_instances) + trafo.trafo.data()[14] += 1e-10 * (++unique_counter); // Search or insert a trafo. auto it = trafos.emplace(trafo).first; const_cast(*it).instances.emplace_back(PrintInstance{ nullptr, model_instance, shift }); @@ -1287,6 +1295,17 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(", i=%1%, key=%2%")%i %changed_keys[i]; } } + // On belt printers the support tilt follows the slicing rotation. The GUI keeps the two in + // sync, but a CLI or 3MF edit of the rotation alone would otherwise leave supports on a stale tilt. + if (const auto *belt_opt = new_full_config.option("belt_printer"); belt_opt && belt_opt->value) { + const auto *axis_opt = new_full_config.option>("belt_slice_rotation"); + const auto *angle_opt = new_full_config.option("belt_slice_rotation_angle"); + if (axis_opt && angle_opt) { + const auto tilt = BeltTransformPipeline::physical_tilt(axis_opt->value, angle_opt->value); + new_full_config.set_key_value("build_plate_tilt_x", new ConfigOptionFloat(tilt.tilt_x_deg)); + new_full_config.set_key_value("build_plate_tilt_y", new ConfigOptionFloat(tilt.tilt_y_deg)); + } + } const ConfigOption* enable_support_option = new_full_config.option("enable_support"); if (enable_support_option && enable_support_option->getBool()) m_support_used = true; @@ -1848,11 +1867,15 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ PrintObjectPtrs print_objects_new; print_objects_new.reserve(std::max(m_objects.size(), m_model.objects.size())); bool new_objects = false; + bool belt_instances_shifted = false; // Walk over all new model objects and check, whether there are matching PrintObjects. for (ModelObject *model_object : m_model.objects) { ModelObjectStatus &model_object_status = const_cast(model_object_status_db.reuse(*model_object)); // Orca: Updated for XYZ filament shrink compensation - model_object_status.print_instances = print_objects_from_model_object(*model_object, this->shrinkage_compensation()); + // Belt printers: force each instance into its own PrintObject so each + // gets independent layer Z values (its bed position is folded into them). + bool belt_force_separate = m_config.belt_printer.value; + model_object_status.print_instances = print_objects_from_model_object(*model_object, this->shrinkage_compensation(), belt_force_separate); std::vector old; old.reserve(print_object_status_db.count(*model_object)); for (const PrintObjectStatus &print_object_status : print_object_status_db.get_range(*model_object)) @@ -1900,6 +1923,7 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ if (status != PrintBase::APPLY_STATUS_UNCHANGED) { size_t extruder_num = new_full_config.option("nozzle_diameter")->size(); update_apply_status(status == PrintBase::APPLY_STATUS_INVALIDATED); + belt_instances_shifted = true; } print_objects_new.emplace_back((*it_old)->print_object); const_cast(*it_old)->status = PrintObjectStatus::Reused; @@ -1922,6 +1946,13 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ } if (new_objects || deleted_objects) update_apply_status(this->invalidate_steps({ psSkirtBrim, psWipeTower, psGCodeExport })); + // A belt brim is clipped against the other objects on the plate (BeltBrim.cpp, + // belt_brim_obstacles), and it is rebuilt with its object's support step: an + // object that arrived or left changes every other brim owner's brim. + if ((new_objects || deleted_objects) && m_config.belt_printer.value) + for (PrintObject *object : m_objects) + if (object->has_belt_brim()) + update_apply_status(object->invalidate_step(posSupportMaterial)); if (new_objects) update_apply_status(false); print_regions_reshuffled = true; @@ -1935,6 +1966,14 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ update_apply_status(object->invalidate_step(posSlice)); } } + + // Belt printer: when any object's instances shifted, re-slice every object. + // The global Z offset follows each object's position along the belt, and the + // belt brims are clipped against the other objects. + if (belt_instances_shifted && m_config.belt_printer.value) { + for (PrintObject *object : m_objects) + update_apply_status(object->invalidate_step(posSlice)); + } } //BBS: check the config again diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index ea19434332..3a73a51ceb 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -3,8 +3,10 @@ #include "Point.hpp" #include "Polygon.hpp" #include "PrintConfigConstants.hpp" +#include "BeltTransform.hpp" #include "ClipperUtils.hpp" #include "Config.hpp" +#include "Geometry.hpp" #include "FilamentMixer.hpp" #include "MaterialType.hpp" #include "I18N.hpp" @@ -385,6 +387,27 @@ static t_config_enum_values s_keys_map_SlicingMode { }; CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(SlicingMode) +static t_config_enum_values s_keys_map_BeltRotationAxis { + { "none", int(BeltRotationAxis::None) }, + { "x", int(BeltRotationAxis::X) }, + { "y", int(BeltRotationAxis::Y) }, + { "z", int(BeltRotationAxis::Z) }, +}; +CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(BeltRotationAxis) + +static t_config_enum_values s_keys_map_RemapAxis { + { "pos_x", int(RemapAxis::PosX) }, + { "pos_y", int(RemapAxis::PosY) }, + { "pos_z", int(RemapAxis::PosZ) }, + { "neg_x", int(RemapAxis::NegX) }, + { "neg_y", int(RemapAxis::NegY) }, + { "neg_z", int(RemapAxis::NegZ) }, + { "rev_x", int(RemapAxis::RevX) }, + { "rev_y", int(RemapAxis::RevY) }, + { "rev_z", int(RemapAxis::RevZ) }, +}; +CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(RemapAxis) + static t_config_enum_values s_keys_map_SupportMaterialPattern { { "rectilinear", smpRectilinear }, { "rectilinear-grid", smpRectilinearGrid }, @@ -501,6 +524,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) @@ -1964,6 +1988,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"); @@ -1977,6 +2040,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")); @@ -1984,6 +2048,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)); @@ -7423,6 +7488,166 @@ void PrintConfigDef::init_fff_params() def->mode = comSimple; def->set_default_value(new ConfigOptionFloatOrPercent(50., true)); + def = this->add("build_plate_tilt_x", coFloat); + def->label = L("Build plate tilt X"); + def->category = L("Support"); + def->tooltip = L("Tilt angle of the build plate along the X axis. " + "A positive value tilts the plate so the +X side is higher, shifting gravity toward -X and increasing overhangs on the +X side. " + "A negative value tilts the -X side higher. Set to 0 for no X-axis tilt. " + "In belt printer mode, this is automatically synced to the belt angle."); + def->sidetext = u8"\u00B0"; + def->min = -89; + def->max = 89; + def->mode = comExpert; + def->set_default_value(new ConfigOptionFloat(0.)); + + def = this->add("build_plate_tilt_y", coFloat); + def->label = L("Build plate tilt Y"); + def->category = L("Support"); + def->tooltip = L("Tilt angle of the build plate along the Y axis. " + "A positive value tilts the plate so the +Y side is higher, shifting gravity toward -Y and increasing overhangs on the +Y side. " + "A negative value tilts the -Y side higher. Set to 0 for no Y-axis tilt."); + def->sidetext = u8"\u00B0"; + def->min = -89; + def->max = 89; + def->mode = comExpert; + def->set_default_value(new ConfigOptionFloat(0.)); + + def = this->add("belt_printer", coBool); + def->label = L("Enable belt printing"); + def->category = L("Printable space"); + def->tooltip = L("Enable belt printer mode. Belt printers use a conveyor belt as the build surface, " + "tilted at an angle (typically 45 degrees). The slicer will rotate the slicing plane " + "and transform G-code coordinates for the tilted build surface."); + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionBool(false)); + + def = this->add("belt_printer_infinite_y", coBool); + def->label = L("Infinite Y axis"); + def->category = L("Printable space"); + def->tooltip = L("Enable infinite Y axis for belt printers. " + "When enabled, the Y axis build volume limit is effectively removed, " + "allowing objects of any length to be printed along the belt direction."); + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionBool(true)); + + // Mesh rotation applied before slicing — the sole mesh-side belt transform AND + // the single source of truth for the physical belt tilt (bed rendering, support + // gravity tilt and bed-exclusion projection all derive their angle from this). + def = this->add("belt_slice_rotation", coEnum); + def->label = L("Belt tilt axis"); + def->category = L("Printable space"); + def->tooltip = L("Axis the mesh is rotated about before slicing. This is the belt " + "printer's tilt: an isometric (no distortion) rotation that also " + "drives bed rendering and support gravity tilt, and that the g-code " + "back-transform inverts before the machine-frame shear/scale and remap. " + "X is the typical gantry tilt (belt travels along Y)."); + def->enum_keys_map = &ConfigOptionEnum::get_enum_values(); + def->enum_values = {"none", "x", "y", "z"}; + def->enum_labels = {L("None"), L("X"), L("Y"), L("Z")}; + def->mode = comDevelop; + def->set_default_value(new ConfigOptionEnum(BeltRotationAxis::X)); + + def = this->add("belt_slice_rotation_angle", coFloat); + def->label = L("Belt tilt angle"); + def->category = L("Printable space"); + def->tooltip = L("Tilt angle of the belt surface, in degrees. Most belt printers use " + "45°. Positive values rotate counter-clockwise looking down the " + "positive tilt axis; the magnitude is also the physical belt tilt " + "used for bed rendering and support gravity."); + def->sidetext = L("°"); + def->min = -180.; + def->max = 180.; + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionFloat(45.)); + + def = this->add("belt_frame_tilt_decouple", coBool); + def->label = L("Decouple machine-frame tilt"); + def->category = L("Printable space"); + def->tooltip = L("Expert override: set the machine-frame (g-code shear/scale) tilt angle " + "independently of the pre-slice rotation angle. When disabled, the " + "machine-frame transform is derived from the belt tilt angle, so a single " + "angle drives both stages. Enable only to compensate for a machine whose " + "physical gantry tilt differs from the slicing rotation."); + def->mode = comExpert; + def->set_default_value(new ConfigOptionBool(false)); + + def = this->add("belt_frame_tilt_angle", coFloat); + def->label = L("Machine-frame tilt angle"); + def->category = L("Printable space"); + def->tooltip = L("Tilt angle (degrees) used to derive the machine-frame shear (cot) and " + "scale (1/|sin|) applied to G-code. Only used when 'Decouple machine-frame " + "tilt' is enabled; otherwise the belt tilt angle is used."); + def->sidetext = L("°"); + def->min = -89.9; + def->max = 89.9; + def->mode = comExpert; + def->set_default_value(new ConfigOptionFloat(45.)); + + // G-code axis remap with sign. Each field is its own row in the settings tab. The + // labels and tooltips are literals in L() so they are extracted for translation. + auto add_belt_remap = [this](const char *key, const std::string &label, const std::string &tooltip, + RemapAxis default_axis, ConfigOptionMode mode) { + auto def = this->add(key, coEnum); + def->label = label; + def->category = L("Printable space"); + def->tooltip = tooltip; + def->enum_keys_map = &ConfigOptionEnum::get_enum_values(); + def->enum_values = {"pos_x", "pos_y", "pos_z", "neg_x", "neg_y", "neg_z", "rev_x", "rev_y", "rev_z"}; + def->enum_labels = {L("+X"), L("+Y"), L("+Z"), L("-X"), L("-Y"), L("-Z"), L("Rev X"), L("Rev Y"), L("Rev Z")}; + def->mode = mode; // Visibility may also be gated by toggle_line in Tab.cpp + def->set_default_value(new ConfigOptionEnum(default_axis)); + }; + add_belt_remap("gcode_remap_x", L("G-code remap X"), + L("Which slicing axis maps to machine X in G-code output. Applied AFTER slicing, during G-code generation."), + RemapAxis::PosX, comDevelop); + add_belt_remap("gcode_remap_y", L("G-code remap Y"), + L("Which slicing axis maps to machine Y in G-code output. Applied AFTER slicing, during G-code generation."), + RemapAxis::PosY, comDevelop); + add_belt_remap("gcode_remap_z", L("G-code remap Z"), + L("Which slicing axis maps to machine Z in G-code output. Applied AFTER slicing, during G-code generation."), + RemapAxis::PosZ, comDevelop); + + // The machine-frame G-code transform (shear + scale) is no longer configured + // by per-axis keys: it is derived from the belt tilt (belt_slice_rotation axis + // + angle, or belt_frame_tilt_angle when decoupled) in MachineFrameTransform. + + // Belt support floor debug controls + def = this->add("belt_support_floor_offset", coFloat); + def->label = L("Support Floor Z offset"); + def->category = L("Printable space"); + def->tooltip = L("Shifts the computed belt floor up or down (mm). Negative values lower the floor, allowing more supports to survive. Use this to diagnose belt floor formula issues."); + def->sidetext = L("mm"); + def->min = -500; + def->max = 500; + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionFloat(0)); + + def = this->add("enable_belt_purge_tower", coBool); + def->label = L("Enable belt purge tower"); + def->category = L("Multimaterial"); + def->tooltip = L("Belt-printer replacement for the wipe/prime tower. When enabled on a belt " + "printer, a purge prism is automatically generated next to the printed parts " + "and filament-change purging is routed into it (the classic wipe tower cannot " + "be used on belt printers because its G-code bypasses the belt transform). " + "Only available on belt printers."); + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionBool(false)); + + def = this->add("belt_purge_tower_width", coFloat); + def->label = L("Belt purge tower width"); + def->category = L("Printable space"); + def->tooltip = L("Width (machine X, across the belt) of the purge prism that is automatically " + "generated on belt printers when the belt purge tower is enabled and multiple " + "filaments are used. Filament-change purging is routed into this prism's " + "extrusions instead of a classic wipe tower. Its height is computed " + "automatically from the worst-case purge volume per layer: a wider prism " + "results in a shorter one."); + def->sidetext = L("mm"); + def->min = 1.; + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionFloat(35.)); + def = this->add("tree_support_branch_angle", coFloat); def->label = L("Tree support branch angle"); def->category = L("Support"); @@ -8076,6 +8301,16 @@ void PrintConfigDef::init_fff_params() "It will not take effect unless the prime tower is enabled."); def->set_default_value(new ConfigOptionBool(false)); + // Internal marker (not shown in any settings tab): identifies the auto-generated + // belt purge prism so it can be updated/removed by the auto-manager and aligned + // to the object layer grid by the backend. Persisted to 3mf like any per-object key. + def = this->add("belt_purge_tower_object", coBool); + def->category = L("Flush options"); + def->label = L("Belt purge tower object"); + def->tooltip = L("Marks the auto-generated belt purge prism. Managed automatically; do not set manually."); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionBool(false)); + def = this->add("wipe_tower_bridging", coFloat); def->label = L("Maximal bridging distance"); def->tooltip = L("Maximal distance between supports on sparse infill sections."); @@ -9578,6 +9813,13 @@ void PrintConfigDef::handle_legacy(t_config_option_key &opt_key, std::string &va "smooth_coefficient", "overhang_totally_speed", "silent_mode", "overhang_speed_classic", "anisotropic_surfaces", // superseded by top_surface_fill_order / bottom_surface_fill_order + // Belt printer keys retired before the first release: the global-mode and + // back-transform switches are presumed on, and the pre-slice axis remap, the + // support Z offset mode, the support floor mode (always on) and the first-layer + // plane evaluator were removed. + "belt_slice_rotation_global", "preslice_remap_x", "preslice_remap_y", "preslice_remap_z", "preslice_remap_global", + "belt_support_z_offset_mode", "first_layer_plane", "first_layer_plane_offset", + "belt_preslice_global", "gcode_back_transform", "belt_support_floor_mode", "first_layer_plane_thickness", }; if (ignore.find(opt_key) != ignore.end()) { @@ -13308,10 +13550,22 @@ Polygons get_bed_excluded_area(const PrintConfig& cfg) { const Pointfs exclude_area_points = cfg.bed_exclude_area.values; + // Belt printer: project exclusion zone points from the belt surface to machine-frame XY. + // On the belt surface Z=0, so the in-plane axis foreshortens by cos(tilt). The tilt + // axis decides which bed axis foreshortens: tilt about X (belt along Y) scales Y, + // tilt about Y (belt along X) scales X. Derived from belt_slice_rotation. + const bool is_belt = cfg.belt_printer.value; + const auto tilt = BeltTransformPipeline::physical_tilt( + cfg.belt_slice_rotation.value, cfg.belt_slice_rotation_angle.value); + const double cos_x = is_belt ? std::cos(Geometry::deg2rad(tilt.tilt_x_deg)) : 1.0; // foreshortens Y + const double cos_y = is_belt ? std::cos(Geometry::deg2rad(tilt.tilt_y_deg)) : 1.0; // foreshortens X + Polygon exclude_poly; for (int i = 0; i < exclude_area_points.size(); i++) { auto pt = exclude_area_points[i]; - exclude_poly.points.emplace_back(scale_(pt.x()), scale_(pt.y())); + double x = is_belt ? pt.x() * cos_y : pt.x(); + double y = is_belt ? pt.y() * cos_x : pt.y(); + exclude_poly.points.emplace_back(scale_(x), scale_(y)); } exclude_poly.make_counter_clockwise(); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 0b8a099e18..6b980ea68f 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -304,6 +304,25 @@ enum class SlicingMode CloseHoles, }; +// Axis around which the mesh is rotated before slicing, when +// `belt_slice_rotation` is set. None disables the rotation stage. This is the +// single "belt tilt" axis: it drives both the pre-slice mesh rotation and the +// post-slice machine-frame transform (shear + scale derived from the tilt angle). +enum class BeltRotationAxis +{ + None = 0, + X = 1, + Y = 2, + Z = 3, +}; + +enum class RemapAxis +{ + PosX = 0, PosY = 1, PosZ = 2, + NegX = 3, NegY = 4, NegZ = 5, + RevX = 6, RevY = 7, RevZ = 8, // Reversed: max - pos +}; + enum SupportMaterialPattern { smpDefault, smpRectilinear, smpRectilinearGrid, smpHoneycomb, @@ -411,6 +430,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 { @@ -744,6 +767,8 @@ CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(NoiseType) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(InfillPattern) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(IroningType) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(SlicingMode) +CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(BeltRotationAxis) +CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(RemapAxis) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(SupportMaterialPattern) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(SupportMaterialStyle) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(SupportMaterialInterfacePattern) @@ -1215,6 +1240,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)) ((ConfigOptionBool, brim_ears_outer_only)) @@ -1298,6 +1325,9 @@ PRINT_CONFIG_CLASS_DEFINE( // BBS ((ConfigOptionBool, flush_into_infill)) ((ConfigOptionBool, flush_into_support)) + // Marker for the auto-generated belt purge prism; identifies the object to + // the auto-manager (GUI) and the layer-grid alignment step (backend). + ((ConfigOptionBool, belt_purge_tower_object)) // BBS ((ConfigOptionFloat, tree_support_branch_distance)) ((ConfigOptionFloat, tree_support_tip_diameter)) @@ -1875,6 +1905,33 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE( PrintConfig, (MachineEnvelopeConfig, GCodeConfig), + // Build plate tilt for off-axis gravity support generation (printer-level setting). + ((ConfigOptionFloat, build_plate_tilt_x)) + ((ConfigOptionFloat, build_plate_tilt_y)) + // Belt printer settings (printer-level). + ((ConfigOptionBool, belt_printer)) + ((ConfigOptionBool, belt_printer_infinite_y)) + // Mesh rotation applied before slicing — the single source of truth for the + // physical belt tilt. Its angle + axis drive bed rendering, support gravity + // tilt, the bed-exclusion projection, AND the post-slice machine-frame + // transform (shear + scale, derived from the tilt angle; see + // MachineFrameTransform). Isometric (no distortion) on the mesh side; the + // g-code back-transform inverts the rotation before the machine-frame stage. + ((ConfigOptionEnum, belt_slice_rotation)) + ((ConfigOptionFloat, belt_slice_rotation_angle)) + // Expert override: decouple the machine-frame tilt angle from the pre-slice + // rotation angle. When disabled, the machine frame uses belt_slice_rotation_angle. + ((ConfigOptionBool, belt_frame_tilt_decouple)) + ((ConfigOptionFloat, belt_frame_tilt_angle)) + ((ConfigOptionEnum, gcode_remap_x)) + ((ConfigOptionEnum, gcode_remap_y)) + ((ConfigOptionEnum, gcode_remap_z)) + ((ConfigOptionFloat, belt_support_floor_offset)) + // Width (machine X, across the belt) of the auto-generated belt purge prism. + ((ConfigOptionFloat, belt_purge_tower_width)) + // Belt-printer-only "type" of purge tower: enables the auto-generated belt + // purge prism (the belt replacement for the classic wipe/prime tower). + ((ConfigOptionBool, enable_belt_purge_tower)) //BBS ((ConfigOptionInts, additional_cooling_fan_speed)) ((ConfigOptionInts, close_additional_fan_first_x_layers)) diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 4f4d823951..3387d22ba8 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -8,6 +8,8 @@ #include "Polygon.hpp" #include "Polyline.hpp" #include "Print.hpp" +#include "BeltTransform.hpp" + #include "BoundingBox.hpp" #include "ClipperUtils.hpp" #include "Geometry.hpp" @@ -18,6 +20,7 @@ #include "PrintConfig.hpp" #include "SLA/IndexedMesh.hpp" #include "Support/SupportMaterial.hpp" +#include "Support/SupportCommon.hpp" #include "Support/SupportSpotsGenerator.hpp" #include "Support/TreeSupport.hpp" #include "Surface.hpp" @@ -81,6 +84,7 @@ #include "Fill/Lightning/Generator.hpp" #include "SurfaceCollection.hpp" #include "TriangleMesh.hpp" +#include "BeltBrim.hpp" namespace Slic3r { enum class EnforcerBlockerType : int8_t; } @@ -983,7 +987,7 @@ void PrintObject::detect_overhangs_for_lift() } } -void PrintObject::generate_support_material() +void PrintObject::generate_support_material(bool with_belt_brim) { if (this->set_started(posSupportMaterial)) { this->clear_support_layers(); @@ -1026,10 +1030,30 @@ 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. + m_belt_brim_pending = true; + if (with_belt_brim) + this->generate_belt_brim(); this->set_done(posSupportMaterial); } } +void PrintObject::generate_belt_brim() +{ + if (! m_belt_brim_pending) + return; + // belt_brim_obstacles() looks up the layers and support layers of every object + // on the plate by print_z. Another object's support step rebuilds those (and + // temporarily shifts its layer Z values), so this must not overlap with it. + make_belt_brim(*this); + m_print->throw_if_canceled(); + m_belt_brim_pending = false; +} + void PrintObject::estimate_curled_extrusions() { if (this->set_started(posEstimateCurledExtrusions)) { @@ -1193,7 +1217,10 @@ FillAdaptive::RegionOctrees PrintObject::prepare_adaptive_infill_data( indexed_triangle_set mesh = this->model_object()->raw_indexed_triangle_set(); // Rotate mesh and build octree on it with axis-aligned (standart base) cubes. auto to_octree = transform_to_octree().toRotationMatrix(); - its_transform(mesh, to_octree * this->trafo_centered(), true); + // Overhangs below are placed at Layer::bottom_z(), which includes the belt global Z offset. + Transform3d object_trafo = this->trafo_sliced(); + object_trafo.translation().z() += m_belt_global_z_offset; + its_transform(mesh, to_octree * object_trafo, true); // Triangulate internal bridging surfaces. std::vector> overhangs(std::max(surfaces_w_layer.size(), size_t(1))); @@ -1276,6 +1303,16 @@ void PrintObject::clear_layers() for (Layer *l : m_layers) delete l; m_layers.clear(); + for (Layer *l : m_belt_truncated_layers) + delete l; + m_belt_truncated_layers.clear(); + // Fills dropped for plastic saving are owned by the stash while they sit + // outside their layer's collection, so they are freed here too. Order + // matters only in that these point at layers deleted just above, and we + // never dereference the layer -- just the entity. + for (const BeltDroppedFill &d : m_belt_dropped_fills) + delete d.entity; + m_belt_dropped_fills.clear(); } } @@ -1309,6 +1346,75 @@ 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; + // The purge prism is sacrificial and sits at the plate's edge; its generator sets no_brim, and + // this keeps it brimless whatever its config says, so a brim on the parts never blocks purging. + if (m_config.belt_purge_tower_object.value) + return false; + if (m_config.brim_type == btNoBrim) + return false; + // An inner-only brim has no leading/extra geometry: leading_brim_length and + // extra_brim_width both widen the OUTER ring, which btInnerOnly never emits, so it + // produces nothing unless brim_width itself is positive. Every other brim type is + // satisfied by any one of the three widths. Requiring the width here (instead of + // "any width") stops has_belt_brim() - and therefore Print::validate() - from + // rejecting the prime tower / spiral vase for a brim that would never be drawn. + if (m_config.brim_type == btInnerOnly) { + if (m_config.brim_width.value <= 0.) + return false; + } else 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(); +} + +unsigned int PrintObject::belt_brim_filament() const +{ + // 1-based, matching PrintRegion::outer_wall_filament_id and the raw values pushed + // into LayerTools::extruders in ToolOrdering::collect_extruders (the whole list is + // reindexed to 0-based later). Lowest positive outer-wall filament over the + // printing regions; 1 when none is explicitly set. + unsigned int brim_filament = 0; + for (size_t i = 0; i < this->num_printing_regions(); ++ i) { + const unsigned int f = this->printing_region(i).config().outer_wall_filament_id.value; + if (f > 0 && (brim_filament == 0 || f < brim_filament)) + brim_filament = f; + } + return brim_filament == 0 ? 1u : brim_filament; +} + +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() @@ -1351,6 +1457,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" @@ -1375,7 +1483,10 @@ bool PrintObject::invalidate_state_by_config_options( const auto* new_brim_type = new_config.option>(opt_key); //BBS: When switch to manual brim, the object must have brim, then re-generate perimeter //to make the wall order of first layer to be outer-first - if (old_brim_type->value == btOuterOnly || new_brim_type->value == btOuterOnly) + // btLeadingEdgeOnly is printed as an outer brim (Brim.cpp, BeltBrim.cpp), so it + // takes part in the same first-layer wall order rule. + if (old_brim_type->value == btOuterOnly || new_brim_type->value == btOuterOnly || + old_brim_type->value == btLeadingEdgeOnly || new_brim_type->value == btLeadingEdgeOnly) steps.emplace_back(posPerimeters); } } else if ( @@ -1735,7 +1846,8 @@ bool PrintObject::invalidate_state_by_config_options( } else if ( opt_key == "flush_into_infill" || opt_key == "flush_into_objects" - || opt_key == "flush_into_support") { + || opt_key == "flush_into_support" + || opt_key == "belt_purge_tower_object") { invalidated |= m_print->invalidate_step(psWipeTower); invalidated |= m_print->invalidate_step(psGCodeExport); } else { @@ -1765,12 +1877,25 @@ bool PrintObject::invalidate_step(PrintObjectStep step) invalidated |= this->invalidate_steps({ posIroning, posContouring, posSimplifyInfill }); invalidated |= m_print->invalidate_steps({ psSkirtBrim }); } else if (step == posSlice) { - invalidated |= this->invalidate_steps({ posPerimeters, posPrepareInfill, posInfill, posIroning, posContouring, posSupportMaterial, posSimplifyPath, posSimplifyInfill }); + // posSimplifySupportPath is listed with posSupportMaterial: invalidate_steps() does not + // propagate, so without it a re-slice regenerated the supports but kept the step done, + // and the new support paths were exported unsimplified, unlike a fresh slice. + // posDetectOverhangsForLift reads the layers' overhang regions, which a re-slice + // starts over empty: without it here the step stayed done and the lift logic in + // GCode::needs_retraction() had no overhangs to test against until something else + // invalidated it. + invalidated |= this->invalidate_steps({ posPerimeters, posPrepareInfill, posInfill, posIroning, posContouring, posSupportMaterial, posSimplifyPath, posSimplifyInfill, posSimplifySupportPath, posDetectOverhangsForLift }); invalidated |= m_print->invalidate_steps({ psSkirtBrim }); m_slicing_params.valid = false; + // The exact belt_floor_z_shift is recomputed when slice() runs again. + m_belt_floor_z_shift_cache_valid = false; } else if (step == posSupportMaterial) { invalidated |= this->invalidate_steps({ posSimplifySupportPath }); invalidated |= m_print->invalidate_steps({ psSkirtBrim }); + // SlicingParameters depend on support config (enable_support / + // raft_layers / enforce_support_layers feed min/max layer height in + // Slicing.cpp), so invalidate them here. The vertex-scan + // belt_floor_z_shift is preserved via m_belt_floor_z_shift_cached. m_slicing_params.valid = false; } @@ -1791,6 +1916,7 @@ bool PrintObject::invalidate_all_steps() bool result = inherited_invalidated || print_invalidated; // Then reset some of the depending values. m_slicing_params.valid = false; + m_belt_floor_z_shift_cache_valid = false; return result; } @@ -4127,8 +4253,36 @@ void PrintObject::update_slicing_parameters() { // Orca: updated function call for XYZ shrinkage compensation if (!m_slicing_params.valid) { - m_slicing_params = SlicingParameters::create_from_config(this->print()->config(), m_config, this->model_object()->max_z(), + coordf_t object_height = this->model_object()->max_z(); + BeltTransformPipeline::BeltFloorParams belt_floor; + const auto &pcfg = this->print()->config(); + if (pcfg.belt_printer.value) { + // The box of the mesh in the frame it is sliced in: XY centred and Z as + // placed on the bed (trafo_centered()). raw_bounding_box() has the + // instance's Z offset removed, and the belt floor is not invariant to a + // Z shift (a point's z and the floor under it move in opposite + // directions under the rotation), so an offset box under-estimates the + // height by twice the shift and the layers stop part way up the object. + BoundingBoxf3 bb; + const Transform3d trafo = this->trafo_centered(); + for (const ModelVolume *v : this->model_object()->volumes) + if (v->is_model_part()) + bb.merge(v->mesh().transformed_bounding_box(trafo * v->get_matrix())); + auto hr = BeltTransformPipeline::compute_belt_height_and_floor(pcfg, bb, object_height); + object_height = hr.object_height; + belt_floor = hr.floor_params; + } + m_slicing_params = SlicingParameters::create_from_config(pcfg, m_config, object_height, this->object_extruders(), this->print()->shrinkage_compensation()); + // Populate belt floor parameters into slicing params for support clipping. + m_slicing_params.belt_floor_shear_factor = belt_floor.shear_factor; + m_slicing_params.belt_floor_from_axis = belt_floor.from_axis; + m_slicing_params.belt_floor_z_shift = belt_floor.z_shift; + // Prefer the vertex-scan z_shift over the bbox approximation when + // slice() has already produced one (e.g. this rebuild was triggered + // by a support-config change, which doesn't move the belt floor). + if (m_belt_floor_z_shift_cache_valid) + m_slicing_params.belt_floor_z_shift = m_belt_floor_z_shift_cached; } } @@ -4169,9 +4323,28 @@ SlicingParameters PrintObject::slicing_parameters(const DynamicPrintConfig &full sort_remove_duplicates(object_extruders); //FIXME add painting extruders - if (object_max_z <= 0.f) - object_max_z = (float)model_object.raw_bounding_box().size().z(); - return SlicingParameters::create_from_config(print_config, object_config, object_max_z, object_extruders, object_shrinkage_compensation); + BeltTransformPipeline::BeltFloorParams belt_floor; + if (object_max_z <= 0.f) { + BoundingBoxf3 bb = model_object.raw_bounding_box(); + object_max_z = (float)bb.size().z(); + if (print_config.belt_printer.value) { + // Z as placed on the bed, XY around the instance origin: the belt floor + // depends on where the box sits in Z (see update_slicing_parameters()). + if (! model_object.instances.empty()) { + bb = model_object.instance_bounding_box(0, false); + const Vec3d off = model_object.instances.front()->get_offset(); + bb.translate(-off.x(), -off.y(), 0.); + } + auto hr = BeltTransformPipeline::compute_belt_height_and_floor(print_config, bb, object_max_z); + object_max_z = (float)hr.object_height; + belt_floor = hr.floor_params; + } + } + SlicingParameters params = SlicingParameters::create_from_config(print_config, object_config, object_max_z, object_extruders, object_shrinkage_compensation); + params.belt_floor_shear_factor = belt_floor.shear_factor; + params.belt_floor_from_axis = belt_floor.from_axis; + params.belt_floor_z_shift = belt_floor.z_shift; + return params; } // returns 0-based indices of extruders used to print the object (without brim, support and other helper extrusions) @@ -4688,9 +4861,58 @@ void PrintObject::_generate_support_material() tree_support.generate(); } else { - PrintObjectSupportMaterial support_material(this, m_slicing_params); - support_material.generate(*this); + // The normal generator anchors its layer grid at the slicing frame origin + // (SlicingParameters: first layer at first_print_layer_height, raft at + // z = 0), so it has to see the object layers in that frame. On a belt the + // object layers carry the global Z offset (PrintObject::slice()), which is + // negative for the leading half of the belt: a top contact below z = 0 + // then turns the intermediate-layer count negative and the generator + // allocates layers until memory runs out. Lift the offset off the object + // layers and the belt floor for the duration of the run and put it back + // on everything, including the new support layers, afterwards (organic + // tree support is shifted the same way below). + const double global_z = m_belt_global_z_offset; + const bool unshift = std::abs(global_z) > EPSILON; + auto shift_object_frame = [this, global_z](double sign) { + for (Layer *layer : m_layers) + layer->print_z += sign * global_z; + m_slicing_params.belt_floor_z_shift += sign * global_z; + }; + if (unshift) + shift_object_frame(-1.); + try { + PrintObjectSupportMaterial support_material(this, m_slicing_params); + support_material.generate(*this); + } catch (...) { + if (unshift) + shift_object_frame(1.); + throw; + } + if (unshift) { + shift_object_frame(1.); + for (SupportLayer *sl : m_support_layers) + sl->print_z += global_z; + } } + // Global Z offset for support layers: + // - Normal support: generated in the object frame above and shifted afterwards. + // - Non-organic tree support (slim/strong/hybrid): plan_layer_heights() reads + // from globally-offset object layers, so support layers already have it. + // - Organic tree support: generate_tree_support_3D() computes its own Z values + // independently and does NOT inherit the offset — apply it here. + // Belt floor polygon clipping for non-organic tree support is done inside + // draw_circles() before area_groups and toolpaths are built. + if (is_tree(m_config.support_type.value) && std::abs(m_belt_global_z_offset) > EPSILON) { + // Resolve effective support style (same logic as SupportParameters). + auto style = m_config.support_style.value; + if (style == smsDefault) + style = smsTreeOrganic; + if (style == smsTreeOrganic) { + for (SupportLayer *sl : m_support_layers) + sl->print_z += m_belt_global_z_offset; + } + } + } // BBS @@ -5040,6 +5262,7 @@ static void project_triangles_to_slabs(ConstLayerPtrsAdaptor layers, const index void PrintObject::project_and_append_custom_facets( bool seam, EnforcerBlockerType type, std::vector& out, std::vector>* vertical_points) const { + const Transform3d object_trafo = this->trafo_sliced(); for (const ModelVolume* mv : this->model_object()->volumes) if (mv->is_model_part()) { const indexed_triangle_set custom_facets = seam @@ -5048,12 +5271,12 @@ void PrintObject::project_and_append_custom_facets( if (! custom_facets.indices.empty()) { if (seam) project_triangles_to_slabs(this->layers(), custom_facets, - (this->trafo_centered() * mv->get_matrix()).cast(), + (object_trafo * mv->get_matrix()).cast(), seam, out); else { std::vector projected; // Support blockers or enforcers. Project downward facing painted areas upwards to their respective slicing plane. - slice_mesh_slabs(custom_facets, zs_from_layers(this->layers()), this->trafo_centered() * mv->get_matrix(), nullptr, &projected, vertical_points, [](){}); + slice_mesh_slabs(custom_facets, zs_from_layers(this->layers()), object_trafo * mv->get_matrix(), nullptr, &projected, vertical_points, [](){}); // Merge these projections with the output, layer by layer. assert(! projected.empty()); assert(out.empty() || out.size() == projected.size()); diff --git a/src/libslic3r/PrintObjectSlice.cpp b/src/libslic3r/PrintObjectSlice.cpp index 038797ed3c..facce01787 100644 --- a/src/libslic3r/PrintObjectSlice.cpp +++ b/src/libslic3r/PrintObjectSlice.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -27,6 +28,9 @@ #include "Point.hpp" #include "Polygon.hpp" #include "Print.hpp" +#include "BeltTransform.hpp" +#include "BeltSliceStrategy.hpp" +#include "Geometry.hpp" //BBS #include "PrintConfig.hpp" #include "PrintBase.hpp" @@ -180,7 +184,8 @@ static std::vector slice_volumes_inner( ModelVolumePtrs model_volumes, const std::vector &layer_ranges, const std::vector &zs, - const std::function &throw_on_cancel_callback) + const std::function &throw_on_cancel_callback, + double *out_belt_min_z = nullptr) { model_volumes_sort_by_id(model_volumes); @@ -195,6 +200,10 @@ static std::vector slice_volumes_inner( params_base.closing_radius = print_object_config.slice_closing_radius.value; params_base.extra_offset = 0; params_base.trafo = object_trafo; + // Pre-slice mesh transforms: axis remap (standalone — works without belt + // mode), belt rotation, and the per-object Z-shift. Owned by BeltSliceStrategy + // so this belt/remap-specific logic stays out of the generic slicing pipeline. + BeltSliceStrategy::apply_preslice_transforms(params_base.trafo, print_config, model_volumes, out_belt_min_z); //BBS: 0.0025mm is safe enough to simplify the data to speed slicing up for high-resolution model. //Also has on influence on arc fitting which has default resolution 0.0125mm. params_base.resolution = print_config.resolution <= 0.001 ? 0.0f : 0.0025; @@ -328,14 +337,38 @@ static std::vector> slices_to_regions( } } else { zs_complex.reserve(zs.size()); + // region.bbox is computed in pre-belt-transform slicer space (see PrintApply.cpp::trafo_for_bbox). + // When belt transforms are active, layer Z values are in post-rotation/shear/scale/remap space, + // so the Z components of region.bbox aren't comparable to z. Skipping the Z filter here + // pushes those layers into the parallel_for path below, which handles multi-volume + // clipping per layer without relying on the bbox Z range. + const bool bbox_z_in_layer_frame = !(print_config.belt_printer.value && + BeltTransformPipeline::has_rotation(print_config)); + // Belt-transform addendum: with bbox-Z untrusted, the simple path's + // "first model_part wins" logic drops subsequent volumes' slices unless + // they XY-overlap with the first. Assemblies whose volumes are stacked + // or side-by-side in pre-transform Z (different bbox.z ranges) thus lose + // the volumes that originally sat outside the first volume's Z range — + // showing up as truncation at the top or bottom of the assembly. Force + // every layer in a multi-volume range through the parallel_for path, + // which correctly merges all volumes per layer. + int num_model_parts = 0; + for (const PrintObjectRegions::VolumeRegion &vr : layer_range.volume_regions) + if (vr.model_volume->is_model_part()) + ++num_model_parts; + const bool force_complex_for_belt = !bbox_z_in_layer_frame && num_model_parts > 1; for (; z_idx < zs.size() && zs[z_idx] < layer_range.layer_height_range.second; ++ z_idx) { float z = zs[z_idx]; + if (force_complex_for_belt) { + zs_complex.push_back({ z_idx, z }); + continue; + } int idx_first_printable_region = -1; bool complex = false; std::vector printable_region_ids; for (int idx_region = 0; idx_region < int(layer_range.volume_regions.size()); ++ idx_region) { const PrintObjectRegions::VolumeRegion ®ion = layer_range.volume_regions[idx_region]; - if (region.bbox->min().z() <= z && region.bbox->max().z() >= z) { + if (!bbox_z_in_layer_frame || (region.bbox->min().z() <= z && region.bbox->max().z() >= z)) { if (region.model_volume->is_model_part()) printable_region_ids.push_back(idx_region); @@ -346,7 +379,9 @@ static std::vector> slices_to_regions( // Test for overlap with some other region. for (int idx_region2 = idx_first_printable_region; idx_region2 < idx_region; ++ idx_region2) { const PrintObjectRegions::VolumeRegion ®ion2 = layer_range.volume_regions[idx_region2]; - if (region2.bbox->min().z() <= z && region2.bbox->max().z() >= z && overlap_in_xy(*region.bbox, *region2.bbox)) { + const bool region2_in_z = !bbox_z_in_layer_frame + || (region2.bbox->min().z() <= z && region2.bbox->max().z() >= z); + if (region2_in_z && overlap_in_xy(*region.bbox, *region2.bbox)) { complex = true; break; } @@ -859,10 +894,32 @@ void PrintObject::slice() this->update_layer_height_profile(*this->model_object(), m_slicing_params, layer_height_profile); m_print->throw_if_canceled(); m_typed_slices = false; + // The belt state below is only written while belt mode is on (and the min-Z + // lift only when there is a rotation or remap). Start every slice from zero, + // or a project switched from a belt printer to a normal one, or whose tilt + // axis was set to None, keeps the previous offsets: the adaptive infill octree + // and the organic support layers (PrintObject.cpp) would still be shifted by + // them. + m_belt_min_z = 0.; + m_belt_global_z_offset = 0.; this->clear_layers(); m_layers = new_layers(this, generate_object_layers(m_slicing_params, layer_height_profile, m_config.precise_z_height.value)); this->slice_volumes(); m_print->throw_if_canceled(); + + // Belt floor Z-shift: where is the belt surface in final slicer space? + // + // The belt surface is the model's Z=0 plane. After the belt rotation and the + // Z-shift it is the plane Z_belt = shear_factor * from_axis + z_shift_val in + // slicer space, with z_shift_val = max(0, -m_belt_min_z), the lift that starts + // the slicing frame at the belt below the footprint. + if (std::abs(m_slicing_params.belt_floor_shear_factor) > EPSILON) { + double z_shift_val = (m_belt_min_z < 0.) ? -m_belt_min_z : 0.; + // The belt surface is at Z=0 in centered slicer space and bb.min.z() is + // already folded into m_belt_min_z. + m_slicing_params.belt_floor_z_shift = z_shift_val; + } + int firstLayerReplacedBy = 0; #if 0 @@ -901,6 +958,105 @@ void PrintObject::slice() if (m_layers.empty()) throw Slic3r::SlicingError(L("No layers were detected. You might want to repair your STL file(s) or check their size or thickness and retry.\n")); + // Belt printer: offset all layer Z values so objects at different positions + // along the belt print at different heights on the tilted belt. This is a + // post-slicing adjustment: the sliced geometry is the same, only the output Z + // coordinates change. + { + const auto &pcfg = this->print()->config(); + BOOST_LOG_TRIVIAL(trace) << "Belt global check: belt_printer=" << pcfg.belt_printer.value + << " belt_slice_rotation=" << int(pcfg.belt_slice_rotation.value) + << " object=" << this->model_object()->name; + if (pcfg.belt_printer.value) { + + Point inst_shift = this->instances().empty() ? Point(0, 0) + : this->instances().front().shift - this->center_offset(); + BOOST_LOG_TRIVIAL(trace) << "Belt global: object " << this->model_object()->name + << " instances=" << this->instances().size() + << " shift=(" << unscale(inst_shift.x()) << ", " << unscale(inst_shift.y()) << ")"; + + // Per-object Z-shift compensation, applied regardless of global mode. + // + // BeltSliceStrategy::apply_preslice_transforms lifts the mesh by max(0, -m_belt_min_z) + // so the slicer can slice with slicer_z >= 0. BeltBackTransform inverts + // build_forward_transform() which DOES NOT include this per-object + // Z-shift (it's not known until vertex scan time). Result: G-code + // coords emerge offset by the un-undone Z-shift — the inverse rotation + // couples slicer_z back into both machine_y and machine_z. Compensating + // layer.print_z by belt_z_shift here makes the back-transform produce + // correct machine-frame coordinates whether or not a global mode is active. + // The compensation must mirror the Z-shift actually applied, which + // is max(0, -m_belt_min_z): when the transformed mesh starts ABOVE + // slicer Z=0 (m_belt_min_z > 0 — possible for counter-rotated or + // asymmetric geometry whose centered-frame minimum lands positive) + // no lift was applied, and an unclamped m_belt_min_z here would + // leak straight into the layer Z values, floating the whole object + // off the belt by exactly that amount. + double belt_z_shift = std::min(m_belt_min_z, 0.); // the belt surface is Z=0 in centered slicer space + double global_z_offset = belt_z_shift; + + // Centering correction: trafo_centered pretranslates by + // -m_center_offset.{x,y}. Under the belt forward transform, the + // Y component of that pretranslate couples into slicer-Z (shear: + // tan*c.y, rotation: sin*c.y). BeltBackTransform inverts the + // rotation/shear but doesn't undo centering, so this Z component + // leaks into machine output as a position offset whenever + // m_center_offset != 0. When a user moves a volume within an + // assembly such that the combined bbox center shifts, this shows + // up as a small Z translation in the print. Compensate by adding + // the Z component of the centering through the forward transform. + { + Transform3d T_fwd = BeltTransformPipeline::build_forward_transform(pcfg); + Vec3d c_off(unscale(m_center_offset.x()), + unscale(m_center_offset.y()), + 0.); + double centering_z_corr = (T_fwd.linear() * c_off).z(); + global_z_offset += centering_z_corr; + } + + { + // Global pre-slice mode: compute full correction c = (T.linear() - I) * d + // where T is the belt forward transform and d is the bed position, so + // objects at different bed positions print at different machine Z values + // along the inclined belt. + Transform3d T = BeltTransformPipeline::build_forward_transform(pcfg); + Vec3d d(unscale(inst_shift.x()), unscale(inst_shift.y()), 0.); + Vec3d c = T.linear() * d - d; + global_z_offset += c.z(); + + BOOST_LOG_TRIVIAL(trace) << "Belt preslice_global: correction=(" + << c.x() << ", " << c.y() << ", " << c.z() << ")" + << " belt_z_shift=" << belt_z_shift << " (m_belt_min_z=" << m_belt_min_z << ")"; + } + + BOOST_LOG_TRIVIAL(trace) << "Belt global: z_offset=" << global_z_offset + << " (" << this->print()->objects().size() << " objects on the plate)"; + m_belt_global_z_offset = global_z_offset; + if (std::abs(global_z_offset) > EPSILON) { + for (Layer *layer : m_layers) + layer->print_z += global_z_offset; + // Keep belt floor clipping in sync with the shifted print_z + // values — the support generator sees globally-offset object + // layer print_z, so belt_floor_z_shift must match. + m_slicing_params.belt_floor_z_shift += global_z_offset; + } + if (!m_layers.empty()) { + BOOST_LOG_TRIVIAL(trace) << "Belt global: first_layer_z=" << m_layers.front()->print_z + << " last_layer_z=" << m_layers.back()->print_z + << " num_layers=" << m_layers.size() + << " center_offset=(" << unscale(m_center_offset.x()) + << ", " << unscale(m_center_offset.y()) << ")"; + } + + // Cache the final patched belt_floor_z_shift so a later support-only + // invalidation can rebuild m_slicing_params without losing this exact + // (vertex-scan-derived) value. update_slicing_parameters() will + // restore it after create_from_config() seeds the bbox approximation. + m_belt_floor_z_shift_cached = m_slicing_params.belt_floor_z_shift; + m_belt_floor_z_shift_cache_valid = true; + } + } + // BBS this->set_done(posSlice); } @@ -1172,6 +1328,7 @@ void apply_fuzzy_skin_segmentation(PrintObject &print_object, ThrowOnCancel thro }); // end of parallel_for } + // 1) Decides Z positions of the layers, // 2) Initializes layers and their regions // 3) Slices the object meshes @@ -1205,7 +1362,8 @@ void PrintObject::slice_volumes() if (!slice_zs.empty()) { objSliceByVolume = slice_volumes_inner( print->config(), this->config(), this->trafo_centered(), - this->model_object()->volumes, m_shared_regions->layer_ranges, slice_zs, throw_on_cancel_callback); + this->model_object()->volumes, m_shared_regions->layer_ranges, slice_zs, throw_on_cancel_callback, + &m_belt_min_z); } //BBS: "model_part" volumes are grouded according to their connections @@ -1577,6 +1735,13 @@ ExPolygons PrintObject::_shrink_contour_holes(double contour_delta, double hole_ return union_ex(new_ex_polys); } +Transform3d PrintObject::trafo_sliced() const +{ + Transform3d trafo = this->trafo_centered(); + BeltSliceStrategy::apply_preslice_transforms(trafo, this->print()->config(), this->model_object()->volumes); + return trafo; +} + std::vector PrintObject::slice_support_volumes(const ModelVolumeType model_volume_type) const { auto it_volume = this->model_object()->volumes.begin(); @@ -1591,7 +1756,7 @@ std::vector PrintObject::slice_support_volumes(const ModelVolumeType m const Print *print = this->print(); auto throw_on_cancel_callback = std::function([print](){ print->throw_if_canceled(); }); MeshSlicingParamsEx params; - params.trafo = this->trafo_centered(); + params.trafo = this->trafo_sliced(); for (; it_volume != it_volume_end; ++ it_volume) if ((*it_volume)->type() == model_volume_type) { std::vector slices2 = slice_volume(*(*it_volume), zs, params, throw_on_cancel_callback); @@ -1634,10 +1799,11 @@ std::vector PrintObject::slice_single_volume_regions(const ModelVolu { if (volume == nullptr) return {}; - // Match the existing slicing heights and centered transform without flattening holes. + // Match the existing slicing heights and the frame the layers were sliced in (belt + // pre-slice transforms included) without flattening holes. const std::vector zs = zs_from_layers(this->layers()); MeshSlicingParamsEx params; - params.trafo = this->trafo_centered(); + params.trafo = this->trafo_sliced(); const Print *print = this->print(); return slice_volume(*volume, zs, params, [print]() { print->throw_if_canceled(); }); } diff --git a/src/libslic3r/Slicing.hpp b/src/libslic3r/Slicing.hpp index 59ae27c8dd..a9bb4937ea 100644 --- a/src/libslic3r/Slicing.hpp +++ b/src/libslic3r/Slicing.hpp @@ -113,6 +113,13 @@ struct SlicingParameters coordf_t object_print_z_uncompensated_max { 0 }; // Scaling factor for compensating shrinkage in Z-axis. coordf_t object_shrinkage_compensation_z { 0 }; + + // Belt printer: floor plane parameters for support clipping. + // Belt contact surface in slicing coords: Z = bb_min_z + sf*Y + slicing_z_shift. + // cutoff = (print_z - belt_floor_z_shift - floor_offset) / shear_factor + double belt_floor_shear_factor { 0.0 }; // shear factor (e.g. cot(45deg)) + int belt_floor_from_axis { 1 }; // which axis the shear is from (0=X, 1=Y) + double belt_floor_z_shift { 0.0 }; // bb_min_z + max(0, -min_z_after_shear) }; static_assert(IsTriviallyCopyable::value, "SlicingParameters class is not POD (and it should be - see constructor)."); diff --git a/src/libslic3r/Support/BeltFloorContext.cpp b/src/libslic3r/Support/BeltFloorContext.cpp new file mode 100644 index 0000000000..26078413dd --- /dev/null +++ b/src/libslic3r/Support/BeltFloorContext.cpp @@ -0,0 +1,132 @@ +#include "BeltFloorContext.hpp" +#include "../Point.hpp" +#include "../Polygon.hpp" +#include "../PrintConfig.hpp" +#include "../Slicing.hpp" +#include "../libslic3r.h" + +#include +#include +#include +#include +#include + +namespace Slic3r { + +bool BeltFloorContext::init(const SlicingParameters &sp, const PrintConfig &pcfg) +{ + m_active = false; + m_shear_factor = sp.belt_floor_shear_factor; + m_from_axis = sp.belt_floor_from_axis; + m_z_shift = sp.belt_floor_z_shift; + m_floor_offset = pcfg.belt_support_floor_offset.value; + + if (std::abs(m_shear_factor) < EPSILON) + return false; + + m_active = true; + return true; +} + +bool BeltFloorContext::init_local(const SlicingParameters &sp, const PrintConfig &pcfg, + double global_z_offset) +{ + if (!init(sp, pcfg)) + return false; + // Local Z: subtract the global Z offset so polygon computation + // works in the object's local coordinate space. + m_z_shift -= global_z_offset; + return true; +} + +Polygons BeltFloorContext::surface_polygon(coordf_t print_z) const +{ + return half_plane(print_z, /*belt_surface=*/true); +} + +Polygons BeltFloorContext::valid_region_polygon(coordf_t print_z) const +{ + return half_plane(print_z, /*belt_surface=*/false); +} + +double BeltFloorContext::floor_print_z(const Point &pos_slicing) const +{ + if (!m_active) + return -std::numeric_limits::infinity(); + double pos = unscale(m_from_axis == 0 ? pos_slicing.x() : pos_slicing.y()); + return m_shear_factor * pos + m_floor_offset + m_z_shift; +} + +std::vector BeltFloorContext::compute_per_layer_floors( + size_t num_layers, + const std::function &layer_print_z) const +{ + std::vector result(num_layers); + if (!m_active) + return result; + for (size_t i = 0; i < num_layers; ++i) + result[i] = surface_polygon(layer_print_z(i)); + return result; +} + +Polygons BeltFloorContext::half_plane(coordf_t print_z, bool belt_surface) const +{ + if (!m_active) + return {}; + + const double cutoff = this->cutoff_u(print_z); + const coord_t cutoff_scaled = scale_(cutoff); + const coord_t large_bound = scale_(1e3); + + // The belt surface is on one side of the cutoff line; the valid region + // is on the other side. Which side depends on shear_factor sign. + // + // belt_surface=true → the belt side (where support should NOT exist) + // belt_surface=false → the valid side (where support IS allowed) + // + // For shear_factor > 0: belt surface is from_axis >= cutoff + // For shear_factor < 0: belt surface is from_axis <= cutoff + bool high_side = (m_shear_factor > 0) == belt_surface; + + Polygon poly; + if (m_from_axis == 0) { + if (high_side) { + // X >= cutoff + poly.points = { + Point(cutoff_scaled, -large_bound), + Point(large_bound, -large_bound), + Point(large_bound, large_bound), + Point(cutoff_scaled, large_bound) + }; + } else { + // X < cutoff + poly.points = { + Point(-large_bound, -large_bound), + Point(cutoff_scaled, -large_bound), + Point(cutoff_scaled, large_bound), + Point(-large_bound, large_bound) + }; + } + } else { + if (high_side) { + // Y >= cutoff + poly.points = { + Point(-large_bound, cutoff_scaled), + Point( large_bound, cutoff_scaled), + Point( large_bound, large_bound), + Point(-large_bound, large_bound) + }; + } else { + // Y < cutoff + poly.points = { + Point(-large_bound, -large_bound), + Point( large_bound, -large_bound), + Point( large_bound, cutoff_scaled), + Point(-large_bound, cutoff_scaled) + }; + } + } + return { poly }; +} + +} // namespace Slic3r diff --git a/src/libslic3r/Support/BeltFloorContext.hpp b/src/libslic3r/Support/BeltFloorContext.hpp new file mode 100644 index 0000000000..629dfce4b6 --- /dev/null +++ b/src/libslic3r/Support/BeltFloorContext.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include "../libslic3r.h" +#include "../Point.hpp" +#include "../Polygon.hpp" +#include "../Slicing.hpp" +#include "../PrintConfig.hpp" +#include +#include +#include + +namespace Slic3r { + +class PrintObject; + +// Belt floor context: encapsulates the parameters and polygon computation +// for belt printer floor clipping in support generation. +// +// All belt floor code across SupportMaterial, TreeSupport, TreeSupport3D, +// and TreeModelVolumes uses the same 4 parameters and the same 4-case +// polygon construction. This class consolidates that logic. +// +// Construct once per PrintObject, then call surface_polygon() or +// valid_region_polygon() per layer with the layer's print_z. +class BeltFloorContext +{ +public: + BeltFloorContext() = default; + + // Initialize from slicing parameters and print config. + // Uses global Z coordinates (for SupportMaterial, non-organic TreeSupport). + bool init(const SlicingParameters &sp, const PrintConfig &pcfg); + + // Initialize with a Z offset subtracted from z_shift. + // Uses local Z coordinates (for TreeSupport3D, TreeModelVolumes organic pipeline). + bool init_local(const SlicingParameters &sp, const PrintConfig &pcfg, + double global_z_offset); + + bool is_active() const { return m_active; } + + // Compute the belt-side half-plane polygon at a given print_z. + // This is the region where the belt surface exists. + Polygons surface_polygon(coordf_t print_z) const; + + // Compute the valid-region half-plane polygon at a given print_z. + // This is the complement: the region where support is allowed. + Polygons valid_region_polygon(coordf_t print_z) const; + + // Compute the belt floor Z position at a given XY position (in slicing coords). + // 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( + size_t num_layers, + const std::function &layer_print_z) const; + + // Accessors + double shear_factor() const { return m_shear_factor; } + int from_axis() const { return m_from_axis; } + double z_shift() const { return m_z_shift; } + double floor_offset() const { return m_floor_offset; } + +private: + bool m_active = false; + double m_shear_factor = 0.0; + int m_from_axis = 1; // 0=X, 1=Y + double m_z_shift = 0.0; + double m_floor_offset = 0.0; + + // Internal: compute the raw half-plane polygon. + // If belt_surface=true, returns the belt side; otherwise the valid (complement) side. + Polygons half_plane(coordf_t print_z, bool belt_surface) const; +}; + +} // namespace Slic3r diff --git a/src/libslic3r/Support/SupportCommon.cpp b/src/libslic3r/Support/SupportCommon.cpp index 59463b4cc6..481213fe48 100644 --- a/src/libslic3r/Support/SupportCommon.cpp +++ b/src/libslic3r/Support/SupportCommon.cpp @@ -290,7 +290,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); @@ -318,7 +320,13 @@ SupportGeneratorLayersPtr generate_raft_base( // How much to inflate the support columns to be stable. This also applies to the 1st layer, if no raft layers are to be printed. const float inflate_factor_fine = float(scale_((slicing_params.raft_layers() > 1) ? 0.5 : EPSILON)); - const float inflate_factor_1st_layer = std::max(0.f, float(scale_(object.config().raft_first_layer_expansion)) - inflate_factor_fine); + // On a belt the first support layer is the leading tip of the support, a sliver + // where the belt crosses the layer, not a flange on a flat bed: inflating it + // puts lines in the air ahead of the belt crossing (and into the belt behind + // it). The belt brim takes the adhesion role instead. + const bool belt_floor_active = std::abs(slicing_params.belt_floor_shear_factor) > EPSILON; + const float inflate_factor_1st_layer = belt_floor_active ? 0.f : + std::max(0.f, float(scale_(object.config().raft_first_layer_expansion)) - inflate_factor_fine); SupportGeneratorLayer *contacts = top_contacts .empty() ? nullptr : top_contacts .front(); SupportGeneratorLayer *interfaces = interface_layers .empty() ? nullptr : interface_layers .front(); SupportGeneratorLayer *base_interfaces = base_interface_layers.empty() ? nullptr : base_interface_layers.front(); @@ -1795,7 +1803,14 @@ void generate_support_toolpaths( bool sheath = support_params.with_sheath; bool no_sort = false; bool done = false; - if (base_layer.layer->bottom_z < EPSILON) { + // Belt printers have no flat bed first layer — the belt is the tilted + // build surface — so the dense raft_first_layer_density flange must not + // fire anywhere, including the layer at z=0 (the belt-surface line). + // (belt_floor_shear_factor is non-zero only when belt_printer is on.) + // For every other printer type, support z is never negative, so this + // matches the original "first layer at z=0" behaviour unchanged. + const bool is_belt_printer = std::abs(slicing_params.belt_floor_shear_factor) > EPSILON; + if (! is_belt_printer && base_layer.layer->bottom_z < EPSILON) { // Base flange (the 1st layer). filler = filler_first_layer; filler->angle = Geometry::deg2rad(float(config.support_angle.value + 90.)); @@ -2053,4 +2068,10 @@ sub clip_with_shape { } */ +Vec2d build_plate_tilt_slope(const PrintConfig &print_config) +{ + auto slope = [](double tilt_deg) { return std::tan(Geometry::deg2rad(std::clamp(tilt_deg, -89., 89.))); }; + return { slope(print_config.build_plate_tilt_y.value), slope(print_config.build_plate_tilt_x.value) }; +} + } // namespace Slic3r diff --git a/src/libslic3r/Support/SupportCommon.hpp b/src/libslic3r/Support/SupportCommon.hpp index c32efd4697..ca91ecc5ae 100644 --- a/src/libslic3r/Support/SupportCommon.hpp +++ b/src/libslic3r/Support/SupportCommon.hpp @@ -7,6 +7,8 @@ #include "libslic3r/ExtrusionEntity.hpp" #include "SupportLayer.hpp" #include "SupportParameters.hpp" +#include "../Point.hpp" +#include "../libslic3r.h" #include #include #include @@ -152,6 +154,16 @@ int idx_lower_or_equal(const std::vector &vec, int idx, FN_LOWER_EQUAL fn_lo return idx_lower_or_equal(vec.begin(), vec.end(), idx, fn_lower_equal); } +// Belt floor: compute the belt-side half-plane polygon at a given print_z. +// Used to clip support polygons against the belt surface. +Polygons belt_floor_surface_polygon( + const SlicingParameters &slicing_params, const PrintConfig &print_config, + const PrintObject &object, coordf_t print_z); + +// Build plate tilt: XY drift of gravity per unit of layer height, zero on a level plate. +// The tilt is capped below 90 degrees to keep the drift finite. +Vec2d build_plate_tilt_slope(const PrintConfig &print_config); + } // namespace Slic3r #endif /* slic3r_SupportCommon_hpp_ */ diff --git a/src/libslic3r/Support/SupportMaterial.cpp b/src/libslic3r/Support/SupportMaterial.cpp index cfd259501f..813acecec4 100644 --- a/src/libslic3r/Support/SupportMaterial.cpp +++ b/src/libslic3r/Support/SupportMaterial.cpp @@ -12,6 +12,7 @@ #include "libslic3r/Support/SupportLayer.hpp" #include "libslic3r/PrintConfig.hpp" #include "SupportCommon.hpp" +#include "BeltFloorContext.hpp" #include "Geometry.hpp" #include "Point.hpp" #include "MutablePolygon.hpp" @@ -397,10 +398,21 @@ inline void layers_append(SupportGeneratorLayersPtr &dst, const SupportGenerator } // Support layer that is covered by some form of dense interface. -static constexpr const std::initializer_list support_types_interface { +static constexpr const std::initializer_list support_types_interface { SupporLayerType::RaftInterface, SupporLayerType::BottomContact, SupporLayerType::BottomInterface, SupporLayerType::TopContact, SupporLayerType::TopInterface }; +// Forward declarations for belt floor helpers (defined later in this file). +// belt_floor_surface_polygon is declared in SupportCommon.hpp (non-static, +// shared with TreeSupport.cpp). + +static Polygons belt_floor_valid_region_polygon( + const SlicingParameters &slicing_params, const PrintConfig &print_config, + const PrintObject &object, coordf_t print_z); +static void trim_support_layers_by_belt_floor( + const SlicingParameters &slicing_params, const PrintConfig &print_config, + const PrintObject &object, SupportGeneratorLayersPtr &support_layers); + void PrintObjectSupportMaterial::generate(PrintObject &object) { BOOST_LOG_TRIVIAL(info) << "Support generator - Start"; @@ -473,11 +485,12 @@ void PrintObjectSupportMaterial::generate(PrintObject &object) object, bottom_contacts, top_contacts, layer_storage); this->trim_support_layers_by_object(object, top_contacts, m_slicing_params.gap_support_object, m_slicing_params.gap_object_support, m_support_params.gap_xy); + trim_support_layers_by_belt_floor(m_slicing_params, *m_print_config, object, top_contacts); #ifdef SLIC3R_DEBUG for (const SupportGeneratorLayer *layer : top_contacts) Slic3r::SVG::export_expolygons( - debug_out_path("support-top-contacts-trimmed-by-object-%d-%lf.svg", iRun, layer->print_z), + debug_out_path("support-top-contacts-trimmed-by-object-%d-%lf.svg", iRun, layer->print_z), union_ex(layer->polygons)); #endif @@ -633,6 +646,37 @@ Polygons collect_region_slices_by_type(const Layer &layer, SurfaceType surface_t return out; } +// Belt printer: compute the belt-side half-plane polygon at a given print_z. +// This represents the region where the belt surface exists (the "phantom top surface"). +// Support that overlaps with this polygon should terminate with a bottom contact. +// Returns empty if belt floor is not active. +Polygons belt_floor_surface_polygon( + const SlicingParameters &slicing_params, + const PrintConfig &print_config, + const PrintObject &object, + coordf_t print_z) +{ + BeltFloorContext ctx; + if (!ctx.init(slicing_params, print_config)) + return {}; + return ctx.surface_polygon(print_z); +} + +// Belt printer: compute the valid-region half-plane polygon at a given print_z. +// This is the region where support is allowed to exist (above the belt). +// Used to clip the downward-propagating support projection. +static Polygons belt_floor_valid_region_polygon( + const SlicingParameters &slicing_params, + const PrintConfig &print_config, + const PrintObject &object, + coordf_t print_z) +{ + BeltFloorContext ctx; + if (!ctx.init(slicing_params, print_config)) + return {}; + return ctx.valid_region_polygon(print_z); +} + // Collect outer contours of all slices of this layer. // This is useful for calculating the support base with holes filled. Polygons collect_slices_outer(const Layer &layer) @@ -1424,6 +1468,9 @@ static inline ExPolygons detect_overhangs( const double threshold_rad = Geometry::deg2rad(thresh_angle); const bool bridge_no_support = object_config.bridge_no_support.value; const coordf_t xy_expansion = scale_(object_config.support_expansion.value); + // Build plate tilt: compute per-layer XY shift for tilted gravity direction + const Vec2d tilt_slope = build_plate_tilt_slope(print_config); + const bool has_tilt = tilt_slope.cwiseAbs().maxCoeff() > EPSILON; float lower_layer_offset = 0; if (layer_id == 0) @@ -1457,6 +1504,16 @@ static inline ExPolygons detect_overhangs( } } + // Apply build plate tilt: shift lower layer polygons to simulate tilted gravity. + // This is loop-invariant across regions, so compute it once here. + const Polygons *effective_lower = &lower_layer_polygons; + Polygons tilted_lower; + if (has_tilt) { + tilted_lower = lower_layer_polygons; + translate(tilted_lower, Point::new_scale(tilt_slope * lower_layer.height)); + effective_lower = &tilted_lower; + } + for (LayerRegion *layerm : layer.regions()) { // Extrusion width accounts for the roundings of the extrudates. // It is the maximum widh of the extrudate. @@ -1475,7 +1532,7 @@ static inline ExPolygons detect_overhangs( Polygons layerm_polygons = to_polygons(layerm->slices.surfaces); if (lower_layer_offset == 0.f) { // Support everything. - diff_polygons = diff(layerm_polygons, lower_layer_polygons); + diff_polygons = diff(layerm_polygons, *effective_lower); if (buildplate_only) { // Don't support overhangs above the top surfaces. // This step is done before the contact surface is calculated by growing the overhang region. @@ -1484,9 +1541,9 @@ static inline ExPolygons detect_overhangs( } else if (auto_normal_support) { // Get the regions needing a suport, collapse very tiny spots. //FIXME cache the lower layer offset if this layer has multiple regions. - diff_polygons = + diff_polygons = diff(layerm_polygons, - expand(lower_layer_polygons, lower_layer_offset, SUPPORT_SURFACES_OFFSET_PARAMETERS)); + expand(*effective_lower, lower_layer_offset, SUPPORT_SURFACES_OFFSET_PARAMETERS)); if (buildplate_only && ! annotations.buildplate_covered[layer_id].empty()) { // Don't support overhangs above the top surfaces. // This step is done before the contact surface is calculated by growing the overhang region. @@ -1494,9 +1551,9 @@ static inline ExPolygons detect_overhangs( } if (! diff_polygons.empty()) { // Offset the support regions back to a full overhang, restrict them to the full overhang. - // This is done to increase size of the supporting columns below, as they are calculated by + // This is done to increase size of the supporting columns below, as they are calculated by // propagating these contact surfaces downwards. - diff_polygons = diff(intersection(expand(diff_polygons, lower_layer_offset, SUPPORT_SURFACES_OFFSET_PARAMETERS), layerm_polygons), lower_layer_polygons); + diff_polygons = diff(intersection(expand(diff_polygons, lower_layer_offset, SUPPORT_SURFACES_OFFSET_PARAMETERS), layerm_polygons), *effective_lower); } //FIXME add user defined filtering here based on minimal area or minimum radius or whatever. @@ -2536,6 +2593,82 @@ static inline SupportGeneratorLayer* detect_bottom_contacts( return &layer_new; } +// Belt printer: detect bottom contacts where support meets the belt floor plane. +// Modeled on detect_bottom_contacts() but uses the belt plane polygon instead of stTop surfaces. +static inline SupportGeneratorLayer* detect_belt_floor_bottom_contacts( + const SlicingParameters &slicing_params, + const SupportParameters &support_params, + const PrintConfig &print_config, + const PrintObject &object, + const Layer &layer, + // Existing top contact layers, for snapping. + const SupportGeneratorLayersPtr &top_contacts, + size_t contact_idx, + SupportGeneratorLayerStorage &layer_storage, + std::vector &layer_support_areas, + const Polygons &supports_projected) +{ + // Compute the belt surface polygon at this layer's Z. + Polygons belt_surface = belt_floor_surface_polygon(slicing_params, print_config, object, layer.print_z); + if (belt_surface.empty()) + return nullptr; + + // Find where projected support overlaps the belt surface. + Polygons touching = intersection(belt_surface, supports_projected); + if (touching.empty()) + return nullptr; + + assert(layer.id() >= slicing_params.raft_layers()); + size_t layer_id = layer.id() - slicing_params.raft_layers(); + + // Allocate a new bottom contact layer resting on the belt plane. + SupportGeneratorLayer &layer_new = layer_storage.allocate_unguarded(SupporLayerType::BottomContact); + + // No object layer to sync with -- compute heights directly from flow parameters. + layer_new.height = support_params.support_material_bottom_interface_flow.height(); + layer_new.print_z = layer.print_z + layer_new.height + slicing_params.gap_object_support; + layer_new.bottom_z = layer.print_z; + layer_new.idx_object_layer_below = layer_id; + layer_new.bridging = ! slicing_params.zero_gap_interface_bottom && object.config().thick_bridges; + layer_new.polygons = expand(touching, float(support_params.support_material_flow.scaled_width()), SUPPORT_SURFACES_OFFSET_PARAMETERS); + + if (! slicing_params.zero_gap_interface_bottom) { + // Snap to nearby top contact layers to avoid very thin support layers. + for (size_t top_idx = size_t(std::max(0, int(contact_idx))); + top_idx < top_contacts.size() && top_contacts[top_idx]->print_z < layer_new.print_z + support_params.support_layer_height_min + EPSILON; + ++ top_idx) { + if (top_contacts[top_idx]->print_z > layer_new.print_z - support_params.support_layer_height_min - EPSILON) { + coordf_t diff = layer_new.print_z - top_contacts[top_idx]->print_z; + assert(std::abs(diff) <= support_params.support_layer_height_min + EPSILON); + if (diff > 0.F) { + if (layer_new.height - diff > support_params.support_layer_height_min) { + layer_new.print_z = top_contacts[top_idx]->print_z; + layer_new.height -= diff; + } else { + continue; + } + } else { + layer_new.print_z = top_contacts[top_idx]->print_z; + layer_new.height -= diff; + } + break; + } + } + } + + // Trim the already created base layers above this belt contact. + touching = expand(touching, float(SCALED_EPSILON)); + for (int layer_id_above = int(layer_id) + 1; layer_id_above < int(object.total_layer_count()); ++ layer_id_above) { + const Layer &layer_above = *object.layers()[layer_id_above]; + if (layer_above.print_z > layer_new.print_z - EPSILON) + break; + if (Polygons &above = layer_support_areas[layer_id_above]; ! above.empty()) + above = diff(above, touching); + } + + return &layer_new; +} + // Returns polygons to print + polygons to propagate downwards. // Called twice: First for normal supports, possibly trimmed by "on build plate only", second for support enforcers not trimmed by "on build plate only". static inline std::pair project_support_to_grid(const Layer &layer, const SupportGridParams &grid_params, const Polygons &overhangs, Polygons *layer_buildplate_covered @@ -2635,6 +2768,7 @@ SupportGeneratorLayersPtr PrintObjectSupportMaterial::bottom_contact_layers_and_ //const auto expansion_to_slice = m_support_material_flow.scaled_spacing() / 2 + 25; const SupportGridParams grid_params(*m_object_config, m_support_params.support_material_flow); const bool buildplate_only = ! buildplate_covered.empty(); + const bool has_belt_floor = std::abs(m_slicing_params.belt_floor_shear_factor) > EPSILON; // Allocate empty surface areas, one per object layer. layer_support_areas.assign(object.total_layer_count(), Polygons()); @@ -2695,8 +2829,9 @@ SupportGeneratorLayersPtr PrintObjectSupportMaterial::bottom_contact_layers_and_ tbb::task_group task_group; const Polygons &overhangs_for_bottom_contacts = buildplate_only ? enforcers_projection_raw : overhangs_projection_raw; if (! overhangs_for_bottom_contacts.empty()) - // Find the bottom contact layers above the top surfaces of this layer. - task_group.run([this, &object, &layer, &top_contacts, contact_idx, &layer_storage, &layer_support_areas, &bottom_contacts, &overhangs_for_bottom_contacts + // Find the bottom contact layers above the top surfaces of this layer, + // and also detect belt floor contacts if belt mode is active. + task_group.run([this, &object, &layer, &top_contacts, contact_idx, &layer_storage, &layer_support_areas, &bottom_contacts, &overhangs_for_bottom_contacts, has_belt_floor #ifdef SLIC3R_DEBUG , iRun, &polygons_new #endif // SLIC3R_DEBUG @@ -2710,6 +2845,15 @@ SupportGeneratorLayersPtr PrintObjectSupportMaterial::bottom_contact_layers_and_ ); if (layer_new) bottom_contacts.push_back(layer_new); + // Belt floor phantom surface: detect where support meets the belt plane. + if (has_belt_floor) { + SupportGeneratorLayer *belt_layer = detect_belt_floor_bottom_contacts( + m_slicing_params, m_support_params, *m_print_config, object, + layer, top_contacts, contact_idx, layer_storage, + layer_support_areas, overhangs_for_bottom_contacts); + if (belt_layer) + bottom_contacts.push_back(belt_layer); + } }); Polygons &layer_support_area = layer_support_areas[layer_id]; @@ -2748,6 +2892,23 @@ SupportGeneratorLayersPtr PrintObjectSupportMaterial::bottom_contact_layers_and_ task_group.wait(); + // Belt floor: clip projections and support areas so support doesn't + // propagate below the belt plane. + if (has_belt_floor) { + Polygons valid_region = belt_floor_valid_region_polygon( + m_slicing_params, *m_print_config, object, layer.print_z); + if (! valid_region.empty()) { + if (! overhangs_projection.empty()) + overhangs_projection = intersection(overhangs_projection, valid_region); + if (! enforcers_projection.empty()) + enforcers_projection = intersection(enforcers_projection, valid_region); + if (! layer_support_area.empty()) + layer_support_area = intersection(layer_support_area, valid_region); + if (! layer_support_area_enforcers.empty()) + layer_support_area_enforcers = intersection(layer_support_area_enforcers, valid_region); + } + } + if (! layer_support_area_enforcers.empty()) { if (layer_support_area.empty()) layer_support_area = std::move(layer_support_area_enforcers); @@ -2758,6 +2919,7 @@ SupportGeneratorLayersPtr PrintObjectSupportMaterial::bottom_contact_layers_and_ std::reverse(bottom_contacts.begin(), bottom_contacts.end()); trim_support_layers_by_object(object, bottom_contacts, m_slicing_params.gap_support_object, m_slicing_params.gap_object_support, m_support_params.gap_xy); + trim_support_layers_by_belt_floor(m_slicing_params, *m_print_config, object, bottom_contacts); return bottom_contacts; } @@ -3131,6 +3293,31 @@ void PrintObjectSupportMaterial::generate_base_layers( #endif /* SLIC3R_DEBUG */ this->trim_support_layers_by_object(object, intermediate_layers, m_slicing_params.gap_support_object, m_slicing_params.gap_object_support, m_support_params.gap_xy); + trim_support_layers_by_belt_floor(m_slicing_params, *m_print_config, object, intermediate_layers); +} + +// Belt printer: trim support layer polygons by the belt floor plane. +// For each support layer, computes the belt floor half-plane at that layer's print_z +// and subtracts it from the support polygons. This follows the same diff() pattern +// as trim_support_layers_by_object() so that interface layers derived from trimmed +// intermediates automatically inherit the belt floor trimming. +static void trim_support_layers_by_belt_floor( + const SlicingParameters &slicing_params, + const PrintConfig &print_config, + const PrintObject &object, + SupportGeneratorLayersPtr &support_layers) +{ + BeltFloorContext ctx; + if (!ctx.init(slicing_params, print_config)) + return; + + tbb::parallel_for(tbb::blocked_range(0, support_layers.size()), + [&](const tbb::blocked_range &range) { + for (size_t i = range.begin(); i < range.end(); ++i) + if (support_layers[i]) + support_layers[i]->polygons = diff(support_layers[i]->polygons, + ctx.surface_polygon(support_layers[i]->print_z)); + }); } void PrintObjectSupportMaterial::trim_support_layers_by_object( diff --git a/src/libslic3r/Support/SupportSpotsGenerator.cpp b/src/libslic3r/Support/SupportSpotsGenerator.cpp index 740521df78..539c174f91 100644 --- a/src/libslic3r/Support/SupportSpotsGenerator.cpp +++ b/src/libslic3r/Support/SupportSpotsGenerator.cpp @@ -752,7 +752,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/libslic3r/Support/TreeModelVolumes.cpp b/src/libslic3r/Support/TreeModelVolumes.cpp index d1d1b4d8cc..33d379c2b5 100644 --- a/src/libslic3r/Support/TreeModelVolumes.cpp +++ b/src/libslic3r/Support/TreeModelVolumes.cpp @@ -10,6 +10,7 @@ #include "libslic3r/Polygon.hpp" #include "libslic3r/ExPolygon.hpp" #include "TreeSupportCommon.hpp" +#include "BeltFloorContext.hpp" #include "../BuildVolume.hpp" #include "../ClipperUtils.hpp" @@ -19,6 +20,7 @@ #include "../Utils.hpp" #include "../format.hpp" #include "libslic3r/libslic3r.h" +#include "../PrintConfig.hpp" #include #include @@ -115,6 +117,21 @@ TreeModelVolumes::TreeModelVolumes( m_increase_until_radius = config.increase_radius_until_radius; m_radius_0 = config.getRadius(0); m_raft_layers = config.raft_layers; + // Support blockers are consumed in the same index space as m_layer_outlines + // (object layer i lives at index num_raft_layers + i), but + // slice_support_blockers() returns them in object-layer space. Shift them. + // + // The belt surface is deliberately NOT a blocker. A blocker is a collision, + // and a branch descending onto a collision slides off it: on a belt that + // walks the branch down the tilted surface, ahead of the part, until it + // reaches the bottom layer floating in mid-air. The belt is where branches + // END: organic_draw_branches() clips their slices with m_belt_floor and the + // first clipped slice is the contact. + { + const size_t num_raft = m_raft_layers.size(); + if (num_raft > 0 && ! m_anti_overhang.empty()) + m_anti_overhang.insert(m_anti_overhang.begin(), num_raft, Polygons{}); + } m_current_outline_idx = 0; m_layer_outlines.emplace_back(mesh_settings, std::vector{}); @@ -127,6 +144,32 @@ TreeModelVolumes::TreeModelVolumes( for (size_t layer_idx = range.begin(); layer_idx < range.end(); ++ layer_idx) outlines[layer_idx] = polygons_simplify(to_polygons(print_object.get_layer(layer_idx - num_raft_layers)->lslices), mesh_settings.resolution); }); + + // Belt floor: pre-compute belt surface polygon per-layer for clipping. + // Branches grow toward the belt and their slices are clipped at the belt + // surface in organic_draw_branches(). The organic pipeline works in LOCAL + // Z (no global_z_offset), so use local z_shift and local print_z. + { + const auto &slicing_params = print_object.slicing_parameters(); + const auto &pcfg2 = print_object.print()->config(); + BeltFloorContext ctx; + ctx.init_local(slicing_params, pcfg2, print_object.belt_global_z_offset()); + if (ctx.is_active()) { + m_belt_floor = ctx.compute_per_layer_floors(num_layers, [&](size_t layer_idx) -> double { + // Object layers: local print_z (subtract global offset). + if (layer_idx >= num_raft_layers) + return print_object.get_layer(layer_idx - num_raft_layers)->print_z + - print_object.belt_global_z_offset(); + // Belt raft layers (below the object): each carries its own + // local print_z in m_raft_layers. The belt floor is a tilted + // plane, so the half-plane to clip grows as print_z drops — + // using 0 here clipped every below-object layer against the + // Z=0 belt surface, under-clipping the raft region and leaving + // a dense support mass below the floor. + return (layer_idx < m_raft_layers.size()) ? m_raft_layers[layer_idx] : 0.; + }); + } + } } #endif @@ -484,7 +527,7 @@ void TreeModelVolumes::calculateCollision(const coord_t radius, const LayerIndex // 2) Sum over top / bottom ranges. const bool processing_last_mesh = outline_idx == layer_outline_indices.back(); tbb::parallel_for(tbb::blocked_range(data.begin(), data.end()), - [&collision_areas_offsetted, &outlines, &machine_border = m_machine_border, &anti_overhang = m_anti_overhang, radius, + [&collision_areas_offsetted, &outlines, &machine_border = m_machine_border, &anti_overhang = m_anti_overhang, radius, xy_distance, z_distance_bottom_layers, z_distance_top_layers, min_resolution = m_min_resolution, &data, processing_last_mesh, &throw_on_cancel] (const tbb::blocked_range& range) { for (LayerIndex layer_idx = range.begin(); layer_idx != range.end(); ++ layer_idx) { @@ -530,9 +573,14 @@ void TreeModelVolumes::calculateCollision(const coord_t radius, const LayerIndex // not support an overhang<90 degree than to risk fusing to it. append(collisions, offset(union_ex(collision_areas_original), radius + required_range_x, jtMiter, 1.2)); } - collisions = processing_last_mesh && layer_idx < int(anti_overhang.size()) ? - union_(collisions, offset(union_ex(anti_overhang[layer_idx]), radius, jtMiter, 1.2)) : - union_(collisions); + if (processing_last_mesh) { + if (layer_idx < int(anti_overhang.size())) + append(collisions, offset(union_ex(anti_overhang[layer_idx]), radius, jtMiter, 1.2)); + // NOTE: m_belt_floor is NOT added to collision here — branches + // should grow toward the belt and terminate at it, not avoid it. + // Belt floor clipping is done post-generation in organic_draw_branches(). + } + collisions = union_(collisions); auto &dst = data[layer_idx]; if (processing_last_mesh) { if (! dst.empty()) diff --git a/src/libslic3r/Support/TreeModelVolumes.hpp b/src/libslic3r/Support/TreeModelVolumes.hpp index 36f5de9fd4..716ef90c9b 100644 --- a/src/libslic3r/Support/TreeModelVolumes.hpp +++ b/src/libslic3r/Support/TreeModelVolumes.hpp @@ -176,6 +176,9 @@ public: } Polygon m_bed_area; + // Belt floor polygons per layer — used for post-generation clipping + // in organic_draw_branches(). Public so the organic pipeline can access it. + std::vector m_belt_floor; private: // Caching polygons for a range of layers. diff --git a/src/libslic3r/Support/TreeSupport.cpp b/src/libslic3r/Support/TreeSupport.cpp index ada9310dda..b1d0e0d1e8 100644 --- a/src/libslic3r/Support/TreeSupport.cpp +++ b/src/libslic3r/Support/TreeSupport.cpp @@ -38,6 +38,7 @@ #include "TreeSupportCommon.hpp" #include "TreeSupport.hpp" #include "TreeSupport3D.hpp" +#include "BeltFloorContext.hpp" #include "libslic3r/libslic3r.h" #include #include @@ -707,6 +708,33 @@ TreeSupport::TreeSupport(PrintObject& object, const SlicingParameters &slicing_p } +double TreeSupport::belt_floor_print_z(const Point &pos_slicing) const +{ + BeltFloorContext ctx; + if (!ctx.init(m_slicing_params, *m_print_config)) + return -std::numeric_limits::infinity(); + return ctx.floor_print_z(pos_slicing); +} + +bool TreeSupport::belt_node_landed(const Point &pos_slicing, double radius, double print_z) const +{ + BeltFloorContext ctx; + if (!ctx.init(m_slicing_params, *m_print_config)) + return false; + return print_z <= ctx.floor_print_z(pos_slicing) - std::abs(ctx.shear_factor()) * std::max(0., radius); +} + +bool TreeSupport::belt_polygon_landed(const ExPolygon &poly, double print_z) const +{ + BeltFloorContext ctx; + if (!ctx.init(m_slicing_params, *m_print_config)) + return false; + double min_floor = std::numeric_limits::max(); + for (const Point &pt : poly.contour.points) + min_floor = std::min(min_floor, ctx.floor_print_z(pt)); + return print_z <= min_floor; +} + #define SUPPORT_SURFACES_OFFSET_PARAMETERS jtSquare, 0. void TreeSupport::detect_overhangs(bool check_support_necessity/* = false*/) { @@ -741,6 +769,21 @@ void TreeSupport::detect_overhangs(bool check_support_necessity/* = false*/) double thresh_angle = config.support_threshold_angle.value > EPSILON ? config.support_threshold_angle.value + 1 : 30; thresh_angle = std::min(thresh_angle, 89.); // should be smaller than 90 const double threshold_rad = Geometry::deg2rad(thresh_angle); + // Build plate tilt: compute per-layer XY shift for tilted gravity direction + const PrintConfig& print_cfg = m_object->print()->config(); + const Vec2d tilt_slope = build_plate_tilt_slope(print_cfg); + const bool has_tilt = tilt_slope.cwiseAbs().maxCoeff() > EPSILON; + + // Belt printers: the object is pre-rotated by the belt angle before slicing, so a wall + // that is vertical in the world advances by one layer height per layer in the sliced + // frame. The build-plate tilt shift above compensates for that, but its direction has to + // follow the belt shear -- the sign and axis are already known exactly from the slicing + // parameters, so take them from there rather than from tan(build_plate_tilt), which + // carries a magnitude but no direction. Non-belt tilted beds keep the existing behaviour. + BeltFloorContext ovh_belt_ctx; + const bool belt_ovh_active = ovh_belt_ctx.init(m_slicing_params, print_cfg); + const double belt_shear = ovh_belt_ctx.shear_factor(); + const int belt_axis = ovh_belt_ctx.from_axis(); // FIXME this is a fudge constant! double support_tree_tip_diameter = 0.8; auto enforcer_overhang_offset = scaled(support_tree_tip_diameter); @@ -883,8 +926,64 @@ void TreeSupport::detect_overhangs(bool check_support_necessity/* = false*/) ExPolygons& curr_polys = layer->lslices_extrudable; ExPolygons& lower_polys = lower_layer->lslices_extrudable; + // Apply build plate tilt: shift lower layer polygons to simulate tilted gravity + // + // On a belt the object's very first slice can come out empty (the bottom + // vertex is a sub-extrudable sliver), leaving the layer above it with an + // empty predecessor even though it rests on the belt. That case needs no + // special handling here: the belt surface is unioned into effective_lower + // below and sampled at the bottom of the layer, so a contacting island is + // covered and a genuinely floating one still reports its overhang. Doing it + // that way keeps the decision per-island -- an earlier whole-layer skip, + // conditioned on the nearest point of the *union* of the cross-section, + // let one contacting island silence a separate floating one. + ExPolygons shifted_lower; + if (belt_ovh_active || has_tilt) { + shifted_lower = lower_polys; // copy + const double lh = lower_layer->height; + Point tilt_shift(0, 0); + if (belt_ovh_active) { + // Advance the lower layer along the belt by exactly the amount a + // world-vertical wall moves per layer, so such a wall stops reading + // as an overhang. Sign comes from the shear, not from a tilt angle. + const coord_t d = coord_t(-scale_(lh * belt_shear)); + if (belt_axis == 0) tilt_shift.x() = d; else tilt_shift.y() = d; + } else { + tilt_shift = Point::new_scale(tilt_slope * lh); + } + translate(shifted_lower, tilt_shift); + } + ExPolygons effective_lower = (belt_ovh_active || has_tilt) ? shifted_lower : lower_polys; + + // Belt printers: material resting on the belt is held up by the belt, not by + // the layer below it, so the belt surface counts as support from underneath. + // Without this the object's belt-contact face reads as a fresh overhang on + // every layer -- the leading strip that produced the spurious support nub. + if (belt_ovh_active) { + // surface_polygon() is a +/-1000mm half-plane. Unioning that raw with + // 20mm-scale geometry and then offsetting it puts a huge dynamic range + // through Clipper, which left intermittent artefacts every few layers. + // Clip it to the layer's own bounding box first. + // Evaluate the belt surface at the BOTTOM of the layer, not its top: + // a layer meets the belt across its whole thickness, and print_z is the + // top. On the object's first layer -- which is thicker, and whose lower + // layer is empty -- using print_z left the leading 0.37mm uncovered and + // produced the one remaining spurious overhang. + Polygons belt_surface = ovh_belt_ctx.surface_polygon(layer->print_z - layer->height); + if (! belt_surface.empty()) { + BoundingBox clip_bb = get_extents(curr_polys); + clip_bb.merge(get_extents(lower_polys)); + clip_bb.offset(scale_(10.)); + belt_surface = intersection(belt_surface, Polygons{ clip_bb.polygon() }); + if (! belt_surface.empty()) { + append(effective_lower, union_ex(belt_surface)); + effective_lower = union_ex(effective_lower); + } + } + } + // normal overhang - ExPolygons lower_layer_offseted = offset_ex(lower_polys, support_offset_scaled, SUPPORT_SURFACES_OFFSET_PARAMETERS); + ExPolygons lower_layer_offseted = offset_ex(effective_lower, support_offset_scaled, SUPPORT_SURFACES_OFFSET_PARAMETERS); overhangs_all_layers[layer_nr] = diff_ex(curr_polys, lower_layer_offseted); double duration{ std::chrono::duration_cast(clock_::now() - t0).count() }; @@ -900,8 +999,13 @@ void TreeSupport::detect_overhangs(bool check_support_necessity/* = false*/) for (const ExPolygon& expoly : curr_polys) { bool is_sharp_tail = false; // 1. nothing below - // this is a sharp tail region if it's floating and non-ignorable - if (!overlaps(offset_ex(expoly, 0.1 * extrusion_width_scaled), lower_polys)) { + // this is a sharp tail region if it's floating and non-ignorable. + // On a belt, "below" has to include the belt itself and the + // shear-advanced lower layer, or every belt-contact island reads as + // a sharp tail -- which is what the empty-predecessor skip above was + // really masking. effective_lower is exactly that notion of below. + const ExPolygons &tail_lower = belt_ovh_active ? effective_lower : lower_polys; + if (!overlaps(offset_ex(expoly, 0.1 * extrusion_width_scaled), tail_lower)) { is_sharp_tail = !offset_ex(expoly, -0.1 * extrusion_width_scaled).empty(); } @@ -1524,6 +1628,15 @@ void TreeSupport::generate_toolpaths() // ORCA: base angle used for explicit interlaced interface orientation. const float base_support_angle = Geometry::deg2rad(object_config.support_angle.value); + // Belt floor: the lowest support layer rests on the moving, tilted belt, not + // on a flat bed — so it must NOT get the bed first-layer treatment (a brim on + // interface areas, a first-layer-flow sheath at raft_first_layer_density on + // base areas). That treatment draws a loop along the Z=0 belt-floor line that + // reads as a stray brim/skirt. Gate those layer_id==0 special cases off when + // the belt floor is active; false on non-belt printers so behavior is unchanged. + BeltFloorContext belt_ctx; + const bool belt_floor_active = belt_ctx.init(m_slicing_params, *m_print_config); + // generate tree support tool paths tbb::parallel_for( tbb::blocked_range(m_raft_layers, m_object->support_layer_count()), @@ -1559,7 +1672,7 @@ void TreeSupport::generate_toolpaths() filler_interface->angle = m_support_params.support_interface_angle(area_group.interface_id); if (area_group.type != SupportLayer::BaseType) { // interface - if (layer_id == 0) { + if (layer_id == 0 && !belt_floor_active) { Flow flow = m_raft_layers == 0 ? m_object->print()->brim_flow() : support_flow; ExtrusionRole brim_role = (area_group.type == SupportLayer::RoofType && !area_group.interface_as_base) ? erSupportMaterialInterface : erSupportMaterial; @@ -1632,7 +1745,7 @@ void TreeSupport::generate_toolpaths() } else { // base_areas - bool support_base_on_bed = (layer_id == 0 && m_raft_layers == 0); + bool support_base_on_bed = (layer_id == 0 && m_raft_layers == 0 && !belt_floor_active); Flow flow = support_base_on_bed ? m_support_params.first_layer_flow : support_flow; bool need_infill = with_infill; if(m_object_config->support_base_pattern==smpDefault) @@ -1653,7 +1766,7 @@ void TreeSupport::generate_toolpaths() std::unique_ptr base_eec = std::make_unique(); base_eec->no_sort = true; ExtrusionEntitiesPtr &base_dst = base_eec->entities; - if (layer_id == 0) { + if (layer_id == 0 && !belt_floor_active) { float density = float(m_object_config->raft_first_layer_density.value * 0.01); fill_expolygons_with_sheath_generate_paths(base_dst, loops, filler_support.get(), density, erSupportMaterial, flow, m_support_params, true, false); @@ -2059,6 +2172,15 @@ void TreeSupport::draw_circles() coordf_t support_extrusion_width = m_support_params.support_extrusion_width; const float tree_brim_width = config.tree_support_brim_width.value; + // Belt floor: the first object layer is not on a flat bed — it rests on the + // tilted, moving belt. So the first-object-layer adhesion features (the tree + // support brim, the hybrid first-layer base expansion) must be suppressed: + // their expanded contact rings project to a stray brim/skirt loop sitting in + // the Z=0 belt plane around the support footprint. false on non-belt printers, + // so behavior there is unchanged. + BeltFloorContext belt_ctx; + const bool belt_floor_active = belt_ctx.init(m_slicing_params, *m_print_config); + if (m_object->support_layer_count() <= m_raft_layers) return; BOOST_LOG_TRIVIAL(info) << "draw_circles for object: " << m_object->model_object()->name; @@ -2169,7 +2291,7 @@ void TreeSupport::draw_circles() circle.points[i] = circle.points[i] * scale + node.position; } } - if (obj_layer_nr == 0 && m_raft_layers == 0) { + if (obj_layer_nr == 0 && m_raft_layers == 0 && !belt_floor_active) { double brim_width = !config.tree_support_auto_brim ? tree_brim_width : std::max(MIN_BRANCH_RADIUS_FIRST_LAYER, std::min(node.radius + node.dist_mm_to_top / (scale * branch_radius) * 0.5, MAX_BRANCH_RADIUS_FIRST_LAYER) - node.radius); auto tmp=offset(circle, scale_(brim_width)); if(!tmp.empty()) @@ -2239,6 +2361,29 @@ void TreeSupport::draw_circles() base_areas = diff_ex(base_areas, ClipperUtils::clip_clipper_polygons_with_subject_bbox(roofs, get_extents(base_areas))); base_areas = intersection_ex(base_areas, m_machine_border); + // Belt floor: clip tree support polygons by the belt surface plane. + // Non-organic tree support layers inherit their print_z from the + // (already globally-offset) object layers — see plan_layer_heights() + // and add_tree_support_layer(); only ORGANIC layers get the global + // Z offset applied later in _generate_support_material(). So here + // ts_layer->print_z is in the GLOBAL frame and we must use init() + // (global), not init_local(): mixing a local-frame clip plane with + // a global print_z displaces the cutoff line by belt_global_z_offset + // along the shear axis, leaving an un-clipped wedge of support below + // the belt floor. In per-object (non-global) mode belt_global_z_offset + // is 0 so init() and init_local() coincide — this is a no-op there. + { + BeltFloorContext ctx; + if (ctx.init(m_slicing_params, *m_print_config)) { + Polygons belt_surface = ctx.surface_polygon(ts_layer->print_z); + base_areas = diff_ex(base_areas, belt_surface); + roof_areas = diff_ex(roof_areas, belt_surface); + roof_1st_layer = diff_ex(roof_1st_layer, belt_surface); + floor_areas = diff_ex(floor_areas, belt_surface); + roof_gap_areas = diff_ex(roof_gap_areas, belt_surface); + } + } + if (SQUARE_SUPPORT) { // simplify support contours ExPolygons base_areas_simplified; @@ -2371,7 +2516,7 @@ void TreeSupport::draw_circles() // part. area_poly is collected from ePolygon nodes above, which are the normal // support nodes in Hybrid mode. Apply the expansion before area_groups and // lslices are built so toolpaths and brim avoidance use the same footprint. - if (layer_nr == 0 && m_raft_layers == 0 && m_support_params.support_style == smsTreeHybrid && + if (layer_nr == 0 && m_raft_layers == 0 && !belt_floor_active && m_support_params.support_style == smsTreeHybrid && m_object_config->raft_first_layer_expansion.value > 0.f) { ExPolygons expanded_base_areas; const float inflate_factor_1st_layer = float(scale_(m_object_config->raft_first_layer_expansion.value)); @@ -2706,6 +2851,7 @@ void TreeSupport::drop_nodes() const size_t tip_layers = base_radius / layer_height; //The number of layers to be shrinking the circle to create a tip. This produces a 45 degree angle. const coordf_t radius_sample_resolution = m_ts_data->m_radius_sample_resolution; const bool support_on_buildplate_only = config.support_on_build_plate_only.value; + const bool has_belt_floor = std::abs(m_slicing_params.belt_floor_shear_factor) > EPSILON; const size_t bottom_interface_layers = number_of_support_interface_bottom_layers(config); SupportNode::diameter_angle_scale_factor = diameter_angle_scale_factor; float DO_NOT_MOVER_UNDER_MM = is_slim ? 0 : 5; // do not move contact points under 5mm @@ -2941,16 +3087,27 @@ void TreeSupport::drop_nodes() node_parent = p_node->parent ? p_node : neighbour; // Make sure the next pass doesn't drop down either of these (since that already happened). node_parent->merged_neighbours.push_front(node_parent == p_node ? neighbour : p_node); - const bool to_buildplate = !is_inside_ex(get_collision(0, obj_layer_nr_next), next_position); - SupportNode* next_node = m_ts_data->create_node(next_position, node_parent->distance_to_top + 1, obj_layer_nr_next, - node_parent->support_roof_layers_below - (node_parent->distance_to_top >= 0 ? 1 : 0), - to_buildplate, node_parent, print_z_next, height_next); - get_max_move_dist(next_node); - m_ts_data->m_mutex.lock(); - contact_nodes[layer_nr_next].push_back(next_node); - neighbour->valid = false; - p_node->valid = false; - m_ts_data->m_mutex.unlock(); + // Belt floor: a merged node ends once its whole circle is in the belt + // (its slices are clipped to the belt plane in draw_circles(), so it + // tapers to a tip on the belt). Treat as object-surface termination + // (not buildplate) so the node gets floor/interface areas instead of + // base pads. + if (has_belt_floor && belt_node_landed(next_position, std::max(node.radius, neighbour->radius), print_z_next)) { + std::scoped_lock lock(m_ts_data->m_mutex); + node_parent->to_buildplate = false; + neighbour->valid = false; + p_node->valid = false; + } else { + const bool to_buildplate = !is_inside_ex(get_collision(0, obj_layer_nr_next), next_position); + SupportNode* next_node = m_ts_data->create_node(next_position, node_parent->distance_to_top + 1, obj_layer_nr_next, + node_parent->support_roof_layers_below - (node_parent->distance_to_top >= 0 ? 1 : 0), + to_buildplate, node_parent, print_z_next, height_next); + get_max_move_dist(next_node); + std::scoped_lock lock(m_ts_data->m_mutex); + contact_nodes[layer_nr_next].push_back(next_node); + neighbour->valid = false; + p_node->valid = false; + } } else if (neighbours.size() > 1) //Don't merge leaf nodes because we would then incur movement greater than the maximum move distance. { @@ -3019,6 +3176,12 @@ void TreeSupport::drop_nodes() ExPolygons overhangs_next = diff_clipped({ node.overhang }, get_collision(0, obj_layer_nr_next)); for(auto& overhang:overhangs_next) { Point next_pt = overhang.contour.centroid(); + // Belt floor: a polygon node ends once all of it is in the belt. + // Treat as object-surface termination (not buildplate). + if (has_belt_floor && belt_polygon_landed(overhang, print_z_next)) { + p_node->to_buildplate = false; + continue; + } PendingNode pending; pending.position = next_pt; pending.distance_to_top = p_node->distance_to_top + 1; @@ -3166,6 +3329,14 @@ void TreeSupport::drop_nodes() if (is_outside) { next_layer_vertex = candidate_vertex; } } } + // Belt floor: a node ends once its whole circle is in the belt; until + // then it keeps dropping and draw_circles() clips each layer's circle + // to the belt plane, so the branch tapers to a tip on the belt. + // Treat as object-surface termination (not buildplate). + if (has_belt_floor && belt_node_landed(next_layer_vertex, node.radius, print_z_next)) { + p_node->to_buildplate = false; + return; // from parallel_for_each lambda + } auto next_collision = get_collision(0, obj_layer_nr_next); const bool to_buildplate = !is_inside_ex(m_ts_data->m_layer_outlines[obj_layer_nr_next], next_layer_vertex); // don't increase radius if next node will collide partially with the object (STUDIO-7883) @@ -3491,6 +3662,8 @@ void TreeSupport::generate_contact_points() const coordf_t max_bridge_length = scale_(config.max_bridge_length.value); coord_t radius_scaled = scale_(base_radius); bool on_buildplate_only = m_object_config->support_on_build_plate_only.value; + const bool has_belt_floor = std::abs(m_slicing_params.belt_floor_shear_factor) > EPSILON; + //First generate grid points to cover the entire area of the print. BoundingBox bounding_box = m_object->bounding_box(); const Point bounding_box_size = bounding_box.max - bounding_box.min; @@ -3578,6 +3751,10 @@ void TreeSupport::generate_contact_points() auto insert_point = [&](Point pt, const ExPolygon& overhang, double radius, bool force_add = false, bool add_interface=true) { + // Belt floor: skip contact points whose bottom_z is at or below + // the belt floor at this XY position (overhang rests on the belt). + if (has_belt_floor && bottom_z <= belt_floor_print_z(pt)) + return (SupportNode*) nullptr; Point hash_pos = pt / ((radius_scaled + 1) / 1); SupportNode* contact_node = nullptr; if (force_add || !already_inserted.count(hash_pos)) { @@ -3613,8 +3790,10 @@ void TreeSupport::generate_contact_points() double radius = unscale_(overhang_bounds.radius()); Point candidate = overhang_bounds.center(); SupportNode *contact_node = insert_point(candidate, overhang, radius, true, true); - contact_node->type = ePolygon; - curr_nodes.emplace_back(contact_node); + if (contact_node) { + contact_node->type = ePolygon; + curr_nodes.emplace_back(contact_node); + } } }else{ // otherwise, all nodes should be circle nodes @@ -3748,6 +3927,14 @@ TreeSupportData::TreeSupportData(const PrintObject &object, coordf_t xy_distance poly.simplify(scale_(m_radius_sample_resolution), &outline); } + // The belt surface is deliberately NOT part of the outlines. The outlines + // feed the collision and avoidance maps, and a node descending onto an + // obstacle is pushed out of it: with the belt as an obstacle the nodes slid + // down the tilted surface, ahead of the part, instead of landing on it. The + // belt is where a branch ENDS: drop_nodes() stops a node once its whole + // circle is in the belt (belt_node_landed()) and draw_circles() clips every + // layer's circles to the belt plane, so the branch tapers to a tip on it. + if (layer_nr == 0) m_layer_outlines_below.push_back(outline); else diff --git a/src/libslic3r/Support/TreeSupport.hpp b/src/libslic3r/Support/TreeSupport.hpp index e0da1f40c7..c6acd806e0 100644 --- a/src/libslic3r/Support/TreeSupport.hpp +++ b/src/libslic3r/Support/TreeSupport.hpp @@ -460,6 +460,17 @@ private: bool is_slim = false; bool with_infill = false; + // Belt printer: compute the belt floor print_z at a given XY position (in slicing coords). + // Returns -infinity if belt floor is not active. + double belt_floor_print_z(const Point &pos_slicing) const; + // Whether a node's whole circle (radius in mm) sits at or below the belt at + // print_z. The belt is a tilted plane, so the circle's leading edge crosses it + // |shear| * radius lower than its centre; stopping a node when its centre crosses + // would leave that edge floating a radius above the belt. + bool belt_node_landed(const Point &pos_slicing, double radius, double print_z) const; + // The same for a polygon: every point of it is at or below the belt. + bool belt_polygon_landed(const ExPolygon &poly, double print_z) const; + /*! diff --git a/src/libslic3r/Support/TreeSupport3D.cpp b/src/libslic3r/Support/TreeSupport3D.cpp index efd7492b00..ef267fba68 100644 --- a/src/libslic3r/Support/TreeSupport3D.cpp +++ b/src/libslic3r/Support/TreeSupport3D.cpp @@ -25,6 +25,7 @@ #include "Polygon.hpp" #include "Polyline.hpp" #include "MutablePolygon.hpp" +#include "BeltFloorContext.hpp" #include "libslic3r/Support/TreeSupportCommon.hpp" #include "libslic3r/PrintConfig.hpp" #include "libslic3r/Support/TreeModelVolumes.hpp" @@ -244,6 +245,9 @@ static std::vector>> group_me const bool support_threshold_auto = support_threshold == 0; // +1 makes the threshold inclusive double tan_threshold = support_threshold_auto ? 0. : tan(M_PI * double(support_threshold + 1) / 180.); + // Build plate tilt: compute per-layer XY shift for tilted gravity direction + const Vec2d tilt_slope = build_plate_tilt_slope(print_config); + const bool has_tilt = tilt_slope.cwiseAbs().maxCoeff() > EPSILON; //FIXME this is a fudge constant! auto enforcer_overhang_offset = scaled(config.tree_support_tip_diameter.value); const coordf_t radius_sample_resolution = g_config_tree_support_collision_resolution; @@ -265,7 +269,7 @@ static std::vector>> group_me size_t num_overhang_layers = support_auto ? num_object_layers : std::min(num_object_layers, std::max(size_t(support_enforce_layers), enforcers_layers.size())); tbb::parallel_for(tbb::blocked_range(1, num_overhang_layers), [&print_object, &config, &print_config, &enforcers_layers, &blockers_layers, - support_auto, support_enforce_layers, support_threshold_auto, tan_threshold, enforcer_overhang_offset, num_raft_layers, radius_sample_resolution, &throw_on_cancel, &out] + support_auto, support_enforce_layers, support_threshold_auto, tan_threshold, enforcer_overhang_offset, num_raft_layers, radius_sample_resolution, has_tilt, tilt_slope, &throw_on_cancel, &out] (const tbb::blocked_range &range) { for (LayerIndex layer_id = range.begin(); layer_id < range.end(); ++ layer_id) { const Layer ¤t_layer = *print_object.get_layer(layer_id); @@ -289,7 +293,15 @@ static std::vector>> group_me lower_layer_offset = external_perimeter_width - float(scale_(config.support_threshold_overlap.get_abs_value(unscale_(external_perimeter_width)))); } else lower_layer_offset = scaled(lower_layer.height / tan_threshold); - Polygons lower_layer_offseted = offset(lower_layer.lslices_extrudable, lower_layer_offset); + // Apply build plate tilt: shift lower layer polygons to simulate tilted gravity + Polygons lower_layer_offseted; + if (has_tilt) { + Polygons lower_src = to_polygons(lower_layer.lslices_extrudable); + translate(lower_src, Point::new_scale(tilt_slope * lower_layer.height)); + lower_layer_offseted = offset(lower_src, lower_layer_offset); + } else { + lower_layer_offseted = offset(lower_layer.lslices_extrudable, lower_layer_offset); + } overhangs = diff(current_layer.lslices_extrudable, lower_layer_offseted); if (lower_layer_offset == 0) { raw_overhangs = overhangs; @@ -3633,6 +3645,26 @@ static void generate_support_areas(Print &print, TreeSupport* tree_support, cons if (layer) layer->polygons = intersection(layer->polygons, volumes.m_bed_area); }); + // Belt floor: clip ALL organic support layers (including intermediate/base + // fill) against the belt surface. The branch slices were already clipped + // in organic_draw_branches(), but intermediate layers generated between + // branches and the build plate need clipping too. + // Compute the belt floor polygon directly from each layer's print_z + // rather than mapping to a layer index (avoids index mismatch issues). + { + const auto &sp = print_object.slicing_parameters(); + const auto &pcfg = print_object.print()->config(); + BeltFloorContext ctx; + ctx.init_local(sp, pcfg, print_object.belt_global_z_offset()); + if (ctx.is_active()) { + tbb::parallel_for_each(layers_sorted.begin(), layers_sorted.end(), [&](SupportGeneratorLayer *layer) { + if (!layer || layer->polygons.empty()) + return; + layer->polygons = diff(layer->polygons, ctx.surface_polygon(layer->print_z)); + }); + } + } + print.set_status(69, _L("Generating support")); generate_support_toolpaths(print_object.support_layers(), print_object.config(), support_params, print_object.slicing_parameters(), raft_layers, bottom_contacts, top_contacts, intermediate_layers, interface_layers, base_interface_layers); @@ -3923,6 +3955,10 @@ void organic_draw_branches( // ORCA: safety offset when trimming collision/bed to improve robustness. slices[i] = diff_clipped(slices[i], volumes.getCollision(0, layer_begin + i, true), ApplySafetyOffset::Yes); // FIXME parent_uses_min || draw_area.element->state.use_min_xy_dist); slices[i] = intersection(slices[i], volumes.m_bed_area, ApplySafetyOffset::Yes); + // Belt floor: clip branch slices against the belt surface plane. + LayerIndex belt_idx = layer_begin + i; + if (belt_idx < LayerIndex(volumes.m_belt_floor.size()) && !volumes.m_belt_floor[belt_idx].empty()) + slices[i] = diff(slices[i], volumes.m_belt_floor[belt_idx]); remove_small(slices[i], tiny_area); } @@ -3967,7 +4003,10 @@ void organic_draw_branches( if (!contacts.empty()) bottom_contacts.emplace_back(std::move(contacts)); } - } else if (layer_begin > 0) { + } else if (layer_begin > 0 && (volumes.m_belt_floor.empty() || num_empty == 0)) { + // Belt-floor clipping makes initial slices empty often; without this + // gate, "verylost" branches propagate rest_support down to layer 0 and + // OOM on tall belt prints. // Drop down areas that do rest non - gracefully on the model to ensure the branch actually rests on something. struct BottomExtraSlice { Polygons polygons; @@ -3976,12 +4015,20 @@ void organic_draw_branches( std::vector bottom_extra_slices; Polygons rest_support; coord_t bottom_radius = support_element_radius(config, *branch.path.front()); + // Belt printer (GeneratorOnly belt floor): the tilted belt surface is the + // build surface, so a branch should terminate ON the belt with a thin tip, + // not stamp its full footprint straight down to Z=0 and weld neighbouring + // branches into a solid floor slab. m_belt_floor is only populated in that + // mode, so it doubles as the gate (no effect on other printer types). + const bool belt_mode = !volumes.m_belt_floor.empty(); // Don't propagate further than 1.5 * bottom radius. //LayerIndex layers_propagate_max = 2 * bottom_radius / config.layer_height; LayerIndex layers_propagate_max = 5 * bottom_radius / config.layer_height; - LayerIndex layer_bottommost = branch.path.front()->state.verylost ? + LayerIndex layer_bottommost = (branch.path.front()->state.verylost && !belt_mode) ? // If the tree bottom is hanging in the air, bring it down to some surface. 0 : + // In belt mode never force-drop to Z=0 (the belt clip below handles + // termination); otherwise the "verylost" branch welds into the slab. //FIXME the "verylost" branches should stop when crossing another support. std::max(0, layer_begin - layers_propagate_max); double support_area_min_radius = M_PI * sqr(double(config.branch_radius)); @@ -3992,12 +4039,29 @@ void organic_draw_branches( LayerIndex collision_layer = (layer_idx == layer_begin - 1) ? layer_begin : layer_idx; Polygons collision = volumes.getCollision(0, collision_layer, false); rest_support = diff_clipped(rest_support.empty() ? slice_front_contact : rest_support, collision, ApplySafetyOffset::Yes); + // Belt floor: clip propagated support at belt surface. + bool belt_cut = false; + if (layer_idx < LayerIndex(volumes.m_belt_floor.size()) && !volumes.m_belt_floor[layer_idx].empty()) { + double area_before = area(rest_support); + rest_support = diff(rest_support, volumes.m_belt_floor[layer_idx]); + // The belt counts as "reached" only when it actually removes part + // of this branch's footprint. The belt half-plane is non-empty at + // every near-belt layer, so testing non-emptiness alone would + // terminate a laterally-distant branch ~1 layer above true contact, + // leaving a gap. Require a real area reduction instead. + belt_cut = belt_mode && area(rest_support) < area_before - tiny_area; + } remove_small(rest_support, tiny_area); double rest_support_area = area(rest_support); if (rest_support_area < support_area_stop) // Don't propagate a fraction of the tree contact surface. break; bottom_extra_slices.push_back({ rest_support, rest_support_area }); + // Belt mode: once the belt surface actually starts cutting this branch + // it has reached the belt — keep this last (belt-clipped) slice as the + // contact and stop, rather than stamping the footprint further down. + if (belt_cut) + break; } // Now remove those bottom slices that are not supported at all. #if 0 @@ -4015,7 +4079,10 @@ void organic_draw_branches( } } #endif - if (config.settings.support_floor_layers > 0) { + // Belt mode: no solid support-floor pad under these branches — it is what + // welds neighbouring belt-terminating branches into the dense Z=0 slab. + // They simply taper out onto the tilted belt as distributed thin contacts. + if (!belt_mode && config.settings.support_floor_layers > 0) { Polygons contacts; if (!bottom_extra_slices.empty()) { const int contact_idx = int(bottom_extra_slices.size()) - 1; // Use the lowest contact slice as the footprint. @@ -4054,7 +4121,10 @@ void organic_draw_branches( } // ORCA: retain bottom contacts even when no placeable areas intersect. - if (branch.has_root && config.support_rests_on_model && branch.path.front()->state.layer_idx > 0 && + // Skipped in belt mode (m_belt_floor populated) so we don't re-introduce a + // solid floor pad for branches that terminate on the tilted belt surface. + if (volumes.m_belt_floor.empty() && + branch.has_root && config.support_rests_on_model && branch.path.front()->state.layer_idx > 0 && config.settings.support_floor_layers > 0 && config.z_distance_bottom_layers > 0 && bottom_contacts.empty() && !slice_front_contact.empty()) bottom_contacts.emplace_back(slice_front_contact); diff --git a/src/libslic3r/Support/TreeSupportCommon.hpp b/src/libslic3r/Support/TreeSupportCommon.hpp index 97a0e31eb3..6767d2193a 100644 --- a/src/libslic3r/Support/TreeSupportCommon.hpp +++ b/src/libslic3r/Support/TreeSupportCommon.hpp @@ -667,7 +667,22 @@ inline SupportGeneratorLayer& layer_initialize( const size_t layer_idx) { layer_new.print_z = layer_z(slicing_params, config, layer_idx); - layer_new.bottom_z = layer_idx > 0 ? layer_z(slicing_params, config, layer_idx - 1) : 0; + // Layer 0 has no layer below it, so its bottom is the build plate at z = 0 -- + // true for a flat bed, false for a belt, whose virtual support layers extend + // below zero. Taking 0 there made the bottom-most belt layer's height come out + // as its own (negative) print_z, which reached Flow::with_height() and threw + // FlowErrorNegativeFlow, so tree support could not slice any belt model whose + // branches reached down that far. + // + // Only the negative case is corrected. An earlier version used + // min(0, print_z - layer_height), which also fires whenever the initial layer + // is THINNER than the regular layer height -- e.g. 0.2 over 0.3, both + // independently configurable -- and silently changed flat-bed support layer + // heights. Keying on the sign leaves every non-negative print_z on exactly + // the previous value of 0. + layer_new.bottom_z = layer_idx > 0 ? layer_z(slicing_params, config, layer_idx - 1) : 0.; + if (layer_idx == 0 && layer_new.print_z < 0.) + layer_new.bottom_z = layer_new.print_z - slicing_params.layer_height; layer_new.height = layer_new.print_z - layer_new.bottom_z; return layer_new; } diff --git a/src/libslic3r/TextureBake/TextureBakeDecimate.cpp b/src/libslic3r/TextureBake/TextureBakeDecimate.cpp index c03607e99c..56551b8e84 100644 --- a/src/libslic3r/TextureBake/TextureBakeDecimate.cpp +++ b/src/libslic3r/TextureBake/TextureBakeDecimate.cpp @@ -551,9 +551,13 @@ DecimateResult decimate(const TriSoup &geometry, size_t target_triangles, bool h // Rebuild from the surviving faces, with per-face normals. TriSoup &out = result.geometry; + if (!face_color.empty()) + result.face_color.reserve(active_faces); for (size_t f = 0; f < face_count; ++f) { if (faces[f * 3] < 0) continue; + if (!face_color.empty()) + result.face_color.push_back(f < face_color.size() ? face_color[f] : -1); const Vec3f a = pos[size_t(faces[f * 3])].cast(); const Vec3f b = pos[size_t(faces[f * 3 + 1])].cast(); const Vec3f c = pos[size_t(faces[f * 3 + 2])].cast(); diff --git a/src/libslic3r/TextureBake/TextureBakeDecimate.hpp b/src/libslic3r/TextureBake/TextureBakeDecimate.hpp index 8b31dbb642..273ad9af35 100644 --- a/src/libslic3r/TextureBake/TextureBakeDecimate.hpp +++ b/src/libslic3r/TextureBake/TextureBakeDecimate.hpp @@ -46,6 +46,13 @@ using DecimateProgressFn = std::function; struct DecimateResult { TriSoup geometry; + // One entry per output face, carried from the `face_color` handed in: a colour difference is a + // crease, so no collapse ever merges two faces of different colour and every survivor keeps exactly + // the colour it came with. Empty when no `face_color` was given. + // + // This is what lets the caller colour the simplified mesh by *provenance* rather than by sampling it + // again: the input colours were masked by the paint on the fine mesh, where that mask is exact. + std::vector face_color; // The locked faces alone met the target, so it was unreachable without touching preserved // geometry. bool locked_over_budget = false; diff --git a/src/libslic3r/TextureBake/TextureBakeMesh.cpp b/src/libslic3r/TextureBake/TextureBakeMesh.cpp index caf6e22d36..0d0a097f98 100644 --- a/src/libslic3r/TextureBake/TextureBakeMesh.cpp +++ b/src/libslic3r/TextureBake/TextureBakeMesh.cpp @@ -3,6 +3,7 @@ #include "libslic3r/Point.hpp" #include +#include #include #include #include @@ -42,7 +43,7 @@ TriSoup to_soup(const indexed_triangle_set &its, const std::vector &fac return out; } -indexed_triangle_set to_indexed_triangle_set(const TriSoup &soup) +indexed_triangle_set to_indexed_triangle_set(const TriSoup &soup, std::vector *face_color) { indexed_triangle_set out; const size_t n = soup.pos.size(); @@ -54,12 +55,22 @@ indexed_triangle_set to_indexed_triangle_set(const TriSoup &soup) if (map.inserted()) out.vertices.push_back(soup.pos[i]); } + const bool track_color = face_color != nullptr && !face_color->empty(); + std::vector kept_color; + if (track_color) + kept_color.reserve(face_color->size()); for (size_t t = 0; t + 2 < n; t += 3) { // Welded-together corners carry no area. if (id[t] == id[t + 1] || id[t + 1] == id[t + 2] || id[t] == id[t + 2]) continue; out.indices.emplace_back(id[t], id[t + 1], id[t + 2]); + if (track_color) { + const size_t src = t / 3; + kept_color.push_back(src < face_color->size() ? (*face_color)[src] : -1); + } } + if (track_color) + *face_color = std::move(kept_color); return out; } diff --git a/src/libslic3r/TextureBake/TextureBakeMesh.hpp b/src/libslic3r/TextureBake/TextureBakeMesh.hpp index 860aa1445d..7b05ed2451 100644 --- a/src/libslic3r/TextureBake/TextureBakeMesh.hpp +++ b/src/libslic3r/TextureBake/TextureBakeMesh.hpp @@ -15,7 +15,10 @@ namespace TextureBake { TriSoup to_soup(const indexed_triangle_set &its, const std::vector &face_excluded = {}); // Welds at the geometry grid. -indexed_triangle_set to_indexed_triangle_set(const TriSoup &soup); +// `face_color`, when given, is read as one entry per soup triangle and rewritten to match the output. +// Welding can leave a triangle with no area, and those are dropped here, so the two would otherwise +// fall out of step. +indexed_triangle_set to_indexed_triangle_set(const TriSoup &soup, std::vector *face_color = nullptr); } // namespace TextureBake } // namespace Slic3r diff --git a/src/libslic3r/TextureBake/TextureBakePipeline.cpp b/src/libslic3r/TextureBake/TextureBakePipeline.cpp index b44ae837bc..d7f9d9a7b9 100644 --- a/src/libslic3r/TextureBake/TextureBakePipeline.cpp +++ b/src/libslic3r/TextureBake/TextureBakePipeline.cpp @@ -290,6 +290,34 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, return result; } + // Colour per face, taken here and carried from here on. This is the only point where the paint mask + // is exact: `exclude_weight` says which faces the paint left out, and the mesh is still the refined + // one the displacement produced. Everything downstream (the collapse, the T-junction repair) carries + // these along rather than sampling again, and the caller uses them as they are. + // + // It also gives the collapse its crease criterion: an edge between two colours is never collapsed + // across, which is what keeps a survivor's colour well defined. + if (color_sample) { + const size_t nf = displaced.triangle_count(); + result.face_color.assign(nf, -1); + const bool have_w = !displaced.exclude_weight.empty(); + tbb::parallel_for(tbb::blocked_range(0, nf), [&](const tbb::blocked_range &r) { + for (size_t t = r.begin(); t < r.end(); ++t) { + // Unpainted faces take no colour at all, which is what stops the texture appearing on + // surfaces the paint never covered. + if (have_w && (displaced.exclude_weight[t * 3] + displaced.exclude_weight[t * 3 + 1] + + displaced.exclude_weight[t * 3 + 2]) / 3.f > 0.99f) + continue; // stays FACE_UNPAINTED + const Vec3f &a = displaced.pos[t * 3], &b = displaced.pos[t * 3 + 1], &c = displaced.pos[t * 3 + 2]; + const int sampled = color_sample((a + b + c) / 3.f, displaced.nrm[t * 3]); + // Painted either way. The sampler expects a point on the base surface and these are on + // the displaced one, so off the patch by more than its tolerance it simply says "no + // colour" - which must not be confused with "not painted". + result.face_color[t] = (sampled >= 0) ? sampled : FACE_NO_COLOUR; + } + }); + } + // 4. Decimate - export only. A bake needs the face-parent map, which a collapse destroys. std::vector parent = std::move(sub.face_parent_id); const size_t displaced_before_decimate = displaced.triangle_count(); @@ -323,24 +351,8 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, // unless the budget was lowered until decimation had to run. Only collapses costing less than // harvest_tol are taken, so this does not reach the relief. const bool harvest_only = !over_budget && settings.harvest_flat && displaced.triangle_count() > 0; + std::vector &face_color = result.face_color; if (over_budget || harvest_only) { - // Colour per face on the fine mesh, so colour boundaries become creases the collapse - // respects. Excluded (unpainted) faces take no colour. - std::vector face_color; - if (color_sample) { - const size_t nf = displaced.triangle_count(); - face_color.assign(nf, -1); - const bool have_w = !displaced.exclude_weight.empty(); - tbb::parallel_for(tbb::blocked_range(0, nf), [&](const tbb::blocked_range &r) { - for (size_t t = r.begin(); t < r.end(); ++t) { - if (have_w && (displaced.exclude_weight[t * 3] + displaced.exclude_weight[t * 3 + 1] + - displaced.exclude_weight[t * 3 + 2]) / 3.f > 0.99f) - continue; - const Vec3f &a = displaced.pos[t * 3], &b = displaced.pos[t * 3 + 1], &c = displaced.pos[t * 3 + 2]; - face_color[t] = color_sample((a + b + c) / 3.f, displaced.nrm[t * 3]); - } - }); - } // Harvesting alone is asked for by handing it the count it already has: nothing is then // over the target, so the loop only ever pops collapses under the tolerance. const size_t before = displaced.triangle_count(); @@ -350,6 +362,7 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, result.locked_over_budget = dec.locked_over_budget; result.budget_limited = result.simplified = dec.target_cost_detail; displaced = std::move(dec.geometry); + face_color = std::move(dec.face_color); lap("decimate", displaced, over_budget ? "over budget, simplified" : "flat faces harvested"); BOOST_LOG_TRIVIAL(info) << "TextureBake decimate: " << before << " -> " << displaced.triangle_count() << (over_budget ? " (budget " : " (flat harvest, budget ") << target << ")"; @@ -377,7 +390,7 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, // 6. Close the T-junctions decimation left behind. Only meaningful when it ran. if (mode == PipelineMode::Export && parent.empty()) { - displaced = resolve_t_junctions(displaced); + displaced = resolve_t_junctions(displaced, {}, &result.face_color); lap("repair", displaced); } diff --git a/src/libslic3r/TextureBake/TextureBakePipeline.hpp b/src/libslic3r/TextureBake/TextureBakePipeline.hpp index dbcc46259a..1fc4e4aae9 100644 --- a/src/libslic3r/TextureBake/TextureBakePipeline.hpp +++ b/src/libslic3r/TextureBake/TextureBakePipeline.hpp @@ -108,9 +108,27 @@ using PipelineProgressFn = std::function; +// Sentinels for PipelineResult::face_color. +static constexpr int FACE_UNPAINTED = -1; // the paint did not cover this face's origin +static constexpr int FACE_NO_COLOUR = -2; // painted, but the sampler returned nothing at this point + struct PipelineResult { TriSoup geometry; + // One entry per output face, carried through decimation, the T-junction repair and the weld. + // FACE_UNPAINTED means the paint never covered the geometry this face came from; anything else means + // it did, and is the palette index `color_sample` returned there (FACE_NO_COLOUR when it returned + // none). The distinction matters: the sampler answers for points on the *base* surface, and these + // are sampled on the displaced one, so a painted face can easily come back without a colour. Only + // the painted/unpainted split is reliable here, and that is what a caller should use it for. + // + // Empty unless the caller gave a `color_sample`. + // + // A caller that needs per-face colour must use this rather than sampling the result again. The + // result is displaced geometry: a point on it is no longer where its base surface was, so matching + // it back by proximity colours whatever base surface happens to be nearest - which on a part thinner + // than the relief depth is the *opposite* face, picking up the texture meant for the painted one. + std::vector face_color; // Output face -> input face. Empty in Export mode, where decimation invalidates it. std::vector face_parent_id; bool safety_cap_hit = false; diff --git a/src/libslic3r/TextureBake/TextureBakeRepair.cpp b/src/libslic3r/TextureBake/TextureBakeRepair.cpp index 49f427d461..877379b574 100644 --- a/src/libslic3r/TextureBake/TextureBakeRepair.cpp +++ b/src/libslic3r/TextureBake/TextureBakeRepair.cpp @@ -72,7 +72,8 @@ size_t count_area_slivers(const TriSoup &geometry) return n; } -TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts) +TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts, + std::vector *face_color) { const size_t n_tri = geometry.triangle_count(); const double on_tol2 = opts.on_seg_tol * opts.on_seg_tol; @@ -98,7 +99,12 @@ TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts) // grid. A needle reads as watertight yet is deleted downstream, and dropping it leaves exactly // the on-edge-vertex topology the pass below closes. std::vector> faces; + // Parallel to `faces` throughout, so a split or a dropped degenerate keeps the two in step. + const bool track_color = face_color != nullptr && !face_color->empty(); + std::vector colors; faces.reserve(n_tri); + if (track_color) + colors.reserve(n_tri); for (size_t t = 0; t < n_tri; ++t) { const int a = vid[t * 3], b = vid[t * 3 + 1], c = vid[t * 3 + 2]; if (a == b || b == c || a == c) @@ -108,6 +114,8 @@ TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts) if (u.cross(w).squaredNorm() < DEGENERATE_AREA_SQ) continue; faces.push_back({ a, b, c }); + if (track_color) + colors.push_back(t < face_color->size() ? (*face_color)[t] : -1); } for (int iter = 0; iter < opts.max_iters; ++iter) { @@ -166,11 +174,16 @@ TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts) break; std::vector> next; + std::vector next_colors; next.reserve(faces.size() + splits.size() * 2); + if (track_color) + next_colors.reserve(next.capacity()); for (size_t fi = 0; fi < faces.size(); ++fi) { const auto it = splits.find(fi); if (it == splits.end()) { next.push_back(faces[fi]); + if (track_color) + next_colors.push_back(colors[fi]); continue; } const auto &f = faces[fi]; @@ -195,11 +208,18 @@ TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts) seq.insert(seq.end(), sp.mids.rbegin(), sp.mids.rend()); seq.push_back(sp.a); } - for (size_t s = 0; s + 1 < seq.size(); ++s) + for (size_t s = 0; s + 1 < seq.size(); ++s) { next.push_back({ seq[s], seq[s + 1], apex }); + if (track_color) + next_colors.push_back(colors[fi]); // every piece of a split face keeps its colour + } } faces.swap(next); + if (track_color) + colors.swap(next_colors); } + if (track_color) + *face_color = std::move(colors); TriSoup out; out.pos.reserve(faces.size() * 3); diff --git a/src/libslic3r/TextureBake/TextureBakeRepair.hpp b/src/libslic3r/TextureBake/TextureBakeRepair.hpp index 07d5323059..ed949dacff 100644 --- a/src/libslic3r/TextureBake/TextureBakeRepair.hpp +++ b/src/libslic3r/TextureBake/TextureBakeRepair.hpp @@ -43,7 +43,12 @@ struct RepairOptions int max_iters = 16; }; -TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts = {}); +// `face_color`, when given, is read as one entry per input face and rewritten to match the output: a +// face split to close a T-junction hands its colour to every piece, and a degenerate face dropped on +// the way takes its entry with it. Without this the caller would have no way to keep a per-face colour +// across this pass, which changes the triangle count. +TriSoup resolve_t_junctions(const TriSoup &geometry, const RepairOptions &opts = {}, + std::vector *face_color = nullptr); } // namespace TextureBake } // namespace Slic3r diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 165605b738..d0bbb114c6 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -1248,52 +1248,81 @@ bool triangles_overlap(const Tri2 &a, const Tri2 &b, float eps) struct NetGrid { static constexpr int BIG_SPAN = 16; - float cell; - float eps; - std::unordered_map> cells; - std::vector big; + // Each stored triangle keeps its own bounding box. Overlap testing is dominated by rejects - a cell + // holds every triangle whose box touches it, and a candidate meets only a couple of them for real - + // so paying six floats per entry to answer most of those rejects with four comparisons, instead of a + // full triangle intersection, is what makes the net affordable. Measured on a 42k-triangle patch the + // grid ran ~19 million candidate pairs per net, nearly all of them misses, and rejecting them this + // way took the net from ~175 ms to ~53 ms. + // + // The box rides inside the entry rather than in a parallel array: splitting them to scan boxes back + // to back was tried and came out slower, because each bucket then grows two vectors instead of one. + struct Entry + { + Tri2 tri; + Vec2f lo, hi; + }; + float cell; + float eps; + std::unordered_map> cells; + std::vector big; static uint64_t key(int x, int y) { return (uint64_t(uint32_t(x)) << 32) | uint32_t(y); } - bool range(const Tri2 &t, int &x0, int &y0, int &x1, int &y1) const + static Entry entry(const Tri2 &t) + { + return Entry{ t, t[0].cwiseMin(t[1]).cwiseMin(t[2]), t[0].cwiseMax(t[1]).cwiseMax(t[2]) }; + } + bool range(const Vec2f &lo, const Vec2f &hi, int &x0, int &y0, int &x1, int &y1) const { - const Vec2f lo = t[0].cwiseMin(t[1]).cwiseMin(t[2]), hi = t[0].cwiseMax(t[1]).cwiseMax(t[2]); x0 = int(std::floor(lo.x() / cell)); y0 = int(std::floor(lo.y() / cell)); x1 = int(std::floor(hi.x() / cell)); y1 = int(std::floor(hi.y() / cell)); return x1 - x0 <= BIG_SPAN && y1 - y0 <= BIG_SPAN; } + // Boxes grown by eps on both sides, to match the tolerance triangles_overlap() itself works to: a + // reject here must never discard a pair that test would have called touching. + bool boxes_apart(const Entry &a, const Entry &b) const + { + return a.hi.x() + eps < b.lo.x() || b.hi.x() + eps < a.lo.x() || a.hi.y() + eps < b.lo.y() || + b.hi.y() + eps < a.lo.y(); + } + bool hits(const Entry &q, const std::vector &bucket) const + { + for (const Entry &b : bucket) + if (!boxes_apart(q, b) && triangles_overlap(q.tri, b.tri, eps)) + return true; + return false; + } bool overlaps(const Tri2 &t) const { - for (const Tri2 &b : big) - if (triangles_overlap(t, b, eps)) - return true; + const Entry q = entry(t); + if (hits(q, big)) + return true; int x0, y0, x1, y1; - if (!range(t, x0, y0, x1, y1)) { - for (const auto &[k, tris] : cells) - for (const Tri2 &b : tris) - if (triangles_overlap(t, b, eps)) - return true; + if (!range(q.lo, q.hi, x0, y0, x1, y1)) { + for (const auto &[k, bucket] : cells) + if (hits(q, bucket)) + return true; return false; } for (int x = x0; x <= x1; ++x) for (int y = y0; y <= y1; ++y) - if (const auto it = cells.find(key(x, y)); it != cells.end()) - for (const Tri2 &b : it->second) - if (triangles_overlap(t, b, eps)) - return true; + if (const auto it = cells.find(key(x, y)); it != cells.end() && hits(q, it->second)) + return true; return false; } void insert(const Tri2 &t) { - int x0, y0, x1, y1; - if (!range(t, x0, y0, x1, y1)) { - big.push_back(t); + const Entry e = entry(t); + int x0, y0, x1, y1; + if (!range(e.lo, e.hi, x0, y0, x1, y1)) { + big.push_back(e); return; } for (int x = x0; x <= x1; ++x) for (int y = y0; y <= y1; ++y) - cells[key(x, y)].push_back(t); + cells[key(x, y)].push_back(e); } }; } // namespace @@ -1305,11 +1334,20 @@ std::vector compute_connected_net(const PatchUnwrap &unwrap) if (n <= 1) return islands; - // Chart adjacency, with one representative shared edge per adjacent pair. + // Chart adjacency, with one representative shared edge per adjacent pair: the fold line the pair is + // unfolded about. + // + // Which edge that is matters, because two charts can touch along more than one run. A chart cut open + // to flatten it - a ring opened by segment_into_charts(), say - touches its other half along *both* + // sides of the cut. Folding is rigid, so only the run the fold line belongs to comes out matching; + // every other run is left mismatched, and a mismatched run is exactly where the texture visibly + // jumps. Taking whichever edge the map happened to yield first therefore left the long side broken + // about as often as the short one. The fold line is picked from the longest run instead, so what is + // left discontinuous is the shortest boundary the pair has. const auto edges = build_shared_edges(unwrap); struct PairEdge { ChartEdge a, b; }; - std::map, PairEdge> pair_edge; - std::vector> adj(static_cast(n)); + struct SharedEdge { PairEdge fold; int base_lo = -1, base_hi = -1; float length = 0.f; }; + std::map, std::vector> pair_shared; for (const auto &[base_edge, list] : edges) { for (size_t i = 0; i < list.size(); ++i) for (size_t j = i + 1; j < list.size(); ++j) { @@ -1317,14 +1355,50 @@ std::vector compute_connected_net(const PatchUnwrap &unwrap) if (c1 == c2 || c1 < 0 || c2 < 0 || c1 >= n || c2 >= n) continue; const std::pair pk{ std::min(c1, c2), std::max(c1, c2) }; - if (pair_edge.count(pk)) - continue; // keep the first shared edge as the fold line for this pair - pair_edge[pk] = (c1 < c2) ? PairEdge{ list[i], list[j] } : PairEdge{ list[j], list[i] }; - adj[size_t(pk.first)].push_back(pk.second); - adj[size_t(pk.second)].push_back(pk.first); + SharedEdge se; + se.fold = (c1 < c2) ? PairEdge{ list[i], list[j] } : PairEdge{ list[j], list[i] }; + se.base_lo = base_edge.first; + se.base_hi = base_edge.second; + // The unwrap is scaled to true surface area, so a uv distance is a length in mm. + se.length = (unwrap.uvs[size_t(se.fold.a.uv_lo)] - unwrap.uvs[size_t(se.fold.a.uv_hi)]).norm(); + pair_shared[pk].push_back(se); } } + std::map, PairEdge> pair_edge; + std::map, float> pair_weight; // length of the run each pair folds across + std::vector> adj(static_cast(n)); + for (const auto &[pk, shared] : pair_shared) { + // Group the pair's shared edges into runs - edges joined end to end through a base vertex - and + // total each run's length. + std::unordered_map local; + for (const SharedEdge &se : shared) + for (const int v : { se.base_lo, se.base_hi }) + local.emplace(v, int(local.size())); + UnionFind runs(local.size()); + for (const SharedEdge &se : shared) + runs.unite(local[se.base_lo], local[se.base_hi]); + + std::unordered_map run_length; + std::unordered_map run_first; + for (size_t i = 0; i < shared.size(); ++i) { + const int root = runs.find(local[shared[i].base_lo]); + run_length[root] += shared[i].length; + run_first.emplace(root, i); + } + int best_root = -1; + float best_len = -1.f; + for (const auto &[root, len] : run_length) + if (len > best_len) { best_len = len; best_root = root; } + if (best_root < 0) + continue; + + pair_edge[pk] = shared[run_first[best_root]].fold; + pair_weight[pk] = best_len; + adj[size_t(pk.first)].push_back(pk.second); + adj[size_t(pk.second)].push_back(pk.first); + } + // Per chart: its vertices, its triangles and its flattened area. std::vector> chart_verts(static_cast(n)), chart_tris(static_cast(n)); std::vector chart_area(static_cast(n), 0.f); @@ -1378,15 +1452,29 @@ std::vector compute_connected_net(const PatchUnwrap &unwrap) for (const int t : chart_tris[size_t(root)]) grid.insert(placed(m, t)); } - std::queue q; - q.push(root); + // Grown strongest-adjacency-first (Prim, not breadth-first): a chart is folded onto whichever + // neighbour it shares the longest boundary with, among everything reachable so far. Order matters + // because only the fold a chart is actually reached by comes out matching - every other boundary + // it has is left to chance. Taking neighbours in breadth-first order, biggest-area first, let a + // far-off branch claim a chart across a short boundary before its true neighbour was reached, and + // the long boundary they shared then stayed broken. That is the visible seam next to a hole: a + // ring is cut into two halves that share a long boundary, and whichever half was reached first + // took the other one along some unrelated edge. + using Candidate = std::pair>; // weight, (from, to) + std::priority_queue q; + const auto push_neighbours = [&](int p) { + for (const int c : adj[size_t(p)]) + if (net_of[size_t(c)] < 0 && !chart_tris[size_t(c)].empty()) { + const auto w = pair_weight.find({ std::min(p, c), std::max(p, c) }); + q.push({ w == pair_weight.end() ? 0.f : w->second, { p, c } }); + } + }; + push_neighbours(root); while (!q.empty()) { - const int p = q.front(); + const auto [weight, link] = q.top(); q.pop(); - std::vector neighbours = adj[size_t(p)]; - std::stable_sort(neighbours.begin(), neighbours.end(), - [&chart_area](int a, int b) { return chart_area[size_t(a)] > chart_area[size_t(b)]; }); - for (const int c : neighbours) { + const int p = link.first, c = link.second; + { if (net_of[size_t(c)] >= 0 || chart_tris[size_t(c)].empty()) continue; const auto it = pair_edge.find({ std::min(p, c), std::max(p, c) }); @@ -1417,7 +1505,7 @@ std::vector compute_connected_net(const PatchUnwrap &unwrap) for (const Tri2 &t : tris) grid.insert(t); net_of[size_t(c)] = net; - q.push(c); + push_neighbours(c); } } } @@ -2284,12 +2372,11 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set // // The *palette* index, not the printed filament. The decimation treats any edge whose two faces // differ as a crease (TextureBakeDecimate.cpp), so it must only ever see where the **perceived** - // colour changes - which is exactly what ColorResolveFn's own contract says the interleaving may - // never be fed into. Handing it the resolved filament made every Z band boundary a crease: on an - // upright wall that is one crease per band, so the collapse ran along those lines and left a stack - // of horizontal slivers, each printing in a single filament. Those were the horizontal colour - // lines in the baked result, and they also spent the triangle budget drawing a pattern the eye is - // meant to blend away. Faces the paint excludes are skipped by the pipeline itself. + // colour changes. A mix is one perceived colour however its components are laid down, which is why + // it has to be the palette index here: back when this was handed a per-triangle interleave instead, + // every band boundary read as a crease, the collapse ran along those lines and left a stack of + // horizontal slivers, and the triangle budget went on drawing a pattern the eye is meant to blend + // away. Faces the paint excludes are skipped by the pipeline itself. const TextureBake::ColorSampleFn color_sample = color_sampler ? TextureBake::ColorSampleFn([&color_sampler](const Vec3f &p, const Vec3f &n) { return color_sampler(p, n); @@ -2315,7 +2402,7 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set stats->triangles_budget = result.triangles_budget; stats->budget_limited = result.budget_limited; } - indexed_triangle_set out = TextureBake::to_indexed_triangle_set(result.geometry); + indexed_triangle_set out = TextureBake::to_indexed_triangle_set(result.geometry, &result.face_color); if (out.indices.empty()) return mesh; if (flip_normals) @@ -2336,6 +2423,8 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set max_depth = std::max(max_depth, std::abs(layer.depth_mm)); const float relief_tol = max_depth + paint_tol; std::vector palette(out.indices.size(), -1); + const std::vector &face_mask = result.face_color; + const bool have_face_mask = face_mask.size() == out.indices.size(); tbb::parallel_for(tbb::blocked_range(0, out.indices.size()), [&](const tbb::blocked_range &r) { for (size_t i = r.begin(); i < r.end(); ++i) { const stl_triangle_vertex_indices &t = out.indices[i]; @@ -2351,8 +2440,19 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set // reason; this path was the inconsistent one. Vec3f foot = centroid, base_n = Vec3f::UnitZ(); const float d2 = painted_closest(centroid, &foot, &base_n); - if (!all_painted && d2 >= relief_tol * relief_tol) + // Which faces may be coloured comes from the pipeline, which recorded it on the + // refined mesh where the paint mask is exact, and carried it through the collapse, + // the T-junction repair and the weld. Proximity cannot answer this: a displaced face + // is no longer where its base was, so on a part thinner than the relief depth the + // nearest painted surface to the *opposite* face is the painted one, and the texture + // appeared there too. Only the position to sample at still comes from the base + // surface, for the projection reason above. + if (have_face_mask) { + if (face_mask[i] == TextureBake::FACE_UNPAINTED) + continue; + } else if (!all_painted && d2 >= relief_tol * relief_tol) { continue; + } palette[i] = sampler(foot, base_n); } }); @@ -2374,7 +2474,7 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set Vec3f normal = (b - a).cross(c - a); const float nl = normal.norm(); normal = (nl > 0.f) ? Vec3f(normal / nl) : Vec3f::UnitZ(); - const int filament = color->resolve ? color->resolve(palette[i], centroid, normal) : palette[i]; + const int filament = palette[i]; if (filament >= 0) out_color[i] = uint8_t(std::min(filament + 1, 255)); } @@ -2831,8 +2931,7 @@ static indexed_triangle_set build_texture_displacement_in_place( Vec3f normal = (b - a).cross(c - a); const float nl = normal.norm(); normal = (nl > 0.f) ? Vec3f(normal / nl) : Vec3f::UnitZ(); - const int filament = color->resolve ? color->resolve(triangle_palette[i], centroid, normal) - : triangle_palette[i]; + const int filament = triangle_palette[i]; if (filament >= 0) out_color[i] = uint8_t(std::min(filament + 1, 255)); } @@ -5053,4 +5152,5 @@ indexed_triangle_set cut_mesh_at_steps(const indexed_triangle_set &mesh, const s return out; } + } // namespace Slic3r diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 94d717dea6..34c3543088 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -321,25 +321,6 @@ struct TextureDisplacementLayer } }; -// How a *mixed* palette entry - one that names two filaments rather than one - is turned into real -// per-facet paint. An MMU extrudes one filament at a time, so an intermediate colour exists only by -// interleaving two of them finely enough that the eye does the blending. -enum class ColorMixMode : int -{ - // Horizontal bands: which of the two filaments a point takes depends on its height, so - // consecutive print layers alternate. This is how filament-blend prints actually work, and on a - // vertical-ish surface it reads as a genuinely smooth colour. On a near-horizontal surface a whole - // layer is one band, so the blend disappears - that is what XYDither is for. - ZBands = 0, - // An ordered (Bayer) checkerboard across the surface, at any orientation. Independent of layer - // height, but its cell is around the size of one facet, so a fine mix can read as texture rather - // than as a clean blend. - XYDither = 1, - // Per triangle, by its orientation: bands where the surface is upright enough for consecutive - // layers to alternate, the checkerboard where it faces up or down and a layer would be one band. - // The default - a flat-topped part with a mix on top gets no blend at all from bands alone. - Auto = 2, -}; // Settings that apply to the whole layer stack rather than to one layer, held per ModelVolume next // to texture_displacement_layers and consumed by build_texture_displacement(). @@ -406,7 +387,6 @@ struct TextureDisplacementOptions // image (TextureDetail::flat_colors): a texture of flat colours prints in single filaments, a // photograph or gradient in mixes. Off forces single filaments everywhere. bool color_mix_enabled = true; - ColorMixMode color_mix_mode = ColorMixMode::Auto; // Majority-filter passes over the assigned colours. See TextureColorRequest::despeckle_passes - // this is the control for it, and 2 is enough to clear the salt-and-pepper an image with detail // finer than the mesh leaves behind, without eating features that are genuinely a facet wide. @@ -414,11 +394,9 @@ struct TextureDisplacementOptions template void serialize(Archive &ar) { - int mix_mode = int(color_mix_mode); ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border, pipeline_v2, v2_refine_mm, v2_regularize, v2_max_triangles_k, - v2_relocate, color_mix_enabled, mix_mode, color_despeckle); - color_mix_mode = ColorMixMode(mix_mode); + v2_relocate, color_mix_enabled, color_despeckle); } }; @@ -511,17 +489,9 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer // is. See GLGizmoTextureDisplacement::make_palette_quantizer(). using ColorQuantizeFn = std::function; -// Resolves a palette index plus a surface position to the filament index that position should print -// in. A pure entry ignores the position; a mixed one interleaves its two filaments per ColorMixMode. -// -// Deliberately separate from ColorQuantizeFn, and deliberately *not* used by the subdivision's colour -// criterion: that criterion asks where the **perceived** colour changes, and must not see the -// interleaving. Refining on every band or dither-cell boundary would spend the whole triangle budget -// drawing a pattern the eye is supposed to blend away. -using ColorResolveFn = std::function; // One printable colour: either a loaded filament on its own, or a blend of two of them realised by -// interleaving (see ColorMixMode). Plain data, so it can be captured into a background job. +// interleaving, which the slicer does per print layer. Plain data, so it can be captured into a job. struct PrintableColor { Vec3f rgb = Vec3f::Zero(); // what it looks like; for a mix, the perceptual average of the two @@ -538,9 +508,6 @@ struct TextureColorSettings { std::vector palette; std::vector palette_pure; // the filaments alone, for flat-colour images - ColorMixMode mix_mode = ColorMixMode::ZBands; - float layer_height = 0.2f; // sizes the Z bands - float dither_cell_mm = 0.4f; // sizes the XY dither cells int despeckle_passes = 2; bool empty() const { return palette.empty(); } @@ -780,9 +747,6 @@ struct TextureColorRequest // made of flat colours (TextureDetail::flat_colors) is matched with this one, so a tile or a logo // prints in single filaments while a photograph on another layer may still use mixes. ColorQuantizeFn quantize_pure; - // Palette index + position -> filament. Optional: without it a palette index is taken to be a - // filament index directly, which is the no-mixing case. - ColorResolveFn resolve; // Majority-filter passes over the *perceived* colour, before any interleaving is resolved. // // Sampling a detailed image once per triangle leaves salt-and-pepper wherever the image's own diff --git a/src/libslic3r/TriangleSelector.cpp b/src/libslic3r/TriangleSelector.cpp index df23e3db85..3944a3cba1 100644 --- a/src/libslic3r/TriangleSelector.cpp +++ b/src/libslic3r/TriangleSelector.cpp @@ -298,7 +298,7 @@ int TriangleSelector::select_unsplit_triangle(const Vec3f &hit, int facet_idx) c return this->select_unsplit_triangle(hit, facet_idx, neighbors); } -void TriangleSelector::select_patch(int facet_start, std::unique_ptr &&cursor, EnforcerBlockerType new_state, const Transform3d& trafo_no_translate, bool triangle_splitting, float highlight_by_angle_deg, const bool select_partially) +void TriangleSelector::select_patch(int facet_start, std::unique_ptr &&cursor, EnforcerBlockerType new_state, const Transform3d& trafo_no_translate, bool triangle_splitting, float highlight_by_angle_deg, const Vec3f &up_direction, const bool select_partially) { assert(facet_start < m_orig_size_indices); @@ -359,8 +359,8 @@ void TriangleSelector::select_patch(int facet_start, std::unique_ptr &&c int facet = facets_to_check[facet_idx]; const Vec3f& facet_normal = m_face_normals[m_triangles[facet].source_triangle]; Matrix3f normal_matrix = static_cast(trafo_no_translate.matrix().block(0, 0, 3, 3).inverse().transpose().cast()); - float world_normal_z = (normal_matrix* facet_normal).normalized().z(); - if (!visited[facet] && (highlight_by_angle_deg == 0.f || world_normal_z < highlight_angle_limit)) { + float world_normal_dot = (normal_matrix * facet_normal).normalized().dot(up_direction); + if (!visited[facet] && (highlight_by_angle_deg == 0.f || world_normal_dot < highlight_angle_limit)) { if (select_triangle(facet, new_state, triangle_splitting, select_partially)) { // add neighboring facets to list to be processed later for (int neighbor_idx : m_neighbors[facet]) @@ -385,7 +385,7 @@ bool TriangleSelector::is_facet_clipped(int facet_idx, const ClippingPlane &clp) void TriangleSelector::seed_fill_select_triangles(const Vec3f &hit, int facet_start, const Transform3d& trafo_no_translate, const ClippingPlane &clp, float seed_fill_angle, float highlight_by_angle_deg, - bool force_reselection) + const Vec3f &up_direction, bool force_reselection) { assert(facet_start < m_orig_size_indices); @@ -409,8 +409,8 @@ void TriangleSelector::seed_fill_select_triangles(const Vec3f &hit, int facet_st const Vec3f &facet_normal = m_face_normals[m_triangles[current_facet].source_triangle]; Matrix3f normal_matrix = static_cast(trafo_no_translate.matrix().block(0, 0, 3, 3).inverse().transpose().cast()); - float world_normal_z = (normal_matrix * facet_normal).normalized().z(); - if (!visited[current_facet] && (highlight_by_angle_deg == 0.f || world_normal_z < highlight_angle_limit)) { + float world_normal_dot = (normal_matrix * facet_normal).normalized().dot(up_direction); + if (!visited[current_facet] && (highlight_by_angle_deg == 0.f || world_normal_dot < highlight_angle_limit)) { if (m_triangles[current_facet].is_split()) { for (int split_triangle_idx = 0; split_triangle_idx <= m_triangles[current_facet].number_of_split_sides(); ++split_triangle_idx) { assert(split_triangle_idx < int(m_triangles[current_facet].children.size())); @@ -2605,7 +2605,7 @@ TriangleSelector::TriangleSplittingData TriangleSelector::remap_painting( if (TriangleCursor::check_normal(norm_b, -norm_a) && check_overlap(pv0, pv1, pv2, ta, tb, tc)) { // Paint this face target_selector.select_patch(face_idx, TriangleCursor::build_cursor(source_selector, tri), tri.get_state(), - Transform3d::Identity(), true, 0.f, true); + Transform3d::Identity(), true, 0.f, Vec3f::UnitZ(), true); } return true; // continue traversal }); diff --git a/src/libslic3r/TriangleSelector.hpp b/src/libslic3r/TriangleSelector.hpp index 3dd2efdaf0..46ebfb99df 100644 --- a/src/libslic3r/TriangleSelector.hpp +++ b/src/libslic3r/TriangleSelector.hpp @@ -350,6 +350,7 @@ public: const Transform3d &trafo_no_translate, // matrix to get from mesh to world without translation bool triangle_splitting, // If triangles will be split base on the cursor or not float highlight_by_angle_deg = 0.f, // The maximal angle of overhang. If it is set to a non-zero value, it is possible to paint only the triangles of overhang defined by this angle in degrees. + const Vec3f &up_direction = Vec3f::UnitZ(), // Up direction for overhang detection (accounts for build plate tilt) bool select_partially = false); // Select a triangle if it's partially in the cursor but too small to be subdivided void seed_fill_select_triangles(const Vec3f &hit, // point where to start @@ -358,6 +359,7 @@ public: const ClippingPlane &clp, // Clipping plane to limit painting to not clipped facets only float seed_fill_angle, // the maximal angle between two facets to be painted by the same color float highlight_by_angle_deg = 0.f, // The maximal angle of overhang. If it is set to a non-zero value, it is possible to paint only the triangles of overhang defined by this angle in degrees. + const Vec3f &up_direction = Vec3f::UnitZ(), // Up direction for overhang detection (accounts for build plate tilt) bool force_reselection = false); // force reselection of the triangle mesh even in cases that mouse is pointing on the selected triangle void bucket_fill_select_triangles(const Vec3f &hit, // point where to start diff --git a/src/libslic3r/calib.cpp b/src/libslic3r/calib.cpp index a6aecda210..3217edf1da 100644 --- a/src/libslic3r/calib.cpp +++ b/src/libslic3r/calib.cpp @@ -1,4 +1,5 @@ #include "calib.hpp" +#include "GCode/BeltKinematics.hpp" #include "BoundingBox.hpp" #include "Config.hpp" #include "Flow.hpp" @@ -18,6 +19,8 @@ #include #include #include +#include +#include #include "clonable_ptr.hpp" namespace Slic3r { @@ -648,9 +651,9 @@ CustomGCode::Info CalibPressureAdvancePattern::generate_custom_gcodes(const Dyna refresh_setup(config, is_bbl_machine, object, origin); - gcode << move_to(Vec2d(m_starting_point.x(), m_starting_point.y()), m_writer, "Move to start XY position"); - gcode << m_writer.travel_to_z(height_first_layer() + height_z_offset(), "Move to start Z position"); - gcode << m_writer.set_pressure_advance(m_params.start); + gcode << move_to(Vec2d(m_starting_point.x(), m_starting_point.y()), *m_writer, "Move to start XY position"); + gcode << m_writer->travel_to_z(height_first_layer() + height_z_offset(), "Move to start Z position"); + gcode << m_writer->set_pressure_advance(m_params.start); const DrawBoxOptArgs default_box_opt_args(wall_count(), height_first_layer(), line_width_first_layer(), speed_adjust(speed_first_layer())); @@ -658,15 +661,15 @@ CustomGCode::Info CalibPressureAdvancePattern::generate_custom_gcodes(const Dyna // create anchoring frame //pattern uses outer wall speed/width gcode << ";" << GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role) << "Outer wall\n"; - gcode << draw_box(m_writer, m_starting_point.x(), m_starting_point.y(), print_size_x(), frame_size_y(), default_box_opt_args); + gcode << draw_box(*m_writer, m_starting_point.x(), m_starting_point.y(), print_size_x(), frame_size_y(), default_box_opt_args); // create tab for numbers DrawBoxOptArgs draw_box_opt_args = default_box_opt_args; draw_box_opt_args.is_filled = true; draw_box_opt_args.num_perimeters = wall_count(); //draw box as bottom surface, so numbers are clearly visible on top - gcode << ";" << GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role) << "Bottom surface\n"; - gcode << draw_box(m_writer, m_starting_point.x(), m_starting_point.y() + frame_size_y() + line_spacing_first_layer(), + gcode << ";" << GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role) << "Bottom surface\n"; + gcode << draw_box(*m_writer, m_starting_point.x(), m_starting_point.y() + frame_size_y() + line_spacing_first_layer(), print_size_x(), max_numbering_height() + line_spacing_first_layer() + m_glyph_padding_vertical * 2, draw_box_opt_args); @@ -694,15 +697,15 @@ CustomGCode::Info CalibPressureAdvancePattern::generate_custom_gcodes(const Dyna gcode = std::stringstream(); // reset for next layer contents gcode << "; start pressure advance pattern for layer\n"; - gcode << m_writer.travel_to_z(layer_height, "Move to layer height"); - gcode << m_writer.reset_e(); + gcode << m_writer->travel_to_z(layer_height, "Move to layer height"); + gcode << m_writer->reset_e(); } // line numbering if (i == 1) { m_number_len = max_numbering_length(); - gcode << m_writer.set_pressure_advance(m_params.start); + gcode << m_writer->set_pressure_advance(m_params.start); double number_e_per_mm = e_per_mm(line_width(), height_layer(), m_config.option("nozzle_diameter")->get_at(0), @@ -714,20 +717,20 @@ CustomGCode::Info CalibPressureAdvancePattern::generate_custom_gcodes(const Dyna for (int j = 0; j < num_patterns; j += 2) { gcode << draw_number(glyph_start_x(j), m_starting_point.y() + frame_size_y() + m_glyph_padding_vertical + line_width(), m_params.start + (j * m_params.step), m_draw_digit_mode, line_width(), number_e_per_mm, - speed_first_layer(), m_writer); + speed_first_layer(), *m_writer); } // flow value int line_num = num_patterns + 2; gcode << draw_number(glyph_start_x(line_num), m_starting_point.y() + frame_size_y() + m_glyph_padding_vertical + line_width(), flow_val(), m_draw_digit_mode, line_width(), number_e_per_mm, - speed_first_layer(), m_writer); + speed_first_layer(), *m_writer); // acceleration line_num = num_patterns + 4; gcode << draw_number(glyph_start_x(line_num), m_starting_point.y() + frame_size_y() + m_glyph_padding_vertical + line_width(), accel, m_draw_digit_mode, line_width(), number_e_per_mm, - speed_first_layer(), m_writer); + speed_first_layer(), *m_writer); } @@ -746,20 +749,20 @@ CustomGCode::Info CalibPressureAdvancePattern::generate_custom_gcodes(const Dyna /* Draw a line at slightly slower accel and speed in order to trick gcode writer to force update acceleration and speed. * We do this since several tests may be generated by their own gcode writers which are * not aware about their neighbours updating acceleration/speed */ - gcode << m_writer.set_print_acceleration(std::max(1, accel - 1)); - gcode << move_to(Vec2d(m_starting_point.x(), m_starting_point.y()), m_writer, "Move to starting point", zhop_height, layer_height); - gcode << draw_line(m_writer, Vec2d(m_starting_point.x(), m_starting_point.y() + frame_size_y()), line_width(), height_layer(), speed_adjust(std::max(1, speed_perimeter() - 1)), "Accel/flow trick line"); - gcode << m_writer.set_print_acceleration(accel); + gcode << m_writer->set_print_acceleration(std::max(1, accel - 1)); + gcode << move_to(Vec2d(m_starting_point.x(), m_starting_point.y()), *m_writer, "Move to starting point", zhop_height, layer_height); + gcode << draw_line(*m_writer, Vec2d(m_starting_point.x(), m_starting_point.y() + frame_size_y()), line_width(), height_layer(), speed_adjust(std::max(1, speed_perimeter() - 1)), "Accel/flow trick line"); + gcode << m_writer->set_print_acceleration(accel); } double initial_x = to_x; double initial_y = to_y; - gcode << move_to(Vec2d(to_x, to_y), m_writer, "Move to pattern start",zhop_height,layer_height); + gcode << move_to(Vec2d(to_x, to_y), *m_writer, "Move to pattern start",zhop_height,layer_height); for (int j = 0; j < num_patterns; ++j) { // increment pressure advance - gcode << m_writer.set_pressure_advance(m_params.start + (j * m_params.step)); + gcode << m_writer->set_pressure_advance(m_params.start + (j * m_params.step)); gcode << ";" << GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role) << "Outer wall\n"; for (int k = 0; k < wall_count(); ++k) { to_x += std::cos(to_radians(m_corner_angle) / 2) * side_length; @@ -769,27 +772,27 @@ CustomGCode::Info CalibPressureAdvancePattern::generate_custom_gcodes(const Dyna auto draw_line_arg_line_width = line_width(); // don't use line_width_first_layer so results are consistent across all layers auto draw_line_arg_speed = i == 0 ? speed_adjust(speed_first_layer()) : speed_adjust(speed_perimeter()); auto draw_line_arg_comment = "Print pattern wall"; - gcode << draw_line(m_writer, Vec2d(to_x, to_y), draw_line_arg_line_width, draw_line_arg_height, draw_line_arg_speed, draw_line_arg_comment); + gcode << draw_line(*m_writer, Vec2d(to_x, to_y), draw_line_arg_line_width, draw_line_arg_height, draw_line_arg_speed, draw_line_arg_comment); to_x -= std::cos(to_radians(m_corner_angle) / 2) * side_length; to_y += std::sin(to_radians(m_corner_angle) / 2) * side_length; - gcode << draw_line(m_writer, Vec2d(to_x, to_y), draw_line_arg_line_width, draw_line_arg_height, draw_line_arg_speed, draw_line_arg_comment); + gcode << draw_line(*m_writer, Vec2d(to_x, to_y), draw_line_arg_line_width, draw_line_arg_height, draw_line_arg_speed, draw_line_arg_comment); to_y = initial_y; if (k != wall_count() - 1) { // perimeters not done yet. move to next perimeter to_x += line_spacing_angle(); - gcode << move_to(Vec2d(to_x, to_y), m_writer, "Move to start next pattern wall", zhop_height, layer_height); // Call move to command with XY as well as z hop and layer height to invoke and undo z lift + gcode << move_to(Vec2d(to_x, to_y), *m_writer, "Move to start next pattern wall", zhop_height, layer_height); // Call move to command with XY as well as z hop and layer height to invoke and undo z lift } else if (j != num_patterns - 1) { // patterns not done yet. move to next pattern to_x += m_pattern_spacing + line_width(); - gcode << move_to(Vec2d(to_x, to_y), m_writer, "Move to next pattern", zhop_height, layer_height); // Call move to command with XY as well as z hop and layer height to invoke and undo z lift + gcode << move_to(Vec2d(to_x, to_y), *m_writer, "Move to next pattern", zhop_height, layer_height); // Call move to command with XY as well as z hop and layer height to invoke and undo z lift } else if (i != m_num_layers - 1) { // layers not done yet. move back to start to_x = initial_x; - gcode << move_to(Vec2d(to_x, to_y), m_writer, "Move back to start position", zhop_height, layer_height); // Call move to command with XY as well as z hop and layer height to invoke and undo z lift - gcode << m_writer.reset_e(); // reset extruder before printing placeholder cube to avoid over extrusion + gcode << move_to(Vec2d(to_x, to_y), *m_writer, "Move back to start position", zhop_height, layer_height); // Call move to command with XY as well as z hop and layer height to invoke and undo z lift + gcode << m_writer->reset_e(); // reset extruder before printing placeholder cube to avoid over extrusion } else { // everything done } @@ -797,8 +800,8 @@ CustomGCode::Info CalibPressureAdvancePattern::generate_custom_gcodes(const Dyna } } - gcode << m_writer.reset_e(); - gcode << m_writer.set_pressure_advance(m_params.start); + gcode << m_writer->reset_e(); + gcode << m_writer->set_pressure_advance(m_params.start); gcode << "; end pressure advance pattern for layer\n"; CustomGCode::Item item; @@ -875,13 +878,35 @@ void CalibPressureAdvancePattern::_refresh_writer(bool is_bbl_machine, const Mod PrintConfig print_config; print_config.apply(m_config, true); - m_writer.apply_print_config(print_config); - m_writer.set_xy_offset(origin(0), origin(1)); - m_writer.set_is_bbl_machine(is_bbl_machine); + // ORCA-Belt: the pattern is drawn in logical bed coordinates directly on + // the build surface — on a belt printer that means the belt plane, which + // needs the machine kinematics (axis remap + frame shear/scale) with the + // coordinates interpreted as world points (see set_world_coordinates). + if (print_config.belt_printer.value) { + auto belt_writer = std::make_shared(); + install_belt_kinematics(*belt_writer, print_config, /*world_coordinates=*/true); + const int rx = int(print_config.gcode_remap_x.value); + const int ry = int(print_config.gcode_remap_y.value); + const int rz = int(print_config.gcode_remap_z.value); + if (rx != 0 || ry != 1 || rz != 2) { + belt_writer->set_axis_remap(rx, ry, rz); + BoundingBoxf bbox_bed(print_config.printable_area.values); + belt_writer->set_build_volume_max(Vec3d(bbox_bed.max.x(), bbox_bed.max.y(), + print_config.printable_height.value)); + } + m_writer = std::move(belt_writer); + } else if (m_writer && dynamic_cast(&m_writer->kinematics()) != nullptr) { + // Previously configured for a belt printer; drop back to a plain writer. + m_writer = std::make_shared(); + } + + m_writer->apply_print_config(print_config); + m_writer->set_xy_offset(origin(0), origin(1)); + m_writer->set_is_bbl_machine(is_bbl_machine); const unsigned int extruder_id = object.volumes.front()->extruder_id(); - m_writer.set_extruders({extruder_id}); - m_writer.set_extruder(extruder_id); + m_writer->set_extruders({extruder_id}); + m_writer->set_extruder(extruder_id); } double CalibPressureAdvancePattern::object_size_x() const diff --git a/src/libslic3r/calib.hpp b/src/libslic3r/calib.hpp index fd22d4a08f..4ef676cdb1 100644 --- a/src/libslic3r/calib.hpp +++ b/src/libslic3r/calib.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #define calib_pressure_advance_dd #include "GCodeWriter.hpp" @@ -369,7 +370,10 @@ private: const Calib_Params &m_params; - GCodeWriter m_writer; + // Belt printers get belt kinematics installed on it (_refresh_writer); + // shared_ptr keeps the class copyable — the writer is rebuilt by + // refresh_setup() before every use anyway. + std::shared_ptr m_writer{std::make_shared()}; Vec3d m_starting_point; bool m_is_start_point_fixed = false; diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index d1ae93d9bf..4c63c11e30 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -440,6 +440,8 @@ set(SLIC3R_GUI_SOURCES GUI/ParamsPanel.hpp GUI/PartPlate.cpp GUI/PartPlate.hpp + GUI/BeltPurgeTower.cpp + GUI/BeltPurgeTower.hpp GUI/FilamentGroupPopup.hpp GUI/FilamentGroupPopup.cpp GUI/PhysicalPrinterDialog.cpp diff --git a/src/slic3r/GUI/3DBed.cpp b/src/slic3r/GUI/3DBed.cpp index 6d14a4d77c..d7fd871f27 100644 --- a/src/slic3r/GUI/3DBed.cpp +++ b/src/slic3r/GUI/3DBed.cpp @@ -36,9 +36,12 @@ #include #include "libslic3r/Preset.hpp" #include "libslic3r/Config.hpp" +#include #if BOOST_VERSION >= 107800 #include +#include +#include #else #include #endif @@ -410,6 +413,8 @@ void Bed3D::render_internal(GLCanvas3D& canvas, const Transform3d& view_matrix, case Type::Custom: { render_custom(canvas, view_matrix, projection_matrix, bottom); break; } } + render_gravity_arrow(view_matrix, projection_matrix); + glsafe(::glDisable(GL_DEPTH_TEST)); } @@ -714,7 +719,7 @@ void Bed3D::render_model(const Transform3d& view_matrix, const Transform3d& proj if (shader != nullptr) { shader->start_using(); shader->set_uniform("emission_factor", 0.0f); - const Transform3d model_matrix = Geometry::assemble_transform(m_model_offset); + Transform3d model_matrix = Geometry::assemble_transform(m_model_offset); shader->set_uniform("volume_world_matrix", model_matrix); shader->set_uniform("view_model_matrix", view_matrix * model_matrix); shader->set_uniform("projection_matrix", projection_matrix); @@ -753,6 +758,59 @@ void Bed3D::render_custom(GLCanvas3D& canvas, const Transform3d& view_matrix, co render_texture(bottom, canvas);*/ } +void Bed3D::render_gravity_arrow(const Transform3d& view_matrix, const Transform3d& projection_matrix) +{ + // build_plate_tilt_{x,y} are kept in sync with the belt tilt (see TabPrinter), so + // reading them here covers both belt and non-belt tilted printers. + const Vec3d up_dir = build_plate_tilt_up_direction(); + if (up_dir == Vec3d::UnitZ()) { + m_gravity_arrow.reset(); + return; + } + + // A plain line along the tilted "up" direction -- the way the layers lean, i.e. + // the gantry -- drawn like the bed axes (no tip: the other direction is not + // possible) and shorter than them, so it reads as a hint inside the YZ corner. + const float length = 0.6f * m_axes.get_total_length(); + if (!m_gravity_arrow.is_initialized() || m_gravity_arrow_length != length) { + m_gravity_arrow.reset(); + m_gravity_arrow.init_from(smooth_cylinder(16, /*Radius*/ length / 75.f, length)); + m_gravity_arrow_length = length; + } + + // The cylinder model points along +Z. Compute the rotation that aligns it with + // up_dir: rotation axis = cross(+Z, up_dir), angle = acos(dot(+Z, up_dir)). + Vec3d from = Vec3d::UnitZ(); + Vec3d to = up_dir; + double dot = from.dot(to); + Transform3d rot = Transform3d::Identity(); + if (dot < -0.9999) { + // Nearly opposite -- rotate 180 degrees around X + rot = Eigen::AngleAxisd(M_PI, Vec3d::UnitX()) * rot; + } else if (dot < 0.9999) { + Vec3d axis = from.cross(to).normalized(); + double angle = std::acos(std::clamp(dot, -1.0, 1.0)); + rot = Eigen::AngleAxisd(angle, axis) * rot; + } + + GLShaderProgram* shader = wxGetApp().get_shader("flat"); + if (shader == nullptr) + return; + + glsafe(::glEnable(GL_DEPTH_TEST)); + shader->start_using(); + + const Camera& camera = wxGetApp().plater()->get_camera(); + Transform3d model_matrix = Eigen::Translation3d(m_axes.get_origin()) * rot; + shader->set_uniform("view_model_matrix", camera.get_view_matrix() * model_matrix); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + + m_gravity_arrow.set_color({ 1.0f, 0.85f, 0.0f, 1.0f }); // yellow + m_gravity_arrow.render(); + + shader->stop_using(); +} + void Bed3D::render_default(bool bottom, const Transform3d& view_matrix, const Transform3d& projection_matrix) { // m_texture.reset(); diff --git a/src/slic3r/GUI/3DBed.hpp b/src/slic3r/GUI/3DBed.hpp index 66e4bc7d0a..eeabe7da82 100644 --- a/src/slic3r/GUI/3DBed.hpp +++ b/src/slic3r/GUI/3DBed.hpp @@ -115,6 +115,8 @@ private: //GLTexture m_temp_texture; GLModel m_model; Vec3d m_model_offset{ Vec3d::Zero() }; + GLModel m_gravity_arrow; + float m_gravity_arrow_length{ 0.f }; Axes m_axes; float m_scale_factor{ 1.0f }; @@ -144,6 +146,7 @@ public: // Build volume geometry for various collision detection tasks. const BuildVolume& build_volume() const { return m_build_volume; } + BuildVolume& build_volume() { return m_build_volume; } // Was the model provided, or was it generated procedurally? Type get_type() const { return m_type; } @@ -183,7 +186,8 @@ private: void render_model(const Transform3d& view_matrix, const Transform3d& projection_matrix); void render_custom(GLCanvas3D& canvas, const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom); void render_default(bool bottom, const Transform3d& view_matrix, const Transform3d& projection_matrix); - + void render_gravity_arrow(const Transform3d& view_matrix, const Transform3d& projection_matrix); + // BBS: remove the bed picking logic // void register_raycasters_for_picking(const GLModel::Geometry& geometry, const Transform3d& trafo); }; diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index 339e545842..5a39f9a279 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -1238,6 +1238,10 @@ void GLVolumeCollection::render(GLVolumeCollection::ERenderType type, // default sampler unit 0, which can conflict with other sampler types. shader->set_uniform("depth_tex", OUTLINE_DEPTH_TEX_UNIT); + // Compute up direction accounting for build plate tilt. This is frame-invariant + // (config cannot change mid-render), so compute it once before the volume loop. + const Vec3f up_direction = GUI::build_plate_tilt_up_direction().cast(); + for (GLVolumeWithIdAndZ& volume : to_render) { #if ENABLE_MODIFIERS_ALWAYS_TRANSPARENT if (type == ERenderType::Transparent) { @@ -1316,6 +1320,7 @@ void GLVolumeCollection::render(GLVolumeCollection::ERenderType type, shader->set_uniform("slope.actived", m_slope.isGlobalActive && !volume.first->is_modifier && !volume.first->is_wipe_tower); shader->set_uniform("slope.volume_world_normal_matrix", static_cast(volume.first->world_matrix().matrix().block(0, 0, 3, 3).inverse().transpose().cast())); shader->set_uniform("slope.normal_z", support_normal_z); + shader->set_uniform("slope.up_direction", up_direction); #if ENABLE_ENVIRONMENT_MAP unsigned int environment_texture_id = GUI::wxGetApp().plater()->get_environment_texture_id(); diff --git a/src/slic3r/GUI/AMSMaterialsSetting.cpp b/src/slic3r/GUI/AMSMaterialsSetting.cpp index 3859daf8e9..903524caaa 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.cpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.cpp @@ -19,7 +19,6 @@ #include "slic3r/GUI/Widgets/Label.hpp" #include #include "slic3r/GUI/wxExtensions.hpp" -#include "slic3r/GUI/Widgets/HyperLink.hpp" #include #include #include "slic3r/GUI/DeviceCore/DevDefs.h" @@ -48,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -103,6 +103,14 @@ void AMSMaterialsSetting::create() m_sizer_button->Add(0, 0, 1, wxEXPAND, 0); + // Orca: link to the Orca Slicer pressure-advance wiki (region-agnostic). + m_wiki_ctrl = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/pressure_advance_calib"; + m_wiki_ctrl->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + m_wiki_ctrl->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + m_wiki_ctrl->SetCanFocus(false); + m_wiki_ctrl->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + m_button_confirm = new Button(this, _L("Confirm")); m_button_confirm->SetStyle(ButtonStyle::Confirm, ButtonType::Choice); m_button_confirm->Bind(wxEVT_BUTTON, &AMSMaterialsSetting::on_select_ok, this); @@ -115,8 +123,9 @@ void AMSMaterialsSetting::create() m_button_close->SetStyle(ButtonStyle::Regular, ButtonType::Choice); m_button_close->Bind(wxEVT_BUTTON, &AMSMaterialsSetting::on_select_close, this); - m_sizer_button->Add(m_button_confirm, 0, wxALIGN_CENTER | wxRIGHT, FromDIP(20)); - m_sizer_button->Add(m_button_reset, 0, wxALIGN_CENTER | wxRIGHT, FromDIP(20)); + m_sizer_button->Add(m_wiki_ctrl, 0, wxALIGN_CENTER | wxRIGHT, FromDIP(20)); + m_sizer_button->Add(m_button_confirm, 0, wxALIGN_CENTER | wxRIGHT, FromDIP(10)); + m_sizer_button->Add(m_button_reset, 0, wxALIGN_CENTER | wxRIGHT, FromDIP(10)); m_sizer_button->Add(m_button_close, 0, wxALIGN_CENTER, 0); m_sizer_main->Add(m_panel_normal, 0, wxALL, FromDIP(2)); @@ -340,11 +349,7 @@ void AMSMaterialsSetting::create_panel_kn(wxWindow* parent) m_ratio_text->SetForegroundColour(wxColour(50, 58, 61)); m_ratio_text->SetFont(Label::Head_14); - // Orca: link to the Orca Slicer pressure-advance wiki (region-agnostic). - wxString link_url = "https://www.orcaslicer.com/wiki/pressure_advance_calib"; - m_wiki_ctrl = new HyperLink(parent, _L("Wiki Guide"), link_url); cali_title_sizer->Add(m_ratio_text, 0, wxALIGN_CENTER_VERTICAL); - cali_title_sizer->Add(m_wiki_ctrl, 0, wxALIGN_CENTER_VERTICAL); wxBoxSizer *m_sizer_cali_resutl = new wxBoxSizer(wxHORIZONTAL); // pa profile diff --git a/src/slic3r/GUI/AMSMaterialsSetting.hpp b/src/slic3r/GUI/AMSMaterialsSetting.hpp index 0ecc8809be..2628e6caf4 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.hpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.hpp @@ -14,7 +14,6 @@ #include "Widgets/CheckBox.hpp" #include "Widgets/ComboBox.hpp" #include "Widgets/TextInput.hpp" -#include "Widgets/HyperLink.hpp" #include "slic3r/Utils/CalibUtils.hpp" #include #include @@ -194,7 +193,7 @@ protected: wxPanel * m_panel_kn; wxStaticText* m_ratio_text; - HyperLink * m_wiki_ctrl; + Button * m_wiki_ctrl; wxStaticText* m_k_param; TextInput* m_input_k_val; wxStaticText* m_n_param; diff --git a/src/slic3r/GUI/BeltPurgeTower.cpp b/src/slic3r/GUI/BeltPurgeTower.cpp new file mode 100644 index 0000000000..c18d549db1 --- /dev/null +++ b/src/slic3r/GUI/BeltPurgeTower.cpp @@ -0,0 +1,465 @@ +#include "BeltPurgeTower.hpp" + +#include "GUI_App.hpp" +#include "GUI_ObjectList.hpp" +#include "PartPlate.hpp" +#include "I18N.hpp" + +#include "libslic3r/Model.hpp" +#include "libslic3r/Preset.hpp" +#include "libslic3r/PresetBundle.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/FilamentMixer.hpp" +#include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/Geometry.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/Config.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/libslic3r.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace Slic3r { +namespace GUI { + +// The classic wipe tower is disabled in belt mode (its G-code bypasses the belt +// transform), so filament-change purging is routed into this prism via +// flush_into_objects (see Print::_plan_belt_purge()). The prism is a real model +// object so it is sliced through the normal pipeline and picks up the belt +// rotation. Width across the belt is user-set (belt_purge_tower_width); height +// is sized so each tilted slicing plane's cross-section through the prism can +// absorb the worst-case purge volume of one layer; length follows the printed +// objects along the belt (plus ramp/height compensation at both tilted ends). +bool ensure_belt_purge_tower(Model &model, PartPlateList &partplate_list, ObjectList *obj_list, std::vector &sigs) +{ + auto is_prism = [](const ModelObject *mo) { + const ConfigOption *opt = mo->config.option("belt_purge_tower_object"); + return opt != nullptr && opt->getBool(); + }; + + // Every plate gets its own prism. A prism belongs to the plate it lies on; one + // that lies on no plate is stale. + const int plate_count = partplate_list.get_plate_count(); + sigs.resize(size_t(plate_count)); + std::vector> prisms_by_plate(static_cast(plate_count)); + std::vector stale_prisms; + for (int i = 0; i < (int) model.objects.size(); ++i) + if (is_prism(model.objects[i])) { + const int plate_idx = model.objects[i]->instances.empty() ? -1 : partplate_list.find_instance(i, 0); + if (plate_idx >= 0 && plate_idx < plate_count) + prisms_by_plate[size_t(plate_idx)].push_back(i); + else + stale_prisms.push_back(i); + } + + // Deletes prism objects, keeping the sidebar and part plates in sync + // (same primitives as Plater::priv::remove(), minus scene update — the + // caller refreshes the scene). Highest index first, so the others stay valid. + auto remove_prisms = [&](std::vector idxs) { + std::sort(idxs.begin(), idxs.end()); + for (auto it = idxs.rbegin(); it != idxs.rend(); ++it) { + model.delete_object(size_t(*it)); + partplate_list.notify_instance_removed(*it, -1); + obj_list->delete_object_from_list(size_t(*it)); + } + }; + auto all_prisms = [&]() { + std::vector all = stale_prisms; + for (const auto &v : prisms_by_plate) + all.insert(all.end(), v.begin(), v.end()); + return all; + }; + + // Cheap early-out for non-belt printers: only belt printers ever get a belt + // purge tower, and this runs on every background-process tick — so avoid the + // full_config() merge below unless this is actually a belt printer. (Also + // tidies up a stale prism if the user switched away from a belt printer.) + { + const auto *belt_pre = wxGetApp().preset_bundle->printers.get_edited_preset().config.option("belt_printer"); + if (belt_pre == nullptr || !belt_pre->value) { + std::fill(sigs.begin(), sigs.end(), BeltPurgeSignature{}); + const std::vector all = all_prisms(); + if (all.empty()) + return false; + remove_prisms(all); + return true; + } + } + + // Read every sizing input from the MERGED full config — the exact same + // config the backend slices with (BackgroundSlicingProcess::apply uses + // preset_bundle->full_config()). Reading from the individual presets caused + // GUI/backend mismatches: e.g. purge_in_prime_tower or a populated + // flush_volumes_matrix present in the merged config but false/empty in the + // preset the GUI happened to read, so the tower was sized for the small + // prime_volume instead of the real color-change flush and could not absorb + // it. The three names below alias the one merged config so the rest of the + // function is unchanged. + const DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config(); + const DynamicPrintConfig &printer_config = full_cfg; + const DynamicPrintConfig &print_config = full_cfg; + const DynamicPrintConfig &project_config = full_cfg; + + const auto *belt_opt = printer_config.option("belt_printer"); + const bool belt = belt_opt != nullptr && belt_opt->value; + // Belt purge tower is its own type, gated by the belt-only printer option + // enable_belt_purge_tower (not the classic process enable_prime_tower). + const bool prime_tower_enabled = printer_config.has("enable_belt_purge_tower") && printer_config.opt_bool("enable_belt_purge_tower"); + const auto *seq_opt = print_config.option>("print_sequence"); + const bool by_object = seq_opt != nullptr && seq_opt->value == PrintSequence::ByObject; + + // Mixed filament slots are expanded to their physical components below. + std::vector is_mixed; + std::vector comp_strs; + if (const auto *o = full_cfg.option("filament_is_mixed")) + is_mixed = o->values; + if (const auto *o = full_cfg.option("filament_mixed_components")) + comp_strs = o->values; + + const double gap = 1.0; + const double layer_h = print_config.has("layer_height") ? print_config.opt_float("layer_height") : 0.2; + + // Belt geometry. The rotation axis is the gantry tilt axis; the belt + // travels along the *other* horizontal axis (X-rotation -> belt along Y, + // the CR-30 default). The purge prism is a long bar laid along the belt + // travel direction, beside the parts. For no/Z rotation we fall back to + // vertical slicing geometry (theta = 90 deg). + const auto *axis_opt = printer_config.option>("belt_slice_rotation"); + const auto *angle_opt = printer_config.option("belt_slice_rotation_angle"); + const BeltRotationAxis rot = axis_opt != nullptr ? axis_opt->value : BeltRotationAxis::X; + const bool belt_is_y = (rot != BeltRotationAxis::Y); // X / None / Z -> belt along Y + double theta = M_PI / 2.; + if ((rot == BeltRotationAxis::X || rot == BeltRotationAxis::Y) && angle_opt != nullptr && std::abs(angle_opt->value) > EPSILON) + theta = std::clamp(Geometry::deg2rad(std::abs(angle_opt->value)), Geometry::deg2rad(5.), M_PI / 2.); + const double sin_t = std::sin(theta); + const double cot_t = std::cos(theta) / sin_t; + + // NOTE: both purge_in_prime_tower and single_extruder_multi_material are + // PRINTER options (Preset.cpp s_Preset_printer_options) — read them from the + // printer preset. Reading purge_in_prime_tower from the print preset returns + // has()==false, collapsing use_matrix to false and sizing the tower for the + // small prime_volume instead of the real color-change flush. This must match + // the backend Print::_plan_belt_purge() which reads both from the merged config. + const bool use_matrix = (printer_config.has("purge_in_prime_tower") && printer_config.opt_bool("purge_in_prime_tower")) + && (printer_config.has("single_extruder_multi_material") && printer_config.opt_bool("single_extruder_multi_material")); + const double prime_volume = print_config.has("prime_volume") ? print_config.opt_float("prime_volume") : 45.; + const double printable_height = printer_config.has("printable_height") ? printer_config.opt_float("printable_height") : 250.; + + // The bed (printable_area) is plate-local but model instances live in the + // plate's world frame, so the plate origin is added to every bed coordinate. + const double inset = 1.; + BoundingBoxf bed_ext; + if (const auto *bed_opt = printer_config.option("printable_area"); + bed_opt != nullptr && !bed_opt->values.empty()) + bed_ext = get_extents(bed_opt->values); + + // What each plate needs, decided before anything is deleted or created. + struct Plan + { + bool wanted = false; + BeltPurgeSignature sig; + int n_islands = 1; + double w_sub = 0., length = 0., height = 0.; + Vec3d center = Vec3d::Zero(); + }; + std::vector plans(static_cast(plate_count)); + + for (int plate_idx = 0; plate_idx < plate_count; ++plate_idx) { + Plan &plan = plans[size_t(plate_idx)]; + PartPlate *plate = partplate_list.get_plate(plate_idx); + + // Filaments used and bounding extent of the non-prism objects on this + // plate (1-based filament ids; volume extruder 0 = object default). + std::set filaments; + double x_min = std::numeric_limits::max(); + double x_max = -std::numeric_limits::max(); + double y_min = std::numeric_limits::max(); + double y_max = -std::numeric_limits::max(); + double z_max = 0.; + bool have_objects = false; + if (belt && plate != nullptr) { + for (int obj_idx = 0; obj_idx < (int) model.objects.size(); ++obj_idx) { + const ModelObject *mo = model.objects[obj_idx]; + if (is_prism(mo)) + continue; + int obj_extruder = 1; + if (const ConfigOption *opt = mo->config.option("extruder"); opt != nullptr && opt->getInt() > 0) + obj_extruder = opt->getInt(); + bool any_instance_on_plate = false; + for (int inst_idx = 0; inst_idx < (int) mo->instances.size(); ++inst_idx) { + if (!plate->contain_instance_totally(obj_idx, inst_idx)) + continue; + any_instance_on_plate = true; + const BoundingBoxf3 bb = mo->instance_bounding_box(inst_idx); + x_min = std::min(x_min, bb.min.x()); + x_max = std::max(x_max, bb.max.x()); + y_min = std::min(y_min, bb.min.y()); + y_max = std::max(y_max, bb.max.y()); + z_max = std::max(z_max, bb.max.z()); + } + if (!any_instance_on_plate) + continue; + have_objects = true; + for (const ModelVolume *mv : mo->volumes) + for (int e : mv->get_extruders()) + filaments.insert(e > 0 ? e : obj_extruder); + } + } + + // A mixed filament slot is VIRTUAL: it never reaches a nozzle. At slice time + // ToolOrdering::resolve_mixed_filaments() replaces it with its physical + // components, so the toolchanges the prism has to absorb are between those + // components, not to the mixed slot itself. Counting the slot as a filament + // of its own therefore over-provisions the prism by one island per mixed slot + // -- the "extra purge tower" -- and, when every component is already used by + // another object, by an island that can never be reached at all. + // + // Expand here with the same helper the backend uses (Print.cpp's sequential + // path), so the GUI sizes the prism against the same filament set the slicer + // will actually produce. No-op when no filament is mixed. + if (has_any_mixed_filament(is_mixed)) { + std::vector zero_based; + zero_based.reserve(filaments.size()); + for (int f : filaments) + if (f > 0) + zero_based.push_back((unsigned int) (f - 1)); + zero_based = expand_mixed_filaments(zero_based, is_mixed, comp_strs); + filaments.clear(); + for (unsigned int f : zero_based) + filaments.insert((int) f + 1); + } + + plan.wanted = belt && prime_tower_enabled && !by_object && have_objects && filaments.size() > 1; + if (!plan.wanted) + continue; + + // --- Sizing ----------------------------------------------------------- + const int n_islands = std::max(1, (int) filaments.size() - 1); + // Every disconnected island needs at least 1 mm of printable width. Honor + // the configured total width whenever possible, but never let the island + // layout silently grow past the footprint used for placement. + const double min_width = n_islands + (n_islands - 1) * gap; + const double width = std::max(min_width, + print_config.has("belt_purge_tower_width") ? print_config.opt_float("belt_purge_tower_width") : 35.); + const double printable_width = width - (n_islands - 1) * gap; + + // Parts' extent along the belt-travel axis and the lateral (across-belt) axis. + const double belt_min = belt_is_y ? y_min : x_min; + const double belt_max = belt_is_y ? y_max : x_max; + const double lat_min = belt_is_y ? x_min : y_min; + const double lat_max = belt_is_y ? x_max : y_max; + + // Worst-case purge volume of one layer: up to (filament count - 1) + // toolchanges, each needing the worst flush matrix entry (mirrors the + // volume selection in Print::_plan_belt_purge()). + double max_flush = prime_volume; + if (use_matrix) { + const size_t extruder_nums = wxGetApp().preset_bundle->get_printer_extruder_count(); + const std::vector matrix = get_flush_volumes_matrix( + project_config.option("flush_volumes_matrix")->values, 0, extruder_nums); + const auto * multi_opt = project_config.option("flush_multiplier"); + const double multiplier = multi_opt != nullptr && !multi_opt->values.empty() ? multi_opt->get_at(0) : 1.; + const int n_total = (int) (std::sqrt(double(matrix.size())) + 0.5); + double m = 0.; + for (int i : filaments) + for (int j : filaments) + if (i != j && i <= n_total && j <= n_total) + m = std::max(m, matrix[size_t(i - 1) * n_total + size_t(j - 1)]); + if (m > 0.) + max_flush = m * multiplier; + } + const double v_layer = double(filaments.size() - 1) * max_flush; + + // Height from the per-layer purge demand. A tilted slicing plane cuts a + // printable_width x (height/sin) rectangle out of the bars, so one layer + // slab absorbs printable_width * (height/sin) * layer_height of purge. Solve for the height that + // holds the worst-case per-layer purge, with eta (infill/perimeter packing) + // and a safety margin for the tilt ramps / grid-alignment slop, plus a + // minimum so the tower is a real printable body rather than a sliver. + const double eta = 0.85; + const double safety = 1.6; + double height = safety * v_layer * sin_t / (printable_width * layer_h * eta); + height = std::clamp(height, 8.0, std::max(8.0, printable_height)); + + // --- Idempotence (input-keyed) ---------------------------------------- + auto q = [](double v) { return std::lround(v * 10.0); }; // 0.1 mm quantization + BeltPurgeSignature &new_sig = plan.sig; + new_sig.valid = true; + new_sig.filament_count = (int) filaments.size(); + new_sig.key[0] = q(width); + new_sig.key[1] = q(layer_h); + new_sig.key[2] = q(height); + new_sig.key[3] = q(belt_min); + new_sig.key[4] = q(belt_max); + new_sig.key[5] = q(lat_min); + new_sig.key[6] = q(z_max); + new_sig.key[7] = static_cast(rot); + new_sig.key[8] = std::lround(theta * 10000.0); + new_sig.key[9] = q(lat_max); + const Vec3d plate_origin = plate->get_origin(); + new_sig.key[10] = q(plate_origin.x()); + new_sig.key[11] = q(plate_origin.y()); + if (bed_ext.defined) { + new_sig.key[12] = q(bed_ext.max.x()); + new_sig.key[13] = q(bed_ext.max.y()); + } + + // --- Position ---------------------------------------------------------- + // Belt-travel axis. With the mesh rotated by theta before slicing, a machine + // point (y,z) maps to slicing-Z = y*sin(theta) + z*cos(theta). The parts + // occupy slicing-Z in [y_min*sin, y_max*sin + z_max*cos], and the bar's + // FULL-cross-section region (the part not in a triangular end ramp) spans + // slicing-Z [belt_start*sin + H*cos, belt_end*sin]. Covering the parts' + // whole band needs belt_start <= y_min - H*cot (bar's own leading ramp) and + // belt_end >= y_max + z_max*cot (parts' top features print further up the + // belt). The trailing z_max*cot term dominates the bar's own ramp. + // + // Along the belt the bar stops at the end of the plate: a longer bar cannot be + // printed, and the cross-sections it loses there are reported by the purge + // planner when the parts' last layers then purge more than the bar holds. + const double margin = 5.; + const double ramp_compensation = height / sin_t; + const double belt_origin = plate_origin[belt_is_y ? 1 : 0]; + double belt_end = belt_max + margin + ramp_compensation + z_max * cot_t; // + parts' top-feature belt reach + if (bed_ext.defined) + belt_end = std::min(belt_end, belt_origin + (belt_is_y ? bed_ext.max.y() : bed_ext.max.x()) - inset); + const double belt_start = std::max(belt_origin, std::min(belt_min - ramp_compensation, belt_end - 10.)); // leading ramp, toward belt origin + const double length = std::max(belt_end - belt_start, 10.); + belt_end = belt_start + length; + const double belt_center = 0.5 * (belt_start + belt_end); + + // Across-belt: flush against the bed's maximum edge, inset by half the bar + // width so the bar's far edge sits on the boundary and the whole bar stays + // on the bed. lat_min/lat_max come from instance_bounding_box (world frame). + const double lat_origin = plate_origin[belt_is_y ? 0 : 1]; + double lat_center = lat_max + 5. + 0.5 * width; // fallback: just past the parts + if (bed_ext.defined) { + const double bed_lat_max = belt_is_y ? bed_ext.max.x() : bed_ext.max.y(); + lat_center = lat_origin + bed_lat_max - inset - 0.5 * width; + } + + plan.n_islands = n_islands; + plan.w_sub = (width - (n_islands - 1) * gap) / n_islands; + plan.length = length; + plan.height = height; + plan.center = Vec3d(belt_is_y ? lat_center : belt_center, + belt_is_y ? belt_center : lat_center, + 0.5 * height); + } + + // --- Decide --------------------------------------------------------------- + // A plate whose prism exists and matches its recorded inputs is left alone, so + // subsequent ticks are no-ops until the parts/config actually change. + std::vector to_delete = stale_prisms; + std::vector to_create; + for (int plate_idx = 0; plate_idx < plate_count; ++plate_idx) { + const Plan &plan = plans[size_t(plate_idx)]; + const std::vector &existing = prisms_by_plate[size_t(plate_idx)]; + BeltPurgeSignature &sig = sigs[size_t(plate_idx)]; + if (!plan.wanted) { + sig = BeltPurgeSignature{}; + to_delete.insert(to_delete.end(), existing.begin(), existing.end()); + } else if (existing.size() == 1 && model.objects[size_t(existing.front())]->instances.size() == 1 && plan.sig == sig) { + continue; + } else { + to_delete.insert(to_delete.end(), existing.begin(), existing.end()); + to_create.push_back(plate_idx); + } + } + if (to_delete.empty() && to_create.empty()) + return false; + + remove_prisms(to_delete); + + // --- (Re)create ----------------------------------------------------------- + // Build the prism as N DISCONNECTED sub-bars side by side across the belt, + // N = (filaments - 1) = the worst-case number of toolchanges on one layer. + // Why: mark_wiping_extrusions overrides whole extrusion-entity COLLECTIONS, + // and each disconnected island slices into its own infill collection. With a + // single solid box there is one collection per layer, so the FIRST toolchange + // on a layer grabs the entire collection (consuming all of it for one swap) + // and any further swaps on that layer find nothing left and go unabsorbed + // (the classic wipe tower hides this by spilling leftover into the real + // tower; the belt prism has no fallback). One island per simultaneous swap + // lets each swap claim its own island. Total lateral footprint stays `width` + // (each island width/N wide, separated by a small gap), so per-layer capacity + // per island ~= max_flush, matching the height sizing. + // Minimal gap between sub-bars: they must stay just-separated so the slicer + // keeps them as distinct islands (hence distinct infill collections, one per + // simultaneous swap). Zero gap would union them into one collection and + // reintroduce the multi-swap-per-layer absorption bug; a hair over ~2 line + // widths also keeps gap-fill from bridging them. 1 mm is about as close as + // they can butt up while staying individually purgeable. + for (int plate_idx : to_create) { + const Plan &plan = plans[size_t(plate_idx)]; + + TriangleMesh prism_mesh; + for (int i = 0; i < plan.n_islands; ++i) { + const double lat_off = i * (plan.w_sub + gap); + // Box dims: lateral = w_sub, along-belt = length, vertical = height. + TriangleMesh box = belt_is_y ? make_cube(plan.w_sub, plan.length, plan.height) // X = lateral, Y = belt + : make_cube(plan.length, plan.w_sub, plan.height); // X = belt, Y = lateral + box.translate(belt_is_y ? Vec3f((float) lat_off, 0.f, 0.f) : Vec3f(0.f, (float) lat_off, 0.f)); + prism_mesh.merge(box); + } + + ModelObject *new_object = model.add_object(); + new_object->name = _u8L("Belt Purge Tower"); + new_object->add_instance(); + ModelVolume *new_volume = new_object->add_volume(std::move(prism_mesh)); + new_volume->name = new_object->name; + + auto &cfg = new_object->config; + cfg.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true)); + cfg.set_key_value("flush_into_objects", new ConfigOptionBool(true)); + cfg.set_key_value("extruder", new ConfigOptionInt(1)); + // Sacrificial solid prism: one wall, no shells, dense rectilinear infill — + // every extrusion is overriddable, so the absorbed volume matches the + // cross-section x layer-height estimate used for the height above. + cfg.set_key_value("wall_loops", new ConfigOptionInt(1)); + cfg.set_key_value("top_shell_layers", new ConfigOptionInt(0)); + cfg.set_key_value("bottom_shell_layers", new ConfigOptionInt(0)); + cfg.set_key_value("sparse_infill_density", new ConfigOptionPercent(100)); + cfg.set_key_value("sparse_infill_pattern", new ConfigOptionEnum(ipRectilinear)); + cfg.set_key_value("enable_support", new ConfigOptionBool(false)); + cfg.set_key_value("brim_type", new ConfigOptionEnum(btNoBrim)); + cfg.set_key_value("seam_slope_type", new ConfigOptionEnum(SeamScarfType::None)); + cfg.set_key_value("precise_z_height", new ConfigOptionBool(false)); + + // Position by the belt-calibration pattern: drop to the bed, then translate + // the instance by the delta between the object's ACTUAL bbox center and the + // target. Setting the instance offset directly is unreliable here — the + // freshly added cube's local frame is not centered, so set_offset() lands + // the min corner (not the center) on the target, leaving the bar centered + // on the bed edge with half of it hanging off. + new_object->invalidate_bounding_box(); + new_object->ensure_on_bed(); + const BoundingBoxf3 cur = new_object->bounding_box_exact(); + new_object->translate_instances(Vec3d(plan.center.x() - cur.center().x(), + plan.center.y() - cur.center().y(), + 0.0)); + new_object->instances.front()->set_assemble_transformation(new_object->instances.front()->get_transformation()); + + const size_t obj_idx = model.objects.size() - 1; + // Registers the object in the sidebar and notifies the part plates; + // selection is left untouched (auto-managed object). + obj_list->add_object_to_list(obj_idx, /*call_selection_changed=*/false); + + sigs[size_t(plate_idx)] = plan.sig; + } + + return true; +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/BeltPurgeTower.hpp b/src/slic3r/GUI/BeltPurgeTower.hpp new file mode 100644 index 0000000000..a009eb4d4b --- /dev/null +++ b/src/slic3r/GUI/BeltPurgeTower.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include + +// ORCA-Belt: auto-managed purge prism for belt printers. +// +// Kept in its own translation unit (not buried in Plater.cpp) so it stays out +// of the way of unrelated upstream changes and carries no regression risk for +// normal printers: nothing here runs unless the printer is a belt printer with +// the belt purge tower enabled. See Slic3r::GUI::ensure_belt_purge_tower(). + +namespace Slic3r { +class Model; +class PartPlateList; +namespace GUI { +class ObjectList; + +// Inputs of the last belt purge prism generation. Idempotence is keyed on these +// (not the prism's resulting bbox): the prism gets nudged by plate assignment +// after creation, so comparing its bbox would falsely detect a change and +// regenerate it on every background-process tick — which would re-invalidate a +// freshly sliced result and make the G-code preview unreachable. +struct BeltPurgeSignature +{ + bool valid = false; + int filament_count = 0; + long key[14] = {0}; // rounded geometry, plate and bed inputs (0.1 mm units) + bool operator==(const BeltPurgeSignature &o) const + { + if (valid != o.valid || filament_count != o.filament_count) + return false; + for (int i = 0; i < 14; ++i) + if (key[i] != o.key[i]) + return false; + return true; + } +}; + +// Keep the auto-generated belt purge prisms, one per plate, in sync with the +// current config and plate contents. Creates / updates / removes the marked prism +// ModelObjects; `sigs` holds the last inputs per plate index. +// Returns true when the model was mutated (caller should refresh the scene). +// Runs on every background-process update, so it is idempotent: it only mutates +// the model when the desired prism differs from the cached signature in `sig`. +bool ensure_belt_purge_tower(Model &model, PartPlateList &partplate_list, ObjectList *obj_list, std::vector &sigs); + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/CalibrationWizardPage.cpp b/src/slic3r/GUI/CalibrationWizardPage.cpp index 7017d52e34..d80bf4edf7 100644 --- a/src/slic3r/GUI/CalibrationWizardPage.cpp +++ b/src/slic3r/GUI/CalibrationWizardPage.cpp @@ -21,7 +21,6 @@ #include "slic3r/GUI/PresetComboBoxes.hpp" #include "slic3r/GUI/wxExtensions.hpp" #include -#include "slic3r/GUI/Widgets/HyperLink.hpp" #include #include #include "slic3r/GUI/Widgets/PopupWindow.hpp" @@ -494,7 +493,10 @@ void CaliPageCaption::init_bitmaps() { void CaliPageCaption::create_wiki(wxWindow* parent) { // ORCA standardized HyperLink - m_wiki_text = new HyperLink(parent, _L("Wiki Guide")); + m_wiki_text = new Button(parent, "", "toolbar_wiki", 0, 15); + m_wiki_text->SetToolTip(_L("Wiki Guide")); + m_wiki_text->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + m_wiki_text->SetCanFocus(false); m_wiki_text->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& e) { if (!m_wiki_url.empty()) wxLaunchDefaultBrowser(m_wiki_url); diff --git a/src/slic3r/GUI/CalibrationWizardPage.hpp b/src/slic3r/GUI/CalibrationWizardPage.hpp index 8624d7c9f7..8f95137e4d 100644 --- a/src/slic3r/GUI/CalibrationWizardPage.hpp +++ b/src/slic3r/GUI/CalibrationWizardPage.hpp @@ -7,7 +7,6 @@ #include "Widgets/TextInput.hpp" #include "Widgets/AMSControl.hpp" #include "Widgets/ProgressBar.hpp" -#include "Widgets/HyperLink.hpp" #include "wxExtensions.hpp" #include "PresetComboBoxes.hpp" @@ -155,7 +154,7 @@ private: void init_bitmaps(); void create_wiki(wxWindow* parent); - HyperLink* m_wiki_text; // ORCA + Button* m_wiki_text; // ORCA wxString m_wiki_url; ScalableBitmap m_prev_bmp_normal; ScalableBitmap m_prev_bmp_hover; diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index 8ca79e6b64..b156f32cb8 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -16,6 +16,7 @@ #include "slic3r/GUI/GUI.hpp" #include "libslic3r/libslic3r.h" #include "slic3r/GUI/Field.hpp" +#include "libslic3r/BeltBrim.hpp" #include #include #include @@ -351,6 +352,31 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con bool is_object_config = (!is_global_config && !is_plate_config); + // Belt printer: a raft and a draft shield are refused by Print::validate(), and + // the fields that would clear them are greyed out in belt mode, so a preset that + // carries either could not be sliced at all. Reset them instead of only disabling + // the fields. + if (GUI::wxGetApp().preset_bundle != nullptr) { + const auto *belt_opt = GUI::wxGetApp().preset_bundle->printers.get_edited_preset().config.option("belt_printer"); + const auto *raft_opt = config->option("raft_layers"); + const auto *shield_opt = config->option>("draft_shield"); + const bool has_raft = raft_opt != nullptr && raft_opt->value > 0; + const bool has_shield = shield_opt != nullptr && shield_opt->value != dsDisabled; + if (belt_opt != nullptr && belt_opt->value && (has_raft || has_shield)) { + const wxString msg_text = _(L("Raft and draft shield are not available on belt printers.\nThey have been disabled.")); + MessageDialog dialog(m_msg_dlg_parent, msg_text, "", wxICON_WARNING | wxOK); + DynamicPrintConfig new_conf = *config; + is_msg_dlg_already_exist = true; + dialog.ShowModal(); + if (has_raft) + new_conf.set_key_value("raft_layers", new ConfigOptionInt(0)); + if (has_shield) + new_conf.set_key_value("draft_shield", new ConfigOptionEnum(dsDisabled)); + apply(config, &new_conf); + is_msg_dlg_already_exist = false; + } + } + // layer_height shouldn't be equal to zero auto layer_height = config->opt_float("layer_height"); if (layer_height < EPSILON) @@ -754,9 +780,31 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in const bool gcf_is_marlin_firmware = gcflavor == GCodeFlavor::gcfMarlinFirmware; 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 &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; float have_volumetric_extrusion_rate_slope_segment_length = config->option("max_volumetric_extrusion_rate_slope_segment_length")->value; - toggle_field("enable_arc_fitting", !have_volumetric_extrusion_rate_slope); + toggle_field("enable_arc_fitting", !have_volumetric_extrusion_rate_slope && !is_belt_printer); toggle_line("max_volumetric_extrusion_rate_slope_segment_length", have_volumetric_extrusion_rate_slope); toggle_line("extrusion_rate_smoothing_external_perimeter_only", have_volumetric_extrusion_rate_slope); if(have_volumetric_extrusion_rate_slope) config->set_key_value("enable_arc_fitting", new ConfigOptionBool(false)); @@ -923,27 +971,47 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in } } - bool have_skirt = config->opt_int("skirt_loops") > 0; + // Belt printer: disable skirt, brim, raft, and draft shield controls. + bool have_skirt = config->opt_int("skirt_loops") > 0 && !is_belt_printer; toggle_field("skirt_height", have_skirt && config->opt_enum("draft_shield") != dsEnabled); toggle_line("single_loop_draft_shield", have_skirt); // ORCA: Display one wall if skirt enabled for (auto el : {"skirt_type", "min_skirt_length", "skirt_distance", "skirt_start_angle", "skirt_speed", "draft_shield"}) toggle_field(el, have_skirt); + if (is_belt_printer) { + toggle_field("skirt_loops", false); + toggle_field("skirt_height", false); + } - bool have_brim = (config->opt_enum("brim_type") != btNoBrim); + // 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); const BrimType brim_type = config->opt_enum("brim_type"); - const bool have_auto_brim_ear = brim_type == btEar; - const bool have_painted_brim_ear = brim_type == btPainted; + const bool have_auto_brim_ear = brim_type == btEar && !is_belt_tilted; + const bool have_painted_brim_ear = brim_type == btPainted && !is_belt_tilted; set_option_label("brim_width", have_auto_brim_ear ? _L("Brim ear radius") : _L("Brim width")); const auto brim_width = config->opt_float("brim_width"); // Automatic brim ear settings require a non-zero brim width. @@ -958,7 +1026,9 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in // Hide Elephant foot compensation layers if elefant_foot_compensation is not enabled toggle_line("elefant_foot_compensation_layers", config->opt_float("elefant_foot_compensation") > 0 || config->option("elefant_foot_layers_density")->get_abs_value(1.0f) < 1.0f); - bool have_raft = config->opt_int("raft_layers") > 0; + bool have_raft = config->opt_int("raft_layers") > 0 && !is_belt_printer; + if (is_belt_printer) + toggle_field("raft_layers", false); bool have_support_material = config->opt_bool("enable_support") || have_raft; SupportType support_type = config->opt_enum("support_type"); @@ -1066,33 +1136,41 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in toggle_line("preheat_steps", have_ooze_prevention && (preheat_steps > 0)); bool have_prime_tower = config->opt_bool("enable_prime_tower"); + // ORCA-Belt: belt printers replace the classic wipe tower with the + // auto-generated belt purge prism, enabled by the belt-only printer option + // enable_belt_purge_tower. Its width (a process option) is only relevant + // then. (is_belt_printer is computed at the top of this function.) + const bool have_belt_purge_tower = is_belt_printer + && preset_bundle->printers.get_edited_preset().config.has("enable_belt_purge_tower") + && preset_bundle->printers.get_edited_preset().config.opt_bool("enable_belt_purge_tower"); + toggle_line("belt_purge_tower_width", have_belt_purge_tower); for (auto el : {"prime_tower_width", "prime_tower_brim_width", "prime_tower_skip_points", "wipe_tower_wall_type", "prime_tower_infill_gap","prime_tower_enable_framework", "enable_tower_interface_features"}) - toggle_line(el, have_prime_tower); + toggle_line(el, have_prime_tower && !is_belt_printer); toggle_line("enable_tower_interface_cooldown_during_tower", - have_prime_tower && config->opt_bool("enable_tower_interface_features")); + have_prime_tower && !is_belt_printer && config->opt_bool("enable_tower_interface_features")); bool purge_in_primetower = preset_bundle->printers.get_edited_preset().config.opt_bool("purge_in_prime_tower"); for (auto el : {"wipe_tower_rotation_angle", "wipe_tower_cone_angle", "wipe_tower_extra_spacing", "wipe_tower_max_purge_speed", "wipe_tower_bridging", "wipe_tower_extra_flow"}) - toggle_line(el, have_prime_tower && supports_wipe_tower_2); + toggle_line(el, have_prime_tower && supports_wipe_tower_2 && !is_belt_printer); // Orca: both tower generators skip sparse layers, so this is not a wipe tower 2 exclusive. - toggle_line("wipe_tower_no_sparse_layers", have_prime_tower); + toggle_line("wipe_tower_no_sparse_layers", have_prime_tower && !is_belt_printer); // Dropping the sparse layers outright leaves nothing to combine, so the two are exclusive. - toggle_line("wipe_tower_sparse_layers_combination", have_prime_tower && !config->opt_bool("wipe_tower_no_sparse_layers")); + toggle_line("wipe_tower_sparse_layers_combination", have_prime_tower && !is_belt_printer && !config->opt_bool("wipe_tower_no_sparse_layers")); WipeTowerWallType wipe_tower_wall_type = config->opt_enum("wipe_tower_wall_type"); - bool have_rib_wall = (wipe_tower_wall_type == WipeTowerWallType::wtwRib)&&have_prime_tower; - toggle_line("wipe_tower_cone_angle", have_prime_tower && supports_wipe_tower_2 && wipe_tower_wall_type == WipeTowerWallType::wtwCone); + bool have_rib_wall = (wipe_tower_wall_type == WipeTowerWallType::wtwRib)&&have_prime_tower&&!is_belt_printer; + toggle_line("wipe_tower_cone_angle", have_prime_tower && supports_wipe_tower_2 && !is_belt_printer && wipe_tower_wall_type == WipeTowerWallType::wtwCone); toggle_line("wipe_tower_extra_rib_length", have_rib_wall); toggle_line("wipe_tower_rib_width", have_rib_wall); toggle_line("wipe_tower_fillet_wall", have_rib_wall); - toggle_field("prime_tower_width", have_prime_tower && !have_rib_wall); + toggle_field("prime_tower_width", have_prime_tower && !have_rib_wall && !is_belt_printer); - toggle_line("single_extruder_multi_material_priming", !bSEMM && have_prime_tower && supports_wipe_tower_2); + toggle_line("single_extruder_multi_material_priming", !bSEMM && have_prime_tower && supports_wipe_tower_2 && !is_belt_printer); bool use_cyclic_ordering = config->opt_enum("toolchange_ordering") == ToolChangeOrderingType::Cyclic; toggle_line("toolchange_cyclic_order", use_cyclic_ordering); @@ -1188,8 +1266,10 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in toggle_line("small_area_infill_flow_compensation_model", have_small_area_infill_flow_compensation); - toggle_field("seam_slope_type", !has_spiral_vase); - bool has_seam_slope = !has_spiral_vase && config->opt_enum("seam_slope_type") != SeamScarfType::None; + // Belt printers: the scarf would start one layer back along the belt, inside the + // previous layer (GCode::extrude_loop skips it there too). + toggle_field("seam_slope_type", !has_spiral_vase && !is_belt_printer); + bool has_seam_slope = !has_spiral_vase && !is_belt_printer && config->opt_enum("seam_slope_type") != SeamScarfType::None; toggle_line("seam_slope_conditional", has_seam_slope); toggle_line("seam_slope_start_height", has_seam_slope); toggle_line("seam_slope_entire_loop", has_seam_slope); diff --git a/src/slic3r/GUI/FilamentGroupPopup.cpp b/src/slic3r/GUI/FilamentGroupPopup.cpp index d8442071b4..bb081a6fc1 100644 --- a/src/slic3r/GUI/FilamentGroupPopup.cpp +++ b/src/slic3r/GUI/FilamentGroupPopup.cpp @@ -4,7 +4,6 @@ #include "wx/dcgraph.h" #include "I18N.hpp" #include "PartPlate.hpp" -#include "Widgets/HyperLink.hpp" #include #include "libslic3r/Config.hpp" #include "libslic3r/PrintConfig.hpp" @@ -27,6 +26,7 @@ #include "slic3r/GUI/Widgets/StaticBox.hpp" #include #include "slic3r/GUI/Widgets/SwitchButton.hpp" +#include "slic3r/GUI/Widgets/Button.hpp" #include namespace Slic3r { namespace GUI { @@ -203,18 +203,24 @@ FilamentGroupPopup::FilamentGroupPopup(wxWindow *parent) : PopupWindow(parent, w { wxBoxSizer *button_sizer = new wxBoxSizer(wxHORIZONTAL); // ORCA Unified hyperlinks - video_link = new HyperLink(this, _L("Video tutorial")); - video_link->Bind(wxEVT_LEFT_DOWN, [](wxMouseEvent& e) - { - play_dual_extruder_slice_video(); - wxGetApp().app_config->set("play_slicing_video", "false"); - }); - button_sizer->Add(video_link, 0, wxLEFT, horizontal_margin + FromDIP(3)); - button_sizer->AddStretchSpacer(); - wiki_link = new HyperLink(this, _L("Wiki Guide")); - wiki_link->Bind(wxEVT_LEFT_DOWN, [](wxMouseEvent&) { open_filament_group_wiki(); }); - button_sizer->Add(wiki_link, 0, wxLEFT, horizontal_margin); + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + "https://e.bambulab.com/t?c=mOkvsXkJ9pldGYp9"); + wiki_btn->SetStyle(ButtonStyle::Confirm, ::ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([](auto& e) {open_filament_group_wiki();})); + + auto video_btn = new Button(this, "", "toolbar_video_guide", 0, 15); + video_btn->SetToolTip(_L("Video Guide")); + video_btn->SetStyle(ButtonStyle::Confirm, ::ButtonType::Circle); + video_btn->SetCanFocus(false); + video_btn->Bind(wxEVT_LEFT_DOWN, ([](auto& e) { + play_dual_extruder_slice_video(); + wxGetApp().app_config->set("play_slicing_video", "false"); + })); + + button_sizer->Add(wiki_btn , 0, wxLEFT, horizontal_margin + FromDIP(3)); + button_sizer->Add(video_btn, 0, wxLEFT, FromDIP(10)); top_sizer->Add(button_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT, horizontal_margin); } diff --git a/src/slic3r/GUI/FilamentGroupPopup.hpp b/src/slic3r/GUI/FilamentGroupPopup.hpp index 09b6b8d344..bc43876af7 100644 --- a/src/slic3r/GUI/FilamentGroupPopup.hpp +++ b/src/slic3r/GUI/FilamentGroupPopup.hpp @@ -29,6 +29,7 @@ public: FilamentGroupPopup(wxWindow *parent); void tryPopup(Plater* plater,PartPlate* plate, bool slice_all); void tryClose(); + void Dismiss() override; FilamentMapMode GetSelectedMode() const { return m_mode; } private: @@ -40,7 +41,6 @@ private: void OnLeaveWindow(wxMouseEvent &); void OnEnterWindow(wxMouseEvent &); void OnTimer(wxTimerEvent &event); - void Dismiss(); void CreateBmps(); @@ -80,10 +80,6 @@ private: wxBitmap unchecked_hover_bmp; wxBitmap global_tag_bmp; - - wxStaticText *wiki_link; - wxStaticText *video_link; - StaticBox *m_smart_filament_panel{nullptr}; wxSizerItem *m_smart_filament_spacer{nullptr}; SwitchButton *m_smart_filament_switch{nullptr}; diff --git a/src/slic3r/GUI/GCodeViewer.cpp b/src/slic3r/GUI/GCodeViewer.cpp index 40d7e2e0ed..c315786858 100644 --- a/src/slic3r/GUI/GCodeViewer.cpp +++ b/src/slic3r/GUI/GCodeViewer.cpp @@ -14,6 +14,7 @@ //BBS: add convex hull logic for toolpath check #include "GUI_App.hpp" +#include "Shortcuts.hpp" #include "Plater.hpp" #include "Camera.hpp" #include "I18N.hpp" @@ -22,6 +23,8 @@ #include "GLCanvas3D.hpp" #include "FilamentGroupPopup.hpp" #include "GLToolbar.hpp" +#include "libslic3r/BeltTransform.hpp" +#include "libslic3r/GCode/MachineFrameTransform.hpp" #include "MsgDialog.hpp" #include #include "slic3r/GUI/MeshUtils.hpp" @@ -36,6 +39,7 @@ #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/GLModel.hpp" #include "slic3r/GUI/GLShader.hpp" +#include "slic3r/GUI/Gizmos/GLGizmoUtils.hpp" #include "libslic3r/Point.hpp" #include #include "libvgcode/include/Viewer.hpp" @@ -88,9 +92,11 @@ #include #include +#include #include #include #include +#include #include "libslic3r/AppConfig.hpp" #include "libslic3r/BoundingBox.hpp" #include "libslic3r/CutUtils.hpp" @@ -1323,6 +1329,52 @@ std::vector GCodeViewer::get_plater_extruder() return m_plater_extruder; } +// Belt printers: compute the full machine->model back-transform from the print +// config, so the "designed" (upright) G-code preview maps each toolpath vertex +// back to Cartesian space. The G-code forward pipeline is (BeltKinematics:: +// to_machine_coords): gcode = MachineFrame( AxisRemap( X ) ), with X the model +// already un-rotated to Cartesian by the back-transform. So the inverse is: +// model = AxisRemap^-1 . MachineFrame^-1 +// (origin-snap is a per-instance translation that only shifts position, not +// orientation, so it is intentionally omitted.) +static Transform3d compute_belt_back_transform(const PrintConfig& cfg) +{ + if (!cfg.belt_printer.value) + return Transform3d::Identity(); + + MachineFrameTransform mft; + mft.init_from_config(cfg); + const Transform3d mf_inv = mft.is_active() ? Transform3d(mft.transform().inverse()) + : Transform3d::Identity(); + + // Forward G-code axis remap as an affine matrix (out_i = sign * in[r_i % 3], plus a + // build-volume offset for Rev axes). This is the matrix form of the per-point + // GCodeWriter::apply_axis_remap (row convention: each OUTPUT axis selects an input + // axis + sign) and MUST stay in sync with it. The build-volume max matches what the + // writer is fed in GCode.cpp (printable_area max + printable_height). (Follow-up: + // precompute this matrix once in GCodeWriter and share it with apply_axis_remap to + // remove the parallel encoding.) + Transform3d ar = Transform3d::Identity(); + const int rr[3] = { int(cfg.gcode_remap_x.value), int(cfg.gcode_remap_y.value), int(cfg.gcode_remap_z.value) }; + if (rr[0] != 0 || rr[1] != 1 || rr[2] != 2) { + BoundingBoxf bbox_bed(cfg.printable_area.values); + const Vec3d vmax(bbox_bed.max.x(), bbox_bed.max.y(), cfg.printable_height.value); + Matrix3d M = Matrix3d::Zero(); + Vec3d t = Vec3d::Zero(); + for (int i = 0; i < 3; ++i) { + const int axis = rr[i] % 3; + if (rr[i] < 3) M(i, axis) = 1.0; + else if (rr[i] < 6) M(i, axis) = -1.0; + else { M(i, axis) = -1.0; t[i] = vmax[axis]; } + } + ar.linear() = M; + ar.translation() = t; + } + const Transform3d ar_inv = ar.inverse(); + + return ar_inv * mf_inv; +} + //BBS: always load shell at preview void GCodeViewer::load_as_gcode(const GCodeProcessorResult& gcode_result, const Print& print, const std::vector& str_tool_colors, const std::vector& str_color_print_colors, const BuildVolume& build_volume, @@ -1330,6 +1382,15 @@ void GCodeViewer::load_as_gcode(const GCodeProcessorResult& gcode_result, const { m_loaded_as_preview = false; + // Belt printers: drive the designed/raw view UI (legend checkbox, hotkey B, canvas-toolbar + // menu item) from the G-code itself. Only BeltGCode writes the belt header, so its tilt + // (gcode_result.belt_tilt_angle, abs of the slicing rotation) says whether this is belt + // G-code; the selected printer does not, for a file opened from disk. The back-transform + // below still reads print.config(), which Plater::load_gcode() fills from the file's own + // config block (GCodeProcessor::export_config_for_render). + m_belt_view_enabled = gcode_result.belt_tilt_angle > 0.f; + m_belt_angle_deg = gcode_result.belt_tilt_angle; + const bool current_top_layer_only = m_viewer.is_top_layer_only_view_range(); const bool required_top_layer_only = get_app_config()->get_bool("seq_top_layer_only"); if (current_top_layer_only != required_top_layer_only) @@ -1339,8 +1400,12 @@ void GCodeViewer::load_as_gcode(const GCodeProcessorResult& gcode_result, const m_viewer.set_dim_previous_layers(get_app_config()->get_bool("preview_dim_previous_layers")); m_viewer.set_dim_previous_layers_brightness(0.01f * std::stoi(get_app_config()->get("preview_dim_previous_layers_brightness"))); - // avoid processing if called with the same gcode_result - if (m_last_result_id == gcode_result.id && wxGetApp().is_editor()) { + // avoid processing if called with the same gcode_result. + // On a belt printer the toolpath geometry fed to libvgcode also depends on the + // designed/raw view state (the back-transform is applied in convert), so the + // same result is converted again only when that view has been toggled. + const bool same_belt_view = !m_belt_view_enabled || m_last_belt_show_designed == m_belt_show_designed; + if (m_last_result_id == gcode_result.id && wxGetApp().is_editor() && same_belt_view) { //BBS: add logs BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": the same id %1%, return directly, result %2% ") % m_last_result_id % (&gcode_result); @@ -1381,8 +1446,112 @@ void GCodeViewer::load_as_gcode(const GCodeProcessorResult& gcode_result, const return; } - // convert data from PrusaSlicer format to libvgcode format - libvgcode::GCodeInputData data = libvgcode::convert(gcode_result, str_tool_colors, str_color_print_colors, m_viewer); + // convert data from PrusaSlicer format to libvgcode format. + // Belt printers: when the designed (upright) view is active, back-transform + // the toolpath geometry into model/Cartesian space using the general belt + // inverse (handles the mesh rotation, shear and axis remap). When off, the + // raw machine-frame G-code is shown (useful for checking the transform itself). + const bool is_belt = m_belt_view_enabled && print.config().belt_printer.value; + Transform3d belt_inv = (is_belt && m_belt_show_designed) + ? compute_belt_back_transform(print.config()) : Transform3d::Identity(); + // Belt: move positions are stored as gcode_Z + belt_z_origin (the start G-code's + // purge-blob advance baked into the machine-Z origin by its G92 Z0 resets). Subtract + // that constant before the linear back-transform so every toolpath maps to the model's + // belt coordinate. Without it the back-transform mixes the offset with the gantry-Y + // term, leaving a per-move designed-Y error that min-corner anchoring cannot remove + // when a bridge/keel move happens to cancel it at the bbox minimum. + if (is_belt && m_belt_show_designed && gcode_result.belt_z_origin != 0.0f) + belt_inv = belt_inv * Transform3d(Eigen::Translation3d(Vec3d(0.0, 0.0, -double(gcode_result.belt_z_origin)))); + const bool apply_belt = is_belt && m_belt_show_designed + && !belt_inv.matrix().isApprox(Transform3d::Identity().matrix()); + if (apply_belt) { + // The linear belt back-transform recovers the print's shape and orientation but not + // the per-object placement/lift translation: the object's position on the belt, the + // BeltSliceStrategy min-Z lift, and the centering pre-translate are applied OUTSIDE + // build_forward_transform() (see PrintObjectSlice.cpp), so its linear inverse leaves + // them un-undone. Uncorrected, the upright toolpaths float a fixed offset from the + // model shell. Recover the translation generally — independent of the offset's exact + // source or the axis remap — by anchoring the back-transformed object body onto the + // upright model bounding box (the same space the shells render in). + BoundingBoxf3 model_bb; + for (const PrintObject* po : print.objects()) + for (const ModelInstance* mi : po->model_object()->instances) + model_bb.merge(po->model_object()->instance_bounding_box(*mi)); + // Build the anchor bbox from surface toolpaths only. After the belt_z_origin + // correction the surface back-transforms onto the model, but a few elevated + // features (bridges/overhangs over the chevron gap) are mis-mapped by the linear + // inverse to well outside the model body; if one becomes the bbox minimum it + // drags the min-corner anchor by ~20mm. Drop moves that land clearly outside the + // (correct) model bbox — a geometric filter, not a role guess. + // Tolerance around the model bbox for outlier rejection, and the minimum fraction + // of MOVES (by count) the clip must retain before we trust it over the full bbox. + // Count, not bbox extent: a single far-flung outlier move (e.g. a belt-entry purge + // that maps to y~0) inflates the full bbox so much that a size-fraction test would + // wrongly reject the clip and re-include the outlier, dragging the min-corner anchor + // by ~the offset. By count the clip keeps ~100% of moves here and ~0% only when the + // object is genuinely offset from the belt entry (the real fall-back-to-full case). + constexpr double ANCHOR_CLIP_MARGIN_MM = 10.0; + constexpr double ANCHOR_CLIP_MIN_KEEP = 0.5; + BoundingBoxf3 tp_bb_clip, tp_bb_full; + size_t n_filtered = 0, n_clip = 0; + const double y_lo = model_bb.defined ? model_bb.min.y() - ANCHOR_CLIP_MARGIN_MM : -1e30; + const double y_hi = model_bb.defined ? model_bb.max.y() + ANCHOR_CLIP_MARGIN_MM : 1e30; + // The anchor aligns the toolpath body onto the object-only model bbox, so it must be + // built from OBJECT-body toolpaths only. Support, skirt, brim and wipe-tower extrusions + // are not part of the model mesh and extend past it (support especially reaches well + // beyond the object on a belt), so including them drags tp_bb.min and shifts the whole + // back-transformed g-code off the mesh — but only when those features are present. That + // is the "g-code shifts vs mesh as soon as supports are enabled" bug. Filter them out. + auto is_object_body = [](ExtrusionRole r) { + return r != erSupportMaterial && r != erSupportMaterialInterface + && r != erSupportTransition + && r != erSkirt && r != erBrim && r != erWipeTower; + }; + auto accumulate = [&](bool body_only) { + tp_bb_clip = BoundingBoxf3(); + tp_bb_full = BoundingBoxf3(); + n_filtered = 0; + n_clip = 0; + for (const GCodeProcessorResult::MoveVertex& mv : gcode_result.moves) + if (mv.type == EMoveType::Extrude && mv.layer_id >= 1 // skip layer-0 prime/skirt + && (!body_only || is_object_body(mv.extrusion_role))) { + ++n_filtered; + const Vec3d p = belt_inv * mv.position.cast(); + tp_bb_full.merge(p); + if (p.y() >= y_lo && p.y() <= y_hi) { + tp_bb_clip.merge(p); + ++n_clip; + } + } + }; + accumulate(/*body_only=*/true); + // Fallback: a plate with no object-body extrusions above layer 0 (e.g. a + // support-only object) would leave tp_bb undefined and silently skip the + // anchor. Re-accumulate over ALL extrude moves so the min-corner anchor is + // still computed rather than letting the preview float by the placement offset. + if (n_filtered == 0) + accumulate(/*body_only=*/false); + // Use the clipped bbox when it still holds the bulk of the moves (outliers removed). + // If the object sits far from the belt entry the toolpaths are grossly offset and the + // clip drops most of them — fall back to the full bbox so the min-corner anchor still + // recovers that gross translation rather than breaking. + const BoundingBoxf3& tp_bb = + (tp_bb_clip.defined && n_filtered > 0 && + double(n_clip) >= ANCHOR_CLIP_MIN_KEEP * double(n_filtered)) ? tp_bb_clip : tp_bb_full; + if (model_bb.defined && tp_bb.defined) { + // Anchor the back-transformed toolpath body onto the upright model bbox by + // its MIN corner. (Center anchoring was tried and regressed when the toolpath + // and model bounding boxes differ in extent.) With the belt_z_origin + // correction above and the outlier-robust bbox below, the surface overlaps the + // shell to well under a millimetre when the object is at the belt entry; an + // object placed elsewhere in global-rotation mode still carries the placement + // translation, which this min-corner step recovers. + const Vec3d d = model_bb.min - tp_bb.min; + belt_inv = Transform3d(Eigen::Translation3d(d)) * belt_inv; + } + } + libvgcode::GCodeInputData data = libvgcode::convert(gcode_result, str_tool_colors, str_color_print_colors, m_viewer, + apply_belt ? &belt_inv : nullptr); //#define ENABLE_DATA_EXPORT 1 //#if ENABLE_DATA_EXPORT @@ -1480,6 +1649,26 @@ void GCodeViewer::load_as_gcode(const GCodeProcessorResult& gcode_result, const m_viewer.reset_default_extrusion_roles_colors(); m_viewer.load(std::move(data)); + // Belt printers: libvgcode labels a layer with the height of its toolpaths, which + // on a tilted layer is wherever its last extrusion happened to end, and the layer + // slider looks its labels and the colour-change ticks up in that list assuming it + // increases. Give it the layers' print Z instead (the slicer's layer Z, which + // increases along the belt), numbered the way libvgcode::convert() numbers the + // layers: consecutively over the moves that exist. + m_belt_layer_zs.clear(); + if (is_belt) { + unsigned int src_layer_id = std::numeric_limits::max(); + for (size_t i = 1; i < gcode_result.moves.size(); ++ i) { + const GCodeProcessorResult::MoveVertex &mv = gcode_result.moves[i]; + if (mv.layer_id != src_layer_id) { + src_layer_id = mv.layer_id; + m_belt_layer_zs.emplace_back(double(mv.print_z)); + } + } + if (m_belt_layer_zs.size() != m_viewer.get_layers_count()) + m_belt_layer_zs.clear(); + } + // #if !VGCODE_ENABLE_COG_AND_TOOL_MARKERS // const size_t vertices_count = m_viewer.get_vertices_count(); // m_cog.reset(); @@ -1513,13 +1702,24 @@ void GCodeViewer::load_as_gcode(const GCodeProcessorResult& gcode_result, const m_paths_bounding_box = BoundingBoxf3(libvgcode::convert(bbox[0]).cast(), libvgcode::convert(bbox[1]).cast()); m_max_bounding_box = m_paths_bounding_box; - if (wxGetApp().is_editor()) - m_contained_in_bed = wxGetApp().plater()->build_volume().all_paths_inside(gcode_result, m_paths_bounding_box); + if (wxGetApp().is_editor()) { + if (is_belt) { + // The moves are machine-frame coordinates (Z is belt travel), so the per-move + // test inside all_paths_inside() can never pass on a belt. Judge the + // back-transformed box instead, with room for the designed view's min-corner + // anchor, which is only accurate to a fraction of a millimetre. + BoundingBoxf3 bed = wxGetApp().plater()->build_volume().bounding_volume(); + bed.offset(1.); + m_contained_in_bed = !m_paths_bounding_box.defined || (bed.contains(m_paths_bounding_box.min) && bed.contains(m_paths_bounding_box.max)); + } else + m_contained_in_bed = wxGetApp().plater()->build_volume().all_paths_inside(gcode_result, m_paths_bounding_box); + } m_extruders_count = gcode_result.filaments_count; //BBS: move the id to the end of reset m_last_result_id = gcode_result.id; + m_last_belt_show_designed = m_belt_show_designed; m_gcode_result = &gcode_result; m_move_type_counts.fill(0); for (auto& move_type_times : m_move_type_times) @@ -1550,8 +1750,10 @@ void GCodeViewer::load_as_gcode(const GCodeProcessorResult& gcode_result, const m_custom_gcode_per_print_z = gcode_result.custom_gcode_per_print_z; m_max_print_height = gcode_result.printable_height; + m_machine_frame_transform_active = gcode_result.machine_frame_transform_active; m_z_offset = gcode_result.z_offset; + // load_toolpaths(gcode_result, build_volume, exclude_bounding_box); // ORCA: Apply the default view type now that we know how many tools the G-code actually uses. @@ -1737,6 +1939,7 @@ void GCodeViewer::reset() //BBS: should also reset the result id BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": current result id %1% ")%m_last_result_id; m_last_result_id = -1; + m_belt_layer_zs.clear(); //BBS: add only gcode mode m_only_gcode_in_preview = false; @@ -1745,6 +1948,7 @@ void GCodeViewer::reset() m_paths_bounding_box = BoundingBoxf3(); m_max_bounding_box = BoundingBoxf3(); m_max_print_height = 0.0f; + m_machine_frame_transform_active = false; m_z_offset = 0.0f; m_extruders_count = 0; m_filament_diameters = std::vector(); @@ -3015,7 +3219,14 @@ void GCodeViewer::load_shells(const Print& print, bool initialized, bool force_p void GCodeViewer::render_toolpaths() { const Camera& camera = wxGetApp().plater()->get_camera(); - const libvgcode::Mat4x4 converted_view_matrix = libvgcode::convert(static_cast(camera.get_view_matrix().matrix().cast())); + Matrix4f view = camera.get_view_matrix().matrix().cast(); + // Belt "designed" (upright) view is produced by back-transforming the toolpath + // GEOMETRY into model space at load time (see load_as_gcode -> + // compute_belt_back_transform -> libvgcode::convert). The camera is left untouched + // here; transforming the view as well would double-apply the inverse. Baking the + // transform into the geometry (not the camera) also keeps the bed and toolpaths in + // the same frame so they stay aligned. + const libvgcode::Mat4x4 converted_view_matrix = libvgcode::convert(view); const libvgcode::Mat4x4 converted_projetion_matrix = libvgcode::convert(static_cast(camera.get_projection_matrix().matrix().cast())); #if VGCODE_ENABLE_COG_AND_TOOL_MARKERS m_viewer.set_cog_marker_scale_factor(m_cog_marker_fixed_screen_size ? 10.0f * m_cog_marker_size * camera.get_inv_zoom() : m_cog_marker_size); @@ -3590,27 +3801,6 @@ void GCodeViewer::render_legend_color_arr_recommen(float window_padding) } }; - auto link_filament_group_wiki = [&](const std::string& label) { - ImVec2 wiki_part_size = ImGui::CalcTextSize(label.c_str()); - ImColor HyperColor = ImColor(0, 150, 136, 255); // ORCA match color - ImGui::PushStyleColor(ImGuiCol_Text, HyperColor.Value); - imgui.text(label.c_str()); - ImGui::PopStyleColor(); - - // ORCA use underline to match hyperlink style - ImVec2 lineEnd = ImGui::GetItemRectMax(); - lineEnd.y -= 2.0f; - ImVec2 lineStart = lineEnd; - lineStart.x = ImGui::GetItemRectMin().x; - ImGui::GetWindowDrawList()->AddLine(lineStart, lineEnd, HyperColor); - // click behavior - if (ImGui::IsMouseHoveringRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), true)) { - if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { - open_filament_group_wiki(); - } - } - }; - auto draw_dash_line = [&](ImDrawList* draw_list, int dash_length = 5, int gap_length = 3) { ImVec2 p1 = ImGui::GetCursorScreenPos(); ImVec2 p2 = ImVec2(p1.x + ImGui::GetContentRegionAvail().x, p1.y); @@ -3684,7 +3874,7 @@ void GCodeViewer::render_legend_color_arr_recommen(float window_padding) else tips_count = 5; - float AMS_container_height = ams_item_height + line_height * tips_count + line_height; + float AMS_container_height = ams_item_height + line_height * tips_count + line_height + window_padding * 2.f; ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // this shold be 0 since its child of gcodeviewer ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 1, 1, 1)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(window_padding * 3, 0)); @@ -3699,10 +3889,6 @@ void GCodeViewer::render_legend_color_arr_recommen(float window_padding) ImGui::Dummy({window_padding, window_padding}); ImGui::PushStyleColor(ImGuiCol_Separator, ImVec4(1.0f,1.0f,1.0f,0.6f)); imgui.bold_text(_u8L("Filament Grouping")); - ImGui::SameLine(); - std::string tip_str = _u8L("Why this grouping"); - ImGui::SetCursorPosX(ImGui::GetWindowContentRegionWidth() - window_padding - ImGui::CalcTextSize(tip_str.c_str()).x); - link_filament_group_wiki(tip_str); ImGui::Separator(); ImGui::PopStyleColor(); ImGui::Dummy({window_padding, window_padding}); @@ -3807,6 +3993,9 @@ void GCodeViewer::render_legend_color_arr_recommen(float window_padding) } ImGui::Dummy({window_padding, window_padding}); + GLGizmoUtils::render_wiki_guide_button(*wxGetApp().plater()->get_current_canvas3D(), m_scale,"https://e.bambulab.com/t?c=mOkvsXkJ9pldGYp9"); + ImGui::SameLine(); + if (!is_optimal_group) { link_text_set_to_optional(_u8L("Set to Optimal")); ImGui::SameLine(); @@ -3815,10 +4004,7 @@ void GCodeViewer::render_legend_color_arr_recommen(float window_padding) } link_text(_u8L("Regroup filament")); - ImGui::SameLine(); - std::string wiki_str = _u8L("Wiki Guide"); // ORCA - ImGui::SetCursorPosX(ImGui::GetWindowContentRegionWidth() - window_padding - ImGui::CalcTextSize(wiki_str.c_str()).x); - link_filament_group_wiki(wiki_str); + ImGui::Dummy({window_padding, window_padding}); ImGui::EndChild(); } @@ -5417,6 +5603,7 @@ void GCodeViewer::render_legend(float &legend_height, int canvas_width, int canv if (m_nozzle_nums > 1 && (m_viewer.get_view_type() == libvgcode::EViewType::Summary || m_viewer.get_view_type() == libvgcode::EViewType::ColorPrint)) // ORCA show only on summary and filament tab render_legend_color_arr_recommen(window_padding); + legend_height = ImGui::GetCurrentWindow()->Size.y; imgui.end(); ImGui::PopStyleColor(7); diff --git a/src/slic3r/GUI/GCodeViewer.hpp b/src/slic3r/GUI/GCodeViewer.hpp index 5b6de9512a..934466a268 100644 --- a/src/slic3r/GUI/GCodeViewer.hpp +++ b/src/slic3r/GUI/GCodeViewer.hpp @@ -204,6 +204,10 @@ private: std::vector m_plater_extruder; bool m_gl_data_initialized{ false }; unsigned int m_last_result_id{ 0 }; + // Belt printers: the view the loaded result was converted for (see load_as_gcode). + bool m_last_belt_show_designed{ true }; + // Belt printers: the print Z of each viewer layer, in the viewer's layer numbering. + std::vector m_belt_layer_zs; //BBS: save m_gcode_result as well const GCodeProcessorResult* m_gcode_result; std::array(EMoveType::Count)> m_move_type_counts{}; @@ -224,6 +228,7 @@ private: //BBS: add shell bounding box BoundingBoxf3 m_shell_bounding_box; float m_max_print_height{ 0.0f }; + bool m_machine_frame_transform_active{ false }; float m_z_offset{ 0.0f }; ConfigOptionMode m_user_mode; @@ -354,6 +359,11 @@ private: mutable bool m_no_render_path { false }; bool m_is_dark = false; + bool m_belt_view_enabled = false; + bool m_belt_show_designed = true; // Designed (upright, back-transformed) view by default; off shows + // the raw machine-frame G-code (canvas view menu, hotkey B). + float m_belt_angle_deg = 0.f; + libvgcode::Viewer m_viewer; // ORCA: section view, as the viewer has it. What it cuts away casts no shadow. std::array m_clipping_plane{ 0.0f, 0.0f, 0.0f, 1.0f }; @@ -410,10 +420,15 @@ public: std::vector get_plater_extruder(); const float get_max_print_height() const { return m_max_print_height; } + bool is_machine_frame_transform_active() const { return m_machine_frame_transform_active; } const BoundingBoxf3& get_paths_bounding_box() const { return m_paths_bounding_box; } const BoundingBoxf3& get_max_bounding_box() const { return m_max_bounding_box; } const BoundingBoxf3& get_shell_bounding_box() const { return m_shell_bounding_box; } std::vector get_layers_zs() const { + // Belt printers: the layer Z the slider labels and the colour-change ticks + // use is the layer's print Z (see load_as_gcode), not a toolpath height. + if (! m_belt_layer_zs.empty()) + return m_belt_layer_zs; const std::vector zs = m_viewer.get_layers_zs(); std::vector ret; std::transform(zs.begin(), zs.end(), std::back_inserter(ret), [](float z) { return static_cast(z); }); @@ -489,6 +504,11 @@ public: void export_toolpaths_to_obj(const char* filename) const; + void set_belt_printer(bool enabled, float angle_deg) { m_belt_view_enabled = enabled; m_belt_angle_deg = angle_deg; } + bool is_belt_view() const { return m_belt_view_enabled && m_belt_angle_deg > 0.f; } + void toggle_belt_show_designed() { if (m_belt_view_enabled) m_belt_show_designed = !m_belt_show_designed; } + bool is_belt_show_designed() const { return m_belt_show_designed; } + size_t get_extruders_count() { return m_extruders_count; } void push_combo_style(); void pop_combo_style(); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 12bbe8e92c..c8637fe5af 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -3185,7 +3185,13 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re need_wipe_tower |= dynamic_cast(dconfig.option("enable_wrapping_detection"))->value; } - if (wt && (need_wipe_tower || filaments_count > 1) && !wxGetApp().plater()->only_gcode_mode() && !wxGetApp().plater()->is_gcode_3mf()) { + // Belt printers replace the classic wipe tower with the auto-generated + // belt purge prism (a real model object), so never draw the tower widget. + bool is_belt_printer = false; + if (const auto *belt_opt = wxGetApp().preset_bundle->printers.get_edited_preset().config.option("belt_printer")) + is_belt_printer = belt_opt->value; + + if (wt && !is_belt_printer && (need_wipe_tower || filaments_count > 1) && !wxGetApp().plater()->only_gcode_mode() && !wxGetApp().plater()->is_gcode_3mf()) { // The tower size estimate reads printer- and filament-scope keys, which the print preset // does not carry; built once here rather than per plate. const DynamicPrintConfig full_config = wxGetApp().preset_bundle->full_config(); @@ -3253,6 +3259,28 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re } update_volumes_colors_by_extruder(); + + // ORCA-Belt: render the auto-generated belt purge prism like a wipe tower — + // semi-transparent, in its (filament) color. It is a real sliced object, so + // the G-code preview already shows the actual per-layer purge colors; here in + // the editor we just make the block translucent so it reads as a purge tower + // rather than a solid part. update_colors_by_extruder() preserves alpha when + // not updating alpha, so lowering it once sticks across recolors. + if (m_model != nullptr) { + for (GLVolume *volume : m_volumes.volumes) { + if (volume == nullptr || volume->volume_idx() < 0) + continue; + const int obj_idx = volume->object_idx(); + if (obj_idx < 0 || obj_idx >= (int) m_model->objects.size()) + continue; + const ConfigOption *opt = m_model->objects[obj_idx]->config.option("belt_purge_tower_object"); + if (opt != nullptr && opt->getBool()) { + volume->color.a(0.66f); + volume->force_transparent = true; + } + } + } + // Update selection indices based on the old/new GLVolumeCollection. if (m_selection.get_mode() == Selection::Instance) m_selection.instances_changed(instance_ids_selected); @@ -3791,6 +3819,16 @@ bool GLCanvas3D::handle_shortcut(const KeyChord& chord) get_gcode_viewer().get_layers_slider()->switch_one_layer_mode(); m_dirty = true; break; + case Shortcut::ToggleBeltRawGcode: + // Same state as the canvas view menu item. The designed-view back-transform is + // baked into the toolpaths at load time, so the preview is re-converted. + if (m_gcode_viewer.is_belt_view()) { + m_gcode_viewer.toggle_belt_show_designed(); + if (Plater* plater = wxGetApp().plater()) + plater->refresh_belt_view(); + m_dirty = true; + } + break; case Shortcut::GoToLayer: if (!m_gizmos.is_enabled()) { get_gcode_viewer().get_layers_slider()->show_go_to_layer(true); @@ -10470,6 +10508,7 @@ void GLCanvas3D::_render_canvas_toolbar() ImGui::TextColored(enable ? ImVec4(1,1,1,1) : ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled), "%s", into_u8(condition ? ImGui::VisibleIcon : ImGui::HiddenIcon).c_str()); }; + create_menu_item( _utf8(L("3D Navigator")), m_canvas_type != ECanvasType::CanvasAssembleView, // not work on assembly wxGetApp().show_3d_navigator(), @@ -10557,6 +10596,22 @@ void GLCanvas3D::_render_canvas_toolbar() [p]{p->show_view3D_labels(!p->are_view3D_labels_shown());} ); + // Belt printers, G-code preview only: show the raw machine-frame G-code instead of + // the designed (upright) view. This menu is the only place the toggle lives (plus + // its shortcut); the reload is deferred (CallAfter) so the preview is not rebuilt + // mid-render. + if (m_canvas_type == ECanvasType::CanvasPreview && m_gcode_viewer.is_belt_view()) { + ImGui::Separator(); + create_menu_item( _utf8(L("Show raw G-code (belt only)")), + true, + !m_gcode_viewer.is_belt_show_designed(), // eye lit = raw machine-frame G-code (designed view off) + [this, p]{ + m_gcode_viewer.toggle_belt_show_designed(); + p->CallAfter([p]{ p->refresh_belt_view(); }); + } + ); + } + ImGui::PopItemFlag(); ImGui::EndPopup(); } @@ -11547,7 +11602,11 @@ void GLCanvas3D::_set_warning_notification_if_needed(EWarning warning) if (current_printer_technology() != ptSLA) { unsigned int max_z_layer = m_gcode_viewer.get_layers_z_range().back(); if (warning == EWarning::ToolHeightOutside) // check if max z_layer height exceed max print height - show = m_gcode_viewer.has_data() && (m_gcode_viewer.get_layers_zs()[max_z_layer] - m_gcode_viewer.get_max_print_height() >= 1e-6); + // Belt printer with active post-gcode machine-frame transform: layer Z values + // live in the machine frame, not the build-volume frame, so the comparison + // against printable_height is meaningless. Suppress the warning entirely. + show = m_gcode_viewer.has_data() && !m_gcode_viewer.is_machine_frame_transform_active() + && (m_gcode_viewer.get_layers_zs()[max_z_layer] - m_gcode_viewer.get_max_print_height() >= 1e-6); else if (warning == EWarning::ToolpathOutside) { // check if max x,y coords exceed bed area show = m_gcode_viewer.has_data() && !m_gcode_viewer.is_contained_in_bed() && (m_gcode_viewer.get_max_print_height() -m_gcode_viewer.get_layers_zs()[max_z_layer] >= 1e-6); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 66362cf213..23ae29d8d7 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -121,6 +121,7 @@ // This is the only place where we want to allow that, so define an override macro. #define SLIC3R_ALLOW_LIBSLIC3R_I18N_IN_SLIC3R #include "libslic3r/I18N.hpp" +#include "libslic3r/Point.hpp" #undef SLIC3R_ALLOW_LIBSLIC3R_I18N_IN_SLIC3R #include "slic3r/GUI/I18N.hpp" @@ -171,6 +172,7 @@ #include #include "libslic3r/Utils.hpp" +#include "libslic3r/Geometry.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/InstanceLock.hpp" @@ -10113,5 +10115,17 @@ bool is_support_filament(int extruder_id, bool strict_check) return support_option->get_at(0); }; +Vec3d build_plate_tilt_up_direction() +{ + const DynamicPrintConfig &cfg = wxGetApp().preset_bundle->printers.get_edited_preset().config; + const auto *opt_x = cfg.option("build_plate_tilt_x"); + const auto *opt_y = cfg.option("build_plate_tilt_y"); + const double tilt_x = opt_x != nullptr ? opt_x->value : 0.; + const double tilt_y = opt_y != nullptr ? opt_y->value : 0.; + if (tilt_x == 0. && tilt_y == 0.) + return Vec3d::UnitZ(); + return Vec3d(std::tan(Geometry::deg2rad(tilt_y)), std::tan(Geometry::deg2rad(tilt_x)), 1.).normalized(); +} + } // GUI } //Slic3r diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 91efa51377..8b9ad1bae7 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -30,6 +30,7 @@ #include "slic3r/GUI/Jobs/UpgradeNetworkJob.hpp" #include "slic3r/GUI/HttpServer.hpp" #include "../Utils/PrintHost.hpp" +#include "libslic3r/Point.hpp" #include #include @@ -878,6 +879,8 @@ bool is_support_filament(int extruder_id, bool strict_check = true); bool is_soluble_filament(int extruder_id); // check if the filament for model is in the list bool has_filaments(const std::vector& model_filaments); +// Up direction of the edited printer's tilted build plate (+Z when untilted). +Vec3d build_plate_tilt_up_direction(); } // namespace GUI } // Slic3r diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index 239e29ba41..28c0c1ceb4 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -110,14 +110,14 @@ 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}, - {"support_object_first_layer_gap","",33} + { 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}, + {"support_object_first_layer_gap","",35} }}, { L("Speed"), {{"support_speed", "",12}, {"support_interface_speed", "",13} }} @@ -1850,7 +1850,7 @@ void MenuFactory::create_filament_action_menu(bool init, int active_filament_men while (menu->GetMenuItemCount() > 0) menu->Destroy(menu->FindItemByPosition(0)); - //if (init) { // + //if (init) { // append_menu_item( menu, wxID_ANY, _L("Edit"), "", [](wxCommandEvent&) { plater()->sidebar().edit_filament(); }, "", nullptr, diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index 47e391b2ed..21210b06b7 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -2701,7 +2701,7 @@ void ObjectList::load_mesh_object(const std::vectorbuild_volume().bounding_volume2d().center(); + auto start_point = wxGetApp().plater()->build_volume().bed_center(); auto empty_cell = wxGetApp().plater()->canvas3D()->get_nearest_empty_cell({start_point(0), start_point(1)}); new_object->instances[0]->set_offset(center ? to_3d(Vec2d(empty_cell(0), empty_cell(1)), -new_object->origin_translation.z()) : bb.center()); diff --git a/src/slic3r/GUI/GUI_Preview.cpp b/src/slic3r/GUI/GUI_Preview.cpp index fd477cf767..3358715885 100644 --- a/src/slic3r/GUI/GUI_Preview.cpp +++ b/src/slic3r/GUI/GUI_Preview.cpp @@ -369,6 +369,25 @@ void Preview::reload_print(bool only_gcode) m_only_gcode = only_gcode; } +void Preview::refresh_belt_view() +{ + // Re-run the G-code preview conversion so the belt "designed view" toggle takes effect + // (the back-transform is baked into the toolpath geometry in GCodeViewer::load_as_gcode, + // whose same-result cache also keys on the view state, so the re-convert runs). + // Reset m_loaded_print to bypass the "already loaded" guard the way reload_print does, but + // keep the current layer range and only-gcode mode so the view doesn't jump on toggle. + // The layer Z values differ between the designed and the raw view (the raw view's are + // machine-frame heights), so keep_z_range alone cannot find the old span: carry the + // slider over by layer index instead. + IMSlider *layers_slider = m_canvas->get_gcode_viewer().get_layers_slider(); + const int lower = layers_slider->GetLowerValue(); + const int higher = layers_slider->GetHigherValue(); + m_loaded_print = nullptr; + load_print(true /*keep_z_range*/, m_only_gcode); + if (higher <= layers_slider->GetMaxValue()) + layers_slider->SetSelectionSpan(lower, higher); +} + //BBS: always load shell at preview void Preview::load_shells(const Print& print, bool force_previewing) { diff --git a/src/slic3r/GUI/GUI_Preview.hpp b/src/slic3r/GUI/GUI_Preview.hpp index b8bf09bd65..acb3211fd9 100644 --- a/src/slic3r/GUI/GUI_Preview.hpp +++ b/src/slic3r/GUI/GUI_Preview.hpp @@ -148,6 +148,8 @@ public: //BBS: add only gcode mode void load_print(bool keep_z_range = false, bool only_gcode = false); void reload_print(bool only_gcode = false); + // Belt printers: re-convert the G-code preview so the "designed view" toggle takes effect. + void refresh_belt_view(); //BBS: always load shell at preview void load_shells(const Print& print, bool force_previewing = false); void reset_shells(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp b/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp index fb2137982a..9b390e8c64 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp @@ -552,6 +552,11 @@ void GLGizmoFdmSupports::select_facets_by_angle(float threshold_deg, bool block) const ModelObject* mo = m_c->selection_info()->model_object(); const ModelInstance* mi = mo->instances[selection.get_instance_idx()]; + // Compute gravity direction accounting for build plate tilt + const Vec3d up_dir = build_plate_tilt_up_direction(); + const bool has_tilt = up_dir != Vec3d::UnitZ(); + const Vec3d gravity_dir = -up_dir; + int mesh_id = -1; for (const ModelVolume* mv : mo->volumes) { if (! mv->is_model_part()) @@ -560,10 +565,17 @@ void GLGizmoFdmSupports::select_facets_by_angle(float threshold_deg, bool block) ++mesh_id; const Transform3d trafo_matrix = mi->get_matrix_no_offset() * mv->get_matrix_no_offset(); - Vec3f down = (trafo_matrix.inverse() * (-Vec3d::UnitZ())).cast().normalized(); - Vec3f limit = (trafo_matrix.inverse() * Vec3d(std::sin(threshold), 0, -std::cos(threshold))).cast().normalized(); - - float dot_limit = limit.dot(down); + Vec3f down = (trafo_matrix.inverse() * gravity_dir).cast().normalized(); + float dot_limit; + if (!has_tilt) { + // Exact upstream computation: threshold derived from a tilted limit + // vector transformed into mesh space, so non-uniform/mirror/shear + // transforms behave identically to upstream. + Vec3f limit = (trafo_matrix.inverse() * Vec3d(std::sin(threshold), 0, -std::cos(threshold))).cast().normalized(); + dot_limit = limit.dot(down); + } else { + dot_limit = std::cos(threshold); + } // Now calculate dot product of vert_direction and facets' normals. int idx = 0; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.cpp b/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.cpp index f6266a0da0..81d4252be5 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.cpp @@ -108,6 +108,11 @@ GLGizmoPainterBase::ClippingPlaneDataWrapper GLGizmoPainterBase::get_clipping_pl return clp_data_out; } +Vec3f GLGizmoPainterBase::get_tilt_up_direction() const +{ + return build_plate_tilt_up_direction().cast(); +} + void GLGizmoPainterBase::render_triangles(const Selection& selection) const { auto* shader = wxGetApp().get_shader("mm_gouraud"); @@ -154,11 +159,15 @@ void GLGizmoPainterBase::render_triangles(const Selection& selection) const float normal_z = -::cos(Geometry::deg2rad(m_highlight_by_angle_threshold_deg)); Matrix3f normal_matrix = static_cast(trafo_matrix.matrix().block(0, 0, 3, 3).inverse().transpose().cast()); + // Compute up direction accounting for build plate tilt + Vec3f up_direction = get_tilt_up_direction(); + shader->set_uniform("volume_world_matrix", trafo_matrix); shader->set_uniform("volume_mirrored", is_left_handed); shader->set_uniform("slope.actived", m_parent.is_using_slope()); shader->set_uniform("slope.volume_world_normal_matrix", normal_matrix); shader->set_uniform("slope.normal_z", normal_z); + shader->set_uniform("slope.up_direction", up_direction); m_triangle_selectors[mesh_id]->render(m_imgui, trafo_matrix); if (is_left_handed) @@ -743,7 +752,7 @@ bool GLGizmoPainterBase::gizmo_event(SLAGizmoEventType action, const Vec2d& mous mi->get_assemble_transformation().get_matrix() * mo->volumes[m_rr.mesh_id]->get_matrix() : mi->get_transformation().get_matrix() * mo->volumes[m_rr.mesh_id]->get_matrix(); m_triangle_selectors[m_rr.mesh_id]->seed_fill_select_triangles(m_rr.hit, int(m_rr.facet), trafo_matrix_not_translate, this->get_clipping_plane_in_volume_coordinates(trafo_matrix), m_smart_fill_angle, - m_paint_on_overhangs_only ? m_highlight_by_angle_threshold_deg : 0.f, true); + m_paint_on_overhangs_only ? m_highlight_by_angle_threshold_deg : 0.f, get_tilt_up_direction(), true); m_triangle_selectors[m_rr.mesh_id]->request_update_render_data(); m_seed_fill_last_mesh_id = m_rr.mesh_id; } @@ -841,7 +850,7 @@ bool GLGizmoPainterBase::gizmo_event(SLAGizmoEventType action, const Vec2d& mous std::unique_ptr cursor = TriangleSelector::SinglePointCursor::cursor_factory(phr.z_world, camera_pos, m_cursor_height, trafo_matrix, clp); m_triangle_selectors[mesh_idx]->select_patch(int(phr.first_facet_idx), std::move(cursor), new_state, trafo_matrix_not_translate, - m_triangle_splitting_enabled, m_paint_on_overhangs_only ? m_highlight_by_angle_threshold_deg : 0.f); + m_triangle_splitting_enabled, m_paint_on_overhangs_only ? m_highlight_by_angle_threshold_deg : 0.f, get_tilt_up_direction()); m_triangle_selectors[mesh_idx]->request_update_render_data(true); m_last_mouse_click = _mouse_position; @@ -893,7 +902,7 @@ bool GLGizmoPainterBase::gizmo_event(SLAGizmoEventType action, const Vec2d& mous m_triangle_selectors[mesh_idx]->seed_fill_apply_on_triangles(new_state); if (m_tool_type == ToolType::SMART_FILL) m_triangle_selectors[mesh_idx]->seed_fill_select_triangles(mesh_hit, facet_idx, trafo_matrix_not_translate, clp, m_smart_fill_angle, - m_paint_on_overhangs_only ? m_highlight_by_angle_threshold_deg : 0.f, true); + m_paint_on_overhangs_only ? m_highlight_by_angle_threshold_deg : 0.f, get_tilt_up_direction(), true); else if (m_tool_type == ToolType::BRUSH && m_cursor_type == TriangleSelector::CursorType::POINTER) // BBS: add infill_angle parameter m_triangle_selectors[mesh_idx]->bucket_fill_select_triangles(mesh_hit, facet_idx, clp, -1.f, false, true); @@ -912,12 +921,12 @@ bool GLGizmoPainterBase::gizmo_event(SLAGizmoEventType action, const Vec2d& mous camera_pos, m_cursor_radius, m_cursor_type, trafo_matrix, clp); m_triangle_selectors[mesh_idx]->select_patch(int(first_position.facet_idx), std::move(cursor), new_state, trafo_matrix_not_translate, - m_triangle_splitting_enabled, m_paint_on_overhangs_only ? m_highlight_by_angle_threshold_deg : 0.f); + m_triangle_splitting_enabled, m_paint_on_overhangs_only ? m_highlight_by_angle_threshold_deg : 0.f, get_tilt_up_direction()); } else { for (auto first_position_it = projected_mouse_positions.cbegin(); first_position_it != projected_mouse_positions.cend() - 1; ++first_position_it) { auto second_position_it = first_position_it + 1; std::unique_ptr cursor = TriangleSelector::DoublePointCursor::cursor_factory(first_position_it->mesh_hit, second_position_it->mesh_hit, camera_pos, m_cursor_radius, m_cursor_type, trafo_matrix, clp); - m_triangle_selectors[mesh_idx]->select_patch(int(first_position_it->facet_idx), std::move(cursor), new_state, trafo_matrix_not_translate, m_triangle_splitting_enabled, m_paint_on_overhangs_only ? m_highlight_by_angle_threshold_deg : 0.f); + m_triangle_selectors[mesh_idx]->select_patch(int(first_position_it->facet_idx), std::move(cursor), new_state, trafo_matrix_not_translate, m_triangle_splitting_enabled, m_paint_on_overhangs_only ? m_highlight_by_angle_threshold_deg : 0.f, get_tilt_up_direction()); } } } @@ -991,7 +1000,7 @@ bool GLGizmoPainterBase::gizmo_event(SLAGizmoEventType action, const Vec2d& mous const TriangleSelector::ClippingPlane &clp = this->get_clipping_plane_in_volume_coordinates(trafo_matrix); if (m_tool_type == ToolType::SMART_FILL) m_triangle_selectors[m_rr.mesh_id]->seed_fill_select_triangles(m_rr.hit, int(m_rr.facet), trafo_matrix_not_translate, clp, m_smart_fill_angle, - m_paint_on_overhangs_only ? m_highlight_by_angle_threshold_deg : 0.f); + m_paint_on_overhangs_only ? m_highlight_by_angle_threshold_deg : 0.f, get_tilt_up_direction(), false); else if (m_tool_type == ToolType::BRUSH && m_cursor_type == TriangleSelector::CursorType::POINTER) // BBS: add infill_angle parameter m_triangle_selectors[m_rr.mesh_id]->bucket_fill_select_triangles(m_rr.hit, int(m_rr.facet), clp, -1.f, false); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp b/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp index a98f303fe1..60ae6f02a3 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp @@ -294,6 +294,9 @@ protected: bool m_paint_on_overhangs_only = false; float m_highlight_by_angle_threshold_deg = 0.f; + // Returns the up direction accounting for build plate tilt (default: UnitZ) + Vec3f get_tilt_up_direction() const; + GLModel m_circle; Vec2d m_old_center{ Vec2d::Zero() }; float m_old_cursor_radius{ 0.0f }; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index f1b0e2b2ee..b6d68383fe 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -27,6 +27,7 @@ #include "slic3r/GUI/MsgDialog.hpp" #include "slic3r/GUI/OpenGLManager.hpp" #include "slic3r/GUI/Plater.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/TextureLibrary.hpp" #include "slic3r/GUI/TextureProjectorFrame.hpp" #include "slic3r/GUI/UVEditorCanvas.hpp" @@ -407,11 +408,48 @@ GLGizmoTextureDisplacement::GLGizmoTextureDisplacement(GLCanvas3D& parent, const bool GLGizmoTextureDisplacement::on_init() { + m_shortcut = Shortcut::GizmoDisplacement; + const wxString ctrl = GUI::shortkey_ctrl_prefix(); + const wxString alt = GUI::shortkey_alt_prefix(); + const wxString shift = GUI::shortkey_shift_prefix(); + + m_desc["cursor_size"] = _L("Brush size"); m_desc["circle"] = _L("Circle"); m_desc["sphere"] = _L("Sphere"); m_desc["remove_layer"] = _L("Remove"); m_desc["bake"] = _L_CONTEXT("Bake", "Texture Displacement"); + + + m_desc["paint"] = _L("Paint"); + m_desc["erase"] = _L("Erase"); + m_desc["gap_area"] = _L("Gap area"); + m_desc["smart_fill_angle"] = _L("Smart fill angle"); + m_desc["toggle_wireframe"] = _L("Toggle Wireframe"); + + std::pair paint_shortcut = {_L("Left mouse button"), m_desc["paint"]}; + std::pair erase_shortcut = {shift + _L("Left mouse button"), m_desc["erase"]}; + std::pair toggle_wireframe_shortcut = {alt + shift + _L_CONTEXT("Enter", "Keyboard Shortcut"), m_desc["toggle_wireframe"]}; + + m_shortcuts_brush = { + paint_shortcut, + erase_shortcut, + {ctrl + _L("Mouse wheel"), m_desc["cursor_size"]}, + toggle_wireframe_shortcut + }; + + m_shortcuts_bucket_fill = { + paint_shortcut, + erase_shortcut, + {ctrl + _L("Mouse wheel"), m_desc["smart_fill_angle"]}, + toggle_wireframe_shortcut + }; + + m_shortcuts_gap_fill = { + {ctrl + _L("Mouse wheel"), m_desc["gap_area"]}, + toggle_wireframe_shortcut + }; + return true; } @@ -523,7 +561,15 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() // volume for a shaded pass that then draws nothing is what made the model vanish - most obviously // with zero layers, but equally with a layer that has no texture picked yet. const bool use_shaded = m_use_shaded_preview && m_shaded_preview_glmodel.is_initialized() && shaded_preview_ready(); - const bool use_true_preview = !use_shaded && m_preview_glmodel.is_initialized(); + // Checker/Distortion are built from the *base* patch and drawn with a polygon offset, which biases + // depth values - it does not move the geometry. It therefore cannot win against a surface that + // genuinely stands in front, and the displaced preview does exactly that: it rises above the base + // surface by the layer's depth. Drawn underneath a UV-check overlay it simply occludes it, which is + // why those two modes looked like they did nothing. Leave it out and let the undisplaced volume show + // through instead (toggle_model_objects_visibility below) - that one *is* coincident with the + // overlay, which is what the offset assumes, and it is the surface whose mapping is being inspected. + const bool use_true_preview = !use_shaded && m_uv_check_mode == UVCheckMode::None && + m_preview_glmodel.is_initialized(); // In Checker/Distortion mode the UV-check overlay *is* the surface visualization the user is // looking at, so the opaque paint-selection highlight must not be drawn on top of it - same // reasoning as skipping it for the shaded preview (see bug #12). Without this the painted area @@ -551,7 +597,13 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() render_triangles(selection); glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); } - } else if (show_paint_overlay) { + } else { + // render_triangles() *is* the model in a painter gizmo (it draws every model-part volume with the + // selector's colours), not an overlay on top of one - so it still has to run under a UV-check + // overlay, or nothing draws the surface at all and the checker floats alone over an empty scene. + // Deliberately without the depth bias the branch above applies: the checker/heatmap is drawn later + // with its own -1 offset and has to win against this. Biasing both by the same amount is what made + // the painted area cover the checker and is why this call used to be skipped outright. render_triangles(selection); } @@ -1462,11 +1514,8 @@ void GLGizmoTextureDisplacement::render_shaded_preview_mesh() // How the entry prints: its filament, or for a mix the two it interleaves and in what ratio. shader->set_uniform(("palette_a" + idx).c_str(), e.a); shader->set_uniform(("palette_b" + idx).c_str(), e.b); - shader->set_uniform(("palette_num" + idx).c_str(), e.num); - shader->set_uniform(("palette_den" + idx).c_str(), e.den); } // The filaments those indices refer to, and the interleave the shader resolves a mix with - the - // same inputs make_mix_resolver() gets, so the preview shows the pattern that prints rather than // the mix's smooth average colour. m_palette_filaments is what m_shaded_preview_palette was built from. const int filament_count = (palette_count > 0) ? int(std::min(m_palette_filaments.size(), size_t(PALETTE_MAX_FILAMENTS))) : 0; @@ -1475,9 +1524,6 @@ void GLGizmoTextureDisplacement::render_shaded_preview_mesh() const ColorRGBA &c = m_palette_filaments[size_t(i)]; shader->set_uniform(("filament_rgb[" + std::to_string(i) + "]").c_str(), Vec3f(c.r(), c.g(), c.b())); } - shader->set_uniform("mix_mode", int(mv->texture_displacement_options.color_mix_mode)); - shader->set_uniform("layer_height", color_band_mm(*mv)); // as color_settings_for() - shader->set_uniform("dither_cell", std::max(m_subdivide_color_mm, 0.05f) * 2.f); // as color_settings_for() if (color_tex != nullptr) { shader->set_uniform("color_tex", 1); glsafe(::glActiveTexture(GL_TEXTURE1)); @@ -2022,7 +2068,13 @@ void GLGizmoTextureDisplacement::queue_preview_job() // The filament list the result's indices refer to, captured with the job rather than read back // when it lands - loading a filament meanwhile must not recolour a preview computed against a // different list. - const std::vector filaments = m_palette_filaments; + // Every extruder, not the palette's physical-only list: the bake writes the filament it resolved + // to, and a mix resolves to a *mixed filament slot*, which is an extruder past the physical ones. + // Grouping against the shorter list dropped every triangle carrying such a slot out of the mesh + // entirely - the relief vanished and left only the few triangles that happened to print in a plain + // filament. The palette still has to be built from physical filaments alone (see filament_palette()), + // which is why these two are not the same list. + const std::vector filaments = wxGetApp().plater()->get_extruders_colors(); m_preview_job_running = true; auto &worker = wxGetApp().plater()->get_ui_job_worker(); @@ -4219,19 +4271,44 @@ bool GLGizmoTextureDisplacement::any_layer_colors(const ModelVolume &mv) return false; } +void GLGizmoTextureDisplacement::bind_mixes_to_filament_slots(std::vector &palette) +{ + Sidebar *sidebar = &wxGetApp().plater()->sidebar(); + if (sidebar == nullptr) + return; + for (PaletteEntry &e : palette) { + if (!e.is_mix()) + continue; + // Components are 1-based in the config; the ratios are percentages summing to 100, which is the + // form create_mixed_filament_from_result() normalises from. + const int a_pct = int(std::lround(100.0 * double(e.num) / double(e.den))); + const int slot = sidebar->ensure_mixed_filament({ unsigned(e.a + 1), unsigned(e.b + 1) }, + { a_pct, 100 - a_pct }); + if (slot >= 0) { + e.a = e.b = slot; + e.num = e.den = 1; + } else { + // No room for another slot. Collapse to the component that dominates the blend, which is what + // the old per-triangle path did on a surface it could not band anyway. + const int dominant = (e.num * 2 >= e.den) ? e.a : e.b; + e.a = e.b = dominant; + e.num = e.den = 1; + } + } +} + TextureColorSettings GLGizmoTextureDisplacement::color_settings_for(const ModelVolume &mv) { TextureColorSettings out; if (!any_layer_colors(mv)) return out; // nothing is colouring: every colour path stays switched off out.palette = cached_palette(); - out.palette_pure = make_palette(m_palette_filaments, /* mixing */ false); - out.mix_mode = mv.texture_displacement_options.color_mix_mode; + out.palette_pure = make_palette(m_palette_filaments, /* mixing */ false, PALETTE_MAX_ENTRIES); + // Done here rather than in cached_palette(): this runs when a preview or a bake is queued, off a + // user action, while that one is also touched from the render path - and creating filament slots + // there would mutate the project mid-frame. + bind_mixes_to_filament_slots(out.palette); out.despeckle_passes = mv.texture_displacement_options.color_despeckle; - out.layer_height = color_band_mm(mv); - // The dither cell is tied to the colour-detail target: a cell much smaller than a facet cannot be - // drawn at all, and one much larger stops reading as a blend and starts reading as a check. - out.dither_cell_mm = std::max(m_subdivide_color_mm, 0.05f) * 2.f; return out; } @@ -4244,10 +4321,15 @@ const std::vector &GLGizmoTextureDispl const ModelVolume *mv = texture_volume(); const bool mixing = mv != nullptr && mv->texture_displacement_options.color_mix_enabled; std::vector filaments = filament_palette(); - if (m_palette_cache.empty() || filaments != m_palette_filaments || mixing != m_palette_mixing) { + // Every mix costs a filament slot once they are bound to one, and the mask can name only so many + // states, so the palette has to leave room beside the physical filaments it already counts. + const int cap = int(EnforcerBlockerType::ExtruderMax); + if (m_palette_cache.empty() || filaments != m_palette_filaments || mixing != m_palette_mixing || + cap != m_palette_cap) { m_palette_filaments = std::move(filaments); m_palette_mixing = mixing; - m_palette_cache = make_palette(m_palette_filaments, mixing); + m_palette_cap = cap; + m_palette_cache = make_palette(m_palette_filaments, mixing, cap); m_palette_quantizer = make_palette_quantizer(m_palette_cache); } return m_palette_cache; @@ -4255,40 +4337,29 @@ const std::vector &GLGizmoTextureDispl std::vector GLGizmoTextureDisplacement::filament_palette() { - std::vector palette = wxGetApp().plater()->get_extruders_colors(); - // mmu_segmentation_facets encodes the filament in a 6-bit prefix code and stops at Extruder16. + std::vector all = wxGetApp().plater()->get_extruders_colors(); + + // Physical filaments only. The mixes this palette produces each become a mixed filament slot of + // their own (see bind_mixes_to_filament_slots()), and those slots are extruders too - so taking the + // list as it comes meant the next rebuild mixed *them* again, and handed components naming a + // virtual slot to a blend that can only name physical ones. That is what left entries reading + // "filament 1 plus nothing" and raised "Mixed filament has invalid or mismatched components". + const auto *is_mixed = wxGetApp().preset_bundle->project_config.option("filament_is_mixed"); + std::vector palette; + palette.reserve(all.size()); + for (size_t i = 0; i < all.size(); ++i) + if (is_mixed == nullptr || i >= is_mixed->values.size() || !is_mixed->values[i]) + palette.push_back(all[i]); + + // A paint mask can only name so many states, and every mix spends one beside these. if (palette.size() > size_t(EnforcerBlockerType::ExtruderMax)) palette.resize(size_t(EnforcerBlockerType::ExtruderMax)); return palette; } -float GLGizmoTextureDisplacement::color_band_mm(const ModelVolume &mv) -{ - const float lh = print_layer_height(); - const float edge = (mv.texture_displacement_options.v2_refine_mm > 0.f) ? mv.texture_displacement_options.v2_refine_mm - : v2_recommendation(mv).edge_mm; - if (edge <= 0.f || lh <= 0.f) - return lh; - // A refined triangle of edge e stacks in rows about 0.87 * e apart (an equilateral triangle's - // height), and a dither needs at least two rows per period to be a dither at all. - constexpr float ROW_PER_EDGE = 0.87f; - return lh * std::max(1.f, std::ceil(2.f * ROW_PER_EDGE * edge / lh)); -} - -float GLGizmoTextureDisplacement::print_layer_height() -{ - try { - const DynamicPrintConfig &cfg = wxGetApp().preset_bundle->prints.get_edited_preset().config; - if (const ConfigOptionFloat *opt = cfg.option("layer_height"); opt != nullptr) - if (opt->value > 1e-3) - return float(opt->value); - } catch (...) { - } - return 0.2f; -} std::vector GLGizmoTextureDisplacement::make_palette( - const std::vector &filaments, bool mixing) + const std::vector &filaments, bool mixing, int max_entries) { std::vector out; const int n = int(filaments.size()); @@ -4305,7 +4376,7 @@ std::vector GLGizmoTextureDisplacement const int pairs = n * (n - 1) / 2; int steps = 0; for (int s = 5; s >= 1; --s) - if (n + pairs * s <= PALETTE_MAX_ENTRIES) { + if (n + pairs * s <= max_entries) { steps = s; break; } @@ -4327,51 +4398,6 @@ std::vector GLGizmoTextureDisplacement return out; } -ColorResolveFn GLGizmoTextureDisplacement::make_mix_resolver(const std::vector &palette, - ColorMixMode mode, float layer_height, - float cell_mm) -{ - if (palette.empty()) - return nullptr; - auto entries = std::make_shared>(palette); - const float band = std::max(layer_height, 0.01f); - const float cell = std::max(cell_mm, 0.01f); - - return [entries, mode, band, cell](int index, const Vec3f &pos, const Vec3f &normal) -> int { - if (index < 0 || size_t(index) >= entries->size()) - return -1; - const PaletteEntry &e = (*entries)[size_t(index)]; - if (!e.is_mix()) - return e.a; - - // Which of the two filaments this point falls on. Both patterns are *ordered*, never random: - // the eye blends a regular pattern into a flat colour, and turns a random one into noise. - // Auto: bands wherever the surface is steeper than ~45 degrees - consecutive layers alternate - // there, which is how a blend prints and reads. On a flat-facing surface a layer is one band - // and the only way to interleave is a checkerboard across the surface, which at print scale - // reads as a pattern rather than a colour; there the mix falls back to its dominant filament. - const bool upright = std::abs(normal.z()) < 0.7f; - if (mode == ColorMixMode::Auto && !upright) - return e.num * 2 >= e.den ? e.a : e.b; - const bool bands = mode == ColorMixMode::ZBands || mode == ColorMixMode::Auto; - if (bands) { - // One band per print layer. floorf, not a cast, so this stays correct below z = 0. - const int slot = int(std::floor(pos.z() / band)); - const int phase = ((slot % e.den) + e.den) % e.den; - return phase < e.num ? e.a : e.b; - } - // Ordered 4x4 Bayer over the surface, indexed by position so the pattern is stable in space - // rather than in triangle order (which would move under any remesh, and read as noise). - static const int BAYER[16] = { 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5 }; - const int gx = ((int(std::floor(pos.x() / cell)) % 4) + 4) % 4; - const int gy = ((int(std::floor(pos.y() / cell)) % 4) + 4) % 4; - // A third axis would be ideal, but the two dominant ones are enough for a surface pattern and - // keep the cell square on the faces that matter. - const float threshold = (float(BAYER[gy * 4 + gx]) + 0.5f) / 16.f; - return (float(e.num) / float(e.den)) > threshold ? e.a : e.b; - }; -} - ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::vector &palette) { if (palette.empty()) @@ -4408,10 +4434,15 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve best_pure = int(i); } } - // A mix is an interleave that only reads as its colour from a distance; up close - // it is stripes. Spend it only where it buys a clearly better match than the nearest - // single filament: ten Delta E is a visible step, less is not worth the stripes. - constexpr float PREFER_PURE_DE = 10.f; + // A mix is an interleave that only reads as its colour from a distance; up close it is + // stripes. So it is spent only where it buys a better match than the nearest single + // filament - but "better" was set at ten Delta E, which is not a visible step, it is a + // different colour. Measured over the whole cube that threshold turned 94% of the + // lookups that wanted a mix back into a pure filament, leaving 38%; along a greyscale + // ramp, the shape a height texture actually traces, it cut 80% to 66%. Two Delta E is + // about where a side-by-side difference stops being arguable, which is the right place + // to start paying for stripes. + constexpr float PREFER_PURE_DE = 2.f; if (best_pure >= 0 && palette[size_t(best)].is_mix() && best_pure_d - best_d < PREFER_PURE_DE) best = best_pure; (*lut)[(size_t(r) * E + size_t(g)) * E + size_t(b)] = uint8_t(best); @@ -4511,7 +4542,7 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( if (params.subdiv_color_edge_mm > 0.f && !palette.empty()) color = make_combined_color_sampler(mesh.its, layers, current, make_palette_quantizer(palette)); // Note the sampler is built on the *quantizer* alone - the refinement follows perceived - // colour, never the interleaving that realises a mix (see ColorResolveFn). + // colour, never the interleaving that realises a mix - the slicer does that per layer. // "Min edge" is a feature-mode control (it is the floor the curvature test refines down // to); in plain adaptive mode the target edge length is the only criterion, so the floor // must not be allowed to silently override a target the user set below it. @@ -5313,6 +5344,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float return; ModelVolume *mv = texture_volume(); + float scale = m_parent.get_scale(); + #ifdef WIN32 + int dpi = get_dpi_for_window(wxGetApp().GetTopWindow()); + scale *= (float) dpi / (float) DPI_DEFAULT; + #endif // WIN32 + const float approx_height = m_imgui->scaled(24.f); y = std::min(y, bottom_limit - approx_height); @@ -5742,14 +5779,20 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float const int cur_mode = m_use_shaded_preview ? 1 : m_uv_check_mode == UVCheckMode::Checker ? 2 : m_uv_check_mode == UVCheckMode::Distortion ? 3 : 0; - int new_mode = cur_mode; - bool wf_toggle = false; - const wxString distortion_na = active == nullptr ? _L("Add a layer first.") : - active->projection_method != TextureProjectionMethod::LSCM ? - _L("Needs the active layer mapped with Unwrap (LSCM).") : - wxString(); - // Distortion over a layer that stopped being an unwrap shows nothing at all, so fall back to Normal. - if (cur_mode == 3 && !distortion_na.empty()) + int new_mode = cur_mode; + bool wf_toggle = false; + bool open_uv_editor = false; + // Checker and Distortion both draw *the unwrap* - the first the texture grid laid over it, the second + // its stretch - so they only mean anything for a layer mapped with Unwrap (LSCM). On the default + // triplanar mapping (or cylindrical / spherical / from view) they are faded out with the reason in the + // tooltip, rather than being offered and then showing nothing. + const wxString uv_view_na = active == nullptr ? _L("Add a layer first.") : + active->projection_method != TextureProjectionMethod::LSCM ? + _L("Only for a layer mapped with Unwrap (LSCM) - set the " + "active layer's Mapping to Unwrap to use this view.") : + wxString(); + // Either view over a layer that stopped being an unwrap shows nothing at all, so fall back to Normal. + if ((cur_mode == 2 || cur_mode == 3) && !uv_view_na.empty()) new_mode = 0; const float x0 = ImGui::GetCursorPosX(); @@ -5768,12 +5811,20 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(0.f, gap_s); if (icon_toggle(703, "texture_displacement_checker.svg", cur_mode == 2, icon_md, _L("Checker"), _L("Checker - a test grid instead of the texture. Where the squares stay square the " - "texture is undistorted; where they stretch, it will too"))) - new_mode = 2; + "texture is undistorted; where they stretch, it will too. Opens the UV editor if " + "it is closed"), + uv_view_na)) { + new_mode = 2; + open_uv_editor = true; + } ImGui::SameLine(0.f, gap_s); if (icon_toggle(704, "texture_displacement_distortion.svg", cur_mode == 3, icon_md, _L("Distortion"), - _L("Distortion - blue-to-red stretch heatmap over the unwrap"), distortion_na)) - new_mode = 3; + _L("Distortion - blue-to-red stretch heatmap over the unwrap. Opens the UV editor if " + "it is closed"), + uv_view_na)) { + new_mode = 3; + open_uv_editor = true; + } vsep(icon_md); if (icon_toggle(705, "texture_displacement_wireframe.svg", m_wireframe_overlay, icon_md, _L("Wireframe"), _L("Wireframe - overlay the mesh edges; independent of the view above"))) @@ -5788,6 +5839,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float hover_tip(_u8L("Rebuilds the preview as soon as anything changes. Turn it off on a heavy model if painting " "or dragging a slider starts to stutter - the preview then waits until you let go.")); + // Both are views of the unwrap, so picking one brings the UV editor up with it - including when that + // view is already the active one and only the pane is missing. + if (open_uv_editor && !m_show_uv_editor) { + m_show_uv_editor = true; + if (new_mode == cur_mode) + update_uv_editor(); // otherwise apply_view_mode() below does it + } if (new_mode != cur_mode) apply_view_mode(new_mode); if (wf_toggle) { @@ -6069,24 +6127,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "of filaments can cover a photo or a gradient. An image of flat colors " "prints the same either way. Off uses one filament per area.")); if (opts.color_mix_enabled) { - slider_label(_L("Mix by")); - const std::string mix_z = _u8L("Layers"); - const std::string mix_xy = _u8L_CONTEXT("Surface", "Texture Displacement"); - const std::string mix_auto = _u8L("Automatic"); - const char *mix_items[] = { mix_z.c_str(), mix_xy.c_str(), mix_auto.c_str() }; - int mix_mode = int(opts.color_mix_mode); - ImGui::SetNextItemWidth(-card_pad); - if (scoped_combo("##color_mix_mode", &mix_mode, mix_items, IM_ARRAYSIZE(mix_items))) { - opts.color_mix_mode = ColorMixMode(mix_mode); - m_preview_params_dirty = true; - } - hover_tip(_u8L("Layers: the two filaments alternate between print layers, which " - "blends smoothly on upright surfaces but disappears on flat-facing " - "ones, where a whole layer is a single band.\n" - "Surface: a fine checkerboard across the surface, which works at " - "any angle but can read as texture rather than as a blend.\n" - "Automatic: layers on upright faces; flat-facing faces take the nearer " - "single filament, since a checkerboard there shows as a pattern.")); ImGui::TextDisabled("%s", Slic3r::format(_u8L("%1% printable colors from %2% filaments"), int(cached_palette().size()), int(m_palette_filaments.size())).c_str()); } @@ -6891,18 +6931,34 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } const float button_h = std::round(frame_h * 1.25f); - const float third = std::floor((panel_w - style.ItemSpacing.x) / 3.f); - if (busy) { - if (ImGui::Button((_u8L("Stop") + "##stop").c_str(), ImVec2(third, button_h))) - wxGetApp().plater()->get_ui_job_worker().cancel_all(); - hover_tip(_u8L("Stops the bake. Whatever it had already finished stays on the model, and can be undone.")); - } else { - if (ImGui::Button((_u8L("Close") + "##close").c_str(), ImVec2(third, button_h))) - m_parent.reset_all_gizmos(); - hover_tip(_u8L("Closes the tool without baking. Your paint, layers and settings stay with the model.")); - } + const float button_w = std::max({ + ImGui::CalcTextSize(_u8L("Close").c_str()).x, + ImGui::CalcTextSize(_u8L("Stop").c_str()).x, + ImGui::CalcTextSize(_u8L("Preparing...").c_str()).x, + ImGui::CalcTextSize(_u8L("Baking...").c_str()).x, + }) + m_imgui->scaled(0.5f); + const float row_y = ImGui::GetCursorPosY(); + const float icon_h = 21.f * scale; + const float row_h = std::max(icon_h, button_h); + const std::vector> shortcut = + m_tool_type == ToolType::BUCKET_FILL ? m_shortcuts_bucket_fill + : m_tool_type == ToolType::SMART_FILL ? m_shortcuts_bucket_fill + : m_tool_type == ToolType::BRUSH ? m_shortcuts_brush + : m_tool_type == ToolType::GAP_FILL ? m_shortcuts_gap_fill + : std::vector>{}; + + ImGui::SetCursorPosY(row_y + (row_h - icon_h) * .5f); // center vertically + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(int(m_imgui->scaled(.5f)), style.ItemSpacing.y)); + GLGizmoUtils::render_tooltip_button(m_imgui, m_parent, shortcut, x, y); ImGui::SameLine(); + GLGizmoUtils::render_wiki_guide_button(m_parent, scale, "https://www.orcaslicer.com/wiki/print_prepare/prepare_texture_displacement"); + ImGui::SameLine(); + GLGizmoUtils::render_video_guide_button(m_parent, scale, "https://www.youtube.com/watch?v=D7w3tG1kdvE"); + ImGui::PopStyleVar(1); + + ImGui::SameLine(x0 + panel_w - button_w * 2 - style.ItemSpacing.x); + const bool can_bake = !busy && mv != nullptr && mv->is_texture_displacement_painted(); const std::string bake_label = m_prepare_in_progress ? _u8L("Preparing...") : m_bake_in_progress ? _u8L("Baking...") : @@ -6910,7 +6966,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float GLGizmoUtils::push_orca_button_style(); m_imgui->push_bold_font(); m_imgui->disabled_begin(!can_bake); - if (ImGui::Button((bake_label + "##bake").c_str(), ImVec2(x0 + panel_w - ImGui::GetCursorPosX(), button_h))) { + ImGui::SetCursorPosY(row_y + (row_h - button_h) * .5f); // center vertically + if (ImGui::Button((bake_label + "##bake").c_str(), ImVec2(button_w, button_h))) { // Standard mode's Bake is the whole pipeline (remesh -> refine -> displace); Pro's is only the // displacement, because there the user has already prepared the mesh with the controls above. if (pro_mode()) @@ -6935,6 +6992,17 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "land on - all in one step."), wrap_w); + ImGui::SameLine(); + if (busy) { + if (ImGui::Button((_u8L("Stop") + "##stop").c_str(), ImVec2(button_w, button_h))) + wxGetApp().plater()->get_ui_job_worker().cancel_all(); + hover_tip(_u8L("Stops the bake. Whatever it had already finished stays on the model, and can be undone.")); + } else { + if (ImGui::Button((_u8L("Close") + "##close").c_str(), ImVec2(button_w, button_h))) + m_parent.reset_all_gizmos(); + hover_tip(_u8L("Closes the tool without baking. Your paint, layers and settings stay with the model.")); + } + // What Bake will produce, and which layers it will skip. if (mv != nullptr) { const std::string base_count = base_k > 0 ? Slic3r::format(_u8L("%1% k"), base_k) : diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 6685f8d713..2406ddbe84 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -81,7 +81,11 @@ public: // How many ratios depends on how many filaments there are, so the palette stays bounded: the // quantizer's lookup cube costs one DeltaE00 per cell per entry to fill, and with sixteen // filaments there are already plenty of colours without mixing any of them. - static std::vector make_palette(const std::vector &filaments, bool mixing); + // `max_entries` bounds the whole palette. It is normally the quantizer's own limit, but when the + // mixes become filament slots it has to be the paint mask's instead: a mask can name only + // EnforcerBlockerType::ExtruderMax states, and every mix now occupies one of them. + static std::vector make_palette(const std::vector &filaments, bool mixing, + int max_entries); // Maps an image colour to the closest entry of `palette`, perceptually (CIEDE2000 over CIELAB - a // plain RGB distance picks visibly wrong filaments, most obviously between a saturated colour and @@ -95,9 +99,6 @@ public: // Turns a palette index plus a position into the filament to print there, interleaving the two // filaments of a mixed entry per `mode`. `layer_height` sizes the Z bands; `cell_mm` the dither - // cells. See ColorResolveFn for why this is separate from the quantizer. - static ColorResolveFn make_mix_resolver(const std::vector &palette, ColorMixMode mode, - float layer_height, float cell_mm); // Everything the jobs need to colour with, for the current volume: palette, mix mode, layer // height, despeckle. Empty when no layer is actually colouring. @@ -106,16 +107,20 @@ public: // The printable palette for the current filaments and mixing setting, rebuilt only when either // actually changes - see the definition for why that caching is not optional. const std::vector &cached_palette(); + // Turns every mix in `palette` into a mixed filament slot and rewrites the entry to name that slot + // as a plain filament, so nothing downstream has to know a mix is involved: is_mix() goes false and + // the resolver simply returns it. The per-layer interleaving then happens in the slicer, where it is + // not limited by how fine the mesh is. Entries whose slot could not be created (the paint-state cap) + // fall back to the nearer of the two components. + void bind_mixes_to_filament_slots(std::vector &palette); std::vector m_palette_cache; std::vector m_palette_filaments; + int m_palette_cap = 0; // the max_entries m_palette_cache was built with bool m_palette_mixing = false; ColorQuantizeFn m_palette_quantizer; // The loaded filaments, clamped to the sixteen mmu_segmentation_facets can address. static std::vector filament_palette(); - // The print's layer height, which sizes ColorMixMode::ZBands. Falls back to 0.2 mm if it cannot be - // read - a wrong band size is a cosmetic error, not a reason to refuse to colour anything. - static float print_layer_height(); // The Z band height, in mm. One print layer is the ideal, but the interleave is realised per // *facet*: a band thinner than the mesh can resolve does not dither, it beats against the triangle // grid and comes out as broad horizontal stripes - and since MMU segmentation reads facet colour, @@ -123,7 +128,6 @@ public: // diagonal and knows nothing about the layer height, so the band is rounded up to a whole number of // layers at least two facet rows tall: still exact on the printer, and representable by the mesh // that has to carry it. Used by both the bake settings and the preview shader, so the two agree. - float color_band_mm(const ModelVolume &mv); // The Normal preview's triangles, grouped by the filament they will print in. Colour is per facet // and there are at most sixteen filaments, so the mesh is uploaded once with its index buffer @@ -943,6 +947,13 @@ private: std::map m_desc; + // Contains all shortcuts in the format of {shortcut, description}, e.g. {alt + _L("Left mouse button"), _L("Part_selection")} + std::vector> m_shortcuts_brush; + // Contains all shortcuts in the format of {shortcut, description}, e.g. {alt + _L("Left mouse button"), _L("Part_selection")} + std::vector> m_shortcuts_bucket_fill; + // Contains all shortcuts in the format of {shortcut, description}, e.g. {alt + _L("Left mouse button"), _L("Part_selection")} + std::vector> m_shortcuts_gap_fill; + // Icons for the panel's icon buttons (tools, views, mapping, tiling, layer actions). Loaded through IconManager with // the same colour/monochrome variants the main toolbar uses, so an inactive button shows the icon in // the theme's normal (grey) foreground colour and an active one shows it in its original colours - diff --git a/src/slic3r/GUI/Gizmos/GLGizmoUtils.cpp b/src/slic3r/GUI/Gizmos/GLGizmoUtils.cpp index 19bd47fb6c..2b52fc486e 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoUtils.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoUtils.cpp @@ -3,6 +3,7 @@ #include "GLGizmosManager.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" +#include "slic3r/GUI/I18N.hpp" #include #include #include @@ -10,6 +11,7 @@ #include #include #include +#include #include #include "slic3r/GUI/GUI_Utils.hpp" @@ -55,7 +57,6 @@ namespace Slic3r::GUI::GLGizmoUtils { auto& gizmos_manager = canvas.get_gizmos_manager(); ImTextureID normal_id = gizmos_manager.get_icon_texture_id(GLGizmosManager::MENU_ICON_NAME::IC_TOOLBAR_TOOLTIP); - ImTextureID hover_id = gizmos_manager.get_icon_texture_id(GLGizmosManager::MENU_ICON_NAME::IC_TOOLBAR_TOOLTIP_HOVER); float scale = canvas.get_scale(); #ifdef WIN32 @@ -63,13 +64,7 @@ namespace Slic3r::GUI::GLGizmoUtils { scale *= (float)dpi / (float)DPI_DEFAULT; #endif - ImVec2 button_size = ImVec2(25 * scale, 25 * scale); - - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, { 0, 0 }); - - ImGui::ImageButton3(normal_id, hover_id, button_size); - + toolbar_circular_button(normal_id, "##tooltip_btn", scale); if (ImGui::IsItemHovered()) { ImGui::BeginTooltip2(ImVec2(x, caption_y)); for (const auto& item : shortcuts) { @@ -79,7 +74,60 @@ namespace Slic3r::GUI::GLGizmoUtils { } ImGui::EndTooltip(); } - ImGui::PopStyleVar(2); + } + + bool render_wiki_guide_button(const GLCanvas3D& canvas, float scale, const char* url) + { + auto& gm = canvas.get_gizmos_manager(); + auto icon = gm.get_icon_texture_id(GLGizmosManager::MENU_ICON_NAME::IC_TOOLBAR_WIKI_GUIDE); + bool clicked = toolbar_circular_button(icon, "##wiki_guide_btn", scale); + if (url && *url && clicked) + wxLaunchDefaultBrowser(wxString::FromUTF8(url)); + if (ImGui::IsItemHovered()){ + if(url && *url) + ImGui::SetTooltip("%s\n%s", _u8L("Wiki Guide").c_str(), url); + else + ImGui::SetTooltip("%s", _u8L("Wiki Guide").c_str()); + } + return clicked; // for dynamically generated links + } + + bool render_video_guide_button(const GLCanvas3D& canvas, float scale, const char* url) + { + auto& gm = canvas.get_gizmos_manager(); + auto icon = gm.get_icon_texture_id(GLGizmosManager::MENU_ICON_NAME::IC_TOOLBAR_VIDEO_GUIDE); + bool clicked = toolbar_circular_button(icon, "##video_guide_btn", scale); + if (url && *url && clicked) + wxLaunchDefaultBrowser(wxString::FromUTF8(url)); + if (ImGui::IsItemHovered()){ + if(url && *url) + ImGui::SetTooltip("%s\n%s", _u8L("Video Guide").c_str(), url); + else + ImGui::SetTooltip("%s", _u8L("Video Guide").c_str()); + } + return clicked; // for dynamically generated links + } + + bool toolbar_circular_button(ImTextureID textureID, const char* id, float scale) + { + ImVec2 btn_sz = ImVec2(21 * scale, 21 * scale); + ImVec2 icon_sz = ImVec2(15 * scale, 15 * scale); + float btn_pad = (btn_sz.x - icon_sz.x) * .5f; + + ImVec2 p = ImGui::GetCursorScreenPos(); + bool clicked = ImGui::InvisibleButton(id, btn_sz); + ImDrawList* dl = ImGui::GetWindowDrawList(); + bool is_dark = ImGuiWrapper::COL_WINDOW_BG.x != ImGui::GetStyleColorVec4(ImGuiCol_WindowBg).x; + ImVec4 col = is_dark ? ImGuiWrapper::COL_ORCA_DARK : ImGuiWrapper::COL_ORCA; + ImVec4 col_hvr = is_dark ? ImGuiWrapper::COL_ORCA_HOVER_DARK : ImGuiWrapper::COL_ORCA_HOVER; + dl->AddCircleFilled( + ImVec2(p.x + btn_sz.x * .5f, p.y + btn_sz.y * .5f), + btn_sz.x * .5f, + ImGui::ColorConvertFloat4ToU32((ImGui::IsItemActive()||ImGui::IsItemHovered()) ? col_hvr : col) + ); + dl->AddImage(textureID, ImVec2(p.x + btn_pad, p.y + btn_pad), ImVec2(p.x + btn_sz.x - btn_pad, p.y + btn_sz.y - btn_pad)); + + return clicked; } void begin_right_aligned_buttons(const std::vector& labels) diff --git a/src/slic3r/GUI/Gizmos/GLGizmoUtils.hpp b/src/slic3r/GUI/Gizmos/GLGizmoUtils.hpp index f6feee3df1..83699f8347 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoUtils.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoUtils.hpp @@ -19,6 +19,11 @@ namespace GLGizmoUtils { // Renders a tooltip button using the provided shortcuts void render_tooltip_button( ImGuiWrapper* imgui_wrapper, const GLCanvas3D& canvas, const std::vector>& shortcuts, float x, float y); + + void render_wiki_buttons( + ImGuiWrapper* imgui_wrapper, const GLCanvas3D& canvas, const char* wiki_url, const char* video_url = ""); + bool render_wiki_guide_button(const GLCanvas3D& canvas, float scale, const char* url = ""); + bool render_video_guide_button(const GLCanvas3D& canvas, float scale, const char* url = ""); // Sets up ImGui to render buttons that are right-aligned within the current window, using the provided labels to calculate spacing. void begin_right_aligned_buttons(const std::vector& labels); @@ -27,6 +32,8 @@ namespace GLGizmoUtils { void pop_orca_button_style(); + bool toolbar_circular_button(ImTextureID textureID, const char* id, float scale); + } // namespace GLGizmoUtils } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp index 00bdd13bcb..d840754ac2 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp @@ -308,13 +308,18 @@ bool GLGizmosManager::init_icon_textures() else return false; - if (IMTexture::load_from_svg_file(Slic3r::resources_dir() + "/images/toolbar_tooltip.svg", 25, 25, texture_id)) // ORCA: Use same resolution with gizmos to prevent blur on icon + if (IMTexture::load_from_svg_file(Slic3r::resources_dir() + "/images/toolbar_tooltip.svg", 15, 15, texture_id)) // ORCA: Use same resolution with gizmos to prevent blur on icon icon_list.insert(std::make_pair((int)IC_TOOLBAR_TOOLTIP, texture_id)); else return false; - if (IMTexture::load_from_svg_file(Slic3r::resources_dir() + "/images/toolbar_tooltip_hover.svg", 25, 25, texture_id)) // ORCA: Use same resolution with gizmos to prevent blur on icon - icon_list.insert(std::make_pair((int)IC_TOOLBAR_TOOLTIP_HOVER, texture_id)); + if (IMTexture::load_from_svg_file(Slic3r::resources_dir() + "/images/toolbar_wiki.svg", 15, 15, texture_id)) + icon_list.insert(std::make_pair((int)IC_TOOLBAR_WIKI_GUIDE, texture_id)); + else + return false; + + if (IMTexture::load_from_svg_file(Slic3r::resources_dir() + "/images/toolbar_video_guide.svg", 15, 15, texture_id)) + icon_list.insert(std::make_pair((int)IC_TOOLBAR_VIDEO_GUIDE, texture_id)); else return false; diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp index 3f53eeaa93..0914f8d958 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp @@ -182,7 +182,8 @@ public: IC_TOOLBAR_RESET_ZERO, IC_TOOLBAR_RESET_ZERO_HOVER, IC_TOOLBAR_TOOLTIP, - IC_TOOLBAR_TOOLTIP_HOVER, + IC_TOOLBAR_WIKI_GUIDE, + IC_TOOLBAR_VIDEO_GUIDE, IC_NAME_COUNT, IC_CANVAS_MENU, IC_CANVAS_MENU_HOVER, diff --git a/src/slic3r/GUI/Jobs/ArrangeJob.cpp b/src/slic3r/GUI/Jobs/ArrangeJob.cpp index a67a78a62b..9f7df5c8b0 100644 --- a/src/slic3r/GUI/Jobs/ArrangeJob.cpp +++ b/src/slic3r/GUI/Jobs/ArrangeJob.cpp @@ -38,6 +38,11 @@ #include #include "libslic3r/LifecycleEvents.hpp" #include +#include "libslic3r/Geometry.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/Config.hpp" +#include +#include #define SAVE_ARRANGE_POLY 0 @@ -86,6 +91,14 @@ public: } }; +// The belt purge prism is generated from the arranged parts (ensure_belt_purge_tower), +// so arrange neither moves it nor packs around it; it reserves the prism's strip instead. +static bool is_belt_purge_prism(const ModelObject *mo) +{ + const ConfigOption *opt = mo->config.option("belt_purge_tower_object"); + return opt != nullptr && opt->getBool(); +} + // BBS: add partplate logic static WipeTower get_wipe_tower(const Plater &plater, int plate_idx) { @@ -100,6 +113,63 @@ arrangement::ArrangePolygon get_wipetower_arrange_poly(WipeTower* tower) return ap; } +// Belt printers pack their parts against the edges of the bed, so what has to stay +// free there is reserved with fixed virtual items on every plate, the way the bed's +// own exclusion areas are: +// - the strip the purge prism comes back to, flush with the far lateral edge (see +// ensure_belt_purge_tower), when the parts use more than one filament; +// - the brim along every edge: a belt brim is printed brim_width wide for every brim +// type but none. Between parts the brims may overlap, as on any printer. +void ArrangeJob::prepare_belt_regions(int num_plates) +{ + if (!params.is_belt || params.is_seq_print) + return; + const DynamicPrintConfig &config = wxGetApp().preset_bundle->full_config(); + const BoundingBoxf bed = get_extents(config.opt("printable_area")->values); + const bool belt_is_y = params.belt_axis == 1; + std::vector regions; + + std::set filaments; + for (const ArrangePolygons *items : { &m_selected, &m_unselected }) + for (const ArrangePolygon &ap : *items) + if (!ap.is_virt_object) + filaments.insert(ap.extrude_ids.begin(), ap.extrude_ids.end()); + if (config.opt_bool("enable_belt_purge_tower") && filaments.size() > 1) { + // The prism is at least one millimetre per island plus the gaps between them. + const int islands = int(filaments.size()) - 1; + const double width = std::max(config.opt_float("belt_purge_tower_width"), 2. * islands - 1.) + 1.; // + the prism's edge inset + BoundingBoxf strip = bed; + if (belt_is_y) + strip.min.x() = std::max(bed.min.x(), bed.max.x() - width); + else + strip.min.y() = std::max(bed.min.y(), bed.max.y() - width); + regions.push_back(strip); + } + + // Virtual items are inflated by the one millimetre exclusion gap already. + const double brim = config.opt_enum("brim_type") == btNoBrim ? 0. : + config.opt_float("brim_width") + config.opt_float("brim_object_gap") + config.opt_float("extra_brim_width") - 1.; + if (brim > 0.) { + regions.emplace_back(bed.min, Vec2d(bed.min.x() + brim, bed.max.y())); + regions.emplace_back(Vec2d(bed.max.x() - brim, bed.min.y()), bed.max); + regions.emplace_back(bed.min, Vec2d(bed.max.x(), bed.min.y() + brim)); + regions.emplace_back(Vec2d(bed.min.x(), bed.max.y() - brim), bed.max); + } + + for (int j = 0; j < num_plates; ++j) + for (size_t i = 0; i < regions.size(); ++i) { + ArrangePolygon ap; + ap.poly.contour = scaled(regions[i]).polygon(); + ap.translation = Vec2crd(0, 0); + ap.rotation = 0.f; + ap.is_virt_object = true; + ap.bed_idx = j; + ap.height = 1; + ap.name = "BeltRegion" + std::to_string(i); + m_unselected.emplace_back(std::move(ap)); + } +} + void ArrangeJob::clear_input() { const Model &model = m_plater->model(); @@ -151,6 +221,8 @@ void ArrangeJob::prepare_selected() { for (size_t oidx = 0; oidx < model.objects.size(); ++oidx) { const Selection::InstanceIdxsList* instlist = obj_sel[oidx]; ModelObject* mo = model.objects[oidx]; + if (is_belt_purge_prism(mo)) + continue; std::vector inst_sel(mo->instances.size(), false); @@ -199,6 +271,7 @@ void ArrangeJob::prepare_selected() { } prepare_wipe_tower(); + prepare_belt_regions(MAX_NUM_PLATES); // The strides have to be removed from the fixed items. For the @@ -229,6 +302,8 @@ void ArrangeJob::prepare_all() { // Go through the objects and check if inside the selection for (size_t oidx = 0; oidx < model.objects.size(); ++oidx) { ModelObject *mo = model.objects[oidx]; + if (is_belt_purge_prism(mo)) + continue; for (size_t i = 0; i < mo->instances.size(); ++i) { ModelInstance * mi = mo->instances[i]; @@ -275,6 +350,7 @@ void ArrangeJob::prepare_all() { // add the virtual object into unselect list if has plate_list.preprocess_exclude_areas(m_unselected, enable_wrapping, MAX_NUM_PLATES); + prepare_belt_regions(MAX_NUM_PLATES); } arrangement::ArrangePolygon estimate_wipe_tower_info(int plate_index, std::set& extruder_ids) @@ -313,6 +389,11 @@ void ArrangeJob::prepare_wipe_tower() bool enable_prime_tower = op && op->getBool(); if (!enable_prime_tower || params.is_seq_print) return; + // Belt printers have no classic wipe tower; purging goes into the belt purge + // prism, whose strip prepare_belt_regions() reserves. + if (params.is_belt) + return; + bool smooth_timelapse = false; auto sop = current_config.option("timelapse_type"); if (sop) { smooth_timelapse = sop->getInt() == TimelapseType::tlSmooth; } @@ -416,6 +497,8 @@ void ArrangeJob::prepare_partplate() { for (size_t oidx = 0; oidx < model.objects.size(); ++oidx) { ModelObject* mo = model.objects[oidx]; + if (is_belt_purge_prism(mo)) + continue; for (size_t inst_idx = 0; inst_idx < mo->instances.size(); ++inst_idx) { bool in_plate = plate->contain_instance(oidx, inst_idx) || plate->intersect_instance(oidx, inst_idx); @@ -451,6 +534,7 @@ void ArrangeJob::prepare_partplate() { // add the virtual object into unselect list if has plate_list.preprocess_exclude_areas(m_unselected, enable_wrapping, current_plate_index + 1); + prepare_belt_regions(current_plate_index + 1); } //BBS: add partplate logic @@ -858,6 +942,19 @@ arrangement::ArrangeParams init_arrange_params(Plater *p) params.is_seq_print = settings.is_seq_print; params.min_obj_distance = scaled(settings.distance); params.align_to_y_axis = settings.align_to_y_axis; + if (print_config.belt_printer.value) { + // Parts print in belt order: the belt runs across the gantry's tilt axis, a + // rotation about X prints toward +Y and one about Y toward -X (see + // BeltTransform), a negative angle flips that, and a tilted layer reaches + // cot(angle) * height past a part's far edge. + const BeltRotationAxis axis = print_config.belt_slice_rotation.value; + const double angle = print_config.belt_slice_rotation_angle.value; + const bool tilted = (axis == BeltRotationAxis::X || axis == BeltRotationAxis::Y) && std::abs(angle) > EPSILON; + params.is_belt = true; + params.belt_axis = axis == BeltRotationAxis::Y ? 0 : 1; + params.belt_reversed = tilted && ((axis == BeltRotationAxis::Y) != (angle < 0.)); + params.belt_tilt_slope = tilted ? float(1. / std::tan(Geometry::deg2rad(std::clamp(std::abs(angle), 5., 90.)))) : 0.f; + } int state = p->get_prepare_state(); if (state == Job::JobPrepareState::PREPARE_STATE_MENU) { diff --git a/src/slic3r/GUI/Jobs/ArrangeJob.hpp b/src/slic3r/GUI/Jobs/ArrangeJob.hpp index 30acdd8700..e3eae8dfd0 100644 --- a/src/slic3r/GUI/Jobs/ArrangeJob.hpp +++ b/src/slic3r/GUI/Jobs/ArrangeJob.hpp @@ -68,6 +68,7 @@ class ArrangeJob : public Job //BBS:prepare the items from current selected partplate void prepare_partplate(); void prepare_wipe_tower(); + void prepare_belt_regions(int num_plates); ArrangePolygon prepare_arrange_polygon(void* instance); diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp index 71c4f4afe7..b876acb62e 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp @@ -53,9 +53,6 @@ void TextureDisplacementBakeJob::process(Ctl &ctl) color_request.quantize = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette); if (!m_input.color.palette_pure.empty()) color_request.quantize_pure = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette_pure); - color_request.resolve = GLGizmoTextureDisplacement::make_mix_resolver( - m_input.color.palette, m_input.color.mix_mode, m_input.color.layer_height, - m_input.color.dither_cell_mm); color_request.despeckle_passes = m_input.color.despeckle_passes; color_request.out_triangle = &m_triangle_color; if (color_request.quantize) diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp index 49cbae4e81..4243cdd9d3 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp @@ -32,9 +32,6 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl) color_request.quantize = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette); if (!m_input.color.palette_pure.empty()) color_request.quantize_pure = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette_pure); - color_request.resolve = GLGizmoTextureDisplacement::make_mix_resolver( - m_input.color.palette, m_input.color.mix_mode, m_input.color.layer_height, - m_input.color.dither_cell_mm); color_request.despeckle_passes = m_input.color.despeckle_passes; color_request.out_triangle = &m_result.triangle_color; if (color_request.quantize) diff --git a/src/slic3r/GUI/LibVGCode/LibVGCodeWrapper.cpp b/src/slic3r/GUI/LibVGCode/LibVGCodeWrapper.cpp index 40ff181fb9..ea010a41b5 100644 --- a/src/slic3r/GUI/LibVGCode/LibVGCodeWrapper.cpp +++ b/src/slic3r/GUI/LibVGCode/LibVGCodeWrapper.cpp @@ -17,6 +17,8 @@ #include "libslic3r/libslic3r.h" #include "LibVGCodeWrapper.hpp" + +#include #include "libslic3r/Print.hpp" #include "libslic3r/Color.hpp" #include "libslic3r/CustomGCode.hpp" @@ -199,10 +201,22 @@ Slic3r::PrintEstimatedStatistics::ETimeMode convert(const ETimeMode& mode) } GCodeInputData convert(const Slic3r::GCodeProcessorResult& result, const std::vector& str_tool_colors, - const std::vector& str_color_print_colors, const Viewer& viewer) + const std::vector& str_color_print_colors, const Viewer& viewer, + const Slic3r::Transform3d* belt_xform) { GCodeInputData ret; + // Belt printers: optionally map each vertex DISPLAY position from machine + // (G-code) space back to model/Cartesian space using the general belt + // back-transform (handles any mesh rotation + shear + axis remap, not just + // 45 deg). Only the rendered position is transformed; layer_id, times and + // the volumetric/flow math below keep the original machine-space values. + auto xform_pos = [belt_xform](const Slic3r::Vec3f& v) -> Vec3 { + if (belt_xform != nullptr) + return convert(Slic3r::Vec3f((*belt_xform * v.cast()).cast())); + return convert(v); + }; + // collect tool colors ret.tools_colors.reserve(str_tool_colors.size()); for (const std::string& color : str_tool_colors) { @@ -218,10 +232,22 @@ GCodeInputData convert(const Slic3r::GCodeProcessorResult& result, const std::ve const std::vector& moves = result.moves; ret.vertices.reserve(2 * moves.size()); + // libvgcode numbers its layers from the vertices it is given and expects them to + // arrive one layer after the other with consecutive ids: a layer without any move + // (a belt file can carry layer changes that print nothing) would leave a gap, and + // every later vertex would then be folded into the last layer created. Renumber + // the ids consecutively over the moves that exist. + uint32_t src_layer_id = std::numeric_limits::max(); + uint32_t layer_id = 0; for (size_t i = 1; i < moves.size(); ++i) { const Slic3r::GCodeProcessorResult::MoveVertex& curr = moves[i]; const Slic3r::GCodeProcessorResult::MoveVertex& prev = moves[i - 1]; const EMoveType curr_type = convert(curr.type); + if (curr.layer_id != src_layer_id) { + if (src_layer_id != std::numeric_limits::max()) + ++ layer_id; + src_layer_id = curr.layer_id; + } const EOptionType option_type = move_type_to_option(curr_type); if (option_type == EOptionType::COUNT || option_type == EOptionType::Travels || option_type == EOptionType::Wipes) { if (ret.vertices.empty() || prev.type != curr.type || prev.extrusion_role != curr.extrusion_role @@ -231,17 +257,17 @@ GCodeInputData convert(const Slic3r::GCodeProcessorResult& result, const std::ve // equal to the current one with the exception of the position, which should match the previous move position, // and the times, which are set to zero #if VGCODE_ENABLE_COG_AND_TOOL_MARKERS - const libvgcode::PathVertex vertex = { convert(prev.position), curr.height, curr.width, curr.feedrate, prev.actual_feedrate, + const libvgcode::PathVertex vertex = { xform_pos(prev.position), curr.height, curr.width, curr.feedrate, prev.actual_feedrate, curr.mm3_per_mm, curr.fan_speed, curr.temperature, 0.0f, convert(curr.extrusion_role), curr_type, - static_cast(curr.gcode_id), static_cast(curr.layer_id), + static_cast(curr.gcode_id), layer_id, static_cast(curr.extruder_id), static_cast(curr.cp_color_id), { 0.0f, 0.0f }, /* ORCA: Add Pressure Advance visualization support */ 0.0f, curr.pressure_advance, /* ORCA: Add Acceleration visualization support */ curr.acceleration, /* ORCA: Add Jerk visualization support */ curr.jerk }; #else - const libvgcode::PathVertex vertex = { convert(prev.position), curr.height, curr.width, curr.feedrate, prev.actual_feedrate, + const libvgcode::PathVertex vertex = { xform_pos(prev.position), curr.height, curr.width, curr.feedrate, prev.actual_feedrate, curr.mm3_per_mm, curr.fan_speed, curr.temperature, convert(curr.extrusion_role), curr_type, - static_cast(curr.gcode_id), static_cast(curr.layer_id), + static_cast(curr.gcode_id), layer_id, static_cast(curr.extruder_id), static_cast(curr.cp_color_id), { 0.0f, 0.0f }, /* ORCA: Add Pressure Advance visualization support */ 0.0f, curr.pressure_advance, /* ORCA: Add Acceleration visualization support */ curr.acceleration, @@ -252,18 +278,18 @@ GCodeInputData convert(const Slic3r::GCodeProcessorResult& result, const std::ve } #if VGCODE_ENABLE_COG_AND_TOOL_MARKERS - const libvgcode::PathVertex vertex = { convert(curr.position), curr.height, curr.width, curr.feedrate, curr.actual_feedrate, + const libvgcode::PathVertex vertex = { xform_pos(curr.position), curr.height, curr.width, curr.feedrate, curr.actual_feedrate, curr.mm3_per_mm, curr.fan_speed, curr.temperature, result.filament_densities[curr.extruder_id] * curr.mm3_per_mm * (curr.position - prev.position).norm(), - convert(curr.extrusion_role), curr_type, static_cast(curr.gcode_id), static_cast(curr.layer_id), + convert(curr.extrusion_role), curr_type, static_cast(curr.gcode_id), layer_id, static_cast(curr.extruder_id), static_cast(curr.cp_color_id), curr.time, /* ORCA: Add Pressure Advance visualization support */ 0.0f, curr.pressure_advance, /* ORCA: Add Acceleration visualization support */ curr.acceleration, /* ORCA: Add Jerk visualization support */ curr.jerk }; #else - const libvgcode::PathVertex vertex = { convert(curr.position), curr.height, curr.width, curr.feedrate, curr.actual_feedrate, + const libvgcode::PathVertex vertex = { xform_pos(curr.position), curr.height, curr.width, curr.feedrate, curr.actual_feedrate, curr.mm3_per_mm, curr.fan_speed, curr.temperature, convert(curr.extrusion_role), curr_type, - static_cast(curr.gcode_id), static_cast(curr.layer_id), + static_cast(curr.gcode_id), layer_id, static_cast(curr.extruder_id), static_cast(curr.cp_color_id), curr.time, /* ORCA: Add Pressure Advance visualization support */ 0.0f, curr.pressure_advance, /* ORCA: Add Acceleration visualization support */ curr.acceleration, @@ -273,6 +299,11 @@ GCodeInputData convert(const Slic3r::GCodeProcessorResult& result, const std::ve } ret.vertices.shrink_to_fit(); + // Note: the belt designed-view anchoring (recovering the per-object placement/ + // lift translation the linear back-transform cannot) is folded into belt_xform + // by the caller (GCodeViewer::load_as_gcode), which anchors onto the upright + // model bounding box. Nothing extra to do here. + ret.spiral_vase_mode = result.spiral_vase_mode; return ret; diff --git a/src/slic3r/GUI/LibVGCode/LibVGCodeWrapper.hpp b/src/slic3r/GUI/LibVGCode/LibVGCodeWrapper.hpp index dfcce08b69..a780a5f033 100644 --- a/src/slic3r/GUI/LibVGCode/LibVGCodeWrapper.hpp +++ b/src/slic3r/GUI/LibVGCode/LibVGCodeWrapper.hpp @@ -70,7 +70,8 @@ extern Slic3r::PrintEstimatedStatistics::ETimeMode convert(const ETimeMode& mode // mapping from Slic3r::GCodeProcessorResult to libvgcode::GCodeInputData extern GCodeInputData convert(const Slic3r::GCodeProcessorResult& result, const std::vector& str_tool_colors, - const std::vector& str_color_print_colors, const Viewer& viewer); + const std::vector& str_color_print_colors, const Viewer& viewer, + const Slic3r::Transform3d* belt_xform = nullptr); // mapping from Slic3r::Print to libvgcode::GCodeInputData extern GCodeInputData convert(const Slic3r::Print& print, const std::vector& str_tool_colors, diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index db21915477..6ef7dedaa8 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -2309,6 +2309,12 @@ wxBoxSizer* MainFrame::create_side_tools() bool slice = true; + // The Slice-plate hover popup is a transient popup that keeps grabbing + // the mouse capture while shown. Left behind the modal grouping dialog it + // would starve that dialog of mouse events, so close it synchronously first. + if (m_filament_group_popup) + m_filament_group_popup->Dismiss(); + auto curr_plate = m_plater->get_partplate_list().get_curr_plate(); #ifdef __linux__ PresetBundle* preset = wxGetApp().preset_bundle; diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 2646852826..3bf53ecb73 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1555,6 +1555,17 @@ void PartPlate::calc_height_limit() { BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << "Unable to create height limit top lines\n"; } +// The plate's icons and labels grow with its depth, but they sit in the gap to the +// next plate, which grows with its width: on a long, narrow bed (a belt) they would +// otherwise run across the neighbouring plate. +float PartPlate::icon_scale_factor() const +{ + const BoundingBoxf bed_ext = get_extents(m_shape); + const double by_depth = bed_ext.size().y() / 200.; + const double by_gap = bed_ext.size().x() * LOGICAL_PART_PLATE_GAP / (PARTPLATE_ICON_SIZE + 2 * PARTPLATE_ICON_GAP_LEFT); + return float(std::min(by_depth, by_gap)); +} + void PartPlate::calc_vertex_for_number(int index, bool one_number, GLModel &buffer) { buffer.reset(); @@ -1571,7 +1582,7 @@ void PartPlate::calc_vertex_for_number(int index, bool one_number, GLModel &buff #else //in the bottom auto bed_ext = get_extents(m_shape); Vec2d p = bed_ext[1]; - float factor = bed_ext.size()(1) / 200.0; + float factor = icon_scale_factor(); float size = PARTPLATE_ICON_SIZE * factor; float offset_y = PARTPLATE_TEXT_OFFSET_Y * factor; float offset_x = (one_number?PARTPLATE_TEXT_OFFSET_X1: PARTPLATE_TEXT_OFFSET_X2) * factor; @@ -1593,7 +1604,7 @@ void PartPlate::calc_vertex_for_plate_name_edit_icon(GLTexture *texture, int ind ExPolygon poly; auto bed_ext = get_extents(m_shape); Vec2d p = bed_ext[3]; - float factor = bed_ext.size()(1) / 200.0; + float factor = icon_scale_factor(); float icon_sz = factor * PARTPLATE_EDIT_PLATE_NAME_ICON_SIZE; float width = icon_sz; float height = icon_sz; @@ -1626,7 +1637,7 @@ void PartPlate::calc_vertex_for_icons(int index, PickingModel &model) ExPolygon poly; auto bed_ext = get_extents(m_shape); Vec2d p = bed_ext[2]; - auto factor = bed_ext.size()(1) / 200.0; + float factor = icon_scale_factor(); float size = PARTPLATE_ICON_SIZE * factor; float gap_left = PARTPLATE_ICON_GAP_LEFT * factor; float gap_y = PARTPLATE_ICON_GAP_Y * factor; @@ -3569,7 +3580,7 @@ void PartPlate::generate_plate_name_texture() ExPolygon poly; auto bed_ext = get_extents(m_shape); Vec2d p = bed_ext[3]; - float factor = bed_ext.size()(1) / 200.0; + float factor = icon_scale_factor(); float icon_sz = factor * PARTPLATE_EDIT_PLATE_NAME_ICON_SIZE; float width = icon_sz * m_name_texture.get_width() / m_name_texture.get_height(); // icon size * text_bb_ratio float height = icon_sz; // scale with icon size to preserve ratio while system scaling @@ -3775,6 +3786,7 @@ bool PartPlate::check_outside(int obj_id, int instance_id, BoundingBoxf3* boundi BoundingBoxf3 instance_box = bounding_box? *bounding_box: object->instance_convex_hull_bounding_box(instance_id); Polygon hull = instance->convex_hull_2d(); BoundingBoxf3 plate_box = get_plate_box(); + this->open_belt_y(plate_box); if (instance_box.max.z() > plate_box.min.z()) plate_box.min.z() += instance_box.min.z(); // not considering outsize if sinking @@ -4479,6 +4491,19 @@ Polygon PartPlate::get_shared_printable_polygon() const return m_extruder_areas.empty() ? Polygon::new_scale(m_shape) : get_shared_poly(m_extruder_areas); } + +bool PartPlate::belt_open_y() const +{ + // Headless (CLI) plates have no plater and no wxApp behind wxGetApp(); the CLI's own belt + // handling lives in Print::validate(). + if (m_plater == nullptr || wxGetApp().preset_bundle == nullptr) + return false; + const DynamicPrintConfig &printer = wxGetApp().preset_bundle->printers.get_edited_preset().config; + const auto *belt = printer.option("belt_printer"); + const auto *infinite_y = printer.option("belt_printer_infinite_y"); + return belt != nullptr && belt->value && infinite_y != nullptr && infinite_y->value; +} + bool PartPlate::contains(const Vec3d& point) const { return m_bounding_box.contains(point); @@ -4498,6 +4523,7 @@ bool PartPlate::contains(const BoundingBoxf3& bb) const print_volume.min(1) -= Slic3r::BuildVolume::BedEpsilon; print_volume.max(0) += Slic3r::BuildVolume::BedEpsilon; print_volume.max(1) += Slic3r::BuildVolume::BedEpsilon; + this->open_belt_y(print_volume); return print_volume.contains(bb); } @@ -4510,6 +4536,7 @@ bool PartPlate::intersects(const BoundingBoxf3& bb) const print_volume.min(1) -= Slic3r::BuildVolume::BedEpsilon; print_volume.max(0) += Slic3r::BuildVolume::BedEpsilon; print_volume.max(1) += Slic3r::BuildVolume::BedEpsilon; + this->open_belt_y(print_volume); return print_volume.intersects(bb); } diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index 0ea6d25707..92fc8377c7 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -224,6 +224,7 @@ private: bool resolve_active_mode_tools(std::string& out_tools_str, int& out_primary_phys) const; void render_imex_zones(bool force_default_color); void refresh_imex_icon(); + float icon_scale_factor() const; void calc_vertex_for_number(int index, bool one_number, GLModel &buffer); void calc_vertex_for_plate_name_edit_icon(GLTexture *texture, int index, PickingModel &model); void calc_vertex_for_icons(int index, PickingModel &model); @@ -540,6 +541,10 @@ public: bool contains(const GLVolume& v) const; bool contains(const BoundingBoxf3& bb) const; bool intersects(const BoundingBoxf3& bb) const; + // A belt printer with belt_printer_infinite_y: the plate is open along Y for the + // containment tests (the drawn plate keeps its shape). + bool belt_open_y() const; + void open_belt_y(BoundingBoxf3 &box) const { if (this->belt_open_y()) { box.min.y() = -1e5; box.max.y() = 1e5; } } void render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_body = false, bool force_background_color = false, HeightLimitMode mode = HEIGHT_LIMIT_NONE, int hover_id = -1, bool render_cali = false, bool show_grid = true, bool hide_chrome = false); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index ca14b83c4a..37081e1d71 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -211,6 +211,7 @@ #include "UVEditorCanvas.hpp" #include "3DBed.hpp" #include "PartPlate.hpp" +#include "BeltPurgeTower.hpp" #include "IMEXFilamentPickerPopover.hpp" #include "Camera.hpp" #include "Mouse3DController.hpp" @@ -268,6 +269,7 @@ #include #include // Needs to be last because reasons :-/ #include +#include #include "WipeTowerDialog.hpp" #include "MixedFilamentDialog.hpp" #include "TextureImportDialog.hpp" @@ -937,6 +939,7 @@ struct Sidebar::priv StaticLine* m_text_mixed_title{nullptr}; ScalableButton* m_btn_mixed_add{nullptr}; ScalableButton* m_btn_mixed_del{nullptr}; + ScalableButton* m_btn_mixed_del_all{nullptr}; wxScrolledWindow* m_mixed_scroll_area{nullptr}; // independent scrollbar for mixed rows wxPanel* m_panel_mixed_content{nullptr}; wxBoxSizer* m_sizer_mixed_filaments{nullptr}; // two-column, mirrors sizer_filaments @@ -3451,6 +3454,11 @@ Sidebar::Sidebar(Plater *parent) }); title_sizer->Add(p->m_btn_mixed_del, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(SidebarProps::IconSpacing())); + p->m_btn_mixed_del_all = new ScalableButton(p->m_panel_mixed_title, wxID_ANY, "delete_all_filaments"); + p->m_btn_mixed_del_all->SetToolTip(_L("Remove all mixed filaments")); + p->m_btn_mixed_del_all->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { remove_all_mixed_filaments(); }); + title_sizer->Add(p->m_btn_mixed_del_all, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(SidebarProps::IconSpacing())); + p->m_btn_mixed_add = new ScalableButton(p->m_panel_mixed_title, wxID_ANY, "add_filament"); p->m_btn_mixed_add->SetToolTip(_L("Add mixed filament")); p->m_btn_mixed_add->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { add_mixed_filament(); }); @@ -5124,6 +5132,58 @@ static bool create_mixed_filament_from_result( return true; } +int Sidebar::ensure_mixed_filament(const std::vector &components, const std::vector &ratios) +{ + if (components.size() < 2 || components.size() != ratios.size()) + return -1; + if (p->combos_filament.size() < 2) + return -1; + + // Normalise the way create_mixed_filament_from_result() stores them, so the comparison below sees + // the same text the config holds rather than two spellings of one blend. + int ratio_sum = 0; + for (const int r : ratios) + ratio_sum += r; + if (ratio_sum <= 0) + return -1; + + std::string comp_str, ratio_str; + { + CNumericLocalesSetter c_locale_setter; + for (size_t i = 0; i < components.size(); ++i) { + if (i > 0) { comp_str += ","; ratio_str += ","; } + comp_str += std::to_string(components[i]); + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.4f", float(ratios[i]) / float(ratio_sum)); + ratio_str += buf; + } + } + + const auto &project_config = wxGetApp().preset_bundle->project_config; + const auto *is_mixed_opt = project_config.option("filament_is_mixed"); + const auto *comp_opt = project_config.option("filament_mixed_components"); + const auto *ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); + if (is_mixed_opt != nullptr && comp_opt != nullptr && ratios_opt != nullptr) + for (size_t i = 0; i < is_mixed_opt->values.size(); ++i) + if (is_mixed_opt->values[i] && i < comp_opt->values.size() && i < ratios_opt->values.size() && + comp_opt->values[i] == comp_str && ratios_opt->values[i] == ratio_str) + return int(i); + + if (wxGetApp().preset_bundle->filament_presets.size() >= size_t(EnforcerBlockerType::ExtruderMax)) + return -1; + + std::vector color_strs, names, types; + collect_physical_filament_info(color_strs, names, types); + + MixedFilamentResult result; + result.components = components; + result.ratios = ratios; + const size_t created_at = wxGetApp().preset_bundle->filament_presets.size(); + if (!create_mixed_filament_from_result(this, result, color_strs)) + return -1; + return int(created_at); +} + void Sidebar::add_mixed_filament() { auto* plater = dynamic_cast(GetParent()); @@ -5301,6 +5361,28 @@ void Sidebar::edit_mixed_filament(size_t panel_idx) } } +void Sidebar::remove_all_mixed_filaments() +{ + auto *plater = dynamic_cast(GetParent()); + if (plater == nullptr) + return; + const size_t count = plater->mixed_filament_config_indices().size(); + if (count == 0) + return; + + // Worth a confirmation: this drops filament slots the model may be painted with, and anything + // painted in one falls back to a plain filament. + MessageDialog dlg(this, format_wxstr(_L("Remove all %1% mixed filaments?"), count), _L("Mixed Filament"), + wxYES_NO | wxNO_DEFAULT | wxICON_QUESTION); + if (dlg.ShowModal() != wxID_YES) + return; + + // Back to front: delete_mixed_filament_at() indexes the list as it stands, so removing from the end + // leaves the indices of everything still to go untouched. + for (size_t i = count; i-- > 0;) + delete_mixed_filament_at(i); +} + void Sidebar::delete_mixed_filament_at(size_t panel_idx) { auto* plater = dynamic_cast(GetParent()); @@ -7430,6 +7512,11 @@ struct Plater::priv void exit_gizmo(); void remove(size_t obj_idx); bool delete_object_from_model(size_t obj_idx, bool refresh_immediately = true); //BBS + // ORCA-Belt: keep the auto-generated belt purge prism in sync with the + // config and plate contents (thin wrapper over GUI::ensure_belt_purge_tower + // in BeltPurgeTower.cpp). Returns true when the model was mutated. + bool ensure_belt_purge_tower(); + std::vector m_belt_purge_sigs; void delete_all_objects_from_model(); void reset(bool apply_presets_change = false, bool reload_presets = true); void center_selection(); @@ -8803,6 +8890,20 @@ void read_binary_stl(const std::string& filename, std::string& model_id, std::st return; } +// Logs what show_substitutions_info() would list, for loads that don't show the dialog. +static void log_substitutions(const ConfigSubstitutions& substitutions, const std::string& source) +{ + for (const ConfigSubstitution& substitution : substitutions) + BOOST_LOG_TRIVIAL(warning) << "Loading " << source << ": " << substitution.opt_def->opt_key << " = \"" << substitution.old_value + << "\" replaced with \"" << substitution.new_value->serialize() << "\""; +} + +static void log_substitutions(const PresetsConfigSubstitutions& substitutions, const std::string& source) +{ + for (const PresetConfigSubstitutions& preset : substitutions) + log_substitutions(preset.substitutions, source + " (preset " + preset.preset_name + ")"); +} + // BBS: backup & restore std::vector Plater::priv::load_files(const std::vector& input_files, LoadStrategy strategy, @@ -9104,7 +9205,6 @@ std::vector Plater::priv::load_files(const std::vector& input_ Semver app_version = *(Semver::parse(SoftFever_VERSION)); const wxString load_3mf_title = _L("Load 3MF"); const wxString newer_3mf_title = _L("Newer 3MF version"); - const wxString bambu_project_title = _L("BambuStudio Project"); const wxString msg_unsupported_geometry = _L("The 3MF is not supported by OrcaSlicer, loading geometry data only."); const wxString msg_old_orca_geometry = _L("The 3MF file was generated by an old OrcaSlicer version, loading geometry data only."); const wxString msg_older_geometry = _L("The 3MF file was generated by an older version, loading geometry data only."); @@ -9115,6 +9215,8 @@ std::vector Plater::priv::load_files(const std::vector& input_ << boost::format("3MF import message [%1%]: %2% | file: %3%") % into_u8(title) % into_u8(text) % path.string(); show_info(q, text, title); }; + // Untagged files up to 2.3.2 may also come from OrcaSlicer, which only started tagging its 3MFs after it. + const bool is_bambu_studio_project = en_3mf_file_type == En3mfType::From_BBS && file_version > Semver(2, 3, 2); if (en_3mf_file_type == En3mfType::From_Prusa) { // do not reset the model config load_config = false; @@ -9174,8 +9276,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ } else if (en_3mf_file_type == En3mfType::From_BBS) { // No OrcaSlicer tag - check Bambu/Application version - Semver orca_tag_start_version(2, 3, 2); - if (file_version <= orca_tag_start_version) { + if (!is_bambu_studio_project) { // Compatible old version (before OrcaSlicer tagging was introduced after 2.3.2). // Any version prior or equal to 2.3.2 is older than the current one, no version warnings needed. // Still apply migration fixes for known old versions. @@ -9208,33 +9309,17 @@ std::vector Plater::priv::load_files(const std::vector& input_ } } else { // BambuStudio project (version > 2.3.2 without OrcaSlicer tag) - // Report that a BambuStudio project is being imported and compare with SLIC3R_VERSION - Semver slic3r_version = *(Semver::parse(SLIC3R_VERSION)); if (load_config && config_loaded.empty()) { load_config = false; log_and_show_3mf_info(msg_bambu_geometry, load_3mf_title); } - else if (load_config && (file_version > slic3r_version)) { - // BambuStudio file version is newer than our compatible SLIC3R_VERSION - if (config_substitutions.unrecogized_keys.size() > 0) { - wxString text = wxString::Format(_L("The 3MF was created by BambuStudio (version %s), which is newer than the compatible version %s. Found unrecognized settings:"), - file_version.to_string(), slic3r_version.to_string()); - text += "\n"; - wxString context = text; - wxString append = _L("You should update your software.\n"); - context += "\n\n"; - context += append; - log_and_show_3mf_info(context, bambu_project_title); - } else { - wxString text = wxString::Format(_L("The 3MF was created by BambuStudio (version %s), which is newer than the compatible version %s. Some settings may not be fully compatible."), - file_version.to_string(), slic3r_version.to_string()); - text += "\n"; - log_and_show_3mf_info(text, bambu_project_title); - } - } else if (load_config && !published_config.published) { - // BambuStudio version is older or same as our SLIC3R_VERSION - wxString text = _L("The 3MF was created by BambuStudio. Some settings may differ from OrcaSlicer."); - log_and_show_3mf_info(text, bambu_project_title); + else if (load_config) { + // Logged, not shown: it is the same for every BambuStudio project and needs no action. + std::string unrecognized; + for (const std::string& key : config_substitutions.unrecogized_keys) + unrecognized += (unrecognized.empty() ? "" : ", ") + key; + BOOST_LOG_TRIVIAL(info) << "BambuStudio " << file_version.to_string() << " project " << path.string() + << ", unrecognized settings: " << (unrecognized.empty() ? "none" : unrecognized); } } } @@ -9323,7 +9408,10 @@ std::vector Plater::priv::load_files(const std::vector& input_ PresetsConfigSubstitutions preset_substitutions; PresetBundle & preset_bundle = *wxGetApp().preset_bundle; preset_substitutions = preset_bundle.load_project_embedded_presets(project_presets, ForwardCompatibilitySubstitutionRule::Enable); - if (!preset_substitutions.empty()) show_substitutions_info(preset_substitutions); + if (is_bambu_studio_project) + log_substitutions(preset_substitutions, path.string()); + else if (!preset_substitutions.empty()) + show_substitutions_info(preset_substitutions); } if (project_presets.size() > 0) { for (unsigned int i = 0; i < project_presets.size(); i++) { delete project_presets[i]; } @@ -9361,7 +9449,11 @@ std::vector Plater::priv::load_files(const std::vector& input_ notify_manager->bbl_show_3mf_warn_notification(error_message); } } - if (!config_substitutions.empty()) show_substitutions_info(config_substitutions.substitutions, filename.string()); + // BambuStudio projects routinely carry values Orca replaces; log them rather than showing a dialog on every open. + if (is_bambu_studio_project) + log_substitutions(config_substitutions.substitutions, path.string()); + else if (!config_substitutions.empty()) + show_substitutions_info(config_substitutions.substitutions, filename.string()); // BBS if (load_model && !load_config) { @@ -10356,7 +10448,7 @@ std::vector Plater::priv::load_model_objects(const ModelObjectPtrs& mode // BBS: find an empty cell to put the copied object for (auto& instance : new_instances) { auto offset = instance->get_offset(); - auto start_point = this->bed.build_volume().bounding_volume2d().center(); + auto start_point = this->bed.build_volume().bed_center(); bool plate_empty = partplate_list.get_curr_plate()->empty(); Vec3d displacement; if (plate_empty) @@ -11215,6 +11307,16 @@ void Plater::priv::process_validation_warnings(const std::vectorobj_list(), m_belt_purge_sigs); +} + + // Update background processing thread from the current config and Model. // Returns a bitmask of UpdateBackgroundProcessReturnState. namespace { @@ -11259,6 +11361,10 @@ unsigned int Plater::priv::update_background_process(bool force_validation, bool // If the update_background_process() was not called by the timer, kill the timer, // so the update_restart_background_process() will not be called again in vain. background_process_timer.Stop(); + // ORCA-Belt: sync the auto-managed belt purge prism before the model is + // applied to the Print below, so the prism change rides this same apply. + if (printer_technology == ptFFF && this->ensure_belt_purge_tower()) + return_state |= UPDATE_BACKGROUND_PROCESS_REFRESH_SCENE; // Update the "out of print bed" state of ModelInstances. update_print_volume_state(); // Apply new config to the possibly running background task. @@ -14860,6 +14966,7 @@ void Plater::priv::set_bed_shape(const Pointfs &shape, Vec2d shape_position = partplate_list.get_current_shape_position(); bool new_shape = bed.set_shape(shape, printable_height, extruder_areas, extruder_heights, custom_model, force_as_custom, shape_position); + float prev_height_lid, prev_height_rod; partplate_list.get_height_limits(prev_height_lid, prev_height_rod); double height_to_lid = config->opt_float("extruder_clearance_height_to_lid"); @@ -15213,7 +15320,8 @@ bool Plater::priv::undo_redo_blocked_by_job() return false; notification_manager->push_notification(NotificationType::CustomNotification, NotificationManager::NotificationLevel::RegularNotificationLevel, - _u8L("Cannot undo or redo while an operation is running. Stop it first.")); + _u8L("Cannot undo or redo while an operation is running. Stop the operation, or wait " + "for it to finish and then retry.")); return true; } @@ -16561,8 +16669,167 @@ bool Plater::add_model(bool imperial_units, std::string fname) return loaded; } +// ORCA-Belt: belt-printer handling for the desktop calibration tests. +// +// Belt slicing applies a global pre-slice rotation R(angle, axis) to every +// mesh (see BeltTransform.hpp) so the slicing planes match the tilted gantry. +// Calibration models are designed for upright slicing: their per-height test +// bands and XY-plane quality features assume slicer Z is the model's own Z +// axis. Counter-rotating each calibration object by the inverse rotation in +// world space cancels the global rotation, so in slicing space the object +// stands upright exactly as on a flat-bed printer and every test keeps its +// designed meaning. Physically the object then leans over the belt with its +// bottom face overhanging, so per-object supports fill the wedge between the +// bottom face and the belt. The wedge prints entirely below the object's base +// plane and leaves the test geometry untouched. Manual tree support is used +// so the deliberate bridge/overhang features of the test models stay +// unsupported; the wedge under the floating bottom face is built by the +// belt-floor extension in TreeSupport::generate(), which stacks the floating +// first-layer footprint down to the belt surface. +static bool belt_calib_rotation_params(double& angle_rad, Vec3d& axis) +{ + const auto& printer_config = wxGetApp().preset_bundle->printers.get_edited_preset().config; + const auto* belt_opt = printer_config.option("belt_printer"); + if (belt_opt == nullptr || !belt_opt->value) + return false; + const auto* axis_opt = printer_config.option>("belt_slice_rotation"); + const auto* angle_opt = printer_config.option("belt_slice_rotation_angle"); + if (axis_opt == nullptr || angle_opt == nullptr) + return false; + switch (axis_opt->value) { + case BeltRotationAxis::X: axis = Vec3d::UnitX(); break; + case BeltRotationAxis::Y: axis = Vec3d::UnitY(); break; + // Z rotation is an in-plane spin and None means no tilt; objects already + // slice upright in those cases and need no special handling. + default: return false; + } + angle_rad = -Geometry::deg2rad(angle_opt->value); + return std::abs(angle_rad) > EPSILON; +} + +// ORCA-Belt: flip the ringing tower 180° about Z before the belt +// counter-rotation — its sloped face then leans over the belt and the +// support wedge gets much smaller. +static void belt_calib_flip_ringing_tower(Model &model) +{ + double angle_rad = 0.; + Vec3d axis = Vec3d::UnitX(); + if (belt_calib_rotation_params(angle_rad, axis) && !model.objects.empty()) + model.objects.front()->rotate(M_PI, Vec3d::UnitZ()); +} + +void Plater::_calib_apply_belt_mode() +{ + double angle_rad = 0.; + Vec3d axis = Vec3d::UnitX(); + if (!belt_calib_rotation_params(angle_rad, axis)) + return; + + auto print_config = &wxGetApp().preset_bundle->prints.get_edited_preset().config; + // Spiral vase stays enabled where the tests request it: the support wedge + // lies strictly below the object, support layers never spiralize + // (spiral_vase_enable requires an object layer), and the spiral/support + // exclusivity check only applies to globally enabled supports — the wedge + // uses per-object supports. + // A skirt would be drawn in the first slicing plane, which lies mostly + // above the belt surface. + print_config->set_key_value("skirt_loops", new ConfigOptionInt(0)); + + const Matrix3d cancel_rotation = Eigen::AngleAxisd(angle_rad, axis).toRotationMatrix(); + std::vector obj_idxs; + for (size_t i = 0; i < model().objects.size(); ++i) { + ModelObject* obj = model().objects[i]; + if (obj->instances.size() != 1) + continue; + obj_idxs.emplace_back(i); + + // Manual tree support: only the floating bottom face gets a support + // wedge (via the belt-floor extension in TreeSupport::generate()), + // leaving the test features untouched. The style is pinned to hybrid + // because the default style resolves to organic, which bypasses the + // non-organic generator that hosts the belt-floor extension. + obj->config.set_key_value("enable_support", new ConfigOptionBool(true)); + obj->config.set_key_value("support_type", new ConfigOptionEnum(stTree)); + obj->config.set_key_value("support_style", new ConfigOptionEnum(smsTreeHybrid)); + obj->config.set_key_value("support_on_build_plate_only", new ConfigOptionBool(false)); + // With the default base pattern, tree support base areas print as + // hollow outlines (no infill) — the wedge needs a real pattern. + obj->config.set_key_value("support_base_pattern", new ConfigOptionEnum(smpRectilinear)); + + // Counter-rotate exactly the way the rotate gizmo would: rotation on + // the instance, then a plain drop to the bed. This leaves the object + // in the same state shape as any manually rotated object, which the + // belt pipeline is known to handle. + ModelInstance* inst = obj->instances.front(); + inst->rotate(cancel_rotation); + obj->invalidate_bounding_box(); + obj->ensure_on_bed(); + } + + // Each object's support wedge extends upstream of it by roughly its own + // depth (at 45°), so the tight flat-bed layouts of the multi-part tests + // leave wedges intersecting the neighbouring parts. Keep the grid rows of + // the test layouts together and open up the space between rows just + // enough for the wedge shadow. + if (obj_idxs.size() > 1) { + std::vector sorted_objs; + sorted_objs.reserve(obj_idxs.size()); + for (size_t i : obj_idxs) + sorted_objs.emplace_back(model().objects[i]); + std::sort(sorted_objs.begin(), sorted_objs.end(), [](const ModelObject* a, const ModelObject* b) { + return a->instances.front()->get_offset(Y) < b->instances.front()->get_offset(Y); + }); + std::vector> rows; + double row_y = std::numeric_limits::quiet_NaN(); + for (ModelObject* o : sorted_objs) { + const double oy = o->instances.front()->get_offset(Y); + if (rows.empty() || oy - row_y > 1.) + rows.emplace_back(); + rows.back().emplace_back(o); + row_y = oy; + } + const double wedge_factor = std::abs(std::tan(angle_rad)); + double cursor = std::numeric_limits::quiet_NaN(); + for (std::vector& row : rows) { + double rmin = std::numeric_limits::max(); + double rmax = std::numeric_limits::lowest(); + for (ModelObject* o : row) { + const BoundingBoxf3 bb = o->instance_bounding_box(0); + rmin = std::min(rmin, bb.min.y()); + rmax = std::max(rmax, bb.max.y()); + } + if (std::isnan(cursor)) + cursor = rmin; // the first row anchors the layout + const double shift = cursor - rmin; + for (ModelObject* o : row) { + ModelInstance* inst = o->instances.front(); + inst->set_offset(Y, inst->get_offset(Y) + shift); + o->invalidate_bounding_box(); + } + cursor += (rmax - rmin) * (1. + wedge_factor) + 5.; + } + } + + wxGetApp().get_tab(Preset::TYPE_PRINT)->update_dirty(); + wxGetApp().get_tab(Preset::TYPE_PRINT)->reload_config(); + changed_objects(obj_idxs); +} + void Plater::calib_pa(const Calib_Params& params) { + // ORCA-Belt: PA Line / PA Pattern have the belt plumbing in place + // (belt kinematics in world-coordinates mode draws them on the belt surface) + // but are not validated yet — keep them gated to the PA Tower for now. + { + double angle_rad = 0.; + Vec3d axis = Vec3d::UnitX(); + if (belt_calib_rotation_params(angle_rad, axis) && params.mode != CalibMode::Calib_PA_Tower) { + MessageDialog msg_dlg(nullptr, _L("PA Line and PA Pattern tests are not enabled yet on belt printers.\nPlease use the PA Tower method instead."), + wxEmptyString, wxICON_WARNING | wxOK); + msg_dlg.ShowModal(); + return; + } + } const auto calib_pa_name = _L("Pressure Advance Test"); new_project(false, false, calib_pa_name); wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); @@ -16891,6 +17158,7 @@ void Plater::_calib_pa_tower(const Calib_Params& params) { cut_horizontal(0, 0, new_height, ModelObjectCutAttribute::KeepLower); } + _calib_apply_belt_mode(); _calib_pa_select_added_objects(); } @@ -17071,22 +17339,143 @@ void Plater::calib_flowrate(bool is_linear, int pass, InfillPattern pattern) { auto printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config; printer_config->set_key_value("resonance_avoidance", new ConfigOptionBool{false}); + _calib_apply_belt_mode(); + // Refresh object after scaling const std::vector object_idx(boost::counting_iterator(0), boost::counting_iterator(model().objects.size())); changed_objects(object_idx); } +// The belt provini tower (Calib_Params::test_model 1) is one embossed model per +// temperature range. +static std::string belt_temp_tower_asset(const Calib_Params ¶ms) +{ + const int t_start = (int) lround(params.start); + const int t_end = (int) lround(params.end); + return Slic3r::resources_dir() + "/calib/temperature_tower/belt_temp_tower_" + + std::to_string(t_start) + "_" + std::to_string(t_end) + ".stl"; +} + void Plater::calib_temp(const Calib_Params& params) { constexpr double base_temp_tower_nozzle_diameter = 0.4; constexpr double base_temp_tower_block_height = 10.0; constexpr int base_temp_tower_temp_step = 5; + // A belt provini tower exists only for the ranges it was embossed for, and another + // range's model would print numbers that do not match its temperatures. Refuse + // before the current project is replaced. + if (params.mode == CalibMode::Calib_Temp_Tower && params.test_model >= 1) { + const auto &printer_config = wxGetApp().preset_bundle->printers.get_edited_preset().config; + if (printer_config.has("belt_printer") && printer_config.opt_bool("belt_printer") && + ! boost::filesystem::exists(belt_temp_tower_asset(params))) { + MessageDialog dlg(static_cast(wxGetApp().mainframe), + format_wxstr(_L("No belt temperature tower is available for the range %1% to %2% °C. " + "Use a range the tower models cover, for example 230 to 190."), + (int) lround(params.start), (int) lround(params.end)), + _L("Temperature tower"), wxICON_ERROR | wxOK); + dlg.ShowModal(); + return; + } + } + const auto calib_temp_name = _L("Nozzle temperature test"); new_project(false, false, calib_temp_name); wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); - if (params.mode != CalibMode::Calib_Temp_Tower) return; - + if (params.mode != CalibMode::Calib_Temp_Tower) + return; + + // Belt printers build along the conveyor, not vertically — a tall tower cannot + // be sliced. Instead lay a row of DISCRETE provini (one per temperature) along + // the belt and change temperature in discrete steps via custom per-layer G-code + // (M104), injected in the empty gap just before each provino so the nozzle is + // settled by the time that provino prints. We deliberately do NOT call + // set_calib_params here: its per-layer interpolation (interpolate_value_across_ + // layers) would overwrite these discrete M104 events. + { + auto belt_printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config; + if (belt_printer_config->has("belt_printer") && belt_printer_config->opt_bool("belt_printer")) { + // Belt temperature-tower model selector (Calib_Params::test_model): + // 0 = "Standard" -> Joe's counter-rotated sectioned tower. + // 1 = "Overhang" -> engraved inverted-L provini that stress overhang + // print-quality at each discrete temperature (below). + if (params.test_model < 1) { + double belt_angle_rad = 0.; Vec3d belt_axis = Vec3d::UnitX(); + belt_calib_rotation_params(belt_angle_rad, belt_axis); + _calib_temp_belt_sectioned(params, std::abs(belt_angle_rad)); + return; + } + constexpr int TEMP_STEP = 5; // matches Temp_Calibration_Dlg + // Shared geometry contract with the offline asset generator + // (resources/calib/temperature_tower/gen_belt_temp_tower.py): provini are + // replicated along the belt (designed Y) at this pitch. The slicing plane + // is oblique (belt_slice_rotation_angle), so the per-zone advance in the + // layer print_z space the custom-gcode matcher uses is PITCH*cos(theta). + constexpr double PITCH_Y = 74.718; // designed-Y pitch == gen PITCH + const double angle = belt_printer_config->has("belt_slice_rotation_angle") + ? belt_printer_config->opt_float("belt_slice_rotation_angle") : 45.0; + const double zone_topz = PITCH_Y * std::cos(angle * M_PI / 180.0); + // The custom-gcode matcher attaches each event to a real sliced layer. The + // empty inter-provino gap has NO layers, so an event placed there is silently + // dropped. Fire it 70 layers ABOVE provino i's start instead — inside the + // provino body, past the gap and the overlap with provino i-1's tail, so the + // temperature change attaches and applies cleanly. (layer print_z steps by the + // process layer height.) + auto belt_print_config = &wxGetApp().preset_bundle->prints.get_edited_preset().config; + const double layer_h = belt_print_config->has("layer_height") + ? belt_print_config->opt_float("layer_height") : 0.2; + const double into_provino = 70.0 * layer_h; + + const int t_start = (int) lround(params.start); + const int t_end = (int) lround(params.end); + std::vector temps; + const int tstep = (t_start >= t_end) ? -TEMP_STEP : TEMP_STEP; + for (int t = t_start; (tstep < 0) ? (t >= t_end) : (t <= t_end); t += tstep) + temps.push_back(t); + if (temps.empty()) temps.push_back(t_start); + + const std::string asset = belt_temp_tower_asset(params); + if (!boost::filesystem::exists(asset)) // refused above, before new_project() + return; + if (!add_model(false, asset) || model().objects.empty()) + return; + + // Place keel-first asset at the belt entry (designed Y = 0) so Z_gcode + // starts at 0, centered laterally on the bed, resting on the conveyor. + ModelObject* obj = model().objects[0]; + obj->ensure_on_bed(); + BoundingBoxf3 obb = obj->bounding_box_exact(); + auto bed_shape = belt_printer_config->option("printable_area")->values; + BoundingBoxf bed_ext = get_extents(bed_shape); + obj->translate_instances(Vec3d(bed_ext.center().x() - obb.center().x(), -obb.min.y(), 0.0)); + + auto belt_filament_config = &wxGetApp().preset_bundle->filaments.get_edited_preset().config; + belt_filament_config->set_key_value("nozzle_temperature_initial_layer", new ConfigOptionInts(1, temps.front())); + belt_filament_config->set_key_value("nozzle_temperature", new ConfigOptionInts(1, temps.front())); + obj->config.set_key_value("brim_type", new ConfigOptionEnum(btNoBrim)); + obj->config.set_key_value("seam_slope_type", new ConfigOptionEnum(SeamScarfType::None)); + obj->config.set_key_value("overhang_reverse", new ConfigOptionBool(false)); + belt_printer_config->set_key_value("resonance_avoidance", new ConfigOptionBool{false}); + + const int plate_idx = get_partplate_list().get_curr_plate_index(); + model().curr_plate_index = plate_idx; + CustomGCode::Info &cg_info = model().plates_custom_gcodes[plate_idx]; + cg_info.mode = CustomGCode::Mode::SingleExtruder; + cg_info.gcodes.clear(); + for (size_t i = 1; i < temps.size(); ++i) { + const double pz = double(i) * zone_topz + into_provino; // 70 layers into provino i + cg_info.gcodes.push_back(CustomGCode::Item{ + pz, CustomGCode::Custom, 1, "", + "M104 S" + std::to_string(temps[i]) + " ; belt temp zone " + std::to_string(temps[i]) }); + } + + changed_objects({ 0 }); + wxGetApp().get_tab(Preset::TYPE_FILAMENT)->update_dirty(); + wxGetApp().get_tab(Preset::TYPE_FILAMENT)->reload_config(); + return; + } + } + if (!add_model(false, Slic3r::resources_dir() + "/calib/temperature_tower/temperature_tower.drc")) return; auto printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config; @@ -17160,6 +17549,107 @@ void Plater::calib_temp(const Calib_Params& params) { p->background_process.fff_print()->set_calib_params(params); } +// ORCA-Belt: sectioned temperature test. Each temperature gets its own block +// cut out of the temperature tower model, printed in native belt orientation +// (no counter-rotation, no support wedge) and spaced along the belt so the +// blocks' layer ranges are disjoint — they print strictly one after another, +// starting with the start temperature closest to the gantry. The temperature +// is encoded in the object name ("temp_230") and applied per object by the +// Calib_Temp_Tower handler at G-code time, replacing the per-layer-band ramp +// that only makes sense for a monolithic upright tower. +void Plater::_calib_temp_belt_sectioned(const Calib_Params& params, double belt_angle_rad) +{ + constexpr double base_temp_tower_nozzle_diameter = 0.4; + constexpr double base_temp_tower_block_height = 10.0; + constexpr int base_temp_tower_temp_step = 5; + + auto printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config; + auto filament_config = &wxGetApp().preset_bundle->filaments.get_edited_preset().config; + auto print_config = &wxGetApp().preset_bundle->prints.get_edited_preset().config; + + const long start_temp = lround(params.start); + const long end_temp = lround(params.end); + const int n_blocks = std::max(1, int((start_temp - end_temp) / base_temp_tower_temp_step) + 1); + + const ConfigOptionFloats* nozzle_diameter_config = printer_config->option("nozzle_diameter"); + size_t nozzle_id = static_cast(std::max(params.extruder_id, 0)); + double nozzle_diameter = base_temp_tower_nozzle_diameter; + if (nozzle_diameter_config && !nozzle_diameter_config->values.empty()) { + nozzle_id = std::min(nozzle_id, nozzle_diameter_config->values.size() - 1); + nozzle_diameter = nozzle_diameter_config->values[nozzle_id]; + } + if (nozzle_diameter <= 0.0) + nozzle_diameter = base_temp_tower_nozzle_diameter; + const double nozzle_scale = nozzle_diameter / base_temp_tower_nozzle_diameter; + + std::vector obj_idxs; + for (int i = 0; i < n_blocks; ++i) { + const long temp = start_temp - long(i) * base_temp_tower_temp_step; + const size_t count_before = model().objects.size(); + add_model(false, Slic3r::resources_dir() + "/calib/temperature_tower/temperature_tower.drc"); + if (model().objects.size() <= count_before) + break; // model failed to load — don't index into an empty list + // The cut replaces the object at the END of the list, so re-acquire + // the index after every operation. + size_t obj_idx = model().objects.size() - 1; + + // Isolate this temperature's block (full-tower coordinates, the same + // 500-down-to-temp indexing the monolithic flow cuts with). + const double block_bottom = double(lround(double(500 - temp) / base_temp_tower_temp_step)) * base_temp_tower_block_height; + auto obj_bb = model().objects[obj_idx]->bounding_box_exact(); + if (block_bottom + base_temp_tower_block_height < obj_bb.size().z()) { + cut_horizontal(obj_idx, 0, block_bottom + base_temp_tower_block_height - EPSILON, ModelObjectCutAttribute::KeepLower); + obj_idx = model().objects.size() - 1; + } + if (block_bottom > 0) { + cut_horizontal(obj_idx, 0, block_bottom + EPSILON, ModelObjectCutAttribute::KeepUpper); + obj_idx = model().objects.size() - 1; + } + + ModelObject* obj = model().objects[obj_idx]; + if (std::abs(nozzle_scale - 1.0) > EPSILON) + obj->scale(nozzle_scale, nozzle_scale, nozzle_scale); + obj->name = std::string("temp_") + std::to_string(temp); + obj->config.set_key_value("layer_height", new ConfigOptionFloat(nozzle_diameter / 2)); + obj->config.set_key_value("alternate_extra_wall", new ConfigOptionBool(false)); + obj->config.set_key_value("seam_slope_type", new ConfigOptionEnum(SeamScarfType::None)); + obj->config.set_key_value("overhang_reverse", new ConfigOptionBool(false)); + obj->config.set_key_value("precise_z_height", new ConfigOptionBool(false)); + obj->ensure_on_bed(); + obj_idxs.emplace_back(obj_idx); + } + + // Space the blocks along the belt with strictly increasing layer ranges: + // each block must start past the previous block's highest slicing plane, + // which trails its far edge by height / tan(angle). Anchor the row near + // the gantry so the whole test stays in the plate area. + const double cot_a = 1. / std::max(0.1, std::tan(belt_angle_rad)); + double cursor = 20.; + for (size_t idx : obj_idxs) { + ModelObject* obj = model().objects[idx]; + ModelInstance* inst = obj->instances.front(); + const BoundingBoxf3 bb = obj->instance_bounding_box(0); + inst->set_offset(Y, inst->get_offset(Y) + (cursor - bb.min.y())); + obj->invalidate_bounding_box(); + cursor += bb.size().y() + bb.size().z() * cot_a + 5.; + } + + printer_config->set_key_value("resonance_avoidance", new ConfigOptionBool{false}); + filament_config->set_key_value("nozzle_temperature_initial_layer", new ConfigOptionInts(1, (int)start_temp)); + filament_config->set_key_value("nozzle_temperature", new ConfigOptionInts(1, (int)start_temp)); + print_config->set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); + print_config->set_key_value("initial_layer_print_height", new ConfigOptionFloat(nozzle_diameter / 2)); + print_config->set_key_value("skirt_loops", new ConfigOptionInt(0)); + + changed_objects(obj_idxs); + wxGetApp().get_tab(Preset::TYPE_PRINT)->update_dirty(); + wxGetApp().get_tab(Preset::TYPE_FILAMENT)->update_dirty(); + wxGetApp().get_tab(Preset::TYPE_PRINT)->reload_config(); + wxGetApp().get_tab(Preset::TYPE_FILAMENT)->reload_config(); + + p->background_process.fff_print()->set_calib_params(params); +} + void Plater::calib_max_vol_speed(const Calib_Params& params) { const auto calib_vol_speed_name = _L("Max volumetric speed test"); @@ -17230,6 +17720,8 @@ void Plater::calib_max_vol_speed(const Calib_Params& params) cut_horizontal(0, 0, height, ModelObjectCutAttribute::KeepLower); } + _calib_apply_belt_mode(); + auto new_params = params; auto mm3_per_mm = Flow(line_width, layer_height, nozzle_diameter).mm3_per_mm() * filament_config->option("filament_flow_ratio")->get_at(0); new_params.end = params.end / mm3_per_mm; @@ -17298,6 +17790,7 @@ void Plater::calib_retraction(const Calib_Params& params) cut_horizontal(0, 0, height, ModelObjectCutAttribute::KeepLower); } + _calib_apply_belt_mode(); p->background_process.fff_print()->set_calib_params(params); } @@ -17377,6 +17870,7 @@ void Plater::calib_VFA(const Calib_Params& params) wxGetApp().get_tab(Preset::TYPE_PRINT)->update_ui_from_settings(); wxGetApp().get_tab(Preset::TYPE_FILAMENT)->update_ui_from_settings(); + _calib_apply_belt_mode(); // Pass the resolved layer height on (only meaningful when resized). GCode's VFA stepping is layer-based, so // it does not require it, but keep it consistent with the geometry. Calib_Params calib_params = params; @@ -17394,6 +17888,8 @@ void Plater::calib_input_shaping_freq(const Calib_Params& params) if (!add_model(false, Slic3r::resources_dir() + (params.test_model < 1 ? "/calib/input_shaping/ringing_tower.drc" : "/calib/input_shaping/fast_tower_test.drc"))) return; + if (params.test_model < 1) + belt_calib_flip_ringing_tower(model()); auto print_config = &wxGetApp().preset_bundle->prints.get_edited_preset().config; auto filament_config = &wxGetApp().preset_bundle->filaments.get_edited_preset().config; auto printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config; @@ -17443,6 +17939,7 @@ void Plater::calib_input_shaping_freq(const Calib_Params& params) wxGetApp().get_tab(Preset::TYPE_PRINT)->update_ui_from_settings(); wxGetApp().get_tab(Preset::TYPE_FILAMENT)->update_ui_from_settings(); + _calib_apply_belt_mode(); p->background_process.fff_print()->set_calib_params(params); } @@ -17456,6 +17953,8 @@ void Plater::calib_input_shaping_damp(const Calib_Params& params) if (!add_model(false, Slic3r::resources_dir() + (params.test_model < 1 ? "/calib/input_shaping/ringing_tower.drc" : "/calib/input_shaping/fast_tower_test.drc"))) return; + if (params.test_model < 1) + belt_calib_flip_ringing_tower(model()); auto print_config = &wxGetApp().preset_bundle->prints.get_edited_preset().config; auto filament_config = &wxGetApp().preset_bundle->filaments.get_edited_preset().config; auto printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config; @@ -17504,6 +18003,7 @@ void Plater::calib_input_shaping_damp(const Calib_Params& params) wxGetApp().get_tab(Preset::TYPE_PRINT)->update_ui_from_settings(); wxGetApp().get_tab(Preset::TYPE_FILAMENT)->update_ui_from_settings(); + _calib_apply_belt_mode(); p->background_process.fff_print()->set_calib_params(params); } @@ -17520,6 +18020,8 @@ void Plater::Calib_Cornering(const Calib_Params& params) : (params.test_model == 1 ? "/calib/input_shaping/fast_tower_test.drc" : "/calib/cornering/SCV-V2.drc"); if (!add_model(false, Slic3r::resources_dir() + cornering_model_path)) return; + if (params.test_model == 0) + belt_calib_flip_ringing_tower(model()); auto print_config = &wxGetApp().preset_bundle->prints.get_edited_preset().config; auto filament_config = &wxGetApp().preset_bundle->filaments.get_edited_preset().config; auto printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config; @@ -17569,6 +18071,7 @@ void Plater::Calib_Cornering(const Calib_Params& params) wxGetApp().get_tab(Preset::TYPE_PRINT)->update_ui_from_settings(); wxGetApp().get_tab(Preset::TYPE_FILAMENT)->update_ui_from_settings(); + _calib_apply_belt_mode(); p->background_process.fff_print()->set_calib_params(params); } @@ -17688,6 +18191,15 @@ void Plater::load_gcode(const wxString& filename) current_print.set_gcode_file_ready(); + // Belt printer: detect the belt tilt from the loaded G-code header and enable + // belt view mode on the GCodeViewer so the "Show designed view" toggle appears. + if (current_result->belt_tilt_angle > 0.f) { + float angle = current_result->belt_tilt_angle; + p->preview->get_canvas3d()->get_gcode_viewer().set_belt_printer(true, angle); + } else { + p->preview->get_canvas3d()->get_gcode_viewer().set_belt_printer(false, 0.f); + } + // show results p->preview->reload_print(m_only_gcode); //BBS: zoom to bed 0 for gcode preview @@ -17722,6 +18234,11 @@ void Plater::reload_print() p->preview->reload_print(); } +void Plater::refresh_belt_view() +{ + p->preview->refresh_belt_view(); +} + // BBS wxString Plater::get_project_name() { diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index bc205d7a01..abcbdb18af 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -291,8 +291,19 @@ public: // Mixed-color filament sidebar section void add_mixed_filament(); + // The filament slot that blends `components` (1-based physical filament indices) in `ratios` + // (percentages), creating it when no existing mixed slot already describes that blend. Returns the + // 0-based filament index, or -1 when the paint-state cap leaves no room for another one. + // + // Exists so a feature that needs a blend can ask for one without going through the modal dialog: + // the texture displacement gizmo turns each mix in its palette into a slot, which is what moves the + // interleaving from its own paint mask to the slicer, where it happens per layer. + int ensure_mixed_filament(const std::vector &components, const std::vector &ratios); void edit_mixed_filament(size_t idx); void delete_mixed_filament_at(size_t idx); + // Drops every mixed filament at once, after confirming. The texture displacement gizmo can create + // one slot per colour in its palette, so clearing them one at a time is tedious. + void remove_all_mixed_filaments(); void decompose_filament_color(int filament_idx); void recalc_filament_scroll_sizes(); void update_mixed_filament_list(); @@ -383,6 +394,10 @@ public: void load_gcode(const wxString& filename); void reload_gcode_from_disk(); void reload_print(); + // Belt printers: re-run the G-code preview conversion so the "designed view" toggle + // (hotkey B / legend checkbox) takes effect; the back-transform is applied to the + void refresh_belt_view(); + // toolpath geometry at load time. Keeps the current layer range and only-gcode mode. // SoftFever void calib_pa(const Calib_Params& params); @@ -1083,6 +1098,8 @@ private: void _calib_pa_pattern_gen_gcode(); void _calib_pa_tower(const Calib_Params& params); void _calib_pa_select_added_objects(); + void _calib_apply_belt_mode(); + void _calib_temp_belt_sectioned(const Calib_Params& params, double belt_angle_rad); void cut_horizontal(size_t obj_idx, size_t instance_idx, double z, ModelObjectCutAttributes attributes); diff --git a/src/slic3r/GUI/PrintOptionsDialog.cpp b/src/slic3r/GUI/PrintOptionsDialog.cpp index e43e47c9aa..571ec0114a 100644 --- a/src/slic3r/GUI/PrintOptionsDialog.cpp +++ b/src/slic3r/GUI/PrintOptionsDialog.cpp @@ -20,7 +20,6 @@ #include "slic3r/GUI/DeviceCore/DevConfigUtil.h" #include "slic3r/GUI/DeviceManager.hpp" #include -#include "slic3r/GUI/Widgets/HyperLink.hpp" #include "slic3r/GUI/Widgets/Button.hpp" #include "libslic3r/CommonDefs.hpp" #include "slic3r/GUI/DeviceCore/DevDefs.h" @@ -1545,12 +1544,14 @@ PrinterPartsDialog::PrinterPartsDialog(wxWindow* parent) change_nozzle_tips->SetFont(Label::Body_13); change_nozzle_tips->SetForegroundColour(STATIC_TEXT_CAPTION_COL); - m_wiki_link = new HyperLink(single_panel, _L("Wiki Guide")); // ORCA - m_wiki_link->SetFont(Label::Body_13); + m_wiki_link = new Button(single_panel, "", "toolbar_wiki", 0, 15); + m_wiki_link->SetToolTip(_L("Wiki Guide")); + m_wiki_link->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + m_wiki_link->SetCanFocus(false); m_wiki_link->Bind(wxEVT_LEFT_DOWN, &PrinterPartsDialog::OnWikiClicked, this); - h_tips_sizer->Add(change_nozzle_tips, 0, wxLEFT); - h_tips_sizer->Add(m_wiki_link, 0, wxLEFT, FromDIP(5)); + h_tips_sizer->Add(m_wiki_link); + h_tips_sizer->Add(change_nozzle_tips, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(5)); wxSizer* single_update_nozzle_sizer = new wxBoxSizer(wxHORIZONTAL); m_single_update_nozzle_button = new Button(single_panel, _L("Refresh")); @@ -1656,13 +1657,15 @@ PrinterPartsDialog::PrinterPartsDialog(wxWindow* parent) multiple_change_nozzle_tips->SetFont(Label::Body_13); multiple_change_nozzle_tips->SetForegroundColour(STATIC_TEXT_CAPTION_COL); - multiple_wiki_link = new HyperLink(multiple_panel, _L("Wiki Guide")); // ORCA - multiple_wiki_link->SetFont(Label::Body_13); + multiple_wiki_link = new Button(multiple_panel, "", "toolbar_wiki", 0, 15); + multiple_wiki_link->SetToolTip(_L("Wiki Guide")); + multiple_wiki_link->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + multiple_wiki_link->SetCanFocus(false); multiple_wiki_link->Bind(wxEVT_LEFT_DOWN, &PrinterPartsDialog::OnWikiClicked, this); wxSizer* multiple_change_tips_sizer = new wxBoxSizer(wxHORIZONTAL); - multiple_change_tips_sizer->Add(multiple_change_nozzle_tips, 0, wxLEFT); - multiple_change_tips_sizer->Add(multiple_wiki_link, 0, wxLEFT, FromDIP(5)); + multiple_change_tips_sizer->Add(multiple_wiki_link); + multiple_change_tips_sizer->Add(multiple_change_nozzle_tips, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(5)); wxSizer* multiple_update_nozzle_sizer = new wxBoxSizer(wxHORIZONTAL); m_multiple_update_nozzle_button = new Button(multiple_panel, _L("Refresh")); diff --git a/src/slic3r/GUI/PrintOptionsDialog.hpp b/src/slic3r/GUI/PrintOptionsDialog.hpp index 803f35c7ff..6e49e19625 100644 --- a/src/slic3r/GUI/PrintOptionsDialog.hpp +++ b/src/slic3r/GUI/PrintOptionsDialog.hpp @@ -22,9 +22,9 @@ #include "Widgets/CheckBox.hpp" #include "Widgets/StaticLine.hpp" #include "Widgets/ComboBox.hpp" -#include "Widgets/HyperLink.hpp" // Previous definitions +class Button; class SwitchBoard; class MultiSwitchButton; @@ -41,7 +41,7 @@ protected: Label* nozzle_flow_type_label; ComboBox* nozzle_flow_type_checkbox; Label *change_nozzle_tips; - HyperLink* m_wiki_link; + Button* m_wiki_link; Button* m_single_update_nozzle_button; Button* m_multiple_update_nozzle_button; @@ -54,7 +54,7 @@ protected: ComboBox *multiple_right_nozzle_flow_checkbox; Label *multiple_change_nozzle_tips; - HyperLink* multiple_wiki_link; + Button* multiple_wiki_link; wxPanel *single_panel; wxPanel *multiple_panel; diff --git a/src/slic3r/GUI/PublishSettingsDialog.cpp b/src/slic3r/GUI/PublishSettingsDialog.cpp index 7beb3bd9dd..080182a1b6 100644 --- a/src/slic3r/GUI/PublishSettingsDialog.cpp +++ b/src/slic3r/GUI/PublishSettingsDialog.cpp @@ -8,8 +8,8 @@ #include "ConfigValueFormatter.hpp" #include "FilamentBitmapUtils.hpp" #include "Widgets/Label.hpp" +#include "Widgets/Button.hpp" #include "Widgets/CheckBox.hpp" -#include "Widgets/HyperLink.hpp" #include "Widgets/TextInput.hpp" #include "Widgets/DialogButtons.hpp" #include "Widgets/StaticLine.hpp" @@ -756,11 +756,24 @@ PublishSettingsDialog::PublishSettingsDialog(wxWindow* parent, dlg_btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); // Guide links, bottom-left, sharing the footer row with the OK/Cancel buttons (pushed right). - wxBoxSizer* links_sizer = new wxBoxSizer(wxVERTICAL); - auto* wiki_link = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/publishing_3mf/publish_3mf.html"); - auto* video_link = new HyperLink(this, _L("Video Guide"), "https://www.youtube.com/watch?v=-xt1N29UIOg"); - links_sizer->Add(wiki_link , 0, wxALIGN_LEFT); - links_sizer->Add(video_link, 0, wxTOP | wxALIGN_LEFT, FromDIP(4)); + wxBoxSizer* links_sizer = new wxBoxSizer(wxHORIZONTAL); + + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/publishing_3mf/publish_3mf"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + + auto video_btn = new Button(this, "", "toolbar_video_guide", 0, 15); + auto video_url = "https://www.youtube.com/watch?v=-xt1N29UIOg"; + video_btn->SetToolTip(_L("Video Guide") + "\n" + video_url); + video_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + video_btn->SetCanFocus(false); + video_btn->Bind(wxEVT_LEFT_DOWN, ([video_url](auto& e) {wxLaunchDefaultBrowser(video_url);})); + + links_sizer->Add(wiki_btn , 0, wxLEFT, FromDIP(10)); + links_sizer->Add(video_btn, 0, wxLEFT, FromDIP(10)); wxBoxSizer* footer = new wxBoxSizer(wxHORIZONTAL); footer->Add(links_sizer, 0, wxALIGN_CENTER_VERTICAL); @@ -771,7 +784,7 @@ PublishSettingsDialog::PublishSettingsDialog(wxWindow* parent, footer_line->SetMinSize(wxSize(-1, 1)); footer_line->SetMaxSize(wxSize(-1, 1)); w_sizer->Add(footer_line, 0, wxRIGHT | wxLEFT | wxTOP | wxEXPAND, FromDIP(10)); - w_sizer->Add(footer, 0, wxRIGHT | wxLEFT | wxTOP | wxBOTTOM | wxEXPAND, FromDIP(10)); + w_sizer->Add(footer, 0, wxEXPAND, FromDIP(10)); SetSizerAndFit(w_sizer); fit_to_content(); // initial size only; the dialog is resizable diff --git a/src/slic3r/GUI/Shortcuts.cpp b/src/slic3r/GUI/Shortcuts.cpp index 8773c6f364..df8d7c00ca 100644 --- a/src/slic3r/GUI/Shortcuts.cpp +++ b/src/slic3r/GUI/Shortcuts.cpp @@ -113,6 +113,7 @@ constexpr std::array shortcut_table = {{ SHORTCUT(GizmoMeasure, "gizmo_measure", L("Gizmo measure"), PLATER, { 'U' }), SHORTCUT(GizmoAssembly, "gizmo_assembly", L("Gizmo assemble"), PLATER, { 'Y' }), SHORTCUT(GizmoBrimEars, "gizmo_brim_ears", L("Gizmo brim ears"), PLATER, { 'E' }), + SHORTCUT(GizmoDisplacement, "gizmo_displacement", L("Gizmo texture displacement painting"), PLATER, { 'D' }), // Sliders SHORTCUT(GoToLayer, "go_to_layer", L("Jump to layer"), PREVIEW, { 'G', SHIFT }), @@ -149,6 +150,7 @@ constexpr std::array shortcut_table = {{ SHORTCUT(ShowWireframe, "show_wireframe", L("Show/Hide wireframe"), CANVAS, { WXK_RETURN, CTRL_SHIFT }), SHORTCUT(ToggleGcodeWindow, "toggle_gcode_window", L("On/Off G-code window"), PREVIEW, { 'C' }), SHORTCUT(ToggleOneLayerMode, "toggle_one_layer_mode", L("On/Off one layer mode of the vertical slider"), PREVIEW, { 'L' }), + SHORTCUT(ToggleBeltRawGcode, "toggle_belt_raw_gcode", L("Show raw G-code (belt only)"), PREVIEW, { 'B' }), // Application SHORTCUT(Preferences, "preferences", L("Preferences"), GLOBAL, PREFERENCES_CHORD), diff --git a/src/slic3r/GUI/Shortcuts.hpp b/src/slic3r/GUI/Shortcuts.hpp index 0e61554e2f..aba315d521 100644 --- a/src/slic3r/GUI/Shortcuts.hpp +++ b/src/slic3r/GUI/Shortcuts.hpp @@ -38,7 +38,7 @@ enum class Shortcut : uint8_t { MoveSelectionLeft, MoveSelectionRight, MoveSelectionUp, MoveSelectionDown, RotateSelectionLeft, RotateSelectionRight, // Gizmos GizmoMove, GizmoRotate, GizmoScale, GizmoFlatten, GizmoCut, GizmoMeshBoolean, GizmoFdmSupports, GizmoSeam, GizmoFuzzySkin, - GizmoMmuSegmentation, GizmoEmboss, GizmoMeasure, GizmoAssembly, GizmoBrimEars, + GizmoMmuSegmentation, GizmoEmboss, GizmoMeasure, GizmoAssembly, GizmoBrimEars, GizmoDisplacement, // Sliders GoToLayer, LayerSliderUp, LayerSliderDown, MovesSliderLeft, MovesSliderRight, MovesSliderStart, MovesSliderEnd, // Painting tools @@ -46,7 +46,7 @@ enum class Shortcut : uint8_t { // Camera ViewDefault, ViewTop, ViewBottom, ViewFront, ViewRear, ViewLeft, ViewRight, ViewPlate, ZoomIn, ZoomOut, Mouse3DSettings, // Display - ShowLabels, ShowWireframe, ToggleGcodeWindow, ToggleOneLayerMode, + ShowLabels, ShowWireframe, ToggleGcodeWindow, ToggleOneLayerMode, ToggleBeltRawGcode, // Application Preferences, Search, SwitchView, CollapseSidebar, ReloadDevicePage, KeyboardShortcuts, // Speed Dial diff --git a/src/slic3r/GUI/StepMeshDialog.cpp b/src/slic3r/GUI/StepMeshDialog.cpp index 07dc559528..379c354e72 100644 --- a/src/slic3r/GUI/StepMeshDialog.cpp +++ b/src/slic3r/GUI/StepMeshDialog.cpp @@ -4,7 +4,6 @@ #include "slic3r/GUI/GUI_Utils.hpp" #include "slic3r/GUI/Widgets/StateColor.hpp" #include "slic3r/GUI/Widgets/Label.hpp" -#include "slic3r/GUI/Widgets/HyperLink.hpp" #include "slic3r/GUI/MsgDialog.hpp" #include "libslic3r/LocalesUtils.hpp" #include "libslic3r/Thread.hpp" @@ -26,6 +25,7 @@ #include #include #include +#include #include using namespace Slic3r; @@ -131,19 +131,14 @@ StepMeshDialog::StepMeshDialog(wxWindow* parent, Slic3r::Step& file, double line auto tip_frame = new RoundedRectangle(this, StateColor::darkModeColorFor(wxColour("#F1F1F1")), wxDefaultPosition, wxSize(-1,-1), 6, 0); - wxBoxSizer* tips_sizer = new wxBoxSizer(wxVERTICAL); + wxBoxSizer* tips_sizer = new wxBoxSizer(wxHORIZONTAL); wxStaticText* info = new wxStaticText(tip_frame, wxID_ANY, _L("Smaller linear and angular deflections result in higher-quality transformations but increase the processing time.")); info->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#363636"))); info->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F1F1F1"))); info->SetFont(::Label::Body_14); info->Wrap(FromDIP(450)); - // ORCA standardized HyperLink - HyperLink *tips = new HyperLink(tip_frame, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/import_export#step"); - tips->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F1F1F1"))); - - tips_sizer->Add(info, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(10)); - tips_sizer->Add(tips, 0, wxALL, FromDIP(10)); + tips_sizer->Add(info, 0, wxEXPAND |wxALL , FromDIP(10)); tip_frame->SetSizer(tips_sizer); tip_frame->Layout(); @@ -289,12 +284,24 @@ StepMeshDialog::StepMeshDialog(wxWindow* parent, Slic3r::Step& file, double line save_default_sizer->Add(m_save_default_checkbox, 0, wxALIGN_LEFT); bSizer->Add(save_default_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT, LEFT_RIGHT_PADING); - wxBoxSizer* bSizer_button = new wxBoxSizer(wxHORIZONTAL); - bSizer_button->SetMinSize(wxSize(FromDIP(100), -1)); + bSizer->AddSpacer(FromDIP(10)); + m_checkbox = new wxCheckBox(this, wxID_ANY, _L("Don't show again"), wxDefaultPosition, wxDefaultSize, 0); m_checkbox->SetFont(::Label::Body_14); m_checkbox->SetForegroundColour(StateColor::darkModeColorFor(FONT_COLOR)); - bSizer_button->Add(m_checkbox, 0, wxALIGN_LEFT | wxLEFT | wxALIGN_CENTER_VERTICAL, LEFT_RIGHT_PADING); + bSizer->Add(m_checkbox, 0, wxLEFT, LEFT_RIGHT_PADING); + + wxBoxSizer* bSizer_button = new wxBoxSizer(wxHORIZONTAL); + + // ORCA standardized HyperLink + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/import_export#step"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + bSizer_button->Add(wiki_btn, 0, wxALIGN_LEFT | wxLEFT | wxALIGN_CENTER_VERTICAL, LEFT_RIGHT_PADING); + bSizer_button->AddStretchSpacer(1); auto dlg_btns = new DialogButtons(this, {"OK", "Cancel"}); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 6921bab6cf..ecceb27597 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -4,6 +4,7 @@ #include "libslic3r/IMEXHelpers.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/PrintConfig.hpp" +#include "libslic3r/BeltTransform.hpp" #include "libslic3r/FilamentMixer.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/Model.hpp" @@ -1654,6 +1655,13 @@ void Tab::update_mode() { m_mode = wxGetApp().get_mode(); + // toggle_options reads m_mode to gate Lines whose contents are mode-mixed + // (e.g., a multi-option row where some options are Advanced and some Expert): + // when all of a Line's options would be hidden, we hide the Line itself. + // Without refreshing here, the toggle_visible state stays stale across mode + // switches and Lines stay hidden. + toggle_options(); + update_visibility(); update_changed_tree_ui(); @@ -3145,6 +3153,7 @@ void TabPrint::build() optgroup->append_single_option_line("enable_tower_interface_cooldown_during_tower", "multimaterial_settings_prime_tower"); optgroup->append_single_option_line("prime_tower_enable_framework", "multimaterial_settings_prime_tower"); optgroup->append_single_option_line("prime_tower_width", "multimaterial_settings_prime_tower#width"); + optgroup->append_single_option_line("belt_purge_tower_width", "multimaterial_settings_prime_tower#belt-purge-tower-width"); optgroup->append_single_option_line("prime_volume", "multimaterial_settings_prime_tower"); optgroup->append_single_option_line("prime_tower_brim_width", "multimaterial_settings_prime_tower#brim-width"); optgroup->append_single_option_line("prime_tower_infill_gap", "multimaterial_settings_prime_tower"); @@ -3210,6 +3219,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"); @@ -3370,6 +3381,42 @@ 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); + // 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. + const bool offer_leading_edge = is_belt_printer || current == btLeadingEdgeOnly; + const bool offered = std::find(opt.enum_values.begin(), opt.enum_values.end(), "leading_edge_only") != opt.enum_values.end(); + if (cb != nullptr && offer_leading_edge != offered) { + 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) { + if (def->enum_values[i] == "leading_edge_only" && ! offer_leading_edge) + 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)) { @@ -5234,6 +5281,35 @@ void TabPrinter::build_fff() //option.opt.full_width = true; //optgroup->append_single_option_line(option); optgroup->append_single_option_line("disable_m73", "printer_basic_information_advanced#disable-set-remaining-print-time"); + + // Belt printer: dedicated section. Everything except the "Enable belt printing" + // checkbox is hidden when belt_printer is off (see TabPrinter::toggle_options). + auto belt_og = page->new_optgroup(L("Belt printer"), L"param_advanced"); + belt_og->append_single_option_line("belt_printer", "printer_basic_information_belt_printer#enable-belt-printing"); + belt_og->append_single_option_line("belt_printer_infinite_y", "printer_basic_information_belt_printer#infinite-y-axis"); + // Belt tilt: the sole mesh-side transform and the single source of truth for + // the physical tilt (drives bed rendering and support gravity tilt too). + // Isometric rotation, no distortion; the back-transform inverts it before the + // machine-frame remap. The angle is what a user checks against the machine; + // the axis is a profile-level kinematics choice, so it is Develop-only. They + // are separate rows because a shared line is shown by its first option's mode. + belt_og->append_single_option_line("belt_slice_rotation_angle", "printer_basic_information_belt_printer#tilt-angle"); + belt_og->append_single_option_line("belt_slice_rotation", "printer_basic_information_belt_printer#tilt-axis"); + belt_og->append_single_option_line("belt_support_floor_offset", "printer_basic_information_belt_printer#support-floor-z-offset"); + + // Machine-frame transform: the shear (cot) + scale (1/sin) that map + // Cartesian G-code into the printer's physical machine frame are derived + // from the belt tilt angle. Only the post-slice axis remap and the expert + // decouple override are exposed here, one option per row. + { + auto mf = page->new_optgroup(L("Machine frame transforms"), L"param_advanced"); + mf->append_single_option_line("gcode_remap_x", "printer_basic_information_machine_frame_transforms#g-code-axis-remap"); + mf->append_single_option_line("gcode_remap_y", "printer_basic_information_machine_frame_transforms#g-code-axis-remap"); + mf->append_single_option_line("gcode_remap_z", "printer_basic_information_machine_frame_transforms#g-code-axis-remap"); + mf->append_single_option_line("belt_frame_tilt_decouple", "printer_basic_information_machine_frame_transforms#machine-frame-tilt"); + mf->append_single_option_line("belt_frame_tilt_angle", "printer_basic_information_machine_frame_transforms#machine-frame-tilt"); + } + option = optgroup->get_option("thumbnails"); option.opt.full_width = true; optgroup->append_single_option_line(option, "printer_basic_information_advanced#g-code-thumbnails"); @@ -5278,6 +5354,8 @@ void TabPrinter::build_fff() optgroup->append_single_option_line("use_firmware_retraction", "printer_basic_information_advanced#use-firmware-retraction"); // optgroup->append_single_option_line("spaghetti_detector"); optgroup->append_single_option_line("time_cost", "printer_basic_information_advanced#time-cost"); + optgroup->append_single_option_line("build_plate_tilt_x", "printer_basic_information_advanced#build-plate-tilt"); + optgroup->append_single_option_line("build_plate_tilt_y", "printer_basic_information_advanced#build-plate-tilt"); optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode"); optgroup->append_single_option_line("printer_plugin_config_overrides"); @@ -5810,6 +5888,12 @@ if (is_marlin_flavor) optgroup->append_single_option_line("tool_change_on_wipe_tower", "printer_multimaterial_wipe_tower#tool-change-on-wipe-tower"); optgroup->append_single_option_line("wait_for_temp_on_wipe_tower", "printer_multimaterial_wipe_tower#wait-for-temperature-on-wipe-tower"); + // Orca-Belt: belt printers replace the classic wipe tower with an + // auto-generated purge prism; this is its enable (gated to belt printers + // in toggle_options()). + optgroup = page->new_optgroup(L("Belt purge tower"), "param_tower"); + optgroup->append_single_option_line("enable_belt_purge_tower", "printer_multimaterial_wipe_tower#belt-purge-tower"); + optgroup = page->new_optgroup(L("Single extruder multi-material parameters"), "param_settings"); optgroup->append_single_option_line("cooling_tube_retraction", "printer_multimaterial_semm_parameters#cooling-tube-position"); @@ -6110,6 +6194,18 @@ if (is_marlin_flavor) // this gets executed after preset is loaded and before GUI fields are updated void TabPrinter::on_preset_loaded() { + // R8: reset the belt-tilt transition tracking to reflect the freshly loaded preset WITHOUT + // running update_fff()'s reset logic. on_preset_loaded() is called from Tab::load_current_preset() + // on every printer preset load, right before update()->update_fff(). Seeding m_was_belt_printer + // from the loaded preset's belt_printer flag means a preset switch (belt preset -> non-belt preset) + // enters update_fff() with m_was_belt_printer==false, so the belt->off clear branch is skipped and + // the newly loaded preset's manual tilt is preserved. An in-place belt toggle does NOT go through + // here (only through on_value_change->update), so m_was_belt_printer stays true there and the clear + // still fires. Seed m_belt_synced_tilt from the loaded tilt as a safeguard. + m_was_belt_printer = m_config->opt_bool("belt_printer"); + m_belt_synced_tilt_x = m_config->opt_float("build_plate_tilt_x"); + m_belt_synced_tilt_y = m_config->opt_float("build_plate_tilt_y"); + // Orca //update nozzle_volume_type const Preset& current_printer = m_preset_bundle->printers.get_selected_preset(); @@ -6407,6 +6503,39 @@ void TabPrinter::toggle_options() bool gcf_is_marlin_firmware = m_config->option>("gcode_flavor")->value == GCodeFlavor::gcfMarlinFirmware; toggle_line("enable_power_loss_recovery", is_BBL_printer || gcf_is_marlin_firmware); + // Belt printer: show belt-specific settings only when belt_printer is enabled. + bool is_belt = m_config->opt_bool("belt_printer"); + // update_fff() derives build_plate_tilt_{x,y} from the belt tilt on a belt + // printer, so an edit here would be overwritten; keep them read-only there. + toggle_option("build_plate_tilt_x", !is_belt); + toggle_option("build_plate_tilt_y", !is_belt); + bool expert_or_above = (m_mode >= comExpert); + toggle_line("belt_printer_infinite_y", is_belt); + // Belt tilt: the sole mesh-side belt transform (visible by default in belt mode). + toggle_line("belt_slice_rotation_angle", is_belt); + toggle_line("belt_slice_rotation", is_belt); + + // Remap, back-transform, and global mesh-transforms toggles are gated by belt + // mode here; finer mode-based visibility is handled by each option's + // ConfigOptionMode in PrintConfig.cpp. The axis remap is Develop-only: a + // printer profile sets it once for its kinematics, and a wrong value sends + // the gantry outside the machine. + for (auto el : {"gcode_remap_x", "gcode_remap_y", "gcode_remap_z"}) + toggle_line(el, is_belt); + + // Rotation is the only mesh-side belt transform. Gray out its angle when no + // rotation axis is selected. + auto rot_axis = m_config->option>("belt_slice_rotation")->value; + toggle_option("belt_slice_rotation_angle", is_belt && rot_axis != BeltRotationAxis::None); + + // Machine-frame transform: derived from the belt tilt. Only the expert + // decouple override is exposed; its angle is shown only when decoupled. + toggle_line("belt_frame_tilt_decouple", is_belt && expert_or_above); + toggle_line("belt_frame_tilt_angle", + is_belt && expert_or_above && m_config->opt_bool("belt_frame_tilt_decouple")); + + + toggle_line("belt_support_floor_offset", is_belt); const bool support_parallel_printheads = printer_cfg.opt_bool("support_parallel_printheads"); toggle_line("parallel_printheads_count", support_parallel_printheads); @@ -6425,8 +6554,14 @@ void TabPrinter::toggle_options() } if (m_active_page->title() == L("Multimaterial")) { + // Orca-Belt: belt printers use the belt purge tower instead of the classic + // wipe tower — show its enable only on belt printers, and hide the classic + // wipe-tower fields there (the classic tower's G-code bypasses the belt transform). + const bool is_belt_printer = m_config->opt_bool("belt_printer"); + toggle_line("enable_belt_purge_tower", is_belt_printer); + const bool supports_wipe_tower_2 = !is_BBL_printer && m_config->opt_enum("wipe_tower_type") == WipeTowerType::Type2; - toggle_line("wipe_tower_type", !is_BBL_printer); + toggle_line("wipe_tower_type", !is_BBL_printer && !is_belt_printer); // SoftFever: hide specific settings for BBL printer for (auto el : { "enable_filament_ramming", @@ -6759,6 +6894,40 @@ void TabPrinter::update_fff() m_imex_modes_ctrl->load_from_config(*m_config); } + // Belt printer: auto-sync build_plate_tilt_{x,y} (which drives support gravity tilt) + // from the belt slicing rotation, the single source of truth for the physical tilt. + // Tilt about X drives tilt_x, tilt about Y drives tilt_y. + // + // R8: value-guessing (zeroing any tilt matching the dormant belt-derived tilt) wiped a + // legitimate manual build_plate_tilt on a non-belt tilted-bed printer, because the belt + // defaults (rotation=X, angle=45) make a manual tilt of 45 look belt-derived. Instead we + // track the belt->non-belt transition and the exact values belt-sync wrote, and clear the + // tilt only on a genuine in-place belt-off toggle, and only if the value is still what + // belt-sync last wrote. Preset switches reset the tracking in on_preset_loaded(), so they + // never trip the reset. + if (m_config->opt_bool("belt_printer")) { + auto rot_axis = m_config->option>("belt_slice_rotation")->value; + const auto tilt = BeltTransformPipeline::physical_tilt( + rot_axis, m_config->opt_float("belt_slice_rotation_angle")); + if (m_config->opt_float("build_plate_tilt_x") != tilt.tilt_x_deg) + m_config->set_key_value("build_plate_tilt_x", new ConfigOptionFloat(tilt.tilt_x_deg)); + if (m_config->opt_float("build_plate_tilt_y") != tilt.tilt_y_deg) + m_config->set_key_value("build_plate_tilt_y", new ConfigOptionFloat(tilt.tilt_y_deg)); + // Remember exactly what belt-sync wrote, so an in-place belt-off toggle can distinguish + // a still-belt-derived tilt (safe to clear) from a since-edited manual one (keep). + m_belt_synced_tilt_x = tilt.tilt_x_deg; + m_belt_synced_tilt_y = tilt.tilt_y_deg; + } else if (m_was_belt_printer) { + // Genuine in-place belt->off toggle on the same preset (on_preset_loaded() was not called + // since the last update, so m_was_belt_printer still reflects belt mode). Clear each axis + // only if it still holds the value belt-sync last wrote; a manual override is preserved. + if (m_config->opt_float("build_plate_tilt_x") == m_belt_synced_tilt_x) + m_config->set_key_value("build_plate_tilt_x", new ConfigOptionFloat(0.)); + if (m_config->opt_float("build_plate_tilt_y") == m_belt_synced_tilt_y) + m_config->set_key_value("build_plate_tilt_y", new ConfigOptionFloat(0.)); + } + m_was_belt_printer = m_config->opt_bool("belt_printer"); + toggle_options(); } diff --git a/src/slic3r/GUI/Tab.hpp b/src/slic3r/GUI/Tab.hpp index ab95061268..48a4b7d4cf 100644 --- a/src/slic3r/GUI/Tab.hpp +++ b/src/slic3r/GUI/Tab.hpp @@ -675,6 +675,12 @@ private: bool m_rebuild_kinematics_page = false; void update_input_shaper_menu(GCodeFlavor flavor); + // R8: track the belt->non-belt transition so update_fff() only clears the belt-derived + // build_plate_tilt on a genuine in-place belt-off toggle, never on a manual tilt or a + // preset switch. m_belt_synced_tilt_{x,y} hold the exact values belt-sync last wrote. + bool m_was_belt_printer = false; + double m_belt_synced_tilt_x = 0.; + double m_belt_synced_tilt_y = 0.; std::vector m_pages_fff; std::vector m_pages_sla; diff --git a/src/slic3r/GUI/TroubleshootDialog.cpp b/src/slic3r/GUI/TroubleshootDialog.cpp index 04e53fa4e4..aa6aa44fb2 100644 --- a/src/slic3r/GUI/TroubleshootDialog.cpp +++ b/src/slic3r/GUI/TroubleshootDialog.cpp @@ -236,7 +236,19 @@ TroubleshootDialog::TroubleshootDialog() Fit(); }); - auto link_wiki = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/troubleshoot_center"); + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/troubleshoot_center"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + + auto video_btn = new Button(this, "", "toolbar_video_guide", 0, 15); + auto video_url = "https://www.youtube.com/watch?v=CFzt8W7OCx0"; + video_btn->SetToolTip(_L("Video Guide") + "\n" + video_url); + video_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + video_btn->SetCanFocus(false); + video_btn->Bind(wxEVT_LEFT_DOWN, ([video_url](auto& e) {wxLaunchDefaultBrowser(video_url);})); // RIGHT SIZER ////////////////////// @@ -367,6 +379,10 @@ TroubleshootDialog::TroubleshootDialog() sys_btn_sizer->AddStretchSpacer(); sys_btn_sizer->Add(sys_copy_btn, 0, wxLEFT | wxRIGHT, FromDIP(5)); + wxBoxSizer *link_btn_sizer = new wxBoxSizer(wxHORIZONTAL); + link_btn_sizer->Add(wiki_btn); + link_btn_sizer->Add(video_btn, 0, wxLEFT, FromDIP(10)); + left_sizer->Add(m_header_logo , 0, wxEXPAND | wxALIGN_CENTER); left_sizer->Add(logo_line , 0, wxEXPAND | wxTOP, FromDIP(12)); left_sizer->Add(version , 0, wxEXPAND | wxTOP, FromDIP(6)); @@ -374,8 +390,7 @@ TroubleshootDialog::TroubleshootDialog() left_sizer->Add(sys_panel , 0, wxEXPAND | wxTOP, FromDIP(15)); left_sizer->AddStretchSpacer(); left_sizer->Add(sys_btn_sizer , 0, wxEXPAND | wxTOP, FromDIP(15)); - left_sizer->Add(link_wiki , 0, wxALIGN_CENTER | wxTOP, FromDIP(15)); - left_sizer->AddSpacer(FromDIP(5)); + left_sizer->Add(link_btn_sizer , 0, wxALIGN_CENTER | wxTOP, FromDIP(15)); wxBoxSizer *right_sizer = new wxBoxSizer(wxVERTICAL); diff --git a/src/slic3r/GUI/UVEditorCanvas.cpp b/src/slic3r/GUI/UVEditorCanvas.cpp index eedf39a527..2432026689 100644 --- a/src/slic3r/GUI/UVEditorCanvas.cpp +++ b/src/slic3r/GUI/UVEditorCanvas.cpp @@ -199,6 +199,7 @@ UVEditorCanvas::UVEditorCanvas(wxWindow *parent) Bind(wxEVT_MIDDLE_DOWN, &UVEditorCanvas::on_mouse, this); Bind(wxEVT_MIDDLE_UP, &UVEditorCanvas::on_mouse, this); Bind(wxEVT_MOTION, &UVEditorCanvas::on_mouse, this); + Bind(wxEVT_MOUSE_CAPTURE_LOST, &UVEditorCanvas::on_capture_lost, this); Bind(wxEVT_MOUSEWHEEL, &UVEditorCanvas::on_mouse, this); Bind(wxEVT_LEAVE_WINDOW, &UVEditorCanvas::on_leave, this); Bind(wxEVT_KEY_DOWN, &UVEditorCanvas::on_key, this); @@ -750,10 +751,47 @@ void UVEditorCanvas::end_gesture() m_rot_raw_deg = 0.f; m_rot_applied_deg = 0.f; m_modal_scale_accum = 1.f; + drop_mouse(); +} + +void UVEditorCanvas::cancel_gesture() +{ + // Undo what the gesture already applied live, as the Esc path does, and commit nothing. + if (m_on_island_edit) { + if (m_gesture == Gesture::RotateIslandModal && m_rot_applied_deg != 0.f) + m_on_island_edit(m_selected_island, Vec2f::Zero(), -m_rot_applied_deg, 1.f, false); + if (m_gesture == Gesture::ScaleIslandModal && m_modal_scale_accum != 1.f) + m_on_island_edit(m_selected_island, Vec2f::Zero(), 0.f, 1.f / m_modal_scale_accum, false); + } + + m_gesture = Gesture::None; + m_rot_raw_deg = 0.f; + m_rot_applied_deg = 0.f; + m_modal_scale_accum = 1.f; + m_vertex_edit_moved = false; +} + +void UVEditorCanvas::grab_mouse() +{ + if (!HasCapture()) + CaptureMouse(); +} + +void UVEditorCanvas::drop_mouse() +{ if (HasCapture()) ReleaseMouse(); } +// The capture was taken from us (a dialog opened, another application grabbed the pointer). wx +// requires this to cancel the gesture: no commit, no Skip(), and no ReleaseMouse() - the capture is +// already gone, and releasing it again would unbalance the stack. +void UVEditorCanvas::on_capture_lost(wxMouseCaptureLostEvent &) +{ + cancel_gesture(); + Refresh(); +} + void UVEditorCanvas::on_key(wxKeyEvent &evt) { const int key = evt.GetKeyCode(); @@ -932,7 +970,7 @@ void UVEditorCanvas::on_mouse(wxMouseEvent &evt) } m_gesture = (m_selected_island >= 0) ? Gesture::MoveIsland : Gesture::Pan; } - CaptureMouse(); + grab_mouse(); Refresh(); } else if (type == wxEVT_RIGHT_DOWN && m_selected_island >= 0 && m_select_mode == SelectMode::Island) { const Vec2f rel = screen_to_uv(pos) - island_centroid(m_selected_island); @@ -942,12 +980,16 @@ void UVEditorCanvas::on_mouse(wxMouseEvent &evt) m_rot_base_deg = island_rotation_deg(m_selected_island); m_rot_display_deg = m_rot_base_deg; m_gesture_last_angle = std::atan2(rel.y(), rel.x()); - CaptureMouse(); + grab_mouse(); } else if (type == wxEVT_MIDDLE_DOWN) { m_gesture = Gesture::Pan; m_drag_last_px = pos; - CaptureMouse(); + grab_mouse(); } else if (type == wxEVT_LEFT_UP || type == wxEVT_RIGHT_UP || type == wxEVT_MIDDLE_UP) { + // The drag is over either way. A modal R/S keeps running until a click confirms it, but it + // tracks the pointer over this canvas and needs no capture to do so, so the capture goes back + // here rather than waiting for that click - which may never come. + drop_mouse(); if (m_gesture != Gesture::RotateIslandModal && m_gesture != Gesture::ScaleIslandModal) { end_gesture(); Refresh(); @@ -1735,7 +1777,7 @@ public: bool toggle, bool accent = false, int size_dip = 26) : wxWindow(parent, id, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE | wxFULL_REPAINT_ON_RESIZE) , m_icon_name(icon), m_icon_dip(size_dip >= 26 ? 16 : 14), m_label(label), m_toggle(toggle), m_accent(accent) - , m_size_dip(size_dip) + , m_size_dip(size_dip), m_tip(tip) { SetBackgroundStyle(wxBG_STYLE_PAINT); SetToolTip(tip); @@ -1748,7 +1790,7 @@ public: Bind(wxEVT_ENTER_WINDOW, [this](wxMouseEvent &) { m_hover = true; Refresh(); }); Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent &) { m_hover = false; m_pressed = false; Refresh(); }); Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent &) { - if (IsEnabled()) { + if (usable()) { m_pressed = true; Refresh(); } @@ -1757,7 +1799,7 @@ public: const bool was_pressed = m_pressed; m_pressed = false; Refresh(); - if (!was_pressed || !IsEnabled() || !GetClientRect().Contains(e.GetPosition())) + if (!was_pressed || !usable() || !GetClientRect().Contains(e.GetPosition())) return; if (m_toggle) m_on = !m_on; @@ -1797,6 +1839,30 @@ public: Refresh(); return changed; } + // Soft-disable: the button is drawn faded and swallows clicks, but stays a live window, so hovering it + // still raises its tooltip - now with `reason` appended, saying what to do to make it usable. A window + // really disabled with Enable(false) gets no mouse events at all on GTK and MSW, which leaves the user + // guessing; this is the same trade-off the gizmo panel's icon_toggle() makes with its `unavailable`. + // An empty reason makes the button usable again. + void SetUnavailable(const wxString &reason) + { + if (reason == m_unavailable) + return; + m_unavailable = reason; + SetToolTip(reason.empty() || m_tip.empty() ? m_tip : m_tip + "\n\n" + reason); + if (!m_unavailable.empty()) + m_pressed = false; // a reason appearing mid-press cancels the press + Refresh(); + } + // Replaces the plain tooltip, keeping whatever reason is currently appended to it. + void SetTip(const wxString &tip) + { + if (tip == m_tip) + return; + m_tip = tip; + SetToolTip(m_unavailable.empty() || m_tip.empty() ? m_tip : m_tip + "\n\n" + m_unavailable); + } + bool usable() const { return IsEnabled() && m_unavailable.empty(); } protected: wxSize DoGetBestSize() const override @@ -1815,7 +1881,7 @@ private: const PaneColors c = PaneColors::current(); const wxRect r = GetClientRect(); const wxColour teal(0x00, 0x96, 0x88); - const bool enabled = IsEnabled(); + const bool enabled = usable(); wxColour fill = c.bg, border = c.frame, text = c.ink; if (m_accent) { @@ -1875,6 +1941,8 @@ private: bool m_toggle = false; bool m_accent = false; int m_size_dip = 26; + wxString m_tip; // the tooltip without any m_unavailable reason appended + wxString m_unavailable; // non-empty: faded and unclickable, and why (see SetUnavailable()) bool m_on = false; bool m_badge = false; bool m_hover = false; @@ -1920,6 +1988,9 @@ UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY) }); m_layer_name->SetMinSize(wxSize(FromDIP(30), -1)); m_tile = text(wxEmptyString, c.dim); + m_tile->SetToolTip(_L("The active layer's tile size: how much of the model one repeat of the texture covers. The " + "canvas is measured in tiles, so one grid cell is one repeat. Change it with Tiling in the " + "layer's settings.")); header->Add(m_thumb, 0, wxALIGN_CENTER_VERTICAL); header->Add(m_layer_name, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, gap); header->Add(m_tile, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, gap); @@ -1973,9 +2044,19 @@ UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY) strip->Add(r, 0, wxALIGN_CENTER_HORIZONTAL | wxTOP, FromDIP(11)); strip->AddSpacer(FromDIP(7)); }; - m_select[0] = tool(ID_UV_SELECT_ISLAND, "texture_displacement_uv_select_island", _L("Island - move, rotate and scale whole islands"), true); - m_select[1] = tool(ID_UV_SELECT_VERTEX, "texture_displacement_uv_select_vertex", _L("Vertex - drag vertices to reshape; Shift/Ctrl to multi-select"), true); - m_select[2] = tool(ID_UV_SELECT_EDGE, "texture_displacement_uv_select_edge", _L("Edge - drag edges to reshape; Shift/Ctrl to multi-select"), true); + m_select[0] = tool(ID_UV_SELECT_ISLAND, "texture_displacement_uv_select_island", + _L("Island - work on whole islands. Click one to select it, then drag to move it, right-drag to " + "rotate it, or press R to rotate and S to scale with the mouse (click or Enter to confirm, Esc " + "to cancel)."), + true); + m_select[1] = tool(ID_UV_SELECT_VERTEX, "texture_displacement_uv_select_vertex", + _L("Vertex - drag vertices to reshape an island by hand; Shift adds to the selection, Ctrl " + "toggles one in or out of it."), + true); + m_select[2] = tool(ID_UV_SELECT_EDGE, "texture_displacement_uv_select_edge", + _L("Edge - drag edges to reshape an island by hand; Shift adds to the selection, Ctrl toggles " + "one in or out of it."), + true); strip_rule(); m_mark_seams = tool(ID_UV_MARK_SEAMS, "texture_displacement_uv_seam", _L("Mark seams - click edges on the model to cut the unwrap along them. The edge under the cursor is " @@ -1996,7 +2077,11 @@ UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY) m_clear_edits = tool(ID_UV_CLEAR_EDITS, "texture_displacement_uv_clear_edits", _L("Clear UV edits - discard all manual vertex/edge moves and return the unwrap to its automatic shape"), false); m_snap = tool(ID_UV_SNAP, "texture_displacement_uv_snap", _L("Snap - stick islands together when dragging one against another"), true); - m_frame = tool(ID_UV_FRAME, "texture_displacement_uv_frame", _L("Frame all islands (Home)"), false); + m_frame = tool(ID_UV_FRAME, "texture_displacement_uv_frame", + _L("Frame all islands, fitting every one of them in view (Home or F).\n" + "Elsewhere on the canvas: scroll to zoom around the cursor, and middle-drag - or drag empty space - " + "to pan."), + false); strip->AddSpacer(FromDIP(4)); m_canvas = new UVEditorCanvas(this); @@ -2007,6 +2092,7 @@ UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY) // ---- status line: the current gesture on the left, the unwrap summary on the right ---- m_status = text(wxEmptyString, c.dim, wxST_ELLIPSIZE_END); + m_status->SetToolTip(_L("What is selected, and the exact figures of the move, rotation or scale while you drag one.")); m_status->SetMinSize(wxSize(FromDIP(40), -1)); m_stats = text(wxEmptyString, c.dim); auto *status = new wxBoxSizer(wxHORIZONTAL); @@ -2082,19 +2168,25 @@ void UVEditorPanel::apply_state(const UVEditorCanvas::PaneState &s) } else { m_thumb->SetBitmap(wxNullBitmap); } - m_thumb->Enable(s.has_layer); + // Every tool that cannot be used right now is faded with the reason appended to its tooltip, rather than + // being hard-disabled (which would hide the tooltip too - see UVToolButton::SetUnavailable()). + const wxString no_layer = s.has_layer ? wxString() : + _L("The pane follows the active texture layer, and that layer has to be mapped " + "with Unwrap (LSCM). Add a layer and set its Mapping to Unwrap."); + m_thumb->SetUnavailable(no_layer); + m_layer_name->SetToolTip(s.has_layer ? m_thumb->GetToolTipText() : no_layer); for (int i = 0; i < 3; ++i) { m_background[i]->SetValue(int(s.background) == i); - m_background[i]->Enable(s.has_layer); + m_background[i]->SetUnavailable(no_layer); } - m_unwrap->Enable(s.has_layer); + m_unwrap->SetUnavailable(no_layer); m_unwrap->SetBadge(s.unwrap_stale); - m_unwrap->SetToolTip(s.unwrap_stale ? - _L("Out of date - the paint, the seams or the seam angle changed since this unwrap was made. " - "Press to unwrap again.") : - _L("Flatten the painted area into UV islands. It is computed only when you press this, not on " - "every edit - so paint, change the seam angle or mark seams first, then press Unwrap.")); + m_unwrap->SetTip(s.unwrap_stale ? + _L("Out of date - the paint, the seams or the seam angle changed since this unwrap was made. " + "Press to unwrap again.") : + _L("Flatten the painted area into UV islands. It is computed only when you press this, not on " + "every edit - so paint, change the seam angle or mark seams first, then press Unwrap.")); if (m_seam_angle->GetValue() != int(std::lround(s.seam_angle_deg))) m_seam_angle->SetValue(int(std::lround(s.seam_angle_deg))); @@ -2103,15 +2195,26 @@ void UVEditorPanel::apply_state(const UVEditorCanvas::PaneState &s) m_connect->Enable(s.has_layer); m_mark_seams->SetValue(s.mark_seams); - m_mark_seams->Enable(s.has_layer); + m_mark_seams->SetUnavailable(no_layer); m_seam_path->SetValue(s.seam_path); - m_seam_path->Enable(s.has_layer && s.mark_seams); - m_clear_seams->Enable(s.has_layer && s.has_seams); - m_clear_edits->Enable(s.has_layer && s.has_uv_edits); + m_seam_path->SetUnavailable(!no_layer.empty() ? no_layer : + s.mark_seams ? wxString() : + _L("Turn Mark seams on first - Path is a quicker way of marking them.")); + m_clear_seams->SetUnavailable(!no_layer.empty() ? no_layer : + s.has_seams ? wxString() : + _L("No seams are marked on this layer.")); + m_clear_edits->SetUnavailable(!no_layer.empty() ? no_layer : + s.has_uv_edits ? wxString() : + _L("No islands have been reshaped by hand, so there is nothing to " + "discard.")); m_stats->SetLabel(s.unwrapped ? wxString::Format(_L("%d islands, %s faces"), s.island_count, wxString(std::to_string(s.face_count))) : wxString()); + m_stats->SetToolTip(s.unwrapped ? _L("How the painted area came out of the unwrap: the number of separate pieces it " + "was cut into (at the seams and at edges sharper than the seam angle), and how " + "many triangles they hold in total.") : + wxString()); refresh_selection_tools(); if (relayout) Layout(); @@ -2121,19 +2224,24 @@ void UVEditorPanel::refresh_selection_tools() { const bool has_islands = m_canvas->has_islands(); const int mode = int(m_canvas->select_mode()); + // Faded rather than hard-disabled, so the tooltip still says what is missing (see apply_state()). + const wxString not_unwrapped = has_islands ? wxString() : _L("Press Unwrap first - there are no islands to work on yet."); for (int i = 0; i < 3; ++i) { m_select[i]->SetValue(i == mode); - m_select[i]->Enable(has_islands); + m_select[i]->SetUnavailable(not_unwrapped); } const bool island_picked = has_islands && m_canvas->select_mode() == UVEditorCanvas::SelectMode::Island && m_canvas->selected_island() >= 0; - m_avg_scale->Enable(has_islands); - m_cut->Enable(island_picked); - m_join->Enable(island_picked); - m_unjoin->Enable(island_picked); - m_snap->Enable(has_islands); + const wxString no_island = !not_unwrapped.empty() ? not_unwrapped : + island_picked ? wxString() : + _L("Click an island on the canvas first, in Island mode."); + m_avg_scale->SetUnavailable(not_unwrapped); + m_cut->SetUnavailable(no_island); + m_join->SetUnavailable(no_island); + m_unjoin->SetUnavailable(no_island); + m_snap->SetUnavailable(not_unwrapped); m_snap->SetValue(m_canvas->snap_enabled()); - m_frame->Enable(has_islands); + m_frame->SetUnavailable(not_unwrapped); } void UVEditorPanel::on_tool(wxCommandEvent &evt) diff --git a/src/slic3r/GUI/UVEditorCanvas.hpp b/src/slic3r/GUI/UVEditorCanvas.hpp index 2b3ee0781d..2fc1055cb8 100644 --- a/src/slic3r/GUI/UVEditorCanvas.hpp +++ b/src/slic3r/GUI/UVEditorCanvas.hpp @@ -200,6 +200,7 @@ private: void on_paint(wxPaintEvent &evt); void on_size(wxSizeEvent &evt); void on_mouse(wxMouseEvent &evt); + void on_capture_lost(wxMouseCaptureLostEvent &evt); void on_key(wxKeyEvent &evt); void on_leave(wxMouseEvent &evt); // drops the +/- cursor hint when the pointer leaves the canvas void on_erase_background(wxEraseEvent &evt) {} // required to avoid flicker on MSW, deliberately a no-op @@ -244,6 +245,16 @@ private: // vertex of some *other* island, in texture-UV space. Zero if nothing is within reach (#2). Vec2f snap_correction(int island) const; void end_gesture(); + // Cancels the gesture instead of finishing it: nothing is committed, and a modal rotate/scale is + // put back the way Esc puts it back. Used when the capture is taken away from us. + void cancel_gesture(); + // One capture at a time, released exactly once. Pressing a second button mid-drag would otherwise + // nest a second capture that the single release on button-up cannot undo, and macOS never sends + // wxEVT_MOUSE_CAPTURE_LOST to recover from that. A leaked capture is not a local problem there: + // while any wx window holds one, wxOSX routes every mouse event to it and the application stops + // seeing enter/leave and motion entirely, which also takes its tooltips down. + void grab_mouse(); + void drop_mouse(); // Rebuilds the status line from the current gesture/selection and pushes it to m_on_status. void update_status(); // Re-picks what a click at `pos` would grab in the current select mode, and repaints when that changed. diff --git a/src/slic3r/GUI/UnsavedChangesDialog.cpp b/src/slic3r/GUI/UnsavedChangesDialog.cpp index b2bc818ad1..ffb3724411 100644 --- a/src/slic3r/GUI/UnsavedChangesDialog.cpp +++ b/src/slic3r/GUI/UnsavedChangesDialog.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -62,7 +63,6 @@ #include "PresetComboBoxes.hpp" #include "Widgets/CheckBox.hpp" #include "Widgets/DialogButtons.hpp" -#include "Widgets/HyperLink.hpp" #ifdef __linux__ #define wxLinux true @@ -1011,8 +1011,14 @@ void UnsavedChangesDialog::build(Preset::Type type, PresetCollection *dependent_ checkbox_sizer->Show(bool(m_buttons & REMEMBER_CHOISE)); if (dependent_presets != nullptr) { - auto wiki = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/transfer_discard_changes"); - m_sizer_button->Add(wiki, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(22)); + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/transfer_discard_changes"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + + m_sizer_button->Add(wiki_btn, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(22)); } m_sizer_button->Add(0, 0, 1, 0, 0); diff --git a/src/slic3r/GUI/Widgets/Button.cpp b/src/slic3r/GUI/Widgets/Button.cpp index 5ecbafda2b..63558245c8 100644 --- a/src/slic3r/GUI/Widgets/Button.cpp +++ b/src/slic3r/GUI/Widgets/Button.cpp @@ -223,6 +223,12 @@ void Button::SetStyle(const ButtonStyle style, const ButtonType type) this->SetMinSize(FromDIP(wxSize(26, 26))); this->SetSize(FromDIP(wxSize(26, 26))); this->SetCornerRadius(this->FromDIP(4)); + } else if (type == ButtonType::Circle) { + this->SetPaddingSize(FromDIP(wxSize(6, 6))); + this->SetMinSize(FromDIP(wxSize(25, 25))); + this->SetMaxSize(FromDIP(wxSize(25, 25))); + this->SetSize(FromDIP(wxSize(25, 25))); + this->SetCornerRadius(this->FromDIP(12)); } else if (type == ButtonType::Expanded) { this->SetMinSize(FromDIP(wxSize(-1, 32))); this->SetPaddingSize(FromDIP(wxSize(12, 8))); diff --git a/src/slic3r/GUI/Widgets/Button.hpp b/src/slic3r/GUI/Widgets/Button.hpp index b4b1e24bb2..f5f675051f 100644 --- a/src/slic3r/GUI/Widgets/Button.hpp +++ b/src/slic3r/GUI/Widgets/Button.hpp @@ -34,6 +34,7 @@ enum class ButtonType { Parameter, // Font14 Semi-Rounded For buttons that near parameter boxes Icon, // ------ Semi-Rounded For buttons that only has icons. icons should be 16x16 and iconSize has to be defined as 16 while // creation of button + Circle, // ------ FullyRounded Same as icon Expanded, // Font14 Semi-Rounded For full length buttons. ex. buttons in static box }; diff --git a/src/slic3r/GUI/calib_dlg.cpp b/src/slic3r/GUI/calib_dlg.cpp index 18b6d5ae3e..ee8da8f0b2 100644 --- a/src/slic3r/GUI/calib_dlg.cpp +++ b/src/slic3r/GUI/calib_dlg.cpp @@ -12,6 +12,7 @@ #include #include "slic3r/GUI/GUI_Utils.hpp" #include +#include "slic3r/GUI/Widgets/Button.hpp" #include "slic3r/GUI/Widgets/Label.hpp" #include "slic3r/GUI/Widgets/LabeledStaticBox.hpp" #include "slic3r/GUI/Widgets/RadioGroup.hpp" @@ -25,7 +26,6 @@ #include #include "MainFrame.hpp" #include "Widgets/DialogButtons.hpp" -#include "Widgets/HyperLink.hpp" #include #include #include @@ -35,6 +35,7 @@ #include #include #include +#include #include #include "libslic3r/PrintConfig.hpp" #include "libslic3r/Flow.hpp" @@ -124,6 +125,19 @@ std::vector make_shaper_type_labels() return labels; } +// ORCA-Belt: PA Line / PA Pattern have belt plumbing in place (drawn on the +// belt surface via BeltKinematics world-coordinates mode) but are not +// validated yet — belt printers are restricted to the PA Tower for now. +bool is_belt_printer_selected() +{ + if (auto* preset_bundle = wxGetApp().preset_bundle) { + const auto& cfg = preset_bundle->printers.get_edited_preset().config; + const auto* opt = cfg.option("belt_printer"); + return opt != nullptr && opt->value; + } + return false; +} + } PA_Calibration_Dlg::PA_Calibration_Dlg(wxWindow* parent, wxWindowID id, Plater* plater) @@ -237,8 +251,15 @@ PA_Calibration_Dlg::PA_Calibration_Dlg(wxWindow* parent, wxWindowID id, Plater* auto dlg_btns = new DialogButtons(this, {"OK"}); auto bottom_sizer = new wxBoxSizer(wxHORIZONTAL); - auto wiki = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/pressure_advance_calib"); - bottom_sizer->Add(wiki, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(20)); + + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/pressure_advance_calib"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + + bottom_sizer->Add(wiki_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); bottom_sizer->AddStretchSpacer(); bottom_sizer->Add(dlg_btns, 0, wxEXPAND); v_sizer->Add(bottom_sizer, 0, wxEXPAND); @@ -334,6 +355,14 @@ void PA_Calibration_Dlg::on_start(wxCommandEvent& event) { m_params.mode = CalibMode::Calib_PA_Tower; } + // ORCA-Belt: backstop in case the selection slipped past the UI guards. + if (is_belt_printer_selected() && m_params.mode != CalibMode::Calib_PA_Tower) { + MessageDialog msg_dlg(nullptr, _L("PA Line and PA Pattern tests are not enabled yet on belt printers.\nPlease use the PA Tower method instead."), + wxEmptyString, wxICON_WARNING | wxOK); + msg_dlg.ShowModal(); + return; + } + m_params.print_numbers = m_cbPrintNum->GetValue(); ParseStringValues(m_tiBMAccels->GetTextCtrl()->GetValue().ToStdString(), m_params.accelerations); ParseStringValues(m_tiBMSpeeds->GetTextCtrl()->GetValue().ToStdString(), m_params.speeds); @@ -360,6 +389,9 @@ void PA_Calibration_Dlg::on_extruder_type_changed(wxCommandEvent& event) { event.Skip(); } void PA_Calibration_Dlg::on_method_changed(wxCommandEvent& event) { + // ORCA-Belt: only the PA Tower method is enabled on belt printers so far. + if (is_belt_printer_selected() && m_rbMethod->GetSelection() != 0) + m_rbMethod->SetSelection(0, true); PA_Calibration_Dlg::reset_params(); event.Skip(); } @@ -370,6 +402,17 @@ void PA_Calibration_Dlg::on_dpi_changed(const wxRect& suggested_rect) { } void PA_Calibration_Dlg::on_show(wxShowEvent& event) { + // ORCA-Belt: the dialog is cached across printer switches, so refresh the + // belt restriction on every show. + if (is_belt_printer_selected()) { + m_rbMethod->SetSelection(0); + const wxString tip = _L("Not enabled yet on belt printers — use the PA Tower method instead."); + m_rbMethod->SetRadioTooltip(1, tip); + m_rbMethod->SetRadioTooltip(2, tip); + } else { + m_rbMethod->SetRadioTooltip(1, wxEmptyString); + m_rbMethod->SetRadioTooltip(2, wxEmptyString); + } PA_Calibration_Dlg::reset_params(); } @@ -406,6 +449,17 @@ Temp_Calibration_Dlg::Temp_Calibration_Dlg(wxWindow* parent, wxWindowID id, Plat method_box->Add(m_rbFilamentType, 0, wxALL | wxEXPAND, FromDIP(4)); v_sizer->Add(method_box, 0, wxTOP | wxRIGHT | wxLEFT | wxEXPAND, FromDIP(10)); + // Belt temperature-tower model: Standard (sectioned tower) vs Overhang (engraved + // inverted-L provini that stress overhang quality per temperature). Only affects + // belt printers; the upright tower ignores it, so the picker is only shown on + // belts. The dialog is cached across printer switches, so visibility is toggled + // per-show in on_show() rather than gated here at construction time. + auto labeled_box_model = new LabeledStaticBox(this, _L("Test model")); + m_model_box = new wxStaticBoxSizer(labeled_box_model, wxHORIZONTAL); + m_rbModel = new RadioGroup(this, { _L("Standard"), _L("Overhang") }, wxVERTICAL); + m_model_box->Add(m_rbModel, 0, wxALL | wxEXPAND, FromDIP(4)); + v_sizer->Add(m_model_box, 0, wxTOP | wxRIGHT | wxLEFT | wxEXPAND, FromDIP(10)); + // Settings wxString start_temp_str = _L("Start temp: "); wxString end_temp_str = _L("End temp: "); @@ -459,8 +513,15 @@ Temp_Calibration_Dlg::Temp_Calibration_Dlg(wxWindow* parent, wxWindowID id, Plat auto dlg_btns = new DialogButtons(this, {"OK"}); auto bottom_sizer = new wxBoxSizer(wxHORIZONTAL); - auto wiki = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/temp_calib"); - bottom_sizer->Add(wiki, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(20)); + + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/temp_calib"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + + bottom_sizer->Add(wiki_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); bottom_sizer->AddStretchSpacer(); bottom_sizer->Add(dlg_btns, 0, wxEXPAND); v_sizer->Add(bottom_sizer, 0, wxEXPAND); @@ -469,6 +530,11 @@ Temp_Calibration_Dlg::Temp_Calibration_Dlg(wxWindow* parent, wxWindowID id, Plat m_rbFilamentType->Connect(wxEVT_COMMAND_RADIOBOX_SELECTED, wxCommandEventHandler(Temp_Calibration_Dlg::on_filament_type_changed), NULL, this); + // Refresh the belt-only model picker on every show — the dialog is cached and + // reused across printer switches. + this->Connect(wxEVT_SHOW, wxShowEventHandler(Temp_Calibration_Dlg::on_show)); + m_model_box->ShowItems(is_belt_printer_selected()); + wxGetApp().UpdateDlgDarkUI(this); Layout(); @@ -527,11 +593,27 @@ void Temp_Calibration_Dlg::on_start(wxCommandEvent& event) { m_params.end = end; m_params.nozzle_based_resize = m_cbResize->GetValue(); m_params.mode = CalibMode::Calib_Temp_Tower; + // Picker only exists on belt printers; default non-belt to the Standard model. + m_params.test_model = m_rbModel ? m_rbModel->GetSelection() : 0; m_plater->calib_temp(m_params); EndModal(wxID_OK); } +void Temp_Calibration_Dlg::on_show(wxShowEvent& event) { + // ORCA-Belt: the dialog is cached across printer switches, so refresh the + // belt-only "Test model" picker on every show. The Overhang model only + // applies to belt printers; hide it (and resize the dialog) otherwise. + const bool belt = is_belt_printer_selected(); + if (m_model_box->AreAnyItemsShown() != belt) { + m_model_box->ShowItems(belt); + Layout(); + Fit(); + GetSizer()->SetSizeHints(this); + } + event.Skip(); +} + void Temp_Calibration_Dlg::on_filament_type_changed(wxCommandEvent& event) { int selection = event.GetSelection(); unsigned long start = 0, end = 0; @@ -642,8 +724,15 @@ MaxVolumetricSpeed_Test_Dlg::MaxVolumetricSpeed_Test_Dlg(wxWindow* parent, wxWin auto dlg_btns = new DialogButtons(this, {"OK"}); auto bottom_sizer = new wxBoxSizer(wxHORIZONTAL); - auto wiki = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/volumetric_speed_calib"); - bottom_sizer->Add(wiki, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(20)); + + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/volumetric_speed_calib"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + + bottom_sizer->Add(wiki_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); bottom_sizer->AddStretchSpacer(); bottom_sizer->Add(dlg_btns, 0, wxEXPAND); v_sizer->Add(bottom_sizer, 0, wxEXPAND); @@ -766,8 +855,15 @@ VFA_Test_Dlg::VFA_Test_Dlg(wxWindow* parent, wxWindowID id, Plater* plater) auto dlg_btns = new DialogButtons(this, {"OK"}); auto bottom_sizer = new wxBoxSizer(wxHORIZONTAL); - auto wiki = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/vfa_calib"); - bottom_sizer->Add(wiki, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(20)); + + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/vfa_calib"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + + bottom_sizer->Add(wiki_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); bottom_sizer->AddStretchSpacer(); bottom_sizer->Add(dlg_btns, 0, wxEXPAND); v_sizer->Add(bottom_sizer, 0, wxEXPAND); @@ -1005,8 +1101,15 @@ Retraction_Test_Dlg::Retraction_Test_Dlg(wxWindow* parent, wxWindowID id, Plater auto dlg_btns = new DialogButtons(this, {"OK"}); auto bottom_sizer = new wxBoxSizer(wxHORIZONTAL); - auto wiki = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/retraction_calib"); - bottom_sizer->Add(wiki, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(20)); + + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/retraction_calib"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + + bottom_sizer->Add(wiki_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); bottom_sizer->AddStretchSpacer(); bottom_sizer->Add(dlg_btns, 0, wxEXPAND); v_sizer->Add(bottom_sizer, 0, wxEXPAND); @@ -1185,8 +1288,15 @@ Input_Shaping_Freq_Test_Dlg::Input_Shaping_Freq_Test_Dlg(wxWindow* parent, wxWin auto dlg_btns = new DialogButtons(this, {"OK"}); auto bottom_sizer = new wxBoxSizer(wxHORIZONTAL); - auto wiki = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/input_shaping_calib"); - bottom_sizer->Add(wiki, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(20)); + + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/input_shaping_calib"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + + bottom_sizer->Add(wiki_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); bottom_sizer->AddStretchSpacer(); bottom_sizer->Add(dlg_btns, 0, wxEXPAND); v_sizer->Add(bottom_sizer, 0, wxEXPAND); @@ -1384,8 +1494,15 @@ Input_Shaping_Damp_Test_Dlg::Input_Shaping_Damp_Test_Dlg(wxWindow* parent, wxWin auto dlg_btns = new DialogButtons(this, {"OK"}); auto bottom_sizer = new wxBoxSizer(wxHORIZONTAL); - auto wiki = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/input_shaping_calib"); - bottom_sizer->Add(wiki, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(20)); + + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/input_shaping_calib"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + + bottom_sizer->Add(wiki_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); bottom_sizer->AddStretchSpacer(); bottom_sizer->Add(dlg_btns, 0, wxEXPAND); v_sizer->Add(bottom_sizer, 0, wxEXPAND); @@ -1580,8 +1697,15 @@ Cornering_Test_Dlg::Cornering_Test_Dlg(wxWindow* parent, wxWindowID id, Plater* auto dlg_btns = new DialogButtons(this, {"OK"}); auto bottom_sizer = new wxBoxSizer(wxHORIZONTAL); - auto wiki = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/cornering_calib"); - bottom_sizer->Add(wiki, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(20)); + + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/cornering_calib"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + + bottom_sizer->Add(wiki_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); bottom_sizer->AddStretchSpacer(); bottom_sizer->Add(dlg_btns, 0, wxEXPAND); v_sizer->Add(bottom_sizer, 0, wxEXPAND); @@ -1719,8 +1843,15 @@ FlowRateCalibrationDialog::FlowRateCalibrationDialog(wxWindow* parent, wxWindowI auto dlg_btns = new DialogButtons(this, {"OK"}); auto bottom_sizer = new wxBoxSizer(wxHORIZONTAL); - auto wiki = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/flow_ratio_calib"); - bottom_sizer->Add(wiki, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(20)); + + auto wiki_btn = new Button(this, "", "toolbar_wiki", 0, 15); + auto wiki_url = "https://www.orcaslicer.com/wiki/flow_ratio_calib"; + wiki_btn->SetToolTip(_L("Wiki Guide") + "\n" + wiki_url); + wiki_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Circle); + wiki_btn->SetCanFocus(false); + wiki_btn->Bind(wxEVT_LEFT_DOWN, ([wiki_url](auto& e) {wxLaunchDefaultBrowser(wiki_url);})); + + bottom_sizer->Add(wiki_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); bottom_sizer->AddStretchSpacer(); bottom_sizer->Add(dlg_btns, 0, wxEXPAND); v_sizer->Add(bottom_sizer, 0, wxEXPAND); diff --git a/src/slic3r/GUI/calib_dlg.hpp b/src/slic3r/GUI/calib_dlg.hpp index 81d6b15200..eb0c5c9c01 100644 --- a/src/slic3r/GUI/calib_dlg.hpp +++ b/src/slic3r/GUI/calib_dlg.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include "libslic3r/calib.hpp" namespace Slic3r { namespace GUI { @@ -62,9 +63,12 @@ protected: virtual void on_start(wxCommandEvent& event); virtual void on_filament_type_changed(wxCommandEvent& event); + void on_show(wxShowEvent& event); Calib_Params m_params; RadioGroup* m_rbFilamentType; + RadioGroup* m_rbModel = nullptr; + wxStaticBoxSizer* m_model_box = nullptr; TextInput* m_tiStart; TextInput* m_tiEnd; TextInput* m_tiStep; diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index efb730da8c..34ae514fba 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -351,8 +351,20 @@ std::vector PluginAuditManager::default_denied_path_keywords() // must never be able to reach a secret, a certificate, or a configuration file just because // it happens to live inside an otherwise-allowed root (e.g. the bundled TLS client cert at // resources_dir()/cert/..., which would become reachable the moment resources_dir() is - // granted as a read-only allowed root). - return {"secret", "cert", "conf"}; + // granted as a read-only allowed root). Match as whole path components, not substrings, so + // imports such as numpy/__config__.py and stdlib configparser.py remain usable. + return {"secret", "secrets", "cert", "certs", "certificate", "certificates", "conf", "config"}; +} + +static bool has_denied_config_extension(std::string name) +{ + const size_t stream_pos = name.find(':'); + if (stream_pos != std::string::npos) + name.erase(stream_pos); + + const boost::filesystem::path path(name); + const std::string extension = path.extension().string(); + return extension == ".conf" || extension == ".ini"; } bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& candidate) const @@ -377,7 +389,7 @@ bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& c continue; std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return std::tolower(c); }); for (const auto& keyword : m_denied_path_keywords) { - if (name.find(keyword) != std::string::npos) + if (name == keyword || (keyword == "conf" && has_denied_config_extension(name))) return true; } } @@ -810,7 +822,8 @@ bool persist_permission(const std::string& plugin_key, int report_denied(PluginAuditManager& mgr, const std::string& event_name, - const AuditDecision& decision) + const AuditDecision& decision, + const std::string& target = {}) { AuditViolation violation; violation.plugin_key = mgr.current_plugin(); @@ -818,7 +831,13 @@ int report_denied(PluginAuditManager& mgr, violation.reason = decision.reason; mgr.report_violation(violation); - PyErr_SetString(PyExc_PermissionError, "Plugin attempted an audited operation without permission"); + std::string message = "Plugin attempted audited operation \"" + event_name + "\" without permission"; + if (!decision.reason.empty()) + message += ": " + decision.reason; + if (!target.empty()) + message += ": " + target; + + PyErr_SetString(PyExc_PermissionError, message.c_str()); return -1; } @@ -987,7 +1006,7 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user if (fs_category) { for (const auto& target : targets) { if (mgr->is_denied_path(boost::filesystem::path(target))) - return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}); + return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}, target); } } diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index 49866d2fa9..41d3dca1e8 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -4,7 +4,6 @@ // Via pybind11 so this file requests the same python3xx.lib as everything else. #include #include -#include #include #include #include @@ -104,22 +103,20 @@ public: bool is_denied_filename(const boost::filesystem::path& candidate) const; // --- denied-path-keyword registry --- - // Keywords that categorically deny a path if ANY of its components (directory or file - // name), not just the base name, contains one case-insensitively -- e.g. a "secrets" - // subfolder, a "certificates" folder, or a "conf"/"config" file anywhere the plugin can - // otherwise reach, including inside an allowed root. This is intentionally broader and - // fuzzier than the exact-name is_denied_filename registry: it exists to categorically rule - // out whole classes of sensitive paths (secrets, certificates, config) rather than name - // specific known files, at the cost of over-blocking an unrelated name that happens to - // contain the keyword -- the fail-safe direction, same rationale as is_denied_filename. + // Keywords that categorically deny a path if ANY component matches one case-insensitively -- + // e.g. a "secrets" subfolder, a "certificates" folder, a "conf"/"config" directory, or a + // .conf/.ini file anywhere the plugin can otherwise reach, including inside an allowed root. + // This is broader than the exact-name is_denied_filename registry, but it is not a substring + // match: importable modules such as numpy/__config__.py, configparser.py, sysconfig.py, or + // user folders such as "Conference" and "Concert" are unrelated names and must stay promptable. void add_denied_path_keyword(const std::string& keyword); // The list install_hook() seeds into the keyword registry. Exposed so tests seed the exact // same set without a live interpreter. static std::vector default_denied_path_keywords(); - // True when any component of candidate's (canonicalized) path contains a registered - // keyword, case-insensitively. + // True when any component of candidate's (canonicalized) path matches a registered keyword, + // case-insensitively. A registered "conf" keyword also blocks .conf/.ini file components. bool is_denied_path_keyword(const boost::filesystem::path& candidate) const; // is_denied_filename(candidate) || is_denied_path_keyword(candidate). Convenience for diff --git a/src/slic3r/plugin/PluginFsUtils.hpp b/src/slic3r/plugin/PluginFsUtils.hpp index bd335373d0..d485ac5efe 100644 --- a/src/slic3r/plugin/PluginFsUtils.hpp +++ b/src/slic3r/plugin/PluginFsUtils.hpp @@ -25,11 +25,19 @@ extern const char* const INSTALL_STATE_FILE; // Plugin config and orca.host.ui payloads both cross the boundary as plain JSON-compatible // values, so both go through these. -inline pybind11::object json_to_py(const nlohmann::json& j) +// Maximum nesting depth for JSON <-> Python conversion. A self-referential or pathologically +// deep value would otherwise recurse until the native C stack overflows, an uncatchable crash; +// past this bound we raise instead. 200 is far beyond any legitimate plugin config or UI payload. +inline constexpr int kMaxJsonConversionDepth = 200; + +inline pybind11::object json_to_py(const nlohmann::json& j, int depth = 0) { namespace py = pybind11; using json = nlohmann::json; + if (depth > kMaxJsonConversionDepth) + throw py::value_error("Plugin JSON value nested too deeply"); + switch (j.type()) { case json::value_t::null: return py::none(); case json::value_t::boolean: return py::bool_(j.get()); @@ -40,24 +48,27 @@ inline pybind11::object json_to_py(const nlohmann::json& j) case json::value_t::array: { py::list lst; for (const auto& e : j) - lst.append(json_to_py(e)); + lst.append(json_to_py(e, depth + 1)); return lst; } case json::value_t::object: { py::dict d; for (auto it = j.begin(); it != j.end(); ++it) - d[py::str(it.key())] = json_to_py(it.value()); + d[py::str(it.key())] = json_to_py(it.value(), depth + 1); return d; } default: return py::none(); } } -inline nlohmann::json py_to_json(const pybind11::handle& o) +inline nlohmann::json py_to_json(const pybind11::handle& o, int depth = 0) { namespace py = pybind11; using json = nlohmann::json; + if (depth > kMaxJsonConversionDepth) + throw py::value_error("Plugin value nested too deeply (possible cycle)"); + if (o.is_none()) return json(nullptr); if (py::isinstance(o)) // bool before int (bool subclasses int in Python) @@ -73,13 +84,13 @@ inline nlohmann::json py_to_json(const pybind11::handle& o) if (py::isinstance(o)) { json obj = json::object(); for (auto item : py::reinterpret_borrow(o)) - obj[py::str(item.first).cast()] = py_to_json(item.second); + obj[py::str(item.first).cast()] = py_to_json(item.second, depth + 1); return obj; } if (py::isinstance(o) || py::isinstance(o)) { json arr = json::array(); for (auto e : o) - arr.push_back(py_to_json(e)); + arr.push_back(py_to_json(e, depth + 1)); return arr; } return py::str(o).cast(); // fallback: str() diff --git a/tests/cli/CMakeLists.txt b/tests/cli/CMakeLists.txt index 171140c775..dddebee566 100644 --- a/tests/cli/CMakeLists.txt +++ b/tests/cli/CMakeLists.txt @@ -11,6 +11,13 @@ endif () add_test(NAME cli_strict_mode COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_strict.sh $ ${ORCA_CLI_TEST_PYTHON}) +add_test(NAME cli_empty_project_config + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_empty_project_config.sh $ ${ORCA_CLI_TEST_PYTHON} ${CMAKE_SOURCE_DIR}) +set_tests_properties(cli_empty_project_config PROPERTIES + LABELS "CLI;RequiresApp" + SKIP_RETURN_CODE 77 + TIMEOUT 300) + set_tests_properties(cli_strict_mode PROPERTIES LABELS "CLI;RequiresApp" SKIP_RETURN_CODE 77 diff --git a/tests/cli/test_cli_empty_project_config.sh b/tests/cli/test_cli_empty_project_config.sh new file mode 100755 index 0000000000..8b4dd0b480 --- /dev/null +++ b/tests/cli/test_cli_empty_project_config.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Regression check: a 3mf whose Metadata/project_settings.config carries no settings must load. +# +# The CLI reads printable_height out of the project config with opt_float(), which dereferences +# what option<>() returns. With create = false that is null when the key is absent, so a project +# saved without settings used to take the CLI down with a segfault. Both models in +# resources/handy_models are such files. +# +# usage: test_cli_empty_project_config.sh +set -u + +BIN="${1:-}" +PY="${2:-python3}" +SRC="${3:-}" +# 77 is the test's SKIP_RETURN_CODE. +[ -x "$BIN" ] || { echo "SKIP: orca-slicer binary not found: $BIN"; exit 77; } +[ -f "$SRC/resources/handy_models/OrcaBadge.3mf" ] || { echo "SKIP: handy model not found"; exit 77; } + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/orca-cli-emptycfg.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/datadir" + +# Rewrite the project settings to an empty object, so the test holds no matter what the shipped +# models carry later on. +cp "$SRC/resources/handy_models/OrcaBadge.3mf" "$WORK/empty_config.3mf" +"$PY" - "$WORK/empty_config.3mf" <<'PYEOF' +import shutil, sys, zipfile + +path = sys.argv[1] +entry = "Metadata/project_settings.config" +with zipfile.ZipFile(path) as src: + items = [(i, src.read(i.filename)) for i in src.infolist()] +with zipfile.ZipFile(path + ".new", "w", zipfile.ZIP_DEFLATED) as dst: + seen = False + for info, data in items: + if info.filename == entry: + data, seen = b"{\n}\n", True + dst.writestr(info, data) + if not seen: + dst.writestr(entry, b"{\n}\n") +shutil.move(path + ".new", path) +PYEOF + +"$BIN" --datadir "$WORK/datadir" --info "$WORK/empty_config.3mf" > "$WORK/info.txt" 2>&1 +rc=$? +if [ $rc -ne 0 ]; then + echo "FAIL: --info on a project with empty settings exited $rc" + tail -20 "$WORK/info.txt" + exit 1 +fi +grep -q "size_x" "$WORK/info.txt" || { echo "FAIL: --info printed no geometry"; cat "$WORK/info.txt"; exit 1; } +echo "PASS: a project with empty settings loads" diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index 41580d1c07..da15226ff4 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -8,6 +8,7 @@ add_executable(${_TEST_NAME}_tests test_extrusion_processor.cpp test_fill.cpp test_flow.cpp + test_gcode_processor.cpp test_gcode_timing.cpp test_gcode.cpp test_gcodeprocessor.cpp diff --git a/tests/fff_print/test_gcode_processor.cpp b/tests/fff_print/test_gcode_processor.cpp new file mode 100644 index 0000000000..d45d6534f7 --- /dev/null +++ b/tests/fff_print/test_gcode_processor.cpp @@ -0,0 +1,95 @@ +#include + +#include "libslic3r/libslic3r.h" +#include "libslic3r/GCode/GCodeProcessor.hpp" +#include "libslic3r/PrintConfig.hpp" + +#include "test_utils.hpp" + +#include +#include +#include +#include +#include +#include + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; + +namespace { + +float processed_belt_tilt(const std::string &gcode) +{ + ScopedTemporaryFile temp(".gcode"); + { + std::ofstream os(temp.string()); + os << gcode; + } + GCodeProcessor proc; + proc.apply_config(FullPrintConfig{}); + proc.process_file(temp.string()); + return proc.get_result().belt_tilt_angle; +} + +constexpr const char *body = "G1 X10 Y10 Z0.2 F3000\nG1 X20 Y10 E1 F1200\n"; + +} // namespace + +TEST_CASE("The config block's belt angle does not mark G-code as belt G-code", "[GCodeProcessor][belt]") +{ + // Every printer's config block lists belt_slice_rotation_angle (default 45), belt or not. + const std::string gcode = std::string("; CONFIG_BLOCK_START\n; belt_printer = 0\n; belt_slice_rotation_angle = 45\n; CONFIG_BLOCK_END\n") + body; + CHECK_THAT(processed_belt_tilt(gcode), WithinAbs(0., 1e-6)); +} + +TEST_CASE("The belt header's angle marks G-code as belt G-code", "[GCodeProcessor][belt]") +{ + const std::string gcode = std::string("; belt_slice_rotation_angle = -45.0\n") + body + + "; CONFIG_BLOCK_START\n; belt_printer = 1\n; belt_slice_rotation_angle = -45\n; CONFIG_BLOCK_END\n"; + CHECK_THAT(processed_belt_tilt(gcode), WithinAbs(45., 1e-6)); +} + +TEST_CASE("Non-belt start G-code moves keep the first-layer Z in the processor", "[GCodeProcessor]") +{ + // The belt path (GCodeWriter tests: "start-gcode prepare-stage moves keep their real Z") + // stores the real Z of a move inside the start G-code. Every other printer must keep + // the historical behaviour: a prepare-stage move is pinned to the first-layer height + // so the preview does not draw the start sequence's travel. The gate is the belt + // header, so a file without one, whatever its config block says, takes this path. + struct BBLPrinterGuard { + bool prev = GCodeProcessor::s_IsBBLPrinter; + BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = false; } + ~BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = prev; } + } bbl_guard; + + const std::string gcode = + "G90\n" + "G21\n" + "M83\n" + ";TYPE:Custom\n" + "G1 E-1.5 F2100\n" + "G1 X45 Y0.3 Z50 F12000\n" // prepare-stage travel to a high Z + "G1 E1.5 F1800\n" + ";TYPE:Outer wall\n" + "G1 X46 Y0.3 Z50 E0.05\n" + "; CONFIG_BLOCK_START\n; belt_printer = 0\n; belt_slice_rotation_angle = 45\n; CONFIG_BLOCK_END\n"; + + GCodeProcessor processor; + FullPrintConfig config; + config.initial_layer_print_height.value = 0.3; + processor.apply_config(config); + processor.process_buffer(gcode); + const GCodeProcessorResult &result = processor.get_result(); + REQUIRE_THAT(result.belt_tilt_angle, WithinAbs(0., 1e-6)); + + size_t first_extrude = result.moves.size(); + for (size_t i = 0; i < result.moves.size(); ++i) + if (result.moves[i].type == EMoveType::Extrude) { first_extrude = i; break; } + REQUIRE(first_extrude < result.moves.size()); + REQUIRE(first_extrude > 0); + + // The extrusion keeps its real Z; the prepare-stage move before it is pinned to the + // first-layer height. + CHECK_THAT(result.moves[first_extrude].position.z(), WithinAbs(50., 1e-3)); + CHECK_THAT(result.moves[first_extrude - 1].position.z(), WithinAbs(0.3, 1e-3)); +} diff --git a/tests/fff_print/test_gcodewriter.cpp b/tests/fff_print/test_gcodewriter.cpp index 418e7d6fc4..170dfabae3 100644 --- a/tests/fff_print/test_gcodewriter.cpp +++ b/tests/fff_print/test_gcodewriter.cpp @@ -8,6 +8,7 @@ #include "libslic3r/Point.hpp" #include "libslic3r/Config.hpp" #include "libslic3r/Extruder.hpp" +#include "libslic3r/Geometry.hpp" #include "libslic3r/libslic3r.h" #include #include @@ -36,11 +37,52 @@ #include #include "test_helpers.hpp" +#include +#include "libslic3r/GCode/GCodeProcessor.hpp" +#include +#include +#include "libslic3r/GCode/BeltKinematics.hpp" +#include "libslic3r/BeltTransform.hpp" +#include "libslic3r/GCodeReader.hpp" +#include "libslic3r/PrintConfig.hpp" #include "libslic3r/Arrange.hpp" using namespace Slic3r; using namespace Slic3r::Test; +TEST_CASE("Belt machine coordinates retain a non-45-degree slicing angle", "[GCodeWriter][belt]") +{ + PrintConfig config; + config.belt_printer.value = true; + config.belt_slice_rotation.value = BeltRotationAxis::X; + config.belt_slice_rotation_angle.value = 30.; + config.gcode_remap_x.value = RemapAxis::PosX; + config.gcode_remap_y.value = RemapAxis::PosZ; + config.gcode_remap_z.value = RemapAxis::PosY; + + GCodeWriter writer; + install_belt_kinematics(writer, config); + writer.set_axis_remap(int(config.gcode_remap_x.value), + int(config.gcode_remap_y.value), + int(config.gcode_remap_z.value)); + + // Start with a point in the unrotated model frame, then feed the writer the + // same rotated coordinate produced by the pre-slice mesh transform. The + // back-transform must recover the model point before the axis swap and + // machine-frame shear/scale are applied. + const Vec3d model(4., 10., 3.); + Transform3d forward = BeltTransformPipeline::build_forward_transform(config); + const Vec3d machine = writer.kinematics().to_machine(forward * model); + + // The conventional X-tilt remap produces (x, z, y). At 30 degrees the + // gantry coordinate is z/sin(30) and belt travel is y + z*cot(30). + // The complementary tan/inv-cos formulas accidentally used by the unified + // transform are indistinguishable at 45 degrees, but fail this case. + REQUIRE_THAT(machine.x(), Catch::Matchers::WithinAbs(4., 1e-9)); + REQUIRE_THAT(machine.y(), Catch::Matchers::WithinAbs(3. / std::sin(Geometry::deg2rad(30.)), 1e-9)); + REQUIRE_THAT(machine.z(), Catch::Matchers::WithinAbs(10. + 3. / std::tan(Geometry::deg2rad(30.)), 1e-9)); +} + // Arrange on a 500x500 bed, which keeps coordinates small while still covering large printers. static void arrange_objects_on_test_bed(Model &model, const DynamicPrintConfig &config) { @@ -1230,6 +1272,440 @@ TEST_CASE("Custom G-code motion limits are restored before generated moves", "[G REQUIRE(gcode.find("M205 X8 Y8 ; adjust jerk", custom_gcode_pos) != std::string::npos); } +// Regression test for the belt-printer "illegal gantry move at print start" bug. +// +// On a belt printer the layer-change z-hop is deferred (lazy_lift) and consumed +// by the first travel_to_xyz, whose NormalLift branch lifts in place via +// _travel_to_z(). On a normal printer _travel_to_z emits a Z-only move, but in +// belt mode Z is coupled to Y/X, so _travel_to_z re-emits the current m_pos +// through the belt shear. At print start (and after custom gcode) +// is_current_position_clear() is false and m_pos.xy is still the uninitialised +// origin (0,0), which shears into machine (X=bed_max, Y=layer_z) — a move far up +// the gantry, e.g. "G1 X95 Y168.19 Z237.857". The fix guards that lift on +// is_current_position_clear(), mirroring the SlopeLift branch. +SCENARIO("Belt: the first travel does not lift through the uninitialised origin", "[GCodeWriter][belt]") +{ + GIVEN("A fresh belt-kinematics GCodeWriter configured for an X-tilt 45 degree belt") { + // Machine-frame + slicer->world back-transform config (X tilt, 45 deg). + PrintConfig belt_config; + belt_config.belt_printer.value = true; + belt_config.belt_slice_rotation.value = BeltRotationAxis::X; + belt_config.belt_slice_rotation_angle.value = 45.0; + belt_config.belt_frame_tilt_decouple.value = false; + belt_config.belt_frame_tilt_angle.value = 45.0; + + GCodeWriter writer; + install_belt_kinematics(writer, belt_config); + + std::vector extruder_ids { 0 }; + writer.set_extruders(extruder_ids); + writer.set_extruder(0); + // travel_speed became per-extruder (ConfigOptionFloatsNullable) upstream. + writer.config.travel_speed.values = { 100.0 }; + writer.config.z_hop.values = { 0.4 }; + writer.config.retract_lift_above.values = { 0.0 }; + writer.config.retract_lift_below.values = { 0.0 }; + + // A fresh writer has not established its planar position yet — this is the + // precondition that made the origin leak into the first move. + REQUIRE_FALSE(writer.is_current_position_clear()); + + WHEN("a layer-change z-hop is pending and we travel to the first object point") { + // Defer a z-hop, exactly as a retract on layer change leaves it. + writer.lazy_lift(LiftType::NormalLift); + + // First object point in slicing coordinates: a near-belt point (y ~= -z) + // so its transformed gantry Y is small (~1mm). The bogus origin lift, in + // contrast, would shear to machine Y ~= nominal_z. + const double nominal_z = 100.0; + std::string gcode = writer.travel_to_xyz(Vec3d(10.0, -(nominal_z - 1.0), nominal_z)); + + THEN("no emitted move flies up the gantry; machine Y stays near the part") { + double max_y = std::numeric_limits::lowest(); + GCodeReader reader; + reader.parse_buffer(gcode, [&max_y](GCodeReader &, const GCodeReader::GCodeLine &line) { + if (line.cmd_is("G1") && line.has(Y)) + max_y = std::max(max_y, double(line.y())); + }); + // The destination shears to machine Y ~= 1mm. The old origin-lift bug + // produced a separate move at machine Y ~= nominal_z (100mm), so any + // Y well above the part means the origin leaked into a move. + REQUIRE(max_y > 0.0); // the destination move was emitted and parsed + REQUIRE(max_y < 10.0); // ... and nothing flew up the gantry + } + } + } +} + +// Regression test for the belt-printer "phantom extrusion line from Y=0" bug. +// +// GCodeProcessor::store_move_vertex pins a move's stored Z to the first-layer +// height while m_processing_start_custom_gcode is set (the start G-code "prepare" +// stage), because on a normal printer the toolhead Z there is not yet a real print +// height. On a belt printer that override is wrong: Z is written explicitly and the +// designed-view back-transform couples machine Z into the rendered model Y (the +// belt tilt mixes the height and belt-feed axes). Overriding it back-transforms the +// last prepare-stage move (the unretract right before the first extrusion) to +// model Y ~= 0, and libvgcode then draws a phantom extrusion segment from Y ~= 0 to +// the first real toolpath — rendered in the first extrusion role's color. The fix +// keeps the real Z for belt printers (gated on belt_tilt_angle). Here we assert the +// prepare-stage move keeps its real Z so it can no longer leak to Y ~= 0. +SCENARIO("Belt: start-gcode prepare-stage moves keep their real Z", "[GCode][belt]") +{ + // Belt printers are non-Bambu, so the G-code uses the "compatible" reserved + // tags ("TYPE:" for the extrusion role). The processor selects the tag table + // from the static s_IsBBLPrinter flag, so mirror the belt-printer setting here + // (saved/restored so test ordering stays unaffected). + struct BBLPrinterGuard { + bool prev = GCodeProcessor::s_IsBBLPrinter; + BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = false; } + ~BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = prev; } + } bbl_guard; + + GIVEN("A belt G-code whose start sequence travels to a high machine Z before the first extrusion") { + // The leading "; belt_slice_rotation_angle = 45" header sets belt_tilt_angle + // (parsed before the body), enabling the belt code path. ;TYPE:Custom before + // any G1 turns on the prepare stage; ;TYPE:Outer wall turns it off, exactly + // as a sliced belt print is laid out. + const std::string gcode = + "; belt_slice_rotation_angle = 45\n" + "G90\n" + "G21\n" + "M83\n" + ";TYPE:Custom\n" + "G1 E-1.5 F2100\n" // retract at the (0,0,0) origin + "G1 X45 Y0.3 Z50 F12000\n" // travel to the approach point (prepare stage) + "G1 E1.5 F1800\n" // unretract in place (prepare stage) + ";TYPE:Outer wall\n" + "G1 X46 Y0.3 Z50 E0.05\n"; // first extrusion, same Z as the approach + + GCodeProcessor processor; + processor.process_buffer(gcode); + const GCodeProcessorResult& result = processor.get_result(); + + THEN("the belt code path is active") { + REQUIRE_THAT(result.belt_tilt_angle, Catch::Matchers::WithinAbs(45.0, 1e-4)); + } + + WHEN("locating the first extrusion and the move that precedes it") { + size_t first_extrude = result.moves.size(); + for (size_t i = 0; i < result.moves.size(); ++i) + if (result.moves[i].type == EMoveType::Extrude) { first_extrude = i; break; } + + THEN("an extrusion and a preceding move exist") { + REQUIRE(first_extrude < result.moves.size()); + REQUIRE(first_extrude > 0); + } + + THEN("the preceding prepare-stage move shares the extrusion's real Z (no leak to Y=0)") { + const float extrude_z = result.moves[first_extrude].position.z(); + const float prev_z = result.moves[first_extrude - 1].position.z(); + // The first extrusion is at the real Z=50; before the fix the + // prepare-stage move's Z was pinned to the first-layer height + // (0 here) instead, which back-transforms to model Y ~= 0 and + // produces the phantom extrusion segment. + REQUIRE_THAT(extrude_z, Catch::Matchers::WithinAbs(50.0, 1e-3)); + REQUIRE_THAT(prev_z, Catch::Matchers::WithinAbs(50.0, 1e-3)); + } + } + } +} + +// --------------------------------------------------------------------------- +// Regression tests for the two latent bugs the MachineKinematics refactor +// preserved deliberately and the follow-up commit fixed. +// --------------------------------------------------------------------------- + +// Bug 1. _travel_to_z() emits full XYZ whenever the mapping must emit every +// axis, and it builds that point from m_pos. While the position is unknown, +// m_pos.xy is the uninitialised origin, which a reverse remap maps to the far +// corner of the bed. Belt kinematics guarded this; a Cartesian writer with an +// axis remap did not, and would command a rapid across the whole bed. +static void configure_lift_writer(GCodeWriter &writer) +{ + std::vector extruder_ids { 0 }; + writer.set_extruders(extruder_ids); + writer.set_extruder(0); + writer.config.travel_speed.values = { 100.0 }; + writer.config.travel_speed_z.values = { 100.0 }; + writer.config.z_hop.values = { 0.4 }; + writer.config.retract_lift_above.values = { 0.0 }; + writer.config.retract_lift_below.values = { 0.0 }; +} + +// Largest X word in a chunk of emitted G-code, or lowest() if none. +static double max_emitted_x(const std::string &gcode) +{ + double max_x = std::numeric_limits::lowest(); + GCodeReader reader; + reader.parse_buffer(gcode, [&max_x](GCodeReader &, const GCodeReader::GCodeLine &line) { + if (line.cmd_is("G1") && line.has(X)) + max_x = std::max(max_x, double(line.x())); + }); + return max_x; +} + +static size_t count_g1(const std::string &gcode) +{ + size_t n = 0; + GCodeReader reader; + reader.parse_buffer(gcode, [&n](GCodeReader &, const GCodeReader::GCodeLine &line) { + if (line.cmd_is("G1")) ++n; + }); + return n; +} + +SCENARIO("Axis remap: no lift is commanded through the uninitialised origin", "[GCodeWriter][remap]") +{ + // Reverse X: machine X = build_vol_max.x - logical X, so the uninitialised + // origin maps to the far edge of the bed and is unmistakable in the output. + const double bed_x = 250.0; + + GIVEN("a writer with a reverse-X remap and an unknown current position") { + GCodeWriter writer; + configure_lift_writer(writer); + writer.set_axis_remap(6, 1, 2); + writer.set_build_volume_max(Vec3d(bed_x, 250.0, 250.0)); + REQUIRE(writer.kinematics().must_emit_all_axes()); + REQUIRE_FALSE(writer.is_current_position_clear()); + + WHEN("a z-hop is pending and we travel to the first point") { + writer.lazy_lift(LiftType::NormalLift); + const std::string gcode = writer.travel_to_xyz(Vec3d(10.0, 10.0, 5.0)); + + THEN("nothing is commanded at the image of the origin") { + // The destination maps to machine X = 250 - 10 = 240; the bogus + // origin lift would have mapped to machine X = 250. + REQUIRE(max_emitted_x(gcode) < bed_x - 1.0); + } + THEN("only the destination move is emitted") { + REQUIRE(count_g1(gcode) == 1); + } + } + } + + GIVEN("the same writer once its position is known") { + GCodeWriter writer; + configure_lift_writer(writer); + writer.set_axis_remap(6, 1, 2); + writer.set_build_volume_max(Vec3d(bed_x, 250.0, 250.0)); + writer.travel_to_xyz(Vec3d(20.0, 20.0, 5.0)); + REQUIRE(writer.is_current_position_clear()); + + WHEN("a z-hop is pending and we travel again") { + writer.lazy_lift(LiftType::NormalLift); + const std::string gcode = writer.travel_to_xyz(Vec3d(30.0, 30.0, 5.0)); + + THEN("the separate lift move is still emitted") { + // Suppression must be pinned to the unknown position, not to the + // presence of a remap. + REQUIRE(count_g1(gcode) == 2); + } + } + } + + GIVEN("an identity-mapping writer with an unknown position") { + GCodeWriter writer; + configure_lift_writer(writer); + REQUIRE_FALSE(writer.kinematics().must_emit_all_axes()); + REQUIRE_FALSE(writer.is_current_position_clear()); + + WHEN("a z-hop is pending and we travel to the first point") { + writer.lazy_lift(LiftType::NormalLift); + const std::string gcode = writer.travel_to_xyz(Vec3d(10.0, 10.0, 5.0)); + + THEN("behaviour is unchanged: the lift is still emitted") { + // Three moves, not two: with no remap and an unknown position the + // destination is emitted as a separate XY move followed by its own + // Z move, on top of the lift. That split is the pre-existing + // identity-mapping path and must not change. + REQUIRE(count_g1(gcode) == 3); + } + } + } +} + +SCENARIO("Axis remap: eager_lift does not lift, or record a lift, at an unknown position", + "[GCodeWriter][remap]") +{ + GIVEN("a writer with a reverse-X remap and an unknown current position") { + GCodeWriter writer; + configure_lift_writer(writer); + writer.set_axis_remap(6, 1, 2); + writer.set_build_volume_max(Vec3d(250.0, 250.0, 250.0)); + REQUIRE_FALSE(writer.is_current_position_clear()); + + WHEN("an eager lift is requested") { + const std::string lift = writer.eager_lift(LiftType::NormalLift); + + THEN("no move is emitted") { + REQUIRE(lift.empty()); + } + THEN("no lift is recorded, so unlift does not descend from it") { + // If m_lifted had been set while nothing was commanded, unlift() + // would emit a descent from a height the machine never reached. + REQUIRE(writer.unlift().empty()); + } + } + } + + GIVEN("an identity-mapping writer with an unknown position") { + GCodeWriter writer; + configure_lift_writer(writer); + + WHEN("an eager lift is requested") { + const std::string lift = writer.eager_lift(LiftType::NormalLift); + + THEN("behaviour is unchanged: the lift is emitted and can be undone") { + REQUIRE_FALSE(lift.empty()); + REQUIRE_FALSE(writer.unlift().empty()); + } + } + } +} + +// Bug 2. extrude_arc_to_xy() emits G2/G3 with logical X/Y and I/J and never +// consulted the mapping. An arc is only representable when logical X and Y reach +// the machine unchanged -- which is a narrower question than "is the remap the +// identity", because a mapping that only touches Z leaves every emitted word alone. +SCENARIO("Arc support is decided by whether the mapping leaves X and Y alone", "[GCodeWriter][remap]") +{ + GIVEN("a Cartesian writer") { + GCodeWriter writer; + + THEN("the identity mapping supports arcs") { + REQUIRE(writer.kinematics().supports_arc_moves()); + } + THEN("a Z-only negation still supports arcs") { + // (+X, +Y, -Z): non-identity, but X, Y, I and J are all untouched. + writer.set_axis_remap(0, 1, 5); + REQUIRE(writer.kinematics().must_emit_all_axes()); + REQUIRE(writer.kinematics().supports_arc_moves()); + } + THEN("a Z-only reversal still supports arcs") { + writer.set_axis_remap(0, 1, 8); + REQUIRE(writer.kinematics().supports_arc_moves()); + } + THEN("swapping X and Y does not support arcs") { + writer.set_axis_remap(1, 0, 2); + REQUIRE_FALSE(writer.kinematics().supports_arc_moves()); + } + THEN("the X-tilt style (x, z, y) remap does not support arcs") { + writer.set_axis_remap(0, 2, 1); + REQUIRE_FALSE(writer.kinematics().supports_arc_moves()); + } + } + + GIVEN("a belt writer") { + PrintConfig belt_config; + belt_config.belt_printer.value = true; + belt_config.belt_slice_rotation.value = BeltRotationAxis::X; + belt_config.belt_slice_rotation_angle.value = 45.0; + + GCodeWriter writer; + install_belt_kinematics(writer, belt_config); + + THEN("arcs are never supported, because the frame shears") { + REQUIRE_FALSE(writer.kinematics().supports_arc_moves()); + } + } +} + +SCENARIO("An unrepresentable arc degrades to its chord rather than emitting a wrong G2/G3", + "[GCodeWriter][remap]") +{ + auto emitted_commands = [](const std::string &gcode) { + std::vector cmds; + GCodeReader reader; + reader.parse_buffer(gcode, [&cmds](GCodeReader &, const GCodeReader::GCodeLine &line) { + if (! line.cmd().empty()) cmds.emplace_back(line.cmd()); + }); + return cmds; + }; + + GIVEN("an identity-mapping writer") { + GCodeWriter writer; + configure_lift_writer(writer); + + WHEN("an arc is extruded") { + const std::string gcode = writer.extrude_arc_to_xy( + Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), 0.0, /*is_ccw=*/true, "", /*force_no_extrusion=*/true); + + THEN("it is still a G3") { + const auto cmds = emitted_commands(gcode); + REQUIRE(cmds.size() == 1); + REQUIRE(cmds.front() == "G3"); + } + } + } + + GIVEN("a writer whose mapping swaps X and Y") { + GCodeWriter writer; + configure_lift_writer(writer); + writer.set_axis_remap(1, 0, 2); + + WHEN("an arc is extruded") { + const std::string gcode = writer.extrude_arc_to_xy( + Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), 0.0, /*is_ccw=*/true, "", /*force_no_extrusion=*/true); + + THEN("no arc is emitted; it is approximated with linear moves") { + const auto cmds = emitted_commands(gcode); + REQUIRE(! cmds.empty()); + for (const auto &c : cmds) + REQUIRE(c == "G1"); + } + } + } + + // The first version of this test used dE = 0 with force_no_extrusion, which + // hid a real bug: the capability check sat AFTER filament()->extrude(dE), so + // the fallback into extrude_to_xy() advanced E twice. Extrusion accounting has + // to be asserted with a positive dE. + GIVEN("a writer whose mapping cannot express arcs, extruding a real amount") { + GCodeWriter writer; + configure_lift_writer(writer); + writer.set_axis_remap(1, 0, 2); + const double dE = 1.5; + // used_filament() accumulates across moves; E() is reset per line in + // relative-E mode, so it would only show the last segment. + const double used_before = writer.filament()->used_filament(); + + WHEN("an arc carrying that extrusion is emitted") { + const std::string gcode = writer.extrude_arc_to_xy( + Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), dE, /*is_ccw=*/true, "", /*force_no_extrusion=*/false); + + THEN("exactly dE is accounted for, not twice dE") { + REQUIRE_THAT(writer.filament()->used_filament() - used_before, + Catch::Matchers::WithinAbs(dE, 1e-6)); + } + THEN("no G2/G3 survives") { + REQUIRE(gcode.find("G2") == std::string::npos); + REQUIRE(gcode.find("G3") == std::string::npos); + } + } + } + + GIVEN("a writer whose mapping CAN express arcs, extruding a real amount") { + GCodeWriter writer; + configure_lift_writer(writer); + const double dE = 1.5; + // used_filament() accumulates across moves; E() is reset per line in + // relative-E mode, so it would only show the last segment. + const double used_before = writer.filament()->used_filament(); + + WHEN("an arc carrying that extrusion is emitted") { + const std::string gcode = writer.extrude_arc_to_xy( + Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), dE, /*is_ccw=*/true, "", /*force_no_extrusion=*/false); + + THEN("it is still a single arc and accounts for dE once") { + REQUIRE(emitted_commands(gcode).size() == 1); + REQUIRE_THAT(writer.filament()->used_filament() - used_before, + Catch::Matchers::WithinAbs(dE, 1e-6)); + } + } + } +} + TEST_CASE("Percent accelerations resolve against the option they are a percentage of", "[GCodeWriter]") { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); diff --git a/tests/fff_print/test_helpers.cpp b/tests/fff_print/test_helpers.cpp index 61c476fdb9..c7302d4c4b 100644 --- a/tests/fff_print/test_helpers.cpp +++ b/tests/fff_print/test_helpers.cpp @@ -470,7 +470,10 @@ int role_passes(const std::string &gcode, const std::string &role) bool in_role = false; GCodeReader reader; reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { - if (! line.extruding(self)) return; + // E-only unretraction moves have positive E but do not lay down material. Ignoring + // them keeps a role pass contiguous across travel/retraction bookkeeping. + if (! line.extruding(self) || (line.dist_XY(self) <= EPSILON && std::abs(line.dist_Z(self)) <= EPSILON)) + return; const bool is_role = line.comment().find(role) != std::string_view::npos; if (is_role && ! in_role) ++passes; in_role = is_role; diff --git a/tests/fff_print/test_precise_seam.cpp b/tests/fff_print/test_precise_seam.cpp index 170c867b95..b5cb913904 100644 --- a/tests/fff_print/test_precise_seam.cpp +++ b/tests/fff_print/test_precise_seam.cpp @@ -23,9 +23,11 @@ #include "libslic3r/GCode/SeamPlacer.hpp" #include "libslic3r/GCodeReader.hpp" #include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/ExPolygon.hpp" #include #include #include +#include using namespace Slic3r; @@ -1427,3 +1429,44 @@ TEST_CASE("Full containment warns for Blocked but not for Enforced", "[PreciseSe else CHECK(fixture.warning().empty()); } + +TEST_CASE("Belt printers slice Precise Seam modifiers in the frame the object was sliced in", "[PreciseSeam][belt]") +{ + Model model; + Print print; + Test::init_print({Test::cube(20)}, print, model, { + { "belt_printer", 1 }, + { "belt_slice_rotation", "x" }, + { "belt_slice_rotation_angle", 45 }, + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "skirt_loops", 0 }, + // Keep the first layer's islands the raw slice, like the modifier's. + { "elefant_foot_compensation", 0 }, + }); + print.process(); + const PrintObject *object = print.get_object(0); + REQUIRE(object->layer_count() > 0); + + // A modifier with the object's own mesh and placement must slice to the object's own islands + // on every layer. Sliced without the belt rotation it would give 20 mm squares on the lower + // layers and nothing above 20 mm, while the tilted cube reaches about 28 mm. + Model modifiers; + ModelVolume *modifier = modifiers.add_object()->add_volume(*model.objects.front()->volumes.front()); + modifier->set_type(ModelVolumeType::PRECISE_SEAM_BLOCKED); + const std::vector slices = object->slice_single_volume_regions(modifier); + REQUIRE(slices.size() == object->layer_count()); + + const double tolerance = scale_(0.05) * scale_(20.); + for (size_t i = 0; i < slices.size(); ++i) { + CAPTURE(i, object->get_layer(int(i))->slice_z); + const ExPolygons &islands = object->get_layer(int(i))->lslices; + double object_area = 0., modifier_area = 0.; + for (const ExPolygon &island : islands) + object_area += island.area(); + for (const ExPolygon ®ion : slices[i]) + modifier_area += region.area(); + CHECK(std::abs(modifier_area - object_area) < tolerance); + CHECK(get_extents(slices[i]).inflated(scale_(0.05)).contains(get_extents(islands))); + } +} diff --git a/tests/fff_print/test_print.cpp b/tests/fff_print/test_print.cpp index 40f6343d95..58f8d12e39 100644 --- a/tests/fff_print/test_print.cpp +++ b/tests/fff_print/test_print.cpp @@ -14,6 +14,7 @@ #include #include #include "libslic3r/PrintConfig.hpp" +#include "libslic3r/libslic3r.h" #include #include "libslic3r/Surface.hpp" #include "libslic3r/Config.hpp" @@ -26,6 +27,17 @@ #include "libslic3r/Print.hpp" #include "libslic3r/Layer.hpp" +#include "libslic3r/BuildVolume.hpp" +#include "libslic3r/Support/TreeModelVolumes.hpp" +#include "libslic3r/Support/TreeSupportCommon.hpp" +#include "libslic3r/Support/BeltFloorContext.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/ExtrusionEntity.hpp" +#include "libslic3r/Polyline.hpp" +#include +#include +#include +#include "libslic3r/Polygon.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/GCodeReader.hpp" #include "libslic3r/GCode/GCodeProcessor.hpp" @@ -36,6 +48,10 @@ #include "test_utils.hpp" #include +#include +#include +#include +#include #include #include #include @@ -451,6 +467,88 @@ TEST_CASE("Print::validate tolerates a null warnings pointer", "[Print][validate CHECK(err.string.empty()); } +TEST_CASE("Purge tower selection keeps ordinary printers on the classic path", "[Print][PurgeTower][Regression]") +{ + DynamicPrintConfig config = multifilament_config(2, { + { "belt_printer", 0 }, + { "enable_prime_tower", 1 }, + { "enable_belt_purge_tower", 1 } + }); + config.set_key_value("timelapse_type", new ConfigOptionEnum(TimelapseType::tlSmooth)); + + Model model; + Print print; + build_cubes(model, print, config, /*n=*/1, /*overlap=*/false); + + CHECK(print.has_wipe_tower()); + CHECK_FALSE(print.has_belt_purge_tower()); +} + +TEST_CASE("Belt purge planning requires its managed purge object", "[Print][PurgeTower][Regression]") +{ + DynamicPrintConfig config = multifilament_config(2, { + { "belt_printer", 1 }, + { "enable_belt_purge_tower", 1 } + }); + + Model model; + Print print; + build_cubes(model, print, config, /*n=*/1, /*overlap=*/false); + CHECK_FALSE(print.has_belt_purge_tower()); + + model.objects.front()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true)); + print.apply(model, config); + CHECK(print.has_belt_purge_tower()); + CHECK_FALSE(print.has_wipe_tower()); +} + +// The GUI creates the purge tower object; a project sliced without one (the CLI) must say +// that its filament changes go unpurged. +TEST_CASE("Belt purge tower enabled without a tower object warns", "[Print][PurgeTower][belt]") +{ + DynamicPrintConfig config = multifilament_config(2, { + { "belt_printer", 1 }, + { "enable_belt_purge_tower", 1 }, + { "layer_change_gcode", "G92 E0\n" } + }); + auto purge_warnings = [](Print &print) { + std::vector warnings; + print.validate(&warnings); + return std::count_if(warnings.begin(), warnings.end(), [](const StringObjectException &w) { + return w.opt_key == "enable_belt_purge_tower"; + }); + }; + + Model model; + Print print; + build_cubes(model, print, config, /*n=*/2, /*overlap=*/false); + model.objects[1]->config.set_key_value("extruder", new ConfigOptionInt(2)); + print.apply(model, config); + REQUIRE(print.extruders().size() > 1); + CHECK(purge_warnings(print) == 1); + + model.objects.front()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true)); + print.apply(model, config); + CHECK(purge_warnings(print) == 0); +} + +TEST_CASE("Belt purge rejects multiple managed purge objects", "[Print][PurgeTower][Regression]") +{ + DynamicPrintConfig config = multifilament_config(2, { + { "belt_printer", 1 }, + { "enable_belt_purge_tower", 1 } + }); + + Model model; + Print print; + build_cubes(model, print, config, /*n=*/2, /*overlap=*/false); + for (ModelObject *object : model.objects) + object->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true)); + print.apply(model, config); + + CHECK_FALSE(print.validate().string.empty()); +} + TEST_CASE("A default slice emits perimeter, infill, and skirt", "[Print]") { const std::string gcode = slice({ cube(20) }, { @@ -661,6 +759,199 @@ TEST_CASE("Sequential printing publishes the nozzle group result", "[Print][Mult } } +// A scarf joint starts one layer height below the layer and ramps up along the +// wall. On a tilted belt that start is a step backwards along the belt axis, into +// the previous layer's wall at the seam: 0.283 mm per 0.2 mm layer at 45 degrees. +// With an aligned seam the nozzle rams the same spot on every layer (field report +// from a BabyBelt Pro: the belt "jumped backwards" and knocked the part loose). +// Belt printers therefore never get a scarf, whatever the process preset says. +TEST_CASE("Belt printers never start a scarf seam below the layer", "[Print][belt][Seam]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "belt_printer", 1 }, + { "belt_slice_rotation", "x" }, + { "belt_slice_rotation_angle", 45 }, + { "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 }, + { "wall_loops", 2 }, + { "seam_position", "back" }, + { "seam_slope_type", "external" }, + { "seam_slope_inner_walls", 1 }, + { "seam_slope_start_height", 0 }, + // No z-hop: on a belt a lift is a move along the belt axis (0.4 mm / sin 45 = 0.57 mm) + // and its return would read as a back-step. The shipped belt profiles print without one. + { "z_hop", 0 }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + const std::string gcode = slice({ cube(20) }, config); + REQUIRE(! gcode.empty()); + + // The belt axis is machine Z. Within a layer it only drifts by the frame + // coupling (well under 0.1 mm across a 20 mm cube); a scarf start is a full + // layer pitch (0.283 mm) backwards. + double last_z = std::numeric_limits::lowest(); + double worst_backstep = 0.; + GCodeReader parser; + parser.parse_buffer(gcode, [&](GCodeReader &, const GCodeReader::GCodeLine &line) { + if (! line.cmd_is("G1") || ! line.has_z()) + return; + const double z = line.z(); + if (last_z != std::numeric_limits::lowest()) + worst_backstep = std::max(worst_backstep, last_z - z); + last_z = z; + }); + CHECK(worst_backstep < 0.2); +} + +// printable_height on a belt printer is the clearance under the gantry, so an object taller +// than that is refused whatever the machine-frame transform does to the emitted coordinates. +TEST_CASE("Belt printers refuse an object taller than the gantry clearance", "[Print][belt]") +{ + auto belt_config = [](double printable_height) { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "belt_printer", 1 }, + { "belt_slice_rotation", "x" }, + { "belt_slice_rotation_angle", 45 }, + { "gcode_remap_x", "rev_x" }, + { "gcode_remap_y", "pos_z" }, + { "gcode_remap_z", "pos_y" }, + { "printable_height", printable_height }, + { "skirt_loops", 0 }, + { "layer_change_gcode", "G92 E0\n" }, + }); + return config; + }; + + SECTION("a 20 mm cube fits under 50 mm of clearance") { + Print print; + Model model; + init_print({ cube(20) }, print, model, belt_config(50)); + CHECK(print.validate().string.empty()); + } + SECTION("a 60 mm cube does not") { + Print print; + Model model; + init_print({ cube(60) }, print, model, belt_config(50)); + CHECK(print.validate().string.find("height") != std::string::npos); + } +} + +// On a belt every tilted layer starts on the belt, so "the first layers" the fan stays off +// for are a band along the belt, not the first slicing layers. The generator marks where +// each extrusion segment enters and leaves that band and the cooling buffer keeps the fan +// off inside it, on every layer. +TEST_CASE("Belt printers keep the part fan off within the band above the belt", "[Print][belt][Cooling]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "belt_printer", 1 }, + { "belt_slice_rotation", "x" }, + { "belt_slice_rotation_angle", 45 }, + { "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 }, + { "z_hop", 0 }, + // Three layers, 0.6 mm: the lowest wall of each tilted layer is centred about 0.3 mm + // above the belt (half a line width in from the contact edge). + { "close_fan_the_first_x_layers", 3 }, + { "full_fan_speed_layer", 0 }, + { "fan_min_speed", 100 }, + { "fan_max_speed", 100 }, + { "slow_down_layer_time", 1000 }, + { "fan_cooling_layer_time", 1001 }, + { "reduce_fan_stop_start_freq", 0 }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + const std::string gcode = slice({ cube(20) }, config); + REQUIRE(! gcode.empty()); + + // The markers are consumed by the cooling buffer and never reach the file. + CHECK(gcode.find(";_BELT_BAND") == std::string::npos); + + // With this axis mapping machine Y is the height above the belt along the gantry. Walk + // the moves with the fan state: extrusions that stay within 0.45 mm of the belt are well + // inside the band and must print with the fan off; extrusions that stay 5 mm clear of it + // must print with it on. The first three slicing layers have the fan off altogether. + size_t in_band = 0, in_band_fan_on = 0, clear = 0, clear_fan_off = 0; + int layer = -1; + bool fan_on = false; + double y = 0.; + std::istringstream lines(gcode); + for (std::string line; std::getline(lines, line); ) { + if (boost::starts_with(line, ";LAYER_CHANGE")) { + ++ layer; + } else if (boost::starts_with(line, "M107")) { + fan_on = false; + } else if (boost::starts_with(line, "M106")) { + const size_t s = line.find('S'); + fan_on = s != std::string::npos && std::atof(line.c_str() + s + 1) > 0.; + } else if (boost::starts_with(line, "G1 ")) { + const size_t comment = line.find(';'); + const std::string cmd = line.substr(0, comment); + const size_t ypos = cmd.find(" Y"), epos = cmd.find(" E"); + if (ypos == std::string::npos) + continue; + const double y_new = std::atof(cmd.c_str() + ypos + 2); + const bool extruding = epos != std::string::npos && std::atof(cmd.c_str() + epos + 2) > 0.; + if (extruding && layer >= 3) { + if (std::max(y, y_new) < 0.45) { + ++ in_band; + in_band_fan_on += fan_on; + } else if (std::min(y, y_new) > 5.) { + ++ clear; + clear_fan_off += ! fan_on; + } + } + y = y_new; + } + } + CHECK(in_band > 20); + CHECK(in_band_fan_on == 0); + CHECK(clear > 20); + CHECK(clear_fan_off == 0); +} + +// Organic supports under an overhang on a belt printer reach below the object's first layer, +// where the virtual belt raft layers sit at negative Z. The lowest of them used to get a +// negative height and abort slicing with a negative flow error. +TEST_CASE("Belt printers slice organic tree supports that reach the belt", "[Print][belt][Support]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "belt_printer", 1 }, + { "belt_slice_rotation", "x" }, + { "belt_slice_rotation_angle", 45 }, + { "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 }, + { "z_hop", 0 }, + { "enable_support", 1 }, + { "support_type", "tree(auto)" }, + { "support_style", "organic" }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + std::string gcode; + REQUIRE_NOTHROW(gcode = slice({ TestMesh::overhang }, config)); + CHECK(! gcode.empty()); +} + TEST_CASE("Slicing errors are reported per object with the object's name", "[Print]") { Print print; @@ -686,3 +977,546 @@ TEST_CASE("Slicing errors are reported per object with the object's name", "[Pri CHECK(message.rfind("floating cube: ", 0) == 0); CHECK(message.find("empty first layer") != std::string::npos); } + +// --------------------------------------------------------------------------- +// Belt mode must be invisible when it is off, and must not leave traces behind. +// --------------------------------------------------------------------------- + +// Everything the slicer decided, without the lines that legitimately differ between +// two exports of the same print: comments (the config block lists every key, the +// header carries the export time) and the thumbnail blocks. +static std::string gcode_body(const std::string &gcode) +{ + std::string body; + std::istringstream in(gcode); + for (std::string line; std::getline(in, line); ) { + line.erase(std::min(line.size(), line.find(';'))); + while (! line.empty() && line.back() == ' ') + line.pop_back(); + if (! line.empty()) + body += line + '\n'; + } + return body; +} + +static DynamicPrintConfig belt_test_config() +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "belt_printer", 1 }, + { "belt_slice_rotation", "x" }, + { "belt_slice_rotation_angle", 45 }, + { "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 }, + { "z_hop", 0 }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + return config; +} + +TEST_CASE("Belt-only keys at non-default values leave non-belt G-code unchanged", "[Print][belt][Regression]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "z_hop", 0 }, + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "enable_support", 1 }, + { "support_type", "tree(auto)" }, + { "support_style", "organic" }, + { "sparse_infill_pattern", "adaptivecubic" }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + const std::string reference = gcode_body(slice({ TestMesh::overhang }, config)); + REQUIRE(! reference.empty()); + + // Every belt key a profile can carry, at a value that would change a belt print. + // belt_printer stays off, so none of them may reach the G-code: the axis remaps are + // gated on belt mode, the rest is only read on belt printers. build_plate_tilt_x/y + // is a feature of its own on a flat bed and is left alone here; "leading_edge_only" + // prints as an outer brim by design. + config.set_deserialize_strict({ + { "belt_printer", 0 }, + { "belt_printer_infinite_y", 0 }, + { "belt_slice_rotation", "y" }, + { "belt_slice_rotation_angle", 30 }, + { "gcode_remap_x", "rev_x" }, + { "gcode_remap_y", "pos_z" }, + { "gcode_remap_z", "pos_y" }, + { "belt_frame_tilt_decouple", 1 }, + { "belt_frame_tilt_angle", 30 }, + { "belt_support_floor_offset", -5 }, + { "enable_belt_purge_tower", 1 }, + { "belt_purge_tower_width", 10 }, + { "leading_brim_length", 10 }, + { "extra_brim_width", 5 }, + }); + CHECK(gcode_body(slice({ TestMesh::overhang }, config)) == reference); +} + +TEST_CASE("Switching a sliced project from belt to non-belt matches a fresh slice", "[Print][belt][Regression]") +{ + // The organic support layers and the adaptive infill octree are placed with the + // belt global Z offset, and the mesh with the belt min-Z lift. Both are only + // written while belt mode slices, so they used to survive a switch away from it. + DynamicPrintConfig flat = DynamicPrintConfig::full_print_config(); + flat.set_deserialize_strict({ + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "skirt_loops", 0 }, + { "z_hop", 0 }, + { "enable_support", 1 }, + { "support_type", "tree(auto)" }, + { "support_style", "organic" }, + { "sparse_infill_pattern", "adaptivecubic" }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + DynamicPrintConfig belt = belt_test_config(); + belt.set_deserialize_strict({ + { "enable_support", 1 }, + { "support_type", "tree(auto)" }, + { "support_style", "organic" }, + { "sparse_infill_pattern", "adaptivecubic" }, + }); + + // Both prints are placed with the belt config, so only the slicing history differs. + auto fresh_slice = [&](const DynamicPrintConfig &target) { + Print print; + Model model; + init_print({ TestMesh::overhang }, print, model, belt); + print.apply(model, target); + const std::string out = gcode(print); + return gcode_body(out); + }; + auto resliced = [&](const DynamicPrintConfig &target) { + Print print; + Model model; + init_print({ TestMesh::overhang }, print, model, belt); + REQUIRE(! gcode(print).empty()); + print.apply(model, target); + const std::string out = gcode(print); + return gcode_body(out); + }; + SECTION("belt printer to a flat-bed printer") { + CHECK(resliced(flat) == fresh_slice(flat)); + } + SECTION("belt tilt axis set to None") { + DynamicPrintConfig untilted = belt; + untilted.set_deserialize_strict({ { "belt_slice_rotation", "none" } }); + CHECK(resliced(untilted) == fresh_slice(untilted)); + } +} + +TEST_CASE("A support-only change on a belt purge print matches a fresh slice", "[Print][belt][PurgeTower][Regression]") +{ + // Snapping the purge prism onto the parts' layer grid shifts every object's layers by + // up to half a layer. A support-only change reruns support generation without + // reslicing, so the cached belt floor and the global Z offset have to carry the + // snap too, or the supports land on the pre-snap grid. + auto make_config = [](bool support) { + DynamicPrintConfig config = multifilament_config(2, { + { "belt_printer", 1 }, + { "belt_slice_rotation", "x" }, + { "belt_slice_rotation_angle", 45 }, + { "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 }, + { "z_hop", 0 }, + { "enable_belt_purge_tower", 1 }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + config.set_deserialize_strict({ + { "enable_support", support ? 1 : 0 }, + { "support_type", "tree(auto)" }, + { "support_style", "organic" }, + }); + return config; + }; + const std::vector> overrides { + { { "extruder", 1 } }, { { "extruder", 2 } }, + }; + auto build = [&](Print &print, Model &model, const DynamicPrintConfig &config) { + init_print(std::vector{ mesh(TestMesh::overhang), cube(20) }, print, model, config, &overrides); + model.objects.back()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true)); + print.apply(model, config); + REQUIRE(print.has_belt_purge_tower()); + }; + + std::string fresh; + { + Print print; + Model model; + build(print, model, make_config(true)); + fresh = gcode_body(gcode(print)); + } + REQUIRE(! fresh.empty()); + + Print print; + Model model; + build(print, model, make_config(false)); + REQUIRE(! gcode(print).empty()); + // Support only: posSlice stays valid, posSupportMaterial reruns. + print.apply(model, make_config(true)); + CHECK(gcode_body(gcode(print)) == fresh); +} + +TEST_CASE("Organic tree supports place a support blocker at its own height above a raft", "[Print][Support][Regression]") +{ + // TreeModelVolumes consumes the support blockers in the same index space as the + // layer outlines, where object layer i sits at num_raft_layers + i, but + // slice_support_blockers() returns them in object-layer space. Without the shift + // every blocker lands num_raft_layers too low, so branches are kept out of the + // wrong layers and may pass through the blocked ones. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "skirt_loops", 0 }, + { "enable_support", 1 }, + { "support_type", "tree(auto)" }, + { "support_style", "organic" }, + { "raft_layers", 3 }, + }); + Print print; + Model model; + init_print({ cube(20) }, print, model, config); + // A blocker floating beside the cube, 8 mm to 12 mm above the bed, so a collision at + // its centre can only come from the blocker itself (the part keeps its mesh + // coordinates in object space, hence the offset relative to the part). + ModelObject *object = model.objects.front(); + ModelVolume *blocker = object->add_volume(TriangleMesh(its_make_cube(6., 6., 4.))); + blocker->set_type(ModelVolumeType::SUPPORT_BLOCKER); + const Vec3d part_offset = object->volumes.front()->get_offset(); + blocker->set_offset(Vec3d(part_offset.x() + 20., part_offset.y(), 10.)); + print.apply(model, config); + print.set_status_silent(); + print.process(); + + const PrintObject &print_object = *print.objects().front(); + const std::vector bed = { { 0., 0. }, { 200., 0. }, { 200., 200. }, { 0., 200. } }; + const BuildVolume build_volume{ bed, print.config().printable_height.value, {}, {} }; + TreeSupport3D::TreeModelVolumes volumes{ print_object, build_volume, scaled(1.), scaled(0.5), 0, {} }; + + // The generator's raft layer count: the raft itself plus the gap layers up to the object. + const size_t num_raft = TreeSupport3D::TreeSupportSettings(TreeSupport3D::TreeSupportMeshGroupSettings(print_object), + print_object.slicing_parameters()).raft_layers.size(); + REQUIRE(num_raft >= 3); + // Object layers the blocker was sliced into (object-layer space, as the generator + // receives them). + const std::vector blockers = print_object.slice_support_blockers(); + size_t first = 0, last = 0; + bool found = false; + for (size_t i = 0; i < blockers.size(); ++ i) + if (! blockers[i].empty()) { + if (! found) { first = i; found = true; } + last = i; + } + REQUIRE(found); + REQUIRE(last - first > num_raft); + // The blocker's centre in the slicing frame (add_volume centred its mesh on its offset). + const Vec3d centre3 = print_object.trafo_sliced() * blocker->get_offset(); + const Point centre = Point::new_scale(centre3.x(), centre3.y()); + auto collides = [&](size_t tree_layer) { + for (const Slic3r::Polygon &poly : volumes.getCollision(0, TreeSupport3D::LayerIndex(tree_layer), false)) + if (poly.contains(centre)) + return true; + return false; + }; + // In TreeModelVolumes' index space the blocker lives at num_raft + object layer. + CHECK(collides(num_raft + first)); + CHECK(collides(num_raft + last)); + // The layers just below it, where an unshifted blocker would land, are free; the + // layers just above the unshifted range, which the blocker does occupy, are not. + CHECK_FALSE(collides(first)); + CHECK_FALSE(collides(first + num_raft - 1)); + CHECK(collides(last + 1)); + CHECK(collides(last + num_raft)); +} + +// organic_draw_branches() trims every branch slice against the collision volume (the +// part grown by the support XY distance), the bed and, on a belt, the belt plane before +// it becomes support, so a branch never runs into the part it supports. Not a belt +// feature: this is the generator every printer uses. +TEST_CASE("Organic tree supports keep their distance from the part", "[Print][Support]") +{ + // A 20 mm cube carrying a 60 x 60 mm plate: a 20 mm wide ceiling all around the + // cube, 16 mm above the bed, with the cube's four corners in the way of the branches + // that drop from it. The plate reaches into the cube so the two shells overlap + // instead of sharing a face. + indexed_triangle_set its = its_make_cube(20., 20., 20.); + indexed_triangle_set plate = its_make_cube(60., 60., 4.); + its_translate(its, Vec3f(20.f, 20.f, 0.f)); + its_translate(plate, Vec3f(0.f, 0.f, 16.f)); + its_merge(its, plate); + TriangleMesh mesh(std::move(its)); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "skirt_loops", 0 }, + { "enable_support", 1 }, + { "support_type", "tree(auto)" }, + { "support_style", "organic" }, + { "support_threshold_angle", 30 }, + }); + Print print; + Model model; + init_print({ mesh }, print, model, config); + // On the bed, not at its corner (the fixture leaves the object at the origin). + model.objects.front()->instances.front()->set_offset(Vec3d(100., 100., 0.)); + print.apply(model, config); + print.set_status_silent(); + print.process(); + + const PrintObject &object = *print.objects().front(); + INFO("object layers " << object.layers().size() << ", support layers " << object.support_layers().size()); + REQUIRE(! object.support_layers().empty()); + // Support exists under the plate at all. + size_t support_layers_with_fills = 0; + for (const SupportLayer *layer : object.support_layers()) + if (! layer->support_fills.empty()) + ++ support_layers_with_fills; + INFO("support layers with extrusions " << support_layers_with_fills); + CHECK(support_layers_with_fills > 20); + + // Object layers by print_z, to look up the part's slice at a support layer's height. + std::map object_layers; + for (const Layer *layer : object.layers()) + object_layers[scaled(layer->print_z)] = layer; + auto contains = [](const ExPolygons &expolys, const Point &pt) { + for (const ExPolygon &ex : expolys) + if (ex.contains(pt)) + return true; + return false; + }; + // No support extrusion may run closer to the part's slice than half a line width: + // the generator keeps the support XY distance (0.35 mm by default) plus the line's + // own half width away from it. + const float min_gap = scaled(0.2); + size_t too_close = 0, points = 0, layers_checked = 0, layers_unmatched = 0; + for (const SupportLayer *layer : object.support_layers()) { + if (layer->support_fills.empty()) + continue; + // The object layer whose slab spans this support layer's height. + auto it = object_layers.lower_bound(scaled(layer->print_z - EPSILON)); + if (it == object_layers.end()) { + ++ layers_unmatched; + continue; + } + ++ layers_checked; + const ExPolygons grown = offset_ex(it->second->lslices, min_gap); + for (const ExtrusionEntity *entity : layer->support_fills.flatten().entities) + for (const Slic3r::Polyline &pl : entity->as_polylines()) + for (size_t i = 0; i < pl.points.size(); ++ i) { + // The vertices and the midpoints of the segments between them. + ++ points; + if (contains(grown, pl.points[i])) + ++ too_close; + if (i + 1 < pl.points.size() && contains(grown, (pl.points[i] + pl.points[i + 1]) / 2)) + ++ too_close; + } + } + INFO("support layers checked " << layers_checked << " (unmatched " << layers_unmatched << "), support points " << points + << ", within 0.2 mm of the part " << too_close); + CHECK(layers_checked > 20); + CHECK(layers_unmatched == 0); + REQUIRE(points > 0); + CHECK(too_close == 0); +} + +// Two parts along the belt: the second part's slicing frame starts at the belt +// below its leading end, so its first layers are empty and interleave with the +// first part's printing layers. Those must not reach the G-code as layer changes +// that print nothing: the preview numbers its layers from the moves it sees, and +// a gap folded every later layer into the one before it. +TEST_CASE("Belt G-code has no layer that prints nothing", "[Print][belt][GCode][Regression]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "belt_printer", 1 }, + { "belt_slice_rotation", "x" }, + { "belt_slice_rotation_angle", 45 }, + { "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 }, + { "z_hop", 0 }, + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + // Both export paths drop the empty layers and count the layers the same way. + SECTION("by layer") { config.set_deserialize_strict({{ "print_sequence", "by layer" }}); } + SECTION("by object") { config.set_deserialize_strict({{ "print_sequence", "by object" }}); } + Print print; + Model model; + TriangleMesh cube_a(its_make_cube(20., 20., 20.)); + TriangleMesh cube_b(its_make_cube(20., 20., 20.)); + init_print({ cube_a, cube_b }, print, model, config); + // 60 mm apart along the belt: the second cube's lead-in layers fall among the + // first cube's layers. + model.objects[0]->instances.front()->set_offset(Vec3d(50., 40., 0.)); + model.objects[1]->instances.front()->set_offset(Vec3d(50., 100., 0.)); + print.apply(model, config); + print.set_status_silent(); + const std::string gc = gcode(print); + REQUIRE(! gc.empty()); + + size_t layers = 0, empty = 0, total_header = 0, total_count = 0; + bool extruded = true; // before the first layer change + auto close_layer = [&]() { if (! extruded) ++ empty; }; + GCodeReader reader; + reader.apply_config(config); + reader.parse_buffer(gc, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + const std::string &raw = line.raw(); + if (raw.rfind(";LAYER_CHANGE", 0) == 0) { + close_layer(); + ++ layers; + extruded = false; + } else if (raw.rfind("; total layer number: ", 0) == 0) { + // Counted by the G-code processor from the layer changes it saw. + total_header = size_t(std::atoi(raw.c_str() + 22)); + } else if (raw.rfind("; total layers count = ", 0) == 0) { + // GCode::m_layer_count, counted up front from the objects' layers; it also + // drives the M73 progress and the total_layer_count placeholder. + total_count = size_t(std::atoi(raw.c_str() + 23)); + } else if (! extruded && line.extruding(self) && line.dist_XY(self) > EPSILON) { + // Material laid down along a move: a wipe or an unretraction does not count. + extruded = true; + } + }); + close_layer(); + INFO("layers " << layers << ", header " << total_header << ", count " << total_count + << ", layers without extrusion " << empty); + CHECK(layers > 150); // both cubes, 141 layers each, overlapping along the belt + CHECK(empty == 0); + CHECK(total_header == layers); + CHECK(total_count == layers); +} + +// A part with an overhang on its LEADING side (the end that prints first) needs +// supports below the object's own lowest slicing layer: the belt under that overhang +// is reached before the object's first contact with it, so the support layers sit at +// a lower slicing Z than any object layer. A generator that stops at the object's +// first layer, or at global Z = 0, leaves those supports floating above the belt. +TEST_CASE("Belt supports reach the belt under a leading overhang", "[Print][belt][Support][Regression]") +{ + // default resolves to organic for tree support; tree_hybrid is the classic tree. + const char *support_type = GENERATE("normal(auto)", "tree(auto)"); + const char *support_style = GENERATE("default", "organic", "tree_hybrid"); + if (std::string(support_type) == "normal(auto)" && std::string(support_style) != "default") + return; // organic and tree_hybrid are tree styles + DYNAMIC_SECTION(support_type << " / " << support_style) { + // A 20 mm cube with a 2 mm thick fin that leaves its top edge and reaches + // 20 mm toward -Y, the end of the part that prints first, climbing at 45 deg + // as it goes (from z = 18 at the cube to z = 38 at the tip). With the layers + // leaning toward -Y at 45 deg the fin's underside is parallel to the layers: + // a ceiling 20 x 28 mm in one layer, with nothing but air between it and the + // belt, which lies up to 41 mm (of slicing Z) below the object's own lowest + // point. Support has to span all of it. + indexed_triangle_set its = its_make_cube(20., 20., 20.); + indexed_triangle_set fin = its_make_cube(20., 20., 2.); + Transform3d shear = Transform3d::Identity(); + shear.matrix() << 1., 0., 0., 0., + 0., 1., 0., -20., + 0., -1., 1., 38., + 0., 0., 0., 1.; + its_transform(fin, shear); + its_merge(its, fin); + TriangleMesh mesh(std::move(its)); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "belt_printer", 1 }, + { "belt_slice_rotation", "x" }, + { "belt_slice_rotation_angle", 45 }, + { "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 }, + { "z_hop", 0 }, + { "enable_support", 1 }, + { "support_type", support_type }, + { "support_style", support_style }, + { "support_threshold_angle", 30 }, + { "machine_start_gcode", "T[initial_tool]\n" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + Print print; + Model model; + init_print({ mesh }, print, model, config); + // On the bed, not at its corner: organic tree support clips its branches to + // the bed outline, and the fixture leaves the object at the origin. + model.objects.front()->instances.front()->set_offset(Vec3d(100., 100., 0.)); + print.apply(model, config); + print.set_status_silent(); + print.process(); + + const PrintObject &object = *print.objects().front(); + REQUIRE(! object.layers().empty()); + // The whole part is sliced: the layers lean at 45 deg, so the part spans + // (y + z) / sqrt(2) of slicing Z, and every layer in that span has geometry. + { + double lo = std::numeric_limits::max(), hi = std::numeric_limits::lowest(); + for (const stl_vertex &v : mesh.its.vertices) { + lo = std::min(lo, v.y() + v.z()); + hi = std::max(hi, v.y() + v.z()); + } + const double span = (hi - lo) / std::sqrt(2.); + size_t nonempty = 0; + for (const Layer *layer : object.layers()) + if (! layer->lslices.empty()) + ++ nonempty; + INFO("non-empty object layers " << nonempty << ", slicing span " << span << " mm"); + CHECK(double(nonempty) * 0.2 > span - 0.6); + } + BeltFloorContext floor; + REQUIRE(floor.init(object.slicing_parameters(), print.config())); + + // The lowest support layer that prints anything, and the belt floor beneath it. + const SupportLayer *lowest = nullptr; + for (const SupportLayer *layer : object.support_layers()) + if (! layer->support_fills.empty() && (lowest == nullptr || layer->print_z < lowest->print_z)) + lowest = layer; + REQUIRE(lowest != nullptr); + double floor_under_lowest = std::numeric_limits::max(); + for (const ExtrusionEntity *entity : lowest->support_fills.flatten().entities) + for (const Slic3r::Polyline &pl : entity->as_polylines()) + for (const Point &pt : pl.points) + floor_under_lowest = std::min(floor_under_lowest, floor.floor_print_z(pt)); + // The object's lowest geometry. The slicing frame starts at the lowest + // belt-floor point under the footprint, so the layers below the leading + // tip of the overhang are empty. + double first_object_z = std::numeric_limits::max(); + for (const Layer *layer : object.layers()) + if (! layer->lslices.empty()) { first_object_z = layer->print_z; break; } + REQUIRE(first_object_z < std::numeric_limits::max()); + INFO("lowest support z " << lowest->print_z << ", floor under it " << floor_under_lowest + << ", first object layer " << first_object_z); + // Well below the object's own lowest layer (the belt under the tip of the fin + // is ~41 mm of slicing Z below the cube's leading edge, which rests on it)... + CHECK(lowest->print_z < first_object_z - 5.); + // ...and resting on the belt: within a few layers of the floor beneath its own lines. + CHECK(lowest->print_z - floor_under_lowest < 4. * 0.2 + EPSILON); + CHECK(lowest->print_z - floor_under_lowest > -0.2 - EPSILON); + } +} diff --git a/tests/fff_print/test_skirt_brim.cpp b/tests/fff_print/test_skirt_brim.cpp index 34a1260b7c..c51dd5ebc6 100644 --- a/tests/fff_print/test_skirt_brim.cpp +++ b/tests/fff_print/test_skirt_brim.cpp @@ -6,13 +6,15 @@ #include #include #include +#include "libslic3r/ClipperUtils.hpp" #include "libslic3r/GCodeReader.hpp" +#include "libslic3r/Layer.hpp" #include "libslic3r/Geometry.hpp" #include "libslic3r/Geometry/ConvexHull.hpp" -#include "libslic3r/Layer.hpp" #include +#include #include #include #include "libslic3r/Polygon.hpp" @@ -24,6 +26,13 @@ #include "libslic3r/PrintConfig.hpp" #include "libslic3r/libslic3r.h" #include +#include +#include +#include +#include +#include +#include +#include #include "test_helpers.hpp" // get access to init_print, etc #include "libslic3r/BoundingBox.hpp" @@ -32,6 +41,10 @@ #include "libslic3r/ExtrusionEntityCollection.hpp" #include "libslic3r/ObjectID.hpp" #include "libslic3r/Print.hpp" +#include "libslic3r/BeltBrim.hpp" +#include "libslic3r/Config.hpp" +#include "libslic3r/ExtrusionEntity.hpp" +#include "libslic3r/TriangleMesh.hpp" using namespace Slic3r::Test; using namespace Slic3r; @@ -612,3 +625,787 @@ 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 }, + { "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" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + return config; +} + +// Same belt as belt_brim_config(), but with `filaments` distinct filaments so the brim's +// tool selection can be observed. Kept separate from belt_brim_config() so the existing +// single-filament belt tests are untouched. +static DynamicPrintConfig belt_brim_multifilament_config(unsigned int filaments, + std::initializer_list extra = {}) +{ + DynamicPrintConfig config = multifilament_config(filaments); + config.set_deserialize_strict({ + { "belt_printer", 1 }, + { "belt_slice_rotation", "x" }, + { "belt_slice_rotation_angle", 45 }, + { "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" }, + { "layer_change_gcode", "G92 E0\n" }, + }); + if (extra.size() > 0) + config.set_deserialize_strict(extra); + return config; +} + +// 0-based tool indices used by extrusions whose role comment contains `role` (needs +// gcode_comments). Mirrors tools_for_role in test_multifilament.cpp; statics do not cross +// translation units, so it is repeated here. +static std::set belt_tools_for_role(const std::string &gcode, const std::string &role) +{ + std::set tools; + int current_tool = 0; + GCodeReader reader; + reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + const std::string cmd(line.cmd()); + if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char) cmd[1])) + current_tool = std::stoi(cmd.substr(1)); + else if (line.extruding(self) && std::string(line.comment()).find(role) != std::string::npos) + tools.insert(current_tool); + }); + return tools; +} + +// Machine Z of the first extruding move whose role comment contains `role`, in file order; +// numeric_limits::max() when the role never extrudes. +static double first_role_z(const std::string &gcode, const std::string &role) +{ + double z = std::numeric_limits::max(); + GCodeReader parser; + parser.parse_buffer(gcode, [&z, &role](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (line.extruding(self) && line.comment().find(role) != std::string_view::npos) { + z = self.z(); + self.quit_parsing(); + } + }); + return z; +} + +// Number of object layers that carry a belt brim band. Every band prints at its own +// layer Z, so this equals role_layers(gcode, "brim") (plus any apron bands below the +// first object layer). It is not a pass count: the bands on the empty lead-in layers +// ahead of the object's first contact print back to back, so they fold into one pass. +static int nonempty_belt_brim_layers(const PrintObject &object) +{ + int n = 0; + for (const ExtrusionEntityCollection &band : object.belt_brim_by_layer()) + if (! band.empty()) + ++ n; + return n; +} + +// Number of distinct Z heights at which `role` extrudes: one per layer that prints it. +static int role_layers(const std::string &gcode, const std::string &role) +{ + std::set zs; + GCodeReader reader; + reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (! line.extruding(self) || line.dist_XY(self) <= EPSILON) + return; + if (line.comment().find(role) != std::string_view::npos) + zs.insert(std::lround(self.z() * 1000.)); + }); + return int(zs.size()); +} + +// Apron bands below the object's first layer that print something. +static int belt_brim_apron_bands(const PrintObject &object) +{ + int n = 0; + for (const BeltBrimBand &band : object.belt_brim_prologue()) + if (! band.fills.empty()) + ++ n; + return n; +} + +// For each active tool, the ordinal (1-based, over extruding moves) of the FIRST move whose +// role comment contains `role`. Lets a per-object ordering check key off the object's +// unique wall filament. +static std::map first_move_by_tool(const std::string &gcode, const std::string &role) +{ + std::map first; + int tool = 0; + long idx = 0; + GCodeReader reader; + reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + const std::string cmd(line.cmd()); + if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char) cmd[1])) { + tool = std::stoi(cmd.substr(1)); + return; + } + if (! line.extruding(self)) + return; + ++ idx; + if (std::string(line.comment()).find(role) != std::string::npos && ! first.count(tool)) + first[tool] = idx; + }); + return first; +} + +// C - the band coincident with the object's FIRST contact with the belt must not be dropped: +// a belt brim has to appear at or below the object's first perimeter. On the unfixed feature +// the first-contact band is dropped and the first brim then appears only at a later (higher) +// layer. Machine Z is meaningful and shared between roles under the belt remap, so the first +// brim's Z must not exceed the first perimeter's. Both with and without support. +TEST_CASE("Belt brim is laid at the object's first belt contact", "[SkirtBrim][belt]") +{ + const bool support = GENERATE(false, true); + DYNAMIC_SECTION("enable_support=" << support) { + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "leading_brim_length", 0 }, + { "extra_brim_width", 0 }, + { "brim_object_gap", 0 }, + { "enable_support", support ? 1 : 0 }, + }); + const std::string gcode = slice({ cube(20) }, config); + + const double brim_z = first_role_z(gcode, "brim"); + const double peri_z = first_role_z(gcode, "perimeter"); + REQUIRE(brim_z < std::numeric_limits::max()); + REQUIRE(peri_z < std::numeric_limits::max()); + CHECK(brim_z <= peri_z + EPSILON); + } +} + +// C control - when the band's own object layer has extrusion (any interior layer of a solid +// cube), the band takes the ordinary process_layer() path and must be drawn immediately +// before that layer's perimeters, and exactly once: never dropped, never double-emitted. +TEST_CASE("Belt brim on an object layer precedes its perimeters, once", "[SkirtBrim][belt]") +{ + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + }); + Print print; + Model model; + init_print({ cube(20) }, print, model, config); + const std::string gc = gcode(print); + + // Ordering: the first thing extruded is brim, then perimeter. + const std::vector seq = role_sequence(gc, { "brim", "perimeter" }); + REQUIRE(seq.size() >= 2); + CHECK(seq[0] == "brim"); + CHECK(seq[1] == "perimeter"); + + // Exactly once: every band prints at its own layer Z, so the number of Z heights with + // brim equals the number of bands - not fewer, which a dropped band would give. (A + // double emission would print twice at one Z: the pass count below catches that for + // the bands that sit on layers with perimeters.) + const PrintObject &object = *print.objects().front(); + const int bands = nonempty_belt_brim_layers(object); + REQUIRE(bands > 0); + CHECK(role_layers(gc, "brim") == bands + belt_brim_apron_bands(object)); + CHECK(role_passes(gc, "brim") <= bands); +} + +// B - single extruder (filament id 1). Every band must survive the 1-based -> 0-based +// filament-id conversion the apron path performs: a wrong conversion drops all single-extruder +// bands, so the pass count would collapse. The expected count is derived from the sliced +// layers, not a ratio. +TEST_CASE("Belt brim on a single extruder emits every band once", "[SkirtBrim][belt]") +{ + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + }); + Print print; + Model model; + init_print({ cube(20) }, print, model, config); + const std::string gc = gcode(print); + + const PrintObject &object = *print.objects().front(); + const int expected = nonempty_belt_brim_layers(object) + belt_brim_apron_bands(object); + REQUIRE(expected > 0); + CHECK(role_layers(gc, "brim") == expected); + CHECK(belt_tools_for_role(gc, "brim") == std::set{ 0 }); // filament 1 -> tool 0 +} + +// Number of brim segments the belt brim generator produced for `object`: the lattice +// lines of every per-layer band plus the apron prologue. Each segment is written as one +// extruding move, so this is what a G-code count has to match. A pass count cannot see a +// band emitted twice back to back (two copies of the same band merge into one pass). +static long belt_brim_segments(const PrintObject &object) +{ + auto segments = [](const ExtrusionEntityCollection &fills) { + long n = 0; + for (const ExtrusionEntity *entity : fills.flatten().entities) + for (const Polyline &pl : entity->as_polylines()) + n += long(pl.size()) - 1; + return n; + }; + long n = 0; + for (const ExtrusionEntityCollection &band : object.belt_brim_by_layer()) + n += segments(band); + for (const BeltBrimBand &band : object.belt_brim_prologue()) + n += segments(band.fills); + return n; +} + +// Number of extruding moves in the G-code whose role is `role`. +static long role_segments(const std::string &gcode, const std::string &role) +{ + long n = 0; + GCodeReader reader; + reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (line.extruding(self) && line.dist_XY(self) > EPSILON && line.comment().find(role) != std::string_view::npos) + ++ n; + }); + return n; +} + +TEST_CASE("Belt brim writes every generated segment exactly once", "[SkirtBrim][belt]") +{ + // The pass count above cannot tell one band from the same band twice in a row; the + // segment count can, so a brim band emitted twice back to back fails here. + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + { "leading_brim_length", 6 }, + }); + Print print; + Model model; + init_print({ cube(20) }, print, model, config); + const std::string gc = gcode(print); + + const long expected = belt_brim_segments(*print.objects().front()); + REQUIRE(expected > 100); + CHECK(role_segments(gc, "brim") == expected); +} + +// B - multi extruder (wall filament id 2). Every belt-brim line must print on the object's +// wall filament (index 2 -> tool 1), and the total number of passes must equal the +// single-extruder baseline: no per-filament doubling. +TEST_CASE("Belt brim on a multi-extruder object uses the wall filament, no doubling", "[SkirtBrim][belt]") +{ + // Single-extruder baseline built the same way (same nozzle/flow), so the band geometry - + // and thus the band count - is identical and only the filament assignment differs. + DynamicPrintConfig base = belt_brim_multifilament_config(1, { + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + }); + const int baseline = role_passes(slice({ cube(20) }, base), "brim"); + REQUIRE(baseline > 0); + + DynamicPrintConfig config = belt_brim_multifilament_config(2, { + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + { "outer_wall_filament_id", 2 }, + { "inner_wall_filament_id", 2 }, + }); + const std::string gc = slice({ cube(20) }, config); + + CHECK(belt_tools_for_role(gc, "brim") == std::set{ 1 }); // filament 2 -> tool 1 + CHECK(role_passes(gc, "brim") == baseline); +} + +// B - two objects offset ALONG the belt (Y, since the tilt is about X), each with its own +// wall filament. Each object's brim/apron must print on that object's filament AND before +// that object's own perimeters. The object is identified by its unique tool. +TEST_CASE("Belt brim of each object precedes its perimeters on its own filament", "[SkirtBrim][belt]") +{ + DynamicPrintConfig config = belt_brim_multifilament_config(2, { + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "leading_brim_length", 6 }, + { "brim_object_gap", 0 }, + }); + + std::vector meshes; + meshes.emplace_back(cube(20)); + TriangleMesh second = cube(20); + second.translate(0.f, 40.f, 0.f); // offset along the belt so it lands well after the first + meshes.emplace_back(std::move(second)); + + const std::vector> overrides { + { { "outer_wall_filament_id", 1 }, { "inner_wall_filament_id", 1 } }, + { { "outer_wall_filament_id", 2 }, { "inner_wall_filament_id", 2 } }, + }; + Print print; + Model model; + init_print(std::move(meshes), print, model, config, &overrides, /*arrange=*/false); + print.process(); + const std::string gc = gcode(print); + + // Both brims appear, each on its object's wall filament (1 -> T0, 2 -> T1). + CHECK(belt_tools_for_role(gc, "brim") == std::set{ 0, 1 }); + + const std::map brim_first = first_move_by_tool(gc, "brim"); + const std::map peri_first = first_move_by_tool(gc, "perimeter"); + for (int tool : { 0, 1 }) { + REQUIRE(brim_first.count(tool) == 1); + REQUIRE(peri_first.count(tool) == 1); + CHECK(brim_first.at(tool) < peri_first.at(tool)); + } +} + +// D - the belt-brim predicate must not fire on a request that produces no belt brim. +// leading_brim_length / extra_brim_width only feed the OUTER ring, so inner_only with zero +// brim_width yields nothing and must not claim the layers the prime tower / spiral vase need. +TEST_CASE("Belt inner-only leading brim does not reject the prime tower or spiral vase", "[SkirtBrim][belt]") +{ + auto inner_leading = [](std::initializer_list extra) { + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "inner_only" }, + { "brim_width", 0 }, + { "leading_brim_length", 6 }, + { "brim_object_gap", 0 }, + }); + config.set_deserialize_strict(extra); + return config; + }; + auto init_inner_leading_with_prime_tower = [](Print &print, Model &model, double brim_width) { + DynamicPrintConfig config = belt_brim_multifilament_config(2, { + { "brim_type", "inner_only" }, + { "brim_width", brim_width }, + { "leading_brim_length", 6 }, + { "brim_object_gap", 0 }, + { "enable_prime_tower", 1 }, + }); + const std::vector> overrides { + { { "extruder", 1 } }, { { "extruder", 2 } }, + }; + init_print({ cube(20), cube(20) }, print, model, config, &overrides); + }; + + SECTION("prime tower is left alone") { + Print print; + Model model; + init_inner_leading_with_prime_tower(print, model, 0); + CHECK_FALSE(print.objects().front()->has_belt_brim()); + CHECK(print.validate().string.empty()); + } + SECTION("spiral vase is left alone") { + Print print; + Model model; + init_print({ cube(20) }, print, model, inner_leading({ { "spiral_mode", 1 } })); + CHECK_FALSE(print.objects().front()->has_belt_brim()); + CHECK(print.validate().string.empty()); + } + // enable_prime_tower stays on for any multi-filament project, but a belt printer never + // prints the classic tower, so the setting alone must not cost the print its brim. + SECTION("a real inner brim is accepted with the prime tower setting on") { + Print print; + Model model; + init_inner_leading_with_prime_tower(print, model, 4); + CHECK(print.objects().front()->has_belt_brim()); + CHECK(print.validate().string.empty()); + CHECK_FALSE(gcode(print).empty()); + } + // A purge tower object is accepted too: the purge plan moves every object, apron + // bands included, onto one layer grid. + SECTION("a brim is accepted next to a belt purge tower object") { + DynamicPrintConfig config = belt_brim_multifilament_config(2, { + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + { "enable_belt_purge_tower", 1 }, + }); + const std::vector> overrides { + { { "extruder", 1 } }, { { "extruder", 2 } }, + }; + Print print; + Model model; + init_print({ cube(20), cube(20) }, print, model, config, &overrides); + model.objects.back()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true)); + print.apply(model, config); + REQUIRE(print.has_belt_purge_tower()); + CHECK(print.validate().string.empty()); + CHECK_FALSE(gcode(print).empty()); + } +} + +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", "leading_edge_only", "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); + } +} + +// The leading-edge-only brim is the outer brim cut down to the part's first contact +// with the belt. The cut has to be taken at the first layer that touches the belt: the +// slicing frame starts at the belt below the footprint, so layers().front() is an empty +// lead-in layer whose contact lies ahead of the part, and a cut taken there left no brim +// at all. +TEST_CASE("Leading-edge-only brim is laid at the first contact and nowhere else", "[SkirtBrim][belt][Regression]") +{ + auto brim_gcode = [](const char *brim_type) { + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", brim_type }, + { "brim_width", 5 }, + { "leading_brim_length", 10 }, + { "extra_brim_width", 0 }, + { "brim_object_gap", 0 }, + }); + return slice({ cube(20) }, config); + }; + const std::string leading = brim_gcode("leading_edge_only"); + const std::string outer = brim_gcode("outer_only"); + + const double brim_z = first_role_z(leading, "brim"); + const double peri_z = first_role_z(leading, "perimeter"); + REQUIRE(brim_z < std::numeric_limits::max()); + REQUIRE(peri_z < std::numeric_limits::max()); + // At the first contact: the brim starts no later than the part does... + CHECK(brim_z <= peri_z + EPSILON); + // ...and stops there, while the outer brim keeps following the footprint. + const int leading_layers = role_layers(leading, "brim"); + const int outer_layers = role_layers(outer, "brim"); + INFO("brim layers: leading-edge " << leading_layers << ", outer " << outer_layers); + CHECK(leading_layers > 0); + CHECK(leading_layers < outer_layers); +} + +// An overhang on the leading side is sliced before the part reaches the belt, so the +// first layer with geometry is the overhang's tip, above the belt. A leading-edge cut +// taken there lies ahead of the part: the brim shrank to a sliver well ahead of it, or +// vanished once the overhang reached further forward than the brim. The overhang does +// not touch the belt, so it must not change the brim at all. +TEST_CASE("Leading-edge-only brim ignores an overhang ahead of the part", "[SkirtBrim][belt][Regression]") +{ + const double fin_length = GENERATE(30., 40.); + CAPTURE(fin_length); + // A 20 mm cube, with or without a 2 mm thick fin leaving its top edge and reaching + // `fin` toward -Y, the end of the part that prints first. The fin overlaps the cube + // by 1 mm so the two shells merge instead of sharing a face. + auto brim_layers = [](double fin) { + indexed_triangle_set its = its_make_cube(20., 20., 20.); + if (fin > 0.) { + indexed_triangle_set fin_its = its_make_cube(20., fin + 1., 2.); + its_translate(fin_its, Vec3f(0.f, float(-fin), 18.f)); + its_merge(its, fin_its); + } + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "brim_type", "leading_edge_only" }, + { "brim_width", 5 }, + { "leading_brim_length", 10 }, + { "extra_brim_width", 0 }, + { "brim_object_gap", 0 }, + }); + return role_layers(slice({ TriangleMesh(std::move(its)) }, config), "brim"); + }; + const int plain = brim_layers(0.); + const int with_fin = brim_layers(fin_length); + INFO("leading-edge brim layers: plain cube " << plain << ", with the fin " << with_fin); + REQUIRE(plain > 0); + // One layer of slack: the fin widens the part's footprint on the plate, which can + // move the layer grid by a fraction of a layer. + CHECK(std::abs(with_fin - plain) <= 1); +} + +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-4f)); +} + +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 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); +} + +// With a 0.3 mm first layer at 45 degrees the brim band on the belt is wider than one bead, +// so its lines go on the nominal lattice instead of at a fixed fraction of the band. A +// lattice line can then land where the belt is almost at the band's print_z; it must be +// moved uphill to the same 0.75 fraction the single-line case uses, not laid scraping the +// belt with its flow clamped to half a layer. +TEST_CASE("Belt brim lattice lines keep their clearance above the belt", "[SkirtBrim][belt]") +{ + DynamicPrintConfig config = belt_brim_config(); + config.set_deserialize_strict({ + { "layer_height", 0.3 }, + { "initial_layer_print_height", 0.3 }, + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + }); + const std::string gcode = slice({ cube(20) }, config); + + // Heights of the brim extrusions, from the ;HEIGHT: tags inside ;TYPE:Brim sections. + std::vector brim_heights; + bool in_brim = false; + std::istringstream lines(gcode); + for (std::string line; std::getline(lines, line); ) { + if (boost::starts_with(line, ";TYPE:")) + in_brim = boost::starts_with(line, ";TYPE:Brim"); + else if (in_brim && boost::starts_with(line, ";HEIGHT:")) + brim_heights.push_back(std::stod(line.substr(8))); + } + REQUIRE(! brim_heights.empty()); + for (const double h : brim_heights) { + CHECK(h >= 0.75 * 0.3 - 1e-3); + CHECK(h <= 0.3 + 1e-3); + } +} + +// The brim prints in the object's outer wall filament even when every extrusion of the object +// is offered to purging (flush_into_objects): the tool ordering registers the brim filament +// itself, so the writer always knows it. +TEST_CASE("Belt brim slices when every object is a flush target", "[SkirtBrim][belt]") +{ + DynamicPrintConfig config = belt_brim_multifilament_config(2, { + { "brim_type", "outer_only" }, + { "brim_width", 4 }, + { "brim_object_gap", 0 }, + { "flush_into_objects", 1 }, + { "flush_into_infill", 1 }, + }); + const std::vector> overrides { + { { "extruder", 1 } }, { { "extruder", 2 } }, + }; + Print print; + Model model; + init_print({ cube(20), cube(20) }, print, model, config, &overrides); + REQUIRE(print.validate().string.empty()); + const std::string out = gcode(print); + CHECK(out.find(";TYPE:Brim") != std::string::npos); +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 5f3f2596fb..667558924e 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -3,6 +3,7 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) add_executable(${_TEST_NAME}_tests ${_TEST_NAME}_tests.cpp test_3mf.cpp + test_amf.cpp # Round-trip seam metadata and active/dormant volume settings in both formats. test_precise_seam_3mf.cpp # Pure perimeter extraction is independent of Print/Layer fixtures. @@ -13,6 +14,7 @@ add_executable(${_TEST_NAME}_tests test_arrange.cpp test_assemble_list.cpp test_bambu_networking.cpp + test_belt_brim.cpp test_buildvolume.cpp test_calib.cpp test_clipper_offset.cpp @@ -38,6 +40,7 @@ add_executable(${_TEST_NAME}_tests test_imex_zones.cpp test_multimaterial_segmentation.cpp test_placeholder_parser.cpp + test_png_read_write.cpp test_polygon.cpp test_mutable_polygon.cpp test_mutable_priority_queue.cpp diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index 78bcd2f96b..f7ac744e39 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -46,6 +46,7 @@ #include #include // for std::enable_if_t #include // for typeid +#include #include #include @@ -416,6 +417,68 @@ TEST_CASE("A project with a plate id below 1 fails to load", "[3mf][Regression]" REQUIRE_FALSE(loaded); } +TEST_CASE("A project whose components reference themselves fails to load", "[3mf][Regression]") +{ + // One self-reference keeps the expansion going without ever reaching a mesh. A thousand also make + // each expansion queue a thousand more, so the bound has to hold the work list, not just the loop. + const int references = GENERATE(1, 1000); + INFO("self-references " << references); + + ScopedTemporaryFile temp(".3mf"); + store_painted_cube(temp.string()); + + // Point the component back at the object that holds it, repeated `references` times. + REQUIRE(rewrite_3mf_entries(temp.string(), [references](std::string& name, std::string& data) { + if (!boost::algorithm::ends_with(name, "3dmodel.model")) + return false; + std::smatch match; + if (!std::regex_search(data, match, std::regex("]*>\\s*]*/>"))) + return false; + std::string repeated; + for (int i = 0; i < references; ++i) + repeated += component.str(); + data.replace(component.position(), component.length(), repeated); + return true; + })); + + ScopedTemporaryDir backup_dir("orca_cycle_dst"); + Model model; + bool loaded = true; + REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir)); + REQUIRE_FALSE(loaded); +} + +TEST_CASE("An object loads up to the component reference budget and fails past it", "[3mf][Regression]") +{ + // The importer queues at most 100000 component references per object. Every reference besides the + // cube's own points at an object the file does not define: it counts toward the budget, then expands + // to nothing, so the object stays a single part whatever the count. + const auto [references, loads] = GENERATE(table({ { 100000, true }, { 100001, false } })); + INFO("component references " << references); + + ScopedTemporaryFile temp(".3mf"); + store_painted_cube(temp.string()); + + std::string dangling; + for (int i = 1; i < references; ++i) + dangling += ""; + REQUIRE(replace_in_3mf_entry(temp.string(), "3dmodel.model", "", dangling + "")); + + ScopedTemporaryDir backup_dir("orca_budget_dst"); + Model model; + bool loaded = !loads; + REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir)); + REQUIRE(loaded == loads); + if (loads) { + REQUIRE(model.objects.size() == 1); + CHECK(model.objects.front()->volumes.size() == 1); + } +} + TEST_CASE("A project with malformed paint data loads without the damaged facet", "[3mf][Regression]") { ScopedTemporaryFile temp(".3mf"); diff --git a/tests/libslic3r/test_amf.cpp b/tests/libslic3r/test_amf.cpp new file mode 100644 index 0000000000..eea678fc4a --- /dev/null +++ b/tests/libslic3r/test_amf.cpp @@ -0,0 +1,80 @@ +#include + +#include "libslic3r/Config.hpp" +#include "libslic3r/Format/AMF.hpp" +#include "libslic3r/Model.hpp" +#include "libslic3r/PrintConfig.hpp" + +#include "test_utils.hpp" + +#include + +#include +#include + +using namespace Slic3r; + +namespace { + +// The smallest AMF the loader accepts: one object holding one volume, a tetrahedron. The metadata +// element of the object is dropped in verbatim, so a test can hand the parser a malformed one. +std::string amf_with_object_metadata(const std::string &object_metadata) +{ + return "\n" + "\n" + " \n" + " " + object_metadata + "\n" + " \n" + " \n" + " 000\n" + " 100\n" + " 010\n" + " 001\n" + " \n" + " \n" + " 021\n" + " 013\n" + " 032\n" + " 123\n" + " \n" + " \n" + " \n" + "\n"; +} + +void write_file(const std::string &path, const std::string &content) +{ + boost::nowide::ofstream f(path, std::ios::binary); + f << content; +} + +bool load(const std::string &path, Model &model) +{ + DynamicPrintConfig config; + ConfigSubstitutionContext substitutions(ForwardCompatibilitySubstitutionRule::Disable); + return load_amf(path.c_str(), &config, &substitutions, &model, nullptr); +} + +} // namespace + +TEST_CASE("An AMF object metadata element with no type attribute is rejected", "[AMF]") +{ + ScopedTemporaryFile tmp(".amf"); + + SECTION("with the attribute the file loads") + { + write_file(tmp.string(), amf_with_object_metadata("tetra")); + + Model model; + REQUIRE(load(tmp.string(), model)); + CHECK(model.objects.size() == 1); + } + + SECTION("without it the load fails instead of reading a null attribute") + { + write_file(tmp.string(), amf_with_object_metadata("tetra")); + + Model model; + CHECK_FALSE(load(tmp.string(), model)); + } +} diff --git a/tests/libslic3r/test_arrange.cpp b/tests/libslic3r/test_arrange.cpp index 7d3c2fb324..f4dad02808 100644 --- a/tests/libslic3r/test_arrange.cpp +++ b/tests/libslic3r/test_arrange.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include "libslic3r/Arrange.hpp" #include "libslic3r/BoundingBox.hpp" #include "libslic3r/ClipperUtils.hpp" @@ -274,6 +275,101 @@ TEST_CASE("Arrange aligns the pile to a custom center", "[Arrange]") require_no_overlap(items); } +// A belt printer starts its parts at the leading end of the belt (best_object_pos 0.5, 0.05). +// Centring a pile on a point that close to the edge pushed everything longer than the room +// around it off the bed: four 90 mm parts on a 95 x 500 mm belt ended with one across the +// edge and one outside, with 290 mm of belt free behind them. The pile stops at the edge. +TEST_CASE("Arrange keeps a pile aligned near an edge on the bed", "[Arrange][belt]") +{ + const BoundingBox belt = bed(95, 500); + ArrangePolygons items = squares(4, 90.); + ArrangeParams params = quiet_params(scaled(2.)); + params.align_center = Vec2d(0.5, 0.05); + params.is_belt = true; + params.belt_axis = 1; + params.belt_tilt_slope = 1.f; + + arrange(items, belt, params); + + coord_t lowest = std::numeric_limits::max(); + for (const ArrangePolygon &ap : items) { + REQUIRE(ap.bed_idx == 0); + const BoundingBox bb = ap.transformed_poly().contour.bounding_box(); + CHECK(belt.contains(bb)); + lowest = std::min(lowest, bb.min.y()); + } + // Snapped to the edge it was aimed at, less the spacing margin, not re-centred. + CHECK(lowest < scaled(10.)); + require_no_overlap(items); +} + +// The clamp is a belt feature. Printers whose best_object_pos is off-centre (the A1 mini +// and the H2 family) keep their final alignment: the pile is centred on that point, even +// when that puts part of it outside the bed. +TEST_CASE("Arrange leaves the final alignment of a flat bed unclamped", "[Arrange]") +{ + const BoundingBox bed_ = bed(95, 500); + ArrangePolygons items = squares(4, 90.); + ArrangeParams params = quiet_params(scaled(2.)); + params.align_center = Vec2d(0.5, 0.05); + + arrange(items, bed_, params); + + BoundingBox pile; + for (const ArrangePolygon &ap : items) { + REQUIRE(ap.bed_idx == 0); + pile.merge(ap.transformed_poly().contour.bounding_box()); + } + // Centred on the 5% mark of the bed's length, not pushed inside it. + CHECK_THAT(unscaled(pile.center().y()), Catch::Matchers::WithinAbs(0.05 * 500., 15.)); + CHECK(pile.min.y() < 0); + require_no_overlap(items); +} + +// On a belt the parts print in belt order, so two colours that alternate along the +// belt, or sit side by side, cost a filament change on every shared layer. Arrange +// keeps each colour together: no part shares belt length with a part of another +// colour, counting the tilted layers that run cot(angle) * height past its far edge, +// whichever end of the belt prints first. +TEST_CASE("Arrange groups the colours of a belt print along the belt", "[Arrange][belt]") +{ + const bool reversed = GENERATE(false, true); + CAPTURE(reversed); + const BoundingBox belt = bed(95, 500); + ArrangePolygons items = squares(6, 30., 20.); + for (size_t i = 0; i < items.size(); ++i) + items[i].extrude_ids = { int(i % 3) + 1 }; // three colours, two parts each + ArrangeParams params = quiet_params(scaled(2.)); + params.align_center = Vec2d(0.5, 0.05); + params.is_belt = true; + params.belt_axis = 1; + params.belt_reversed = reversed; + params.belt_tilt_slope = 1.f; // 45 degrees + + arrange(items, belt, params); + require_no_overlap(items); + + // Belt position in print order, so the same check serves both directions. + const coord_t dir = reversed ? -1 : 1; + auto start = [&](const ArrangePolygon &ap) { const BoundingBox bb = ap.transformed_poly().contour.bounding_box(); return dir * (reversed ? bb.max.y() : bb.min.y()); }; + auto end = [&](const ArrangePolygon &ap) { const BoundingBox bb = ap.transformed_poly().contour.bounding_box(); return dir * (reversed ? bb.min.y() : bb.max.y()) + scaled(ap.height * params.belt_tilt_slope); }; + + for (const ArrangePolygon &ap : items) { + REQUIRE(ap.bed_idx == 0); + CHECK(belt.contains(ap.transformed_poly().contour.bounding_box())); + } + for (const ArrangePolygon &a : items) + for (const ArrangePolygon &b : items) { + if (a.extrude_ids == b.extrude_ids) + continue; + // The part printed later starts after the earlier one has finished. + const coord_t earlier_end = start(a) <= start(b) ? end(a) : end(b); + const coord_t later_start = std::max(start(a), start(b)); + INFO("colour " << a.extrude_ids.front() << " vs " << b.extrude_ids.front()); + CHECK(earlier_end <= later_start); + } +} + TEST_CASE("Sequential print floors the object distance by object height", "[Arrange]") { // The only place sequential-print clearance is enforced. The arrange menu offers diff --git a/tests/libslic3r/test_belt_brim.cpp b/tests/libslic3r/test_belt_brim.cpp new file mode 100644 index 0000000000..f6d794f939 --- /dev/null +++ b/tests/libslic3r/test_belt_brim.cpp @@ -0,0 +1,435 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "libslic3r/BeltBrim.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/ExPolygon.hpp" +#include "libslic3r/Polygon.hpp" +#include "libslic3r/libslic3r.h" + +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("belt_brim_region builds an inner ring inside a hole", "[BeltBrim]") { + // Holed prisms (a washer) are the only footprints an inner brim has anything to grab. + // The inner path offsets the hole boundary inward and keeps the ring between the two + // offsets, clipped back inside the hole - it must be non-empty and live in the hole, + // never spill out onto the plate. No apron is applied to the inner ring. + const coord_t mm = scale_(1.); + const BeltBrimFrame frame { 1.0, 1 }; + const coord_t width = 3 * mm; + const coord_t gap = 1 * mm; + + GIVEN("a 40x40 mm washer with a 20 mm square hole") { + ExPolygon washer = make_box(0, 0, 40 * mm, 40 * mm); + add_hole(washer, 10 * mm, 10 * mm, 30 * mm, 30 * mm); + const ExPolygons footprint { washer }; + const BoundingBox hole_bb = get_extents(washer.holes.front()); + + WHEN("an inner-only brim is requested") { + const ExPolygons region = belt_brim_region(footprint, false, true, width, gap, 0, 0, frame); + THEN("a non-empty ring is produced strictly inside the hole") { + REQUIRE(! region.empty()); + CHECK(area(region) > 0); + const BoundingBox rb = get_extents(region); + CHECK(rb.min.x() >= hole_bb.min.x()); + CHECK(rb.min.y() >= hole_bb.min.y()); + CHECK(rb.max.x() <= hole_bb.max.x()); + CHECK(rb.max.y() <= hole_bb.max.y()); + } + } + WHEN("no inner brim is requested") { + THEN("the hole contributes nothing") { + CHECK(belt_brim_region(footprint, false, false, width, gap, 0, 0, frame).empty()); + } + } + } +} + +SCENARIO("Leading-edge-only retains the downhill half of the brim region", "[BeltBrim]") { + // The production clip, belt_brim_clip_leading_edge(), keeps what lies at or downhill + // of the first-contact cut. downhill_sign() pins the convention for both tilt signs: + // low_side = shear > 0, i.e. downhill is -u. + const coord_t mm = scale_(1.); + const double shear = GENERATE(1.0, -1.0); + DYNAMIC_SECTION("shear " << shear) { + const BeltBrimFrame frame { shear, 1 }; // from_axis 1 => u is Y + CHECK((frame.downhill_sign() < 0) == (frame.shear > 0.)); + + const ExPolygons region { make_box(0, 0, 20 * mm, 20 * mm) }; // straddles the cut + const coordf_t u_cut = 8.; // mm + + const ExPolygons kept = belt_brim_clip_leading_edge(region, frame, u_cut); + + REQUIRE(! kept.empty()); + const BoundingBox kb = get_extents(kept); + if (frame.shear > 0.) { + // downhill is -u: nothing above the cut survives. + CHECK(kb.max.y() <= 8 * mm + 2); + CHECK(kb.min.y() < 8 * mm); + } else { + // downhill is +u: nothing below the cut survives. + CHECK(kb.min.y() >= 8 * mm - 2); + CHECK(kb.max.y() > 8 * mm); + } + // Half of the box is kept either way, and the full width across the belt. + CHECK_THAT(area(kept), Catch::Matchers::WithinRel(area(region) * (frame.shear > 0. ? 8. / 20. : 12. / 20.), 0.01)); + CHECK(kb.min.x() == 0); + CHECK(kb.max.x() == 20 * mm); + } + WHEN("the cut lies beyond the region") { + const BeltBrimFrame frame { 1.0, 1 }; + const ExPolygons region { make_box(0, 0, 20 * mm, 20 * mm) }; + THEN("a cut past the uphill end keeps everything") { + CHECK_THAT(area(belt_brim_clip_leading_edge(region, frame, 30.)), Catch::Matchers::WithinRel(area(region), 0.001)); + } + THEN("a cut before the downhill end keeps nothing") { + CHECK(belt_brim_clip_leading_edge(region, frame, -5.).empty()); + } + THEN("an empty region stays empty") { + CHECK(belt_brim_clip_leading_edge(ExPolygons{}, frame, 8.).empty()); + } + } +} + +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); + } + } +} diff --git a/tests/libslic3r/test_filament_mixer.cpp b/tests/libslic3r/test_filament_mixer.cpp index f43a8e7588..7c01b26d87 100644 --- a/tests/libslic3r/test_filament_mixer.cpp +++ b/tests/libslic3r/test_filament_mixer.cpp @@ -65,6 +65,50 @@ TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", " } } +TEST_CASE("belt purge tower island count ignores virtual mixed slots", "[FilamentMixer][belt]") +{ + // Regression for the "extra purge tower" on a belt printer with a mixed + // filament (MCTEST5). The belt purge prism is sized as + // n_islands = used_filaments.size() - 1 + // and GUI::ensure_belt_purge_tower() collected those filaments straight off + // the model objects' extruder assignments. A mixed slot is VIRTUAL -- no + // nozzle carries it, and ToolOrdering::resolve_mixed_filaments() replaces it + // with its components before any G-code is emitted -- so counting it as a + // filament of its own provisions one island that can never be reached. + // + // MCTEST5: five cubes on extruders 1..5, where filament 5 is a 50/50 blend of + // filaments 2 and 4. The G-code uses only T0..T3 and reports + // "filament used [g] = 53.35, 141.11, 40.84, 107.23, 0.00" -- filament 5 + // consumes nothing, exactly as a virtual slot should. + const std::vector is_mixed = {0, 0, 0, 0, 1}; + const std::vector comp_strs = {"", "", "", "", "2,4"}; + + // The set the sizer used to see: slots 0..4 (filaments 1..5). + const std::vector assigned = {0, 1, 2, 3, 4}; + const auto physical = expand_mixed_filaments(assigned, is_mixed, comp_strs); + + // Slot 4 dissolves into 1 and 3, which are already present. + REQUIRE(physical == std::vector({0, 1, 2, 3})); + + // Four physical filaments => three transitions => three islands, not four. + REQUIRE(int(physical.size()) - 1 == 3); + REQUIRE(int(assigned.size()) - 1 == 4); // what it produced before the fix + + SECTION("A mixed slot whose components are otherwise unused still counts them") { + // Only the mixed slot is assigned: it must still yield its two components, + // i.e. one island, rather than collapsing to zero. + const auto only_mixed = expand_mixed_filaments({4}, is_mixed, comp_strs); + REQUIRE(only_mixed == std::vector({1, 3})); + REQUIRE(int(only_mixed.size()) - 1 == 1); + } + + SECTION("No mixed filaments anywhere leaves the set untouched") { + const std::vector none_mixed = {0, 0, 0, 0, 0}; + REQUIRE_FALSE(has_any_mixed_filament(none_mixed)); + REQUIRE(expand_mixed_filaments(assigned, none_mixed, {"", "", "", "", ""}) == assigned); + } +} + TEST_CASE("check_mixed_filament_integrity flags dangling component references", "[FilamentMixer]") { const std::vector is_mixed = {0, 0, 1}; diff --git a/tests/libslic3r/test_png_read_write.cpp b/tests/libslic3r/test_png_read_write.cpp new file mode 100644 index 0000000000..2aa28e1a3a --- /dev/null +++ b/tests/libslic3r/test_png_read_write.cpp @@ -0,0 +1,83 @@ +#include + +#include +#include +#include +#include + +#include + +#include "libslic3r/PNGReadWrite.hpp" + +using namespace Slic3r; + +// libpng reports a corrupt or truncated file by longjmp()ing out of the decoder, so the decoders have +// to come back with false rather than crash or hand back a half filled image. +namespace { + +// A real PNG, produced by the writer next door, so the bytes are a file libpng accepts. +std::vector encoded_png(size_t w, size_t h) +{ + std::vector pixels(w * h); + for (size_t i = 0; i < pixels.size(); ++ i) + pixels[i] = uint8_t((i * 7) % 256); + + const boost::filesystem::path path = boost::filesystem::temp_directory_path() / + boost::filesystem::unique_path("png_rw_%%%%%%%%.png"); + REQUIRE(png::write_gray_to_file(path.string(), w, h, pixels)); + std::vector bytes; + { + std::ifstream ifs(path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(path, ec); + REQUIRE(bytes.size() > 64); + return bytes; +} + +png::ReadBuf buf_of(const std::vector &bytes, size_t size) +{ + return png::ReadBuf{ bytes.data(), size }; +} + +} // namespace + +TEST_CASE("A whole PNG decodes", "[PNG]") { + const std::vector bytes = encoded_png(24, 16); + + png::ImageGreyscale grey; + REQUIRE(png::decode_png(buf_of(bytes, bytes.size()), grey)); + CHECK(grey.cols == 24); + CHECK(grey.rows == 16); + CHECK(grey.buf.size() == 24 * 16); +} + +TEST_CASE("A truncated PNG is refused instead of crashing", "[PNG]") { + const std::vector bytes = encoded_png(64, 64); + + // Cut past the signature: inside the header, and inside the pixel data. Not in the trailing + // chunks - decode_png() does not read those, so a file missing only its IEND still decodes, and + // that is the pre-existing contract rather than anything this change touches. + const size_t size = GENERATE_COPY(size_t(16), size_t(40), bytes.size() / 2, bytes.size() * 3 / 4); + REQUIRE(size < bytes.size()); + + png::ImageGreyscale grey; + CHECK_FALSE(png::decode_png(buf_of(bytes, size), grey)); + + png::ImageColorscale colour; + CHECK_FALSE(png::decode_colored_png(buf_of(bytes, size), colour)); +} + +TEST_CASE("A PNG whose body is garbage is refused", "[PNG]") { + std::vector bytes = encoded_png(32, 32); + // Keep the signature, scribble over everything after it. + for (size_t i = 8; i < bytes.size(); ++ i) + bytes[i] = uint8_t(0xA5); + + png::ImageGreyscale grey; + CHECK_FALSE(png::decode_png(buf_of(bytes, bytes.size()), grey)); + + png::ImageColorscale colour; + CHECK_FALSE(png::decode_colored_png(buf_of(bytes, bytes.size()), colour)); +} diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 97a282825b..2e0fcdb444 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -34,6 +34,7 @@ add_executable(${_TEST_NAME}_tests test_plugin_sort.cpp test_plugin_cloud_metadata.cpp test_plugin_audit.cpp + test_plugin_json_depth.cpp test_shortcuts.cpp test_file_url.cpp test_user_manager.cpp diff --git a/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index ca8c46fabd..267cdb4099 100644 --- a/tests/slic3rutils/test_plugin_audit.cpp +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -222,18 +222,19 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword", CHECK(mgr.is_denied_path_keyword(fs::path("/resources/certificates/ca.pem"))); } - SECTION("a 'conf'/'config' directory or file component is denied") + SECTION("a 'conf'/'config' directory or config file component is denied") { CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/conf/settings.json"))); CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/config/settings.json"))); CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.conf"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.ini"))); } SECTION("matching is case-insensitive") { CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/SECRETS/token.txt"))); - CHECK(mgr.is_denied_path_keyword(fs::path("/resources/CertBundle/ca.pem"))); - CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/CONFIG.JSON"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/resources/Certificates/ca.pem"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/PLUGIN.CONF"))); } SECTION("matching is not limited to the base name -- any ancestor component counts") @@ -245,6 +246,14 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword", { CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/output/model.gcode"))); CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/storage/state.json"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/numpy/__config__.py"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/numpy/_core/_ufunc_config.py"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/configparser.py"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/sysconfig.py"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/logging/config.py"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/certifi/cacert.pem"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/users/Conference/output.txt"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/users/Concert/output.txt"))); } SECTION("an empty path is not denied") diff --git a/tests/slic3rutils/test_plugin_json_depth.cpp b/tests/slic3rutils/test_plugin_json_depth.cpp new file mode 100644 index 0000000000..ad3c834b93 --- /dev/null +++ b/tests/slic3rutils/test_plugin_json_depth.cpp @@ -0,0 +1,73 @@ +#include +#include + +#include +#include +#include + +#include "plugin_test_utils.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +using namespace Slic3r; + +namespace { +// Brings the embedded interpreter up for one test and tears it down before boost::log does, +// mirroring the ScopedPluginManager idiom in the other plugin tests. +struct ScopedPluginManager +{ + ScopedDataDir python_data_dir{"plugin-json-depth"}; + bool initialized = PluginManager::instance().initialize(); + ~ScopedPluginManager() + { + PluginManager::instance().shutdown(); + PythonInterpreter::instance().shutdown(); + } +}; +} // namespace + +TEST_CASE("py_to_json raises instead of overflowing on pathologically deep input", "[PluginHost][Python]") +{ + ScopedPluginManager manager; + REQUIRE(manager.initialized); + namespace py = pybind11; + py::gil_scoped_acquire gil; + + // [[[ ... 0 ... ]]] nested 300 deep: past the 200 conversion-depth cap, but shallow enough + // that the pre-fix code returns without crashing, so a regression fails cleanly rather than + // taking the process down. Built in C++ so the test does not depend on Python builtins. + py::object deep = py::int_(0); + for (int i = 0; i < 300; ++i) { + py::list wrapper; + wrapper.append(deep); + deep = std::move(wrapper); + } + CHECK_THROWS_AS(py_to_json(deep), std::exception); +} + +TEST_CASE("py_to_json still converts reasonably nested input", "[PluginHost][Python]") +{ + ScopedPluginManager manager; + REQUIRE(manager.initialized); + namespace py = pybind11; + py::gil_scoped_acquire gil; + + py::dict d; + d["a"] = py::int_(1); + py::list inner; + inner.append(py::str("x")); + inner.append(py::int_(2)); + d["b"] = inner; + + const nlohmann::json j = py_to_json(d); + CHECK(j.at("a").get() == 1); + CHECK(j.at("b").at(0).get() == "x"); + CHECK(j.at("b").at(1).get() == 2); +}