Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions .github/workflows/copilot_build_artifact.yml
Original file line number Diff line number Diff line change
@@ -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:-<none>}"
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/<input>.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
50 changes: 50 additions & 0 deletions docs/pr_artifacts/README.md
Original file line number Diff line number Diff line change
@@ -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"`).
Binary file added docs/pr_artifacts/batch.iso.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr_artifacts/batch.xy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
107 changes: 107 additions & 0 deletions docs/pr_artifacts/render_gcode.py
Original file line number Diff line number Diff line change
@@ -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")
Binary file added docs/pr_artifacts/single_specimen.iso.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr_artifacts/single_specimen.xy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading