diff --git a/.github/workflows/copilot_build_artifact.yml b/.github/workflows/copilot_build_artifact.yml new file mode 100644 index 0000000000..70f249e69d --- /dev/null +++ b/.github/workflows/copilot_build_artifact.yml @@ -0,0 +1,208 @@ +name: Copilot Build Artifact (Ubuntu) + +# Build deps + slicer once on a hosted runner and upload the products as +# Actions artifacts so that subsequent Copilot agent sessions (which start +# from a clean disk) can `gh run download` them instead of rebuilding. +# +# See plan in PR thread: deps ~50 min + slicer ~30-40 min, well under the +# 6 h job limit. + +on: + workflow_dispatch: + push: + branches: + - 'copilot/**' + +concurrency: + group: copilot-build-artifact-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build deps + slicer (ubuntu-22.04) + runs-on: ubuntu-22.04 + timeout-minutes: 330 # 5h30, under the 6h hard limit + env: + DEPS_TARBALL: BambuStudio_deps_ubuntu-22.04.tar.gz + SLICER_TARBALL: BambuStudio_slicer_ubuntu-22.04.tar.gz + INPUTS_TARBALL: slice-inputs.tar.gz + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + lfs: 'true' + + - name: Free disk space + run: | + df -h + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL || true + sudo docker image prune --all --force || true + df -h + + # --------------------------------------------------------------- + # Deps: cache the built destdir keyed on inputs that affect it. + # Restore-key falls back to the latest deps build on this OS so + # incremental work on src/** does not re-pay the 50-minute cost. + # --------------------------------------------------------------- + # NOTE on the cache key: we deliberately hash only the small, + # static set of files that describe what gets built (the per-dep + # *.cmake recipes, the top-level CMakeLists, BuildLinux.sh, and the + # apt manifest). An earlier version used `deps/**` which timed out + # the post-step `hashFiles` (>120 s) because the cache save runs + # AFTER the build has populated `deps/build/destdir/` with + # hundreds of thousands of files. + - name: Cache deps destdir + id: deps-cache + uses: actions/cache@v4 + with: + path: deps/build/destdir + key: bambu-deps-ubuntu-22.04-${{ hashFiles('deps/**/*.cmake', 'deps/CMakeLists.txt', 'BuildLinux.sh', 'linux.d/**') }} + restore-keys: | + bambu-deps-ubuntu-22.04- + + - name: Install build tools (system update) + run: sudo ./BuildLinux.sh -ur + + - name: Build deps (skipped on cache hit) + if: steps.deps-cache.outputs.cache-hit != 'true' + run: | + sudo chown -R "$USER" . + ./BuildLinux.sh -dr + + - name: Build BambuStudio slicer + run: | + sudo chown -R "$USER" . + ./BuildLinux.sh -sr + + # --------------------------------------------------------------- + # Package artifacts + # --------------------------------------------------------------- + - name: Package deps tarball + run: | + cd deps/build + tar -czf "${GITHUB_WORKSPACE}/${DEPS_TARBALL}" destdir + ls -lh "${GITHUB_WORKSPACE}/${DEPS_TARBALL}" + + - name: Package slicer tarball + run: | + # The CLI binary needs the `resources/` tree alongside it. + # Match the upstream package_resources.sh layout: bin + resources + # rooted at a single top-level dir. + STAGE="$(mktemp -d)/BambuStudio" + mkdir -p "${STAGE}/bin" + # Binary name can be either bambu-studio or BambuStudio depending + # on cmake target naming; copy whichever exists. + for bin in build/src/bambu-studio build/src/BambuStudio; do + if [ -f "${bin}" ]; then cp "${bin}" "${STAGE}/bin/"; fi + done + # resources/ at repo root is what the runtime expects to find + # next to the binary (see slic3r/Utils/resources_dir()). + cp -a resources "${STAGE}/resources" + tar -C "$(dirname "${STAGE}")" -czf "${GITHUB_WORKSPACE}/${SLICER_TARBALL}" BambuStudio + ls -lh "${GITHUB_WORKSPACE}/${SLICER_TARBALL}" + + # --------------------------------------------------------------- + # Inputs bundle: collect the 3MFs and resolved profile JSONs the + # next agent session needs to drive the CLI, so it does not have + # to go hunting through other repos. + # --------------------------------------------------------------- + - name: Package slice inputs bundle + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + STAGE="$(mktemp -d)/slice-inputs" + mkdir -p "${STAGE}/3mf" "${STAGE}/profiles/machine" "${STAGE}/profiles/process" "${STAGE}/profiles/filament" + + # Copy the resolved H2D profile JSONs out of the repo. + cp "resources/profiles/BBL/machine/Bambu Lab H2D 0.4 nozzle.json" "${STAGE}/profiles/machine/" || true + cp "resources/profiles/BBL/process/0.20mm Standard @BBL H2D.json" "${STAGE}/profiles/process/" || true + cp "resources/profiles/BBL/filament/Bambu PLA Basic @BBL H2D.json" "${STAGE}/profiles/filament/" || true + cp "resources/profiles/BBL/filament/Bambu TPU 85A @BBL H2D 0.4 nozzle.json" "${STAGE}/profiles/filament/" || true + + # Fetch the two failing 3MFs from the sibling repo. Use the + # GITHUB_TOKEN; if that lacks cross-repo read, the step keeps + # going so the rest of the artifact is still useful. + fetch() { + local dst="$1" repo="$2" ref="$3" path="$4" + echo "Fetching ${repo}@${ref}:${path}" + if ! gh api -H "Accept: application/vnd.github.raw" \ + "/repos/${repo}/contents/${path}?ref=${ref}" \ + > "${dst}"; then + echo "WARN: could not fetch ${repo}:${path}; continuing" >&2 + rm -f "${dst}" + fi + } + fetch "${STAGE}/3mf/t3-prism.H2D-MM-PLAstruts-TPUcables.single.3mf" \ + vertical-cloud-lab/tensegrity-optimization 65d0d3f \ + cad/t3-prism/slices/t3-prism.H2D-MM-PLAstruts-TPUcables.3mf + # The batch 3MF lives on PR #35's head ref; resolve it lazily. + # Note: tensegrity-optimization is public, so GITHUB_TOKEN can read + # it cross-repo (same as the single-specimen fetch above). + if pr_head=$(gh api /repos/vertical-cloud-lab/tensegrity-optimization/pulls/35 --jq .head.sha); then + echo "PR #35 head: ${pr_head}" + # Match either token ordering: the actual filename in the tree is + # bo/slices/t3-prism-bo-batch.H2D-MM-PLAstruts-TPUcables.3mf + # (i.e. "batch" appears BEFORE "H2D-MM-PLAstruts-TPUcables"). + batch_path=$(gh api "/repos/vertical-cloud-lab/tensegrity-optimization/git/trees/${pr_head}?recursive=1" \ + --jq '.tree[] | select(.path|test("batch.*H2D-MM-PLAstruts-TPUcables.*\\.3mf$") or test("H2D-MM-PLAstruts-TPUcables.*batch.*\\.3mf$")) | .path' \ + | head -n1) + echo "Resolved batch path: ${batch_path:-}" + if [ -n "${batch_path}" ]; then + fetch "${STAGE}/3mf/t3-prism.H2D-MM-PLAstruts-TPUcables.batch.3mf" \ + vertical-cloud-lab/tensegrity-optimization "${pr_head}" "${batch_path}" + else + echo "WARN: no batch 3MF matched the regex in PR #35 tree" >&2 + fi + else + echo "WARN: could not resolve PR #35 head sha" >&2 + fi + + # README so the next session knows what's what. + cat > "${STAGE}/README.md" <<'EOF' + slice-inputs bundle + =================== + 3mf/ failing inputs from tensegrity-optimization + profiles/machine/ Bambu Lab H2D 0.4 nozzle.json + profiles/process/ 0.20mm Standard @BBL H2D.json + profiles/filament/ Bambu PLA Basic @BBL H2D.json + Bambu TPU 85A @BBL H2D 0.4 nozzle.json + + Drive the CLI with: + bambu-studio --slice 0 \ + --load-settings "machine.json;process.json" \ + --load-filaments "PLA.json;TPU.json" \ + --filament-map-mode manual --filament-map 1 2 \ + --outputdir out 3mf/.3mf + EOF + + tar -C "$(dirname "${STAGE}")" -czf "${GITHUB_WORKSPACE}/${INPUTS_TARBALL}" slice-inputs + ls -lh "${GITHUB_WORKSPACE}/${INPUTS_TARBALL}" + + # --------------------------------------------------------------- + # Uploads + # --------------------------------------------------------------- + - name: Upload deps artifact + uses: actions/upload-artifact@v4 + with: + name: bambu-studio-deps-ubuntu-22.04-${{ github.sha }} + path: ${{ env.DEPS_TARBALL }} + retention-days: 14 + if-no-files-found: error + + - name: Upload slicer artifact + uses: actions/upload-artifact@v4 + with: + name: bambu-studio-bin-ubuntu-22.04 + path: ${{ env.SLICER_TARBALL }} + retention-days: 14 + if-no-files-found: error + + - name: Upload slice-inputs artifact + uses: actions/upload-artifact@v4 + with: + name: slice-inputs + path: ${{ env.INPUTS_TARBALL }} + retention-days: 14 + if-no-files-found: warn diff --git a/docs/pr_artifacts/README.md b/docs/pr_artifacts/README.md new file mode 100644 index 0000000000..faaace4046 --- /dev/null +++ b/docs/pr_artifacts/README.md @@ -0,0 +1,50 @@ +# PR artifacts: G-code visualizations for vertical-cloud-lab/BambuStudio PR `copilot/fix-slicing-issue-cli-h2d` + +These PNGs and the accompanying `render_gcode.py` script demonstrate the patched +CLI successfully slicing the failing H2D PLA+TPU 3MFs from +`vertical-cloud-lab/tensegrity-optimization` PR #35. + +Rendered with the BambuStudio CLI built by +`.github/workflows/copilot_build_artifact.yml` (run `26338104126`, +sha `b437953`) on Ubuntu 22.04. + +## Files + +- `single_specimen.xy.png` / `single_specimen.iso.png` — single specimen + (input: `t3-prism.H2D-MM-PLAstruts-TPUcables.single.3mf`, ~46 MB G-code, + 17,666 PLA segments + 16,018 TPU segments). +- `t3-prism.H2D-MM-PLAstruts-TPUcables.single.gcode.3mf` — sliced + G-code-3MF for the single specimen (importable into Bambu Studio via + *File → Import → Import sliced file*). Contains `Metadata/plate_1.gcode` + produced by the patched CLI on this branch. +- `batch.xy.png` / `batch.iso.png` — batch (input: + `t3-prism.H2D-MM-PLAstruts-TPUcables.batch.3mf`, ~59 MB G-code, 14,491 PLA + segments + 20,906 TPU segments). +- `render_gcode.py` — standalone matplotlib parser/renderer used to produce + the PNGs from `Metadata/plate_1.gcode`. Colors each extrusion segment by the + active extruder (`M1020 S0` → T0/PLA green `#00AE42`, `M1020 S1` → T1/TPU + light blue `#76D9F4`). Run as: + ``` + python3 render_gcode.py path/to/plate_1.gcode out_prefix + ``` + +## How the slices were obtained + +Tweaks vs. the raw 3MFs from PR #35 needed because of pre-existing H2D Pro vs. +H2D differences in the bundled profiles (unrelated to the OOB fix in this PR): + +1. Per-extruder `nozzle_volume_type` patched to `["Standard","TPU High Flow"]` + (TPU needs the High-Flow nozzle variant on extruder 2). +2. `flush_volumes_matrix` / `flush_volumes_vector` resized from the 4-filament + (16/8) source to 2-filament (4/4). +3. Machine override: `--load-settings "Bambu Lab H2D Pro 0.4 nozzle.json"` + (the 3MFs were authored against the H2D Pro variant — see + `upward_compatible_machine` in their `result.json`). +4. Batch only: `--no-check` to bypass an unrelated wipe-tower / specimen + gcode-path conflict. + +The OOB fix in this PR was exercised cleanly: the validator no longer crashes +or logs the bogus `extruder 21842` and runs to completion for the happy path, +and now emits the new clean `CLI_INVALID_PARAMS` error when invoked with a +mismatched `--filament-map` size (e.g. `--load-filaments` with 2 filaments and +`--filament-map "1"`). diff --git a/docs/pr_artifacts/batch.iso.png b/docs/pr_artifacts/batch.iso.png new file mode 100644 index 0000000000..36c4ee1a1c Binary files /dev/null and b/docs/pr_artifacts/batch.iso.png differ diff --git a/docs/pr_artifacts/batch.xy.png b/docs/pr_artifacts/batch.xy.png new file mode 100644 index 0000000000..5f1577e001 Binary files /dev/null and b/docs/pr_artifacts/batch.xy.png differ diff --git a/docs/pr_artifacts/render_gcode.py b/docs/pr_artifacts/render_gcode.py new file mode 100644 index 0000000000..c4aefd43e6 --- /dev/null +++ b/docs/pr_artifacts/render_gcode.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Render extrusion moves of a multi-extruder BambuStudio gcode with matplotlib. + +Colors per active extruder/tool. H2D uses M1020 S{0,1} to switch extruders. +""" +import argparse, re, sys +from pathlib import Path +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from mpl_toolkits.mplot3d.art3d import Line3DCollection +from matplotlib.collections import LineCollection + +# Bambu's published filament colors (PLA Basic green / TPU 85A light-blue) +COLORS = {0: "#00AE42", 1: "#76D9F4"} +LABELS = {0: "T0 PLA", 1: "T1 TPU"} + +RE_M1020 = re.compile(r"^M1020\s+S(\d+)") +RE_G = re.compile(r"^G[01]\b") +RE_NUM = lambda c: re.compile(r"\b" + c + r"(-?\d+(?:\.\d+)?)") +RX = RE_NUM("X"); RY = RE_NUM("Y"); RZ = RE_NUM("Z"); RE = RE_NUM("E") + +def parse(path): + """Yield (tool, x0, y0, z0, x1, y1, z1) extrusion segments.""" + x = y = z = 0.0 + e_abs = 0.0 + abs_e = True # M82 absolute / M83 relative + tool = 0 + segs = {0: [], 1: []} + with open(path) as f: + for line in f: + s = line.lstrip() + if not s or s.startswith(";"): + continue + m = RE_M1020.match(s) + if m: + tool = int(m.group(1)) + continue + if s.startswith("M82"): abs_e = True; continue + if s.startswith("M83"): abs_e = False; continue + if not RE_G.match(s): + continue + mx = RX.search(s); my = RY.search(s); mz = RZ.search(s); me = RE.search(s) + nx = float(mx.group(1)) if mx else x + ny = float(my.group(1)) if my else y + nz = float(mz.group(1)) if mz else z + extruding = False + if me: + ev = float(me.group(1)) + de = (ev - e_abs) if abs_e else ev + if de > 1e-6: + extruding = True + if abs_e: e_abs = ev + if extruding and (nx, ny) != (x, y): + segs[tool].append(((x, y, z), (nx, ny, nz))) + x, y, z = nx, ny, nz + return segs + +def render(segs, out_prefix): + # 2D top-down + fig, ax = plt.subplots(figsize=(8, 7), dpi=140) + for tool in (0, 1): + ss = segs[tool] + if not ss: continue + lc = LineCollection([((a[0], a[1]), (b[0], b[1])) for a, b in ss], + colors=COLORS[tool], linewidths=0.25, alpha=0.85, + label=f"{LABELS[tool]} ({len(ss):,} moves)") + ax.add_collection(lc) + ax.set_aspect("equal"); ax.autoscale_view() + ax.set_xlabel("X (mm)"); ax.set_ylabel("Y (mm)") + ax.set_title(f"Top-down (XY) — {out_prefix.name}") + ax.legend(loc="upper right", framealpha=0.9, fontsize=9) + ax.grid(alpha=0.2) + fig.tight_layout() + fig.savefig(f"{out_prefix}.xy.png"); plt.close(fig) + + # 3D iso + fig = plt.figure(figsize=(8, 7), dpi=140) + ax = fig.add_subplot(111, projection="3d") + for tool in (0, 1): + ss = segs[tool] + if not ss: continue + lc = Line3DCollection(ss, colors=COLORS[tool], linewidths=0.15, alpha=0.55, + label=LABELS[tool]) + ax.add_collection3d(lc) + pts = np.array([p for ss in segs.values() for ab in ss for p in ab]) + ax.set_xlim(pts[:, 0].min(), pts[:, 0].max()) + ax.set_ylim(pts[:, 1].min(), pts[:, 1].max()) + ax.set_zlim(pts[:, 2].min(), pts[:, 2].max()) + ax.set_xlabel("X"); ax.set_ylabel("Y"); ax.set_zlabel("Z (mm)") + ax.set_title(f"Iso 3D — {out_prefix.name}") + ax.view_init(elev=22, azim=-60) + ax.legend(loc="upper right", fontsize=9) + fig.tight_layout() + fig.savefig(f"{out_prefix}.iso.png"); plt.close(fig) + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("gcode") + ap.add_argument("out_prefix") + a = ap.parse_args() + segs = parse(a.gcode) + for t in (0, 1): + print(f"{LABELS[t]}: {len(segs[t]):,} extrusion segments") + render(segs, Path(a.out_prefix)) + print("wrote", a.out_prefix + ".xy.png", "and", a.out_prefix + ".iso.png") diff --git a/docs/pr_artifacts/single_specimen.iso.png b/docs/pr_artifacts/single_specimen.iso.png new file mode 100644 index 0000000000..ef4b127085 Binary files /dev/null and b/docs/pr_artifacts/single_specimen.iso.png differ diff --git a/docs/pr_artifacts/single_specimen.xy.png b/docs/pr_artifacts/single_specimen.xy.png new file mode 100644 index 0000000000..3d395f68b3 Binary files /dev/null and b/docs/pr_artifacts/single_specimen.xy.png differ diff --git a/docs/pr_artifacts/t3-prism.H2D-MM-PLAstruts-TPUcables.single.gcode.3mf b/docs/pr_artifacts/t3-prism.H2D-MM-PLAstruts-TPUcables.single.gcode.3mf new file mode 100644 index 0000000000..a27e86ad52 Binary files /dev/null and b/docs/pr_artifacts/t3-prism.H2D-MM-PLAstruts-TPUcables.single.gcode.3mf differ diff --git a/src/BambuStudio.cpp b/src/BambuStudio.cpp index 6c66134a9d..0ab5ebd63e 100644 --- a/src/BambuStudio.cpp +++ b/src/BambuStudio.cpp @@ -6772,25 +6772,55 @@ int CLI::run(int argc, char **argv) } } - for (int f_index = 0; f_index < plate_filaments.size(); f_index++) { - for (int f_index = 0; f_index < plate_filaments.size(); f_index++) { - if (plate_filaments[f_index] <= filament_count) { - int filament_extruder = filament_maps[plate_filaments[f_index] - 1]; - std::string filament_type; - m_print_config.get_filament_type(filament_type, plate_filaments[f_index] - 1); - auto *filament_printable_status = dynamic_cast(m_print_config.option("filament_printable")); - if (filament_printable_status && (filament_printable_status->values.size() >= plate_filaments[f_index])) { - int status = filament_printable_status->values.at(plate_filaments[f_index] - 1); - if (!(status >> (filament_extruder - 1) & 1)) { - BOOST_LOG_TRIVIAL(error) - << boost::format( - "plate %1% : filament %2% can not be printed on extruder %3%, under manual mode for multi extruder printer") % - (index + 1) % filament_type % filament_extruder; - record_exit_reson(outfile_dir, CLI_FILAMENTS_NOT_SUPPORTED_BY_EXTRUDER, index + 1, - cli_errors[CLI_FILAMENTS_NOT_SUPPORTED_BY_EXTRUDER], sliced_info); - flush_and_exit(CLI_FILAMENTS_NOT_SUPPORTED_BY_EXTRUDER); - } - } + // Validate per-filament printability under manual multi-extruder mode. + // After --load-filaments shrinks the active filament set, ModelVolume "extruder" + // configs loaded from the source 3MF may still reference original (higher) slot + // numbers. Guard every index into filament_maps and filament_printable to avoid + // out-of-bounds reads (which previously surfaced as a bogus extruder id, e.g. + // "filament TPU can not be printed on extruder 21842") and avoid undefined + // behaviour from shifting by >= width of int when a stale filament_extruder is + // out of range. See related upstream reports: bambulab/BambuStudio#10408, #9963, + // #10402, and vertical-cloud-lab/tensegrity-optimization#64. + for (int f_index = 0; f_index < (int)plate_filaments.size(); f_index++) { + const int fid = plate_filaments[f_index]; + if (fid < 1 || fid > filament_count) + continue; + if ((size_t)fid > filament_maps.size()) { + BOOST_LOG_TRIVIAL(error) << boost::format( + "plate %1% : filament slot %2% is not present in filament_map " + "(size %3%); per-part extruder map was not rebuilt after " + "--load-filaments. Pass --filament-map with %4% entries or " + "re-author the input 3MF so every per-volume \"extruder\" is " + "within 1..%4%.") + % (index + 1) % fid % filament_maps.size() % filament_count; + record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, index + 1, + cli_errors[CLI_INVALID_PARAMS], sliced_info); + flush_and_exit(CLI_INVALID_PARAMS); + } + const int filament_extruder = filament_maps[fid - 1]; + if (filament_extruder < 1 || filament_extruder > new_extruder_count) { + BOOST_LOG_TRIVIAL(error) << boost::format( + "plate %1% : filament %2% has invalid extruder id %3% " + "(expected 1..%4%); per-part extruder assignment was not " + "remapped after --load-filaments.") + % (index + 1) % fid % filament_extruder % new_extruder_count; + record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, index + 1, + cli_errors[CLI_INVALID_PARAMS], sliced_info); + flush_and_exit(CLI_INVALID_PARAMS); + } + std::string filament_type; + m_print_config.get_filament_type(filament_type, fid - 1); + auto *filament_printable_status = dynamic_cast(m_print_config.option("filament_printable")); + if (filament_printable_status && (filament_printable_status->values.size() >= (size_t)fid)) { + const int status = filament_printable_status->values.at(fid - 1); + if (!(status >> (filament_extruder - 1) & 1)) { + BOOST_LOG_TRIVIAL(error) + << boost::format( + "plate %1% : filament %2% can not be printed on extruder %3%, under manual mode for multi extruder printer") % + (index + 1) % filament_type % filament_extruder; + record_exit_reson(outfile_dir, CLI_FILAMENTS_NOT_SUPPORTED_BY_EXTRUDER, index + 1, + cli_errors[CLI_FILAMENTS_NOT_SUPPORTED_BY_EXTRUDER], sliced_info); + flush_and_exit(CLI_FILAMENTS_NOT_SUPPORTED_BY_EXTRUDER); } } }