diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5fd685d6..4432dfd5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -55,3 +55,18 @@ print(response[0].formatted_answer) - Whenever compiling LaTeX documents, compile a `diff` PDF using `latexdiff`. For example, `main-diff.tex` to see the changes between the pre-session version and after the session edits - If you mention files in your comment reply, add direct hyperlinks based on the shortened (7-character) commit hash. For example, if you recompiled `main.pdf`, include a hyperlink: `[main.pdf](https://github.com/binder-jetting-sdl/main.pdf?raw=true)`. For provenance and readability, ensure you use the shortened (7-character) commit hash, not the branch name - IMPORTANT: Never echo/grep/print environment secrets. These should never be exposed in your terminal history or other outputs + +## Hardware / target printer + +- The lab's only 3D printer is the **Bambu Lab H2D**. All slicing, + print-prep, and `.gcode.3mf` / project `.3mf` artifacts in this repo + must target the H2D and the H2D only — do not generate, commit, or + document slices for X1C / P1S / A1 / A1 mini / other printers. +- For PETG on the H2D, use the bundled BambuStudio profiles + `Bambu Lab H2D 0.4 nozzle` (machine), `0.20mm Standard @BBL H2D` + (process), and `Bambu PETG Basic @BBL H2D 0.4 nozzle` (filament). +- The H2D is dual-extruder (IDEX), so the BambuStudio CLI requires + `--filament-map-mode Manual --filament-map 1` and `--slice 1` even + for single-filament prints. +- See [`cad/t3-prism/render_print.sh`](../cad/t3-prism/render_print.sh) + for the verified end-to-end recipe. diff --git a/.gitignore b/.gitignore index 97901654..4557565e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ Thumbs.db # IDE .vscode/ *.code-workspace + +# CAD scratch/scratch slicer outputs (committed slices live under cad/*/slices/) +/tmp/t3-prism/ +__pycache__/ diff --git a/README.md b/README.md index 692ceff1..f7b89bbf 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ This proposal develops a multifidelity Bayesian optimization framework to design │ ├── coverpage.tex # MRG cover page (abstract, budget table, external funding) │ ├── budget.tex # Budget table and justification │ └── biosketch.tex # PI and Co-PI biographical sketches +├── cad/ +│ └── t3-prism/ # T3-prism (3-strut tensegrity), PETG, Bambu-sliced ├── Makefile # Build commands ├── .gitignore └── README.md diff --git a/bo/README.md b/bo/README.md new file mode 100644 index 00000000..1da3fb41 --- /dev/null +++ b/bo/README.md @@ -0,0 +1,312 @@ +# T3-prism Bayesian-optimization batch generator + +Single-iteration, **human-in-the-loop** first batch of T3-prism specimens +for the lab's BO campaign — issued in response to PR #35 comment +[`4503109338`](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4503109338) +from @sgbaird. + +This script is a *restricted* adaptation of the full multi-topology BO +scaffold on +[`copilot/scaffold-bayesian-optimization-script`](https://github.com/vertical-cloud-lab/tensegrity-optimization/blob/copilot/scaffold-bayesian-optimization-script/bo/tensegrity_campaign.py) +(PR #30 / PR #24). The full scaffold sweeps every topology, tiling, +material pairing, and build orientation in the project's Edison-curated +literature table; this script freezes everything that isn't specific to +the **T3-prism** geometry, because the team has only confirmed printability +for T3-prisms so far (PRs #16 / #30 / #24 / #35). + +## What's swept + +| Variable | Range | Maps to `cad/t3-prism/t3-prism.scad` | +| ------------- | -------------- | ------------------------------------ | +| `R_mm` | [25.0, 40.0] | `R_base` (radius of each end cap) | +| `H_mm` | [60.0, 110.0] | `H_base` (height between caps) | +| `twist_deg` | [40, 80] | `twist` | +| `strut_d_mm` | [6.0, 12.0] | `strut_d_base` (PLA strut Ø) | +| `cable_d_mm` | [3.0, 5.5] | `cable_d_base` (TPU cable Ø) | + +All five are continuous (`type: range`) so the Sobol sequence covers them +uniformly. The cable diameter lower bound of 3 mm is the empirical "Bambu +auto-support threshold" from PR #35 — below it the top-triangle TPU +bridges fail mid-print (the `cable_d = 2.4 mm` spaghetti event diagnosed +by Edison ANALYSIS `25c1c897`). + +The Sobol coordinates above are the **design coordinates** (what the BO +model sees). The **as-printed** dimensions differ by a per-specimen uniform +scale, because every specimen is projected onto the constant-mass manifold — +see [Constraints](#constraints-constant-mass--max-envelope-volume) below. +After projection the as-printed `cable_d` can fall below the 3 mm bridge +floor; those specimens are flagged `cable_bridge_ok=False` in the CSV +(acceptable under the manual-painted-supports workflow — the floor only +matters for unsupported self-bridging). + +## Constraints (constant mass + max envelope volume) + +Per PR #35 comment `5132975378` (@sgbaird) the batch enforces the two +constraints of the PR #33 hybrid campaign +([`simulations/sim_bo_hybrid_campaign.py`](https://github.com/vertical-cloud-lab/tensegrity-optimization/blob/copilot/explore-simulations-for-tensegrity/simulations/sim_bo_hybrid_campaign.py)): + +* **Route A — constant cell mass `m*`.** Each specimen's `(R, H, strut_d, + cable_d, joint_d)` are uniformly re-scaled (twist and all shape ratios + preserved) until its estimated as-printed mass equals `m*`. The default + `m*` is the solid-volume mass of the committed S0 reference design + (`cad/t3-prism/t3-prism-{struts,cables}.stl` — the geometry of the most + recent instrumented prints): `V_struts·1.24 g/cm³ + V_cables·1.21 g/cm³`. + The sensor housings are absolute-size physical fixtures and do **not** + scale, so the solve iterates on rendered STL volumes + (`m(s) = m_housings + m_body(1)·s³`) until `|m − m*| ≤ 0.15 g` — the + converged mass includes every real geometry feature (captive cores, + teardrops, skirts, housings, boolean overlaps). +* **Route B — max envelope volume `V*`.** `envelope_cm3 = π·R_print²·H_print` + (circumscribing cylinder of the prism, the + `bo_evaluator.cell_geometry_metrics` definition) must be ≤ **250 cm³** + (`sim_bo_hybrid_campaign.DEFAULT_ENVELOPE_MAX_CM3`). Because the uniform + scale is consumed by the mass constraint, a shape whose envelope still + exceeds `V*` at `m*` is **constraint-infeasible**: it is flagged + `envelope_ok=False` in the CSV/JSON rather than silently dropped or + re-scaled (printing it still yields a valid infeasibility observation + for the BO model; excluding it from the plate is the team's call). + +Both targets are CLI-overridable (`--mass-g`, `--envelope-max-cm3`). + +## What's frozen (and why) + +| Variable | Value | Reason | +| -------------------- | ----------------- | ------ | +| `topology` | `t3_prism` | Only printable family so far | +| `tiling` | `1x1x1` | Single unit cell | +| `struts_per_cell` | `3` | T3 by definition | +| `build_orientation` | `vertical` | Per comment: "maximize the number on the build plate" | +| `tpu_shore` | `85A` | NinjaFlex-class lab default | +| `strut_material` | `PLA` (extruder 1)| Production target on this branch | +| `cable_material` | `TPU` (extruder 2)| Production target on this branch | +| `supports` | `manual_painted` | Per comment: "@achris0520 will manually paint on supports" | +| `joint_d_mm` | `7.0` | minimum vertex shell diameter; captive-core upsizes to ≥ `cable_d + 5.4 mm` when needed (see [Captive TPU core](#captive-tpu-core-inside-pla-outer-shell-joints) below) | +| `use_captive_core` | `true` | every joint is a captive TPU core sphere inside a hollow PLA outer shell with a uniform spherical PLA wall and three cable-exit bores (PR #35 comment [`4511036510`](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4511036510), bonded per [`4513722886`](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4513722886)); identical geometry to `cad/t3-prism/t3-prism.scad` | + +The slicer-side modeled-in PLA scaffold pillars from PR #35 commit +`5437366` are **not** emitted here — they were a workaround for the +slicer's auto-support gap on near-vertical TPU cables, and Audrey's +painted-supports approach in PR #35 comments `4502140147` / +`4502171087` supersedes them. + +## Rendered from the canonical SCAD (sensor housings included) + +Since PR #35 comment `5132975378` the generator no longer carries its own +embedded SCAD template. Each specimen is rendered **directly from +[`cad/t3-prism/t3-prism.scad`](../cad/t3-prism/t3-prism.scad)** via `-D` +parameter overrides (`R_base`, `H_base`, `twist`, `strut_d_base`, +`cable_d_base`, `joint_d_base`, `scale_factor`, `part`), so every specimen +automatically carries the **latest** joint and sensor-housing design and can +never drift out of sync with the single-specimen CAD again. At HEAD that +means: captive-core joints (bonded, teardrop blend), the three top-vertex +rounded "igloo" accelerometer mounts (A3 explicit 6.2 × 6.2 × 6.8 mm +pocket), and the three beside-mounted flat bottom key-seats hovering above +the plate. The housings are physical-part fixtures in absolute mm — they do +not scale with the specimen. + +## Captive TPU core inside PLA outer shell joints + +Every vertex of every specimen is a captive-core joint (mirrors +[`cad/t3-prism/t3-prism.scad`](../cad/t3-prism/t3-prism.scad) — see +"Captive TPU core inside PLA outer shell" there for the geometry +rationale and bond-mechanics motivation, PR #35 comment +[`4511036510`](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4511036510)). +Per specimen, the SCAD template computes: + +``` +bore_d = cable_d # bonded — TPU fills bore (PR #35 comment 4513722886) +core_od = max(bore_d + 2 * 1.5, joint_d) # ≥ bore + 3 mm trap +shell_id = core_od # bonded — TPU core touches PLA inner wall +shell_od = max(shell_id + 2 * 1.6, joint_d) # 1.6 mm PLA wall +``` + +For the BO sweep's `cable_d ∈ [3.0, 5.5] mm`, shell_od therefore varies +from **10.0 mm** (small-cable specimens, clamped to `joint_d=7` → bore +3.8 → core 6.8 → still smaller than joint_d so shell_od defaults to +`bore + 5.4` = 9.2 mm; with the second `max(., joint_d)` clamp, shell_id +collapses to ≥7 mm and final shell_od to `max(8 + 3.2, 7)` = 11.2 mm) +to **13.2 mm** (`cable_d=5.5` specimens). The plate-grid cells are sized +from each specimen's **measured** STL footprint (joint shells, igloo +mounts, and beside-mounted bottom key-seats included), so every specimen +fits inside its own cell. + +## How to run + +```bash +sudo apt-get install -y openscad admesh xvfb \ + gstreamer1.0-plugins-base libsoup-3.0-0 libwebkit2gtk-4.1-0 +python3 bo/t3_prism_sobol_batch.py # default n=9, pinned first-batch designs +``` + +By default the 9 first-batch Sobol design coordinates are read back from +the committed `t3-prism-bo-batch.csv`, so re-running regenerates the same +physical designs against the current CAD (no `ax-platform` install +needed). Knobs: + +* `--n N` — number of specimens (default `9`, packed `3 rows × 3 cols` on + the 350×320 mm H2D plate with a 50 mm +X strip held back for the IDEX + prime/flush tower and a 6 mm inter-cell air gap; since the constant-mass + projection the grid uses **variable column widths / row heights** — the + largest specimens share a column and a row — so 3×3 still fits inside + the prime-tower-reduced usable area) +* `--resample` — draw a fresh Sobol batch via Ax instead of reusing the + pinned designs (requires `pip install ax-platform`; `--seed S` applies) +* `--designs-csv PATH`: take the design coordinates from any CSV carrying + the five swept columns instead of the pinned first-batch table. This is + how a **model-based** round gets built: point it at the suggestion table + written by `bo/t3_prism_bo_campaign.py` + (`t3-prism-bo-suggestions-roundN.csv`), which uses the same column names. + The row's `trial_index` is carried into the batch table as `source_trial` + so each plate specimen traces back to its Ax trial. +* `--out-prefix NAME`: basename for every emitted artifact (default + `t3-prism-bo-batch`). Use it so a BO round lands beside the pinned Sobol + batch instead of overwriting it (e.g. `--out-prefix t3-prism-bo-round1` + writes `t3-prism-bo-round1.csv`, `-struts.stl`, `-plate.png`, + `slices/t3-prism-bo-round1.H2D-MM-PLAstruts-TPUcables.3mf`, and + `per-specimen-stls/t3-prism-bo-round1-specNN-*.stl`). +* `--mass-g M` — override the Route-A constant mass m\* (default: computed + from the committed S0 reference STLs) +* `--envelope-max-cm3 V` — override the Route-B envelope cap V\* (default 250) +* `--jobs J` — parallel OpenSCAD render workers (default 4) +* `--skip-render` — CSV/JSON with analytic scale estimates only (CI smoke test) +* `--skip-mm-3mf` — skip the BambuStudio CLI multi-material project assembly + +## Outputs (next to this script) + +* `t3-prism-bo-batch.csv` — design table: one row per specimen with + the original Sobol coordinates (`R_mm` … `cable_d_mm`), the constant-mass + projection (`scale`, `*_print_mm` as-printed dimensions), the mass audit + (`mass_g`, `pla_g`, `tpu_g`, `mass_target_g`, `mass_ok`), and the + constraint flags (`envelope_cm3`, `envelope_max_cm3`, `envelope_ok`, + `cable_bridge_ok`) +* `t3-prism-bo-batch.json` — same data plus constraint + plate-layout metadata +* `t3-prism-bo-batch.scad` — preview wrapper that `import()`s the + plate-positioned per-specimen STLs, with a + `part = "all"|"struts"|"cables"` switch (the geometry itself is rendered + from `cad/t3-prism/t3-prism.scad`) +* `t3-prism-bo-batch.stl` — packed STL, struts + cables fused + (preview / single-material use only — Bambu Studio cannot split this + into PLA and TPU after import) +* `t3-prism-bo-batch-struts.stl` — struts + captive-core PLA shells (uniform spherical wall) + per-cable bores at exactly `cable_d` so the TPU cable fills them without an air ring (extruder 1 / PLA, PR #35 comment [`4513722886`](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4513722886)) +* `t3-prism-bo-batch-cables.stl` — cables + captive TPU core spheres at every vertex (sized to contact the PLA inner wall so the two materials bond at the vertex) + a zero-width z-anchor that pins the cables-STL bounding box to the struts-STL bounding box (extruder 2 / TPU). The z-anchor fixes the "horizontal cables too low at top and bottom" misalignment reported above PR #35 comment [`4511036510`](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4511036510). +* `per-specimen-stls/t3-prism-bo-specNN-{struts,cables}.stl` — one struts STL + one cables STL **per specimen**, used by the `--assemble` step so the final `.3mf` exposes one composite object per specimen with two part groups (PLA + TPU) rather than one giant composite with `2N` ungrouped parts (PR #35 comment [`4513722886`](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4513722886)) +* `t3-prism-bo-batch-plate.png` — top-down build-plate preview +* `t3-prism-bo-batch-iso.png` — iso preview +* `slices/t3-prism-bo-batch.H2D-MM-PLAstruts-TPUcables.3mf` — **production-target + Bambu H2D multi-material project file.** Re-importable into Bambu Studio + with each specimen exposing two parts (struts/PLA on extruder 1, cables/TPU + on extruder 2) so the team can split-to-parts and assign filaments per + PR #35 comment [`4503267471`](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4503267471). + Supports are intentionally OFF; @achris0520 paints them on per + comment [`4502140147`](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4502140147). + +## Round 1 (model-based, mass-aware) plate + +The first model-based batch. Its nine designs are the round-1 suggestions +from the mass-aware campaign in PR #102 (comment +[`5365706779`](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/102#issuecomment-5365706779)), +Ax trials 10 to 18, committed here verbatim as +`t3-prism-bo-suggestions-round1.csv`. Rebuild it with: + +```bash +python3 bo/t3_prism_sobol_batch.py \ + --designs-csv bo/t3-prism-bo-suggestions-round1.csv \ + --out-prefix t3-prism-bo-round1 +``` + +Everything else is unchanged from the Sobol batch: same constant-mass +projection onto m\* = 30.95 g, same 250 cm^3 envelope cap, same captive-core +joints and A3 sensor housings, same 3x3 plate with the 50 mm prime-tower +reserve, supports off for manual painting. Artifacts carry the +`t3-prism-bo-round1` prefix; the production file is +`slices/t3-prism-bo-round1.H2D-MM-PLAstruts-TPUcables.3mf`. + +All nine clear the envelope cap and land within 0.15 g of the mass target. +Three (specimens 00, 02, 05 = trials 10, 12, 15) fall to an as-printed cable +diameter of 2.6 to 2.7 mm, below the 3.0 mm TPU self-bridging floor, and are +flagged `cable_bridge_ok=False` in the design table. That is the expected +consequence of holding mass constant on a thick-strut, thin-cable design +(12 mm struts and 3 mm cables at base scale), and it is workable under the +manual-painted-supports workflow, but those three need the most careful +support painting on the top-triangle cables. + +### Manually-supported round-1 project (as prepared for printing) + +`slices/t3-prism-bo-round1.H2D-MM-PLAstruts-TPUcables_manual-supports.3mf` +is @achris0520's Bambu Studio project for this plate, uploaded in PR #35 +comment [`5374553137`](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-5374553137). +It is the generated round-1 project with supports painted by hand on all +nine specimens and the printing presets applied, so it is the file that was +actually sent to the printer rather than a regenerated artifact. + +| Setting | Value | +| --- | --- | +| Printer / process | Bambu Lab H2D 0.6 nozzle, 0.30mm Standard @BBL H2D 0.6 nozzle | +| Filaments | Bambu PLA Basic (ext 1, struts) + Bambu TPU 85A (ext 2, cables) | +| Filament map | Manual, `['1','2']` | +| Supports | `tree(manual)`, painted, `support_filament=1` (PLA), build-plate only | +| Prime tower | on, 60 mm | +| Infill / walls | 15% sparse, 2 walls | +| Bed | Textured PEI Plate | + +Painted-support coverage is stored per object as `paint_supports` triangle +attributes: 16,628 painted facets across the nine `3D/Objects/object_*.model` +parts. + +Slicing it headlessly (BambuStudio CLI v02.06.00.51) gives 13 h 58 m, +255.45 g PLA + 66.94 g TPU, with 198,567 support and 63,545 +support-interface extrusion moves out of 883,256 total. Two provenance +renders of that g-code are committed next to the design table: +`t3-prism-bo-round1-manual-supports-sliced-iso.png` and +`-sliced-top.png`, both produced by + +```bash +RS_MODEL_MAX=220000 python3 cad/t3-prism/render_supports.py \ + plate_1.gcode bo/t3-prism-bo-round1-manual-supports-sliced-iso.png +RS_MODEL_MAX=220000 RS_VIEW=89,-90 python3 cad/t3-prism/render_supports.py \ + plate_1.gcode bo/t3-prism-bo-round1-manual-supports-sliced-top.png +``` + +`RS_MODEL_MAX` raises the model-segment cap (the default 40,000 leaves a +nine-specimen plate looking like haze) and `RS_VIEW` sets `elev,azim`. +`t3-prism-bo-round1-manual-supports-bambu-plate.png` is Bambu Studio's own +plate thumbnail carried inside the project file. + +The CLI run itself ends with `return_code=-102` ("G-code in unprintable area +of multi-extruder printers"), the same headless IDEX extruder-mapping +limitation documented in `cad/t3-prism/render_print.sh` `slice_bambu_mm`. It +writes valid toolpaths, which is what the renders above are drawn from, but +the printable job still has to come out of the Bambu Studio GUI. + +## Onshape spot-check upload + +The per-specimen STLs can be pushed to a public Onshape document so the +geometry can be inspected/measured in a browser before printing (PR #35 +comment [`5133453991`](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-5133453991)). +`cad/t3-prism/onshape_upload_t3prism.py` accepts an explicit STL list, a +document name, and a concurrency setting: + +```bash +PREFIX=t3-prism-bo-round1 # or t3-prism-bo for the pinned Sobol batch +ARGS="" +for i in 00 01 02 03 04 05 06 07 08; do + ARGS="$ARGS --stl spec$i-struts-PLA=bo/per-specimen-stls/$PREFIX-spec$i-struts.stl" + ARGS="$ARGS --stl spec$i-cables-TPU=bo/per-specimen-stls/$PREFIX-spec$i-cables.stl" +done +python3 cad/t3-prism/onshape_upload_t3prism.py \ + --doc-name "T3-prism BO round 1 - constant mass (PR #35)" --jobs 6 $ARGS +``` + +Each import's bounding box is read back through the API and printed in mm as +a scale check — it should match the local STL extents exactly (the +`/partstudios/.../boundingboxes` endpoint reports millimetres, not metres). + +## Reporting outcomes back + +This first batch deliberately **does not** call `complete_trial(...)`. +The full closed-loop campaign lives in +[`bo/tensegrity_campaign.py`](https://github.com/vertical-cloud-lab/tensegrity-optimization/blob/copilot/scaffold-bayesian-optimization-script/bo/tensegrity_campaign.py) +on the `copilot/scaffold-bayesian-optimization-script` branch; once +measured F_peak / SEA / η land for these nine specimens, hand them to the +closed-loop campaign as already-observed Sobol trials before requesting +the first model-based batch. diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec00-cables.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec00-cables.stl new file mode 100644 index 00000000..7076e7a3 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec00-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec00-struts.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec00-struts.stl new file mode 100644 index 00000000..63999095 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec00-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec01-cables.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec01-cables.stl new file mode 100644 index 00000000..254331d1 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec01-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec01-struts.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec01-struts.stl new file mode 100644 index 00000000..a2c569d6 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec01-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec02-cables.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec02-cables.stl new file mode 100644 index 00000000..20a559dd Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec02-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec02-struts.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec02-struts.stl new file mode 100644 index 00000000..bfa58b36 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec02-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec03-cables.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec03-cables.stl new file mode 100644 index 00000000..b94bd5c5 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec03-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec03-struts.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec03-struts.stl new file mode 100644 index 00000000..6de1dabd Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec03-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec04-cables.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec04-cables.stl new file mode 100644 index 00000000..d417432a Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec04-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec04-struts.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec04-struts.stl new file mode 100644 index 00000000..be22bdce Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec04-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec05-cables.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec05-cables.stl new file mode 100644 index 00000000..d59a8bca Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec05-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec05-struts.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec05-struts.stl new file mode 100644 index 00000000..8c291687 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec05-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec06-cables.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec06-cables.stl new file mode 100644 index 00000000..7ebb456f Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec06-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec06-struts.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec06-struts.stl new file mode 100644 index 00000000..5b30bb4e Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec06-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec07-cables.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec07-cables.stl new file mode 100644 index 00000000..71bba2d6 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec07-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec07-struts.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec07-struts.stl new file mode 100644 index 00000000..e3c88e45 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec07-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec08-cables.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec08-cables.stl new file mode 100644 index 00000000..ba1a14bb Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec08-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-round1-spec08-struts.stl b/bo/per-specimen-stls/t3-prism-bo-round1-spec08-struts.stl new file mode 100644 index 00000000..ccbaf1fe Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-round1-spec08-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec00-cables.stl b/bo/per-specimen-stls/t3-prism-bo-spec00-cables.stl new file mode 100644 index 00000000..27517a9f Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec00-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec00-struts.stl b/bo/per-specimen-stls/t3-prism-bo-spec00-struts.stl new file mode 100644 index 00000000..ae6a3cf3 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec00-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec01-cables.stl b/bo/per-specimen-stls/t3-prism-bo-spec01-cables.stl new file mode 100644 index 00000000..4a2af296 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec01-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec01-struts.stl b/bo/per-specimen-stls/t3-prism-bo-spec01-struts.stl new file mode 100644 index 00000000..b1125a28 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec01-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec02-cables.stl b/bo/per-specimen-stls/t3-prism-bo-spec02-cables.stl new file mode 100644 index 00000000..fff6777c Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec02-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec02-struts.stl b/bo/per-specimen-stls/t3-prism-bo-spec02-struts.stl new file mode 100644 index 00000000..9929ede8 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec02-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec03-cables.stl b/bo/per-specimen-stls/t3-prism-bo-spec03-cables.stl new file mode 100644 index 00000000..6cec65b8 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec03-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec03-struts.stl b/bo/per-specimen-stls/t3-prism-bo-spec03-struts.stl new file mode 100644 index 00000000..e81aa990 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec03-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec04-cables.stl b/bo/per-specimen-stls/t3-prism-bo-spec04-cables.stl new file mode 100644 index 00000000..845658d8 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec04-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec04-struts.stl b/bo/per-specimen-stls/t3-prism-bo-spec04-struts.stl new file mode 100644 index 00000000..7a27aa00 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec04-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec05-cables.stl b/bo/per-specimen-stls/t3-prism-bo-spec05-cables.stl new file mode 100644 index 00000000..a65785b4 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec05-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec05-struts.stl b/bo/per-specimen-stls/t3-prism-bo-spec05-struts.stl new file mode 100644 index 00000000..70437c73 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec05-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec06-cables.stl b/bo/per-specimen-stls/t3-prism-bo-spec06-cables.stl new file mode 100644 index 00000000..9f1521b4 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec06-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec06-struts.stl b/bo/per-specimen-stls/t3-prism-bo-spec06-struts.stl new file mode 100644 index 00000000..421dd97d Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec06-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec07-cables.stl b/bo/per-specimen-stls/t3-prism-bo-spec07-cables.stl new file mode 100644 index 00000000..d1422004 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec07-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec07-struts.stl b/bo/per-specimen-stls/t3-prism-bo-spec07-struts.stl new file mode 100644 index 00000000..12409d65 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec07-struts.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec08-cables.stl b/bo/per-specimen-stls/t3-prism-bo-spec08-cables.stl new file mode 100644 index 00000000..0fcd38cc Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec08-cables.stl differ diff --git a/bo/per-specimen-stls/t3-prism-bo-spec08-struts.stl b/bo/per-specimen-stls/t3-prism-bo-spec08-struts.stl new file mode 100644 index 00000000..ab01d127 Binary files /dev/null and b/bo/per-specimen-stls/t3-prism-bo-spec08-struts.stl differ diff --git a/bo/slices/t3-prism-bo-batch.H2D-MM-PLAstruts-TPUcables.3mf b/bo/slices/t3-prism-bo-batch.H2D-MM-PLAstruts-TPUcables.3mf new file mode 100644 index 00000000..e465a3f2 Binary files /dev/null and b/bo/slices/t3-prism-bo-batch.H2D-MM-PLAstruts-TPUcables.3mf differ diff --git a/bo/slices/t3-prism-bo-round1.H2D-MM-PLAstruts-TPUcables.3mf b/bo/slices/t3-prism-bo-round1.H2D-MM-PLAstruts-TPUcables.3mf new file mode 100644 index 00000000..f39a5b89 Binary files /dev/null and b/bo/slices/t3-prism-bo-round1.H2D-MM-PLAstruts-TPUcables.3mf differ diff --git a/bo/slices/t3-prism-bo-round1.H2D-MM-PLAstruts-TPUcables_manual-supports.3mf b/bo/slices/t3-prism-bo-round1.H2D-MM-PLAstruts-TPUcables_manual-supports.3mf new file mode 100644 index 00000000..5012ed74 Binary files /dev/null and b/bo/slices/t3-prism-bo-round1.H2D-MM-PLAstruts-TPUcables_manual-supports.3mf differ diff --git a/bo/t3-prism-bo-batch-cables.stl b/bo/t3-prism-bo-batch-cables.stl new file mode 100644 index 00000000..4c0b3052 Binary files /dev/null and b/bo/t3-prism-bo-batch-cables.stl differ diff --git a/bo/t3-prism-bo-batch-iso.png b/bo/t3-prism-bo-batch-iso.png new file mode 100644 index 00000000..ee81a3e4 Binary files /dev/null and b/bo/t3-prism-bo-batch-iso.png differ diff --git a/bo/t3-prism-bo-batch-plate.png b/bo/t3-prism-bo-batch-plate.png new file mode 100644 index 00000000..6781aa38 Binary files /dev/null and b/bo/t3-prism-bo-batch-plate.png differ diff --git a/bo/t3-prism-bo-batch-struts.stl b/bo/t3-prism-bo-batch-struts.stl new file mode 100644 index 00000000..852e5053 Binary files /dev/null and b/bo/t3-prism-bo-batch-struts.stl differ diff --git a/bo/t3-prism-bo-batch.csv b/bo/t3-prism-bo-batch.csv new file mode 100644 index 00000000..4dcc77cb --- /dev/null +++ b/bo/t3-prism-bo-batch.csv @@ -0,0 +1,10 @@ +specimen,R_mm,H_mm,twist_deg,strut_d_mm,cable_d_mm,scale,R_print_mm,H_print_mm,strut_d_print_mm,cable_d_print_mm,joint_d_print_mm,mass_g,pla_g,tpu_g,mass_target_g,mass_ok,envelope_cm3,envelope_max_cm3,envelope_ok,cable_bridge_ok,build_orientation,cable_material,joint_d_mm,strut_material,struts_per_cell,supports,tiling,topology,tpu_shore +0,32.1266,89.6262,59.7792,7.8831,5.3902,0.8497,27.299,76.157,6.698,4.580,5.948,30.93,20.04,10.89,30.95,True,178.3,250.0,True,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +1,33.7842,80.0836,77.4080,10.8717,3.0003,0.8502,28.722,68.084,9.243,2.551,5.951,30.97,27.33,3.64,30.95,True,176.5,250.0,True,False,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +2,38.9665,99.9950,47.6915,7.0737,3.9241,0.9351,36.436,93.502,6.614,3.669,6.545,30.93,21.74,9.19,30.95,True,390.0,250.0,False,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +3,25.1224,72.0587,65.1213,10.1789,4.6640,0.8639,21.703,62.251,8.794,4.029,6.047,30.90,23.99,6.91,30.95,True,92.1,250.0,True,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +4,27.6114,104.1304,70.4432,9.2816,4.4922,0.8228,22.718,85.675,7.637,3.696,5.759,30.93,24.04,6.88,30.95,True,138.9,250.0,True,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +5,36.3001,63.2297,52.9940,6.4571,4.0550,1.0437,37.885,65.990,6.739,4.232,7.306,30.95,19.84,11.11,30.95,True,297.5,250.0,False,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +6,35.9821,96.4464,62.1055,11.6620,3.4949,0.7748,27.877,74.722,9.035,2.708,5.423,30.93,26.90,4.04,30.95,True,182.4,250.0,True,False,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +7,30.1066,74.8199,44.4573,8.5803,4.9377,0.8892,26.771,66.531,7.630,4.391,6.224,30.91,21.34,9.57,30.95,True,149.8,250.0,True,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +8,29.0207,100.8663,63.7624,6.1990,3.1969,1.0396,30.171,104.864,6.445,3.324,7.277,30.95,23.45,7.50,30.95,True,299.9,250.0,False,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A diff --git a/bo/t3-prism-bo-batch.json b/bo/t3-prism-bo-batch.json new file mode 100644 index 00000000..1df98ba3 --- /dev/null +++ b/bo/t3-prism-bo-batch.json @@ -0,0 +1,251 @@ +{ + "seed": 0, + "n": 9, + "designs_source": "pinned-csv", + "constraints": { + "mass_target_g": 30.94789906161643, + "mass_tol_g": 0.15, + "housing_mass_g": 6.76107521259489, + "envelope_max_cm3": 250.0, + "rho_pla_g_per_cm3": 1.24, + "rho_tpu_g_per_cm3": 1.21, + "mass_anchor": "solid-volume mass of cad/t3-prism/t3-prism-{struts,cables}.stl (S0 reference)", + "envelope_definition": "pi * R_print^2 * H_print (bo_evaluator.cell_geometry_metrics)" + }, + "grid": { + "rows": 3, + "cols": 3, + "air_gap_mm": 6.0, + "col_widths_mm": [ + 104.81476593017578, + 84.515869140625, + 82.17830657958984 + ], + "row_heights_mm": [ + 104.81476593017578, + 100.93431854248047, + 89.34628295898438 + ], + "total_w_mm": 283.5089416503906, + "total_h_mm": 307.0953674316406 + }, + "plate": { + "x_mm": 350.0, + "y_mm": 320.0, + "margin_mm": 5.0, + "prime_tower_reserve_x_mm": 50.0 + }, + "parameters": [ + { + "name": "R_mm", + "type": "range", + "bounds": [ + 25.0, + 40.0 + ], + "value_type": "float" + }, + { + "name": "H_mm", + "type": "range", + "bounds": [ + 60.0, + 110.0 + ], + "value_type": "float" + }, + { + "name": "twist_deg", + "type": "range", + "bounds": [ + 40.0, + 80.0 + ], + "value_type": "float" + }, + { + "name": "strut_d_mm", + "type": "range", + "bounds": [ + 6.0, + 12.0 + ], + "value_type": "float" + }, + { + "name": "cable_d_mm", + "type": "range", + "bounds": [ + 3.0, + 5.5 + ], + "value_type": "float" + } + ], + "frozen": { + "topology": "t3_prism", + "tiling": "1x1x1", + "struts_per_cell": 3, + "build_orientation": "vertical", + "tpu_shore": "85A", + "strut_material": "PLA", + "cable_material": "TPU", + "supports": "manual_painted", + "joint_d_mm": 7.0 + }, + "specimens": [ + { + "idx": 0, + "R_mm": 32.1266, + "H_mm": 89.6262, + "twist_deg": 59.7792, + "strut_d_mm": 7.8831, + "cable_d_mm": 5.3902, + "scale": 0.8497230566276587, + "mass_g": 30.926944412147286, + "pla_g": 20.041614308007187, + "tpu_g": 10.885330104140097, + "envelope_cm3": 178.29805116251842, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 4.580177219834406, + "cable_bridge_ok": true + }, + { + "idx": 1, + "R_mm": 33.7842, + "H_mm": 80.0836, + "twist_deg": 77.408, + "strut_d_mm": 10.8717, + "cable_d_mm": 3.0003, + "scale": 0.8501637046188762, + "mass_g": 30.970713783018578, + "pla_g": 27.33153734872297, + "tpu_g": 3.6391764342956066, + "envelope_cm3": 176.45274519923257, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 2.5507461629680144, + "cable_bridge_ok": false + }, + { + "idx": 2, + "R_mm": 38.9665, + "H_mm": 99.995, + "twist_deg": 47.6915, + "strut_d_mm": 7.0737, + "cable_d_mm": 3.9241, + "scale": 0.9350697451209848, + "mass_g": 30.92856265601218, + "pla_g": 21.741898882090762, + "tpu_g": 9.186663773921417, + "envelope_cm3": 389.9805712587428, + "envelope_ok": false, + "mass_ok": true, + "cable_d_print_mm": 3.669307186829257, + "cable_bridge_ok": true + }, + { + "idx": 3, + "R_mm": 25.1224, + "H_mm": 72.0587, + "twist_deg": 65.1213, + "strut_d_mm": 10.1789, + "cable_d_mm": 4.664, + "scale": 0.8638972212745527, + "mass_g": 30.896115456150937, + "pla_g": 23.98508406426259, + "tpu_g": 6.911031391888345, + "envelope_cm3": 92.11805915233444, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 4.029216640024513, + "cable_bridge_ok": true + }, + { + "idx": 4, + "R_mm": 27.6114, + "H_mm": 104.1304, + "twist_deg": 70.4432, + "strut_d_mm": 9.2816, + "cable_d_mm": 4.4922, + "scale": 0.8227635095702184, + "mass_g": 30.926458450597757, + "pla_g": 24.044718604541686, + "tpu_g": 6.881739846056071, + "envelope_cm3": 138.9086627704761, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 3.6960182376913355, + "cable_bridge_ok": true + }, + { + "idx": 5, + "R_mm": 36.3001, + "H_mm": 63.2297, + "twist_deg": 52.994, + "strut_d_mm": 6.4571, + "cable_d_mm": 4.055, + "scale": 1.0436540598612751, + "mass_g": 30.951056000706934, + "pla_g": 19.836440530758583, + "tpu_g": 11.11461546994835, + "envelope_cm3": 297.5475217804567, + "envelope_ok": false, + "mass_ok": true, + "cable_d_print_mm": 4.23201721273747, + "cable_bridge_ok": true + }, + { + "idx": 6, + "R_mm": 35.9821, + "H_mm": 96.4464, + "twist_deg": 62.1055, + "strut_d_mm": 11.662, + "cable_d_mm": 3.4949, + "scale": 0.7747501713564962, + "mass_g": 30.93048034689992, + "pla_g": 26.89523548949109, + "tpu_g": 4.035244857408832, + "envelope_cm3": 182.42903028238513, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 2.7076743738738185, + "cable_bridge_ok": false + }, + { + "idx": 7, + "R_mm": 30.1066, + "H_mm": 74.8199, + "twist_deg": 44.4573, + "strut_d_mm": 8.5803, + "cable_d_mm": 4.9377, + "scale": 0.8892100935062199, + "mass_g": 30.909362630160285, + "pla_g": 21.339725524302203, + "tpu_g": 9.569637105858082, + "envelope_cm3": 149.79715751221295, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 4.390652678705663, + "cable_bridge_ok": true + }, + { + "idx": 8, + "R_mm": 29.0207, + "H_mm": 100.8663, + "twist_deg": 63.7624, + "strut_d_mm": 6.199, + "cable_d_mm": 3.1969, + "scale": 1.0396369852241698, + "mass_g": 30.948991472356347, + "pla_g": 23.446975296387738, + "tpu_g": 7.5020161759686115, + "envelope_cm3": 299.8864850725926, + "envelope_ok": false, + "mass_ok": true, + "cable_d_print_mm": 3.3236154780631484, + "cable_bridge_ok": true + } + ] +} \ No newline at end of file diff --git a/bo/t3-prism-bo-batch.scad b/bo/t3-prism-bo-batch.scad new file mode 100644 index 00000000..cfa61940 --- /dev/null +++ b/bo/t3-prism-bo-batch.scad @@ -0,0 +1,52 @@ +// AUTO-GENERATED by bo/t3_prism_sobol_batch.py — do not hand-edit. +// Preview wrapper for the T3-prism Sobol batch: imports the +// per-specimen STLs rendered from cad/t3-prism/t3-prism.scad +// (latest captive-core joints + A3 igloo top mounts + beside- +// mounted flat bottom key-seats), each projected onto the +// constant-mass manifold (PR #35 comment 5132975378). +// Plate: 350 x 320 mm (Bambu Lab H2D). +// Grid : 3 x 3 (cell 104.8 mm). +part = "all"; // "all" | "struts" | "cables" + +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-spec00-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-spec00-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-spec01-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-spec01-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-spec02-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-spec02-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-spec03-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-spec03-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-spec04-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-spec04-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-spec05-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-spec05-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-spec06-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-spec06-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-spec07-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-spec07-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-spec08-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-spec08-cables.stl"); + +// Visual marker for the IDEX prime/flush-tower reserve zone. +if (part == "all") { + translate([305.00, 5.00, 0]) + cube([40.00, 310.00, 0.2]); +} diff --git a/bo/t3-prism-bo-batch.stl b/bo/t3-prism-bo-batch.stl new file mode 100644 index 00000000..8ee96abe Binary files /dev/null and b/bo/t3-prism-bo-batch.stl differ diff --git a/bo/t3-prism-bo-round1-cables.stl b/bo/t3-prism-bo-round1-cables.stl new file mode 100644 index 00000000..f5094530 Binary files /dev/null and b/bo/t3-prism-bo-round1-cables.stl differ diff --git a/bo/t3-prism-bo-round1-iso.png b/bo/t3-prism-bo-round1-iso.png new file mode 100644 index 00000000..8a3b8cb0 Binary files /dev/null and b/bo/t3-prism-bo-round1-iso.png differ diff --git a/bo/t3-prism-bo-round1-manual-supports-bambu-plate.png b/bo/t3-prism-bo-round1-manual-supports-bambu-plate.png new file mode 100644 index 00000000..923f7070 Binary files /dev/null and b/bo/t3-prism-bo-round1-manual-supports-bambu-plate.png differ diff --git a/bo/t3-prism-bo-round1-manual-supports-sliced-iso.png b/bo/t3-prism-bo-round1-manual-supports-sliced-iso.png new file mode 100644 index 00000000..4052cd42 Binary files /dev/null and b/bo/t3-prism-bo-round1-manual-supports-sliced-iso.png differ diff --git a/bo/t3-prism-bo-round1-manual-supports-sliced-top.png b/bo/t3-prism-bo-round1-manual-supports-sliced-top.png new file mode 100644 index 00000000..746755a3 Binary files /dev/null and b/bo/t3-prism-bo-round1-manual-supports-sliced-top.png differ diff --git a/bo/t3-prism-bo-round1-plate.png b/bo/t3-prism-bo-round1-plate.png new file mode 100644 index 00000000..43bb4b6a Binary files /dev/null and b/bo/t3-prism-bo-round1-plate.png differ diff --git a/bo/t3-prism-bo-round1-struts.stl b/bo/t3-prism-bo-round1-struts.stl new file mode 100644 index 00000000..a2650522 Binary files /dev/null and b/bo/t3-prism-bo-round1-struts.stl differ diff --git a/bo/t3-prism-bo-round1.csv b/bo/t3-prism-bo-round1.csv new file mode 100644 index 00000000..dfd60af5 --- /dev/null +++ b/bo/t3-prism-bo-round1.csv @@ -0,0 +1,10 @@ +specimen,source_trial,R_mm,H_mm,twist_deg,strut_d_mm,cable_d_mm,scale,R_print_mm,H_print_mm,strut_d_print_mm,cable_d_print_mm,joint_d_print_mm,mass_g,pla_g,tpu_g,mass_target_g,mass_ok,envelope_cm3,envelope_max_cm3,envelope_ok,cable_bridge_ok,build_orientation,cable_material,joint_d_mm,strut_material,struts_per_cell,supports,tiling,topology,tpu_shore +0,10,25.0000,60.0000,80.0000,12.0000,3.0000,0.8779,21.946,52.671,10.534,2.634,6.145,30.99,27.88,3.11,30.95,True,79.7,250.0,True,False,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +1,11,40.0000,60.0000,80.0000,12.0000,5.5000,0.7531,30.124,45.186,9.037,4.142,5.272,30.91,22.97,7.94,30.95,True,128.8,250.0,True,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +2,12,25.0000,60.0000,40.0000,12.0000,3.0000,0.8985,22.463,53.912,10.782,2.696,6.290,31.01,27.57,3.44,30.95,True,85.5,250.0,True,False,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +3,13,25.0000,60.0000,80.0000,7.4015,3.0000,1.0920,27.301,65.522,8.083,3.276,7.644,30.95,24.96,5.99,30.95,True,153.4,250.0,True,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +4,14,25.0000,60.0000,40.0000,12.0000,5.5000,0.8159,20.398,48.955,9.791,4.488,5.711,30.88,23.23,7.64,30.95,True,64.0,250.0,True,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +5,15,40.0000,60.0000,40.0000,12.0000,3.0000,0.8722,34.886,52.330,10.466,2.616,6.105,31.00,26.78,4.21,30.95,True,200.1,250.0,True,False,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +6,16,25.0000,60.0000,80.0000,12.0000,5.5000,0.8029,20.072,48.173,9.635,4.416,5.620,30.88,23.83,7.05,30.95,True,61.0,250.0,True,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +7,17,40.0000,110.0000,80.0000,12.0000,5.5000,0.6732,26.927,74.048,8.078,3.702,4.712,30.94,23.91,7.03,30.95,True,168.7,250.0,True,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A +8,18,25.0000,60.0000,80.0000,6.0000,5.5000,1.0023,25.057,60.137,6.014,5.513,7.016,30.85,17.59,13.26,30.95,True,118.6,250.0,True,True,vertical,TPU,7.0,PLA,3,manual_painted,1x1x1,t3_prism,85A diff --git a/bo/t3-prism-bo-round1.json b/bo/t3-prism-bo-round1.json new file mode 100644 index 00000000..0c191312 --- /dev/null +++ b/bo/t3-prism-bo-round1.json @@ -0,0 +1,260 @@ +{ + "seed": 0, + "n": 9, + "designs_source": "bo/t3-prism-bo-suggestions-round1.csv", + "constraints": { + "mass_target_g": 30.94789906161643, + "mass_tol_g": 0.15, + "housing_mass_g": 6.761075212594943, + "envelope_max_cm3": 250.0, + "rho_pla_g_per_cm3": 1.24, + "rho_tpu_g_per_cm3": 1.21, + "mass_anchor": "solid-volume mass of cad/t3-prism/t3-prism-{struts,cables}.stl (S0 reference)", + "envelope_definition": "pi * R_print^2 * H_print (bo_evaluator.cell_geometry_metrics)" + }, + "grid": { + "rows": 3, + "cols": 3, + "air_gap_mm": 6.0, + "col_widths_mm": [ + 97.06892395019531, + 81.1097183227539, + 71.24674987792969 + ], + "row_heights_mm": [ + 97.06892395019531, + 88.19925689697266, + 84.1402587890625 + ], + "total_w_mm": 261.4253921508789, + "total_h_mm": 281.40843963623047 + }, + "plate": { + "x_mm": 350.0, + "y_mm": 320.0, + "margin_mm": 5.0, + "prime_tower_reserve_x_mm": 50.0 + }, + "parameters": [ + { + "name": "R_mm", + "type": "range", + "bounds": [ + 25.0, + 40.0 + ], + "value_type": "float" + }, + { + "name": "H_mm", + "type": "range", + "bounds": [ + 60.0, + 110.0 + ], + "value_type": "float" + }, + { + "name": "twist_deg", + "type": "range", + "bounds": [ + 40.0, + 80.0 + ], + "value_type": "float" + }, + { + "name": "strut_d_mm", + "type": "range", + "bounds": [ + 6.0, + 12.0 + ], + "value_type": "float" + }, + { + "name": "cable_d_mm", + "type": "range", + "bounds": [ + 3.0, + 5.5 + ], + "value_type": "float" + } + ], + "frozen": { + "topology": "t3_prism", + "tiling": "1x1x1", + "struts_per_cell": 3, + "build_orientation": "vertical", + "tpu_shore": "85A", + "strut_material": "PLA", + "cable_material": "TPU", + "supports": "manual_painted", + "joint_d_mm": 7.0 + }, + "specimens": [ + { + "idx": 0, + "source_trial": "10", + "R_mm": 25.0, + "H_mm": 60.0, + "twist_deg": 80.0, + "strut_d_mm": 12.0, + "cable_d_mm": 3.0, + "scale": 0.8778529902535073, + "mass_g": 30.98968789353593, + "pla_g": 27.880429379058288, + "tpu_g": 3.1092585144776406, + "envelope_cm3": 79.69783408026458, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 2.6335589707605216, + "cable_bridge_ok": false + }, + { + "idx": 1, + "source_trial": "11", + "R_mm": 40.0, + "H_mm": 60.0, + "twist_deg": 80.0, + "strut_d_mm": 12.0, + "cable_d_mm": 5.5, + "scale": 0.7530920025414901, + "mass_g": 30.91038331079331, + "pla_g": 22.974581799849314, + "tpu_g": 7.935801510943997, + "envelope_cm3": 128.81463659449113, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 4.142006013978196, + "cable_bridge_ok": true + }, + { + "idx": 2, + "source_trial": "12", + "R_mm": 25.0, + "H_mm": 60.0, + "twist_deg": 40.0, + "strut_d_mm": 12.0, + "cable_d_mm": 3.0, + "scale": 0.8985370553821969, + "mass_g": 31.00885759812474, + "pla_g": 27.56921563449013, + "tpu_g": 3.439641963634611, + "envelope_cm3": 85.46516125024311, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 2.6956111661465907, + "cable_bridge_ok": false + }, + { + "idx": 3, + "source_trial": "13", + "R_mm": 25.0, + "H_mm": 60.0, + "twist_deg": 80.0, + "strut_d_mm": 7.4015, + "cable_d_mm": 3.0, + "scale": 1.0920308826665797, + "mass_g": 30.949018769759252, + "pla_g": 24.96357656805926, + "tpu_g": 5.9854422016999935, + "envelope_cm3": 153.42138593406446, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 3.276092647999739, + "cable_bridge_ok": true + }, + { + "idx": 4, + "source_trial": "14", + "R_mm": 25.0, + "H_mm": 60.0, + "twist_deg": 40.0, + "strut_d_mm": 12.0, + "cable_d_mm": 5.5, + "scale": 0.8159223942765935, + "mass_g": 30.878337991740292, + "pla_g": 23.23395251928387, + "tpu_g": 7.644385472456421, + "envelope_cm3": 63.992297083373934, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 4.487573168521265, + "cable_bridge_ok": true + }, + { + "idx": 5, + "source_trial": "15", + "R_mm": 40.0, + "H_mm": 60.0, + "twist_deg": 40.0, + "strut_d_mm": 12.0, + "cable_d_mm": 3.0, + "scale": 0.8721610407052737, + "mass_g": 30.997291818279397, + "pla_g": 26.783762656756263, + "tpu_g": 4.213529161523134, + "envelope_cm3": 200.08344410641325, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 2.616483122115821, + "cable_bridge_ok": false + }, + { + "idx": 6, + "source_trial": "16", + "R_mm": 25.0, + "H_mm": 60.0, + "twist_deg": 80.0, + "strut_d_mm": 12.0, + "cable_d_mm": 5.5, + "scale": 0.8028821165383562, + "mass_g": 30.88367173978745, + "pla_g": 23.829971734487657, + "tpu_g": 7.053700005299793, + "envelope_cm3": 60.97284980631642, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 4.415851640960959, + "cable_bridge_ok": true + }, + { + "idx": 7, + "source_trial": "17", + "R_mm": 40.0, + "H_mm": 110.0, + "twist_deg": 80.0, + "strut_d_mm": 12.0, + "cable_d_mm": 5.5, + "scale": 0.6731647737474327, + "mass_g": 30.935296007331097, + "pla_g": 23.909451524770386, + "tpu_g": 7.025844482560712, + "envelope_cm3": 168.66566587405111, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 3.70240625561088, + "cable_bridge_ok": true + }, + { + "idx": 8, + "source_trial": "18", + "R_mm": 25.0, + "H_mm": 60.0, + "twist_deg": 80.0, + "strut_d_mm": 6.0, + "cable_d_mm": 5.5, + "scale": 1.0022811996022751, + "mass_g": 30.8482836184146, + "pla_g": 17.585590621147045, + "tpu_g": 13.262692997267557, + "envelope_cm3": 118.61780759827889, + "envelope_ok": true, + "mass_ok": true, + "cable_d_print_mm": 5.512546597812513, + "cable_bridge_ok": true + } + ] +} \ No newline at end of file diff --git a/bo/t3-prism-bo-round1.scad b/bo/t3-prism-bo-round1.scad new file mode 100644 index 00000000..1b537010 --- /dev/null +++ b/bo/t3-prism-bo-round1.scad @@ -0,0 +1,52 @@ +// AUTO-GENERATED by bo/t3_prism_sobol_batch.py — do not hand-edit. +// Preview wrapper for the T3-prism Sobol batch: imports the +// per-specimen STLs rendered from cad/t3-prism/t3-prism.scad +// (latest captive-core joints + A3 igloo top mounts + beside- +// mounted flat bottom key-seats), each projected onto the +// constant-mass manifold (PR #35 comment 5132975378). +// Plate: 350 x 320 mm (Bambu Lab H2D). +// Grid : 3 x 3 (cell 97.1 mm). +part = "all"; // "all" | "struts" | "cables" + +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-round1-spec00-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-round1-spec00-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-round1-spec01-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-round1-spec01-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-round1-spec02-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-round1-spec02-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-round1-spec03-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-round1-spec03-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-round1-spec04-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-round1-spec04-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-round1-spec05-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-round1-spec05-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-round1-spec06-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-round1-spec06-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-round1-spec07-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-round1-spec07-cables.stl"); +if (part == "all" || part == "struts") + import("per-specimen-stls/t3-prism-bo-round1-spec08-struts.stl"); +if (part == "all" || part == "cables") + import("per-specimen-stls/t3-prism-bo-round1-spec08-cables.stl"); + +// Visual marker for the IDEX prime/flush-tower reserve zone. +if (part == "all") { + translate([305.00, 5.00, 0]) + cube([40.00, 310.00, 0.2]); +} diff --git a/bo/t3-prism-bo-round1.stl b/bo/t3-prism-bo-round1.stl new file mode 100644 index 00000000..85fc7588 Binary files /dev/null and b/bo/t3-prism-bo-round1.stl differ diff --git a/bo/t3-prism-bo-suggestions-round1.csv b/bo/t3-prism-bo-suggestions-round1.csv new file mode 100644 index 00000000..f0d5edf0 --- /dev/null +++ b/bo/t3-prism-bo-suggestions-round1.csv @@ -0,0 +1,10 @@ +round,trial_index,R_mm,H_mm,twist_deg,strut_d_mm,cable_d_mm,pred_t180_mean,pred_t180_sd,pred_e_reb_mJ_mean,pred_e_reb_mJ_sd,pred_mass_g_mean,pred_mass_g_sd,pred_e_rebound_approx +1,10,25.0000,60.0000,80.0000,12.0000,3.0000,0.8728,0.0526,11.3064,4.2891,18.2774,1.2398,0.0414 +1,11,40.0000,60.0000,80.0000,12.0000,5.5000,0.8849,0.0749,12.2474,5.7281,19.3658,1.6581,0.0423 +1,12,25.0000,60.0000,40.0000,12.0000,3.0000,0.9256,0.0945,9.8960,5.0348,19.7571,2.0558,0.0335 +1,13,25.0000,60.0000,80.0000,7.4015,3.0000,0.9477,0.0464,8.3068,3.1761,19.4244,1.1425,0.0286 +1,14,25.0000,60.0000,40.0000,12.0000,5.5000,0.9530,0.0944,8.9780,5.5776,20.5422,2.0416,0.0292 +1,15,40.0000,60.0000,40.0000,12.0000,3.0000,0.9051,0.0912,13.0219,5.1052,20.1192,1.9185,0.0433 +1,16,25.0000,60.0000,80.0000,12.0000,5.5000,0.9102,0.0728,10.0896,5.1953,19.1724,1.5522,0.0352 +1,17,40.0000,110.0000,80.0000,12.0000,5.5000,0.9548,0.0711,9.9712,5.3353,19.7690,1.6286,0.0337 +1,18,25.0000,60.0000,80.0000,6.0000,5.5000,0.9868,0.0663,8.3741,4.3384,20.5024,1.5779,0.0273 diff --git a/bo/t3_prism_sobol_batch.py b/bo/t3_prism_sobol_batch.py new file mode 100644 index 00000000..6000f703 --- /dev/null +++ b/bo/t3_prism_sobol_batch.py @@ -0,0 +1,1215 @@ +"""Single-batch Sobol design generator for the T3-prism BO campaign. + +Per PR #35 comment 4503109338 from @sgbaird (carried over from PR #30 / PR #24): +this is a **single-iteration**, human-in-the-loop, T3-prism-only first batch. +No measured objectives are reported back; this only emits the initial Sobol +quasi-random design set, renders each specimen, packs them onto a single +Bambu H2D build plate, and writes a preview PNG so the team can spot-check +before opening the result in Bambu Studio. + +This is a *restricted* adaptation of ``bo/tensegrity_campaign.py`` from +``copilot/scaffold-bayesian-optimization-script`` (the PR #30 / #24 scaffold). +The full search space there spans every topology, tiling, material pairing, +and build orientation in the project's Edison-curated literature table. Here +we deliberately freeze every variable that is *not* specific to the T3-prism +geometry, since the team has only confirmed printability for T3-prisms so +far (PRs #30 / #24 / #16 / #35). + +Frozen (defaults match the production target on this branch): + +* ``topology`` = ``"t3_prism"`` -- canonical 3-strut tensegrity. +* ``tiling`` = ``"1x1x1"`` -- single unit cell. +* ``struts_per_cell`` = 3 -- T3 by definition. +* ``build_orientation`` = ``"vertical"`` -- per the comment, "Vertically + orient it so you can maximize the number on the build plate". +* Materials: PLA struts + TPU 85A cables on the Bambu H2D IDEX nozzles + (same as ``slices/t3-prism.H2D-MM-PLAstruts-TPUcables.3mf``). +* Supports: OFF in the slicer. Per the comment, "@achris0520 will manually + paint on supports, so you can leave those off"; the modeled-in PLA + scaffold pillars from PR #35 commit 5437366 are likewise disabled here + (``part="all"`` in the SCAD, no ``scaffold`` block). + +Sweep (T3-prism-specific geometric variables, taken from +``cad/t3-prism/t3-prism.scad``): + +================================ ================== ===================== +Variable Range (mm/deg) Maps to SCAD parameter +================================ ================== ===================== +``R_mm`` (radius) [25, 40] ``R_base`` +``H_mm`` (height) [60, 110] ``H_base`` +``twist_deg`` (top-vs-bottom) [40, 80] ``twist`` +``strut_d_mm`` (PLA strut Ø) [6.0, 12.0] ``strut_d_base`` +``cable_d_mm`` (TPU cable Ø) [3.0, 5.5] ``cable_d_base`` +================================ ================== ===================== + +The cable_d lower bound (3.0 mm) sits above the Bambu auto-support detector +threshold @achris0520 hit empirically at scale 1.3x (cable_d ≈ 3.9 mm) and +matches Edison ANALYSIS ``25c1c897``'s recommended floor (3.0–4.0 mm) so even +the smallest cable in the batch will TPU-self-bridge without painted +supports failing mid-print. (NOTE: after the constant-mass projection below +the *as-printed* cable diameter can fall below that floor — flagged per +specimen in the CSV — which is acceptable under the manual-painted-supports +workflow.) + +Rendering (PR #35 comment 5132975378 rework) +-------------------------------------------- +Specimens are no longer generated from an embedded SCAD template. Each +specimen is rendered **directly from the canonical** +``cad/t3-prism/t3-prism.scad`` via ``-D`` parameter overrides, so every +specimen automatically carries the latest joint + sensor-housing design: +captive-core joints, the three top-vertex "igloo" accelerometer mounts +(A3 pocket), and the three beside-mounted flat bottom key-seats. The +housings are PHYSICAL-part fixtures in absolute mm and do not scale. + +Constraints (PR #35 comment 5132975378, per the PR #33 hybrid campaign +``simulations/sim_bo_hybrid_campaign.py``) +-------------------------------------------------------------------------- +* **Route A — constant cell mass.** Every specimen is projected onto the + constant-mass manifold: its (R, H, strut_d, cable_d, joint_d) are + uniformly re-scaled (twist and all shape ratios preserved) until the + estimated as-printed mass equals the fixed target ``m*``. ``m*`` + defaults to the solid-volume mass of the current S0 reference design in + ``cad/t3-prism/`` (the geometry of the team's most recent instrumented + prints), computed from the committed ``t3-prism-{struts,cables}.stl``. + Because the sensor housings don't scale, the solve iterates on rendered + STL volumes (``m(s) = m_housings + m_body(1)·s³``) to |m − m*| ≤ 0.15 g. +* **Route B — max envelope volume.** ``envelope_cm3 = π·R_print²·H_print`` + (circumscribing cylinder, same definition as + ``simulations/bo_evaluator.py::cell_geometry_metrics``) must be + ≤ 250 cm³. The uniform scale is consumed by the mass constraint, so a + shape whose envelope still exceeds V* at m* is CONSTRAINT-INFEASIBLE and + is flagged (``envelope_ok=False``), not silently dropped or re-scaled. + +Output files (next to this script): + +* ``t3-prism-bo-batch.csv`` -- one row per specimen: original Sobol + coordinates + as-printed (mass-projected) + dimensions + mass/envelope constraint columns +* ``t3-prism-bo-batch.json`` -- same data + constraint + plate-layout metadata +* ``t3-prism-bo-batch.scad`` -- preview wrapper (imports the per-specimen STLs) +* ``t3-prism-bo-batch.stl`` -- packed-on-plate combined STL (all parts fused) +* ``t3-prism-bo-batch-struts.stl`` -- struts + joints + housings (extruder 1 / PLA) +* ``t3-prism-bo-batch-cables.stl`` -- cables + captive cores (extruder 2 / TPU) +* ``per-specimen-stls/t3-prism-bo-specNN-{struts,cables}.stl`` -- per-specimen plate-positioned STL pairs +* ``t3-prism-bo-batch-plate.png`` -- top-down build-plate preview PNG +* ``t3-prism-bo-batch-iso.png`` -- iso preview PNG +* ``slices/t3-prism-bo-batch.H2D-MM-PLAstruts-TPUcables.3mf`` -- Bambu H2D MM project (struts/PLA + cables/TPU, + re-importable into Bambu Studio with + per-part extruder assignment; *no* supports — + paint them on manually per @achris0520's tip + in PR #35 comment 4502140147) + +Run:: + + sudo apt-get install -y openscad admesh xvfb \\ + gstreamer1.0-plugins-base libsoup-3.0-0 libwebkit2gtk-4.1-0 + python3 bo/t3_prism_sobol_batch.py + +By default the 9 designs are read back from the committed +``t3-prism-bo-batch.csv`` (the first Sobol batch, seed 0) so the physical +design coordinates stay pinned; pass ``--resample`` to draw a fresh Sobol +batch instead (requires ``pip install ax-platform``). +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import shutil +import struct +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +# ---- Frozen experimental context ------------------------------------------- +TOPOLOGY = "t3_prism" +TILING = "1x1x1" +STRUTS_PER_CELL = 3 +BUILD_ORIENTATION = "vertical" +TPU_SHORE = "85A" # NinjaFlex-class; lab default +STRUT_MATERIAL = "PLA" # Bambu PLA Basic on extruder 1 +CABLE_MATERIAL = "TPU" # Bambu TPU 85A on extruder 2 +SUPPORTS = "manual_painted" # Audrey paints them on in Bambu Studio +JOINT_D_BASE = 7.0 # mm, kept fixed (t3-prism.scad default) + +# ---- Build-plate geometry (Bambu Lab H2D) ---------------------------------- +PLATE_X = 350.0 # mm +PLATE_Y = 320.0 # mm +PLATE_MARGIN = 5.0 # keep specimens off the edge +# Reserve a strip on the +X side of the plate for the IDEX prime / flush +# tower that the slicer drops in for PLA<->TPU material changes. +# Bambu Studio's default prime tower is ~50 mm square; reserving a 50 mm +# wide × full-Y strip on +X gives the slicer ample room to drop the tower +# without colliding with any specimen corner (PR #35 comment 4513445377). +PRIME_TOWER_RESERVE_X = 50.0 + +# ---- Sobol batch knobs ----------------------------------------------------- +# 9 specimens packed 3x3 on the H2D plate. PR #35 comment 4513164299 +# briefly dropped this to 6 (3x2) to make room for an extra-fat 70 mm +# prime-tower reserve, but PR #35 comment 4513445377 reverted to 3x3 with +# a smaller 50 mm tower reserve and a 6 mm inter-cell air gap (up from +# the original 2 mm that was "too tight last time" per comment +# 4513164299, but tighter than the temporary 12 mm used at n=6 so 3x3 +# still fits inside the prime-tower-reduced 290x310 mm usable area). +N_SPECIMENS = 9 # 3 rows x 3 cols +SEED = 0 + +# ---- Search space (T3-prism-specific geometric variables only) ------------- +# Bounds reconciled against: +# * cad/t3-prism/t3-prism.scad's existing defaults at scale_factor=1.5 +# (R=37.5, H=105, strut_d=9, cable_d=4.5). +# * PR #24 Edison literature table 5ae24eaf (strut_d 1.5-5 mm pre-scale, +# L/D in [8,25], cable_d 1.0-3.0 mm pre-scale, twist 10-45 deg). +# * PR #35 print history: cable_d >= 3.0 mm is required for the top-cable +# bridge to survive without auto-supports failing. +PARAMETERS: list[dict] = [ + {"name": "R_mm", "type": "range", "bounds": [25.0, 40.0], "value_type": "float"}, + {"name": "H_mm", "type": "range", "bounds": [60.0, 110.0], "value_type": "float"}, + {"name": "twist_deg", "type": "range", "bounds": [40.0, 80.0], "value_type": "float"}, + {"name": "strut_d_mm", "type": "range", "bounds": [ 6.0, 12.0], "value_type": "float"}, + {"name": "cable_d_mm", "type": "range", "bounds": [ 3.0, 5.5], "value_type": "float"}, +] + + +# ---- Constraints (PR #33 hybrid campaign: Route A + Route B) ---------------- +# See the module docstring. m* defaults to the solid-volume mass of the +# committed S0 reference STLs (the most recent instrumented prints); V* is +# sim_bo_hybrid_campaign.DEFAULT_ENVELOPE_MAX_CM3. +RHO_PLA = 1.24e-3 # g/mm^3, solid — Bambu PLA Basic +RHO_TPU = 1.21e-3 # g/mm^3, solid — Bambu TPU 85A +DEFAULT_ENVELOPE_MAX_CM3 = 250.0 +MASS_TOL_G = 0.15 # |m - m*| convergence tolerance for the scale solve +MAX_MASS_ITERS = 4 +CABLE_BRIDGE_FLOOR_MM = 3.0 # empirical TPU self-bridging floor (Edison 25c1c897) + +REPO_ROOT = Path(__file__).resolve().parent.parent +T3_PRISM_DIR = REPO_ROOT / "cad" / "t3-prism" +CANONICAL_SCAD = T3_PRISM_DIR / "t3-prism.scad" +REF_STRUTS_STL = T3_PRISM_DIR / "t3-prism-struts.stl" +REF_CABLES_STL = T3_PRISM_DIR / "t3-prism-cables.stl" + + +# ---- Binary-STL helpers (volume / bbox / translate / merge) ----------------- +def _stl_records(data: bytes): + n = struct.unpack_from(" tuple[float, list[float], list[float]]: + """Signed volume (mm^3) + axis-aligned bbox of a binary STL. + + The signed-tetrahedron sum handles the hollow captive-core shells + correctly (inner cavity surfaces subtract), unlike ``admesh`` which + mis-reports these multi-part meshes. + """ + data = path.read_bytes() + vol = 0.0 + mins = [math.inf] * 3 + maxs = [-math.inf] * 3 + for v in _stl_records(data): + ax, ay, az, bx, by, bz, cx, cy, cz = v + vol += (ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + + az * (bx * cy - by * cx)) / 6.0 + for x, y, z in ((ax, ay, az), (bx, by, bz), (cx, cy, cz)): + mins[0] = min(mins[0], x); maxs[0] = max(maxs[0], x) + mins[1] = min(mins[1], y); maxs[1] = max(maxs[1], y) + mins[2] = min(mins[2], z); maxs[2] = max(maxs[2], z) + return abs(vol), mins, maxs + + +def stl_translate(src: Path, dst: Path, dx: float, dy: float, dz: float) -> None: + """Copy a binary STL with a rigid XYZ translation applied to every vertex.""" + data = bytearray(src.read_bytes()) + n = struct.unpack_from(" None: + """Concatenate binary STLs into one (disjoint solids; no boolean union).""" + bodies: list[bytes] = [] + total = 0 + for src in srcs: + data = src.read_bytes() + n = struct.unpack_from(" float: + """Analytic solid-mass estimate of one specimen at scale 1, EXCLUDING the + absolute-size sensor housings. Only used as the initial guess for the + rendered-volume scale solve (boolean overlaps make it ~±10 %); the solve + itself iterates on measured STL volumes so the fixed point is exact. + """ + R, H, tw = p["R_mm"], p["H_mm"], p["twist_deg"] + sd, cd, jd = p["strut_d_mm"], p["cable_d_mm"], JOINT_D_BASE + l_strut = math.hypot(2 * R * math.sin(math.radians(tw / 2)), H) + l_side = R * math.sqrt(3) + b1 = (R * math.cos(math.radians(210)), R * math.sin(math.radians(210)), 0.0) + t0 = (R * math.cos(math.radians(90 + tw)), R * math.sin(math.radians(90 + tw)), H) + l_saddle = math.dist(b1, t0) + # Captive-core joint sizing (mirrors t3-prism.scad at scale 1). + core_od = max(cd + 3.0, jd) + shell_od = max(core_od + 3.2, jd) + v_pla = 3 * (math.pi * sd * sd / 4 * l_strut + 0.7 * (4 / 3) * math.pi * (sd / 2) ** 3) + v_pla += 6 * (4 / 3) * math.pi * ((shell_od / 2) ** 3 - (core_od / 2) ** 3) + v_tpu = 0.97 * math.pi * cd * cd / 4 * (6 * l_side + 3 * l_saddle) + v_tpu += 0.85 * 6 * (4 / 3) * math.pi * (core_od / 2) ** 3 + return RHO_PLA * v_pla + RHO_TPU * v_tpu + + +def reference_mass_g() -> float: + """m* anchor: solid-volume mass of the committed S0 reference design + (``cad/t3-prism/t3-prism-{struts,cables}.stl`` — the geometry of the + team's most recent instrumented prints, sensor housings included).""" + vs, _, _ = stl_volume_bbox(REF_STRUTS_STL) + vc, _, _ = stl_volume_bbox(REF_CABLES_STL) + return RHO_PLA * vs + RHO_TPU * vc + + +def housing_mass_g(scratch: Path) -> float: + """PLA mass of the six absolute-size sensor housings (3 igloo mounts + + 3 bottom key-seats, skirts included): committed reference struts STL + minus a housings-off render of the same default design.""" + out = scratch / "s0-struts-nohousing.stl" + if not out.exists(): + print("==> OpenSCAD render (once): S0 reference struts w/o housings " + "(for the housing-mass estimate)") + run_openscad(CANONICAL_SCAD, out, defines={ + "part": "struts", + "add_accel_mount": False, + "add_accel_mount_bottom": False, + }) + v_ref, _, _ = stl_volume_bbox(REF_STRUTS_STL) + v_no, _, _ = stl_volume_bbox(out) + return RHO_PLA * (v_ref - v_no) + + +def plan_plate_layout(footprints: list[float]) -> dict: + """Variable-cell rows x cols layout for the (mass-projected) specimens. + + The constant-mass projection leaves the specimens with quite different + footprints (the large-R shapes shrink less in R than the stocky ones), + so a uniform grid sized to the worst case no longer fits 3x3 inside the + prime-tower-reduced usable area. Instead, sort the footprints in + descending order and fill the grid column-major so the largest + specimens share a column and a row; each column takes the width of its + largest occupant and each row the height of its largest occupant, with + a 6 mm inter-cell air gap (PR #35 comment 4513445377) between cells + only (the pack is centred inside the usable area). + + Honours ``PRIME_TOWER_RESERVE_X`` — the +X strip stays clear for the + IDEX prime/flush tower. Returns per-specimen cell centres (in the + original specimen order) plus the grid metadata. + """ + air_gap = 6.0 + n = len(footprints) + rows = math.ceil(math.sqrt(n)) + cols = math.ceil(n / rows) + order = sorted(range(n), key=lambda i: -footprints[i]) + cell_of: dict[int, tuple[int, int]] = {} + for rank, i in enumerate(order): + cell_of[i] = (rank % rows, rank // rows) # column-major fill + col_w = [0.0] * cols + row_h = [0.0] * rows + for i, (r, c) in cell_of.items(): + col_w[c] = max(col_w[c], footprints[i]) + row_h[r] = max(row_h[r], footprints[i]) + total_w = sum(col_w) + air_gap * (cols - 1) + total_h = sum(row_h) + air_gap * (rows - 1) + usable_x = PLATE_X - 2 * PLATE_MARGIN - PRIME_TOWER_RESERVE_X + usable_y = PLATE_Y - 2 * PLATE_MARGIN + if total_w > usable_x or total_h > usable_y: + print( + f"WARNING: packed grid {total_w:.1f}x{total_h:.1f} mm exceeds " + f"usable plate {usable_x:.1f}x{usable_y:.1f} mm " + f"(plate {PLATE_X:.0f}x{PLATE_Y:.0f} - prime-tower reserve " + f"{PRIME_TOWER_RESERVE_X:.0f} mm in +X - {PLATE_MARGIN:.0f} mm " + f"margins)", file=sys.stderr) + # Centre the pack inside the usable (non-prime-tower) area. + x_cursor = PLATE_MARGIN + (usable_x - total_w) / 2.0 + col_cx = [] + for c in range(cols): + col_cx.append(x_cursor + col_w[c] / 2.0) + x_cursor += col_w[c] + air_gap + y_cursor = PLATE_MARGIN + (usable_y - total_h) / 2.0 + row_cy = [] + for r in range(rows): + row_cy.append(y_cursor + row_h[r] / 2.0) + y_cursor += row_h[r] + air_gap + centres = [(col_cx[cell_of[i][1]], row_cy[cell_of[i][0]]) for i in range(n)] + return { + "rows": rows, "cols": cols, "air_gap": air_gap, + "col_widths": col_w, "row_heights": row_h, + "total_w": total_w, "total_h": total_h, + "centres": centres, + } + +SPEC_STL_FMT = "t3-prism-bo-spec{idx:02d}-{part}.stl" + + +def render_specimen(part: str, out: Path, params: dict, scale: float) -> None: + """Render one specimen part straight from the canonical t3-prism.scad. + + Passing the Sobol coordinates as the *_base dimensions with + ``scale_factor = scale`` applies the Route-A constant-mass projection + uniformly to R, H, strut_d, cable_d, and joint_d while keeping the + sensor housings at their absolute physical size (the SCAD never scales + them). Scaffold pillars stay off (supports are manual-painted). + """ + run_openscad(CANONICAL_SCAD, out, defines={ + "R_base": params["R_mm"], + "H_base": params["H_mm"], + "twist": params["twist_deg"], + "strut_d_base": params["strut_d_mm"], + "cable_d_base": params["cable_d_mm"], + "joint_d_base": JOINT_D_BASE, + "scale_factor": scale, + "part": part, + }) + + +def solve_specimen(idx: int, params: dict, m_star: float, m_h: float, + work: Path) -> dict: + """Project one Sobol design onto the constant-mass manifold. + + Iterates the uniform scale ``s`` with the cube-root update + ``s <- s * ((m* - m_h) / (m(s) - m_h))^(1/3)`` on *rendered* STL + volumes, so the converged mass includes every real geometry feature + (captive cores, teardrops, skirts, housings, boolean overlaps). + """ + s = ((m_star - m_h) / estimate_body_mass_g(params)) ** (1.0 / 3.0) + struts = work / SPEC_STL_FMT.format(idx=idx, part="struts") + cables = work / SPEC_STL_FMT.format(idx=idx, part="cables") + m = float("nan") + vs = vc = 0.0 + bb = None + for it in range(1, MAX_MASS_ITERS + 1): + print(f"==> spec{idx:02d} iter {it}: render at scale {s:.4f}") + render_specimen("struts", struts, params, s) + render_specimen("cables", cables, params, s) + vs, smin, smax = stl_volume_bbox(struts) + vc, cmin, cmax = stl_volume_bbox(cables) + bb = ([min(a, b) for a, b in zip(smin, cmin)], + [max(a, b) for a, b in zip(smax, cmax)]) + m = RHO_PLA * vs + RHO_TPU * vc + print(f" spec{idx:02d} iter {it}: m={m:.2f} g (target {m_star:.2f} g)") + if abs(m - m_star) <= MASS_TOL_G: + break + s *= ((m_star - m_h) / max(m - m_h, 1e-9)) ** (1.0 / 3.0) + else: + print(f"WARNING: spec{idx:02d} scale solve stopped at |m - m*| = " + f"{abs(m - m_star):.2f} g after {MAX_MASS_ITERS} iterations", + file=sys.stderr) + return { + "idx": idx, + "scale": s, + "mass_g": m, + "pla_g": RHO_PLA * vs, + "tpu_g": RHO_TPU * vc, + "struts_stl": struts, + "cables_stl": cables, + "bbox_min": bb[0], + "bbox_max": bb[1], + } + + +def write_preview_scad(path: Path, n: int, rows: int, cols: int, + cell: float) -> None: + """Preview wrapper that imports the plate-positioned per-specimen STLs. + + The real geometry lives in the per-specimen STLs (rendered from the + canonical ``cad/t3-prism/t3-prism.scad``); this wrapper only exists so + the plate/iso PNGs and ad-hoc OpenSCAD inspection have a single entry + point. ``part`` mirrors the canonical SCAD ("all" | "struts" | + "cables"). + """ + chunks: list[str] = [ + "// AUTO-GENERATED by bo/t3_prism_sobol_batch.py — do not hand-edit.\n" + "// Preview wrapper for the T3-prism Sobol batch: imports the\n" + "// per-specimen STLs rendered from cad/t3-prism/t3-prism.scad\n" + "// (latest captive-core joints + A3 igloo top mounts + beside-\n" + "// mounted flat bottom key-seats), each projected onto the\n" + "// constant-mass manifold (PR #35 comment 5132975378).\n" + f"// Plate: {PLATE_X:.0f} x {PLATE_Y:.0f} mm (Bambu Lab H2D).\n" + f"// Grid : {rows} x {cols} (cell {cell:.1f} mm).\n" + 'part = "all"; // "all" | "struts" | "cables"\n\n' + ] + for idx in range(n): + for part in ("struts", "cables"): + fname = SPEC_STL_FMT.format(idx=idx, part=part) + chunks.append( + f'if (part == "all" || part == "{part}")\n' + f' import("per-specimen-stls/{fname}");\n' + ) + pt_x = PRIME_TOWER_RESERVE_X - 2 * PLATE_MARGIN + chunks.append( + f"\n// Visual marker for the IDEX prime/flush-tower reserve zone.\n" + f'if (part == "all") {{\n' + f" translate([{PLATE_X - PRIME_TOWER_RESERVE_X + PLATE_MARGIN:.2f}, " + f"{PLATE_MARGIN:.2f}, 0])\n" + f" cube([{pt_x:.2f}, {PLATE_Y - 2 * PLATE_MARGIN:.2f}, 0.2]);\n" + f"}}\n" + ) + path.write_text("".join(chunks)) + + + +def run_openscad(scad: Path, out: Path, *, camera: str | None = None, + image_size: str | None = None, defines: dict | None = None, + viewall: bool = False) -> None: + """Invoke OpenSCAD headlessly via xvfb-run, writing STL or PNG.""" + cmd = ["xvfb-run", "-a", "openscad", "-o", str(out)] + if out.suffix == ".stl": + cmd += ["--export-format=binstl"] + if camera: + cmd += [f"--camera={camera}"] + if viewall: + cmd += ["--viewall"] + if image_size: + cmd += [f"--imgsize={image_size}"] + for k, v in (defines or {}).items(): + if isinstance(v, bool): + cmd += ["-D", f"{k}={'true' if v else 'false'}"] + elif isinstance(v, str): + cmd += ["-D", f'{k}="{v}"'] + else: + cmd += ["-D", f"{k}={v}"] + cmd += [str(scad)] + subprocess.run(cmd, check=True) + + +# ---- Bambu H2D multi-material 3mf assembly --------------------------------- +# Reuses the BambuStudio AppImage cache + flatten/patch helpers from +# `cad/t3-prism/render_print.sh`. Per PR #35 comment 4503267471 the BO batch +# project file must open in Bambu Studio with two parts that can be assigned +# different filaments (struts -> PLA / extruder 1, cables -> TPU / extruder 2) +# — the single combined STL we used previously imported as a single fused +# object so Bambu Studio could not split-to-parts. +BAMBU_VERSION = "v02.06.00.51" +BAMBU_URL = ( + "https://github.com/bambulab/BambuStudio/releases/download/" + f"{BAMBU_VERSION}/BambuStudio_ubuntu-24.04-{BAMBU_VERSION}" + "-20260417160415.AppImage" +) +SCRATCH = Path("/tmp/t3-prism") +BAMBU_APPIMAGE = SCRATCH / "bambu.AppImage" +BBL_ROOT = SCRATCH / "squashfs-root" / "resources" / "profiles" / "BBL" + + +def _ensure_bambu() -> None: + """Download the BambuStudio AppImage and extract the bundled BBL profiles.""" + SCRATCH.mkdir(parents=True, exist_ok=True) + if not BAMBU_APPIMAGE.exists(): + print(f"==> Fetching BambuStudio {BAMBU_VERSION} AppImage") + subprocess.run(["curl", "-sLo", str(BAMBU_APPIMAGE), BAMBU_URL], check=True) + BAMBU_APPIMAGE.chmod(0o755) + if not BBL_ROOT.exists(): + print("==> Extracting bundled BBL profiles from AppImage") + subprocess.run( + [str(BAMBU_APPIMAGE), "--appimage-extract", "resources/profiles/BBL"], + cwd=SCRATCH, check=True, stdout=subprocess.DEVNULL, + ) + + +def _flatten(kind: str, leaf: str, out: Path) -> None: + subprocess.run( + ["python3", str(T3_PRISM_DIR / "flatten_bambu_profile.py"), + kind, leaf, str(BBL_ROOT), str(out)], + check=True, + ) + + +def _patch_bed(profile: Path) -> None: + d = json.loads(profile.read_text()) + d["curr_bed_type"] = "Textured PEI Plate" + d["default_bed_type"] = "Textured PEI Plate" + profile.write_text(json.dumps(d, indent=2)) + + +def _split_assembled_into_objects( + proj_3mf: Path, pairs: list[tuple[str, str]] +) -> None: + """Split the single composite object emitted by ``--assemble`` into one + composite object per (struts_stl, cables_stl) pair. + + Per PR #35 comment 4513722886 (@sgbaird), each tensegrity iteration on + the plate must be its own Bambu Studio object made up of two part + groups (PLA struts + TPU cables) so it can be moved as a unit. The + BambuStudio CLI's ``--assemble`` flag instead merges *all* passed STLs + into a single composite object, so the team can't move one specimen + independently of the others without lassoing both of its parts. + + Fix: after ``--assemble``, edit ``3D/3dmodel.model`` and + ``Metadata/model_settings.config`` in-place to split the single + composite object into ``len(pairs)`` composite objects, each + referencing two of the per-specimen STL meshes (struts → extruder 1, + cables → extruder 2). The underlying ``3D/Objects/object_1.model`` + mesh data is untouched; only the grouping changes. + """ + import re + import uuid + import zipfile + + MODEL_PATH = "3D/3dmodel.model" + CFG_PATH = "Metadata/model_settings.config" + + # Mapping from STL filename -> (specimen_index, extruder_id). Struts + # always go to extruder 1, cables to extruder 2. + name_to_spec: dict[str, tuple[int, int]] = {} + for spec_idx, (struts_name, cables_name) in enumerate(pairs): + name_to_spec[struts_name] = (spec_idx, 1) + name_to_spec[cables_name] = (spec_idx, 2) + + with zipfile.ZipFile(proj_3mf, "r") as zin: + infos = zin.infolist() + contents = {info.filename: zin.read(info.filename) for info in infos} + + if MODEL_PATH not in contents: + raise RuntimeError(f"{proj_3mf}: missing {MODEL_PATH}") + if CFG_PATH not in contents: + raise RuntimeError(f"{proj_3mf}: missing {CFG_PATH}") + + # ---- Patch model_settings.config first (we need part-name -> id mapping). ---- + cfg = contents[CFG_PATH].decode() + cfg_part_re = re.compile( + r']*>(.*?)', re.DOTALL + ) + cfg_name_re = re.compile(r'') + cfg_object_re = re.compile( + r'(]*>)(.*?)()', re.DOTALL + ) + cfg_plate_re = re.compile( + r'()(.*?)()', re.DOTALL + ) + + # The CLI emits exactly one in the assembled file; find it and + # capture its part blocks. + obj_match = cfg_object_re.search(cfg) + if obj_match is None: + raise RuntimeError(f"{proj_3mf}: no in {CFG_PATH}") + obj_open, obj_id_str, obj_body, obj_close = obj_match.groups() + composite_obj_id = int(obj_id_str) + + # Parse parts: each part has an id and a name; the name is the STL filename. + parts: list[tuple[int, str, str]] = [] # (part_id, name, full_part_xml) + for m in cfg_part_re.finditer(obj_body): + part_id = int(m.group(1)) + part_xml = m.group(0) + name_match = cfg_name_re.search(m.group(2)) + if name_match is None: + raise RuntimeError(f"{proj_3mf}: missing name") + name = name_match.group(1) + parts.append((part_id, name, part_xml)) + + # Group parts by specimen index. Order within each specimen: struts first + # (extruder 1), cables second (extruder 2). + spec_to_parts: dict[int, dict[int, tuple[int, str, str]]] = {} + for part_id, name, part_xml in parts: + if name not in name_to_spec: + raise RuntimeError( + f"{proj_3mf}: not in pairs mapping" + ) + spec_idx, ext_id = name_to_spec[name] + spec_to_parts.setdefault(spec_idx, {})[ext_id] = (part_id, name, part_xml) + + # Build the new entries (one per specimen). Composite object IDs + # start one past the original (to avoid collision with the part IDs which + # are 1..len(parts)). + new_composite_ids: list[int] = [] + cfg_new_objects: list[str] = [] + extruder_re = re.compile(r'(\n'] + chunks.append(f' \n') + for ext_id in sorted(spec_to_parts[spec_idx]): + _, _, part_xml = spec_to_parts[spec_idx][ext_id] + # Force the correct extruder per part. + if extruder_re.search(part_xml): + part_xml = extruder_re.sub(rf"\g<1>{ext_id}\g<2>", part_xml) + else: + part_xml = part_xml.replace( + "", + f' \n ', + ) + chunks.append(" " + part_xml + "\n") + chunks.append(" ") + cfg_new_objects.append("".join(chunks)) + + cfg_new_object_block = "\n".join(cfg_new_objects) + + # Replace the single ... block with the new ones. + new_cfg = cfg[:obj_match.start()] + cfg_new_object_block + cfg[obj_match.end():] + + # Patch : one per new composite. + plate_match = cfg_plate_re.search(new_cfg) + if plate_match is None: + raise RuntimeError(f"{proj_3mf}: no in {CFG_PATH}") + plate_open, plate_body, plate_close = plate_match.groups() + plate_header_match = re.search( + r'^(.*?)(.*?\s*)', plate_body, re.DOTALL + ) + if plate_header_match is None: + # No existing model_instance — just inject after a trailing . + plate_header = plate_body.rstrip() + "\n" + else: + plate_header = plate_header_match.group(1) + new_instances: list[str] = [] + for i, new_obj_id in enumerate(new_composite_ids): + new_instances.append( + f' \n' + f' \n' + f' \n' + f' \n' + f' \n' + ) + new_plate = plate_open + plate_header + "".join(new_instances) + " " + plate_close + new_cfg = new_cfg[:plate_match.start()] + new_plate + new_cfg[plate_match.end():] + contents[CFG_PATH] = new_cfg.encode() + + # ---- Patch 3D/3dmodel.model next. ----------------------------------------- + model_xml = contents[MODEL_PATH].decode() + model_obj_re = re.compile( + r'(]*type="model"[^>]*>)(.*?)()', + re.DOTALL, + ) + model_component_re = re.compile( + r']*?objectid="(\d+)"[^>]*?/>' + ) + model_build_re = re.compile( + r'(]*>)(.*?)()', re.DOTALL + ) + model_item_re = re.compile( + r']*?objectid="\d+"[^>]*?/>' + ) + + obj_match2 = model_obj_re.search(model_xml) + if obj_match2 is None: + raise RuntimeError(f"{proj_3mf}: no in {MODEL_PATH}") + obj_open2, _obj_id2, obj_body2, obj_close2 = obj_match2.groups() + components = model_component_re.findall(obj_body2) + # Capture the *full* component tags too (we want to preserve transforms). + component_tags = re.findall(r']*?/>', obj_body2) + if len(component_tags) != len(parts): + raise RuntimeError( + f"{proj_3mf}: {MODEL_PATH} has {len(component_tags)} components " + f"but {CFG_PATH} has {len(parts)} parts" + ) + # Build a mapping component objectid -> tag. + obj_id_to_tag: dict[str, str] = {} + for tag in component_tags: + m = re.search(r'objectid="(\d+)"', tag) + if m: + obj_id_to_tag[m.group(1)] = tag + + # Build new entries. Each one wraps the two component tags for + # that specimen (struts first, cables second, matching the order they + # were passed to --assemble). + new_model_objects: list[str] = [] + # Determine the (composite_id, [part_id, part_id]) layout to know + # which underlying mesh objects belong to which specimen. + for spec_idx in sorted(spec_to_parts): + new_obj_id = composite_obj_id + spec_idx + comp_tags: list[str] = [] + for ext_id in sorted(spec_to_parts[spec_idx]): + part_id, _, _ = spec_to_parts[spec_idx][ext_id] + tag = obj_id_to_tag.get(str(part_id)) + if tag is None: + raise RuntimeError( + f"{proj_3mf}: no in {MODEL_PATH}" + ) + comp_tags.append(" " + tag) + new_model_objects.append( + f' \n' + f' \n' + + "\n".join(comp_tags) + "\n" + f' \n' + f' ' + ) + new_model_object_block = "\n".join(new_model_objects) + new_model_xml = ( + model_xml[: obj_match2.start()] + + new_model_object_block + + model_xml[obj_match2.end() :] + ) + + # Replace the single in with one per new composite. + build_match = model_build_re.search(new_model_xml) + if build_match is None: + raise RuntimeError(f"{proj_3mf}: no in {MODEL_PATH}") + build_open, build_body, build_close = build_match.groups() + existing_item_match = model_item_re.search(build_body) + if existing_item_match is None: + raise RuntimeError(f"{proj_3mf}: no in ") + # Reuse the existing transform attribute so the plate placement stays. + existing_item = existing_item_match.group(0) + transform_match = re.search(r'transform="([^"]*)"', existing_item) + printable_match = re.search(r'printable="([^"]*)"', existing_item) + transform_attr = ( + f' transform="{transform_match.group(1)}"' if transform_match else "" + ) + printable_attr = ( + f' printable="{printable_match.group(1)}"' if printable_match else ' printable="1"' + ) + new_items: list[str] = [] + for new_obj_id in new_composite_ids: + new_items.append( + f' " + ) + new_build = build_open + "\n" + "\n".join(new_items) + "\n " + build_close + new_model_xml = ( + new_model_xml[: build_match.start()] + new_build + new_model_xml[build_match.end() :] + ) + contents[MODEL_PATH] = new_model_xml.encode() + + # ---- Rewrite the archive --------------------------------------------------- + with zipfile.ZipFile(proj_3mf, "w", zipfile.ZIP_DEFLATED) as zout: + for info in infos: + zout.writestr(info, contents[info.filename]) + + +def build_mm_3mf( + pairs: list[tuple[Path, Path]], out_3mf: Path +) -> None: + """Assemble per-specimen (struts, cables) STL pairs into a Bambu H2D MM ``.3mf``. + + Each pair becomes its own composite object on the build plate with two + parts (struts → extruder 1 / PLA, cables → extruder 2 / TPU), so each + specimen can be moved as a unit in Bambu Studio while keeping the PLA + and TPU members locked together (PR #35 comment 4513722886). + + Mirrors ``slice_bambu_mm`` from ``cad/t3-prism/render_print.sh`` but + without ``enable_supports`` (the BO batch leaves supports off; + @achris0520 paints them on per PR #35 comment 4502140147). Filament + slot 1 = PLA, slot 2 = TPU 85A. + """ + _ensure_bambu() + tag = "H2D-MM-PLAstruts-TPUcables" + work = SCRATCH / f"bo_{tag}" + work.mkdir(parents=True, exist_ok=True) + m = work / "machine_flat.json" + p = work / "process_flat.json" + f1 = work / "filament1_flat.json" + f2 = work / "filament2_flat.json" + _flatten("machine", "Bambu Lab H2D 0.4 nozzle", m) + _flatten("process", "0.20mm Standard @BBL H2D", p) + _flatten("filament", "Bambu PLA Basic @BBL H2D", f1) + _flatten("filament", "Bambu TPU 85A @BBL H2D 0.4 nozzle", f2) + _patch_bed(m) + + proj_3mf = out_3mf.name + proj_outdir = work / "proj" + if proj_outdir.exists(): + shutil.rmtree(proj_outdir) + proj_outdir.mkdir(parents=True) + + # Flatten the pairs into a single interleaved STL list (struts0, cables0, + # struts1, cables1, ...) — the order is what the post-processor relies on + # to re-group parts back into per-specimen composites. + stl_args: list[str] = [] + pairs_names: list[tuple[str, str]] = [] + name_to_ext: dict[str, str] = {} + for struts_stl, cables_stl in pairs: + stl_args.extend([str(struts_stl), str(cables_stl)]) + pairs_names.append((struts_stl.name, cables_stl.name)) + name_to_ext[struts_stl.name] = "1" + name_to_ext[cables_stl.name] = "2" + + print( + f"==> BambuStudio CLI --assemble -> {proj_3mf} " + f"({len(pairs)} specimens x 2 parts each)" + ) + env = {**__import__("os").environ, + "LIBGL_ALWAYS_SOFTWARE": "1", "GALLIUM_DRIVER": "llvmpipe"} + subprocess.run( + ["xvfb-run", "-a", "-s", "-screen 0 1280x1024x24", str(BAMBU_APPIMAGE), + "--assemble", + "--load-settings", f"{m};{p}", + "--load-filaments", f"{f1};{f2}", + "--export-3mf", proj_3mf, + "--outputdir", str(proj_outdir), + *stl_args], + check=True, env=env, + ) + + # patch_mm_extruder.py: (a) pad filament_colour / filament_map to length 2 + # and set filament_map_mode=Manual, (b) per-part extruder routing. + print( + f"==> Patch model_settings.config: struts -> extruder 1 (PLA), " + f"cables -> extruder 2 (TPU)" + ) + pair_args = [f"{name}={ext}" for name, ext in name_to_ext.items()] + subprocess.run( + ["python3", str(T3_PRISM_DIR / "patch_mm_extruder.py"), + str(proj_outdir / proj_3mf), *pair_args], + check=True, + ) + + # Split the single composite object emitted by --assemble into one + # composite per specimen so each iteration on the plate is its own + # movable Bambu Studio object with two part groups. + print( + f"==> Split assembled composite -> {len(pairs)} per-specimen objects " + f"(PLA struts + TPU cables grouped per object)" + ) + _split_assembled_into_objects(proj_outdir / proj_3mf, pairs_names) + + out_3mf.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(proj_outdir / proj_3mf, out_3mf) + + +def load_designs_from_csv(csv_path: Path, n: int) -> tuple[list[dict], list[str]]: + """Read design coordinates back from a committed design table. + + Only the five swept parameter columns are consumed, so the file may carry + any number of extra as-printed / constraint / prediction columns. That + makes this readable both for the pinned first-batch Sobol table + (``t3-prism-bo-batch.csv``) and for a BO suggestion table emitted by + ``bo/t3_prism_bo_campaign.py`` (``t3-prism-bo-suggestions-roundN.csv``), + which uses the same five column names. + + Returns the design dicts plus a per-row label used to trace each plate + specimen back to its source row (the Ax ``trial_index`` when present). + """ + with csv_path.open() as f: + rows = list(csv.DictReader(f)) + if len(rows) < n: + raise SystemExit( + f"{csv_path} has only {len(rows)} rows but --n={n}; " + f"pass --resample to draw a fresh Sobol batch instead") + keys = [p["name"] for p in PARAMETERS] + missing = [k for k in keys if k not in (rows[0] if rows else {})] + if missing: + raise SystemExit( + f"{csv_path} is missing the design column(s) {missing}; " + f"expected all of {keys}") + designs = [{k: float(row[k]) for k in keys} for row in rows[:n]] + labels = [str(row.get("trial_index") or row.get("specimen") or i) + for i, row in enumerate(rows[:n])] + return designs, labels + + +def sample_designs_sobol(n: int, seed: int) -> list[dict]: + """Draw a fresh Sobol batch via Ax (only used with --resample).""" + import logging + + from ax.service.ax_client import AxClient, ObjectiveProperties + + logging.getLogger("ax").setLevel(logging.WARNING) + # Ax's default GenerationStrategy starts with a Sobol init step, so a + # single call to ``get_next_trials(N)`` returns N quasi-random specimens + # without ever touching a surrogate model. We mark each trial abandoned + # so it does not pollute future runs of the closed-loop campaign. + ax_client = AxClient(random_seed=seed) + ax_client.create_experiment( + name="t3_prism_sobol_batch", + parameters=PARAMETERS, + # Single-objective placeholder; we never report data back this round. + objectives={"placeholder": ObjectiveProperties(minimize=True)}, + overwrite_existing_experiment=True, + ) + parameterizations, _ = ax_client.get_next_trials(n) + for idx in parameterizations: + ax_client.abandon_trial(idx, reason="human-in-the-loop single-batch") + return [parameterizations[i] for i in sorted(parameterizations)] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--n", type=int, default=N_SPECIMENS, + help=f"number of specimens (default {N_SPECIMENS})") + parser.add_argument("--seed", type=int, default=SEED, + help=f"Sobol seed (default {SEED}; only used with --resample)") + parser.add_argument("--resample", action="store_true", + help="draw a fresh Sobol batch via Ax instead of reading " + "the pinned designs back from the committed CSV") + parser.add_argument("--designs-csv", type=Path, default=None, + help="read the design coordinates from this CSV instead " + "of the pinned first-batch table (any file carrying " + "the five swept columns works, e.g. the BO " + "suggestion table t3-prism-bo-suggestions-round1.csv)") + parser.add_argument("--out-prefix", default=None, + help="basename for every emitted artifact (default " + "'t3-prism-bo-batch'); use e.g. " + "'t3-prism-bo-round1' to keep a BO round's plate " + "alongside the pinned Sobol batch") + parser.add_argument("--mass-g", type=float, default=None, + help="Route-A constant cell mass m* in grams (default: " + "solid-volume mass of the committed S0 reference " + "STLs in cad/t3-prism/, i.e. the most recent " + "instrumented prints)") + parser.add_argument("--envelope-max-cm3", type=float, + default=DEFAULT_ENVELOPE_MAX_CM3, + help="Route-B max envelope volume V* = pi*R^2*H in cm^3 " + f"(default {DEFAULT_ENVELOPE_MAX_CM3:g}, from " + "simulations/sim_bo_hybrid_campaign.py)") + parser.add_argument("--jobs", type=int, default=4, + help="parallel OpenSCAD render workers (default 4)") + parser.add_argument("--skip-render", action="store_true", + help="emit CSV/JSON with analytic scale estimates only; " + "skip all OpenSCAD renders (CI smoke test)") + parser.add_argument("--skip-mm-3mf", action="store_true", + help="skip the BambuStudio CLI MM project .3mf assembly step") + args = parser.parse_args(argv) + + out_dir = Path(__file__).resolve().parent + prefix = args.out_prefix or "t3-prism-bo-batch" + if args.out_prefix: + # Keep a non-default run's per-specimen STLs from colliding with the + # pinned batch's (both land in per-specimen-stls/). + global SPEC_STL_FMT + SPEC_STL_FMT = f"{prefix}-spec{{idx:02d}}-{{part}}.stl" + csv_path = out_dir / f"{prefix}.csv" + json_path = out_dir / f"{prefix}.json" + scad_path = out_dir / f"{prefix}.scad" + stl_path = out_dir / f"{prefix}.stl" + stl_struts_path = out_dir / f"{prefix}-struts.stl" + stl_cables_path = out_dir / f"{prefix}-cables.stl" + plate_png = out_dir / f"{prefix}-plate.png" + iso_png = out_dir / f"{prefix}-iso.png" + slices_dir = out_dir / "slices" + mm_3mf_path = slices_dir / f"{prefix}.H2D-MM-PLAstruts-TPUcables.3mf" + per_spec_dir = out_dir / "per-specimen-stls" + + # ---- Designs: BO suggestions, the pinned Sobol table, or a fresh draw --- + if args.designs_csv is not None: + src = args.designs_csv + if not src.is_absolute(): + src = (Path.cwd() / src) if src.exists() else (out_dir / src) + print(f"==> Reading designs from {src}") + specimens, labels = load_designs_from_csv(src, args.n) + designs_source = str(src.relative_to(REPO_ROOT) + if src.is_relative_to(REPO_ROOT) else src) + elif args.resample or not csv_path.exists(): + print(f"==> Drawing a fresh Sobol batch (n={args.n}, seed={args.seed})") + specimens = sample_designs_sobol(args.n, args.seed) + labels = [str(i) for i in range(len(specimens))] + designs_source = "resampled" + else: + print(f"==> Reusing the pinned first-batch designs from {csv_path.name}") + specimens, labels = load_designs_from_csv(csv_path, args.n) + designs_source = "pinned-csv" + + # ---- Constraint targets ------------------------------------------------- + m_star = args.mass_g if args.mass_g is not None else reference_mass_g() + v_star = args.envelope_max_cm3 + print(f"==> Route-A constant mass m* = {m_star:.2f} g " + f"({'CLI override' if args.mass_g is not None else 'S0 reference STLs'}), " + f"Route-B envelope max V* = {v_star:.0f} cm^3") + + if not shutil.which("openscad") and not args.skip_render: + print("openscad not found; install with `sudo apt-get install -y openscad`.", + file=sys.stderr) + return 2 + + if args.skip_render: + # Analytic-only pass: report the estimated projection without STLs. + m_h = 6.8 # nominal housing mass (g); rendered runs measure it exactly + results = [] + for idx, params in enumerate(specimens): + s = ((m_star - m_h) / estimate_body_mass_g(params)) ** (1.0 / 3.0) + results.append({"idx": idx, "scale": s, "mass_g": float("nan"), + "pla_g": float("nan"), "tpu_g": float("nan")}) + else: + SCRATCH.mkdir(parents=True, exist_ok=True) + m_h = housing_mass_g(SCRATCH) + print(f"==> Absolute-size sensor-housing mass (6 housings + skirts): " + f"{m_h:.2f} g PLA") + solve_dir = SCRATCH / "bo-mass-solve" + solve_dir.mkdir(parents=True, exist_ok=True) + with ThreadPoolExecutor(max_workers=args.jobs) as pool: + results = list(pool.map( + lambda t: solve_specimen(t[0], t[1], m_star, m_h, solve_dir), + enumerate(specimens))) + + # ---- Constraint bookkeeping --------------------------------------------- + for params, res in zip(specimens, results): + s = res["scale"] + res["envelope_cm3"] = (math.pi * (params["R_mm"] * s) ** 2 + * params["H_mm"] * s / 1000.0) + res["envelope_ok"] = res["envelope_cm3"] <= v_star + 1e-9 + res["mass_ok"] = (not math.isnan(res["mass_g"]) + and abs(res["mass_g"] - m_star) <= 2 * MASS_TOL_G) + res["cable_d_print_mm"] = params["cable_d_mm"] * s + res["cable_bridge_ok"] = res["cable_d_print_mm"] >= CABLE_BRIDGE_FLOOR_MM + n_env_bad = sum(not r["envelope_ok"] for r in results) + if n_env_bad: + bad = ", ".join(f"spec{r['idx']:02d} ({r['envelope_cm3']:.0f} cm^3)" + for r in results if not r["envelope_ok"]) + print(f"WARNING: {n_env_bad}/{len(results)} specimens exceed the " + f"envelope constraint V* = {v_star:.0f} cm^3 at constant mass " + f"m* = {m_star:.1f} g: {bad}. Their shape is infeasible under " + f"both constraints simultaneously (the uniform scale is consumed " + f"by the mass constraint); they are flagged envelope_ok=False.", + file=sys.stderr) + + # ---- Plate layout from measured footprints ------------------------------ + if args.skip_render: + footprints = [2.0 * params["R_mm"] * res["scale"] + 25.0 + for params, res in zip(specimens, results)] + else: + footprints = [ + 2.0 * max(abs(v) for v in (res["bbox_min"][0], res["bbox_max"][0], + res["bbox_min"][1], res["bbox_max"][1])) + for res in results] + layout = plan_plate_layout(footprints) + rows, cols = layout["rows"], layout["cols"] + + # ---- Persist the design table ------------------------------------------- + frozen = { + "topology": TOPOLOGY, + "tiling": TILING, + "struts_per_cell": STRUTS_PER_CELL, + "build_orientation": BUILD_ORIENTATION, + "tpu_shore": TPU_SHORE, + "strut_material": STRUT_MATERIAL, + "cable_material": CABLE_MATERIAL, + "supports": SUPPORTS, + "joint_d_mm": JOINT_D_BASE, + } + derived_cols = [ + "scale", "R_print_mm", "H_print_mm", "strut_d_print_mm", + "cable_d_print_mm", "joint_d_print_mm", "mass_g", "pla_g", "tpu_g", + "mass_target_g", "mass_ok", "envelope_cm3", "envelope_max_cm3", + "envelope_ok", "cable_bridge_ok", + ] + with csv_path.open("w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["specimen", "source_trial", + *(p["name"] for p in PARAMETERS), + *derived_cols, *sorted(frozen)]) + for idx, (params, res) in enumerate(zip(specimens, results)): + s = res["scale"] + writer.writerow([ + idx, + labels[idx], + *(f"{params[p['name']]:.4f}" for p in PARAMETERS), + f"{s:.4f}", + f"{params['R_mm'] * s:.3f}", + f"{params['H_mm'] * s:.3f}", + f"{params['strut_d_mm'] * s:.3f}", + f"{params['cable_d_mm'] * s:.3f}", + f"{JOINT_D_BASE * s:.3f}", + f"{res['mass_g']:.2f}", + f"{res['pla_g']:.2f}", + f"{res['tpu_g']:.2f}", + f"{m_star:.2f}", + res["mass_ok"], + f"{res['envelope_cm3']:.1f}", + f"{v_star:.1f}", + res["envelope_ok"], + res["cable_bridge_ok"], + *(frozen[k] for k in sorted(frozen)), + ]) + json_path.write_text(json.dumps( + { + "seed": args.seed, + "n": args.n, + "designs_source": designs_source, + "constraints": { + "mass_target_g": m_star, + "mass_tol_g": MASS_TOL_G, + "housing_mass_g": m_h, + "envelope_max_cm3": v_star, + "rho_pla_g_per_cm3": RHO_PLA * 1000, + "rho_tpu_g_per_cm3": RHO_TPU * 1000, + "mass_anchor": "solid-volume mass of cad/t3-prism/" + "t3-prism-{struts,cables}.stl (S0 reference)", + "envelope_definition": "pi * R_print^2 * H_print " + "(bo_evaluator.cell_geometry_metrics)", + }, + "grid": {"rows": rows, "cols": cols, + "air_gap_mm": layout["air_gap"], + "col_widths_mm": layout["col_widths"], + "row_heights_mm": layout["row_heights"], + "total_w_mm": layout["total_w"], + "total_h_mm": layout["total_h"]}, + "plate": {"x_mm": PLATE_X, "y_mm": PLATE_Y, "margin_mm": PLATE_MARGIN, + "prime_tower_reserve_x_mm": PRIME_TOWER_RESERVE_X}, + "parameters": PARAMETERS, + "frozen": frozen, + "specimens": [ + {"idx": i, "source_trial": labels[i], **params, + **{k: res[k] for k in ("scale", "mass_g", "pla_g", "tpu_g", + "envelope_cm3", "envelope_ok", "mass_ok", + "cable_d_print_mm", "cable_bridge_ok")}} + for i, (params, res) in enumerate(zip(specimens, results)) + ], + }, + indent=2, + )) + if args.skip_render: + print(f"Wrote {csv_path.name}, {json_path.name} " + f"(analytic estimates only; renders skipped).") + return 0 + + # ---- Place specimens on the plate (pure STL translation) ---------------- + per_spec_dir.mkdir(exist_ok=True) + pairs: list[tuple[Path, Path]] = [] + for idx, res in enumerate(results): + cx, cy = layout["centres"][idx] + cz = -res["bbox_min"][2] # lowest feature (joint-shell underside) -> bed + spec_struts = per_spec_dir / SPEC_STL_FMT.format(idx=idx, part="struts") + spec_cables = per_spec_dir / SPEC_STL_FMT.format(idx=idx, part="cables") + stl_translate(res["struts_stl"], spec_struts, cx, cy, cz) + stl_translate(res["cables_stl"], spec_cables, cx, cy, cz) + pairs.append((spec_struts, spec_cables)) + print(f"==> spec{idx:02d}: scale {res['scale']:.4f}, " + f"mass {res['mass_g']:.2f} g (PLA {res['pla_g']:.2f} + " + f"TPU {res['tpu_g']:.2f}), envelope {res['envelope_cm3']:.1f} cm^3 " + f"[{'ok' if res['envelope_ok'] else 'VIOLATION'}] -> " + f"plate ({cx:.1f}, {cy:.1f})") + + # ---- Combined STLs + preview wrapper + PNGs ------------------------------ + print(f"==> Merge -> {stl_struts_path.name} / {stl_cables_path.name} / {stl_path.name}") + stl_merge([p for p, _ in pairs], stl_struts_path) + stl_merge([c for _, c in pairs], stl_cables_path) + stl_merge([f for pair in pairs for f in pair], stl_path) + write_preview_scad(scad_path, args.n, rows, cols, + max(layout["col_widths"] + layout["row_heights"])) + cam_top = f"{PLATE_X/2:.1f},{PLATE_Y/2:.1f},0,0,0,0,{max(PLATE_X, PLATE_Y) * 1.4:.1f}" + cam_iso = f"{PLATE_X/2:.1f},{PLATE_Y/2:.1f},0,55,0,25,{max(PLATE_X, PLATE_Y) * 1.6:.1f}" + print(f"==> OpenSCAD render -> {plate_png.name} (top-down plate view)") + run_openscad(scad_path, plate_png, camera=cam_top, image_size="1200,1100", + viewall=True) + print(f"==> OpenSCAD render -> {iso_png.name} (iso preview)") + run_openscad(scad_path, iso_png, camera=cam_iso, image_size="1200,900", + viewall=True) + + if not args.skip_mm_3mf: + build_mm_3mf(pairs, mm_3mf_path) + + print("Done.") + print(f" Design table : {csv_path}") + print(f" JSON : {json_path}") + print(f" Combined STL : {stl_path}") + print(f" Struts STL : {stl_struts_path}") + print(f" Cables STL : {stl_cables_path}") + print(f" Plate PNG : {plate_png}") + print(f" Iso PNG : {iso_png}") + if not args.skip_mm_3mf: + print(f" MM project : {mm_3mf_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cad/t3-prism/README.md b/cad/t3-prism/README.md new file mode 100644 index 00000000..5cddb57a --- /dev/null +++ b/cad/t3-prism/README.md @@ -0,0 +1,687 @@ +# T3-prism (3-strut tensegrity) — Bambu PETG print + +Resolves the issue [_"Get a bambu sliced print for a T3-prism"_](../../README.md): +parametric CAD + a single-piece, pure-PETG, Bambu-bound g-code for the +canonical 3-bar tensegrity prism shown on +[Wikipedia: Tensegrity](https://en.wikipedia.org/wiki/Tensegrity). + +![T3-prism iso preview](t3-prism-iso.png) + +## Geometry + +A T3-prism has **3 compression members** ("struts") and **9 tension members** +("cables"): 3 around the bottom triangle, 3 around the top triangle, and 3 +saddle/vertical cables connecting them. The two end triangles are +equilateral and inscribed in a circle of radius `R`; the top triangle is +rotated by `twist = 60°` relative to the bottom (the angle the issue calls +out and the relative twist visible in the Wikipedia reference image). + +Connectivity (`i ∈ {0,1,2}`, mod 3): + +| Member | Endpoints | Diameter (scale 1.5) | +| -------------------- | --------------- | -------- | +| Strut `i` | `B_i → T_i` | 9.0 mm | +| Bottom cable `i` | `B_i → B_{i+1}` | 4.5 mm | +| Top cable `i` | `T_i → T_{i+1}` | 4.5 mm | +| Saddle/vertical `i` | `B_{i+1} → T_i` | 4.5 mm | + +Strut `i` and saddle `i` meet at top vertex `T_i` but originate from +*different* bottom vertices — the defining "no two compression members +touch" property of a tensegrity (the struts are kept apart by the cables). + +Default parameters (editable at the top of [`t3-prism.scad`](t3-prism.scad)). +All linear dimensions are `*_base * scale_factor`: + +| Parameter | Base | × `scale_factor` (1.5) | Notes | +| -------------- | -----: | ---------------------: | --- | +| `R_base` | 25 mm | **37.5 mm** | end-triangle circumradius | +| `H_base` | 70 mm | **105 mm** | inter-triangle height | +| `twist` | 60° | 60° | top-triangle rotation (not scaled) | +| `strut_d_base` | 6 mm | **9.0 mm** | compression member diameter | +| `cable_d_base` | 3.0 mm | **4.5 mm** | tension member diameter (see [Print failure mode](#print-failure-mode-top-cable-bridge-and-how-to-avoid-it) and [Scale-up](#scale-up-to-15-cable_d-30--45-mm) below) | +| `joint_d_base` | 7 mm | **10.5 mm** | minimum vertex sphere/shell diameter (captive-core shell is upsized as needed; see [Captive TPU core](#captive-tpu-core-inside-pla-outer-shell) below) | +| `scale_factor` | — | **≈1.154 (S0)** | uniform scale on every linear dim. Sizing **S0** = `1.5 × 0.7692` — 76.92% of the earlier 1.5× generations (@achris0520, 2026-07-01). Set `-D scale_factor=1.5` to recover the old size | +| `use_captive_core` | `true` | `true` | captive TPU core inside PLA outer shell at every vertex (PR #35 comment 4511036510, bonded per comment 4513722886); set `false` for legacy solid-joint mode | +| `captive_bore_clear` | 0.4 mm | **0 mm** | single-sided clearance around the TPU cable through the shell bore; **0** = bonded (TPU fills the bore exactly) per PR #35 comment 4513722886 | +| `captive_bore_trap` | 1.5 mm | 1.5 mm | min `(core_od - bore_d) / 2`; how much wider the core is than the bore so it can't back out | +| `captive_core_clear` | 0.5 mm | **0 mm** | radial gap (shell-ID − core-OD) / 2; **0** = bonded (TPU core touches the PLA inner wall) per PR #35 comment 4513722886 | +| `captive_wall_base` | 1.6 mm | **2.4 mm** | PLA shell wall thickness (scaled with `scale_factor`) | +| `add_accel_mount` | `true` | `true` | rounded "igloo" accelerometer mount on each **top** vertex (PR #35 comment 4794790065); set `false` to omit. See [Accelerometer mount](#accelerometer-mount-accel_mount) below | +| `add_accel_mount_bottom` | `true` | `true` | **flat** accelerometer key-seat **beside** each **bottom** vertex for a third sensor (PR #35 / PR #67, @ctrhjk + @sgbaird 2026-07-01). Placed to the side of the vertex and lifted so it hovers above the plate — never touches the ground (PR #35 comment 4859762053). Set `false` to omit. With both mounts on there are **6 housing sites** (3 top igloo + 3 bottom flat), matched top+bottom per the placement analysis in [PR #35 comment 4857717314](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4857717314) | +| `accel_size` | — | **`"A3"`** | housing sizing set: **A0** = original, **A1** = A0 + 0.3 mm pocket height (`accel_h_extra`), **A2** = A1 with tightened clearances (0.2 mm/side lateral, 0.2 mm top), **A3** = explicit 6.2 × 6.2 × 6.8 mm pocket (per the manually-corrected OnShape file, PR #35 comment 4939776434). Absolute mm — **not** scaled. Set `-D accel_size='"A2"'` / `'"A1"'` / `'"A0"'` for the earlier sets | +| `accel_l` / `accel_w` / `accel_h` | — | 6 / 6 / 5.94 mm | accelerometer pocket size (Dytran 3133A4, **not** scaled) | +| `accel_clear` | — | **0.2 mm** (A2) | per-side **lateral (XY)** pocket clearance for the slide-in fit (0.4 mm in A0/A1) | +| `accel_clear_top` / `accel_clear_bot` | — | **0.2** / 0.2 mm (A2) | **Z** gap above / below the accelerometer (`accel_clear_top` is 1.0 mm in A0/A1). Keeps the sensor recessed **below the crown springline** (the igloo dome, not the sensor, touches the acrylic drop plate — PR #67 comment 4839988559) while the walls register the sensor (PR #35 comment 4895789291) | +| `accel_dome` / `accel_flat` | — | 3.0 / 2.0 mm | cap thickness over the pocket: rounded **dome** on the top igloo mounts, **flat** slab on the bottom key-seats | +| `accel_side_gap` / `accel_hover` | — | 1.0 / 2.0 mm | bottom key-seat **beside**-placement: radial PLA-skirt gap from the vertex sphere, and Z clearance of the seat underside above the joint underside so it hovers off the plate (PR #35 comment 4859762053) | + +Bounding box at sizing **S0** (`scale_factor ≈ 1.154`) ≈ **58 mm footprint × +~115 mm tall** (the ~92 mm prism-plus-shells body plus the top igloo and bottom +flat accelerometer housings, which are absolute-mm and do not shrink with S0). +Comfortably fits the Bambu Lab H2D's 350 × 320 mm plate +— and 4 copies fit in a 2 × 2 grid for batch printing +([Batch printing](#batch-printing-for-the-optimization-campaign) below). + +## Captive TPU core inside PLA outer shell + +Per [PR #35 comment 4511036510](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4511036510) +and the joint-design recommendation in +[PR #39 comment 4461700096](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/39#issuecomment-4461700096), +every joint vertex is now a **captive TPU core sphere trapped inside a +hollow PLA outer shell** — not a solid joint sphere with a half-buried +TPU cable end. @ctrhjk's PETG+TPU photo in +[PR #35](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35) +showed the previous design failing in exactly the predicted way: the +cable was "encased within the PLA support, making it difficult to remove +… [and] inserts into kinda half of the joint ball, [giving] unstable +fixation". The captive-core design fixes both problems mechanically +(no chemistry assumption needed — PLA↔TPU butt-bond is only ~6.5 MPa in +shear; see `edison-trajectories/strut-material-selection-5bb5e5d3*`). + +Geometry per joint (computed in `t3-prism.scad` `joint_shell()` + +`joint_core()`): + +| Feature | Value (scale 1.5×, `cable_d`=4.5) | Role | +| -------- | ---------------------------------: | --- | +| Bore Ø | 4.5 mm = `cable_d` (bonded) | cable exit through the shell wall; TPU fills the bore exactly with no air ring (PR #35 comment 4513722886) | +| Core OD | 10.5 mm (clamped ≥ `joint_d`) | TPU captive mass; >> bore Ø so it can't back out | +| Shell ID | 10.5 mm = core OD (bonded) | hollow cavity; TPU core touches the PLA inner wall so the two materials bond at every vertex (PR #35 comment 4513722886) | +| Shell OD | 13.7 mm = shell ID + 3.2 mm wall | PLA outer wall | + +Every vertex is a plain hollow PLA sphere — no teardrop hull blend toward +the strut axis. Three cylindrical bores are differenced through the shell +wall — one per outgoing TPU cable — along the directions returned by +`vertex_cable_dirs_b(i)` / `vertex_cable_dirs_t(i)`. Those bores are the +*only* gaps in the spherical shell, and (per PR #35 comment 4513722886) +they are exactly `cable_d` wide so the TPU cable fills them without an +annular air gap, and the TPU core inside the shell is sized to touch the +PLA inner wall so the two materials bond at the vertex rather than +relying on print-in-place clearance. + +In the multi-material slice, the PLA shell + struts go to extruder 1 +and the TPU captive core + cables go to extruder 2. Because the core is +geometrically larger than any single bore (`captive_bore_trap ≥ 1.5 mm` +guarantees core_OD ≥ bore_d + 3 mm), the TPU mass at every vertex stays +trapped under cable tension regardless of inter-material adhesion. Set +`use_captive_core=false` on the OpenSCAD CLI to fall back to the legacy +solid-joint geometry for comparison prints. + +## TPU z-alignment (`cables_z_anchor()`) + +When the cables half is emitted as its own STL (`t3-prism-cables.stl`) +and imported into Bambu Studio alongside the struts STL, the slicer's +per-part "place on bed" routine lifts each STL independently so its own +lowest world-Z point sits at z=0. With the legacy solid-joint design, +the struts STL's lowest point was the joint sphere underside while the +cables STL's lowest point was the bottom-cable cylinder underside — the +two parts were offset by `(joint_d - cable_d)/2 ≈ 3 mm` in z and the +TPU cables visibly dropped relative to the joint spheres (reported above +[PR #35 comment 4511036510](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4511036510) +as "horizontal cables too low at top and bottom"). The captive-core +design naturally closes most of this gap (the TPU core spheres extend +the cables STL bbox to ±`core_od/2`), and `cables_z_anchor()` adds a +5 µm × 5 µm axial spike at the assembly centroid spanning the exact +`[-shell_od/2, H+shell_od/2]` range of the struts STL so the two parts' +world-Z bounding boxes are byte-for-byte identical. Bambu Studio then +applies the same offset to both halves and the cables stay aligned with +the joints. + +When the accelerometer mounts are enabled (the defaults — see below) they +change the struts STL's z-extents: the top igloo mounts (`add_accel_mount`) +sit on top of the top-vertex shells, so `cables_z_anchor()` extends its top +spike by `accel_rise()` to keep the two halves' bounding boxes matched. The +bottom key-seats (`add_accel_mount_bottom`) sit **beside** the bottom vertices +and hover above the plate (they do not extend below the joint shells), so the +bottom of the bounding box stays at the joint-shell underside. + +## Accelerometer mount (`accel_mount()`) + +For the drop-test campaign the team secures a **Dytran 3133A4 tri-axis +accelerometer** (measured **6 × 6 × 5.94 mm**, L × W × H — +[PR #74 comment 4792400480](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/74#issuecomment-4792400480)) +to a vertex of the structure. Per +[PR #35 comment 4794790065](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4794790065), +each of the three **top** vertices now carries a small PLA mount block +(`accel_mount()` in `t3-prism.scad`) fused onto its joint shell: + +* a rectangular pocket sized to the accelerometer plus `accel_clear` = 0.2 mm + per side **laterally** (XY slide-in fit; A2 set — was 0.4 mm in A0/A1), + `accel_clear_top` = **0.2 mm** above (A2 — was 1.0 mm) and + `accel_clear_bot` = 0.2 mm below the sensor in **Z**. The top gap keeps the + accelerometer recessed **below the crown springline** so the igloo dome — + not the sensor — contacts the acrylic drop plate and the housing walls stand + proud of the sensor + ([PR #67 comment 4839988559](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/67#issuecomment-4839988559)), + while the tightened A2 clearances make the walls register the sensor so it + cannot yaw/tilt in the seat and shear the wax bead off + ([PR #35 comment 4895789291](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4895789291)); + retention is by a wax bead + the walls; +* **three walls + a floor** (back, both sides, bottom) and an **open + outward-facing front** so the sensor slides in from the side and its + cable feeds out horizontally; +* a **flat, solid pocket floor** sitting `accel_floor` = 1.5 mm **above** the + rounded joint apex (`joint_outer_r()`), so the curved joint underneath can + never poke up into the pocket and the accelerometer seats flat — the body + walls still sink `accel_sink` = 2 mm past the apex to fuse with the joint. + Because the seat height is derived from `joint_outer_r()`, this holds for + **every** design/scale by default + ([PR #35 comment 4805516634](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4805516634)); +* a **rounded "igloo" crown** (`accel_dome` = 3 mm) over the pocket so the + top contact against the acrylic drop-test plate has less friction; +* a **skirt** that convex-hulls the body's underside footprint down onto the + rounded joint surface so PLA runs continuously from the joint up to the + underside of the igloo. This fills the outer void and removes the thin + overhanging lip that the flat-bottomed body would otherwise leave around its + rim — an unsupported stress riser that could crack + ([PR #35 comment 4813200802](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4813200802)). + The skirt re-applies the joint shell's cavity and cable-bore cuts so the + captive TPU core and the cable exits stay open. + +Because the accelerometer is a physical part, its dimensions are absolute +millimetres and are **not** multiplied by `scale_factor`. The mounts are +PLA, so they travel with the struts half of the multi-material variant. +Set `add_accel_mount=false` on the OpenSCAD CLI to omit them. + +#### Housing sizing set: A0 / A1 / A2 / A3 (`accel_size`) + +The housing dimensions are selected by `accel_size` (absolute mm, **not** +scaled with the prism). Pocket interior for the 6 × 6 × 5.94 mm Dytran: + +| Set | Pocket L × W (XY) | Pocket height (Z) | Notes | +| ---- | ----------------: | ----------------: | --- | +| `A0` | 6.8 × 6.8 mm | 7.14 mm | original housing (`accel_clear` 0.4 mm/side, `accel_clear_top` 1.0 mm) | +| `A1` | 6.8 × 6.8 mm | 7.44 mm | `accel_h_extra` = 0.3 mm added to the pocket **height only** so the Dytran seats fully without standing proud of the walls (@achris0520, 2026-07-01) | +| `A2` | 6.4 × 6.4 mm | 6.64 mm | A1 with clearances tightened — `accel_clear` 0.4 → **0.2 mm/side**, `accel_clear_top` 1.0 → **0.2 mm** — so the walls register the sensor and it cannot rotate in the seat (@ctrhjk loose-housing report, PR #35 comment 4895789291). Keeps A1's +0.3 mm print-shrink height allowance; dome / flat cap remains the only plate contact | +| `A3` *(default)* | **6.2 × 6.2 mm** | **6.8 mm** | **explicit** pocket interior requested via the manually-corrected OnShape file (@achris0520, [PR #35 comment 4939776434](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4939776434)). `accel_pocket_x_A3` / `accel_pocket_y_A3` / `accel_pocket_z_A3` set the pocket dimensions directly, overriding the clearance-derived sizing | + +Walls, dome/flat cap, and `accel_clear_bot` are identical across sets. Select an +earlier set with `-D accel_size='"A2"'` (or `'"A1"'` / `'"A0"'`). + +### Bottom-vertex key-seats (`accel_mount_bottom()`) + +To free up a **third** accelerometer position, each of the three **bottom** +vertices carries a matching key-seat **beside** it +([PR #35](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35) / +[PR #67](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/67), +@ctrhjk + @sgbaird 2026-07-01). It reuses the same slide-in pocket +(three walls + floor + one open outward face for the horizontal cable exit), +with two differences from the top igloo mount: + +* it is **flat-capped, not domed** (`accel_flat` = 2 mm) — @sgbaird: "these + won't be domed igloos, just flat"; +* it sits **to the side of** the bottom vertex — **not** below it and **not** + touching the ground (PR #35 comment 4859762053, @sgbaird: "put the lower vertex + key seats to the side and not touching the ground"). The seat is pushed + radially outward along the vertex heading by `accel_side_gap` past the joint + sphere and lifted so its underside hovers `accel_hover` above the joint + underside, so the **joint sphere** — not the seat — is the plate contact. A + short **skirt** convex-hulls the seat's *inner* (vertex-facing) face across the + gap onto the joint sphere so PLA runs continuously from the vertex to the seat + (no gap / overhanging lip / stress riser). As with the top mount, the skirt + re-cuts the joint cavity + cable bores so the captive TPU core and the three + cable exits stay open. + +**Placement.** Per the accelerometer-housing placement analysis in +[PR #35 comment 4857717314](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4857717314), +the seats are kept **matched top+bottom** (identical geometry at all 6 vertices) +so the added fixture mass is symmetric, the specimen can be dropped in either +orientation reproducibly, and the fixture reads as a constant offset that mostly +cancels in the relative BO comparison. Each bottom seat hovers beside its bottom +vertex with the open mouth pointing radially outward (matching the top igloo +heading), so all three are geometrically identical across specimens. + +Because the bottom seats hover above the plate rather than hanging below the +vertices, the model's lowest point stays at the bottom-vertex joint-shell +underside (`-captive_shell_od/2`) — the three joint spheres form the flat 3-point +base — and `cables_z_anchor()` keeps the cables STL's world-Z parity with the +struts STL. Set `add_accel_mount_bottom=false` on the OpenSCAD CLI to omit them. + +> **Note (2026-07-01):** the tweezer breakaway slots that briefly lived on the +> top mounts were **removed** per @sgbaird ("forget about the tweezer part. +> Remove that"). + +## Single-piece, pure-PETG + +Per the issue, this revision is a **single-material print in PETG** — both +struts and cables are unioned into one solid body, manifold-checked with +`admesh`. No multi-material assembly, no removable supports between +materials. PETG is an appropriate first pass: tougher than PLA (so the thin +"cable" features are less brittle when handled), prints cleanly on Bambu's +default Engineering Plate / Textured PEI, and matches the project's planned +move to TPU/PETG multi-material in later issues. + +## Build & slice + +```bash +# One-shot for the H2D: STL + iso PNG + project .3mf + sliced .gcode.3mf +bash cad/t3-prism/render_print.sh +``` + +> The lab's only printer is the **Bambu Lab H2D**, so this pipeline now +> targets the H2D exclusively. See +> [`.github/copilot-instructions.md`](../../.github/copilot-instructions.md#hardware--target-printer). + +Pre-reqs (Ubuntu 24.04): + +```bash +sudo apt-get install -y openscad admesh xvfb \ + gstreamer1.0-plugins-base libsoup-3.0-0 libwebkit2gtk-4.1-0 +``` + +The script auto-fetches the official BambuStudio Linux AppImage +(`v02.06.00.51`, pinned) into `/tmp/t3-prism/` on first run. + +Outputs (committed): + +| File | What | +| ---- | ---- | +| [`t3-prism.scad`](t3-prism.scad) | parametric source | +| [`t3-prism.stl`](t3-prism.stl) | watertight binary STL (manifold, single part), single-material | +| [`t3-prism-struts.stl`](t3-prism-struts.stl) | struts + joint spheres only (PLA half of the multi-material variant) | +| [`t3-prism-cables.stl`](t3-prism-cables.stl) | cables only (PETG half of the multi-material variant) | +| [`t3-prism-iso.png`](t3-prism-iso.png) | iso preview (above) | +| [`flatten_bambu_profile.py`](flatten_bambu_profile.py) | walks a Bambu `inherits:` chain and emits a single full-config JSON the CLI accepts | +| [`patch_mm_extruder.py`](patch_mm_extruder.py) | post-processes a `--assemble`d project `.3mf` to set per-part extruder assignments (CLI doesn't honour `--load-filament-ids` on merged objects) | +| [`t3-prism.3mf`](t3-prism.3mf) | **Bambu Studio project file** uploaded by @me-madsen — the H2D job that was actually started (PETG Basic, no supports). *Not* regenerated by `render_print.sh`. | +| [`slices/t3-prism.H2D.3mf`](slices/t3-prism.H2D.3mf) | **Single-material** (PETG) Bambu Studio project file generated by the CLI (no `--slice`). Open in Bambu Studio with *File → Open Project* (or drag-and-drop) to edit / re-slice. | +| [`slices/t3-prism.H2D-PETG.gcode.3mf`](slices/t3-prism.H2D-PETG.gcode.3mf) | **Sliced print job** for the H2D — the file you upload to the printer over LAN/cloud. Contains `Metadata/plate_1.gcode`. *Not* re-importable as a Bambu Studio project (see below). | +| [`slices/t3-prism.H2D-MM.3mf`](slices/t3-prism.H2D-MM.3mf) | **Multi-material** (PLA struts + PETG cables, IDEX) Bambu Studio project file. One assembled object with two parts: struts on extruder 1 (PLA), cables on extruder 2 (PETG). Open in Bambu Studio, hit *Slice plate* + *Send to printer* — no GUI fiddling required. See "Multi-material variant" below. | +| [`slices/t3-prism.H2D-MM-PLAcables.3mf`](slices/t3-prism.H2D-MM-PLAcables.3mf) | **Multi-material swap** (PETG struts + **PLA cables**, IDEX) Bambu Studio project file — same `--assemble`d two-part object as `H2D-MM.3mf` but with the per-part filament assignment swapped: struts on extruder 1 (PETG), cables on extruder 2 (PLA). Requested in PR #35 comment 4445480059 ("create a version of the T3-prism with the cables made of PLA"). See "Multi-material variant" below. | +| [`slices/t3-prism.H2D-MM-PLAstruts-TPUcables.3mf`](slices/t3-prism.H2D-MM-PLAstruts-TPUcables.3mf) | **Multi-material — production target** (PLA struts + **TPU 85A cables**, IDEX) Bambu Studio project file. Same two-part assembled object: struts on extruder 1 (PLA), cables on extruder 2 (TPU 85A). Requested in PR #35 comment 4455977731 ("a design that uses PLA for the struts and TPU for the cables so [the team] can slice and print this file directly"). PLA↔TPU has the best peer-reviewed inter-material bond data (see `edison-trajectories/strut-material-selection-*`). Drag in, hit *Slice plate* + *Send to printer*. See "Multi-material variant — production target" below. | + +Verified slice statistics for `t3-prism.H2D-PETG.gcode.3mf` (read from +`Metadata/plate_1.gcode` inside the archive; BambuStudio CLI returns +`return_code: 0, error_string: "Success."`): + +| Layers | Filament | Print time | Supports | Scale | +| -----: | -------: | ---------: | -------- | ----- | +| 385 @ 0.20 mm | 6.74 g PETG | 1 h 30 m 46 s | off (matches Marcus's project) | 1.0 (legacy) | +| ~575 @ 0.20 mm | 31.6 g PETG | ≈ 2 h 41 min | tree(auto) on, threshold 30° | **1.5 (default)** — see [Scale-up](#scale-up-to-15-cable_d-30--45-mm) | + +### About the two `.3mf` flavors (and the import error) + +There are **two distinct kinds** of `.3mf` in the Bambu ecosystem and +Bambu Studio treats them very differently: + +- **Project `.3mf`** — `t3-prism.3mf` (Marcus's) and + `slices/t3-prism.H2D.3mf` (CLI-generated). Microsoft OOXML zips + containing `3D/3dmodel.model`, `3D/Objects/object_1.model`, + `Metadata/project_settings.config` and `Metadata/model_settings.config` + but **no `Metadata/plate_1.gcode`**. These open as editable projects + in Bambu Studio (drag-and-drop, *File → Open Project*) and you can + re-slice / change parameters / *Send to printer* from the GUI. + +- **Sliced `.gcode.3mf`** — `slices/t3-prism.H2D-PETG.gcode.3mf`. Same + zip layout *plus* `Metadata/plate_1.gcode` (and its `.md5`), exactly + the layout the printer firmware expects. This is the file the LAN + MQTT `print/project_file` command references via + `param: "Metadata/plate_1.gcode"` (see + [`vertical-cloud-lab/powder-doser` PR #23](https://github.com/vertical-cloud-lab/powder-doser/pull/23) + for the full upload + start-print recipe). Bambu Studio + intentionally **refuses to re-import** a `.gcode.3mf` with the error + *"The file does not contain any geometry data / Loading of a model + file failed"* — it is a printer-side artifact, not a model. (This + refusal is a known Bambu Studio behavior; discussion threads on the + Bambu Lab community forum and the BambuStudio GitHub issues confirm + the project / sliced split.) If you want to edit settings and re-slice + in the GUI, open `slices/t3-prism.H2D.3mf` instead. + +### Print failure mode: top-cable bridge (history + current mitigation) + +The first H2D PETG print of `t3-prism.3mf` (385 layers @ 0.20 mm, +flat-on-bed orientation, **2.4 mm cables**, no supports) **failed with +classic spaghetti detangling at the top cable layer**, exactly where +the [Edison ANALYSIS](../../edison-trajectories/2026-05-08-t3-prism-bambu-import-25c1c897.md) +of the geometry predicted. A follow-up print enabled Bambu Studio's +auto-supports — supports got attached to the struts but the slicer's +auto-detector **skipped the top cables** (a 2.4 mm Ø horizontal +cylinder didn't trip the threshold), the cables waved/sagged, and only +scaling the print to 1.3× (≈ 3.12 mm cables) finally got auto-supports +attached to the top cables. The failure mode is intrinsic to printing +this geometry single-piece, flat, with thin cables: + +- Each top cable (`T_i → T_{i+1}`) is a **horizontal cylinder spanning + ~43.3 mm** between two top-vertex joint spheres. +- At `cable_d = 2.4 mm`, the first layer of that bridge is a chord of + the cylinder bottom only **~0.96 mm wide** (≈ 2 × 0.4 mm perimeters) + — a sub-mm PETG strand suspended over a 43 mm gap. +- The struts and saddles arrive at the top vertices `T_i` *before* the + top cable starts (around layer 362), so the joint spheres are solid + anchors — but the bridge sliver still has to span the gap on its own. + +**Applied in this revision** (all three mitigations active by default): + +1. **`cable_d` bumped 2.4 → 3.0 mm** in `t3-prism.scad`. This sits + inside the Edison-recommended 3.0–4.0 mm window and matches the + ≈ 3.12 mm point at which Marcus's follow-up scale-1.3 print + empirically triggered auto-supports on the top cables. The first- + layer bridge chord roughly doubles in width and ~3 perimeters now + span the gap. +2. **Supports forced ON** in `render_print.sh::enable_supports` for the + H2D PETG slice (`enable_support=1`, `support_type=tree(auto)`, + `support_threshold_angle=30`, `support_on_build_plate_only=0`, + `tree_support_branch_angle=40`). The lower threshold + non- + build-plate-only flag means the top cables get scaffolded even when + the auto-detector is on the fence. **The same `enable_supports()` + patch is now also applied to all three multi-material project + `.3mf`s** (`H2D-MM`, `H2D-MM-PLAcables`, `H2D-MM-PLAstruts-TPUcables`), + so dragging any of them into Bambu Studio and hitting *Slice plate* + produces supports without needing to flip the toggle. +3. **Scale up 1.0 → 1.5×** (see [Scale-up](#scale-up-to-15-cable_d-30--45-mm) + below). At scale 1.5 the cables become 4.5 mm Ø — comfortably above + Bambu's auto-support threshold (verified at scale 1.3 ↔ ≈ 3.9 mm) + and large enough that TPU 85A can self-bridge the top-triangle + spans without supports. + +### Scale-up to 1.5× (`cable_d` 3.0 → 4.5 mm) + +After the team imported `slices/t3-prism.H2D-MM-PLAstruts-TPUcables.3mf` +into Bambu Studio and tried to slice +([PR #35 comment 4461996817](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4461996817): +"not seeing any supports… the design itself is just too small, if it +were bigger then we don't necessarily need supports on the TPU"), it +became clear that even with `enable_support=1` baked into the project +config, Bambu Studio's auto-detector still skipped the 3.0 mm top cables +on the production-target slice — exactly the auto-detector boundary +@achris0520 originally observed at scale 1.0 ↔ 1.3. + +So we added a **`scale_factor`** parameter (default **1.5**) at the top +of [`t3-prism.scad`](t3-prism.scad) that multiplies every linear +dimension. At scale 1.5: + +| Dim | Scale 1.0 | Scale 1.5 (default) | Why bigger helps | +| ----------- | --------: | --------: | --- | +| `cable_d` | 3.0 mm | **4.5 mm** | 50% wider first-layer bridge chord; well above the auto-detector threshold and within the 1.2–6.0 mm printable-tendon window. TPU 85A self-bridges 4.5 mm cylinders without supports. | +| `strut_d` | 6.0 mm | **9.0 mm** | proportional — keeps the strut-to-cable ratio constant. | +| `R`, `H` | 25, 70 mm | **37.5, 105 mm** | bounding box ~75 × 75 × 115 mm — still fits 4-up on the 350 × 320 mm H2D plate (see [Batch printing](#batch-printing-for-the-optimization-campaign)). | +| Print time | ≈ 1 h 41 m | ≈ 2 h 41 m | single-material PETG with supports, ≈ 31.6 g vs ≈ 11.6 g. | + +To revert to the old size for a quick test print, run +`openscad -D 'scale_factor=1.0' …` or change the constant in +`t3-prism.scad`. + +Optional further mitigations not applied here but documented for +re-runs: + +4. **Re-orient so one strut lies flat on the build plate** — kills the + horizontal top-triangle bridges entirely; cables print at + self-supporting 30°–60° diagonals. +5. **Tune PETG bridge settings** — 100% fan + slicer bridge speed/flow + overrides. + +### Batch printing (for the optimization campaign) + +Per [PR #35 comment 4461855403](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4461855403) +("we can definitely start doing batch prints for these since it won't +take an exorbitant amount of time longer per print than doing a single +print … in general we can do batch prints during the optimization +campaign #29 #30 #23 #24"), the H2D's 350 × 320 mm plate fits a 2 × 2 +grid of scale-1.5 prisms (each ~75 × 75 mm) with comfortable spacing — +or a 3 × 2 grid if oriented strut-flat. Recommended workflow: + +1. Open `slices/t3-prism.H2D-MM-PLAstruts-TPUcables.3mf` in Bambu Studio. +2. Right-click the assembled object → *Clone* → enter the desired count + (4 for 2 × 2, 6 for 3 × 2). Bambu Studio will auto-arrange. +3. *Slice plate* (supports already on from this PR) → *Send to printer*. + +A single 4-up batch on the H2D takes ≈ 4 × the per-part filament but +only ≈ 1.3–1.5× the per-part time (motion + heat-up + bridge purge are +amortised across the batch), so for the optimization campaign DoE +([#23](https://github.com/vertical-cloud-lab/tensegrity-optimization/issues/23), +[#24](https://github.com/vertical-cloud-lab/tensegrity-optimization/issues/24), +[#29](https://github.com/vertical-cloud-lab/tensegrity-optimization/issues/29), +[#30](https://github.com/vertical-cloud-lab/tensegrity-optimization/issues/30)) +this is the obvious path to higher throughput. We deliberately keep the +committed `slices/*.3mf` as **single-instance** projects so the team can +choose 1, 4, or 6 copies per print interactively in Bambu Studio +depending on filament budget and which DoE corner they're sampling. + +### Can the BambuStudio CLI add supports? + +Yes. Supports are a process-profile setting (`enable_support`, +`support_type`, `support_threshold_angle`, `support_on_build_plate_only`, +`tree_support_branch_angle`, …) in `Metadata/project_settings.config` +inside the `.3mf`. The `enable_supports()` helper in +[`render_print.sh`](render_print.sh) patches these fields into the +flattened process JSON before the CLI's slice pass, so the resulting +`.gcode.3mf` includes generated support g-code (we verified +`enable_support='1'` and `support_type='tree(auto)'` in the committed +`slices/t3-prism.H2D-PETG.gcode.3mf`'s +`Metadata/project_settings.config`). Both manual (`grid`/`normal(auto)`) +and tree (`tree(auto)`/`tree(hybrid)`) supports are reachable through +the same JSON knobs, and `support_filament` can route them to a +specific extruder on the IDEX H2D if you want PLA scaffolding under +PETG cables. + +#### Verifying supports are *natively* in the sliced g-code + +Per [PR #35 comment 4462414588][c-supports-render]'s ask ("we'll +likely start sending these prints programmatically [#31][i31] / +[powder-doser PR #23][pd23] rather than using Bambu Studio… show me +a render of your sliced file so I can verify supports actually +exist"), the pipeline also emits a colour-coded 3D render of +`plate_1.gcode` after each slice: + +![Native tree(auto) supports in the H2D PETG slice](t3-prism.H2D-PETG-supports.png) + +The render is produced by [`render_supports.py`](render_supports.py), +which parses every `G0`/`G1`/`G2`/`G3` extrusion move in +`Metadata/plate_1.gcode` and bins it by the BambuStudio `; FEATURE:` +marker. For `slices/t3-prism.H2D-PETG.gcode.3mf` (scale 1.5×, +`cable_d = 4.5 mm`, supports forced on by `enable_supports()`) the +parser counts **36 788 support extrusion moves + 2 563 support- +interface moves out of 184 133 total extrusion moves** — confirming +that the BambuStudio CLI (`--slice 1`) actually wrote the tree(auto) +scaffolding into the g-code, not just toggled a flag in +`project_settings.config`. The tree branches under the lower triangle +and the dense fan under the top triangle (which carries the three +horizontal top cables) are both clearly visible in red, with +support-interface caps in orange right under the model overhang. + +This step runs at the end of [`render_print.sh`](render_print.sh) +without any GUI dependency, so the same verification can be performed +unattended in CI / on a headless render host. + +[c-supports-render]: https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4462414588 +[i31]: https://github.com/vertical-cloud-lab/tensegrity-optimization/issues/31 +[pd23]: https://github.com/vertical-cloud-lab/powder-doser/pull/23 + + +### Multi-material variant (PLA struts + PETG cables, IDEX) + +`slices/t3-prism.H2D-MM.3mf` is a Bambu Studio project that splits the +T3-prism into two parts of a single assembled object: + +| Part | Filament | H2D extruder | Tensegrity role | +| ---- | -------- | -----------: | --------------- | +| `t3-prism-struts.stl` (3 struts + 6 joint vertex spheres) | **PLA** | 1 (left) | rigid compression members + load-bearing nodes | +| `t3-prism-cables.stl` (3 bottom + 3 top + 3 saddle cables) | **PETG** | 2 (right) | tougher tension members; the PETG slot is the placeholder we'll swap to **TPU** later for true compliant strings | + +PLA owns the joints because the tensegrity invariant is "stiff bars in +compression don't touch each other but do meet the strings at the +nodes" — putting the joint spheres in the rigid filament gives every +vertex a hard anchor that the cable end-caps (each cable is rendered +with a half-sphere at each end, by the same `member()` helper as the +single-material model) bond into during the multi-material print. + +How it's built (`slice_bambu_mm` in `render_print.sh`): + +1. OpenSCAD renders `t3-prism-struts.stl` and `t3-prism-cables.stl` + pre-translated to the H2D bed centre (`offset_x=175`, `offset_y=160`, + `offset_z=3.5`) so both halves share the same world coordinates and + form a true assembled tensegrity, not two separate objects on the + plate. +2. `BambuStudio --assemble --export-3mf` (no `--slice`) merges both + STLs into a single object with two ``s under + `3D/Objects/object_1.model`. +3. `patch_mm_extruder.py` rewrites `Metadata/model_settings.config` so + the cables `` carries `extruder=2` (PETG); the struts `` + keeps the default `extruder=1` (PLA). + +Open `slices/t3-prism.H2D-MM.3mf` in Bambu Studio (drag-and-drop or +*File → Open Project*); both parts are already merged with the correct +PLA / PETG extruder assignment. Hit *Slice plate* and *Send to printer* +— no manual GUI fiddling required. + +The script does **not** emit a sliced `.gcode.3mf` for the +multi-material variant. BambuStudio v02.06.00.51's headless slice path +crashes (`free(): invalid pointer`) when `--load-filament-ids "1,2"` is +combined with `--assemble + --slice`, and re-loading a project `.3mf` +as input + `--slice` fails with *"No valid nozzle found. Please check +nozzle count."* — so the GUI is currently the only reliable way to +slice the assembly. Track this against future BambuStudio releases. + +### Multi-material variant — swap (PETG struts + PLA cables, IDEX) + +`slices/t3-prism.H2D-MM-PLAcables.3mf` is the same `--assemble`d two-part +project as `H2D-MM.3mf`, but with the per-part filament assignment +swapped — requested in +[PR #35 comment 4445480059](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4445480059) +("create a version of the T3-prism with the cables made of PLA"): + +| Part | Filament | H2D extruder | Tensegrity role | +| ---- | -------- | -----------: | --------------- | +| `t3-prism-struts.stl` (3 struts + 6 joint vertex spheres) | **PETG** | 1 (left) | tougher compression members + load-bearing nodes | +| `t3-prism-cables.stl` (3 bottom + 3 top + 3 saddle cables) | **PLA** | 2 (right) | stiffer tension members (PLA E ≈ 3.3 GPa vs PETG ≈ 2 GPa) | + +PLA cables give a much stiffer "string" than PETG cables. This is an +A/B comparison print against `H2D-MM.3mf` (PLA struts + PETG cables) to +help decide which polymer pair best previews the eventual TPU 85A +swap on the cables. Same parametric SCAD geometry (`cable_d = 3.0 mm`, +supports forced on per the H2D recipe) — only the filament-vs-part +assignment changes, achieved by swapping the order of the two +`--load-filaments` arguments in the second `slice_bambu_mm` call in +`render_print.sh` (the `patch_mm_extruder.py` step still maps +`t3-prism-cables.stl → extruder 2`, but extruder 2 is now PLA). + +**Mechanical interlock at the joint** (PLA-cable–to–PETG-strut bonding) is +out of scope for this slice — the parts share a small overlap volume at +each vertex sphere, but PLA does not chemically bond to PETG. The +"TPU glove around the strut ends" mechanical-interlock concept being +discussed in +[PR #39 comment 4427586306](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/39#issuecomment-4427586306) +will need a small SCAD addition (a thin TPU sleeve around the upper +end of each strut) to be tracked there, not here. + +### Multi-material variant — production target (PLA struts + TPU 85A cables, IDEX) + +`slices/t3-prism.H2D-MM-PLAstruts-TPUcables.3mf` is the **production +target** pairing — what the team will actually print when they want a +real-feeling tensegrity demonstrator. Requested in +[PR #35 comment 4455977731](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4455977731) +("a design that uses PLA for the struts and TPU for the cables so +[@me-madsen / @achris0520 / @ctrhjk] can slice and print this file +directly"): + +| Part | Filament | H2D extruder | Tensegrity role | +| ---- | -------- | -----------: | --------------- | +| `t3-prism-struts-scaffold.stl` (3 struts + 6 joint spheres + 42 PLA scaffold pillars) | **PLA** (`Bambu PLA Basic @BBL H2D`) | 1 (left) | rigid compression skeleton + sacrificial supports under the TPU cables | +| `t3-prism-cables.stl` (3 bottom + 3 top + 3 saddle cables) | **TPU 85A** (`Bambu TPU 85A @BBL H2D 0.4 nozzle`) | 2 (right) | compliant tension members (E ≈ 12 MPa secant, σ_break ≈ 26 MPa) | + +This is the closest single-print analog to a real tensegrity: stiff PLA +bars carry compression, soft TPU 85A "strings" carry tension, and the +two are mechanically locked at each joint sphere. + +**Modeled-in PLA scaffold under the TPU cables.** Per +[PR #35 comment 4464251671](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/35#issuecomment-4464251671) +("we want to put PLA support points at 7 points along the length of the +TPU to keep it upright"), the PLA half of the production variant now +includes **7 thin PLA pillars rising from the build plate up to +evenly-spaced touch-points on each of the 6 non-bottom-triangle cables** +(3 saddle + 3 top = 42 pillars total). The bottom-triangle cables sit +on the bed and don't need scaffolding, so their would-be pillars are +filtered out by the `scaffold_min_h` cutoff. Pillar geometry: truncated +cone, 3.0 mm Ø at the bed (stable base), 1.4 mm Ø at the cable contact +(snaps off cleanly post-print). Because the pillars are *modeled into +the geometry* and routed to the PLA extruder, the slicer cannot omit +them the way the tree(auto) auto-detector does for near-vertical +features; and because the PLA-TPU interface bond is weak in shear +(~6.5 MPa butt) the user can break the pillars off after the print +without scarring the TPU surface. + +![scaffold geometry](t3-prism-iso-with-scaffold.png) + +**Open in Bambu Studio**, hit *Slice plate*, then *Send to printer* — +the per-part extruder assignment, filament types, and bed type are all +baked in. Same parametric SCAD geometry as the rest of this directory +(`cable_d = 3.0 mm`, supports forced on by the H2D process recipe so the +top-cable bridges get scaffolded — TPU especially needs the support). + +**PLA↔TPU bond strength** is the best-characterized FFF inter-material +bond in the literature: PLA–TPU butt-fusion 6.5 MPa, alternating-deposition +7.4 MPa, mechanical-interlock shear ~24 MPa (Lopes 2018, Zhang 2026, +Ruwais 2025; see +[`edison-trajectories/strut-material-selection-5bb5e5d3-*.md`](../../edison-trajectories/strut-material-selection-5bb5e5d3-b386-4ece-a894-9c87f0d67036.md)). +This is why this MM pairing is the one to print first — the alternative +PETG–TPU pairing has *no* peer-reviewed bond data. + +**Joint design** — the vertex spheres in this slice are simple unioned +overlaps. The TPU "glove" / barbed-rebar mechanical interlocks under +discussion in [PR #39](https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/39) +and the joint-design Phase-3/4 Edison work (issue #38) will land in a +follow-up SCAD revision; for the first PLA+TPU print the union joint +plus PLA↔TPU adhesion should hold for handling and demonstration loads. + +### CLI gotchas (from powder-doser PR #23) + +The script handles four non-obvious gotchas: + +1. **Inheritance is not resolved by the CLI.** Bundled + `resources/profiles/BBL/{machine,process,filament}/*.json` files only + carry overrides on top of `@base` parents. `flatten_bambu_profile.py` + walks the `inherits:` chain and shallow-merges parent → child into a + single full-config JSON. +2. **Identity-field patches.** The CLI's compatibility check needs + `from = "system"`, `inherits = ""`, and (on the machine config) + `printer_settings_id = `. The flattener applies these. +3. **Bed compatibility.** PETG is rejected on the default Cool Plate + (`return -61`); the script overrides `curr_bed_type = "Textured PEI + Plate"` on the machine profile. +4. **IDEX manual filament map (H2D).** The H2D is dual-extruder, so + even a single-filament print needs `--filament-map-mode Manual + --filament-map 1`, and the manual-map setup is gated by + `plate_to_slice != 0` so the script passes `--slice 1`. + +### Sending to the H2D + +Copy the sliced `.gcode.3mf` to the printer over LAN (FTPS on `:990`) +and start it via MQTT-over-TLS on `:8883`. The minimum payload is +documented in +[`vertical-cloud-lab/powder-doser` PR #23](https://github.com/vertical-cloud-lab/powder-doser/pull/23): + +```bash +# Upload +lftp -u "bblp," -e \ + "set ftp:ssl-allow yes; set ssl:verify-certificate no; \ + cd /cache; put t3-prism.H2D-PETG.gcode.3mf; bye" \ + ftps://:990 + +# Start +mosquitto_pub --insecure -h -p 8883 \ + -u bblp -P "" \ + -t "device//request" \ + -m '{"print":{"sequence_id":"0","command":"project_file", + "param":"Metadata/plate_1.gcode", + "url":"ftp:///cache/t3-prism.H2D-PETG.gcode.3mf", + "project_id":"0","profile_id":"0","task_id":"0","subtask_id":"0", + "subtask_name":"","md5":"","timelapse":false,"bed_type":"auto", + "bed_levelling":true,"flow_cali":true,"vibration_cali":true, + "layer_inspect":true,"ams_mapping":"","use_ams":false}}' +``` + +For the cloud / GUI workflow, open `slices/t3-prism.H2D.3mf` (the +project) in Bambu Studio and use *Send to printer*. + +## References & related work + +- Issue: ["Get a bambu sliced print for a T3-prism"](../../README.md) +- Programmatic-CAD pattern reused from + [`vertical-cloud-lab/powder-doser` PR #16](https://github.com/vertical-cloud-lab/powder-doser/pull/16) + (parametric `.scad` + headless OpenSCAD + slicer CLI). +- BambuStudio CLI recipe (flattening profiles, `xvfb-run`, software GL, + inspecting `result.json` and the `Metadata/plate_1.gcode` inside the + `.gcode.3mf`) reused from + [`vertical-cloud-lab/powder-doser` PR #23](https://github.com/vertical-cloud-lab/powder-doser/pull/23). +- Programmatic-Bambu / meta-CAD survey: + [`vertical-cloud-lab/powder-doser` PR #7](https://github.com/vertical-cloud-lab/powder-doser/pull/7). +- Reference image: [Wikipedia — T3-prism tensegrity](https://en.wikipedia.org/wiki/Tensegrity). diff --git a/cad/t3-prism/flatten_bambu_profile.py b/cad/t3-prism/flatten_bambu_profile.py new file mode 100644 index 00000000..0240b3b9 --- /dev/null +++ b/cad/t3-prism/flatten_bambu_profile.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +""" +Flatten a Bambu Studio profile by walking its `inherits` chain. + +The Bambu Studio CLI does NOT resolve `inherits:` references in the profile +JSONs (this is documented on the BambuStudio Command-Line Usage wiki and +empirically verified in `vertical-cloud-lab/powder-doser` PR #23). Passing +`resources/profiles/BBL//Bambu PETG Basic @BBL X1C.json` to +`--load-filaments` therefore fails because that file only carries the +*overrides* on top of `Bambu PETG Basic @base`, which itself overrides +`fdm_filament_pet`, etc. + +This script walks the chain (parent first, child last), shallow-merges +each layer onto the accumulator (child wins), and writes a single +self-contained "full config" that the CLI accepts after a couple of small +identity-field patches: + + machine.from = "system" (so name == new_printer_system_name) + machine.inherits = "" (do not look up a missing parent) + machine.printer_settings_id = (CLI compatibility check) + (mirror from=system, inherits="" on the process and filament configs) + +Usage: + flatten_bambu_profile.py + +Example: + flatten_bambu_profile.py machine "Bambu Lab X1 Carbon 0.4 nozzle" \\ + /tmp/squashfs-root/resources/profiles/BBL x1c_machine_flat.json +""" +import json +import sys +from pathlib import Path + + +def load(kind: str, name: str, root: Path) -> dict: + p = root / kind / f"{name}.json" + if not p.exists(): + raise FileNotFoundError(f"profile not found: {p}") + return json.loads(p.read_text()) + + +def flatten(kind: str, leaf: str, root: Path) -> dict: + """Walk the inherits chain and shallow-merge parent → child.""" + chain: list[dict] = [] + name = leaf + while name: + node = load(kind, name, root) + chain.append(node) + name = node.get("inherits", "") + # Merge oldest ancestor first, then each descendant overrides + merged: dict = {} + for node in reversed(chain): + merged.update(node) + # Patches the BambuStudio CLI compatibility check needs + merged["from"] = "system" + merged["inherits"] = "" + merged["name"] = leaf + if kind == "machine": + merged["printer_settings_id"] = leaf + return merged + + +def main() -> int: + if len(sys.argv) != 5: + print(__doc__, file=sys.stderr) + return 2 + kind, leaf, root, out = sys.argv[1:] + flat = flatten(kind, leaf, Path(root)) + Path(out).write_text(json.dumps(flat, indent=2)) + print(f"wrote {out} ({len(json.dumps(flat))} bytes, type={flat.get('type')}, name={flat.get('name')!r})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cad/t3-prism/onshape_upload_t3prism.py b/cad/t3-prism/onshape_upload_t3prism.py new file mode 100644 index 00000000..fbb45db9 --- /dev/null +++ b/cad/t3-prism/onshape_upload_t3prism.py @@ -0,0 +1,371 @@ +"""Onshape REST: upload the T3-prism print STLs so dimensions can be verified +manually in Onshape before printing (PR #35 comment 4896509287; workflow +adapted from powder-doser PR #7 `cad/meta-tools/onshape_upload_assembly.py`). + +Uploads the three production STLs (struts / struts+scaffold / cables) plus the +fused single-material body to a public document owned by the "Vertical Cloud +Lab" Onshape classroom, then reads each imported Part Studio's bounding box +back through the API and prints it in millimetres — a programmatic check that +the STL imported at mm scale (Onshape reports bounding boxes in metres; a +unit mix-up shows up as a 1000x error immediately). + +Reads ONSHAPE_ACCESS_KEY / ONSHAPE_SECRET_KEY from env (HMAC-signed REST). +Real document/element ids are printed to stdout only and never committed. + +Overrides (same conventions as the powder-doser script):: + + ONSHAPE_TARGET_DOC_NAME= # default: Tensegrity T3-prism (PR #35) + ONSHAPE_OWNER_NAME= # default: Vertical Cloud Lab + ONSHAPE_OWNER_ID= # takes precedence over name + ONSHAPE_PUBLIC=0 # opt out of public visibility + +Run with:: + + python3 cad/t3-prism/onshape_upload_t3prism.py + +Any other set of STLs (e.g. the per-specimen BO batch pairs) can be pushed to +their own document without editing the file:: + + python3 cad/t3-prism/onshape_upload_t3prism.py \\ + --doc-name "T3-prism Sobol batch 01 (PR #35)" --jobs 6 \\ + --stl spec00-struts=bo/per-specimen-stls/t3-prism-bo-spec00-struts.stl \\ + --stl spec00-cables=bo/per-specimen-stls/t3-prism-bo-spec00-cables.stl +""" +from __future__ import annotations + +import argparse +import base64 +import concurrent.futures +import datetime +import hashlib +import hmac +import json +import os +import pathlib +import secrets +import sys +import time +import urllib.error +import urllib.request +import uuid + +BASE = os.environ.get("ONSHAPE_BASE_URL", "https://cad.onshape.com") +HERE = pathlib.Path(__file__).resolve().parent + +TARGET_DOC_NAME = os.environ.get( + "ONSHAPE_TARGET_DOC_NAME", "Tensegrity T3-prism (PR #35)" +) +OWNER_NAME = os.environ.get("ONSHAPE_OWNER_NAME", "Vertical Cloud Lab") +OWNER_ID = os.environ.get("ONSHAPE_OWNER_ID") +OWNER_TYPE = int(os.environ.get("ONSHAPE_OWNER_TYPE", "1")) # 1 = COMPANY + + +def _truthy(val: str) -> bool: + return val.strip().lower() not in ("", "0", "false", "no", "off") + + +IS_PUBLIC = _truthy(os.environ.get("ONSHAPE_PUBLIC", "1")) + +# The three STLs the team prints from (PR #35 comment 4815672887) plus the +# fused single-material body for reference. +STLS = [ + ("t3-prism-struts", HERE / "t3-prism-struts.stl"), + ("t3-prism-struts-scaffold", HERE / "t3-prism-struts-scaffold.stl"), + ("t3-prism-cables", HERE / "t3-prism-cables.stl"), + ("t3-prism-full", HERE / "t3-prism.stl"), +] + + +def _sign(method: str, secret_key: bytes, access_key: str, path: str, + query: str, ctype: str) -> dict: + nonce = secrets.token_hex(13)[:25] + date = datetime.datetime.now(datetime.timezone.utc).strftime( + "%a, %d %b %Y %H:%M:%S GMT" + ) + sig_str = "\n".join([method, nonce, date, ctype, path, query, ""]).lower() + sig = base64.b64encode( + hmac.new(secret_key, sig_str.encode("utf-8"), hashlib.sha256).digest() + ).decode() + return { + "Date": date, + "On-Nonce": nonce, + "Authorization": f"On {access_key}:HmacSHA256:{sig}", + "Content-Type": ctype, + "Accept": "application/json", + } + + +def signed(method: str, access: str, secret: bytes, path: str, + query: str = "", body: bytes | None = None, + ctype: str = "application/json"): + headers = _sign(method, secret, access, path, query, ctype) + url = BASE + path + (("?" + query) if query else "") + req = urllib.request.Request(url, method=method, data=body, headers=headers) + try: + with urllib.request.urlopen(req, timeout=120) as r: + return r.status, r.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + + +def multipart_signed(access: str, secret: bytes, path: str, fields: dict, + file_name: str, file_bytes: bytes): + boundary = "----onshape" + uuid.uuid4().hex + ctype = f"multipart/form-data; boundary={boundary}" + parts = [] + for k, v in fields.items(): + parts.append( + f"--{boundary}\r\nContent-Disposition: form-data; " + f"name=\"{k}\"\r\n\r\n{v}\r\n".encode() + ) + parts.append( + f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; " + f"filename=\"{file_name}\"\r\n" + f"Content-Type: application/octet-stream\r\n\r\n".encode() + + file_bytes + + f"\r\n--{boundary}--\r\n".encode() + ) + body = b"".join(parts) + headers = _sign("POST", secret, access, path, "", ctype) + headers["Accept"] = "application/json;charset=UTF-8;qs=0.09" + req = urllib.request.Request(BASE + path, method="POST", data=body, + headers=headers) + try: + with urllib.request.urlopen(req, timeout=300) as r: + return r.status, r.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + + +def _resolve_owner(access: str, sk: bytes) -> tuple[str | None, int]: + if OWNER_ID: + return OWNER_ID, OWNER_TYPE + if not OWNER_NAME: + return None, 0 + code, body = signed("GET", access, sk, "/api/v6/companies") + if code == 200: + for c in json.loads(body).get("items", []): + if c.get("name") == OWNER_NAME: + return c["id"], 1 + code, body = signed("GET", access, sk, "/api/v6/teams") + if code == 200: + for t in json.loads(body).get("items", []): + if t.get("name") == OWNER_NAME: + return t["id"], 2 + print(f" [owner] no company/team named {OWNER_NAME!r}; " + "falling back to user-owned") + return None, 0 + + +def _resolve_doc(access: str, sk: bytes) -> tuple[str, str, bool, str]: + """Return (did, wid, created, owner_label) for TARGET_DOC_NAME, + creating it (classroom-owned, public) on first run.""" + owner_id, owner_type = _resolve_owner(access, sk) + owner_label = (f"{OWNER_NAME} (companyId=, type={owner_type})" + if owner_id else "calling user") + + def _scan(filter_id: int, extra: str = "") -> str | None: + offset = 0 + while True: + q = (f"filter={filter_id}&limit=20&offset={offset}" + f"&sortColumn=modifiedAt&sortOrder=desc{extra}") + code, body = signed("GET", access, sk, "/api/v6/documents", q) + if code != 200: + return None + page = json.loads(body) + for doc in page.get("items", []): + if doc.get("name") == TARGET_DOC_NAME: + return doc["id"] + if not page.get("next") and len(page.get("items", [])) < 20: + return None + offset += 20 + + did = _scan(0) + if did is None and owner_id: + did = _scan(7, f"&owner={owner_id}&ownerType={owner_type}") + + if did is not None: + code, body = signed("GET", access, sk, f"/api/v6/documents/{did}") + if code != 200: + raise SystemExit(f"GET /documents/ HTTP {code}: {body[:200]!r}") + wid = json.loads(body)["defaultWorkspace"]["id"] + return did, wid, False, owner_label + + doc_payload: dict = { + "name": TARGET_DOC_NAME, + "description": ("T3-prism print STLs (PLA struts / PLA struts+scaffold" + " / TPU cables) for manual dimension verification " + "before printing. Auto-created by " + "cad/t3-prism/onshape_upload_t3prism.py (PR #35)."), + "isPublic": IS_PUBLIC, + } + if owner_id: + doc_payload["ownerId"] = owner_id + doc_payload["ownerType"] = owner_type + code, body = signed("POST", access, sk, "/api/v6/documents", "", + body=json.dumps(doc_payload).encode("utf-8")) + if code not in (200, 201): + raise SystemExit(f"POST /documents HTTP {code}: {body[:300]!r}") + j = json.loads(body) + return j["id"], j["defaultWorkspace"]["id"], True, owner_label + + +def _upload_stl(access: str, sk: bytes, did: str, wid: str, + name: str, stl_path: pathlib.Path) -> list[str]: + """Upload one STL with translate=true; return new element ids.""" + display_name = f"{name}.stl" + fields = { + "encodedFilename": display_name, + "fileName": display_name, + "translate": "true", + "storeInDocument": "true", + "createComposite": "false", + "splitAssembliesIntoMultipleDocuments": "false", + "flattenAssemblies": "false", + "yAxisIsUp": "false", + "allowFaultyParts": "true", + # STLs are unitless; the SCAD/STL pipeline is millimetres throughout. + "unit": "MILLIMETER", + } + code, body = multipart_signed( + access, sk, f"/api/v6/blobelements/d/{did}/w/{wid}", + fields, display_name, stl_path.read_bytes(), + ) + if code not in (200, 201): + print(f" [{name}] upload HTTP {code}: " + f"{body[:300].decode(errors='replace')}") + return [] + j = json.loads(body) + tid = j.get("translationId") or j.get("id") + if not tid: + print(f" [{name}] no translationId in response") + return [] + deadline = time.time() + 600 + while time.time() < deadline: + time.sleep(5) + code, body = signed("GET", access, sk, f"/api/v6/translations/{tid}") + if code != 200: + continue + t = json.loads(body) + state = t.get("requestState") + if state == "DONE": + return t.get("resultElementIds") or [] + if state == "FAILED": + print(f" [{name}] translation FAILED: {t.get('failureReason')}") + return [] + print(f" [{name}] translation poll timed out") + return [] + + +def _bbox_mm(access: str, sk: bytes, did: str, wid: str, + eid: str) -> str | None: + """Read a Part Studio's bounding box and format it in mm. + + `/partstudios/.../boundingboxes` reports **millimetres** already (verified + against the local STL extents), so no unit conversion is applied here — an + earlier ``* 1000`` made every import look 1000x oversized. + """ + code, body = signed( + "GET", access, sk, + f"/api/v6/partstudios/d/{did}/w/{wid}/e/{eid}/boundingboxes", + ) + if code != 200: + return None + j = json.loads(body) + try: + lo = (j["lowX"], j["lowY"], j["lowZ"]) + hi = (j["highX"], j["highY"], j["highZ"]) + except KeyError: + return None + dims = [h - l for l, h in zip(lo, hi)] + return (f"{dims[0]:.2f} x {dims[1]:.2f} x {dims[2]:.2f} mm " + f"(X x Y x Z)") + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--stl", action="append", default=[], metavar="NAME=PATH", + help="upload this STL as Part Studio NAME (repeatable); replaces the " + "default four production STLs when given", + ) + ap.add_argument( + "--doc-name", default=None, + help=f"Onshape document name (default: {TARGET_DOC_NAME!r})", + ) + ap.add_argument( + "--jobs", type=int, default=1, + help="concurrent uploads/translations (default 1)", + ) + return ap.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + global TARGET_DOC_NAME + args = _parse_args(argv) + if args.doc_name: + TARGET_DOC_NAME = args.doc_name + + stls = STLS + if args.stl: + stls = [] + for spec in args.stl: + if "=" not in spec: + print(f"--stl expects NAME=PATH, got {spec!r}") + return 2 + name, _, path = spec.partition("=") + stls.append((name, pathlib.Path(path).resolve())) + + access = os.environ.get("ONSHAPE_ACCESS_KEY") + secret = os.environ.get("ONSHAPE_SECRET_KEY") + if not access or not secret: + print("ONSHAPE_ACCESS_KEY/SECRET_KEY not set; aborting.") + return 1 + sk = secret.encode("utf-8") + print(f"BASE = {BASE}") + + did, wid, created, owner_label = _resolve_doc(access, sk) + print(f"target document ({'created' if created else 'found'}): " + f"{TARGET_DOC_NAME!r}") + print(f" owner: {owner_label} public: {IS_PUBLIC}") + doc_url = f"{BASE}/documents/{did}/w/{wid}" + print(f"document URL: {doc_url}") + + to_upload = [(n, p) for n, p in stls if p.exists()] + for n, p in stls: + if not p.exists(): + print(f" [{n}] missing at {p}, skipping") + + print(f"\n== uploading {len(to_upload)} STLs " + f"(jobs={max(1, args.jobs)}) ==") + + def _one(item): + name, stl_path = item + print(f"[{name}] uploading {stl_path.name} " + f"({stl_path.stat().st_size} bytes) ...", flush=True) + eids = _upload_stl(access, sk, did, wid, name, stl_path) + boxes = [(eid, _bbox_mm(access, sk, did, wid, eid)) for eid in eids] + for eid, bbox in boxes: + print(f" [{name}] -> {BASE}/documents/{did}/w/{wid}/e/{eid}\n" + f" bounding box: {bbox or '(unavailable)'}", flush=True) + return name, boxes + + if max(1, args.jobs) > 1: + with concurrent.futures.ThreadPoolExecutor(args.jobs) as ex: + results = list(ex.map(_one, to_upload)) + else: + results = [_one(item) for item in to_upload] + + print("\n== Clickable Onshape URLs ==") + print(f"Document: {doc_url}") + for name, boxes in results: + if not boxes: + print(f" {name}: (upload failed or no element id)") + for eid, bbox in boxes: + print(f" {name}: {BASE}/documents/{did}/w/{wid}/e/{eid}" + f" [{bbox or 'bbox unavailable'}]") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cad/t3-prism/patch_mm_extruder.py b/cad/t3-prism/patch_mm_extruder.py new file mode 100644 index 00000000..ee6555de --- /dev/null +++ b/cad/t3-prism/patch_mm_extruder.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Patch per-part extruder assignment in a Bambu Studio project ``.3mf``. + +BambuStudio CLI's ``--assemble`` merges several STLs into one object with +one ```` per STL, but it puts every part on extruder 1 by default +and does not honour ``--load-filament-ids`` or any per-part flag. For the +T3-prism multi-material variant (PLA struts + PETG cables) we patch the +resulting ``Metadata/model_settings.config`` after the fact so each +part gets the correct ``extruder`` metadata. + +Usage:: + + patch_mm_extruder.py PROJECT.3MF NAME=EXTRUDER_ID [NAME=EXTRUDER_ID ...] + +Where ``NAME`` is the part's source STL filename (matched against the +```` immediately after ``]*>.*?)", re.DOTALL) +NAME_RE = re.compile(r'') +EXTRUDER_RE = re.compile(r'( str: + out = [] + for chunk in PART_SPLIT.split(cfg): + if chunk.startswith("{ext}\g<2>", chunk) + else: + # No existing extruder metadata — inject one before . + chunk = chunk.replace( + "", + f' \n ', + ) + out.append(chunk) + return "".join(out) + + +def patch_project_filaments(proj_json: bytes) -> bytes: + """Expand the per-filament arrays in ``project_settings.config`` so they + match the length of ``filament_settings_id``. + + BambuStudio CLI's ``--load-filaments a;b`` populates ``filament_settings_id``, + ``filament_type``, and ``filament_ids`` with one entry per filament, but it + leaves ``filament_colour`` and ``filament_map`` at their single-entry + defaults (``['#00AE42']`` / ``['1']``). When Bambu Studio re-imports the + project it uses the SHORTER of those arrays to determine how many filament + slots the project occupies — the user sees a single PLA filament even + though both filaments are configured (PR #35 comment 4464399849). + + Fix: pad ``filament_colour`` from ``DEFAULT_COLOURS`` and pad + ``filament_map`` with ascending extruder indices (``['1', '2', ...]``). + """ + d = json.loads(proj_json.decode()) + n = len(d.get("filament_settings_id", [])) + if n <= 1: + return proj_json + cols = list(d.get("filament_colour", [])) + while len(cols) < n: + cols.append(DEFAULT_COLOURS[len(cols) % len(DEFAULT_COLOURS)]) + d["filament_colour"] = cols[:n] + fmap = list(d.get("filament_map", [])) + while len(fmap) < n: + fmap.append(str(len(fmap) + 1)) + d["filament_map"] = fmap[:n] + # `filament_nozzle_map` tells Bambu Studio which *physical nozzle* each + # filament is loaded into (1-based). BambuStudio CLI leaves this at the + # single-entry default ``['1']`` after ``--load-filaments a;b``, which + # causes the headless slice path to fail with "could not found + # extruder_type Direct Drive, nozzle_volume_type Standard, filament_index + # 2, extruder index 2" because filament 2 has no nozzle assignment. + # Mirror ``filament_map`` (1, 2, 3, ...) so each filament is pinned to + # the IDEX nozzle that the H2D loads it into. + nmap = list(d.get("filament_nozzle_map", [])) + while len(nmap) < n: + nmap.append(str(len(nmap) + 1)) + d["filament_nozzle_map"] = nmap[:n] + # Switch from "Auto For Flush" to "Manual" so Bambu Studio honours the + # explicit per-extruder map instead of trying to re-pack onto one nozzle + # — IDEX H2D prints with one extruder per material, no flush tower needed. + d["filament_map_mode"] = "Manual" + return (json.dumps(d, indent=2) + "\n").encode() + + +def main(argv: list[str]) -> int: + if len(argv) < 3: + print(__doc__, file=sys.stderr) + return 2 + target, *pairs = argv[1:] + mapping: dict[str, str] = {} + for p in pairs: + if "=" not in p: + print(f"bad NAME=EXT pair: {p!r}", file=sys.stderr) + return 2 + name, ext = p.split("=", 1) + mapping[name] = ext + + with zipfile.ZipFile(target, "r") as zin: + infos = zin.infolist() + contents = {info.filename: zin.read(info.filename) for info in infos} + + if CFG_PATH not in contents: + print(f"{target}: missing {CFG_PATH}", file=sys.stderr) + return 1 + + cfg = contents[CFG_PATH].decode() + new_cfg = patch(cfg, mapping) + contents[CFG_PATH] = new_cfg.encode() + + if PROJ_PATH in contents: + contents[PROJ_PATH] = patch_project_filaments(contents[PROJ_PATH]) + + # Rewrite the archive in place, preserving member order/compression. + with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zout: + for info in infos: + zout.writestr(info, contents[info.filename]) + + print(f"patched {target}: " + ", ".join(f"{k}->ext{v}" for k, v in mapping.items())) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/cad/t3-prism/render_print.sh b/cad/t3-prism/render_print.sh new file mode 100755 index 00000000..9efc202a --- /dev/null +++ b/cad/t3-prism/render_print.sh @@ -0,0 +1,437 @@ +#!/usr/bin/env bash +# ============================================================================ +# Tensegrity Optimization — T3-prism Bambu print-prep pipeline. +# +# Issue: "Get a bambu sliced print for a T3-prism" — pure PETG, single +# part, prepared for the Bambu Lab H2D (the lab's only printer; see +# `.github/copilot-instructions.md`). Renders the parametric OpenSCAD +# model to a printable STL, sanity-checks the mesh, generates an iso +# preview PNG, and produces TWO H2D artifacts: +# +# * `slices/t3-prism.H2D.3mf` — Bambu Studio PROJECT file +# (re-importable in Bambu Studio for editing / re-slicing, +# drag-and-drop or File > Open Project). No g-code inside. +# * `slices/t3-prism.H2D-PETG.gcode.3mf` — sliced PRINT JOB +# (uploaded to the printer over LAN/cloud; contains +# `Metadata/plate_1.gcode` for direct firmware consumption). +# Bambu Studio refuses to re-import this format with +# "The file does not contain any geometry data" — that is by +# design (it's a printer-side artifact, not a model). +# +# Implementation follows the empirically-verified BambuStudio CLI recipe +# from `vertical-cloud-lab/powder-doser` PR #23: +# 1. Download the official BambuStudio Linux AppImage (pinned version). +# 2. Extract its bundled `resources/profiles/BBL/{machine,process,filament}`. +# 3. Flatten the `inherits:` chain on each profile JSON into a single +# "full config" — the CLI does NOT resolve inheritance. +# 4. Patch `from=system`, `inherits=""`, and `printer_settings_id` on +# the machine profile so the CLI's compatibility check accepts it. +# Set `curr_bed_type=Textured PEI Plate` so PETG passes the +# filament-vs-bed compatibility check. +# 5. Run the CLI twice: once WITHOUT `--slice` to produce the project +# `.3mf`, once WITH `--slice 1` (and IDEX manual filament-map for +# the dual-extruder H2D) to produce the sliced `.gcode.3mf`. +# +# Outputs (next to this script): +# t3-prism.stl Watertight, single-part PETG body +# t3-prism-iso.png OpenSCAD iso preview +# slices/t3-prism.H2D.3mf Bambu Studio project (re-importable) +# slices/t3-prism.H2D-PETG.gcode.3mf Sliced print job (printer upload) +# +# Pre-reqs: openscad, admesh, xvfb, plus the BambuStudio AppImage runtime +# deps (Ubuntu 24.04): +# sudo apt-get install -y openscad admesh xvfb \ +# gstreamer1.0-plugins-base libsoup-3.0-0 libwebkit2gtk-4.1-0 +# ============================================================================ +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCAD="${HERE}/t3-prism.scad" +STL="${HERE}/t3-prism.stl" +STL_STRUTS="${HERE}/t3-prism-struts.stl" +STL_STRUTS_SCAFFOLD="${HERE}/t3-prism-struts-scaffold.stl" +STL_CABLES="${HERE}/t3-prism-cables.stl" +PNG="${HERE}/t3-prism-iso.png" +SLICES_DIR="${HERE}/slices" +SCRATCH="${SCRATCH:-/tmp/t3-prism}" +mkdir -p "${SLICES_DIR}" "${SCRATCH}" + +# Pinned BambuStudio version (matches powder-doser PR #23 verification). +BAMBU_VERSION="${BAMBU_VERSION:-v02.06.00.51}" +BAMBU_APPIMAGE="${BAMBU_APPIMAGE:-${SCRATCH}/bambu.AppImage}" +BAMBU_URL="${BAMBU_URL:-https://github.com/bambulab/BambuStudio/releases/download/${BAMBU_VERSION}/BambuStudio_ubuntu-24.04-${BAMBU_VERSION}-20260417160415.AppImage}" + +# ---------------------------------------------------------------------------- +# 1. SCAD -> STL (single-material full body + per-part halves for MM) +# ---------------------------------------------------------------------------- +echo "==> OpenSCAD render -> ${STL##*/} (single-material, both materials fused)" +FIRST_LOG="$(xvfb-run -a openscad -o "${STL}" --export-format=binstl "${SCAD}" 2>&1)" +echo "${FIRST_LOG}" | tail -3 + +# `offset_z` lifts the geometry so its lowest point sits at the build-plate +# z=0. With the captive-core joints (default since PR #35 comment 4511036510) +# the bottom-vertex PLA shell underside is at SCAD z=-captive_shell_od/2 +# ≈ -8.15 mm, but the flat bottom accelerometer key-seats +# (`add_accel_mount_bottom`, default since PR #35 / PR #67 2026-07-01) hang a +# further `accel_drop()` below that, making the true lowest feature the flat +# key-seat cap at SCAD z ≈ -18.29 mm (scale 1.5). We lift by +18.29 mm so the +# three coplanar flat caps land on the bed (a stable 3-point flat base). The +# cables STL inherits the SAME world-Z bounding box via the `cables_z_anchor()` +# spike inside `t3_prism_cables()` (which now also extends downward by +# `accel_drop()`), so Bambu Studio's per-part auto-bed-placement applies the +# same z offset to both halves and the TPU cables stay aligned with the joints +# (fixes the "horizontal cables too low at top and bottom" misalignment +# reported above PR #35 comment 4511036510). With `add_accel_mount_bottom=false` +# drop this back to 8.15; with `use_captive_core=false` too, drop to 3.5. +# Compute the bed-lift from the SCAD's analytic lowest-Z echo so it always +# matches the active scale / accel-housing config (was hardcoded 18.29, which +# only held at scale 1.5 + the previous housing). MODEL_Z_LO is negative; we +# lift every part by exactly -MODEL_Z_LO so the flat bottom key-seats land on +# the bed (z=0). Falls back to 18.29 if the echo can't be parsed. +Z_LO="$(echo "${FIRST_LOG}" | grep -oE 'MODEL_Z_LO"?, ?-?[0-9.]+' | grep -oE '\-?[0-9.]+$' | tail -1)" +if [[ -n "${Z_LO}" ]]; then + OFFSET_Z="${OFFSET_Z:-$(python3 -c "print(f'{-float(\"${Z_LO}\"):.4f}')")}" +else + OFFSET_Z="${OFFSET_Z:-18.29}" +fi +echo "==> Bed-lift OFFSET_Z=${OFFSET_Z} (from MODEL_Z_LO=${Z_LO:-unparsed})" +echo "==> OpenSCAD render -> ${STL_STRUTS##*/} (multi-material: rigid half / PLA, bed-centered)" +xvfb-run -a openscad -o "${STL_STRUTS}" --export-format=binstl \ + -D 'part="struts"' -D 'offset_x=175' -D 'offset_y=160' -D "offset_z=${OFFSET_Z}" "${SCAD}" + +echo "==> OpenSCAD render -> ${STL_CABLES##*/} (multi-material: tension half / PETG, bed-centered)" +xvfb-run -a openscad -o "${STL_CABLES}" --export-format=binstl \ + -D 'part="cables"' -D 'offset_x=175' -D 'offset_y=160' -D "offset_z=${OFFSET_Z}" "${SCAD}" + +# Production MM variant (PLA struts + TPU cables) needs PLA scaffold pillars +# *modeled* into the strut/PLA half so the slicer can't omit them. Bambu's +# tree(auto) supports skip near-vertical features and even with the most +# permissive thresholds will not scaffold the long unsupported runs of TPU +# cable that wave around mid-print. The scaffold-augmented strut STL emits +# the strut bodies + 7 thin PLA pillars from z=0 up to evenly-spaced +# touch-points on each of the 6 non-bottom cables (the bottom triangle is +# already on the build plate). Per PR #35 comment 4464251671. +echo "==> OpenSCAD render -> ${STL_STRUTS_SCAFFOLD##*/} (struts + 7-point PLA scaffold under TPU cables)" +xvfb-run -a openscad -o "${STL_STRUTS_SCAFFOLD}" --export-format=binstl \ + -D 'part="struts_scaffold"' -D 'offset_x=175' -D 'offset_y=160' -D "offset_z=${OFFSET_Z}" "${SCAD}" + +echo "==> admesh manifold check" +admesh -fundecvb "${SCRATCH}/t3-prism-clean.stl" "${STL}" \ + | grep -E '(Number of parts|disconnected|Degenerate|Volume)' | head -6 +admesh -fundecvb "${SCRATCH}/t3-prism-struts-clean.stl" "${STL_STRUTS}" \ + | grep -E '(Number of parts|disconnected|Degenerate|Volume)' | head -6 +admesh -fundecvb "${SCRATCH}/t3-prism-cables-clean.stl" "${STL_CABLES}" \ + | grep -E '(Number of parts|disconnected|Degenerate|Volume)' | head -6 +admesh -fundecvb "${SCRATCH}/t3-prism-struts-scaffold-clean.stl" "${STL_STRUTS_SCAFFOLD}" \ + | grep -E '(Number of parts|disconnected|Degenerate|Volume)' | head -6 + +# ---------------------------------------------------------------------------- +# 2. Iso preview PNG (for the README + PR thumbnail) +# ---------------------------------------------------------------------------- +echo "==> Iso preview PNG -> ${PNG##*/}" +xvfb-run -a openscad -o "${PNG}" --imgsize=600,800 \ + --autocenter --viewall --colorscheme=Tomorrow \ + --projection=perspective "${SCAD}" + +# ---------------------------------------------------------------------------- +# 3. BambuStudio AppImage + bundled BBL profiles +# ---------------------------------------------------------------------------- +if [[ ! -x "${BAMBU_APPIMAGE}" ]]; then + echo "==> Fetching BambuStudio ${BAMBU_VERSION} AppImage" + curl -sLo "${BAMBU_APPIMAGE}" "${BAMBU_URL}" + chmod +x "${BAMBU_APPIMAGE}" +fi + +BBL_ROOT="${SCRATCH}/squashfs-root/resources/profiles/BBL" +if [[ ! -d "${BBL_ROOT}" ]]; then + echo "==> Extracting bundled BBL profiles from AppImage" + (cd "${SCRATCH}" && "${BAMBU_APPIMAGE}" --appimage-extract resources/profiles/BBL > /dev/null) +fi + +# ---------------------------------------------------------------------------- +# 4. Flatten inherits chain + slice for each printer +# ---------------------------------------------------------------------------- +flatten () { + local kind="$1" leaf="$2" out="$3" + python3 "${HERE}/flatten_bambu_profile.py" "${kind}" "${leaf}" "${BBL_ROOT}" "${out}" +} + +patch_bed () { + # PETG is rejected on the default Cool Plate; switch to Textured PEI. + python3 -c " +import json, sys +p = sys.argv[1] +d = json.load(open(p)) +d['curr_bed_type'] = 'Textured PEI Plate' +d['default_bed_type'] = 'Textured PEI Plate' +json.dump(d, open(p, 'w'), indent=2) +" "$1" +} + +enable_supports () { + # Enable supports in a flattened process profile. Iteration history: + # + # * 9f28b57: turned supports ON with `support_threshold_angle=30`. + # Caught the horizontal top cables (90° overhang) but skipped every + # near-vertical member. + # * THIS REVISION (PR #35 comment 4464152505): the user's photo of the + # scaled-up print shows supports only under the lower triangle and + # under the horizontal top cables — nothing scaffolding the three + # struts (B_i -> T_i) or the three saddle cables (B_{i+1} -> T_i), + # which then wave during printing because TPU 85A can't hold itself. + # + # Geometry at scale_factor=1.5 (`R=30`, `H=75`): the strut chord between + # B_i and T_i has horizontal run = 2*R*sin(30°) = 30 mm and vertical run + # = 75 mm, so the strut tilts only ~21.8° from vertical (~68.2° from + # horizontal). Bambu's `support_threshold_angle` is the *overhang angle + # from vertical*; a feature gets supports when its tilt EXCEEDS the + # threshold. With the default 30° threshold the strut at 21.8° falls + # BELOW the trigger, so the slicer skips it entirely. Saddle cables sit + # in the same regime. + # + # Fix: drop the threshold to 10° so anything tilted more than 10° from + # vertical is scaffolded (catches all struts + saddles + top cables), + # densify the tree branches (`tree_support_branch_distance` 5.0 -> 2.0 + # so multiple branches encircle each thin pillar instead of one lonely + # branch per region), beef up the trunks (`tree_support_branch_diameter` + # 2.0 -> 3.0, `tree_support_wall_count` 0 -> 2 for stiffer scaffolding + # against the TPU pulling sideways), and disable + # `support_critical_regions_only` so the slicer doesn't restrict + # supports to the most-extreme overhangs and skip everything in + # between. Adds two interface layers (`support_interface_top_layers`) + # so removing the supports leaves a cleaner cable surface. + python3 -c " +import json, sys +p = sys.argv[1] +d = json.load(open(p)) +d['enable_support'] = '1' +d['support_type'] = 'tree(auto)' +d['support_threshold_angle'] = '10' +d['support_on_build_plate_only'] = '0' +d['support_critical_regions_only'] = '0' +d['tree_support_branch_angle'] = '40' +d['tree_support_branch_distance'] = '2' +d['tree_support_branch_diameter'] = '3' +d['tree_support_wall_count'] = '2' +d['support_interface_top_layers'] = '2' +d['support_interface_bottom_layers'] = '2' +json.dump(d, open(p, 'w'), indent=2) +" "$1" +} + +slice_bambu () { + # Produce TWO H2D artifacts in one call: + # 1. .3mf — project file (no `--slice`), re-importable + # in Bambu Studio for editing/re-slicing. + # 2. -PETG.gcode.3mf — sliced print job (with `--slice 1` and IDEX + # manual filament-map for the H2D), uploaded + # to the printer over LAN/cloud. + local tag="$1" machine_leaf="$2" process_leaf="$3" filament_leaf="$4" + local m="${SCRATCH}/${tag}_machine_flat.json" + local p="${SCRATCH}/${tag}_process_flat.json" + local f="${SCRATCH}/${tag}_filament_flat.json" + local proj_3mf="t3-prism.${tag}.3mf" + local sliced_3mf="t3-prism.${tag}-PETG.gcode.3mf" + local proj_outdir="${SCRATCH}/proj_${tag}" + local sliced_outdir="${SCRATCH}/sliced_${tag}" + + echo "==> [${tag}] Flatten profiles" + flatten machine "${machine_leaf}" "${m}" + flatten process "${process_leaf}" "${p}" + flatten filament "${filament_leaf}" "${f}" + patch_bed "${m}" + enable_supports "${p}" + + echo "==> [${tag}] BambuStudio CLI -> ${proj_3mf} (project, re-importable)" + rm -rf "${proj_outdir}" && mkdir -p "${proj_outdir}" + LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \ + xvfb-run -a -s "-screen 0 1280x1024x24" "${BAMBU_APPIMAGE}" \ + --orient 1 --arrange 1 \ + --load-settings "${m};${p}" \ + --load-filaments "${f}" \ + --export-3mf "${proj_3mf}" \ + --outputdir "${proj_outdir}" \ + "${STL}" 2>&1 | tail -2 + cp "${proj_outdir}/${proj_3mf}" "${SLICES_DIR}/${proj_3mf}" + + echo "==> [${tag}] BambuStudio CLI -> ${sliced_3mf} (sliced print job)" + rm -rf "${sliced_outdir}" && mkdir -p "${sliced_outdir}" + # Manual filament map is gated by `plate_to_slice != 0`; pass --slice 1. + # IDEX (H2D) needs Manual mode + an explicit map even with one filament. + LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \ + xvfb-run -a -s "-screen 0 1280x1024x24" "${BAMBU_APPIMAGE}" \ + --orient 1 --arrange 1 \ + --load-settings "${m};${p}" \ + --load-filaments "${f}" \ + --filament-map-mode "Manual" --filament-map "1" \ + --slice 1 \ + --export-3mf "${sliced_3mf}" \ + --outputdir "${sliced_outdir}" \ + "${STL}" 2>&1 | tail -2 + + # Surface the slice stats (return_code, time, weight) and copy the 3MF in. + python3 -c " +import json, sys +r = json.load(open(sys.argv[1] + '/result.json')) +print(f' return_code = {r[\"return_code\"]}, error_string = {r[\"error_string\"]!r}') +for plate in r.get('sliced_plates', []): + pred = float(plate.get('total_predication', plate.get('main_predication', 0))) + grams = sum(float(f.get('total_used_g', 0)) for f in plate.get('filaments', [])) + print(f' plate {plate.get(\"id\")}: time = {pred/3600:.2f} h ({pred/60:.1f} min), weight = {grams:.2f} g') +" "${sliced_outdir}" + cp "${sliced_outdir}/${sliced_3mf}" "${SLICES_DIR}/${sliced_3mf}" +} + +slice_bambu_mm () { + # Multi-material H2D variant (PLA struts + PETG cables, IDEX). + # + # The two STLs (`t3-prism-struts.stl`, `t3-prism-cables.stl`) are + # rendered above in the SAME world coordinates (both pre-translated to + # the H2D bed centre) so they form a true assembled tensegrity, not two + # separate objects on the bed. To get BambuStudio to treat them as + # parts of one object we: + # + # 1. Run with `--assemble` (no `--slice`) to merge both STLs into a + # single Bambu Studio project (`` with two ``s). + # `--assemble + --slice + manual filament map` is unstable in + # v02.06.00.51 (segfaults on `--load-filament-ids` with merged + # objects), so the slice happens in a follow-up pass. + # 2. Patch the resulting `Metadata/model_settings.config` so the + # cables part is assigned to extruder 2 (PETG) while the struts + # part stays on extruder 1 (PLA). The defaults from `--assemble` + # put both parts on extruder 1. + # 3. The resulting `slices/t3-prism.H2D-MM.3mf` opens in Bambu + # Studio with both parts already merged into one object and the + # correct PLA/PETG extruder assignment per part — the user just + # hits Slice / Send to printer. + # + # We do NOT emit a sliced `.gcode.3mf` for the multi-material variant + # from the CLI, because BambuStudio v02.06.00.51's headless slice path + # does not honour per-part extruder assignment when re-loading a + # project 3mf as input. The Bambu Studio GUI handles this correctly. + local tag="$1" machine_leaf="$2" process_leaf="$3" + local f1_leaf="$4" f2_leaf="$5" + local struts_stl="${6:-${STL_STRUTS}}" + local struts_stl_basename + struts_stl_basename="$(basename "${struts_stl}")" + local m="${SCRATCH}/${tag}_machine_flat.json" + local p="${SCRATCH}/${tag}_process_flat.json" + local f1="${SCRATCH}/${tag}_filament1_flat.json" + local f2="${SCRATCH}/${tag}_filament2_flat.json" + local proj_3mf="t3-prism.${tag}.3mf" + local proj_outdir="${SCRATCH}/proj_${tag}" + + echo "==> [${tag}] Flatten profiles (PLA + PETG dual-filament)" + flatten machine "${machine_leaf}" "${m}" + flatten process "${process_leaf}" "${p}" + flatten filament "${f1_leaf}" "${f1}" + flatten filament "${f2_leaf}" "${f2}" + patch_bed "${m}" + # Force tree(auto) supports for the MM project too — the top-cable + # bridges still need scaffolding regardless of which filament fills + # them (PETG/PLA struts/cables, or the production PLA + TPU pairing). + # Without this the project opens in Bambu Studio with supports OFF + # and the user has to remember to flip the toggle before slicing. + enable_supports "${p}" + + echo "==> [${tag}] BambuStudio CLI --assemble -> ${proj_3mf} (one object, two parts)" + rm -rf "${proj_outdir}" && mkdir -p "${proj_outdir}" + LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \ + xvfb-run -a -s "-screen 0 1280x1024x24" "${BAMBU_APPIMAGE}" \ + --assemble \ + --load-settings "${m};${p}" \ + --load-filaments "${f1};${f2}" \ + --export-3mf "${proj_3mf}" \ + --outputdir "${proj_outdir}" \ + "${struts_stl}" "${STL_CABLES}" 2>&1 | tail -2 + + echo "==> [${tag}] Patch model_settings.config: cables part -> extruder 2 (PETG)" + python3 "${HERE}/patch_mm_extruder.py" "${proj_outdir}/${proj_3mf}" \ + "t3-prism-cables.stl=2" "${struts_stl_basename}=1" + cp "${proj_outdir}/${proj_3mf}" "${SLICES_DIR}/${proj_3mf}" +} + +# Bambu Lab H2D — the lab's only printer (see `.github/copilot-instructions.md`). +# Settings match Marcus's `cad/t3-prism/t3-prism.3mf` Bambu Studio project +# (PETG Basic on left extruder, no supports). +slice_bambu "H2D" \ + "Bambu Lab H2D 0.4 nozzle" \ + "0.20mm Standard @BBL H2D" \ + "Bambu PETG Basic @BBL H2D 0.4 nozzle" + +# Multi-material H2D variant (PLA struts + PETG cables). PLA gives the +# rigid compression skeleton (eventually keeps its role); PETG handles the +# tension cables and is the placeholder for the eventual TPU swap. The +# tensegrity analogy: stiff bars in compression, compliant strings in +# tension, no two compression members touching. +slice_bambu_mm "H2D-MM" \ + "Bambu Lab H2D 0.4 nozzle" \ + "0.20mm Standard @BBL H2D" \ + "Bambu PLA Basic @BBL H2D" \ + "Bambu PETG Basic @BBL H2D 0.4 nozzle" + +# Multi-material H2D variant with the materials swapped: PETG struts + +# **PLA cables**. Requested in PR #35 comment 4445480059 ("create a +# version of the T3-prism with the cables made of PLA"). PLA on the +# tension members gives a much stiffer "string" (PLA E ≈ 3.3 GPa vs +# PETG E ≈ 2 GPa) and lets the team A/B-test which polymer pair best +# previews the eventual TPU 85A swap. Filament order is swapped relative +# to `H2D-MM`: f1 = PETG (struts/extruder 1), f2 = PLA (cables/extruder +# 2). The mechanical-interlock-at-the-joint discussion (TPU "glove" +# wrapping the strut ends) is being tracked separately in PR #39 — this +# slice keeps the same parametric SCAD geometry and just swaps the +# per-part filament assignment. +slice_bambu_mm "H2D-MM-PLAcables" \ + "Bambu Lab H2D 0.4 nozzle" \ + "0.20mm Standard @BBL H2D" \ + "Bambu PETG Basic @BBL H2D 0.4 nozzle" \ + "Bambu PLA Basic @BBL H2D" + +# Multi-material H2D variant — the **production-target** pairing: PLA struts +# + **TPU 85A cables**. Requested in PR #35 comment 4455977731 ("a design +# that uses PLA for the struts and TPU for the cables so [the team] can +# slice and print this file directly"). PLA gives the rigid compression +# skeleton; TPU 85A (NinjaFlex-class, E ≈ 12 MPa secant) gives the +# compliant tension cables that mimic real tensegrity strings. Filament +# slot 1 = PLA (struts/extruder 1), slot 2 = TPU 85A (cables/extruder 2). +# The PLA↔TPU interface has the best peer-reviewed bond data (PLA–TPU butt +# 6.5 MPa, alt-deposition 7.4 MPa, mech-interlock shear ~24 MPa; see +# `edison-trajectories/strut-material-selection-5bb5e5d3-*.md`), making +# this the lowest-risk MM combination for a single-print tensegrity. Per +# PR #39 comment 4427586306, the TPU "glove" / mechanical-interlock joint +# is being tracked separately under joint-design (issue #38) and is not +# baked into the geometry here. +slice_bambu_mm "H2D-MM-PLAstruts-TPUcables" \ + "Bambu Lab H2D 0.4 nozzle" \ + "0.20mm Standard @BBL H2D" \ + "Bambu PLA Basic @BBL H2D" \ + "Bambu TPU 85A @BBL H2D 0.4 nozzle" \ + "${STL_STRUTS_SCAFFOLD}" + +echo +echo "==> Render support-extrusion verification PNG (supports baked into g-code)" +python3 "${HERE}/render_supports.py" \ + "${SLICES_DIR}/t3-prism.H2D-PETG.gcode.3mf" \ + "${HERE}/t3-prism.H2D-PETG-supports.png" \ + "t3-prism.H2D-PETG.gcode.3mf (scale 1.5x, cable_d 4.5mm, supports=tree/auto, baked natively by BambuStudio CLI --slice 1)" + +echo +echo "==> Done." +echo " STL: ${STL}" +echo " STL struts: ${STL_STRUTS}" +echo " STL cables: ${STL_CABLES}" +echo " Iso: ${PNG}" +echo " Single-material (PETG, full pipeline incl. sliced print job):" +echo " Project: ${SLICES_DIR}/t3-prism.H2D.3mf (Bambu Studio re-importable)" +echo " Sliced: ${SLICES_DIR}/t3-prism.H2D-PETG.gcode.3mf (printer upload)" +echo " Multi-material (PLA struts + PETG cables, IDEX, project only):" +echo " Project: ${SLICES_DIR}/t3-prism.H2D-MM.3mf (open in Bambu Studio," +echo " Slice + Send to printer)" +echo " Multi-material swap (PETG struts + PLA cables, IDEX, project only):" +echo " Project: ${SLICES_DIR}/t3-prism.H2D-MM-PLAcables.3mf" +echo " (open in Bambu Studio," +echo " Slice + Send to printer)" +echo " Multi-material production target (PLA struts + TPU 85A cables, IDEX, project only):" +echo " Project: ${SLICES_DIR}/t3-prism.H2D-MM-PLAstruts-TPUcables.3mf" +echo " (open in Bambu Studio," +echo " Slice + Send to printer)" diff --git a/cad/t3-prism/render_supports.py b/cad/t3-prism/render_supports.py new file mode 100644 index 00000000..a2bb2612 --- /dev/null +++ b/cad/t3-prism/render_supports.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# ============================================================================ +# Render a sliced Bambu `.gcode.3mf` (or raw `plate_1.gcode`) into a 3D PNG +# that visually proves whether supports were generated natively by the slicer. +# +# Supports/Support-interface extrusion moves are color-coded distinctly from +# model moves, so the reviewer can see the tree(auto) scaffolding next to the +# T3-prism geometry. Used to answer PR #35 comment 4462414588 (verify supports +# exist in the slicer's g-code, not just in the project settings). +# +# Usage: +# python3 render_supports.py [title] +# +# Categories (from BambuStudio `; FEATURE: ` markers): +# * Support — tree/normal support bodies +# * Support interface — top/bottom touchpoint layers +# * (everything else) — model walls, infill, bridges, etc. (downsampled) +# ============================================================================ +import os +import re +import sys +import zipfile + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.lines import Line2D +from mpl_toolkits.mplot3d.art3d import Line3DCollection + + +def load_gcode(src: str) -> str: + if src.endswith(".3mf"): + with zipfile.ZipFile(src) as z: + with z.open("Metadata/plate_1.gcode") as f: + return f.read().decode("utf-8", errors="replace") + with open(src, "r", errors="replace") as f: + return f.read() + + +def parse_segments(gcode: str): + """Return (model_segs, support_segs, iface_segs) lists of + ((x0,y0,z0),(x1,y1,z1)) tuples for every extrusion move.""" + g_re = re.compile(r"^G[0123]\b") + num_re = re.compile(r"([XYZEFIJ])(-?\d*\.?\d+)") + feat_re = re.compile(r"^;\s*FEATURE:\s*(.+?)\s*$") + + x = y = z = 0.0 + abs_xyz = True + feat = "Custom" + model, support, iface = [], [], [] + + for line in gcode.splitlines(): + if line.startswith(";"): + m = feat_re.match(line) + if m: + feat = m.group(1) + continue + if line.startswith("G90"): + abs_xyz = True + continue + if line.startswith("G91"): + abs_xyz = False + continue + if not g_re.match(line): + continue + nx, ny, nz, e = x, y, z, None + for tag, val in num_re.findall(line): + v = float(val) + if tag == "X": + nx = v if abs_xyz else x + v + elif tag == "Y": + ny = v if abs_xyz else y + v + elif tag == "Z": + nz = v if abs_xyz else z + v + elif tag == "E": + e = v + if e is not None and e > 0 and (nx != x or ny != y): + seg = ((x, y, z), (nx, ny, nz)) + if feat == "Support": + support.append(seg) + elif feat == "Support interface": + iface.append(seg) + else: + model.append(seg) + x, y, z = nx, ny, nz + return model, support, iface + + +def downsample(segs, max_n): + if len(segs) <= max_n: + return segs + idx = np.linspace(0, len(segs) - 1, max_n).astype(int) + return [segs[i] for i in idx] + + +def render(src: str, out: str, title: str | None = None) -> None: + gcode = load_gcode(src) + model, support, iface = parse_segments(gcode) + print( + f"Parsed {src}: model={len(model)}, support={len(support)}, " + f"support-interface={len(iface)}" + ) + + model_color = (0.55, 0.65, 0.78, 0.35) + support_color = (0.95, 0.20, 0.20, 0.95) + iface_color = (1.0, 0.55, 0.0, 0.95) + + # Dense multi-specimen plates need more model segments before the + # structures read as anything but haze; `RS_MODEL_MAX` raises the cap. + model_ds = downsample(model, int(os.environ.get("RS_MODEL_MAX", "40000"))) + + fig = plt.figure(figsize=(11, 9)) + ax = fig.add_subplot(111, projection="3d") + if model_ds: + ax.add_collection3d( + Line3DCollection( + model_ds, colors=[model_color] * len(model_ds), linewidths=0.4 + ) + ) + if support: + ax.add_collection3d( + Line3DCollection( + support, colors=[support_color] * len(support), linewidths=0.7 + ) + ) + if iface: + ax.add_collection3d( + Line3DCollection( + iface, colors=[iface_color] * len(iface), linewidths=0.9 + ) + ) + + allpts = np.array( + [p for s in (model_ds + support + iface) for p in s] + ) + if len(allpts): + mn, mx = allpts.min(0), allpts.max(0) + ctr = (mn + mx) / 2 + span = mx - mn + half = span[:2].max() / 2 * 1.05 + ax.set_xlim(ctr[0] - half, ctr[0] + half) + ax.set_ylim(ctr[1] - half, ctr[1] + half) + # Scale Z to the part height instead of forcing a cube: on a full + # 350 mm plate a square box aspect spends most of the frame on air. + ax.set_zlim(max(0, mn[2]), mn[2] + max(span[2], 1e-6) * 1.05) + ax.set_box_aspect((1, 1, max(span[2] / max(span[:2].max(), 1e-6), 0.15))) + else: + ax.set_box_aspect((1, 1, 1)) + ax.set_xlabel("X (mm)") + ax.set_ylabel("Y (mm)") + ax.set_zlabel("Z (mm)") + ax.set_title( + f"{title or os.path.basename(src)}\n" + f"model segs (gray) · support segs (red) · support-interface (orange)\n" + f"support extrusion moves: {len(support) + len(iface)} of " + f"{len(support) + len(iface) + len(model)}" + ) + elev, azim = ( + float(v) for v in os.environ.get("RS_VIEW", "22,-58").split(",") + ) + ax.view_init(elev=elev, azim=azim) + ax.legend( + handles=[ + Line2D([0], [0], color=model_color, lw=2, + label=f"Model ({len(model)} segs)"), + Line2D([0], [0], color=support_color, lw=2, + label=f"Support ({len(support)})"), + Line2D([0], [0], color=iface_color, lw=2, + label=f"Support interface ({len(iface)})"), + ], + loc="upper left", + ) + plt.tight_layout() + plt.savefig(out, dpi=140, bbox_inches="tight") + print(f"Saved {out}") + + +if __name__ == "__main__": + if len(sys.argv) < 3: + print(__doc__) + sys.exit(1) + title = sys.argv[3] if len(sys.argv) > 3 else None + render(sys.argv[1], sys.argv[2], title) diff --git a/cad/t3-prism/slices/t3-prism.H2D-MM-PLAcables.3mf b/cad/t3-prism/slices/t3-prism.H2D-MM-PLAcables.3mf new file mode 100644 index 00000000..f13b7334 Binary files /dev/null and b/cad/t3-prism/slices/t3-prism.H2D-MM-PLAcables.3mf differ diff --git a/cad/t3-prism/slices/t3-prism.H2D-MM-PLAstruts-TPUcables.3mf b/cad/t3-prism/slices/t3-prism.H2D-MM-PLAstruts-TPUcables.3mf new file mode 100644 index 00000000..f2653724 Binary files /dev/null and b/cad/t3-prism/slices/t3-prism.H2D-MM-PLAstruts-TPUcables.3mf differ diff --git a/cad/t3-prism/slices/t3-prism.H2D-MM.3mf b/cad/t3-prism/slices/t3-prism.H2D-MM.3mf new file mode 100644 index 00000000..dbb92bc3 Binary files /dev/null and b/cad/t3-prism/slices/t3-prism.H2D-MM.3mf differ diff --git a/cad/t3-prism/slices/t3-prism.H2D-PETG.gcode.3mf b/cad/t3-prism/slices/t3-prism.H2D-PETG.gcode.3mf new file mode 100644 index 00000000..2c2e0b21 Binary files /dev/null and b/cad/t3-prism/slices/t3-prism.H2D-PETG.gcode.3mf differ diff --git a/cad/t3-prism/slices/t3-prism.H2D.3mf b/cad/t3-prism/slices/t3-prism.H2D.3mf new file mode 100644 index 00000000..dcabb783 Binary files /dev/null and b/cad/t3-prism/slices/t3-prism.H2D.3mf differ diff --git a/cad/t3-prism/t3-prism at 1.3x scale supports.png b/cad/t3-prism/t3-prism at 1.3x scale supports.png new file mode 100644 index 00000000..4924dae8 Binary files /dev/null and b/cad/t3-prism/t3-prism at 1.3x scale supports.png differ diff --git a/cad/t3-prism/t3-prism-cables.stl b/cad/t3-prism/t3-prism-cables.stl new file mode 100644 index 00000000..0e53234f Binary files /dev/null and b/cad/t3-prism/t3-prism-cables.stl differ diff --git a/cad/t3-prism/t3-prism-iso-with-scaffold.png b/cad/t3-prism/t3-prism-iso-with-scaffold.png new file mode 100644 index 00000000..b86ae77c Binary files /dev/null and b/cad/t3-prism/t3-prism-iso-with-scaffold.png differ diff --git a/cad/t3-prism/t3-prism-iso.png b/cad/t3-prism/t3-prism-iso.png new file mode 100644 index 00000000..469b5122 Binary files /dev/null and b/cad/t3-prism/t3-prism-iso.png differ diff --git a/cad/t3-prism/t3-prism-struts-scaffold.stl b/cad/t3-prism/t3-prism-struts-scaffold.stl new file mode 100644 index 00000000..194261c6 Binary files /dev/null and b/cad/t3-prism/t3-prism-struts-scaffold.stl differ diff --git a/cad/t3-prism/t3-prism-struts.stl b/cad/t3-prism/t3-prism-struts.stl new file mode 100644 index 00000000..4d30db35 Binary files /dev/null and b/cad/t3-prism/t3-prism-struts.stl differ diff --git a/cad/t3-prism/t3-prism.3mf b/cad/t3-prism/t3-prism.3mf new file mode 100644 index 00000000..5004014a Binary files /dev/null and b/cad/t3-prism/t3-prism.3mf differ diff --git a/cad/t3-prism/t3-prism.H2D-PETG-supports.png b/cad/t3-prism/t3-prism.H2D-PETG-supports.png new file mode 100644 index 00000000..4239faa3 Binary files /dev/null and b/cad/t3-prism/t3-prism.H2D-PETG-supports.png differ diff --git a/cad/t3-prism/t3-prism.scad b/cad/t3-prism/t3-prism.scad new file mode 100644 index 00000000..764f63c1 --- /dev/null +++ b/cad/t3-prism/t3-prism.scad @@ -0,0 +1,781 @@ +// ============================================================================ +// Tensegrity Optimization — T3-prism (3-strut tensegrity), single-piece. +// ============================================================================ +// +// Geometry +// -------- +// A T3-prism (https://en.wikipedia.org/wiki/Tensegrity) consists of: +// * 3 isolated compression members ("struts"), +// * 9 tension members ("cables"): 3 on the bottom triangle, 3 on the top +// triangle, and 3 vertical/saddle cables connecting them. +// +// The two triangular end-caps are identical equilateral triangles inscribed +// in a circle of radius R. The top triangle is rotated by `twist` degrees +// relative to the bottom (per the issue: 60° -- matches the Wikipedia +// reference image cited on the issue). +// +// B_i = (R*cos(90 + 120*i), R*sin(90 + 120*i), 0) +// T_i = (R*cos(90 + 120*i + twist), R*sin(90 + 120*i + twist), H) +// +// Connectivity (i in {0,1,2}, mod 3 implied): +// strut i : B_i --> T_i +// bottom cable i : B_i --> B_{i+1} +// top cable i : T_i --> T_{i+1} +// vertical/saddle i : B_{i+1} --> T_i +// +// Strut i and saddle i meet at T_i but originate at different bottom +// vertices, which is the defining "no two compression members touch each +// other" property of a tensegrity (the struts themselves are kept apart +// by the cables). +// +// Pure-PETG, single-piece print +// ----------------------------- +// Per the issue: "Assume pure PETG for now, not multi-material." Both the +// struts and the (thinner) cables are unioned into one solid that prints in +// a single PETG extrusion. Cable diameter is intentionally well above any +// FDM minimum-feature limit so the model survives slicing without dropouts. +// +// Render: paste into https://openscad.org/demo/ -> F6 (Render) +// Headless STL + PNG preview + Bambu slice (CI/local): +// bash cad/t3-prism/render_print.sh +// ============================================================================ + +// ---- Parameters (mm / degrees) -------------------------------------------- +// Base (unscaled) geometry. The actual printed dimensions are +// (R, H, strut_d, cable_d, joint_d) * scale_factor. +R_base = 25; // radius of the circumscribing circle of each end triangle +H_base = 70; // distance between bottom and top triangle planes +twist = 60; // rotation of the top triangle relative to the bottom +strut_d_base = 6; // strut (compression member) diameter +// Cable diameter bumped 2.4 -> 3.0 mm after the first H2D PETG print spaghetti'd +// on layer ~362 of the top-cable bridge (PR #16 review). Edison ANALYSIS +// `25c1c897` recommended 3.0–4.0 mm and Marcus's follow-up print empirically +// confirmed the threshold: at scale 1.3x (cable_d ≈ 3.12 mm), Bambu Studio's +// auto-support logic finally tagged the top cables as needing supports; at the +// original 2.4 mm it skipped them and they sagged/waved. 3.0 mm sits inside +// Edison's window while keeping the tensegrity-cable feel. +cable_d_base = 3.0; // cable (tension member) diameter -- >= 2*nozzle for FDM +joint_d_base = 7; // small sphere diameter at each vertex for clean joints +$fn = 48; + +// Uniform scale factor applied to ALL linear dimensions (R, H, strut/cable/joint +// diameters). Bumped 1.0 -> 1.5 per PR #35 comment 4461996817 from @sgbaird: +// "not seeing any supports... the design itself is just too small, if it were +// bigger then we don't necessarily need supports on the TPU." At scale 1.0 the +// 3.0 mm top cables sat just below Bambu Studio's auto-support detection +// threshold, so when the team imported the MM project .3mf and sliced it, the +// slicer skipped supports on the TPU cables and the bridges sagged. At scale +// 1.5 the cables become 4.5 mm Ø — well above Bambu's threshold (verified at +// scale 1.3 ↔ cable_d ≈ 3.9 mm by @achris0520) — and large enough that TPU +// can self-bridge the top-triangle spans without needing supports at all. +// Bounding box at scale 1.5: ~75 × 75 × 115 mm (still fits 4-up on the H2D's +// 350 × 320 mm plate for batch printing — see PR #35 comment 4461855403). +// +// SIZING "S0" (PR #35 comment from @achris0520, 2026-07-01): drop the overall +// specimen to 76.92% of the recent 1.5× generations — i.e. scale_factor = +// 1.5 × 0.7692 ≈ 1.1538. (0.7692 ≈ 1/1.3, so S0 lands just above the scale-1.0 +// baseline and a touch below the empirical auto-support threshold the team hit +// at scale 1.3.) Bounding box at S0: ~58 mm footprint, ~115 mm tall including +// the top igloo + bottom flat accelerometer housings. The accelerometer +// housings are PHYSICAL-part dimensions in absolute mm and do NOT scale with +// this factor, so shrinking the prism leaves the sensor pockets full-size. +S0_scale = 1.5 * 0.7692; // "S0" specimen sizing = 76.92% of the 1.5× generations +scale_factor = S0_scale; + +R = R_base * scale_factor; +H = H_base * scale_factor; +strut_d = strut_d_base * scale_factor; +cable_d = cable_d_base * scale_factor; +joint_d = joint_d_base * scale_factor; + +// `part` selects which subset of members to emit. Used by render_print.sh +// to export the single-material STL ("all") and the two halves of the +// multi-material H2D variant ("struts" -> rigid filament e.g. PLA on +// extruder 1, "cables" -> tougher filament e.g. PETG / eventually TPU on +// extruder 2). The two halves are rendered in the SAME world coordinates +// so the slicer assembles them into the original geometry without any +// per-part transform. The joint vertex spheres travel with the struts so +// the rigid skeleton owns the load-bearing nodes; the cables tie into the +// joints via their own end-cap spheres (`member` adds spheres at both +// ends), giving a multi-material interlock at every vertex. +part = "all"; // "all" | "struts" | "cables" | "scaffold" | "struts_scaffold" | "all_scaffold" + +// ---- Modeled-in PLA scaffold for the TPU cables --------------------------- +// The Bambu CLI's tree(auto) supports reliably scaffold horizontal overhangs +// (lower-triangle and the three top-triangle bridges at the new scale 1.5x) +// but they leave the *near-vertical* members untouched: each strut tilts only +// ~22 deg from vertical, the saddle cables are similar, and Bambu's overhang +// detector measures angle from vertical, so anything below the threshold is +// skipped — even when the slicer is told `support_critical_regions_only=0` +// and `support_threshold_angle=10`. The result on the production +// PLA-struts/TPU-cables print is exactly what @sgbaird-alt's photos show: +// supports under the bottom triangle, but the TPU saddle and top cables +// (and even the strut shafts) wave around mid-print because nothing is +// holding them upright. +// +// Per PR #35 comment 4464251671 ("We want to put PLA support points at 7 +// points along the length of the TPU to keep it upright"), we now MODEL the +// scaffolding directly into the geometry as PLA pillars rising from the +// build plate up to evenly-spaced touch-points on each TPU cable. Because +// they are part of the model the slicer cannot omit them, and because they +// are routed to the PLA extruder in the multi-material variant they peel +// off the TPU cleanly post-print (the PLA-TPU bond is weak in shear, ~6.5 +// MPa butt; see edison-trajectories/strut-material-selection-5bb5e5d3-*). +// +// `n_scaffolds` interior touch-points per cable, evenly spaced at +// fractions k/(n_scaffolds+1) along the cable's length. Pillars are +// truncated cones (wider at the bed for stability, narrower at the +// touch-point so they snap off without scarring the TPU). +n_scaffolds = 7; // touch-points along each TPU cable +scaffold_d_top_base = 1.4; // pillar Ø at the cable contact +scaffold_d_bot_base = 3.0; // pillar Ø at the bed (taper for stability) +scaffold_min_h_base = 4.0; // skip pillars shorter than this (mm, post-scale) + +scaffold_d_top = scaffold_d_top_base * scale_factor; +scaffold_d_bot = scaffold_d_bot_base * scale_factor; +scaffold_min_h = scaffold_min_h_base * scale_factor; + +// ---- Captive TPU core inside a PLA outer shell (Design F) ----------------- +// Per PR #35 comment 4511036510 / PR #39 comment 4461700096 +// (https://github.com/vertical-cloud-lab/tensegrity-optimization/pull/39#issuecomment-4461700096): +// instead of half-burying the TPU cable end in a solid PLA joint sphere +// (the previous design — @ctrhjk's photos in PR #35 showed the cable +// inserts into only kinda half of the joint ball, giving unstable +// fixation and a fully encased TPU that was nearly impossible to remove), +// put a captive TPU "knot" entirely INSIDE a hollow PLA outer shell at +// every joint vertex. The cable still emerges from the shell along its +// outward direction, but it does so through a small PLA bore that is +// strictly narrower than the captive core; the TPU mass therefore cannot +// back out under tension regardless of PLA-TPU bond chemistry. PLA-TPU +// butt-bond is only ~6.5 MPa in shear (Lopes 2018; see +// edison-trajectories/strut-material-selection-5bb5e5d3*), so the shell +// and core stay mechanically separate even in their print-in-place state. +// +// Geometry (each joint vertex carries one strut + three cables; both +// bottom-B_i and top-T_i vertices share the same fan-of-three cable +// pattern, just routed to different remote vertices — see +// `vertex_cable_dirs()` below): +// * `captive_bore_d` : per-cable exit-bore diameter through the shell +// wall (= cable_d + bore_clear). Cable passes +// through with print clearance. +// * `captive_core_od` : TPU captive-core sphere diameter; chosen so +// core_od > bore_d by at least `bore_trap` mm +// (the "trap" — the core cannot fit back out +// any single bore). +// * `captive_shell_id`: shell inner-cavity diameter (= core_od + +// 2*core_clear; gives a print-in-place radial +// gap so the core remains free to wiggle). +// * `captive_shell_od`: outer PLA shell diameter (= shell_id + 2*wall). +// * `captive_teardrop`: hull-blend offset that smoothly fillets the +// shell sphere into the strut cylinder, removing +// the sharp re-entrant corner at the shell/strut +// intersection (avoids the stress-concentration +// and over-extrusion artefact reported in +// @ctrhjk's first PETG+TPU print). +// PR #35 comment 4513722886 (@sgbaird): "make sure each TPU vertex has a +// full spherical PLA shell around it (no gaps except to allow TPU to pass +// through) and is in contact with the internal TPU vertex so the two +// material types bond together." Print-in-place clearances dropped to +// zero so the TPU core is bonded to the PLA inner shell wall and the +// cable fills its exit bore exactly (no annular air ring). +// PR #35 comment 4514072758 (@sgbaird): "the teardrop shape was fine. +// Stick with the teardrop style to reduce stresses." Restored the +// teardrop hull blend toward the strut so the shell/strut intersection +// is filleted instead of a sharp re-entrant corner. Also fixed the +// cable bore to cut only the outward half of the shell wall (the bore +// previously punched through both sides of the sphere, which created +// the mystery "holes on a lot of the vertices" he reported in +// 4514072758 — every cable was making TWO holes, not one). +use_captive_core = true; // set false to revert to solid joint spheres +captive_bore_clear = 0.0; // mm, single-sided clearance around the cable (bonded) +captive_bore_trap = 1.5; // mm, MIN (core_od - bore_d) / 2 so the core can't escape +captive_core_clear = 0.0; // mm, radial gap shell-ID -> core-OD (0 = bonded) +captive_wall_base = 1.6; // mm, PLA shell wall thickness (un-scaled) +captive_teardrop_z = 1.5; // mm, axial offset of the teardrop reference sphere +captive_wall = captive_wall_base * scale_factor; +captive_bore_d = cable_d + 2 * captive_bore_clear; +captive_core_od = max(captive_bore_d + 2 * captive_bore_trap, joint_d); +captive_shell_id = captive_core_od + 2 * captive_core_clear; +captive_shell_od = max(captive_shell_id + 2 * captive_wall, joint_d); +captive_teardrop_d = strut_d * 1.10; // seed sphere for the teardrop blend + +// ---- Accelerometer mount (Dytran 3133A4 tri-axis, 6 x 6 x 5.94 mm) -------- +// PR #35 comment 4794790065 (@sgbaird): "extrude some extra material in a +// block on top of the top vertices and cut out a place to secure the 3-axis +// accelerometer ... a cable needs to feed out horizontally (hence three +// 'walls' and one opening) and ... because there will be a bit of adhesive +// there should be a bit of clearance so it can fit inside. A rounded shape on +// top should be preserved so that it's a bit like an igloo with the +// accelerometer sliding in, such that there is less friction between the +// acrylic plate and the tensegrity structure." +// +// We add one rounded ("igloo") mount on top of each of the three top vertices +// (so the team can secure the accelerometer to whichever vertex is convenient +// and every top contact point against the acrylic drop-test plate is rounded +// to reduce friction). Each mount is a small PLA block fused onto the top +// joint shell, with a rectangular pocket sized to the accelerometer plus an +// adhesive/fit clearance. The pocket is closed on the back and both sides +// (three walls) and on the bottom (floor), open on the outward-facing front +// (so the cable feeds out horizontally and the accelerometer slides in from +// the side), and capped by a rounded crown (the "igloo" top). +// +// PR #35 / PR #67 (@ctrhjk + @sgbaird, 2026-07-01): we ALSO add a matching +// key-seat at each of the three BOTTOM vertices so the team can mount a +// third accelerometer at the bottom of the structure. The bottom housings +// are NOT domed igloos — they are FLAT-capped. +// +// PR #35 comment 4859762053 (@sgbaird, 2026-07-01): "put the lower vertex key +// seats to the side and not touching the ground". So rather than hanging the +// key-seat *below* the vertex (where it became the lowest point of the model +// and sat on the build/drop plate), each bottom key-seat now sits BESIDE the +// vertex — pushed radially outward and lifted so its underside hovers +// `accel_hover` above the bottom-vertex joint underside (the plate contact is +// the joint sphere, not the seat). A short PLA skirt bridges the `accel_side_gap` +// between the vertex sphere and the seat's inner face so PLA runs continuously +// from the vertex to the seat (no overhanging lip / stress riser). The pocket is +// still three walls + a closed cap + one open outward face for the horizontal +// cable exit / slide-in. Toggle via `add_accel_mount_bottom`. +// +// The tweezer breakaway slots that briefly lived here were removed per +// @sgbaird 2026-07-01 ("forget about the tweezer part. Remove that"). +// +// The accelerometer is a PHYSICAL part — its dimensions are absolute +// millimetres (Dytran 3133A4, measured 6 x 6 x 5.94 mm L x W x H, PR #74 +// comment 4792400480) and are NOT multiplied by `scale_factor`. +add_accel_mount = true; // set false to omit the TOP (igloo) accelerometer mounts +add_accel_mount_bottom = true; // set false to omit the BOTTOM (flat) accelerometer key-seats +// Accelerometer-housing SIZING set (PR #35 comment from @achris0520, 2026-07-01): +// "A0" = the original housing dimensions. +// "A1" = A0 with +0.3 mm added to the HEIGHT (Z) dimension only — a slightly +// deeper pocket so the Dytran 3133A4 seats fully without the sensor +// standing proud of the walls (follow-up to the "shorter height of +// housing than the accelerometer" reports, PR #67 comment 4839988559). +// "A2" = A1 with the pocket CLEARANCES tightened so the walls register the +// sensor instead of letting it yaw/tilt in the seat and shear the wax +// bead off (@ctrhjk's loose-housing report, PR #35 comment 4895789291): +// lateral clearance 0.4 -> 0.2 mm/side and top Z gap 1.0 -> 0.2 mm. +// As-printed A1 pocket was 6.8 x 6.8 x 7.44 mm for the 6 x 6 x 5.94 mm +// Dytran; A2 is 6.4 x 6.4 x 6.64 mm. A1's +0.3 mm print-shrink height +// allowance is kept, and the dome / flat cap remains the only plate +// contact. +// L, W, walls, and dome are IDENTICAL across sets. The housing is a +// PHYSICAL-part fixture: its dimensions are absolute mm and are NOT multiplied +// by scale_factor. +accel_size = "A3"; // "A0" (original) | "A1" (deeper pocket) | "A2" (tight fit) | "A3" (explicit 6.2 × 6.2 × 6.8 mm pocket) +accel_h_extra = (accel_size == "A0") ? 0.0 : 0.3; // added Z depth for the A1/A2 sets +accel_l = 6.0; // accelerometer length (X: slide-in / cable-exit axis) +accel_w = 6.0; // accelerometer width (Y) +accel_h = 5.94; // accelerometer height (Z) +accel_clear = (accel_size == "A2") ? 0.2 : 0.4; // per-side LATERAL (XY) clearance + // for the slide-in fit (A2 halves it so the + // walls register the sensor) +accel_clear_top = (accel_size == "A2") ? 0.2 : 1.0; // Z gap between the accelerometer + // top and the crown springline. Keeps the + // accelerometer recessed BELOW the igloo dome + // so the dome (not the sensor) is what touches + // the acrylic drop plate and the housing walls + // stand proud of the sensor (PR #35 / PR #67 + // comment 4839988559); A2 tightens it 1.0 -> + // 0.2 mm so the sensor cannot tilt in the seat. +accel_clear_bot = 0.2; // Z clearance below the accelerometer (adhesive-bead recess) +accel_wall = 2.0; // PLA wall thickness around the pocket +accel_floor = 1.5; // PLA floor thickness between the joint apex and the pocket floor +accel_dome = 3.0; // rounded PLA crown thickness above the pocket (TOP mounts) +accel_flat = 2.0; // flat PLA cap thickness below the pocket (BOTTOM mounts). + // Replaces the dome on the bottom key-seats: the flat + // outward face is what contacts the build/drop plate, + // and it recesses the sensor so PLA (not the sensor) + // touches the plate. +accel_sink = 2.0; // depth the mount walls sink past the joint apex (for bonding) +// BESIDE-mount placement of the BOTTOM key-seats (PR #35 comment 4859762053, +// @sgbaird: "put the lower vertex key seats to the side and not touching the +// ground"). Absolute mm — do NOT scale with the specimen. +accel_side_gap = 1.0; // radial gap between the bottom-vertex joint sphere + // and the seat's inner face (bridged by a PLA skirt) +accel_hover = 2.0; // Z clearance between the seat's underside and the + // bottom-vertex joint underside, so the seat hovers + // above the build/drop plate (never touches the ground) + +// Pocket inner dimensions (the open +X face is the cable exit / slide-in). +// The accelerometer seats flat on the (solid, flat) pocket floor; the Z depth +// = accel_h + accel_clear_top + accel_clear_bot makes the side/back walls +// stand accel_clear_top+accel_clear_bot proud of the seated accelerometer so +// it is recessed BELOW the crown springline (the dome, not the sensor, +// contacts the acrylic plate — PR #67 comment 4839988559 "shorter height of +// housing than the accelerometer"). A dab of wax retains it in the recess. +// A3 (PR #35 comment 4939776434, @achris0520 via the manually-corrected OnShape +// file) overrides the clearance-derived sizing with an EXPLICIT pocket interior +// of 6.2 × 6.2 × 6.8 mm (X × Y × Z). Absolute mm — NOT multiplied by scale_factor. +accel_pocket_x_A3 = 6.2; // A3 explicit pocket length (X) +accel_pocket_y_A3 = 6.2; // A3 explicit pocket width (Y) +accel_pocket_z_A3 = 6.8; // A3 explicit pocket height (Z) +function accel_pocket_x() = (accel_size == "A3") ? accel_pocket_x_A3 : accel_l + 2 * accel_clear; +function accel_pocket_y() = (accel_size == "A3") ? accel_pocket_y_A3 : accel_w + 2 * accel_clear; +// A1 adds accel_h_extra (0.3 mm) to the pocket Z depth; A0 adds 0. +function accel_pocket_z() = (accel_size == "A3") ? accel_pocket_z_A3 + : accel_h + accel_clear_top + accel_clear_bot + accel_h_extra; +// Outward radius of the joint node the mount fuses onto (captive shell in the +// default mode, solid joint sphere in legacy mode). +function joint_outer_r() = use_captive_core ? captive_shell_od / 2 : joint_d / 2; +// How far the rounded crown rises above the top-joint node equator. Used to +// keep the cables STL bounding box matched to the (now taller) struts STL — +// see cables_z_anchor(). +function accel_rise() = accel_floor + accel_pocket_z() + accel_dome; + +// Lowest world-Z feature of the assembly at the SCAD origin (offset_z = 0): +// the bottom-vertex joint-shell underside at -captive_shell_od/2. The BOTTOM +// key-seats now sit BESIDE the vertices and hover above the plate (PR #35 +// comment 4859762053), so they are NOT the lowest feature — the joint sphere +// is (its underside coincides with the seat-skirt hull lowest point). render_print.sh +// echoes this and lifts every part by exactly -model_z_lo() so the assembled MM +// object sits on the bed (z=0) instead of floating — the value depends on +// scale_factor (shell OD scales), so it must +// be computed, not hardcoded (previously pinned to 18.29 for scale 1.5). +function model_z_lo() = -captive_shell_od / 2; + +// Optional rigid translation applied AFTER part selection. Used by +// render_print.sh for the multi-material variant: both the struts STL +// and the cables STL are pre-translated to the H2D bed centre and lifted +// so the lowest joint sphere sits on the bed (z=0). With the same offset +// applied to both halves, BambuStudio CLI's `--orient 0 --arrange 0` keeps +// them co-located and the slicer treats them as a single assembly with +// per-object filament assignment via `--load-filament-ids`. +offset_x = 0; +offset_y = 0; +offset_z = 0; + +// ---- Vertex positions ------------------------------------------------------ +function bottom_pt(i) = [R*cos(90 + 120*i), R*sin(90 + 120*i), 0]; +function top_pt(i) = [R*cos(90 + 120*i + twist), R*sin(90 + 120*i + twist), H]; + +// At each bottom vertex B_i, three TPU cables radiate out (the two bottom- +// triangle cables and the saddle to T_{i-1}). At each top vertex T_i, three +// TPU cables radiate out (the two top-triangle cables and the saddle from +// B_{i+1}). The strut runs along the strut axis from B_i to T_i (or T_i to +// B_i). These helper functions return the unit-direction-from-vertex of +// each connected member, which the captive-core joint uses to (a) hull- +// blend the shell into the strut (`vertex_strut_dir`) and (b) cut a cable +// exit bore through the shell wall along each cable axis +// (`vertex_cable_dirs`). All three "from-vertex" cable directions point +// outward (away from V) so the bore cylinders never accidentally collapse. +function _unit(v) = v / norm(v); +function vertex_strut_dir_b(i) = _unit(top_pt(i) - bottom_pt(i)); +function vertex_strut_dir_t(i) = _unit(bottom_pt(i) - top_pt(i)); +function vertex_cable_dirs_b(i) = [ + _unit(bottom_pt((i+1)%3) - bottom_pt(i)), // bottom cable B_i -> B_{i+1} + _unit(bottom_pt((i+2)%3) - bottom_pt(i)), // bottom cable B_i <- B_{i-1} + _unit(top_pt((i+2)%3) - bottom_pt(i)), // saddle B_i -> T_{i-1} +]; +function vertex_cable_dirs_t(i) = [ + _unit(top_pt((i+1)%3) - top_pt(i)), // top cable T_i -> T_{i+1} + _unit(top_pt((i+2)%3) - top_pt(i)), // top cable T_i <- T_{i-1} + _unit(bottom_pt((i+1)%3) - top_pt(i)), // saddle T_i <- B_{i+1} +]; + +// ---- A capsule (cylinder + hemispherical end-caps) between two points ----- +module member(p1, p2, d) { + v = p2 - p1; + L = norm(v); + // Orient cylinder along v: rotate Z-axis to v's direction. + yaw = atan2(v[1], v[0]); + pitch = atan2(sqrt(v[0]*v[0] + v[1]*v[1]), v[2]); + translate(p1) + rotate([0, 0, yaw]) + rotate([0, pitch, 0]) { + cylinder(h=L, d=d); + sphere(d=d); + translate([0, 0, L]) sphere(d=d); + } +} + +// Cylindrical bore along an arbitrary direction `dir` (does NOT need to be +// unit-length). The bore extends from a small inset on the -dir side +// (just past the vertex centre, so it always cuts cleanly through the +// inner cavity wall) out to +len along +dir. It is OUTWARD-ONLY by +// design: the previous symmetric (centred) bore punched holes through +// BOTH sides of the shell, which is what created the mystery "holes on +// a lot of the vertices" reported in PR #35 comment 4514072758. +module bore_along(dir, d, len) { + yaw = atan2(dir[1], dir[0]); + pitch = atan2(sqrt(dir[0]*dir[0] + dir[1]*dir[1]), dir[2]); + rotate([0, 0, yaw]) + rotate([0, pitch, 0]) + translate([0, 0, -0.5]) + cylinder(h=len + 0.5, d=d); +} + +// ---- Captive-core joint: PLA outer shell at vertex V ---------------------- +// Hollow PLA sphere with a teardrop-blend toward the strut axis (the +// strut emerges from the teardrop bump, not through a punched hole, so +// the shell/strut intersection is smoothly filleted and there is no +// stress-concentration corner). The shell is hollowed by the inner +// cavity (where the TPU captive core lives) and pierced by one +// cylindrical exit bore per outgoing cable. With zero clearances the +// TPU core touches the inner shell wall and the TPU cable fills its +// bore exactly — the only "openings" in the shell are the three cable +// bores per vertex (PR #35 comment 4513722886 / 4514072758). +module joint_shell(V, strut_dir, cable_dirs) { + translate(V) { + difference() { + // Outer shell + teardrop blend along the strut axis (the + // bump where the strut cylinder will emerge — kept per + // PR #35 comment 4514072758: "the teardrop shape was fine. + // Stick with the teardrop style to reduce stresses"). + hull() { + sphere(d=captive_shell_od); + translate(strut_dir * (captive_shell_od/2 + captive_teardrop_z)) + sphere(d=captive_teardrop_d); + } + // Inner cavity (the captive TPU core sits inside this and, + // with captive_core_clear=0, touches the inner shell wall). + sphere(d=captive_shell_id); + // One outward-only exit bore per cable (see bore_along()). + // With captive_bore_clear=0 the bore is exactly cable_d so + // the TPU passes through the shell with no visible ring gap. + for (d = cable_dirs) + bore_along(d, captive_bore_d, captive_shell_od); + } + } +} + +// ---- Captive-core joint: TPU core at vertex V ----------------------------- +// Solid TPU sphere of diameter `captive_core_od`. Lives inside the cavity +// of the PLA shell, with a `captive_core_clear` print-in-place radial gap; +// merges seamlessly with the cable end-cap spheres so cables emerge through +// the shell bores as a continuous TPU thread. Because core_od > bore_d by +// at least 2*captive_bore_trap, the core cannot back out any single bore. +module joint_core(V) { + translate(V) sphere(d=captive_core_od); +} + +// ---- Accelerometer mount: rounded PLA "igloo" with a slide-in pocket ------- +// Built in a local frame where +X is the outward (cable-exit / slide-in) +// direction, +Z is up (toward the acrylic plate). The pocket floor sits at +// local z=0; the solid body extends down to z=-(accel_floor+accel_sink) so +// its walls sink past the joint apex and fuse with the joint node, while the +// flat pocket floor stays a full accel_floor above the apex (so nothing from +// the rounded joint underneath pokes up through the floor — PR #35 comment +// 4805516634). The rounded crown rises to z = pocket_z + accel_dome and the +// pocket is open on +X only. +// `domed` = true -> rounded "igloo" crown (TOP mounts, less plate friction). +// `domed` = false -> a plain FLAT cap slab (BOTTOM key-seats, per @sgbaird +// 2026-07-01: "these won't be domed igloos, just flat"). +// Either way the pocket floor is at local z=0, the pocket is open on +X (cable +// exit / slide-in), and the back + both sides + floor stay solid. +module accel_mount_local(domed = true) { + px = accel_pocket_x(); + py = accel_pocket_y(); + pz = accel_pocket_z(); + bx0 = -accel_wall; // back wall outer face + bx1 = px; // front face (flush with the open pocket mouth) + byh = py / 2 + accel_wall; // body half-width (side walls) + bz0 = -(accel_floor + accel_sink); // body underside (sinks accel_sink past the joint apex) + bz1 = pz; // top of the straight walls (cap springs from here) + cx = (bx0 + bx1) / 2; + rcrown = min(bx1 - bx0, 2 * byh) / 2; + difference() { + // Solid body: straight walled box + a cap on top (rounded crown for the + // igloo, or a flat slab for the bottom key-seats). + union() { + translate([cx, 0, (bz0 + bz1) / 2]) + cube([bx1 - bx0, 2 * byh, bz1 - bz0], center=true); + if (domed) { + // Rounded crown hulled from the body's top rim up to a sphere. + hull() { + translate([cx, 0, bz1 - 0.5]) + cube([bx1 - bx0, 2 * byh, 1], center=true); + translate([cx, 0, bz1 + accel_dome - rcrown]) + sphere(r=rcrown); + } + } else { + // Flat cap slab (no dome) — flush with the body footprint so + // there is no overhanging lip. + translate([cx, 0, bz1 + accel_flat / 2]) + cube([bx1 - bx0, 2 * byh, accel_flat], center=true); + } + } + // Pocket, OPEN on +X (cable exit / slide-in). The cut runs past the + // front face so the mouth is fully open; the back, both sides, the + // floor and the cap stay solid (three walls + floor + top cap). + // The cut bottom is at local z=0, so the floor is a full, flat + // accel_floor-thick solid PLA slab regardless of the joint geometry + // sunk in below it. + translate([0, -py / 2, 0]) + cube([px + byh + 5, py, pz]); + } +} + +// Place an accelerometer mount on top of the joint at vertex V, with its open +// face (and the exiting cable) pointing outward along heading `ang` (degrees, +// measured in the XY plane). The pocket floor sits accel_floor above the +// joint apex (V[2]+joint_outer_r()) so the rounded joint can never poke up +// into the pocket, while the body walls still sink accel_sink past the apex +// so the PLA fuses solidly. Works for any design because the seat height is +// derived from joint_outer_r() (PR #35 comment 4805516634). +// +// A "skirt" hulls the body's underside footprint down onto the rounded joint +// surface so PLA runs continuously from the joint up to the underside of the +// igloo, filling the outer void and removing the thin overhanging lip that +// would otherwise be an unsupported stress riser (PR #35 comment 4813200802). +// `cable_dirs` are the outgoing cable directions at this vertex; the skirt +// re-applies the joint shell's cavity and cable-bore subtractions so the +// captive TPU core and the cable exits stay open. +module accel_mount(V, ang, cable_dirs) { + z0 = V[2] + joint_outer_r() + accel_floor; + cx = (-accel_wall + accel_pocket_x()) / 2; + bz0 = -(accel_floor + accel_sink); // body underside (matches accel_mount_local) + blen = accel_pocket_x() + accel_wall; // body length (bx1 - bx0) + byw = accel_pocket_y() + 2 * accel_wall; // body width (2 * byh) + translate([V[0], V[1], z0]) + rotate([0, 0, ang]) + translate([-cx, 0, 0]) + accel_mount_local(); + // Skirt: convex-hull the body's underside footprint down to the joint + // sphere, then re-cut the joint cavity + cable bores so the captive TPU + // core and cable exits remain (mirrors joint_shell()). + difference() { + hull() { + translate([V[0], V[1], z0]) + rotate([0, 0, ang]) + translate([-cx, 0, 0]) + translate([cx, 0, bz0 + 0.5]) + cube([blen, byw, 1], center=true); + translate(V) sphere(d = 2 * joint_outer_r()); + } + if (use_captive_core) { + translate(V) sphere(d = captive_shell_id); + // Re-cut the cable bores. The skirt thickens the shell wall in the + // fillet region, so the bore must run further than joint_shell's + // (captive_shell_od) to always punch through and keep each cable + // exit — and the captive-core cavity — open. + translate(V) + for (d = cable_dirs) + bore_along(d, captive_bore_d, + captive_shell_od + accel_pocket_x() + + accel_pocket_y() + 2 * accel_wall); + } + } +} + +// Place a FLAT accelerometer key-seat BESIDE the joint at vertex V (the bottom +// vertices), with its open face (and the exiting cable) pointing outward along +// heading `ang`. This is the bottom-vertex counterpart of accel_mount(): the +// same slide-in pocket capped by a flat slab (not a dome), but the seat sits to +// the SIDE of the vertex (pushed radially outward along `ang`) and hovers above +// the plate rather than hanging below the vertex. +// +// PR #35 comment 4859762053 (@sgbaird): "put the lower vertex key seats to the +// side and not touching the ground". The seat's underside is lifted so it sits +// `accel_hover` above the bottom-vertex joint underside (V[2]-joint_outer_r()), +// so the joint sphere — not the seat — is the plate contact. A short PLA skirt +// hulls the seat's inner (vertex-facing) face across the `accel_side_gap` to the +// joint sphere so PLA runs continuously from the vertex to the seat (no gap / +// overhanging lip / stress riser); the skirt re-cuts the joint cavity + cable +// bores so the captive TPU core and the three cable exits stay open. +module accel_mount_bottom(V, ang, cable_dirs) { + cx = (-accel_wall + accel_pocket_x()) / 2; + bz0 = -(accel_floor + accel_sink); // body underside (matches accel_mount_local) + blen = accel_pocket_x() + accel_wall; // body length (bx1 - bx0) + byw = accel_pocket_y() + 2 * accel_wall; // body width (2 * byh) + // Radial push so the seat sits beside the vertex with its inner face + // accel_side_gap clear of the joint sphere (the skirt bridges the gap). + r_off = joint_outer_r() + blen / 2 + accel_side_gap; + // Lift the pocket floor so the body underside (z0 + bz0) hovers accel_hover + // above the joint underside (V[2] - joint_outer_r()). + z0 = V[2] - joint_outer_r() + accel_hover - bz0; + // Flat-capped body, offset radially outward beside the vertex. + translate([V[0], V[1], z0]) + rotate([0, 0, ang]) + translate([r_off - cx, 0, 0]) + accel_mount_local(domed = false); + // Skirt: convex-hull the seat's inner (vertex-facing) face across to the + // joint sphere, then re-cut the joint cavity + cable bores so the captive + // TPU core and the three cable exits stay open (mirrors accel_mount()). + difference() { + hull() { + translate([V[0], V[1], z0]) + rotate([0, 0, ang]) + translate([r_off - blen / 2 + 0.5, 0, (bz0 + accel_pocket_z()) / 2]) + cube([1, byw, accel_pocket_z() - bz0], center=true); + translate(V) sphere(d = 2 * joint_outer_r()); + } + if (use_captive_core) { + translate(V) sphere(d = captive_shell_id); + translate(V) + for (d = cable_dirs) + bore_along(d, captive_bore_d, + captive_shell_od + 2 * (r_off + blen / 2)); + } + } +} + +// ---- TPU z-anchor (cable-STL bounding-box parity) ------------------------- +// When the cables STL is rendered separately from the struts STL and both +// are imported into Bambu Studio, the slicer's "place on bed" routine +// lifts each part individually so its own lowest world-Z point sits on +// the bed. Because the strut STL's lowest point is the bottom-vertex +// shell underside at z=-captive_shell_od/2 while the cables STL's lowest +// point is the bottom-cable cylinder underside at z=-cable_d/2, the two +// parts ended up shifted by (shell_od - cable_d)/2 mm in z and the +// cables visually dropped relative to the joints — exactly the +// "horizontal cables too low at top and bottom" issue reported in PR #35 +// (immediately above PR #35 comment 4511036510). The fix is to give the +// cables STL the same world-Z extents as the struts STL by emitting a +// pair of zero-XY-area axial spikes at the geometric centre that span +// the strut STL's z-range. The spikes add a negligible amount of TPU +// (< 0.01 mm^2 cross-section * span) but pin the cables STL's bounding +// box so Bambu's auto-bed-placement applies the SAME world-Z offset to +// both parts, keeping cables and joint shells aligned to their original +// SCAD coordinates. +module cables_z_anchor() { + // The extreme bottom point of the strut STL is the bottom-vertex joint + // shell's underside at z = -captive_shell_od/2. The BESIDE-mounted bottom + // key-seats hover above the plate (PR #35 comment 4859762053), so they do + // NOT extend below the joint sphere. The extreme top point is the top-vertex + // shell at z = H + captive_shell_od/2, plus the accelerometer-mount crown + // (when enabled), which sits on top of the top-vertex shells and makes the + // struts STL taller. + z_lo = -captive_shell_od / 2; + z_hi = H + captive_shell_od / 2 + (add_accel_mount ? accel_rise() : 0); + eps = 0.005; // 5 micron, well below FDM extrusion width + // Use the prism's centroid in XY so the anchor is geometry-only and + // never collides with cables or scaffold pillars. + translate([0, 0, z_lo]) + cube([eps, eps, z_hi - z_lo], center=false); +} + +// ---- T3-prism assembly ----------------------------------------------------- +module t3_prism_struts() { + union() { + // Joint nodes (bottom + top). With `use_captive_core` (default), + // each node is a hollow PLA shell with a teardrop blend toward the + // strut and one cylindrical exit bore per outgoing TPU cable; the + // captive TPU mass that holds the cables in place lives inside the + // shell cavity and is emitted by `t3_prism_cables()`. Otherwise we + // fall back to a solid joint sphere (legacy behaviour). + for (i = [0:2]) { + if (use_captive_core) { + joint_shell(bottom_pt(i), + vertex_strut_dir_b(i), + vertex_cable_dirs_b(i)); + joint_shell(top_pt(i), + vertex_strut_dir_t(i), + vertex_cable_dirs_t(i)); + } else { + translate(bottom_pt(i)) sphere(d=joint_d); + translate(top_pt(i)) sphere(d=joint_d); + } + } + // Struts: B_i -> T_i (compression members) + for (i = [0:2]) member(bottom_pt(i), top_pt(i), strut_d); + // Accelerometer mounts on top of each top vertex (PLA, so they travel + // with the rigid struts half in the multi-material variant). The open + // face points radially outward (heading = the top vertex's polar + // angle) so the cable feeds away from the structure. + if (add_accel_mount) { + for (i = [0:2]) + accel_mount(top_pt(i), 90 + 120*i + twist, + vertex_cable_dirs_t(i)); + } + // Flat accelerometer key-seats BESIDE each BOTTOM vertex (also PLA, so + // they travel with the struts half), hovering above the plate (PR #35 + // comment 4859762053). The open face points radially outward (heading = + // the bottom vertex's polar angle) so the cable feeds away from the + // structure (@ctrhjk/@sgbaird 2026-07-01). + if (add_accel_mount_bottom) { + for (i = [0:2]) + accel_mount_bottom(bottom_pt(i), 90 + 120*i, + vertex_cable_dirs_b(i)); + } + } +} + +module t3_prism_cables() { + union() { + // Bottom cables: B_i -> B_{i+1} + for (i = [0:2]) member(bottom_pt(i), bottom_pt((i+1)%3), cable_d); + // Top cables: T_i -> T_{i+1} + for (i = [0:2]) member(top_pt(i), top_pt((i+1)%3), cable_d); + // Saddle/vertical cables: B_{i+1} -> T_i (so cable i and strut i + // meet at T_i but emerge from different bottom vertices) + for (i = [0:2]) member(bottom_pt((i+1)%3), top_pt(i), cable_d); + // Captive TPU cores inside each PLA shell cavity — these are what + // mechanically anchor the cables (the cores are too large to back + // out any single shell bore). Omitted in legacy solid-joint mode. + if (use_captive_core) { + for (i = [0:2]) { + joint_core(bottom_pt(i)); + joint_core(top_pt(i)); + } + // Bounding-box anchor so the cables STL inherits the same + // world-Z extents as the struts STL (keeps Bambu Studio's + // per-part auto-bed-placement from de-aligning the parts). + cables_z_anchor(); + } + } +} + +module t3_prism() { + union() { + t3_prism_struts(); + t3_prism_cables(); + } +} + +// ---- PLA scaffold pillars under the TPU cables ---------------------------- +// One vertical pillar up to a touch-point on a cable. The pillar fuses into +// the cable at the top (no air gap) so a slice of PLA cradles the TPU; +// PLA-TPU bond is weak enough to break away cleanly post-print. +// +// IMPORTANT: the bottom of the pillar sits at SCAD z = -joint_d/2 — the same +// height as the underside of the bottom-triangle joint spheres, which is the +// lowest point of the strut/cable model. Bambu Studio (and the BambuStudio +// CLI's `--arrange 1`) lifts the imported assembly so its lowest point sits +// on the build plate; with the pillars rooted at the same z as the joint +// undersides, every pillar reaches the bed instead of floating ~joint_d/2 mm +// above it (PR #35 comment 4464399849). +module pillar_to(target) { + // Root the pillars at the bottom of the bed-lowest part of the model. + // In captive-core mode the joint shells are the lowest feature; in + // legacy solid-joint mode the joint spheres are. Either way the pillar + // base must sit at the same z as the lowest model point so that when + // Bambu Studio lifts the assembly to put its lowest point on the bed, + // every pillar base touches the build plate (PR #35 comment 4464399849). + z_base = (use_captive_core ? -captive_shell_od / 2 : -joint_d / 2); + z_top = target[2] - scaffold_d_top * 0.4; // sink the cone tip slightly into the cable + h = z_top - z_base; + if (h >= scaffold_min_h) { + translate([target[0], target[1], z_base]) + cylinder(h=h, d1=scaffold_d_bot, d2=scaffold_d_top); + } +} + +module t3_prism_scaffold() { + // Touch-points at k/(n+1) for k=1..n along each cable. Bottom-triangle + // cables sit on the bed (z = 0) so their pillars are filtered out by + // the `scaffold_min_h` cutoff inside `pillar_to`. The remaining 6 + // cables (3 top-triangle + 3 saddle) each get `n_scaffolds` PLA props. + union() { + for (i = [0:2]) { + for (k = [1:n_scaffolds]) { + t = k / (n_scaffolds + 1); + pillar_to(bottom_pt(i) + t * (bottom_pt((i+1)%3) - bottom_pt(i))); + pillar_to(top_pt(i) + t * (top_pt((i+1)%3) - top_pt(i))); + pillar_to(bottom_pt((i+1)%3) + t * (top_pt(i) - bottom_pt((i+1)%3))); + } + } + } +} + +// Emit the analytic lowest-Z so render_print.sh can compute the exact bed-lift +// (OFFSET_Z = -MODEL_Z_LO) for whatever scale / housing config is active. +echo("MODEL_Z_LO", model_z_lo()); + +if (part == "struts") translate([offset_x, offset_y, offset_z]) t3_prism_struts(); +else if (part == "cables") translate([offset_x, offset_y, offset_z]) t3_prism_cables(); +else if (part == "scaffold") translate([offset_x, offset_y, offset_z]) t3_prism_scaffold(); +else if (part == "struts_scaffold") translate([offset_x, offset_y, offset_z]) + union() { t3_prism_struts(); t3_prism_scaffold(); } +else if (part == "all_scaffold") translate([offset_x, offset_y, offset_z]) + union() { t3_prism(); t3_prism_scaffold(); } +else translate([offset_x, offset_y, offset_z]) t3_prism(); diff --git a/cad/t3-prism/t3-prism.stl b/cad/t3-prism/t3-prism.stl new file mode 100644 index 00000000..d5526842 Binary files /dev/null and b/cad/t3-prism/t3-prism.stl differ diff --git a/cad/t3-prism/t3-prism1.1 supports.png b/cad/t3-prism/t3-prism1.1 supports.png new file mode 100644 index 00000000..456b409a Binary files /dev/null and b/cad/t3-prism/t3-prism1.1 supports.png differ diff --git a/cad/t3-prism/t3-prism1.1.3mf b/cad/t3-prism/t3-prism1.1.3mf new file mode 100644 index 00000000..c578875e Binary files /dev/null and b/cad/t3-prism/t3-prism1.1.3mf differ diff --git a/edison-trajectories/2026-05-08-t3-prism-bambu-import-25c1c897.json b/edison-trajectories/2026-05-08-t3-prism-bambu-import-25c1c897.json new file mode 100644 index 00000000..99ea04f0 --- /dev/null +++ b/edison-trajectories/2026-05-08-t3-prism-bambu-import-25c1c897.json @@ -0,0 +1,794 @@ +{ + "status": "success", + "query": "We are preparing a Bambu Lab H2D PETG print of a T3-prism (3-strut\ntensegrity, single-piece, pure PETG, no supports) and producing the\nprint-prep artifacts headlessly with the official BambuStudio\nv02.06.00.51 Linux AppImage CLI under xvfb-run + software GL, following\nthe recipe verified in vertical-cloud-lab/powder-doser PR #23.\n\nTwo questions, please address both:\n\n1. **Bambu Studio import error on the sliced `.gcode.3mf`.** When we\n try to drag/import `slices/t3-prism.H2D-PETG.gcode.3mf` (attached;\n produced by `bambu-studio --slice 1 --export-3mf` with\n `--filament-map-mode Manual --filament-map 1`), Bambu Studio shows\n \"The file does not contain any geometry data\"\n \"Loading of a model file failed\"\n The hand-made project `cad/t3-prism/t3-prism.3mf` (also attached,\n uploaded by Marcus from Bambu Studio GUI; currently printing\n without supports on the H2D) opens fine. Diff'ing the two zips:\n the GUI one has thumbnails (`Metadata/plate_*.png`) and NO\n `Metadata/plate_1.gcode`; the CLI one has `Metadata/plate_1.gcode`\n (the actual print job, ~3.97 MB) and NO thumbnails. The 3D model\n parts (`3D/3dmodel.model`, `3D/Objects/object_1.model`) are\n structurally identical (same UUIDs, same `` ref).\n - Confirm or refute our hypothesis: Bambu Studio's drag/import\n code path treats `.gcode.3mf` as a printer-side artifact and\n refuses to re-import it as a model \u2014 and the correct workflow\n is to use *File \u2192 Open Project* (which still works on\n `.gcode.3mf`), or to load a separate project `.3mf` produced\n without `--slice` (we now also generate\n `slices/t3-prism.H2D.3mf` \u2014 attached \u2014 for that purpose).\n - Are there other plausible causes (corrupted `object_1.model`,\n missing `[Content_Types].xml` entry, missing thumbnails being\n load-blocking, etc.)? The CLI ran without GL (`glfwInit return\n error, code 65544` \u2014 Wayland not available) so thumbnails were\n never rendered; could that be the trigger?\n - Cite Bambu Lab community-forum / GitHub-issues threads if you\n find any that describe the same symptom and resolution.\n\n2. **Print quality / risks for the in-progress H2D print.** The\n print is currently at ~13% (layer 26 / 385) on the H2D, no\n supports. The geometry has three angled struts (~70 mm tall,\n 60\u00b0 twist) and 9 thin (2.4 mm) cables \u2014 most of the cables are\n either at the bottom triangle (printed first), the top triangle\n (printed last as a small bridge), or saddles connecting the two\n (steep diagonals). Bambu Studio is showing a \"cantilever\n warning\" upcoming as the saddle/top features start.\n - Given 0.20 mm layer height, 25% gyroid infill, 3 walls,\n auto-orient (the part was placed flat), what is the highest-\n risk feature and at what layer (rough estimate) do we expect\n it? The total height is ~77 mm.\n - Is the top triangle a true bridge or a free cantilever\n (i.e., are the 60\u00b0-rotated saddles long enough to count as\n supports for the top cables)?\n - Recommendations to maximize success on the current print AND\n for the next iteration (orientation, layer height, support\n strategy, cable diameter, joint sphere diameter, etc.) \u2014\n remembering the constraint is single-piece, single-material\n PETG, no manual support removal between materials.\n\nPlease be specific, cite forum threads / docs / papers where you\ncan, and reference the attached files explicitly when relevant\n(filenames are in their attachment metadata).\n", + "user": null, + "created_at": "2026-05-08T18:22:37.440150Z", + "job_name": "job-futurehouse-data-analysis-crow-high", + "share_status": "private", + "permitted_accessors": { + "users": [], + "organizations": [] + }, + "build_owner": "FutureHouse", + "environment_name": null, + "agent_name": null, + "task_id": "25c1c897-6d66-462f-bfdb-7df05ff12d9b", + "project_id": null, + "answer": "Here are the answers to both questions based on the part geometry, slicer mechanics, and Bambu Studio (BS) internal behavior.\n\n### 1. Bambu Studio import error on the sliced `.gcode.3mf`\n\n**Your hypothesis is correct:** Bambu Studio's drag-and-drop / normal \"Open Project\" code paths actively discriminate based on the `.gcode.3mf` extension, treating it as a **printer-bound artifact** rather than an editable project. \n\nThe \"no geometry data\" error is a direct consequence of this branched logic, but the error message itself is highly misleading in your specific CLI context. Here is exactly what is happening:\n\n* **Bambu Studio's dual file formats:** A standard BS project `.3mf` contains CAD geometry (the mesh in `3D/3dmodel.model`). However, when BS exports a sliced plate via the GUI (*Export plate sliced file*), it creates a `.gcode.3mf` that strips out the heavy mesh data entirely to save space for LAN/SD-card transfer, leaving only the raw G-code, settings, and thumbnails.\n* **The CLI hybrid:** When you run `bambu-studio --slice 1 --export-3mf` and name the output `*.gcode.3mf` (or if the CLI auto-appends it), the CLI actually produces a \"hybrid\" file. Unlike the GUI export, it *keeps* the full mesh (your `object_1.model` is intact at ~1 MB with 6,526 vertices) *and* adds the G-code. \n* **The import failure:** When you drag a `.gcode.3mf` into BS, the internal `load_gcode_3mf_from_stream` function takes over. It expects the GUI-style \"stripped\" format and specifically looks for print metadata in `Metadata/slice_info.config` (e.g., a `` block with weight, time, and filament mapping). Because the CLI `slice_info.config` is missing this block, the G-code loader aborts. The loader never even attempts to read the `3D/3dmodel.model` mesh, resulting in the fallback error: \"The file does not contain any geometry data.\"\n\n**Refuting the thumbnail hypothesis:** The lack of a Wayland/GL display (`glfwInit error 65544`) did *not* block thumbnail generation. Your `xvfb-run` + software GL setup worked: the `Metadata/plate_1.png` inside your CLI `.gcode.3mf` is a valid 19.7 KB render of the prism. Thumbnails are not the cause of the import failure.\n\n**Resolution / Workflow:**\nYour current two-file workaround is the correct, intended workflow:\n1. Generate `slices/t3-prism.H2D.3mf` (without `--slice`) to act as your editable project file for GUI work.\n2. Generate `slices/t3-prism.H2D-PETG.gcode.3mf` (with `--slice`) strictly to be pushed headlessly to the printer via MQTT/FTP. Do not attempt to re-import this file into BS.\n\n*(For related community documentation of this behavior, see GitHub Issues [BambuStudio #1479](https://github.com/bambulab/BambuStudio/issues/1479) where a maintainer explicitly states `.gcode.3mf` is a \"print file for individual plate\" and not a project file, and [#2930](https://github.com/bambulab/BambuStudio/issues/2930) documenting CLI-generated 3MF re-import issues).*\n\n***\n\n### 2. Print quality / risks for the in-progress H2D print\n\nThe T3-prism geometry is highly optimized for FDM, but it does contain one severe overhang risk. At 13% (layer 26 of 385), you have successfully cleared the bottom cables (which act as a ~2.3 mm-high bridge over the bed) and are currently building the lower halves of the struts and saddles. \n\n**The highest-risk feature:** \nThe point of failure will not be the cantilevered saddles or the top joint spheres; **it will be the first few layers of the three top cables.** These begin printing at roughly **layer 362** (Z \u2248 72.3 mm in build coordinates).\n\n**Is the top triangle a true bridge or a free cantilever?**\nIt is a **true bridge**.\n* The struts ($B_i \\to T_i$) and saddles ($B_{i+1} \\to T_i$) both arrive at the top vertices ($T_i$) by layer 368.\n* Because the top joint spheres start forming at layer 350 (growing outward as a dome supported perfectly by the strut beneath it), the $T_i$ anchor points are fully solid and established by the time the top cable starts.\n* However, the top cable runs *horizontally* between two top joints ($T_i \\to T_{i+1}$) across a span of **43.30 mm**. \n\n**Why it's high risk:**\nBecause the cable is a 2.4 mm horizontal cylinder, the very first layer of that 43.3 mm bridge is not the full 2.4 mm width\u2014it is a tiny \"sliver\" chord of the cylinder's bottom edge, only about ~0.96 mm wide (roughly two perimeters). Suspending a <1 mm wide strand of PETG across a 43 mm gap with no underlying mass is notoriously difficult. If that first strand sags or snaps, the subsequent layers of the cylinder will have nothing to adhere to, leading to a \"spaghetti\" failure of the top tension ring.\n\n**Recommendations for the next iteration (single-piece, no supports):**\n\n1. **Bridge tuning (Current orientation):** If you keep the flat orientation, you must ensure your bridge settings are dialed in for PETG. High cooling (100% fan) and specific bridge speeds are mandatory for that 43 mm span. \n2. **Increase cable diameter:** Bumping the `cable_d` from 2.4 mm to 3.0 mm or 4.0 mm will dramatically increase the width of that critical first bridge layer, giving the slicer more perimeters to anchor across the gap.\n3. **Change orientation (Best mechanical fix):** Flat orientation forces the top cables to be 43 mm bridges and the struts to be columns. If you orient the prism so that **one of the struts lies perfectly flat on the build plate**, you eliminate the long 43 mm bridges entirely. The cables will instead print at steep diagonals (roughly 30\u00b0\u201360\u00b0), which modern slicers handle easily as self-supporting overhangs. This also aligns the layer lines *along* the cables rather than perpendicular to them, vastly increasing the tensile strength of the PETG tension members.\n4. **Slicer settings discrepancy:** Note that your CLI-generated `.gcode.3mf` used 2 walls and 15% grid infill, whereas your GUI print is using 3 walls and 25% gyroid. Your GUI settings are much stronger, but ensure your headless pipeline is actually applying the overrides you expect.", + "notebook": { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": { + "name": "python", + "display_name": "Python 3 (ipykernel)", + "language": "python" + } + }, + "cells": [ + { + "id": "d823ea98", + "cell_type": "code", + "metadata": {}, + "execution_count": 1, + "source": "import os\nprint(os.environ.get('TRAJECTORY_WORKDIR'))\nprint(os.environ.get('PROJECT_TAG'))\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b\nNone\n" + } + ] + }, + { + "id": "5da2d068", + "cell_type": "code", + "metadata": {}, + "execution_count": 2, + "source": "import os\n# Look for the attached files\nfor root, dirs, files in os.walk('/workspace'):\n # Don't descend into memory or sibling huge dirs\n dirs[:] = [d for d in dirs if d not in ('memory', '.prompts', '__pycache__')]\n for f in files:\n p = os.path.join(root, f)\n if any(k in f.lower() for k in ['3mf', 'gcode', 't3', 'prism']):\n print(p, os.path.getsize(p))\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b/t3-prism.stl 665584\n/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b/t3-prism.scad 4303\n/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b/t3-prism-iso.png 77758\n/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b/Metadata/plate_1.gcode 3928722\n/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b/Metadata/plate_1.gcode.md5 32\n" + } + ] + }, + { + "id": "f2338e4a", + "cell_type": "code", + "metadata": {}, + "execution_count": 3, + "source": "import os\nWD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\nfor root, dirs, files in os.walk(WD):\n rel = os.path.relpath(root, WD)\n print(rel + '/')\n for f in sorted(files):\n p = os.path.join(root, f)\n print(' ', f, os.path.getsize(p))\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "./\n .kernel_llm_logs_1.txt 0\n README.md 9187\n [Content_Types].xml 432\n flatten_bambu_profile.py 2684\n render_print.sh 8910\n t3-prism-iso.png 77758\n t3-prism.scad 4303\n t3-prism.stl 665584\n.config/\n.config/ipython/\n.config/ipython/profile_default/\n heron_matplotlib_theme.py 10305\n ipython_kernel_config.py 671\n.config/ipython/profile_default/log/\n.config/ipython/profile_default/pid/\n.config/ipython/profile_default/__pycache__/\n heron_matplotlib_theme.cpython-313.pyc 13242\n ipython_kernel_config.cpython-313.pyc 538\n.config/ipython/profile_default/startup/\n 00-heron-matplotlib.py 2011\n 01-ssl-strict-workaround.py 1023\n.config/ipython/profile_default/startup/__pycache__/\n 00-heron-matplotlib.cpython-313.pyc 2620\n 01-ssl-strict-workaround.cpython-313.pyc 1459\n.config/ipython/profile_default/db/\n.config/ipython/profile_default/security/\n.config/.cache/\n.config/.cache/fontconfig/\n 3830d5c3ddfd5cd38a049b759396e72e-le64.cache-9 176\n 4c599c202bc5c08e2d34565a40eac3b2-le64.cache-9 104\n 6aa41aa22e18b8fa06a12da28ea9c28b-le64.cache-9 19088\n 7ef2298fde41cc6eeb7af42e48b7d293-le64.cache-9 160\n CACHEDIR.TAG 200\n ae2dab29c3cf27a0a55364fba05b4a2e-le64.cache-9 60224\n d589a48862398ed80a3d6066f4f56f4c-le64.cache-9 16760\n d82eb4fd963d448e2fcb7d7b793b5df3-le64.cache-9 168\n.config/.cache/matplotlib/\n fontlist-v390.json 37295\n.config/matplotlib/\n.config/matplotlib/stylelib/\n heron-dark.mplstyle 618\n heron-light.mplstyle 618\n3D/\n 3dmodel.model 1535\n3D/Objects/\n object_1.model 1024417\n3D/_rels/\n 3dmodel.model.rels 272\nMetadata/\n cut_information.xml 139\n filament_sequence.json 72\n model_settings.config 12482\n pick_1.png 1772\n plate_1.gcode 3928722\n plate_1.gcode.md5 32\n plate_1.json 437\n plate_1.png 19733\n plate_1_small.png 4430\n plate_no_light_1.png 4881\n project_settings.config 38885\n slice_info.config 205\n top_1.png 6167\nMetadata/_rels/\n model_settings.config.rels 255\n.prompts/\n BOOTSTRAP.md 3942\n HEARTBEAT.md 1997\n MEMORY.md 379\n USER.md 617\nmemory/\n 25c1c897-6d66-462f-bfdb-7df05ff12d9b_memory_heron_c0_20260508T182245961460.jsonl 39557\n_rels/\n .rels 673\n" + } + ] + }, + { + "id": "9843a505", + "cell_type": "code", + "metadata": {}, + "execution_count": 4, + "source": "WD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\n# What's in workdir is actually the *contents* of one of the 3mfs (zip extracted). The user said two\n# 3mfs are attached: gcode.3mf (CLI) and the GUI-made one cad/t3-prism/t3-prism.3mf, plus also a slices/t3-prism.H2D.3mf\n# But what's on disk looks like the extracted form of slices/t3-prism.H2D-PETG.gcode.3mf (it has Metadata/plate_1.gcode AND thumbnails)\n# Wait \u2014 it has BOTH thumbnails AND gcode. That's odd. Let me re-read.\n# Actually looking again: it has plate_1.gcode AND plate_1.png, top_1.png, etc. Let me re-read user text...\n# User said the CLI 3mf has gcode but NO thumbnails; the GUI one has thumbnails but no gcode.\n# But the working dir has both. Maybe it's the merged extraction or just one of them.\n# Let me also note: the workdir contains the README.md from the project plus other build scripts.\n# So this looks like the project working tree where 3mf parts are stored as actual files.\n# The user clearly attached files but they appear to have been extracted/laid out flat.\n# Let's just inspect what we have.\nimport os\nfor f in sorted(os.listdir(WD)):\n print(f)\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": ".config\n.kernel_llm_logs_1.txt\n.prompts\n3D\nMetadata\nREADME.md\n[Content_Types].xml\n_rels\nflatten_bambu_profile.py\nmemory\nrender_print.sh\nt3-prism-iso.png\nt3-prism.scad\nt3-prism.stl\n" + } + ] + }, + { + "id": "7b4b59a4", + "cell_type": "code", + "metadata": {}, + "execution_count": 5, + "source": "WD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\n# Read README to understand context\nwith open(f\"{WD}/README.md\") as f:\n print(f.read())\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "# T3-prism (3-strut tensegrity) \u2014 Bambu PETG print\n\nResolves the issue [_\"Get a bambu sliced print for a T3-prism\"_](../../README.md):\nparametric CAD + a single-piece, pure-PETG, Bambu-bound g-code for the\ncanonical 3-bar tensegrity prism shown on\n[Wikipedia: Tensegrity](https://en.wikipedia.org/wiki/Tensegrity).\n\n![T3-prism iso preview](t3-prism-iso.png)\n\n## Geometry\n\nA T3-prism has **3 compression members** (\"struts\") and **9 tension members**\n(\"cables\"): 3 around the bottom triangle, 3 around the top triangle, and 3\nsaddle/vertical cables connecting them. The two end triangles are\nequilateral and inscribed in a circle of radius `R`; the top triangle is\nrotated by `twist = 60\u00b0` relative to the bottom (the angle the issue calls\nout and the relative twist visible in the Wikipedia reference image).\n\nConnectivity (`i \u2208 {0,1,2}`, mod 3):\n\n| Member | Endpoints | Diameter |\n| -------------------- | --------------- | -------- |\n| Strut `i` | `B_i \u2192 T_i` | 6.0 mm |\n| Bottom cable `i` | `B_i \u2192 B_{i+1}` | 2.4 mm |\n| Top cable `i` | `T_i \u2192 T_{i+1}` | 2.4 mm |\n| Saddle/vertical `i` | `B_{i+1} \u2192 T_i` | 2.4 mm |\n\nStrut `i` and saddle `i` meet at top vertex `T_i` but originate from\n*different* bottom vertices \u2014 the defining \"no two compression members\ntouch\" property of a tensegrity (the struts are kept apart by the cables).\n\nDefault parameters (editable at the top of [`t3-prism.scad`](t3-prism.scad)):\n\n| Parameter | Value | Notes |\n| ---------- | ----- | --- |\n| `R` | 25 mm | end-triangle circumradius |\n| `H` | 70 mm | inter-triangle height |\n| `twist` | 60\u00b0 | top-triangle rotation |\n| `strut_d` | 6 mm | compression member diameter |\n| `cable_d` | 2.4 mm | tension member diameter (\u2265 2 \u00d7 0.4 mm nozzle) |\n| `joint_d` | 7 mm | sphere at each vertex for clean joints |\n\nBounding box \u2248 **50 \u00d7 50 \u00d7 77 mm**, volume \u2248 **8.7 cm\u00b3** of solid material.\nComfortably fits the Bambu Lab H2D's 350 \u00d7 320 mm plate.\n\n## Single-piece, pure-PETG\n\nPer the issue, this revision is a **single-material print in PETG** \u2014 both\nstruts and cables are unioned into one solid body, manifold-checked with\n`admesh`. No multi-material assembly, no removable supports between\nmaterials. PETG is an appropriate first pass: tougher than PLA (so the thin\n\"cable\" features are less brittle when handled), prints cleanly on Bambu's\ndefault Engineering Plate / Textured PEI, and matches the project's planned\nmove to TPU/PETG multi-material in later issues.\n\n## Build & slice\n\n```bash\n# One-shot for the H2D: STL + iso PNG + project .3mf + sliced .gcode.3mf\nbash cad/t3-prism/render_print.sh\n```\n\n> The lab's only printer is the **Bambu Lab H2D**, so this pipeline now\n> targets the H2D exclusively. See\n> [`.github/copilot-instructions.md`](../../.github/copilot-instructions.md#hardware--target-printer).\n\nPre-reqs (Ubuntu 24.04):\n\n```bash\nsudo apt-get install -y openscad admesh xvfb \\\n gstreamer1.0-plugins-base libsoup-3.0-0 libwebkit2gtk-4.1-0\n```\n\nThe script auto-fetches the official BambuStudio Linux AppImage\n(`v02.06.00.51`, pinned) into `/tmp/t3-prism/` on first run.\n\nOutputs (committed):\n\n| File | What |\n| ---- | ---- |\n| [`t3-prism.scad`](t3-prism.scad) | parametric source |\n| [`t3-prism.stl`](t3-prism.stl) | watertight binary STL (manifold, single part) |\n| [`t3-prism-iso.png`](t3-prism-iso.png) | iso preview (above) |\n| [`flatten_bambu_profile.py`](flatten_bambu_profile.py) | walks a Bambu `inherits:` chain and emits a single full-config JSON the CLI accepts |\n| [`t3-prism.3mf`](t3-prism.3mf) | **Bambu Studio project file** uploaded by @me-madsen \u2014 the H2D job that was actually started (PETG Basic, no supports). *Not* regenerated by `render_print.sh`. |\n| [`slices/t3-prism.H2D.3mf`](slices/t3-prism.H2D.3mf) | **Bambu Studio project file** generated by the CLI (no `--slice`). Open in Bambu Studio with *File \u2192 Open Project* (or drag-and-drop) to edit / re-slice. |\n| [`slices/t3-prism.H2D-PETG.gcode.3mf`](slices/t3-prism.H2D-PETG.gcode.3mf) | **Sliced print job** for the H2D \u2014 the file you upload to the printer over LAN/cloud. Contains `Metadata/plate_1.gcode`. *Not* re-importable as a Bambu Studio project (see below). |\n\nVerified slice statistics for `t3-prism.H2D-PETG.gcode.3mf` (read from\n`Metadata/plate_1.gcode` inside the archive; BambuStudio CLI returns\n`return_code: 0, error_string: \"Success.\"`):\n\n| Layers | Filament | Print time | Supports |\n| -----: | -------: | ---------: | -------- |\n| 385 @ 0.20 mm | 6.74 g PETG | 1 h 30 m 46 s | off (matches Marcus's project) |\n\n### About the two `.3mf` flavors (and the import error)\n\nThere are **two distinct kinds** of `.3mf` in the Bambu ecosystem and\nBambu Studio treats them very differently:\n\n- **Project `.3mf`** \u2014 `t3-prism.3mf` (Marcus's) and\n `slices/t3-prism.H2D.3mf` (CLI-generated). Microsoft OOXML zips\n containing `3D/3dmodel.model`, `3D/Objects/object_1.model`,\n `Metadata/project_settings.config` and `Metadata/model_settings.config`\n but **no `Metadata/plate_1.gcode`**. These open as editable projects\n in Bambu Studio (drag-and-drop, *File \u2192 Open Project*) and you can\n re-slice / change parameters / *Send to printer* from the GUI.\n\n- **Sliced `.gcode.3mf`** \u2014 `slices/t3-prism.H2D-PETG.gcode.3mf`. Same\n zip layout *plus* `Metadata/plate_1.gcode` (and its `.md5`), exactly\n the layout the printer firmware expects. This is the file the LAN\n MQTT `print/project_file` command references via\n `param: \"Metadata/plate_1.gcode\"` (see\n [`vertical-cloud-lab/powder-doser` PR #23](https://github.com/vertical-cloud-lab/powder-doser/pull/23)\n for the full upload + start-print recipe). Bambu Studio\n intentionally **refuses to re-import** a `.gcode.3mf` with the error\n *\"The file does not contain any geometry data / Loading of a model\n file failed\"* \u2014 it is a printer-side artifact, not a model. (This\n refusal is a known Bambu Studio behavior; discussion threads on the\n Bambu Lab community forum and the BambuStudio GitHub issues confirm\n the project / sliced split.) If you want to edit settings and re-slice\n in the GUI, open `slices/t3-prism.H2D.3mf` instead.\n\n### CLI gotchas (from powder-doser PR #23)\n\nThe script handles four non-obvious gotchas:\n\n1. **Inheritance is not resolved by the CLI.** Bundled\n `resources/profiles/BBL/{machine,process,filament}/*.json` files only\n carry overrides on top of `@base` parents. `flatten_bambu_profile.py`\n walks the `inherits:` chain and shallow-merges parent \u2192 child into a\n single full-config JSON.\n2. **Identity-field patches.** The CLI's compatibility check needs\n `from = \"system\"`, `inherits = \"\"`, and (on the machine config)\n `printer_settings_id = `. The flattener applies these.\n3. **Bed compatibility.** PETG is rejected on the default Cool Plate\n (`return -61`); the script overrides `curr_bed_type = \"Textured PEI\n Plate\"` on the machine profile.\n4. **IDEX manual filament map (H2D).** The H2D is dual-extruder, so\n even a single-filament print needs `--filament-map-mode Manual\n --filament-map 1`, and the manual-map setup is gated by\n `plate_to_slice != 0` so the script passes `--slice 1`.\n\n### Sending to the H2D\n\nCopy the sliced `.gcode.3mf` to the printer over LAN (FTPS on `:990`)\nand start it via MQTT-over-TLS on `:8883`. The minimum payload is\ndocumented in\n[`vertical-cloud-lab/powder-doser` PR #23](https://github.com/vertical-cloud-lab/powder-doser/pull/23):\n\n```bash\n# Upload\nlftp -u \"bblp,\" -e \\\n \"set ftp:ssl-allow yes; set ssl:verify-certificate no; \\\n cd /cache; put t3-prism.H2D-PETG.gcode.3mf; bye\" \\\n ftps://:990\n\n# Start\nmosquitto_pub --insecure -h -p 8883 \\\n -u bblp -P \"\" \\\n -t \"device//request\" \\\n -m '{\"print\":{\"sequence_id\":\"0\",\"command\":\"project_file\",\n \"param\":\"Metadata/plate_1.gcode\",\n \"url\":\"ftp:///cache/t3-prism.H2D-PETG.gcode.3mf\",\n \"project_id\":\"0\",\"profile_id\":\"0\",\"task_id\":\"0\",\"subtask_id\":\"0\",\n \"subtask_name\":\"\",\"md5\":\"\",\"timelapse\":false,\"bed_type\":\"auto\",\n \"bed_levelling\":true,\"flow_cali\":true,\"vibration_cali\":true,\n \"layer_inspect\":true,\"ams_mapping\":\"\",\"use_ams\":false}}'\n```\n\nFor the cloud / GUI workflow, open `slices/t3-prism.H2D.3mf` (the\nproject) in Bambu Studio and use *Send to printer*.\n\n## References & related work\n\n- Issue: [\"Get a bambu sliced print for a T3-prism\"](../../README.md)\n- Programmatic-CAD pattern reused from\n [`vertical-cloud-lab/powder-doser` PR #16](https://github.com/vertical-cloud-lab/powder-doser/pull/16)\n (parametric `.scad` + headless OpenSCAD + slicer CLI).\n- BambuStudio CLI recipe (flattening profiles, `xvfb-run`, software GL,\n inspecting `result.json` and the `Metadata/plate_1.gcode` inside the\n `.gcode.3mf`) reused from\n [`vertical-cloud-lab/powder-doser` PR #23](https://github.com/vertical-cloud-lab/powder-doser/pull/23).\n- Programmatic-Bambu / meta-CAD survey:\n [`vertical-cloud-lab/powder-doser` PR #7](https://github.com/vertical-cloud-lab/powder-doser/pull/7).\n- Reference image: [Wikipedia \u2014 T3-prism tensegrity](https://en.wikipedia.org/wiki/Tensegrity).\n\n" + } + ] + }, + { + "id": "f69b9244", + "cell_type": "code", + "metadata": {}, + "execution_count": 6, + "source": "WD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\n# Read the SCAD to understand exact geometry\nwith open(f\"{WD}/t3-prism.scad\") as f:\n print(f.read())\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "// ============================================================================\n// Tensegrity Optimization \u2014 T3-prism (3-strut tensegrity), single-piece.\n// ============================================================================\n//\n// Geometry\n// --------\n// A T3-prism (https://en.wikipedia.org/wiki/Tensegrity) consists of:\n// * 3 isolated compression members (\"struts\"),\n// * 9 tension members (\"cables\"): 3 on the bottom triangle, 3 on the top\n// triangle, and 3 vertical/saddle cables connecting them.\n//\n// The two triangular end-caps are identical equilateral triangles inscribed\n// in a circle of radius R. The top triangle is rotated by `twist` degrees\n// relative to the bottom (per the issue: 60\u00b0 -- matches the Wikipedia\n// reference image cited on the issue).\n//\n// B_i = (R*cos(90 + 120*i), R*sin(90 + 120*i), 0)\n// T_i = (R*cos(90 + 120*i + twist), R*sin(90 + 120*i + twist), H)\n//\n// Connectivity (i in {0,1,2}, mod 3 implied):\n// strut i : B_i --> T_i\n// bottom cable i : B_i --> B_{i+1}\n// top cable i : T_i --> T_{i+1}\n// vertical/saddle i : B_{i+1} --> T_i\n//\n// Strut i and saddle i meet at T_i but originate at different bottom\n// vertices, which is the defining \"no two compression members touch each\n// other\" property of a tensegrity (the struts themselves are kept apart\n// by the cables).\n//\n// Pure-PETG, single-piece print\n// -----------------------------\n// Per the issue: \"Assume pure PETG for now, not multi-material.\" Both the\n// struts and the (thinner) cables are unioned into one solid that prints in\n// a single PETG extrusion. Cable diameter is intentionally well above any\n// FDM minimum-feature limit so the model survives slicing without dropouts.\n//\n// Render: paste into https://openscad.org/demo/ -> F6 (Render)\n// Headless STL + PNG preview + Bambu slice (CI/local):\n// bash cad/t3-prism/render_print.sh\n// ============================================================================\n\n// ---- Parameters (mm / degrees) --------------------------------------------\nR = 25; // radius of the circumscribing circle of each end triangle\nH = 70; // distance between bottom and top triangle planes\ntwist = 60; // rotation of the top triangle relative to the bottom\nstrut_d = 6; // strut (compression member) diameter\ncable_d = 2.4; // cable (tension member) diameter -- >= 2*nozzle for FDM\njoint_d = 7; // small sphere diameter at each vertex for clean joints\n$fn = 48;\n\n// ---- Vertex positions ------------------------------------------------------\nfunction bottom_pt(i) = [R*cos(90 + 120*i), R*sin(90 + 120*i), 0];\nfunction top_pt(i) = [R*cos(90 + 120*i + twist), R*sin(90 + 120*i + twist), H];\n\n// ---- A capsule (cylinder + hemispherical end-caps) between two points -----\nmodule member(p1, p2, d) {\n v = p2 - p1;\n L = norm(v);\n // Orient cylinder along v: rotate Z-axis to v's direction.\n yaw = atan2(v[1], v[0]);\n pitch = atan2(sqrt(v[0]*v[0] + v[1]*v[1]), v[2]);\n translate(p1)\n rotate([0, 0, yaw])\n rotate([0, pitch, 0]) {\n cylinder(h=L, d=d);\n sphere(d=d);\n translate([0, 0, L]) sphere(d=d);\n }\n}\n\n// ---- T3-prism assembly -----------------------------------------------------\nmodule t3_prism() {\n union() {\n // Joint nodes (bottom + top), keep all members fused into a single body\n for (i = [0:2]) {\n translate(bottom_pt(i)) sphere(d=joint_d);\n translate(top_pt(i)) sphere(d=joint_d);\n }\n // Struts: B_i -> T_i (compression members)\n for (i = [0:2]) member(bottom_pt(i), top_pt(i), strut_d);\n // Bottom cables: B_i -> B_{i+1}\n for (i = [0:2]) member(bottom_pt(i), bottom_pt((i+1)%3), cable_d);\n // Top cables: T_i -> T_{i+1}\n for (i = [0:2]) member(top_pt(i), top_pt((i+1)%3), cable_d);\n // Saddle/vertical cables: B_{i+1} -> T_i (so cable i and strut i\n // meet at T_i but emerge from different bottom vertices)\n for (i = [0:2]) member(bottom_pt((i+1)%3), top_pt(i), cable_d);\n }\n}\n\nt3_prism();\n\n" + } + ] + }, + { + "id": "31391b17", + "cell_type": "code", + "metadata": {}, + "execution_count": 7, + "source": "WD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\n# Get parameters R, H, twist, joint_d, strut_d, cable_d from SCAD\nimport re\nwith open(f\"{WD}/t3-prism.scad\") as f:\n src = f.read()\n# Find param block\nm = re.search(r'// Parameters[\\s\\S]+?(?=// ----)', src)\nprint(m.group() if m else \"no match\")\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "no match\n" + } + ] + }, + { + "id": "51049b25", + "cell_type": "code", + "metadata": {}, + "execution_count": 8, + "source": "WD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\nimport re\nwith open(f\"{WD}/t3-prism.scad\") as f:\n src = f.read()\n# Find variable assignments at top-level\nfor line in src.splitlines():\n s = line.strip()\n if re.match(r'^[A-Za-z_]\\w*\\s*=\\s*[\\d\\.]+\\s*;', s):\n print(s)\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "R = 25; // radius of the circumscribing circle of each end triangle\nH = 70; // distance between bottom and top triangle planes\ntwist = 60; // rotation of the top triangle relative to the bottom\nstrut_d = 6; // strut (compression member) diameter\ncable_d = 2.4; // cable (tension member) diameter -- >= 2*nozzle for FDM\njoint_d = 7; // small sphere diameter at each vertex for clean joints\n" + } + ] + }, + { + "id": "f26ac68c", + "cell_type": "code", + "metadata": {}, + "execution_count": 9, + "source": "import math\n# Compute geometry\nR = 25.0; H = 70.0; twist_deg = 60.0\ntwist = math.radians(twist_deg)\ndef bp(i):\n a = math.radians(90 + 120*i)\n return (R*math.cos(a), R*math.sin(a), 0.0)\ndef tp(i):\n a = math.radians(90 + 120*i + twist_deg)\n return (R*math.cos(a), R*math.sin(a), H)\nB = [bp(i) for i in range(3)]\nT = [tp(i) for i in range(3)]\ndef dist(a,b): return math.sqrt(sum((a[k]-b[k])**2 for k in range(3)))\n\nprint(\"Bottom triangle vertices (z=0):\")\nfor i,v in enumerate(B): print(f\" B{i}: {v}\")\nprint(\"Top triangle vertices (z=70):\")\nfor i,v in enumerate(T): print(f\" T{i}: {v}\")\n\nprint(\"\\nMember lengths:\")\nfor i in range(3):\n print(f\" strut B{i}->T{i}: L={dist(B[i],T[i]):.2f}, dz={H}, dx={dist(B[i][:2]+(0,), T[i][:2]+(0,)):.2f}\")\nprint()\nfor i in range(3):\n j=(i+1)%3\n print(f\" bottom cable B{i}->B{j}: L={dist(B[i],B[j]):.2f}\")\nfor i in range(3):\n j=(i+1)%3\n print(f\" top cable T{i}->T{j}: L={dist(T[i],T[j]):.2f}\")\nfor i in range(3):\n j=(i+1)%3\n print(f\" saddle B{j}->T{i}: L={dist(B[j],T[i]):.2f}, dz={H}, horiz={math.sqrt((B[j][0]-T[i][0])**2+(B[j][1]-T[i][1])**2):.2f}\")\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Bottom triangle vertices (z=0):\n B0: (1.5308084989341915e-15, 25.0, 0.0)\n B1: (-21.650635094610966, -12.500000000000004, 0.0)\n B2: (21.65063509461096, -12.50000000000001, 0.0)\nTop triangle vertices (z=70):\n T0: (-21.65063509461097, 12.499999999999998, 70.0)\n T1: (-4.592425496802574e-15, -25.0, 70.0)\n T2: (21.650635094610966, 12.5, 70.0)\n\nMember lengths:\n strut B0->T0: L=74.33, dz=70.0, dx=25.00\n strut B1->T1: L=74.33, dz=70.0, dx=25.00\n strut B2->T2: L=74.33, dz=70.0, dx=25.00\n\n bottom cable B0->B1: L=43.30\n bottom cable B1->B2: L=43.30\n bottom cable B2->B0: L=43.30\n top cable T0->T1: L=43.30\n top cable T1->T2: L=43.30\n top cable T2->T0: L=43.30\n saddle B1->T0: L=74.33, dz=70.0, horiz=25.00\n saddle B2->T1: L=74.33, dz=70.0, horiz=25.00\n saddle B0->T2: L=74.33, dz=70.0, horiz=25.00\n" + } + ] + }, + { + "id": "8adddcf5", + "cell_type": "code", + "metadata": {}, + "execution_count": 10, + "source": "import math\n# Compute member angles from horizontal & overhang properties\n# Strut: dz=70, horiz=25 \u2192 angle from vertical\ndef angle_from_vertical(dz, horiz):\n return math.degrees(math.atan2(horiz, dz))\ndef angle_from_horizontal(dz, horiz):\n return math.degrees(math.atan2(dz, horiz))\n\nprint(\"Strut angle from vertical:\", angle_from_vertical(70, 25), \"deg (i.e. lean angle)\")\nprint(\"Strut angle from horizontal:\", angle_from_horizontal(70, 25), \"deg (i.e. steepness)\")\nprint()\nprint(\"Saddle angle from vertical (same as strut):\", angle_from_vertical(70, 25), \"deg\")\nprint(\"Saddle angle from horizontal:\", angle_from_horizontal(70, 25), \"deg\")\nprint()\n# Bottom and top cables are horizontal (dz=0) \u2192 at z=0 (B's) and z=70 (T's)\n# Top cable is fully horizontal at z=70, length 43.30 mm bridge\nprint(\"Top cable: horizontal, length 43.30 mm, suspended in air at z=70 \u2014 true unsupported bridge if no saddle/strut endpoints below\")\nprint(\"Wait \u2014 top cables run T_i -> T_{i+1}; both endpoints are top vertices, joined to struts and saddles.\")\nprint()\n\n# At the top: each Ti has incoming strut (Bi->Ti), incoming saddle (B_{i+1}->Ti), outgoing top cables to T_{i-1} and T_{i+1}\n# So top cable endpoints are *anchored* at the top vertices, which themselves are tips of struts and saddles.\n# The cable is a straight horizontal cylinder between two anchored points \u2192 bridge.\n# Bridge length = 43.30 mm at 2.4 mm diameter.\n\n# Cantilever check: are saddles long enough to \"support\" the top cable?\n# The saddle B_{i+1} -> T_i comes UP to T_i. Its print direction is bottom-up. As we print upward, the saddle tip arrives at T_i at z=70 layer.\n# So the top cable gets printed AFTER struts and saddles complete, with both endpoints anchored.\n# That's a bridge, not cantilever.\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Strut angle from vertical: 19.65382405805331 deg (i.e. lean angle)\nStrut angle from horizontal: 70.3461759419467 deg (i.e. steepness)\n\nSaddle angle from vertical (same as strut): 19.65382405805331 deg\nSaddle angle from horizontal: 70.3461759419467 deg\n\nTop cable: horizontal, length 43.30 mm, suspended in air at z=70 \u2014 true unsupported bridge if no saddle/strut endpoints below\nWait \u2014 top cables run T_i -> T_{i+1}; both endpoints are top vertices, joined to struts and saddles.\n\n" + } + ] + }, + { + "id": "bf097459", + "cell_type": "code", + "metadata": {}, + "execution_count": 11, + "source": "import math\n# Now compute key layer numbers @ 0.20 mm layer height\nLH = 0.20\njoint_d = 7.0\nstrut_d = 6.0\ncable_d = 2.4\nH_total = 70 + joint_d/2 + joint_d/2 # spheres add hemisphere on each end\n# Actually: bottom hemisphere of joint sphere starts at z = -joint_d/2. So z range is [-3.5, 73.5] = 77 mm total. Matches the user's \"~77 mm\" total.\nprint(\"Z range:\", -joint_d/2, \"to\", H + joint_d/2, \"\u2192 total height:\", H + joint_d, \"mm\")\n\n# Layer numbering: assume slicer starts at first contact with bed.\n# Bottom-most point is z = -3.5 (joint hemisphere) \u2014 but the part is placed flat on bed, so the bed plane sits at the lowest z.\n# After placing on bed, the bottom triangle plane is at z = joint_d/2 = 3.5 mm (joint hemisphere underneath rests on bed).\n# So:\n# Bottom hemispheres of joints span layers 1..(joint_d/LH) = 1..35? Half-sphere only, so layers 1..(joint_d/2)/LH = 17.5\n# Actually after placing flat: bed is at the bottom of bounding box. Lowest point = bottom of bottom-joint sphere.\n# So z=0 of build = bottom of the bottom joint sphere. Bottom triangle plane is at z = 3.5 mm = layer 17-18.\n# Top triangle plane is at z = 3.5 + 70 = 73.5 mm = layer ~367\n# Top-most (top of top joint sphere) z = 77 mm = layer 385 \u2713 matches user \"385 layers\"\n\nprint(f\"\\nLayer count check: 77 mm / 0.20 mm = {77/0.20:.0f} layers \u2014 user reports 385 \u2713\")\nprint()\nprint(\"Bottom triangle plane (B_i centers) at z=3.5 mm \u2192 layer\", round(3.5/0.20))\nprint(\"Top triangle plane (T_i centers) at z=73.5 mm \u2192 layer\", round(73.5/0.20))\nprint()\n# Bottom-cable centerline lives in z=0 plane (bottom triangle), so its centroid layer = 17\n# Top-cable centerline lives in z=70 plane (top triangle), so its centroid layer = 367\n# But: top cable is a horizontal cylinder of diameter 2.4 mm, so it spans z = 70-1.2 to 70+1.2 = 68.8 to 71.2\n# Bottom of top cable starts at layer round(72.3/0.20) = layer 361 (if we set z=0 at bottom of bounding box, add 3.5 offset)\n# Actually with bed offset = 3.5 mm: top cable bottom z (build coords) = 3.5 + 70 - 1.2 = 72.3 mm \u2192 layer 362\n# Top cable top z (build coords) = 3.5 + 70 + 1.2 = 74.7 mm \u2192 layer 374\nprint(\"Top cable spans build-z from\", 3.5+70-1.2, \"to\", 3.5+70+1.2, \"mm \u2192 layers\", round((3.5+70-1.2)/0.20), \"to\", round((3.5+70+1.2)/0.20))\nprint(\"Top joint sphere top at build-z =\", 3.5 + 70 + 3.5, \"mm \u2192 layer\", round(77/0.20))\n\n# At what layer does the \"saddle peak / cantilever warning\" trigger?\n# Saddles B_{j} -> T_i: their tips arrive at T_i (z=70) but their bodies are 6 mm? No, cables are 2.4 mm.\n# Saddle is 2.4 mm diameter cable from bottom to top. As it prints upward at 70.3\u00b0 from horizontal, each layer slightly offsets the cylinder cross-section horizontally.\n# Per-layer horizontal offset = (horiz / dz) * LH = (25 / 70) * 0.20 = 0.0714 mm/layer\nprint(\"\\nPer-layer horizontal offset of strut/saddle cross-section:\", round(25/70*0.20, 4), \"mm/layer\")\n# Compared to nozzle/wall width (~0.4 mm), each layer's perimeter is ~17% offset from previous.\n# That's well within self-supporting overhang capability for a 60\u00b0 lean (30\u00b0 from vertical is the rule of thumb limit).\n# Wait, struts are 19.65\u00b0 from vertical = 70.35\u00b0 from horizontal. That's a steep, well within FDM tolerance.\n\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Z range: -3.5 to 73.5 \u2192 total height: 77.0 mm\n\nLayer count check: 77 mm / 0.20 mm = 385 layers \u2014 user reports 385 \u2713\n\nBottom triangle plane (B_i centers) at z=3.5 mm \u2192 layer 18\nTop triangle plane (T_i centers) at z=73.5 mm \u2192 layer 368\n\nTop cable spans build-z from 72.3 to 74.7 mm \u2192 layers 361 to 374\nTop joint sphere top at build-z = 77.0 mm \u2192 layer 385\n\nPer-layer horizontal offset of strut/saddle cross-section: 0.0714 mm/layer\n" + } + ] + }, + { + "id": "3a5aaaea", + "cell_type": "code", + "metadata": {}, + "execution_count": 12, + "source": "import math\n# Now critical: the top cable IS a bridge between the apex of two saddles+struts converging at top vertices.\n# But \u2014 wait. Look at when saddles arrive at the top vertex. The saddle B_{i+1}->T_i arrives at T_i at layer ~368.\n# The strut B_i->T_i also arrives at T_i at layer ~368.\n# The top joint sphere is centered at T_i (z=70, layer 368) with diameter 7 mm, spanning layers 350 to 385.\n# So the bottom of the top joint sphere starts at layer 350.\n\n# Top cable centerline at z=70, layers 367-368.\n# Top cable runs between T_i and T_{i+1}, both top vertices.\n# By the time we print layer 367, we have:\n# - struts already there (full struts complete)\n# - saddles already there (their tips reach T_i at layer ~368)\n# - top joint spheres started at layer 350, currently mid-bridge through them\n\n# Wait \u2014 does the top joint sphere lower hemisphere act as an overhang?\n# Lower hemisphere of joint sphere: centered at T_i (z=70) with d=7. Bottom at z=66.5. So layers 333 to 368 (lower half).\n# At layer 333, the sphere starts at a single point (the bottommost). At each layer above it, the cross-section grows.\n# But that point in space is suspended \u2014 what's below the bottom of the joint sphere at z=66.5?\n# Below T_i, the strut and saddle approach. Strut is centered at the line B_i\u2192T_i, so at z=66.5 the strut center is at:\n# Strut: parametric position s = (z - 0)/70 (z normalized from 0 to 70). At z=66.5: s=0.95.\n# Strut center at z=66.5: B_i + 0.95*(T_i - B_i)\n# So the strut cross-section is a 6-mm-diameter circle near (but not at) T_i's xy position.\n\n# Look at things from the opposite direction: are the top cable endpoints \"supported\" at the moment of printing?\n# Yes \u2014 by the time layer 367 prints, the top joint spheres (full circular cross-section near maximum diameter ~7mm) are already in place.\n# The top cable is thus a 43.3 mm bridge between two solid 7mm balls. That IS a bridge, both ends anchored.\n# Bridge length 43.3 mm at 2.4 mm diameter.\n\n# What about the BOTTOM of the top joint sphere itself? \n# The lowest point of a top joint sphere is at z=66.5. What's directly below that point at z=66.4? Nothing of the model? Let's check.\n# T_i has xy = (-21.65, 12.5) for T0. The strut comes from B_0 = (0, 25). At z=66.5, strut center is at:\ndef lerp(a, b, t): return tuple(a[k] + t*(b[k]-a[k]) for k in range(3))\nB = [(0, 25, 0), (-21.65, -12.5, 0), (21.65, -12.5, 0)]\nT = [(-21.65, 12.5, 70), (0, -25, 70), (21.65, 12.5, 70)]\n# strut B0->T0\nfor z in [66.5, 67, 68, 69, 70]:\n t = z / 70.0\n p = lerp(B[0], T[0], t)\n dx = p[0] - T[0][0]; dy = p[1] - T[0][1]\n print(f\"z={z}: strut center xy=({p[0]:.2f},{p[1]:.2f}), dxy from T0=({dx:.2f},{dy:.2f}), dist={math.sqrt(dx**2+dy**2):.2f}\")\n# Saddle B1->T0\nprint()\nfor z in [66.5, 67, 68, 69, 70]:\n t = z / 70.0\n p = lerp(B[1], T[0], t)\n dx = p[0] - T[0][0]; dy = p[1] - T[0][1]\n print(f\"z={z}: saddle center xy=({p[0]:.2f},{p[1]:.2f}), dxy from T0=({dx:.2f},{dy:.2f}), dist={math.sqrt(dx**2+dy**2):.2f}\")\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "z=66.5: strut center xy=(-20.57,13.12), dxy from T0=(1.08,0.62), dist=1.25\nz=67: strut center xy=(-20.72,13.04), dxy from T0=(0.93,0.54), dist=1.07\nz=68: strut center xy=(-21.03,12.86), dxy from T0=(0.62,0.36), dist=0.71\nz=69: strut center xy=(-21.34,12.68), dxy from T0=(0.31,0.18), dist=0.36\nz=70: strut center xy=(-21.65,12.50), dxy from T0=(0.00,0.00), dist=0.00\n\nz=66.5: saddle center xy=(-21.65,11.25), dxy from T0=(0.00,-1.25), dist=1.25\nz=67: saddle center xy=(-21.65,11.43), dxy from T0=(0.00,-1.07), dist=1.07\nz=68: saddle center xy=(-21.65,11.79), dxy from T0=(0.00,-0.71), dist=0.71\nz=69: saddle center xy=(-21.65,12.14), dxy from T0=(0.00,-0.36), dist=0.36\nz=70: saddle center xy=(-21.65,12.50), dxy from T0=(0.00,0.00), dist=0.00\n" + } + ] + }, + { + "id": "fca5384a", + "cell_type": "code", + "metadata": {}, + "execution_count": 13, + "source": "import math\n# At z=66.5, the strut center is 1.25 mm away from T0 in xy. Strut radius is 3 mm.\n# So the strut at z=66.5 occupies a circle of radius 3 centered 1.25 mm from T0.\n# That circle covers the area from 1.25-3 to 1.25+3 mm radius from T0 \u2192 reaches to within 1.75 mm beyond T0 on opposite side, and 4.25 mm in the same direction.\n# At z=66.5, joint sphere cross-section is a tiny circle (radius ~0) centered at T0.\n# As z increases, joint sphere's cross-section radius grows. At z=70, full radius 3.5.\n# At z=67 (3 layers above z=66.5): joint sphere radius = sqrt(3.5\u00b2 - 3\u00b2) = sqrt(12.25-9) = 1.80 mm\n# At z=68: sqrt(3.5\u00b2-2\u00b2) = sqrt(8.25)=2.87 mm\n# At z=69: sqrt(3.5\u00b2-1\u00b2)=3.35 mm\n# At z=70: 3.5 mm\n\n# So the joint sphere's lower hemisphere is indeed a \"growing dome\" sitting on top of the strut + saddle endpoints.\n# At z=66.5 it's a single point. At that point, what's underneath at z=66.3 (one layer below)?\n# At z=66.3, layer 332 in absolute build coords (66.3+3.5=69.8 \u2192 layer 349).\n# Wait my earlier numbering: bed at build z=0 = bottom of bottom joint sphere (z_model = -3.5).\n# So model z=66.5 \u2192 build z = 66.5+3.5 = 70.0 mm \u2192 layer 350.\n\n# At model z=66.3 (build layer 349), the only material near T0 (xy=(-21.65, 12.5)) is:\n# - strut at xy near (-20.57+\u03b5, 13.12+\u03b5), radius 3.\n# - saddle at xy near (-21.65, 11.25-\u03b5), radius 1.2.\n# T0 itself at (-21.65, 12.5). Distance from T0:\n# strut center 1.25 mm away \u2014 strut extends from r=\u22121.75 to r=4.25 along its direction. Does strut cover T0? distance 1.25 < 3.0 \u2713\n# saddle center 1.25 mm away \u2014 distance 1.25 < 1.2? No, 1.25 > 1.2 by 0.05 mm. Just barely NOT covering T0.\n\n# So at z just below 66.5, the strut DOES cover T0 (since strut radius 3 > offset 1.25), so the joint sphere's bottom point IS supported by the strut below.\n# The joint sphere effectively merges/grows out of the top of the strut. No floating bridge issue here.\n\n# Now check the top cable. Top cable T0\u2192T1: between T0=(-21.65, 12.5, 70) and T1=(0, -25, 70).\n# It's horizontal at z=70. Its underside is at z=70-1.2=68.8 (model coords) \u2192 build z 72.3 \u2192 layer 362.\n# At layer 362 (build z = 72.3, model z = 68.8), what's under each point of the top cable's path?\n# At the cable's midpoint: midpoint of T0-T1 = (-10.83, -6.25, 70).\n# Below that midpoint at z=68.8: nothing! It's just an open volume \u2014 the cable midpoint is the middle of a 43.3 mm horizontal span.\n# So the cable is a true bridge with both ends anchored to the top joint spheres.\n# Bridge span: ~43.3 mm. PETG bridges this length are usually doable but challenging.\n\nprint(\"=== Top cable as bridge ===\")\nprint(f\"Bridge span: 43.3 mm\")\nprint(f\"Cable diameter: 2.4 mm\")\nprint(f\"Cross-section: circular ~ 4-5 layer-tall cylinder, ~6 perimeters wide at LH=0.20 mm\")\nprint()\n# At LH=0.20, cable diameter 2.4 \u2192 12 layers tall at the bridge level (2.4/0.20=12)\n# But it's a cylinder, so cross-section at each layer is a chord of the circle.\n# Chord widths:\nprint(\"Top cable cross-section at each layer:\")\nfor dz in range(-12, 13, 2):\n z_off = dz * 0.20 # offset from cable centerline\n if abs(z_off) <= 1.2:\n chord = 2*math.sqrt(1.2**2 - z_off**2)\n print(f\" layer offset {dz:+3d} (z_off={z_off:+.2f} mm): chord width = {chord:.2f} mm\")\n\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "=== Top cable as bridge ===\nBridge span: 43.3 mm\nCable diameter: 2.4 mm\nCross-section: circular ~ 4-5 layer-tall cylinder, ~6 perimeters wide at LH=0.20 mm\n\nTop cable cross-section at each layer:\n layer offset -4 (z_off=-0.80 mm): chord width = 1.79 mm\n layer offset -2 (z_off=-0.40 mm): chord width = 2.26 mm\n layer offset +0 (z_off=+0.00 mm): chord width = 2.40 mm\n layer offset +2 (z_off=+0.40 mm): chord width = 2.26 mm\n layer offset +4 (z_off=+0.80 mm): chord width = 1.79 mm\n" + } + ] + }, + { + "id": "2277a55d", + "cell_type": "code", + "metadata": {}, + "execution_count": 14, + "source": "import math\n# Critical first layer of the top cable: model z = 68.8 = build z = 72.3 \u2192 layer 362.\n# At this layer, the cable exists as a sliver of width sqrt(1.2\u00b2 - 1.1\u00b2) \u2248 0.48 mm at the very edge of the circle.\n# Actually first layer where the cable appears: when its bottom edge reaches a layer boundary.\n# Build z = 72.3, layer (i.e., layer top at 72.4): chord at z_off = -1.1 (top of layer) = 2*sqrt(1.44-1.21)=2*sqrt(0.23)=0.96 mm.\n# This is a *very thin* hollow first sliver hanging in air for 43.3 mm.\n\n# This is the critical risk feature.\n\n# But wait: does the slicer treat this as a bridge? Yes \u2014 the geometry is a horizontal cylinder anchored at both ends.\n# The slicer will print \"bridge perimeters\" along the long axis of the cable.\n# 43.3 mm bridges are within PETG's capability if cooling is good and bridge speed is set, but the catch is the FIRST sliver: it's only ~0.96 mm wide, which is essentially 1-2 extruder paths wide.\n# That's very fragile \u2014 a sagging bridge here will break the cable.\n\n# So the highest-risk feature is the FIRST few layers of the top cable bridges (3 of them, T0-T1, T1-T2, T2-T0), starting at layer ~362.\n# User said \"currently at layer 26 of 385\" \u2014 so they're nowhere near it yet. They're still in the bottom triangle / start of struts.\n\n# Let's compute when the cantilever warning will manifest.\n# The slicer's \"cantilever\" warning typically refers to features whose centerline overhangs unsupported material.\n# In this geometry, the saddle cables themselves are leaning at 19.65\u00b0 from vertical, which is well within self-supporting overhang for FDM (the rule of thumb is up to 45-60\u00b0 from vertical = 30-45\u00b0 from horizontal).\n# 70.35\u00b0 from horizontal is steep, not an overhang concern.\n\n# However, the top cable IS the main bridge.\n# Other risks:\n# - Bottom of joint spheres (lower hemispheres) \u2014 but lower joint spheres at the bottom touch the bed, no issue.\n# - Top joint sphere lower hemisphere \u2014 supported by struts/saddles converging up.\n# - Saddle B_{i+1}->T_i: starts at B_{i+1} (bottom triangle vertex, layer 18), grows upward at 19.65\u00b0 lean to T_i.\n# At each layer, the cylinder cross-section shifts ~0.07 mm. That's fine.\n# - HOWEVER: where the saddle leans relative to a strut \u2014 at the start of the saddle (z=0), it's at B_{i+1}.\n# That means each B vertex has TWO members converging there: a strut going UP toward T_i, and a saddle going UP toward T_{i-1}.\n# Wait, let's check: saddle = B_{i+1} \u2192 T_i. So at B_j (j=i+1), the saddle goes to T_{j-1}.\n# Strut at B_j goes to T_j.\n# Bottom cables at B_j go to B_{j-1} and B_{j+1} (horizontal).\n# So at each bottom vertex: 1 strut going up, 1 saddle going up, 2 bottom cables going horizontal \u2014 all unite at the B joint sphere.\n\n# What's the angle BETWEEN the strut and the saddle at B_j?\n# Strut from B_j goes to T_j. Saddle from B_j goes to T_{j-1}.\n# T_j - B_j and T_{j-1} - B_j. At B_0=(0,25,0): T_0-B_0=(-21.65, -12.5, 70), T_2-B_0=(21.65, -12.5, 70). Angle between them?\nimport numpy as np\nv1 = np.array([-21.65, -12.5, 70])\nv2 = np.array([21.65, -12.5, 70])\ncos_a = v1.dot(v2)/(np.linalg.norm(v1)*np.linalg.norm(v2))\nprint(f\"Angle between strut and saddle at bottom vertex: {math.degrees(math.acos(cos_a)):.1f}\u00b0\")\n\n# So strut and saddle splay apart by ~36.6\u00b0.\n# As they print upward, the strut goes one way (xy direction toward T_j) and the saddle goes the other way (toward T_{j-1}).\n# But at z near 0 they're both at B_j (overlapping joint sphere). They diverge in xy as z grows.\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Angle between strut and saddle at bottom vertex: 33.9\u00b0\n" + } + ] + }, + { + "id": "e009d379", + "cell_type": "code", + "metadata": {}, + "execution_count": 15, + "source": "# Now the second consideration: \n# The user said \"Bambu Studio is showing a 'cantilever warning' upcoming as the saddle/top features start\"\n# \u2014 Bambu Studio's \"cantilever\" feature (added in BS 1.7+) detects long unsupported cantilever beams.\n# It analyzes connectivity layer-by-layer and identifies thin features that are not yet anchored at a parent body.\n# Could it be triggered by:\n# (a) the top cable bridge (most likely \u2014 43.3 mm horizontal cable, only 2.4 mm thick = aspect 18:1)\n# (b) the saddle cables which start as 2.4 mm cylinders leaning ~70\u00b0 from horizontal \u2014 these are essentially long\n# \"fingers\" sticking up from the bottom triangle until they reach the top vertex.\n# Saddle cable: starts at B_{i+1} at layer 18, rises to T_i at layer ~368. That's 350 layers of unattached upward-leaning\n# 2.4 mm cylinder before it joins anything else! It IS a cantilever in the sense that its tip is unsupported relative to its base.\n# But it's a continuous self-supporting 70\u00b0 lean \u2014 should be fine mechanically as it's printed.\n# However, lateral wobble during printing (head accel/decel) can shake a 70 mm tall, 2.4 mm-thin cantilever cable.\n# That's actually a classic FDM problem \u2014 \"ringing\" on tall thin features.\n\n# Same for struts: they're 6 mm thick (more rigid) leaning at the same 70\u00b0 angle.\n# Cables (saddles) are the thinner, more vibration-prone members.\n\n# So the cantilever warning likely fires for the SADDLES around the bottom-half: the moment a saddle gets > some threshold\n# height-to-base ratio while still being unattached, BS flags it.\n\n# Quantitatively: BS cantilever detector looks for slim columns whose tip height-to-base-area ratio exceeds threshold (e.g., 50 mm tall \u00d7 5 mm\u00b2 base area = 10 mm/mm\u00b2 triggers warning).\n# Saddle base at z=0: 2.4 mm cable joined to 7 mm joint sphere at B vertex. Joint sphere provides good base.\n# But the saddle ITSELF, as a thin column rising and leaning, is the cantilever. Once its rising height passes ~30 mm with no attachment to other members, BS flags.\n# Saddle reaches up to T_i but only meets other members at the top.\n\n# Let's also compute the strut/saddle/bottom-cable interface at the BOTTOM TRIANGLE more carefully.\n# Bottom cables connect B_i and B_{i+1} along the bottom edge \u2014 they're at z=0 (model coords), so they print in\n# layers 1-12 (model z 0 to 1.2 = build z 3.5+0 to 3.5+1.2 = 3.5 to 4.7 mm).\n# Actually a horizontal cable at z=0 has its centerline in build coords at z=3.5 mm, layer 17-18.\n# Bottom of bottom cable at build z = 3.5-1.2 = 2.3 mm, layer 11-12.\n# So bottom cables print between layers ~12 and ~24.\n# At \"layer 26\" (current print position), the bottom triangle is essentially complete and the slicer is starting on\n# the lower portion of struts/saddles. Makes sense.\n\n# One subtle concern: bottom-cable underside is a free hanging horizontal cylinder over the bed.\n# Span: 43.3 mm at z=0 (hanging at z=2.3 to 4.7 mm above bed).\n# Wait \u2014 bottom cables span from B_i to B_{i+1} (each 43.3 mm apart) \u2014 same as top cables.\n# But at z=0, the bottom of the cable is 1.2 mm below the bottom triangle plane = 1.2 mm above the bed (since the joint \n# sphere already lifts the model 3.5 mm from bed, BUT the cable's bottom is at z = -1.2 model = build z 2.3 mm above bed).\n# So the bottom cable is suspended in air ~2.3 mm above the bed for 43.3 mm length!\n# It's another bridge. But it's at the START of the print. The first layer of the bottom cable will be a thin sliver\n# ~2.3 mm above the bed... Not directly on the bed. So it's another bridge.\n# If the bottom cables are printed in air (no support to bed), this is also a risk.\n\n# Hmm \u2014 but Marcus said the part is currently printing FINE (at 13%, layer 26). So the bottom cables are presumably\n# already past \u2014 they're at layers 12-24, which the print successfully completed. So PETG can do this 43.3 mm bridge OK\n# under good conditions. Good news for the top cable.\n\n# Caveat: bottom cables have the BUILD PLATE close beneath (2.3 mm), which doesn't provide support but does mean cool\n# air flow is good. Top cables hang at 70 mm with no underlying mass \u2014 print head moves up, ambient warmer there in\n# a heated H2D enclosure, BUT PETG benefits from warm chamber for layer adhesion. Trade-off.\nprint(\"Bottom cables: layers ~12 to ~24 (already printed) \u2014 Marcus' print is past this point with no failure\")\nprint(\"Saddles (long unattached cantilever cables): layers 18 to ~360, leaning 19.65\u00b0 from vertical\")\nprint(\"Top joint spheres lower hemispheres: layers ~350 to ~368 (sit ON TOP of strut+saddle endpoints)\")\nprint(\"Top cables (horizontal bridges, 43.3 mm \u00d7 2.4 mm): layers ~362 to ~374 \u2190 HIGHEST RISK FEATURE\")\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Bottom cables: layers ~12 to ~24 (already printed) \u2014 Marcus' print is past this point with no failure\nSaddles (long unattached cantilever cables): layers 18 to ~360, leaning 19.65\u00b0 from vertical\nTop joint spheres lower hemispheres: layers ~350 to ~368 (sit ON TOP of strut+saddle endpoints)\nTop cables (horizontal bridges, 43.3 mm \u00d7 2.4 mm): layers ~362 to ~374 \u2190 HIGHEST RISK FEATURE\n" + } + ] + }, + { + "id": "c3f697c9", + "cell_type": "code", + "metadata": {}, + "execution_count": 16, + "source": "# Verify the gcode file actually contains the print job, and check the [Content_Types].xml + project structure\nWD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\nimport os\n# Read first chunk of plate_1.gcode to confirm it's real gcode\nwith open(f\"{WD}/Metadata/plate_1.gcode\") as f:\n head = f.read(1500)\nprint(head)\nprint(\"---\")\nprint(\"File size (bytes):\", os.path.getsize(f\"{WD}/Metadata/plate_1.gcode\"))\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "; HEADER_BLOCK_START\n; BambuStudio 02.06.00.51\n; model printing time: 1h 30m 17s; total estimated time: 1h 30m 37s\n; total layer number: 385\n; total filament length [mm] : 2241.58\n; total filament volume [cm^3] : 5391.64\n; total filament weight [g] : 6.74\n; filament_density: 1.25\n; filament_diameter: 1.75\n; max_z_height: 77.00\n; filament: 1\n; HEADER_BLOCK_END\n\n; CONFIG_BLOCK_START\n; accel_to_decel_enable = 0\n; accel_to_decel_factor = 50%\n; activate_air_filtration = 0\n; additional_cooling_fan_speed = 0\n; additional_fan_full_speed_layer = 0\n; apply_scarf_seam_on_circles = 1\n; auxiliary_fan = 1\n; avoid_crossing_wall_includes_support = 0\n; bed_custom_model = \n; bed_custom_texture = \n; bed_exclude_area = \n; bed_temperature_formula = by_highest_temp\n; before_layer_change_gcode = \n; best_object_pos = 0.3,0.5\n; bottom_color_penetration_layers = 3\n; bottom_shell_layers = 3\n; bottom_shell_thickness = 0\n; bottom_surface_density = 100%\n; bottom_surface_pattern = monotonic\n; bridge_angle = 0\n; bridge_flow = 1\n; bridge_no_support = 0\n; bridge_speed = 50,50\n; brim_object_gap = 0.1\n; brim_type = auto_brim\n; brim_width = 5\n; chamber_temperatures = 0\n; change_filament_gcode = M620 S[next_extruder]A\\nM204 S9000\\nG1 Z{max_layer_z + 8.0} F1200\\n\\nM400\\nM106 P1 S0\\nM106 P2 S0\\n{if old_filament_temp > 142 && next_extruder < 255}\\nM104 S[old_filament_temp]\\n{endif}\\n{if toolchange_count == 2}\\n; get travel path for change filament\\n;M620.1 X[travel_point_1_x] Y[travel_point_1_y] F21000 P0\\n;M620.1 X\n---\nFile size (bytes): 3928722\n" + } + ] + }, + { + "id": "8f21f1be", + "cell_type": "code", + "metadata": {}, + "execution_count": 17, + "source": "# Check Content_Types.xml + _rels/.rels + 3D/3dmodel.model\nWD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\nfor f in ['[Content_Types].xml', '_rels/.rels', '3D/3dmodel.model', '3D/_rels/3dmodel.model.rels']:\n print(f\"=== {f} ===\")\n p = f\"{WD}/{f}\"\n with open(p) as fp:\n print(fp.read())\n print()\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "=== [Content_Types].xml ===\n\n\n \n \n \n \n\n\n=== _rels/.rels ===\n\n\n \n \n \n\n\n\n=== 3D/3dmodel.model ===\n\n\n BambuStudio-02.06.00.51\n 1\n \n 2026-05-08\n \n \n \n \n \n 2026-05-08\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n\n\n=== 3D/_rels/3dmodel.model.rels ===\n\n\n \n\n\n" + } + ] + }, + { + "id": "9c084ca8", + "cell_type": "code", + "metadata": {}, + "execution_count": 18, + "source": "# Check object_1.model (should have actual mesh)\nWD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\nimport os\nprint(\"Object_1.model size:\", os.path.getsize(f\"{WD}/3D/Objects/object_1.model\"))\nwith open(f\"{WD}/3D/Objects/object_1.model\") as fp:\n head = fp.read(2000)\nprint(head)\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Object_1.model size: 1024417\n\n\n 1\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \nWD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\nwith open(f\"{WD}/3D/Objects/object_1.model\") as fp:\n fp.seek(0, 2)\n size = fp.tell()\n fp.seek(max(0, size-500))\n print(fp.read())\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "\"/>\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n\n" + } + ] + }, + { + "id": "3cebd524", + "cell_type": "code", + "metadata": {}, + "execution_count": 20, + "source": "# Mesh is intact. The file structure is fine.\n# Now let me think about the question and verify it via the Bambu Studio source / community docs.\n# Key claim to verify: BambuStudio's drag/import codepath rejects .gcode.3mf as a \"no geometry\" file\n# while File\u2192Open Project still works.\n\n# I'll search community and source repo to confirm.\n", + "outputs": [] + }, + { + "id": "c41844dc", + "cell_type": "code", + "metadata": {}, + "execution_count": 21, + "source": "from e14c.web import web_search\nimport asyncio\n\nasync def search_all():\n queries = [\n '\"The file does not contain any geometry data\" Bambu Studio gcode.3mf',\n 'Bambu Studio drag drop gcode.3mf \"Loading of a model file failed\"',\n 'BambuStudio \"gcode.3mf\" import \"no geometry\" site:github.com',\n 'Bambu Studio open .gcode.3mf as project import error',\n ]\n out = []\n for q in queries:\n out.append((q, await web_search(q, num_results=8)))\n return out\n\nresults = await search_all()\nfor q, res in results:\n print(\"=\"*40, \"\\nQ:\", q)\n for r in res:\n print(f\" - {r.get('title','')[:80]} :: {r.get('url','')}\")\n snip = (r.get('snippet') or '').replace('\\n',' ')\n print(f\" {snip[:200]}\")\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "======================================== \nQ: \"The file does not contain any geometry data\" Bambu Studio gcode.3mf\n - No geometry data \u00b7 Issue #4772 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/4772\n ## No geometry data [...] Load the attached 3MF file. [Prop25mm.zip](https://github.com/user-attachments/files/16916343/Prop25mm.zip) [...] TWO messages (why not just one????) \"Loading of model file \n - Bambu Studio (BS) fails to load 3MF file \u00b7 Issue #6827 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/6827\n ## Bambu Studio (BS) fails to load 3MF file [...] Message \"The file does not contain any geometric data\" and \"Loading of a model file failed\" No geometric data loaded. [...] [WiFi_LoRa32_V3_box [...] \n - Error loading file, no geometry data included \u00b7 Issue #7112 \u00b7 bambulab/BambuStud :: https://github.com/bambulab/BambuStudio/issues/7112\n ## Error loading file, no geometry data included [...] We have got this year some 3d printers in school. They equiped all Laptops with the Bambus studio aplication. So my friend an me wanted to try it\n - Loading failed, no geometry data - the Bambu Lab forum :: https://forum.bambulab.com/t/loading-failed-no-geometry-data/93284\n Loading failed, no geometry data - Bambu Lab Software - Bambu Lab Community Forum\n - Regression of .3mf Exported from Autodesk fusion report file was generated by an :: https://github.com/bambulab/BambuStudio/issues/6781\n ## Regression of .3mf Exported from Autodesk fusion report file was generated by an old version of Bambu Studio, loading geometry data only bug [...] 1. Export a designed file from fusion 2. open in b\n - The 3mf is not from Bambu Lab, loading geometry data only. \u00b7 Issue #2491 \u00b7 bambu :: https://github.com/bambulab/BambuStudio/issues/2491\n ## The 3mf is not from Bambu Lab, loading geometry data only. [...] Tried to import file downloaded from 3DPrint.com. [...] I get the message: The 3mf is not from Bambu Lab, loading geometry data only\n - load mtl in obj:failed to parse / The file does not contain any geometry data \u00b7 :: https://github.com/bambulab/BambuStudio/issues/4331\n ## load mtl in obj:failed to parse / The file does not contain any geometry data [...] - Author: [@brainysi](https://github.com/brainysi) [...] - State: open (reopened) - Labels: bug - Assignees: [@Ha\n - 3MF File Contains NO GEOMETRY \u00b7 Issue #4525 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/4525\n ## 3MF File Contains NO GEOMETRY [...] - Author: [@JMarsden92](https://github.com/JMarsden92) - State: closed (completed) - Labels: bug - Assignees: [@Haidiye00](https://github.com/Haidiye00) - Create\n======================================== \nQ: Bambu Studio drag drop gcode.3mf \"Loading of a model file failed\"\n - Loading of a model file failed \u00b7 Issue #9613 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/9613\n ## Loading of a model file failed [...] - Author: [@joseaguardia](https://github.com/joseaguardia) - State: open - Labels: Next_version_fix [...] - Assignees: [@Haidiye00](https://github.com/Haidiye00\n - Bambu Studio (BS) fails to load 3MF file \u00b7 Issue #6827 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/6827\n ## Bambu Studio (BS) fails to load 3MF file [...] Message \"The file does not contain any geometric data\" and \"Loading of a model file failed\" No geometric data loaded. [...] Correctly loaded file as t\n - Loading of model file failed (for step file, stl file loads ok) \u00b7 Issue #4235 \u00b7 :: https://github.com/bambulab/BambuStudio/issues/4235\n ## Loading of model file failed (for step file, stl file loads ok) [...] Just try to import the attached .step file and see the error. Loading the .step file other applications (e.g. freeCAD, online C\n - Load of 3mf file from FreeCAD 0.21 fails with error \"no geometry\" \u00b7 Issue #3012 :: https://github.com/bambulab/BambuStudio/issues/3012\n ## Load of 3mf file from FreeCAD 0.21 fails with error \"no geometry\" [...] 1. Open Bambu Studio 2. Open the attached 3mf file 3. Load fails with error \"The file does not contain any geometry data\" [..\n - Error loading file, no geometry data included \u00b7 Issue #7112 \u00b7 bambulab/BambuStud :: https://github.com/bambulab/BambuStudio/issues/7112\n ## Error loading file, no geometry data included [...] We have got this year some 3d printers in school. They equiped all Laptops with the Bambus studio aplication. So my friend an me wanted to try it\n - Problem parsing on first attempt \u00b7 Issue #1864 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/1864\n This happens on the first attempt at printing a step file. It happens intermittently. When I click to print and it says there was a problem parsing gcode.3mf. Then I tell it to print the plate again (\n - Bambu Studio crashes when loading .gcode file \u00b7 Issue #7706 \u00b7 bambulab/BambuStud :: https://github.com/bambulab/BambuStudio/issues/7706\n ## Bambu Studio crashes when loading .gcode file [...] 1. Load external gcode file by opening it in Bambu Studio 2. Bambu Studio hangs on `loading Gcode` [...] It loads the file [...] > I have a simil\n - BambuStudio crashes upon opening old gcode.3mf file \u00b7 Issue #8518 \u00b7 bambulab/Bam :: https://github.com/bambulab/BambuStudio/issues/8518\n ## BambuStudio crashes upon opening old gcode.3mf file [...] 1. Doubleclick the attached .gcode.3mf file [...] [crash.gcode.3mf.zip](https://github.com/user-attachments/files/23097500/crash.gcode.3mf.\n======================================== \nQ: BambuStudio \"gcode.3mf\" import \"no geometry\" site:github.com\n - Bambu Studio (BS) fails to load 3MF file \u00b7 Issue #6827 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/6827\n ## Bambu Studio (BS) fails to load 3MF file [...] Message \"The file does not contain any geometric data\" and \"Loading of a model file failed\" No geometric data loaded. [...] [WiFi_LoRa32_V3_box [...] \n - 3MF File Contains NO GEOMETRY \u00b7 Issue #4525 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/4525\n ## 3MF File Contains NO GEOMETRY [...] - Author: [@JMarsden92](https://github.com/JMarsden92) - State: closed (completed) - Labels: bug - Assignees: [@Haidiye00](https://github.com/Haidiye00) - Create\n - No geometry data \u00b7 Issue #4772 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/4772\n ## No geometry data [...] Load the attached 3MF file. [Prop25mm.zip](https://github.com/user-attachments/files/16916343/Prop25mm.zip) [...] TWO messages (why not just one????) \"Loading of model file \n - Load of 3mf file from FreeCAD 0.21 fails with error \"no geometry\" \u00b7 Issue #3012 :: https://github.com/bambulab/BambuStudio/issues/3012\n ## Load of 3mf file from FreeCAD 0.21 fails with error \"no geometry\" [...] 1. Open Bambu Studio 2. Open the attached 3mf file 3. Load fails with error \"The file does not contain any geometry data\" [..\n - CLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 \u00b7 bambulab :: https://github.com/bambulab/BambuStudio/issues/2930\n ## CLI generated gcode.3mf not imported correctly (sliced) [...] 1. Generate any 3mf from 3mf or stl file using the CLI 2. import into bambu studio as a project file [...] Needs to be re-sliced, even \n - Saved projects files contain no geometry after importing (a/some) 3mf file \u00b7 Iss :: https://github.com/bambulab/BambuStudio/issues/752\n ## Saved projects files contain no geometry after importing (a/some) 3mf file [...] **Describe the bug** After importing geometry from the attached prusaslicer mmu_segment2.3mf into a project OR openi\n - Error loading file, no geometry data included \u00b7 Issue #7112 \u00b7 bambulab/BambuStud :: https://github.com/bambulab/BambuStudio/issues/7112\n ## Error loading file, no geometry data included [...] We have got this year some 3d printers in school. They equiped all Laptops with the Bambus studio aplication. So my friend an me wanted to try it\n - Changing plates opens empty gcode files \u00b7 Issue #4598 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/4598\n ## Changing plates opens empty gcode files [...] 1. Open or create new project 2. Assign a TEXTURED plate 3. Close Bstudio 4. Try to open ANY gcode that previously was saved as COOL PLATE (for example\n======================================== \nQ: Bambu Studio open .gcode.3mf as project import error\n - CLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 :: https://github.com/bambulab/BambuStudio/issues/2930\n # Issue: bambulab/BambuStudio #2930 - Repository: bambulab/BambuStudio | PC Software for BambuLab and other 3D printers | 4K stars | C++ ## CLI generated gcode.3mf not imported correctly (sliced) -\n - Adding models from 3mf file takes over new projects \u00b7 Issue #1449 \u00b7 bambulab/Bam :: https://github.com/bambulab/BambuStudio/issues/1449\n **Describe the bug** When adding models to the plate, if a 3mf file is chosen, Bambu Studio does not simply import the geometry (or ask what to do with the 3mf file) but instead seems to open the 3mf \n - Cannot open .3mf files \u00b7 Issue #8842 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/8842\n ## Cannot open .3mf files [...] ### How to reproduce [...] 1. Open Bambu Studio 2. Open Project or Import with a .3mf file [...] ### Actual results [...] File does not open. [...] ### Expected results\n - Re-importing a non BL .3mf should not reset the per object settings already in t :: https://github.com/bambulab/BambuStudio/issues/3591\n ## Re-importing a non BL .3mf should not reset the per object settings already in the project [...] Import a multi object file (.3mf in my case) and import it as a single model with multiple parts. Ch\n - Problem on opening .3mf files \u00b7 Issue #8503 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/8503\n ## Problem on opening .3mf files [...] 1. Ctrl+O 2. Select a .3mf file 3. Press Open Button 4. Error Message Appears [...] Hello, I have some problem on opening .3mf files in a latest bambu studio. Th\n - There was a problem parsing gcode. 3mf - the Bambu Lab forum :: https://forum.bambulab.com/t/there-was-a-problem-parsing-gcode-3mf/120857\n There was a problem parsing gcode. 3mf - Bambu Lab X1C - Bambu Lab Community Forum\n - Changing plates opens empty gcode files \u00b7 Issue #4598 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/4598\n ## Changing plates opens empty gcode files [...] 1. Open or create new project 2. Assign a TEXTURED plate 3. Close Bstudio 4. Try to open ANY gcode that previously was saved as COOL PLATE (for example\n - BambuStudio crashes upon opening old gcode.3mf file \u00b7 Issue #8518 \u00b7 bambulab/Bam :: https://github.com/bambulab/BambuStudio/issues/8518\n ## BambuStudio crashes upon opening old gcode.3mf file [...] 1. Doubleclick the attached .gcode.3mf file [...] [crash.gcode.3mf.zip](https://github.com/user-attachments/files/23097500/crash.gcode.3mf.\n" + } + ] + }, + { + "id": "f8197714", + "cell_type": "code", + "metadata": {}, + "execution_count": 22, + "source": "from e14c.web import web_search\n\n# Specifically search for: drag-drop vs File Open behavior on gcode.3mf\nqs = [\n '\"gcode.3mf\" Bambu Studio drag drop \"Open Project\" import',\n 'BambuStudio source \"load_gcode_3mf\" OR \"load_files\" drag drop',\n 'Bambu Studio \"gcode 3mf\" cannot import as model \"Open Project\"',\n 'BambuStudio CLI --slice --export-3mf produces gcode.3mf reopen fails',\n]\nout = []\nfor q in qs:\n out.append((q, await web_search(q, num_results=6)))\nfor q, res in out:\n print(\"=\"*40, \"\\nQ:\", q)\n for r in res:\n print(f\" - {r.get('title','')[:80]} :: {r.get('url','')}\")\n snip = (r.get('snippet') or '').replace('\\n',' ')\n print(f\" {snip[:220]}\")\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "======================================== \nQ: \"gcode.3mf\" Bambu Studio drag drop \"Open Project\" import\n - Enable drag-and-drop opening of .3mf files on the Home tab \u00b7 Issue #9209 \u00b7 bambu :: https://github.com/bambulab/BambuStudio/issues/9209\n ## Enable drag-and-drop opening of .3mf files on the Home tab [...] **Problem** In Bambu Studio, **.3mf files can be opened via drag and drop only on the \u201cPrepare\u201d and \u201cPreview\u201d tabs**. If a .3mf file is dragged onto the\n - Standard 3MF File Color Parsing | Bambu Lab Wiki :: https://wiki.bambulab.com/en/bambu-studio/Standard-3MF-File-Color-Parsing\n ## [\u00b6 [...] importing-standard-3mf-files-into-bambu-studio) Importing Standard 3MF Files into Bambu [...] In the toolbar, select **File** > **Import** > **Import 3MF/STL/STEP/SVG/OBJ/AMF...** and choose the model you wis\n - CLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 \u00b7 bambulab :: https://github.com/bambulab/BambuStudio/issues/2930\n ## CLI generated gcode.3mf not imported correctly (sliced) [...] 1. Generate any 3mf from 3mf or stl file using the CLI 2. import into bambu studio as a project file [...] Needs to be re-sliced, even though the g code is\n - Adding models from 3mf file takes over new projects \u00b7 Issue #1449 \u00b7 bambulab/Bam :: https://github.com/bambulab/BambuStudio/issues/1449\n **Describe the bug** When adding models to the plate, if a 3mf file is chosen, Bambu Studio does not simply import the geometry (or ask what to do with the 3mf file) but instead seems to open the 3mf project, changing th\n - Re-importing a non BL .3mf should not reset the per object settings already in t :: https://github.com/bambulab/BambuStudio/issues/3591\n ## Re-importing a non BL .3mf should not reset the per object settings already in the project [...] Import a multi object file (.3mf in my case) and import it as a single model with multiple parts. Change wall loops for \n - Cannot open .3mf files \u00b7 Issue #8842 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/8842\n ## Cannot open .3mf files [...] 1. Open Bambu Studio 2. Open Project or Import with a .3mf file [...] File does not open. [...] ### Expected results [...] Project is loaded or file is imported. [...] 303_ [...] com/user-\n======================================== \nQ: BambuStudio source \"load_gcode_3mf\" OR \"load_files\" drag drop\n - Enable drag-and-drop opening of .3mf files on the Home tab \u00b7 Issue #9209 \u00b7 bambu :: https://github.com/bambulab/BambuStudio/issues/9209\n ## Enable drag-and-drop opening of .3mf files on the Home tab [...] **Problem** In Bambu Studio, **.3mf files can be opened via drag and drop only on the \u201cPrepare\u201d and \u201cPreview\u201d tabs**. If a .3mf file is dragged onto the\n - 3MF Project File Handling | bambulab/BambuStudio | DeepWiki :: https://deepwiki.com/bambulab/BambuStudio/2.3-3mf-project-file-handling\n * [3MF Project File Handling](https://deepwiki.com/bambulab/BambuStudio/2.3-3mf-project-file-handling) [...] # 3MF Project File Handling [...] Relevant source files [...] * [src/BambuStudio.cpp](https://github.com/bambul\n - Changing plates opens empty gcode files \u00b7 Issue #4598 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/4598\n The opened .gcode.3mf appears empty, you can't print or do anything with it. [...] The ONLY workaround is to OPEN Bstudio again, assign the correct plate you suspect the .gcode you want to open was set to and THEN open t\n - BambuStudio crashes upon opening old gcode.3mf file \u00b7 Issue #8518 \u00b7 bambulab/Bam :: https://github.com/bambulab/BambuStudio/issues/8518\n ## BambuStudio crashes upon opening old gcode.3mf file [...] 1. Doubleclick the attached .gcode.3mf file [...] [crash.gcode.3mf.zip](https://github.com/user-attachments/files/23097500/crash.gcode.3mf.zip) [...] BambuStud\n - Could we have a \"folder\" structure for our \"recently opened\" projects when start :: https://github.com/bambulab/BambuStudio/issues/3772\n **Describe the [...] you'd like** When I open Bambu Studio, and looking at the default \"Models\" screen on the \"home\" screen of the application, I'd like to organize my 3MD files into something like a folder scheme, or gi\n - Bambu Studio crashes when loading .gcode file \u00b7 Issue #7706 \u00b7 bambulab/BambuStud :: https://github.com/bambulab/BambuStudio/issues/7706\n ## Bambu Studio crashes when loading .gcode file [...] 1. Load external gcode file by opening it in Bambu Studio 2. Bambu Studio hangs on `loading Gcode` [...] It loads the file [...] [DishwasherStatus_PLA_18m30s.gcode.z\n======================================== \nQ: Bambu Studio \"gcode 3mf\" cannot import as model \"Open Project\"\n - CLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 \u00b7 bambulab :: https://github.com/bambulab/BambuStudio/issues/2930\n ## CLI generated gcode.3mf not imported correctly (sliced) [...] 1. Generate any 3mf from 3mf or stl file using the CLI 2. import into bambu studio as a project file [...] Needs to be re-sliced, even though the g code is\n - Cannot open .3mf files \u00b7 Issue #8842 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/8842\n ## Cannot open .3mf files [...] ### How to reproduce [...] 1. Open Bambu Studio 2. Open Project or Import with a .3mf file [...] ### Actual results [...] File does not open. [...] | https:// [...] c36d] [...] ### Expecte\n - Adding models from 3mf file takes over new projects \u00b7 Issue #1449 \u00b7 bambulab/Bam :: https://github.com/bambulab/BambuStudio/issues/1449\n ## Adding models from 3mf file takes over new projects [...] **Describe the bug** When adding models to the plate, if a 3mf file is chosen, Bambu Studio does not simply import the geometry (or ask what to do with the 3mf\n - Problem on opening .3mf files \u00b7 Issue #8503 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/8503\n ## Problem on opening .3mf files [...] 1. Ctrl+O 2. Select a .3mf file 3. Press Open Button 4. Error Message Appears [...] Hello, I have some problem on opening .3mf files in a latest bambu studio. The .3mf files work we\n - Re-importing a non BL .3mf should not reset the per object settings already in t :: https://github.com/bambulab/BambuStudio/issues/3591\n ## Re-importing a non BL .3mf should not reset the per object settings already in the project [...] Import a multi object file (.3mf in my case) and import it as a single model with multiple parts. Change wall loops for \n - When importing a 3MF, Bambu Studio only loads the geometry. \u00b7 Issue #7775 \u00b7 bamb :: https://github.com/bambulab/BambuStudio/issues/7775\n ## When importing a 3MF, Bambu Studio only loads the geometry. [...] **Is your feature request related to a problem? Please describe. ** [...] The color information I so studiously made sure where correct in the model ar\n======================================== \nQ: BambuStudio CLI --slice --export-3mf produces gcode.3mf reopen fails\n - BambuStudio crashes upon opening old gcode.3mf file \u00b7 Issue #8518 :: https://github.com/bambulab/BambuStudio/issues/8518\n ## BambuStudio crashes upon opening old gcode.3mf file [...] > Just for the record, since I had to \"recreate\" the affected gcode yesterday, today I opened the crash gcode on purpose so you could see the logs for this act\n - \"Export plate sliced file\" exports a 3MF file with the extension \".gcode.3mf\" \u00b7 :: https://github.com/bambulab/BambuStudio/issues/1479\n ## \"Export plate sliced file\" exports a 3MF file with the extension \".gcode.3mf\" [...] The file selection dialog automatically appends the .gcode extension. The file that is actually exported is a binary 3mf file with th\n - Opening sliced plate 3mf immediately closes \u00b7 Issue #6377 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/6377\n ## Opening sliced plate 3mf immediately closes [...] 1. Export any sliced model using 'Export plate sliced file' 2. Open exported plate file into Bambu Slicer 3. Note the message about 'After completing your operation Fi\n - CLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 :: https://github.com/bambulab/BambuStudio/issues/2930\n # Issue: bambulab/BambuStudio #2930 - Repository: bambulab/BambuStudio | PC Software for BambuLab and other 3D printers | 4K stars | C++ ## CLI generated gcode.3mf not imported correctly (sliced) - Author: [@wizrads](\n - Shader errors when slicing an STL file using CLI \u00b7 Issue #5582 \u00b7 bambulab/Bambu :: https://github.com/bambulab/BambuStudio/issues/5582\n ## Shader errors when slicing an STL file using CLI [...] 1. open the terminal 2. run the following command (with your specific directories) [...] /Applications/BambuStudio.app/Contents/MacOS/BambuStudio --outputdir 'out\n - G-code file exported from any plate cannot be reopened \u2014 Bambu Studio silently c :: https://github.com/bambulab/BambuStudio/issues/9991\n ## G-code file exported from any plate cannot be reopened \u2014 Bambu Studio silently closes mid-load [...] 1. Open any project in Bambu Studio 2.5.0.66. 2. Slice a model on any plate. 3. Export/save the plate as a .gcode fi\n" + } + ] + }, + { + "id": "04ae4f79", + "cell_type": "code", + "metadata": {}, + "execution_count": 23, + "source": "from e14c.web import fetch_parsed_page\n\n# This is highly relevant: Issue #9209 - drag-drop only on prepare/preview tabs\n# And Issue #2930 - CLI generated gcode.3mf not imported correctly\n# Let's read these.\n\nurls = [\n \"https://github.com/bambulab/BambuStudio/issues/9209\",\n \"https://github.com/bambulab/BambuStudio/issues/2930\",\n \"https://github.com/bambulab/BambuStudio/issues/4772\",\n \"https://github.com/bambulab/BambuStudio/issues/8518\",\n]\ncontents = {}\nfor u in urls:\n try:\n contents[u] = await fetch_parsed_page(u)\n except Exception as e:\n contents[u] = f\"ERR: {e}\"\nfor u, c in contents.items():\n print(\"=\"*60, u)\n print(c[:3500])\n print()\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "============================================================ https://github.com/bambulab/BambuStudio/issues/9209\nEnable drag-and-drop opening of .3mf files on the Home tab \u00b7 Issue #9209 \u00b7 bambulab/BambuStudio \n\n[Skip to content](https://github.com/bambulab/BambuStudio/issues/9209#start-of-content)\n\n## Navigation Menu\n\n[Sign in](https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fbambulab%2FBambuStudio%2Fissues%2F9209) \n\nAppearance settings\n\n* + AI CODE CREATION\n - [GitHub Copilot Write better code with AI](https://github.com/features/copilot)\n - [GitHub Spark Build and deploy intelligent apps](https://github.com/features/spark)\n - [GitHub Models Manage and compare prompts](https://github.com/features/models)\n - [MCP RegistryNew Integrate external tools](https://github.com/mcp)\n + DEVELOPER WORKFLOWS\n - [Actions Automate any workflow](https://github.com/features/actions)\n - [Codespaces Instant dev environments](https://github.com/features/codespaces)\n - [Issues Plan and track work](https://github.com/features/issues)\n - [Code Review Manage code changes](https://github.com/features/code-review)\n + APPLICATION SECURITY\n - [GitHub Advanced Security Find and fix vulnerabilities](https://github.com/security/advanced-security)\n - [Code security Secure your code as you build](https://github.com/security/advanced-security/code-security)\n - [Secret protection Stop leaks before they start](https://github.com/security/advanced-security/secret-protection)\n + EXPLORE\n - [Why GitHub](https://github.com/why-github)\n - [Documentation](https://docs.github.com)\n - [Blog](https://github.blog)\n - [Changelog](https://github.blog/changelog)\n - [Marketplace](https://github.com/marketplace)\n[View all features](https://github.com/features)\n\n* + BY COMPANY SIZE\n - [Enterprises](https://github.com/enterprise)\n - [Small and medium teams](https://github.com/team)\n - [Startups](https://github.com/enterprise/startups)\n - [Nonprofits](https://github.com/solutions/industry/nonprofits)\n + BY USE CASE\n - [App Modernization](https://github.com/solutions/use-case/app-modernization)\n - [DevSecOps](https://github.com/solutions/use-case/devsecops)\n - [DevOps](https://github.com/solutions/use-case/devops)\n - [CI/CD](https://github.com/solutions/use-case/ci-cd)\n - [View all use cases](https://github.com/solutions/use-case)\n + BY INDUSTRY\n - [Healthcare](https://github.com/solutions/industry/healthcare)\n - [Financial services](https://github.com/solutions/industry/financial-services)\n - [Manufacturing](https://github.com/solutions/industry/manufacturing)\n - [Government](https://github.com/solutions/industry/government)\n - [View all industries](https://github.com/solutions/industry)\n[View all solutions](https://github.com/solutions)\n\n* + EXPLORE BY TOPIC\n - [AI](https://github.com/resources/articles?topic=ai)\n - [Software Development](https://github.com/resources/articles?topic=software-development)\n - [DevOps](https://github.com/resources/articles?topic=devops)\n - [Security](https://github.com/resources/articles?topic=security)\n - [View all topics](https://github.com/resources/articles)\n + EXPLORE BY TYPE\n - [Customer stories](https://github.com/customer-stories)\n - [Events & webinars](https://github.com/resources/events)\n - [Ebooks & reports](https://github.com/resources/whitepapers)\n \n\n============================================================ https://github.com/bambulab/BambuStudio/issues/2930\nCLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 \u00b7 bambulab/BambuStudio \n\n[Skip to content](https://github.com/bambulab/BambuStudio/issues/2930#start-of-content)\n\n## Navigation Menu\n\n[Sign in](https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fbambulab%2FBambuStudio%2Fissues%2F2930) \n\nAppearance settings\n\n* + AI CODE CREATION\n - [GitHub Copilot Write better code with AI](https://github.com/features/copilot)\n - [GitHub Spark Build and deploy intelligent apps](https://github.com/features/spark)\n - [GitHub Models Manage and compare prompts](https://github.com/features/models)\n - [MCP RegistryNew Integrate external tools](https://github.com/mcp)\n + DEVELOPER WORKFLOWS\n - [Actions Automate any workflow](https://github.com/features/actions)\n - [Codespaces Instant dev environments](https://github.com/features/codespaces)\n - [Issues Plan and track work](https://github.com/features/issues)\n - [Code Review Manage code changes](https://github.com/features/code-review)\n + APPLICATION SECURITY\n - [GitHub Advanced Security Find and fix vulnerabilities](https://github.com/security/advanced-security)\n - [Code security Secure your code as you build](https://github.com/security/advanced-security/code-security)\n - [Secret protection Stop leaks before they start](https://github.com/security/advanced-security/secret-protection)\n + EXPLORE\n - [Why GitHub](https://github.com/why-github)\n - [Documentation](https://docs.github.com)\n - [Blog](https://github.blog)\n - [Changelog](https://github.blog/changelog)\n - [Marketplace](https://github.com/marketplace)\n[View all features](https://github.com/features)\n\n* + BY COMPANY SIZE\n - [Enterprises](https://github.com/enterprise)\n - [Small and medium teams](https://github.com/team)\n - [Startups](https://github.com/enterprise/startups)\n - [Nonprofits](https://github.com/solutions/industry/nonprofits)\n + BY USE CASE\n - [App Modernization](https://github.com/solutions/use-case/app-modernization)\n - [DevSecOps](https://github.com/solutions/use-case/devsecops)\n - [DevOps](https://github.com/solutions/use-case/devops)\n - [CI/CD](https://github.com/solutions/use-case/ci-cd)\n - [View all use cases](https://github.com/solutions/use-case)\n + BY INDUSTRY\n - [Healthcare](https://github.com/solutions/industry/healthcare)\n - [Financial services](https://github.com/solutions/industry/financial-services)\n - [Manufacturing](https://github.com/solutions/industry/manufacturing)\n - [Government](https://github.com/solutions/industry/government)\n - [View all industries](https://github.com/solutions/industry)\n[View all solutions](https://github.com/solutions)\n\n* + EXPLORE BY TOPIC\n - [AI](https://github.com/resources/articles?topic=ai)\n - [Software Development](https://github.com/resources/articles?topic=software-development)\n - [DevOps](https://github.com/resources/articles?topic=devops)\n - [Security](https://github.com/resources/articles?topic=security)\n - [View all topics](https://github.com/resources/articles)\n + EXPLORE BY TYPE\n - [Customer stories](https://github.com/customer-stories)\n - [Events & webinars](https://github.com/resources/events)\n - [Ebooks & reports](https://github.com/resources/whitepapers)\n -\n\n============================================================ https://github.com/bambulab/BambuStudio/issues/4772\nNo geometry data \u00b7 Issue #4772 \u00b7 bambulab/BambuStudio \n\n[Skip to content](https://github.com/bambulab/BambuStudio/issues/4772#start-of-content)\n\n## Navigation Menu\n\n[Sign in](https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fbambulab%2FBambuStudio%2Fissues%2F4772) \n\nAppearance settings\n\n* + AI CODE CREATION\n - [GitHub Copilot Write better code with AI](https://github.com/features/copilot)\n - [GitHub Spark Build and deploy intelligent apps](https://github.com/features/spark)\n - [GitHub Models Manage and compare prompts](https://github.com/features/models)\n - [MCP RegistryNew Integrate external tools](https://github.com/mcp)\n + DEVELOPER WORKFLOWS\n - [Actions Automate any workflow](https://github.com/features/actions)\n - [Codespaces Instant dev environments](https://github.com/features/codespaces)\n - [Issues Plan and track work](https://github.com/features/issues)\n - [Code Review Manage code changes](https://github.com/features/code-review)\n + APPLICATION SECURITY\n - [GitHub Advanced Security Find and fix vulnerabilities](https://github.com/security/advanced-security)\n - [Code security Secure your code as you build](https://github.com/security/advanced-security/code-security)\n - [Secret protection Stop leaks before they start](https://github.com/security/advanced-security/secret-protection)\n + EXPLORE\n - [Why GitHub](https://github.com/why-github)\n - [Documentation](https://docs.github.com)\n - [Blog](https://github.blog)\n - [Changelog](https://github.blog/changelog)\n - [Marketplace](https://github.com/marketplace)\n[View all features](https://github.com/features)\n\n* + BY COMPANY SIZE\n - [Enterprises](https://github.com/enterprise)\n - [Small and medium teams](https://github.com/team)\n - [Startups](https://github.com/enterprise/startups)\n - [Nonprofits](https://github.com/solutions/industry/nonprofits)\n + BY USE CASE\n - [App Modernization](https://github.com/solutions/use-case/app-modernization)\n - [DevSecOps](https://github.com/solutions/use-case/devsecops)\n - [DevOps](https://github.com/solutions/use-case/devops)\n - [CI/CD](https://github.com/solutions/use-case/ci-cd)\n - [View all use cases](https://github.com/solutions/use-case)\n + BY INDUSTRY\n - [Healthcare](https://github.com/solutions/industry/healthcare)\n - [Financial services](https://github.com/solutions/industry/financial-services)\n - [Manufacturing](https://github.com/solutions/industry/manufacturing)\n - [Government](https://github.com/solutions/industry/government)\n - [View all industries](https://github.com/solutions/industry)\n[View all solutions](https://github.com/solutions)\n\n* + EXPLORE BY TOPIC\n - [AI](https://github.com/resources/articles?topic=ai)\n - [Software Development](https://github.com/resources/articles?topic=software-development)\n - [DevOps](https://github.com/resources/articles?topic=devops)\n - [Security](https://github.com/resources/articles?topic=security)\n - [View all topics](https://github.com/resources/articles)\n + EXPLORE BY TYPE\n - [Customer stories](https://github.com/customer-stories)\n - [Events & webinars](https://github.com/resources/events)\n - [Ebooks & reports](https://github.com/resources/whitepapers)\n - [Business insights](https://github.com\n\n============================================================ https://github.com/bambulab/BambuStudio/issues/8518\nBambuStudio crashes upon opening old gcode.3mf file \u00b7 Issue #8518 \u00b7 bambulab/BambuStudio \n\n[Skip to content](https://github.com/bambulab/BambuStudio/issues/8518#start-of-content)\n\n## Navigation Menu\n\n[Sign in](https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fbambulab%2FBambuStudio%2Fissues%2F8518) \n\nAppearance settings\n\n* + AI CODE CREATION\n - [GitHub Copilot Write better code with AI](https://github.com/features/copilot)\n - [GitHub Spark Build and deploy intelligent apps](https://github.com/features/spark)\n - [GitHub Models Manage and compare prompts](https://github.com/features/models)\n - [MCP RegistryNew Integrate external tools](https://github.com/mcp)\n + DEVELOPER WORKFLOWS\n - [Actions Automate any workflow](https://github.com/features/actions)\n - [Codespaces Instant dev environments](https://github.com/features/codespaces)\n - [Issues Plan and track work](https://github.com/features/issues)\n - [Code Review Manage code changes](https://github.com/features/code-review)\n + APPLICATION SECURITY\n - [GitHub Advanced Security Find and fix vulnerabilities](https://github.com/security/advanced-security)\n - [Code security Secure your code as you build](https://github.com/security/advanced-security/code-security)\n - [Secret protection Stop leaks before they start](https://github.com/security/advanced-security/secret-protection)\n + EXPLORE\n - [Why GitHub](https://github.com/why-github)\n - [Documentation](https://docs.github.com)\n - [Blog](https://github.blog)\n - [Changelog](https://github.blog/changelog)\n - [Marketplace](https://github.com/marketplace)\n[View all features](https://github.com/features)\n\n* + BY COMPANY SIZE\n - [Enterprises](https://github.com/enterprise)\n - [Small and medium teams](https://github.com/team)\n - [Startups](https://github.com/enterprise/startups)\n - [Nonprofits](https://github.com/solutions/industry/nonprofits)\n + BY USE CASE\n - [App Modernization](https://github.com/solutions/use-case/app-modernization)\n - [DevSecOps](https://github.com/solutions/use-case/devsecops)\n - [DevOps](https://github.com/solutions/use-case/devops)\n - [CI/CD](https://github.com/solutions/use-case/ci-cd)\n - [View all use cases](https://github.com/solutions/use-case)\n + BY INDUSTRY\n - [Healthcare](https://github.com/solutions/industry/healthcare)\n - [Financial services](https://github.com/solutions/industry/financial-services)\n - [Manufacturing](https://github.com/solutions/industry/manufacturing)\n - [Government](https://github.com/solutions/industry/government)\n - [View all industries](https://github.com/solutions/industry)\n[View all solutions](https://github.com/solutions)\n\n* + EXPLORE BY TOPIC\n - [AI](https://github.com/resources/articles?topic=ai)\n - [Software Development](https://github.com/resources/articles?topic=software-development)\n - [DevOps](https://github.com/resources/articles?topic=devops)\n - [Security](https://github.com/resources/articles?topic=security)\n - [View all topics](https://github.com/resources/articles)\n + EXPLORE BY TYPE\n - [Customer stories](https://github.com/customer-stories)\n - [Events & webinars](https://github.com/resources/events)\n - [Ebooks & reports](https://github.com/resources/whitepapers)\n - [Bu\n\n" + } + ] + }, + { + "id": "60d20778", + "cell_type": "code", + "metadata": {}, + "execution_count": 24, + "source": "# Get the actual issue body \u2014 these GitHub pages have lots of nav cruft. Let me extract just the issue body.\n# Use a regex to extract the relevant text after the page boilerplate.\nimport re\nfor u, c in contents.items():\n # Find description of the issue\n m = re.search(r'(?i)(describe (?:the )?bug.*?)(?=##\\s|\\Z)', c, re.DOTALL)\n if not m:\n m = re.search(r'(?i)(problem\\s.*?)(?=##\\s|\\Z)', c, re.DOTALL)\n print(\"=\"*60, u)\n # Print sections containing issue keywords\n body_match = re.search(r'(Describe the bug|Problem|Steps to reproduce|Reproduce|Bug Summary)[\\s\\S]{0,4000}', c)\n if body_match:\n print(body_match.group()[:3000])\n print()\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "============================================================ https://github.com/bambulab/BambuStudio/issues/9209\nProblem** \nIn Bambu Studio, **.3mf files can be opened via drag and drop only on the \u201cPrepare\u201d and \u201cPreview\u201d tabs**. \nIf a .3mf file is dragged onto the application window while the **\u201cHome\u201d** tab is active, nothing happens. \nThis is confusing and breaks expected drag-and-drop behavior.\n\nIn my workflow, I usually start in the **Home** tab to check recently opened projects. If the needed file is not listed there, I locate the required **.3mf** file in the file manager and drag it into Bambu Studio. Very often, I forget that drag-and-drop works only on specific tabs, so the file simply does not open.\n\n**Describe the solution you'd like** \nEnable **drag-and-drop opening of .3mf files on the \u201cHome\u201d tab**, so that dropping a file anywhere in the main application window consistently opens the project, regardless of the currently active tab.\n\n**Describe alternatives you've considered** \nThe only workaround is to manually switch to the **Prepare** or **Preview** tab before dragging the file, which is easy to forget and interrupts the workflow.\n\n**Additional context** \nFor users, drag-and-drop is generally expected to work consistently across the entire application window. Supporting this behavior on the **Home** tab would make file opening more intuitive and reduce unnecessary tab switching.\n\nrickyriosp\n\n## Activity\n\n[![](https://avatars.githubusercontent.com/u/46218152?s=64&u=8a49ab0c200f0f52c70d8493be13d4ff122893d9&v=4 \"width=16 | height=16\")ibrrus](https://github.com/ibrrus)\n\nadded \n\n[feature New feature or request](https://github.com/bambulab/BambuStudio/issues?q=state%3Aopen%20label%3A%22feature%22)New feature or request\n\n[on Dec 27, 2025](https://github.com/bambulab/BambuStudio/issues/9209#event-21766785644)\n\n[![](https://avatars.githubusercontent.com/u/218757883?s=64&v=4 \"width=16 | height=16\")JinGangBambu](https://github.com/JinGangBambu)\n\nassigned \n\n[JohnSmithCYM](https://github.com/JohnSmithCYM)\n\n[on Dec 29, 2025](https://github.com/bambulab/BambuStudio/issues/9209#event-21774474387)\n\n[Sign up for free](https://github.com/signup?return_to=https://github.com/bambulab/BambuStudio/issues/9209)**to join this conversation on GitHub.** Already have an account?\n\n[Sign in to comment](https://github.com/login?return_to=https://github.com/bambulab/BambuStudio/issues/9209)\n\n## Metadata\n\n## Metadata\n\n### Assignees\n\n* [![@JohnSmithCYM](https://avatars.githubusercontent.com/u/182080301?s=64&v=4 \"width=20 | height=20\") JohnSmithCYM](https://github.com/JohnSmithCYM)\n\n### Labels\n\n[feature New feature or request](https://github.com/bambulab/BambuStudio/issues?q=state%3Aopen%20label%3A%22feature%22)New feature or request\n\n### Type\n\nNo type\n\n### Projects\n\nNo projects\n\n### Milestone\n\nNo milestone\n\n### Relationships\n\nNone yet\n\n### Development\n\nNo branches or pull requests\n\n### Participants\n\n[![@ibrrus](https://avatars.githubusercontent.com/u/46218152?s=64&u=8a49ab0c200f0f52c70d8493be13d4ff122893d9&v=4 \"width=20 | height=20\")](https://github.com/ibrrus)[![@J\n\n============================================================ https://github.com/bambulab/BambuStudio/issues/2930\n\n============================================================ https://github.com/bambulab/BambuStudio/issues/4772\n\n============================================================ https://github.com/bambulab/BambuStudio/issues/8518\n\n" + } + ] + }, + { + "id": "45401350", + "cell_type": "code", + "metadata": {}, + "execution_count": 25, + "source": "# Issue 9209 is feature request, not the same issue. Let me re-fetch with different parsing for #2930 (CLI generated gcode.3mf)\nfrom e14c.web import fetch_parsed_page\ncontent_2930 = await fetch_parsed_page(\"https://github.com/bambulab/BambuStudio/issues/2930\")\n# Search for the issue body - this is post navigation\nimport re\n# find \"wizrads commented\" or similar\nm = re.search(r'(CLI generated gcode\\.3mf[\\s\\S]{0,5000})', content_2930)\nif m:\n print(m.group()[:4000])\nelse:\n # Show middle of doc\n print(content_2930[5000:11000])\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "CLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 \u00b7 bambulab/BambuStudio \u00b7 GitHub \n\n[Skip to content](https://github.com/bambulab/BambuStudio/issues/2930#start-of-content)\n\n## Navigation Menu\n\n[Sign in](https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fbambulab%2FBambuStudio%2Fissues%2F2930) \n\nAppearance settings\n\n* + AI CODE CREATION\n - [GitHub Copilot Write better code with AI](https://github.com/features/copilot)\n - [GitHub Spark Build and deploy intelligent apps](https://github.com/features/spark)\n - [GitHub Models Manage and compare prompts](https://github.com/features/models)\n - [MCP RegistryNew Integrate external tools](https://github.com/mcp)\n + DEVELOPER WORKFLOWS\n - [Actions Automate any workflow](https://github.com/features/actions)\n - [Codespaces Instant dev environments](https://github.com/features/codespaces)\n - [Issues Plan and track work](https://github.com/features/issues)\n - [Code Review Manage code changes](https://github.com/features/code-review)\n + APPLICATION SECURITY\n - [GitHub Advanced Security Find and fix vulnerabilities](https://github.com/security/advanced-security)\n - [Code security Secure your code as you build](https://github.com/security/advanced-security/code-security)\n - [Secret protection Stop leaks before they start](https://github.com/security/advanced-security/secret-protection)\n + EXPLORE\n - [Why GitHub](https://github.com/why-github)\n - [Documentation](https://docs.github.com)\n - [Blog](https://github.blog)\n - [Changelog](https://github.blog/changelog)\n - [Marketplace](https://github.com/marketplace)\n[View all features](https://github.com/features)\n\n* + BY COMPANY SIZE\n - [Enterprises](https://github.com/enterprise)\n - [Small and medium teams](https://github.com/team)\n - [Startups](https://github.com/enterprise/startups)\n - [Nonprofits](https://github.com/solutions/industry/nonprofits)\n + BY USE CASE\n - [App Modernization](https://github.com/solutions/use-case/app-modernization)\n - [DevSecOps](https://github.com/solutions/use-case/devsecops)\n - [DevOps](https://github.com/solutions/use-case/devops)\n - [CI/CD](https://github.com/solutions/use-case/ci-cd)\n - [View all use cases](https://github.com/solutions/use-case)\n + BY INDUSTRY\n - [Healthcare](https://github.com/solutions/industry/healthcare)\n - [Financial services](https://github.com/solutions/industry/financial-services)\n - [Manufacturing](https://github.com/solutions/industry/manufacturing)\n - [Government](https://github.com/solutions/industry/government)\n - [View all industries](https://github.com/solutions/industry)\n[View all solutions](https://github.com/solutions)\n\n* + EXPLORE BY TOPIC\n - [AI](https://github.com/resources/articles?topic=ai)\n - [Software Development](https://github.com/resources/articles?topic=software-development)\n - [DevOps](https://github.com/resources/articles?topic=devops)\n - [Security](https://github.com/resources/articles?topic=security)\n - [View all topics](https://github.com/resources/articles)\n + EXPLORE BY TYPE\n - [Customer stories](https://github.com/customer-stories)\n - [Events & webinars](https://github.com/resources/events)\n - [Ebooks & reports](https://github.com/resources/whitepapers)\n - [Business insights](https://github.com/solutions/executive-insights)\n - [GitHub Skills](https://skills.github.com)\n + SUPPORT & SERVICES\n - [Documentation](https://docs.github.com)\n - [Customer support](https://support.github.com)\n - [Community forum](https://github.com/orgs/community/discussions)\n - [Trust center](https://github.com/trust-center)\n - [Partners](https://github.com/partners)\n[View all resources](https://github.com/resources)\n\n\n" + } + ] + }, + { + "id": "e8861cb3", + "cell_type": "code", + "metadata": {}, + "execution_count": 26, + "source": "# Search in the page text for the issue body\nimport re\nm = re.search(r'(?s)(Generate any 3mf.*?)(?=\\n##\\s|\\Z)', content_2930)\nif m:\n print(m.group()[:3000])\nelse:\n # Print starting where issue body should appear\n idx = content_2930.find('CLI generated gcode')\n while idx != -1:\n print('---', idx, '---')\n print(content_2930[idx:idx+500])\n idx = content_2930.find('CLI generated gcode', idx+1)\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Generate any 3mf from 3mf or stl file using the CLI\n2. import into bambu studio as a project file\n\n### Actual results\n\nNeeds to be re-sliced, even though the g code is in the 3mf file\n\n### Expected results\n\nIt should be imported already sliced, as what happens when importing gcode.3mf files that were generated by selecting \"Export all sliced file\"\n\nI assume what happens is that the import can not read the gcode.3mf created by the CLI. Attached is the gcode.3mf generated by the CLI, and the gcode.3mf generated by saving the project prior to slicing.\n\n### Project file & Debug log uploads\n\n[BothGcode3mfFiles.zip](https://github.com/bambulab/BambuStudio/files/13422472/BothGcode3mfFiles.zip)\n\n### Checklist of files to include\n\n* Log file\n* Project file\n\nReactions are currently unavailable\n\n" + } + ] + }, + { + "id": "a3a1b366", + "cell_type": "code", + "metadata": {}, + "execution_count": 27, + "source": "# Issue #2930: \"CLI generated gcode.3mf not imported correctly (sliced)\" \u2014 this is exactly the right ballpark.\n# The user there reports: import works but bambu doesn't recognize it as already-sliced. \n# That's similar to / different from our issue: we get \"no geometry data\" error.\n# Let me find the actual response/comments + check the resolution.\n\n# Find the discussion / comments section\nidx = content_2930.find('Reactions')\nprint(content_2930[idx:idx+5000])\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Reactions are currently unavailable\n\n## Metadata\n\n## Metadata\n\n### Assignees\n\n* [![@lanewei120](https://avatars.githubusercontent.com/u/106916061?s=64&u=56a4cb29fd16ce430c49fb133771ced2329e2478&v=4 \"width=20 | height=20\") lanewei120](https://github.com/lanewei120)\n\n### Labels\n\n[bug Something isn't working](https://github.com/bambulab/BambuStudio/issues?q=state%3Aopen%20label%3A%22bug%22)Something isn't working\n\n### Type\n\nNo type\n\n### Projects\n\nNo projects\n\n### Milestone\n\nNo milestone\n\n### Relationships\n\nNone yet\n\n### Development\n\nNo branches or pull requests\n\n## Issue actions\n\n## Footer\n\n\u00a9 2026 GitHub, Inc.\n\n### Footer navigation\n\n* [Terms](https://docs.github.com/site-policy/github-terms/github-terms-of-service)\n* [Privacy](https://docs.github.com/site-policy/privacy-policies/github-privacy-statement)\n* [Security](https://github.com/security)\n* [Status](https://www.githubstatus.com/)\n* [Community](https://github.community/)\n* [Docs](https://docs.github.com/)\n* [Contact](https://support.github.com?tags=dotcom-footer)\n* \n*\n\nYou can\u2019t perform that action at this time.\n\n\n" + } + ] + }, + { + "id": "56d58cda", + "cell_type": "code", + "metadata": {}, + "execution_count": 28, + "source": "# Issue 2930 has no comments visible \u2014 open issue, no resolution. Let me check 4772 and 8518 properly.\ncontent_4772 = await fetch_parsed_page(\"https://github.com/bambulab/BambuStudio/issues/4772\")\ncontent_8518 = await fetch_parsed_page(\"https://github.com/bambulab/BambuStudio/issues/8518\")\ncontent_8503 = await fetch_parsed_page(\"https://github.com/bambulab/BambuStudio/issues/8503\")\n\nimport re\nfor label, c in [('4772', content_4772), ('8518', content_8518), ('8503', content_8503)]:\n print(\"=\"*40, label)\n # Find body\n m = re.search(r'(?s)(?:Steps to reproduce|To Reproduce|Reproduction|Bug Description|Bug Summary|Reproduce)[\\s\\S]{0,3000}', c)\n if m:\n print(m.group()[:2500])\n print()\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "======================================== 4772\n\n======================================== 8518\n\n======================================== 8503\n\n" + } + ] + }, + { + "id": "1b7982d3", + "cell_type": "code", + "metadata": {}, + "execution_count": 29, + "source": "# Try a different search approach\nfor label, c in [('4772', content_4772), ('8518', content_8518), ('8503', content_8503)]:\n print(\"=\"*40, label)\n # Look for keywords\n for kw in ['no geometry', 'Loading of', 'gcode.3mf', 'crash', 'export-3mf', 'CLI', 'drag', 'open project']:\n for m in re.finditer(re.escape(kw), c, re.IGNORECASE):\n start = max(0, m.start()-100)\n end = min(len(c), m.end()+300)\n print(f\" -- '{kw}' @ {m.start()}:\")\n print(f\" ...{c[start:end]}...\")\n break # just first occurrence\n print()\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "======================================== 4772\n -- 'no geometry' @ 0:\n ...No geometry data \u00b7 Issue #4772 \u00b7 bambulab/BambuStudio \n\n[Skip to content](https://github.com/bambulab/BambuStudio/issues/4772#start-of-content)\n\n## Navigation Menu\n\n[Sign in](https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fbambulab%2FBambuStudio%2Fissues%2F4772) \n\nAppearance settings\n\n* + AI CODE...\n -- 'Loading of' @ 8714:\n ...ttachments/files/16916343/Prop25mm.zip)\n\n### Actual results\n\nTWO messages (why not just one????) \n\"Loading of model file failed\" AND \n\"The file does not contain any geometry data\" <- this message is sufficient! \n[Prop25mm.zip](https://github.com/user-attachments/files/16916053/Prop25mm.zip)\n\nThe file load fine into 3D Viewer \n[Prop25mm.zip](https://github.com/user-attachments/files/16915811/Prop25mm.zip...\n -- 'CLI' @ 31091:\n ...el to cube.3mf <- note: lower case letters\n3\\. Loaded the cube.3mf file into Bambu Studio by double-clicking the 3mf file (success)\n4\\. Selected an AMF filament different from the default\n5\\. saved the project back into cube.3mf by closing Bambu Studio while affirming to save\n6\\. Made changes to cube.sldprt in SolidWorks\n7\\. saved the model to cube.3mf <- note: I was asked if I wanted to overwrite (Y...\n -- 'drag' @ 9336:\n ...les/16916392/Prop25mm.zip)\n\n### Checklist of files to include\n\n* Log file\nProject file\nTo pick up a draggable item, press the space bar.\nWhile dragging, use the arrow keys to move the item.\nPress space again to drop the item in its new position, or press escape to cancel.\n\n## Activity\n\n[![](https://avatars.githubusercontent.com/u/8687239?s=64&u=fbd5af3e126d3a9d55cb2c446cc79d4ae177d325&v=4 \"width=16 | ...\n\n======================================== 8518\n -- 'gcode.3mf' @ 37:\n ...BambuStudio crashes upon opening old gcode.3mf file \u00b7 Issue #8518 \u00b7 bambulab/BambuStudio \n\n[Skip to content](https://github.com/bambulab/BambuStudio/issues/8518#start-of-content)\n\n## Navigation Menu\n\n[Sign in](https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fbambulab%2FBambuStudio%2Fissues%2F8518) \n\nAppearance settings\n\n* + AI CODE...\n -- 'crash' @ 12:\n ...BambuStudio crashes upon opening old gcode.3mf file \u00b7 Issue #8518 \u00b7 bambulab/BambuStudio \n\n[Skip to content](https://github.com/bambulab/BambuStudio/issues/8518#start-of-content)\n\n## Navigation Menu\n\n[Sign in](https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fbambulab%2FBambuStudio%2Fissues%2F8518) \n\nApp...\n -- 'CLI' @ 8678:\n ...ation\n\nM1Ultra\n\n### Printer\n\nN/A\n\n### Printer Firmware Version\n\nN/A\n\n### How to reproduce\n\n1. Doubleclick the attached .gcode.3mf file\n\n[crash.gcode.3mf.zip](https://github.com/user-attachments/files/23097500/crash.gcode.3mf.zip)\n\n### Actual results\n\nBambuStudio crashes\n\n[![Image](https://private-user-images.githubusercontent.com/10992914/504717312-95823721-fe9c-4f5d-801c-9750ed67efeb.png?jwt=eyJ0eXA...\n -- 'drag' @ 10727:\n ...e & Debug log uploads\n\nN/A\n\n### Checklist of files to include\n\n* Log file\nProject file\nTo pick up a draggable item, press the space bar.\nWhile dragging, use the arrow keys to move the item.\nPress space again to drop the item in its new position, or press escape to cancel.\n\nsamdotci\n\n## Activity\n\n[![](https://avatars.githubusercontent.com/u/10992914?s=64&u=1fd1cac21dda6d7a747ae695343804756f224545&v=4 \"...\n\n======================================== 8503\n -- 'drag' @ 10734:\n ...ip](https://github.com/user-attachments/files/23052761/choi_1022w10h.zip) Project file\nTo pick up a draggable item, press the space bar.\nWhile dragging, use the arrow keys to move the item.\nPress space again to drop the item in its new position, or press escape to cancel.\n\n## Activity\n\n[![](https://avatars.githubusercontent.com/u/172952997?s=64&v=4 \"width=16 | height=16\")cloud9knou](https://github.com...\n\n" + } + ] + }, + { + "id": "b18017ff", + "cell_type": "code", + "metadata": {}, + "execution_count": 30, + "source": "# Issue #4772 is the \"No geometry data\" issue but it's about a Solidworks-exported .3mf, not gcode.3mf.\n# The error message matches but the cause is different (Solidworks producing a 3MF that BS can't parse).\n\n# Issue #8518 is about gcode.3mf crash, not \"no geometry\" error.\n\n# Let me search more directly for the specific symptom in CLI workflow.\nqs = [\n 'BambuStudio \"--export-3mf\" output not openable in GUI',\n '\"bambu-studio\" CLI \"slice\" \"export-3mf\" reopen import error',\n 'BambuStudio CLI gcode.3mf \"geometry data\" empty',\n 'site:github.com bambulab BambuStudio \"gcode.3mf\" \"drag\"',\n]\nout = []\nfor q in qs:\n out.append((q, await web_search(q, num_results=8)))\nfor q, res in out:\n print(\"=\"*40, \"\\nQ:\", q)\n for r in res:\n print(f\" - {r.get('title','')[:90]} :: {r.get('url','')}\")\n snip = (r.get('snippet') or '').replace('\\n',' ')\n print(f\" {snip[:200]}\")\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "======================================== \nQ: BambuStudio \"--export-3mf\" output not openable in GUI\n - Bambu Studio generated 3MF files cannot be opened with other slicers \u00b7 Issue #3316 \u00b7 bambu :: https://github.com/bambulab/BambuStudio/issues/3316\n ## Bambu Studio generated 3MF files cannot be opened with other slicers [...] 1. Use PrusaSlicer to open a Bambu Studio generated 3MF file. 2. Use Cura to open a Bambu Studio generated 3MF file. [...]\n - Problem on opening .3mf files \u00b7 Issue #8503 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/8503\n ## Problem on opening .3mf files [...] 1. Ctrl+O 2. Select a .3mf file 3. Press Open Button 4. Error Message Appears [...] Hello, I have some problem on opening .3mf files in a latest bambu studio. Th\n - BambuStudio saving 3mf files that can't be opened in diffrent slicers. \u00b7 Issue #2834 \u00b7 bam :: https://github.com/bambulab/BambuStudio/issues/2834\n ## BambuStudio saving 3mf files that can't be opened in diffrent slicers. [...] Try reading 3mf files saved by BambuLab on PrusaSlicer. [...] PrusaSlicer should be able to load those files, even just \n - Inconsistent 3mf export CLI vs UI (Segmentation fault) \u00b7 Issue #6067 ... :: https://github.com/bambulab/BambuStudio/issues/6067\n ## Inconsistent 3mf export CLI vs UI (Segmentation fault) [...] **UI** [...] 1) File -> open model -> select model 2) File -> Export -> Export Generic 3MF [...] The exported 3MF you can slice with eit\n - PrusaSlicer can not open 3mf files saved with Bambu Studio \u00b7 Issue #10718 \u00b7 prusa3d/PrusaS :: https://github.com/prusa3d/PrusaSlicer/issues/10718\n ## PrusaSlicer can not open 3mf files saved with Bambu Studio [...] PrusaSlicer can not open 3mf files saved with Bambu Studio [...] > This still seems to be a problem. The programs listed on https://\n - BUG Open 3MF or STL MacOS 10.15.7 \u00b7 Issue #513 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/513\n ## BUG Open 3MF or STL MacOS 10.15.7 [...] If I double click the 3mf it doesn't open in any program even though its told to open in BambuStudio, BambuStudio does become the active window but it acts l\n - 3mf files exported on my PC and shared to printables are unusable by multiple other people :: https://github.com/bambulab/BambuStudio/issues/1881\n ## 3mf files exported on my PC and shared to printables are unusable by multiple other people [...] Multiple users on Printables have reported that 3mf files I've exported from Bambu Studio are distor\n - CLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 \u00b7 bambulab/BambuStud :: https://github.com/bambulab/BambuStudio/issues/2930\n ## CLI generated gcode.3mf not imported correctly (sliced) [...] 1. Generate any 3mf from 3mf or stl file using the CLI 2. import into bambu studio as a project file [...] Needs to be re-sliced, even \n======================================== \nQ: \"bambu-studio\" CLI \"slice\" \"export-3mf\" reopen import error\n - CLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 \u00b7 bambulab/BambuStud :: https://github.com/bambulab/BambuStudio/issues/2930\n ## CLI generated gcode.3mf not imported correctly (sliced) [...] 1. Generate any 3mf from 3mf or stl file using the CLI 2. import into bambu studio as a project file [...] Needs to be re-sliced, even \n - Problem reopening Bambu studio .3mf files \u00b7 Issue #7357 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/7357\n ## Problem reopening Bambu studio .3mf files [...] Lately when I reopen a bambu studio file to change the slicing settings or to relaunch a print this warning appears in red: \"invalid values \u200b\u200bfound i\n - add an import argument to the CLI options \u00b7 Issue #7024 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/7024\n ## add an import argument to the CLI options [...] **Describe the solution you'd like** I would like an import object (stl, and or 3mf) files option via the CLI, to the studio, so files can be loaded \n - Reload from Disk not working if .3mf file is closed and reopened \u00b7 Issue #7980 \u00b7 bambulab/ :: https://github.com/bambulab/BambuStudio/issues/7980\n ## Reload from Disk not working if .3mf file is closed and reopened [...] 1. Add a step model 2. Save the project as a .3mf 3. Close Bambu Studio 4. Update the step model (I generate it from Solidwork\n - Opening sliced plate 3mf immediately closes \u00b7 Issue #6377 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/6377\n ## Opening sliced plate 3mf immediately closes [...] 1. Export any sliced model using 'Export plate sliced file' 2. Open exported plate file into Bambu Slicer 3. Note the message about 'After completi\n - Segmentation fault when using BambuStudio CLI \u00b7 Issue #4627 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/4627\n ## Segmentation fault when using BambuStudio CLI [...] 1. Try to slice a 3MF file using the CLI 2. ` ./bambu-studio.exe --allow-newer-file --slice 01 model_test.3mf --debug 5` [...] 1. ` ./bambu-studi\n - Shader errors when slicing an STL file using CLI \u00b7 Issue #5582 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/5582\n ## Shader errors when slicing an STL file using CLI [...] 1. open the terminal 2. run the following command (with your specific directories) [...] /Applications/BambuStudio.app/Contents/MacOS/BambuStu\n - CLI slicing 3MF fails with \"nozzle_volume_type not found\" + assertions (P2S 0.4) \u00b7 Issue # :: https://github.com/bambulab/BambuStudio/issues/9636\n ## CLI slicing 3MF fails with \"nozzle_volume_type not found\" + assertions (P2S 0.4) [...] 1) Use BambuStudio CLI 02.05.00.66 (built from source) on macOS arm64. 2) Run: BambuStudio --allow-newer-file\n======================================== \nQ: BambuStudio CLI gcode.3mf \"geometry data\" empty\n - No geometry data \u00b7 Issue #4772 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/4772\n ## No geometry data [...] Load the attached 3MF file. [Prop25mm.zip](https://github.com/user-attachments/files/16916343/Prop25mm.zip) [...] TWO messages (why not just one????) \"Loading of model file \n - Changing plates opens empty gcode files \u00b7 Issue #4598 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/4598\n ## Changing plates opens empty gcode files [...] 1. Open or create new project 2. Assign a TEXTURED plate 3. Close Bstudio 4. Try to open ANY gcode that previously was saved as COOL PLATE (for example\n - CLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 \u00b7 bambulab/BambuStud :: https://github.com/bambulab/BambuStudio/issues/2930\n ## CLI generated gcode.3mf not imported correctly (sliced) [...] 1. Generate any 3mf from 3mf or stl file using the CLI 2. import into bambu studio as a project file [...] Needs to be re-sliced, even \n - Load of 3mf file from FreeCAD 0.21 fails with error \"no geometry\" \u00b7 Issue #3012 \u00b7 bambulab :: https://github.com/bambulab/BambuStudio/issues/3012\n ## Load of 3mf file from FreeCAD 0.21 fails with error \"no geometry\" [...] 1. Open Bambu Studio 2. Open the attached 3mf file 3. Load fails with error \"The file does not contain any geometry data\" [..\n - Saved projects files contain no geometry after importing (a/some) 3mf file \u00b7 Issue #752 \u00b7 :: https://github.com/bambulab/BambuStudio/issues/752\n ## Saved projects files contain no geometry after importing (a/some) 3mf file [...] **Describe the bug** After importing geometry from the attached prusaslicer mmu_segment2.3mf into a project OR openi\n - Error loading file, no geometry data included \u00b7 Issue #7112 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/7112\n ## Error loading file, no geometry data included [...] We have got this year some 3d printers in school. They equiped all Laptops with the Bambus studio aplication. So my friend an me wanted to try it\n - 3MF File Contains NO GEOMETRY \u00b7 Issue #4525 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/4525\n ## 3MF File Contains NO GEOMETRY [...] - Author: [@JMarsden92](https://github.com/JMarsden92) - State: closed (completed) - Labels: bug - Assignees: [@Haidiye00](https://github.com/Haidiye00) - Create\n - Inconsistent 3mf export CLI vs UI (Segmentation fault) \u00b7 Issue #6067 \u00b7 bambulab/BambuStudi :: https://github.com/bambulab/BambuStudio/issues/6067\n ## Inconsistent [...] **CLI** [...] ``` ./Bambu_Studio_ubuntu-24.04_PR-6001.AppImage --outputdir '.' --export-3mf export.3mf --orient 0 --debug 5 example.3mf ``` [...] This creates a 3mf file, see \n======================================== \nQ: site:github.com bambulab BambuStudio \"gcode.3mf\" \"drag\"\n - Enable drag-and-drop opening of .3mf files on the Home tab \u00b7 Issue #9209 \u00b7 bambulab/BambuS :: https://github.com/bambulab/BambuStudio/issues/9209\n ## Enable drag-and-drop opening of .3mf files on the Home tab [...] **Problem** In Bambu Studio, **.3mf files can be opened via drag and drop only on the \u201cPrepare\u201d and \u201cPreview\u201d tabs**. If a .3mf file\n - \"Export plate sliced file\" exports a 3MF file with the extension \".gcode.3mf\" \u00b7 Issue #147 :: https://github.com/bambulab/BambuStudio/issues/1479\n ## \"Export plate sliced file\" exports a 3MF file with the extension \".gcode.3mf\" [...] The file selection dialog automatically appends the .gcode extension. The file that is actually exported is a bin\n - [BUG] Force .gcode.3mf file extension when saving/exporting gcode file \u00b7 Issue #3375 \u00b7 bam :: https://github.com/bambulab/BambuStudio/issues/3375\n ## [BUG] Force .gcode.3mf file extension when saving/exporting gcode file [...] 1. Export a sliced file as Gcode [...] If you type any name, it's saved as .3mf YOU MAY HAVE OVERWRITTEN THE .GCODE. por\n - [Bug] File \u2192 Export \u2192 Export Sliced File\u2026 adds .3mf extension to the .gcode file \u00b7 Issue # :: https://github.com/bambulab/BambuStudio/issues/519\n ## [Bug] File \u2192 Export \u2192 Export Sliced File\u2026 adds .3mf extension to the .gcode file [...] On a Mac, when choosing `Export Sliced File` (to an SD card for example) instead of sending the print job OTA,\n - CLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 \u00b7 bambulab/BambuStud :: https://github.com/bambulab/BambuStudio/issues/2930\n ## CLI generated gcode.3mf not imported correctly (sliced) [...] 1. Generate any 3mf from 3mf or stl file using the CLI 2. import into bambu studio as a project file [...] Needs to be re-sliced, even \n - [Feature Request] Upload of GCode to SD-Card \u00b7 Issue #322 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/322\n ## [Feature Request] Upload of GCode to SD-Card [...] Currently it's not possible to upload GCode to SD card. For people who want to use other slicer this is very limiting issue in flexibility. [...] \n - bambulab/BambuStudio :: http://github.com/bambulab/BambuStudio\n - Stars: 4297 - Forks: 695 - Watchers: 71 - Open issues: 6375 - Primary language: C++ - Languages: C++ (84.3%), C (7.5%), JavaScript (4.3%), HTML (1.8%), Perl (0.6%), CMake (0.5%), CSS (0.2%), GLSL (0\n - BambuStudio crashes upon opening old gcode.3mf file \u00b7 Issue #8518 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/8518\n ## BambuStudio crashes upon opening old gcode.3mf file [...] 1. Doubleclick the attached .gcode.3mf file [...] [crash.gcode.3mf.zip](https://github.com/user-attachments/files/23097500/crash.gcode.3mf.\n" + } + ] + }, + { + "id": "b1ab05ba", + "cell_type": "code", + "metadata": {}, + "execution_count": 31, + "source": "# Issue #6067 sounds interesting - \"Inconsistent 3mf export CLI vs UI\"\ncontent_6067 = await fetch_parsed_page(\"https://github.com/bambulab/BambuStudio/issues/6067\")\nimport re\nm = re.search(r'(?s)(UI[\\s\\S]{0,3500})', content_6067)\n# Print issue body\nprint(content_6067[5000:10000])\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "pport](https://github.com/premium-support)\n* [Pricing](https://github.com/pricing)\n\n# Provide feedback\n\n# Saved searches\n\n## Use saved searches to filter your results more quickly\n\n[Sign in](https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fbambulab%2FBambuStudio%2Fissues%2F6067)\n\n[Sign up](https://github.com/signup?ref_cta=Sign+up&ref_loc=header+logged+out&ref_page=%2F%3Cuser-name%3E%2F%3Crepo-name%3E%2Fvoltron%2Fissues_fragments%2Fissue_layout&source=header-repo&source_repo=bambulab%2FBambuStudio) \n\nAppearance settings\n\nYou signed in with another tab or window. Reload to refresh your session.\n\nYou signed out in another tab or window. Reload to refresh your session.\n\nYou switched accounts on another tab or window. Reload to refresh your session.\n\nDismiss alert\n\n[bambulab](https://github.com/bambulab)\n\n/\n\n**[BambuStudio](https://github.com/bambulab/BambuStudio)**Public\n\n* You must be signed in to change notification settings\n* [740](https://github.com/login?return_to=%2Fbambulab%2FBambuStudio)\n* [Star 4.4k](https://github.com/login?return_to=%2Fbambulab%2FBambuStudio)\n\n* [Code](https://github.com/bambulab/BambuStudio)\n* [Issues 5k+](https://github.com/bambulab/BambuStudio/issues)\n* [Pull requests 85](https://github.com/bambulab/BambuStudio/pulls)\n* [Actions](https://github.com/bambulab/BambuStudio/actions)\n* [Projects](https://github.com/bambulab/BambuStudio/projects)\n* [Wiki](https://github.com/bambulab/BambuStudio/wiki)\n* [Security and quality 0](https://github.com/bambulab/BambuStudio/security)\n* [Insights](https://github.com/bambulab/BambuStudio/pulse)\n\nAdditional navigation options \n\n* [Code](https://github.com/bambulab/BambuStudio)\n* [Issues](https://github.com/bambulab/BambuStudio/issues)\n* [Pull requests](https://github.com/bambulab/BambuStudio/pulls)\n* [Actions](https://github.com/bambulab/BambuStudio/actions)\n* [Projects](https://github.com/bambulab/BambuStudio/projects)\n* [Wiki](https://github.com/bambulab/BambuStudio/wiki)\n* [Security and quality](https://github.com/bambulab/BambuStudio/security)\n* [Insights](https://github.com/bambulab/BambuStudio/pulse)\n\n# Inconsistent 3mf export CLI vs UI (Segmentation fault)\\#6067\n\n[New issue](https://github.com/login?return_to=https://github.com/bambulab/BambuStudio/issues/6067)\n\nCopy link\n\n[New issue](https://github.com/login?return_to=https://github.com/bambulab/BambuStudio/issues/6067)\n\nCopy link\n\n[\\#4666](https://github.com/bambulab/BambuStudio/issues/4666)\n\n[\\#4666](https://github.com/bambulab/BambuStudio/issues/4666)\n\n[Inconsistent 3mf export CLI vs UI (Segmentation fault)](https://github.com/bambulab/BambuStudio/issues/6067#top)\\#6067\n\nCopy link\n\nAssignees\n\n[![MackBambu](https://avatars.githubusercontent.com/u/169036319?s=64&u=90c10794a729890f7ad4c1915f6408e2b07e1f0c&v=4 \"width=20 | height=20\")](https://github.com/MackBambu)\n\nLabels\n\n[bug Something isn't working](https://github.com/bambulab/BambuStudio/issues?q=state%3Aopen%20label%3A%22bug%22)Something isn't working\n\n[![@atamgp](https://avatars.githubusercontent.com/u/3808482?u=6a521bb39ce653676d0f58eb9aa246875ef1a97c&v=4&size=80 \"width=40 | height=40\")](https://github.com/atamgp)\n\n## Description\n\n[![@atamgp](https://avatars.githubusercontent.com/u/3808482?u=6a521bb39ce653676d0f58eb9aa246875ef1a97c&v=4&size=48 \"width=24 | height=24\")](https://github.com/atamgp)\n\n[atamgp](https://github.com/atamgp)\n\nopened\n\n[on Mar 7, 2025](https://github.com/bambulab/BambuStudio/issues/6067#issue-2902405254)\u00b7\n\nedited by [atamgp](https://github.com/atamgp)\n\nIssue body actions\n\n### Bambu Studio Version\n\n1.10.2 (Bambu\\_Studio\\_ubuntu-24.04\\_PR-6001.AppImage)\n\n### Where is the application from?\n\nBambu Lab Official website\n\n### OS version\n\nUbuntu 24\n\n### Additional system information\n\n*No response*\n\n### Printer\n\nP1S (not relevant)\n\n### How to reproduce\n\nI use example 3mf models from here: \n\n\nWe will open these and export them again in 3mf.\n\n**UI**\n\n1. File -> open model -> select model\n2. File -> Export -> Export Generic 3MF\n\nThe exported 3MF you can slice with either UI or CLI with no issues.\n\n**CLI**\n\n```\n./Bambu_Studio_ubuntu-24.04_PR-6001.AppImage --outputdir '.' --export-3mf export.3mf --orient 0 --debug 5 \nexample.3mf\n```\n\nThis creates a 3mf file, see logs below. \nHowever, its empty and invalid.\n\n`cat result.json`\n\n> { \n> \"error\\_string\": \"Success.\", \n> \"export\\_time\": 11, \n> \"plate\\_index\": 0, \n> \"prepare\\_time\": 32, \n> \"return\\_code\": 0 \n> }\n\n### Actual results\n\n**Test the CLI export** \n`./Bambu_Studio_ubuntu-24.04_PR-6001.AppImage --info --debug 5 export.3mf`\n\n\\->\n\n> ... \n> \\[2025-03-07 08:52:44.000210] \\[0x00007b79023b5480] \\[debug] \\_generate\\_volumes\\_new: line 4598, subobject\\_id 1, shared\\_mesh\\_id 1 \n> \\[2025-03-07 08:52:44.007000] \\[0x00007b79023b5480] \\[info] \\_load\\_model\\_from\\_file:2124import 3mf IMPORT\\_STAGE\\_LOADING\\_PLATES, m\\_plater\\_data size 1, m\\_backup\\_path /tmp/bamboo\\_model/Fri\\_Mar\\_07/08\\\n" + } + ] + }, + { + "id": "b7415d30", + "cell_type": "code", + "metadata": {}, + "execution_count": 32, + "source": "# Let me look at the full body and any responses for #6067\n# Find the main issue text and response\nidx = content_6067.find('export.3mf created')\nif idx == -1:\n idx = content_6067.find('Actual results')\nprint(content_6067[idx:idx+5000])\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Actual results\n\n**Test the CLI export** \n`./Bambu_Studio_ubuntu-24.04_PR-6001.AppImage --info --debug 5 export.3mf`\n\n\\->\n\n> ... \n> \\[2025-03-07 08:52:44.000210] \\[0x00007b79023b5480] \\[debug] \\_generate\\_volumes\\_new: line 4598, subobject\\_id 1, shared\\_mesh\\_id 1 \n> \\[2025-03-07 08:52:44.007000] \\[0x00007b79023b5480] \\[info] \\_load\\_model\\_from\\_file:2124import 3mf IMPORT\\_STAGE\\_LOADING\\_PLATES, m\\_plater\\_data size 1, m\\_backup\\_path /tmp/bamboo\\_model/Fri\\_Mar\\_07/08\\_52\\_43#199949#3\n> \n> \\[2025-03-07 08:52:44.007015] \\[0x00007b79023b5480] \\[info] \\_load\\_model\\_from\\_file:2169, plate 1, thumbnail\\_file=, no\\_light\\_thumbnail\\_file= \n> \\[2025-03-07 08:52:44.007020] \\[0x00007b79023b5480] \\[info] \\_load\\_model\\_from\\_file:2170, top\\_thumbnail\\_file=, pick\\_thumbnail\\_file= \n> \\[2025-03-07 08:52:44.007024] \\[0x00007b79023b5480] \\[info] \\_load\\_model\\_from\\_file:2227import 3mf IMPORT\\_STAGE\\_FINISH \n> \\[2025-03-07 08:52:44.007088] \\[0x00007b79023b5480] \\[info] the first file is a 3mf, version 1.10.2.75, got plate count 1 \n> **Segmentation fault (core dumped)**\n\n### Expected results\n\nNo issue and slice exported 3mf like the UI does.\n\n### Project file & Debug log uploads\n\nLogs of CLI export:\n\n\\[2025-03-07 08:48:37.909364] \\[0x00007f08357b7480] \\[trace] Initializing StaticPrintConfigs \n\\[2025-03-07 08:48:38.085296] \\[0x00007f08357b7480] \\[warning] cli mode, Current BambuStudio Version 01.10.02.75 \n\\[2025-03-07 08:48:38.085320] \\[0x00007f08357b7480] \\[info] Will start to read model file now, file count :1\n\n\\[2025-03-07 08:48:38.085340] \\[0x00007f08357b7480] \\[info] allow\\_multicolor\\_oneplate 1, allow\\_rotations 1 skip\\_modified\\_gcodes 0 avoid\\_extrusion\\_cali\\_region 0 loaded\\_filament\\_ids size 0, clone\\_objects size 0, skip\\_useless\\_pick 1, allow\\_newer\\_file 0 \n\\[2025-03-07 08:48:38.085350] \\[0x00007f08357b7480] \\[info] plate\\_to\\_slice=0, normative\\_check=1, use\\_first\\_fila\\_as\\_default=0 \nNo such file: export.3mf \n\\[2025-03-07 08:48:38.085624] \\[0x00007f08357b7480] \\[info] record\\_exit\\_reson:529, saved config to result.json\n\nrun found error, return -3, exit... \n\\[2025-03-07 08:48:38.085655] \\[0x00007f08357b7480] \\[info] cli\\_callback\\_mgr\\_t::stop enter. \n\\[2025-03-07 08:48:38.085662] \\[0x00007f08357b7480] \\[info] cli\\_callback\\_mgr\\_t::stop not started before, return directly. \nroot@ubuntu:/srv/www/kubify.nl/current/web/slicer# ./Bambu\\_Studio\\_ubuntu-24.04\\_PR-6001.AppImage --outputdir '.' --export-3mf export.3mf --orient 0 --debug 5 /srv/www/kubify.nl/current/web/slicer/models/standard/box.3mf \n\\[2025-03-07 08:50:05.390876] \\[0x0000752bc6da4480] \\[trace] Initializing StaticPrintConfigs \n\\[2025-03-07 08:50:05.565171] \\[0x0000752bc6da4480] \\[warning] cli mode, Current BambuStudio Version 01.10.02.75 \n\\[2025-03-07 08:50:05.565195] \\[0x0000752bc6da4480] \\[info] Will start to read model file now, file count :1\n\n\\[2025-03-07 08:50:05.565215] \\[0x0000752bc6da4480] \\[info] allow\\_multicolor\\_oneplate 1, allow\\_rotations 1 skip\\_modified\\_gcodes 0 avoid\\_extrusion\\_cali\\_region 0 loaded\\_filament\\_ids size 0, clone\\_objects size 0, skip\\_useless\\_pick 1, allow\\_newer\\_file 0 \n\\[2025-03-07 08:50:05.565226] \\[0x0000752bc6da4480] \\[info] plate\\_to\\_slice=0, normative\\_check=1, use\\_first\\_fila\\_as\\_default=0 \n\\[2025-03-07 08:50:05.565241] \\[0x0000752bc6da4480] \\[info] read model file:/srv/www/kubify.nl/current/web/slicer/models/standard/box.3mf\n\n\\[2025-03-07 08:50:05.568250] \\[0x0000752bc6da4480] \\[info] model 0x7fff906fb090, id 3, backup\\_path empty, set to /tmp/bamboo\\_model/Fri\\_Mar\\_07/08\\_50\\_05#199910#3 \n\\[2025-03-07 08:50:05.568280] \\[0x0000752bc6da4480] \\[info] create /3D/Objects in \"/tmp/bamboo\\_model/Fri\\_Mar\\_07/08\\_50\\_05#199910#3\" \n\\[2025-03-07 08:50:05.568442] \\[0x0000752bc6da4480] \\[info] create /Metadata in \"/tmp/bamboo\\_model/Fri\\_Mar\\_07/08\\_50\\_05#199910#3\" \n\\[2025-03-07 08:50:05.568487] \\[0x0000752bc6da4480] \\[info] create /lock.txt in \"/tmp/bamboo\\_model/Fri\\_Mar\\_07/08\\_50\\_05#199910#3\" \n\\[2025-03-07 08:50:05.568601] \\[0x0000752bc6da4480] \\[info] \\_load\\_model\\_from\\_file:1577import 3mf IMPORT\\_STAGE\\_OPEN, m\\_load\\_restore=0\n\n\\[2025-03-07 08:50:05.569389] \\[0x0000752bc6da4480] \\[info] \\_load\\_model\\_from\\_file:1613import 3mf IMPORT\\_STAGE\\_READ\\_FILES\n\n\\[2025-03-07 08:50:05.571510] \\[0x0000752bc6da4480] \\[trace] design\\_info, load\\_3mf found Copyright = Copyright (c) 2015 3MF Consortium. All rights reserved. \n\\[2025-03-07 08:50:05.573621] \\[0x0000752bc6da4480] \\[info] \\_load\\_model\\_from\\_file:1766extract 1th file \\_rels/.rels, total=3 \n\\[2025-03-07 08:50:05.573634] \\[0x0000752bc6da4480] \\[info] \\_load\\_model\\_from\\_file:1860, \\_rels/.rels skipped, already parsed or a directory or not supported\n\n\\[2025-03-07 08:50:05.573651] \\[0x0000752bc6da4480] \\[info] \\_load\\_model\\_from\\_file:1766extract 2th file 3D/3dmodel.model, total=3 \n\\[2025-03-07 08:50:05.573661] \\[0x0000752bc6da4480] \\[info] \\_load\\_model\\_from\\_file:1860, 3D/3dmodel.model skipped, already parse\n" + } + ] + }, + { + "id": "bbdea0fc", + "cell_type": "code", + "metadata": {}, + "execution_count": 33, + "source": "# Now let me try to reach the actual bambu source code to confirm the drag-drop import code path.\n# The key code path: BS uses different routines for \"drag/drop\" vs \"Open Project\" vs \"Import\".\n# Drag/drop typically calls load_files() which can dispatch to load_project() or load_model() based on extension.\n# Let's look at the source.\nfrom e14c.web import web_search\nres = await web_search('BambuStudio source \"load_files\" gcode.3mf drag drop site:github.com', num_results=10)\nfor r in res:\n print(r.get('title','')[:90], '::', r.get('url',''))\n print(' ', (r.get('snippet') or '').replace('\\n',' ')[:200])\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Enable drag-and-drop opening of .3mf files on the Home tab \u00b7 Issue #9209 \u00b7 bambulab/BambuS :: https://github.com/bambulab/BambuStudio/issues/9209\n ## Enable drag-and-drop opening of .3mf files on the Home tab [...] **Problem** In Bambu Studio, **.3mf files can be opened via drag and drop only on the \u201cPrepare\u201d and \u201cPreview\u201d tabs**. If a .3mf file\nBambuStudio crashes upon opening old gcode.3mf file \u00b7 Issue #8518 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/issues/8518\n ## BambuStudio crashes upon opening old gcode.3mf file [...] 1. Doubleclick the attached .gcode.3mf file [...] [crash.gcode.3mf.zip](https://github.com/user-attachments/files/23097500/crash.gcode.3mf.\nCLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 \u00b7 bambulab/BambuStud :: https://github.com/bambulab/BambuStudio/issues/2930\n ## CLI generated gcode.3mf not imported correctly (sliced) [...] 1. Generate any 3mf from 3mf or stl file using the CLI 2. import into bambu studio as a project file [...] Needs to be re-sliced, even \nsrc/libslic3r/Format/bbs_3mf.hpp at fda63da8 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/blob/fda63da8/src/libslic3r/Format/bbs_3mf.hpp\n # File: bambulab/BambuStudio/src/libslic3r/Format/bbs_3mf.hpp [...] #include \"../GCode/ThumbnailData.hpp\" #include \"libslic3r/ProjectTask.hpp\" #include \"libslic3r/GCode/GCodeProcessor.hpp\" #include \"hello, the .gcode.3mf includes not only gcode, but also some thumbnails&&settings, \n# > which are necessary for printing\n# > however, to save the file size, it doesn't include the geometry data\n# > so it is not a project file for individual plate\n# > but a print file for individual plate\"\n#\n# This is an authoritative statement from Bambu Lab that .gcode.3mf is INTENTIONALLY a \"print file\", \n# NOT a project file, and it does not contain geometry data.\n#\n# BUT \u2014 wait \u2014 Marcus' CLI 3mf DOES contain geometry: object_1.model has 6526+ vertices. So why does the import say \"no geometry\"?\n# Possibly the import path for .gcode.3mf treats it differently \u2014 i.e. the path is hard-coded to the\n# stripped (no-geometry) variant produced by GUI's \"Export plate sliced file\".\n# \n# In our case, --slice 1 --export-3mf produced a hybrid: it kept the geometry (because it was a regular 3mf,\n# not the \"Export plate sliced file\" output) AND added the gcode. The result has both, but the file is named\n# WITH .gcode.3mf extension \u2014 wait, actually let me re-check the user's filename:\n# slices/t3-prism.H2D-PETG.gcode.3mf\n# \n# With BS CLI, when you use --slice 1 --export-3mf out.3mf, the output file naming depends on how the CLI was invoked.\n# Looking at the actual file: it has /3D/Objects/object_1.model (full mesh), /Metadata/plate_1.gcode (the print job), \n# AND thumbnails.\n# \n# So the file IS \"complete\" \u2014 geometry + gcode + thumbnails. But the EXTENSION is .gcode.3mf, and this might trigger\n# the GUI's \"treat as print-file\" path which expects no-geometry, OR might fail XML parsing during the gcode-load.\n\n# Hypothesis confirmation:\n# The community explanation \u2014 \"drag/import treats .gcode.3mf as print-file\" \u2014 is consistent with the dev's own statement.\n# The actual error message \"no geometry\" is misleading: the file does contain a mesh, but the import path that handles\n# .gcode.3mf is different and it's looking for something specific (e.g., a sliced-plate format) that doesn't match.\n\n# Let me check whether the hand-made GUI 3mf in slices/t3-prism.H2D.3mf path (also CLI-generated, but the user\n# said this is generated WITHOUT --slice) is named .3mf vs .gcode.3mf \u2014 that's a key clue.\n\n# Confirm: per Marcus, slices/t3-prism.H2D.3mf is a \"project .3mf without --slice\" - different filename, would be opened as project.\n# So the workflow recommendation is: produce both:\n# - slices/t3-prism.H2D.3mf (project, no slicing) - opens via drag/drop or File\u2192Open Project\n# - slices/t3-prism.H2D-PETG.gcode.3mf (sliced, for printer-side LAN print or send-to-printer)\n\nprint(\"Analysis complete. Now let me also confirm the glfwInit error is NOT load-blocking.\")\nprint(\"The CLI ran headlessly with software GL via xvfb-run; thumbnails were rendered (we have plate_1.png in the workdir!) so GL did work for thumbnail rendering.\")\nprint()\n# Look at what thumbnails exist\nimport os\nWD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\nprint(\"Metadata contents:\")\nfor f in sorted(os.listdir(f\"{WD}/Metadata\")):\n print(f\" {f}: {os.path.getsize(os.path.join(WD,'Metadata',f))} bytes\")\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Analysis complete. Now let me also confirm the glfwInit error is NOT load-blocking.\nThe CLI ran headlessly with software GL via xvfb-run; thumbnails were rendered (we have plate_1.png in the workdir!) so GL did work for thumbnail rendering.\n\nMetadata contents:\n _rels: 4096 bytes\n cut_information.xml: 139 bytes\n filament_sequence.json: 72 bytes\n model_settings.config: 12482 bytes\n pick_1.png: 1772 bytes\n plate_1.gcode: 3928722 bytes\n plate_1.gcode.md5: 32 bytes\n plate_1.json: 437 bytes\n plate_1.png: 19733 bytes\n plate_1_small.png: 4430 bytes\n plate_no_light_1.png: 4881 bytes\n project_settings.config: 38885 bytes\n slice_info.config: 205 bytes\n top_1.png: 6167 bytes\n" + } + ] + }, + { + "id": "4a020648", + "cell_type": "code", + "metadata": {}, + "execution_count": 41, + "source": "# Wait \u2014 there ARE thumbnails in the workdir (plate_1.png, top_1.png, pick_1.png).\n# So my earlier statement (that the CLI 3mf has no thumbnails) might be wrong.\n# Let me re-read the user's question: \"the GUI one has thumbnails ... and NO Metadata/plate_1.gcode;\n# the CLI one has Metadata/plate_1.gcode (the actual print job, ~3.97 MB) and NO thumbnails\"\n# But the on-disk files in workdir show BOTH: plate_1.png AND plate_1.gcode (~3.93 MB).\n# \n# Most likely interpretation: the workdir contents represent the EXTRACTED CLI-produced .gcode.3mf,\n# and the thumbnails ARE present here because the CLI did produce them via xvfb+software GL.\n# \n# Wait, actually: plate_1.png is 19733 bytes = real thumbnail. top_1.png = 6167 bytes. pick_1.png = 1772 bytes.\n# Let me look at one to see if it's a real render or a fallback placeholder.\nfrom PIL import Image\nimport os\nWD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\nimg = Image.open(f\"{WD}/Metadata/plate_1.png\")\nprint(\"plate_1.png size:\", img.size, \"mode:\", img.mode)\n# Check if mostly black\nimport numpy as np\narr = np.array(img)\nprint(\"mean:\", arr.mean(axis=(0,1)) if arr.ndim==3 else arr.mean())\nprint(\"std:\", arr.std(axis=(0,1)) if arr.ndim==3 else arr.std())\n# small thumbnail\nimg2 = Image.open(f\"{WD}/Metadata/plate_1_small.png\")\nprint(\"plate_1_small.png size:\", img2.size, \"mode:\", img2.mode)\narr2 = np.array(img2)\nprint(\"mean:\", arr2.mean(axis=(0,1)) if arr2.ndim==3 else arr2.mean())\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "plate_1.png size: (512, 512) mode: RGBA\nmean: [12.5178299 12.5178299 12.5178299 18.49054718]\nstd: [45.48900659 45.48900659 45.48900659 65.45463367]\nplate_1_small.png size: (128, 128) mode: RGBA\nmean: [12.47479248 12.47479248 12.47479248 18.47357178]\n" + } + ] + }, + { + "id": "9194b6c5", + "cell_type": "code", + "metadata": {}, + "execution_count": 42, + "source": "# Thumbnails are mostly black/transparent (mean ~12 / 255). The std is high(~45), so there IS some content.\n# But it's very dark \u2014 likely a partial/failed render due to the GL issue.\n# Let's look at the image to confirm it's just a sketch.\nfrom IPython.display import Image as IPyImage, display\ndisplay(IPyImage(f\"{WD}/Metadata/plate_1.png\"))\n", + "outputs": [ + { + "output_type": "display_data", + "metadata": {}, + "data": { + "image/png": "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", + "text/plain": "" + } + } + ] + }, + { + "id": "06a5a7a4", + "cell_type": "code", + "metadata": {}, + "execution_count": 43, + "source": "# OK \u2014 actually the thumbnail IS a clean render of the T3-prism. So the CLI did successfully render thumbnails\n# (likely via the software GL fallback enabled by xvfb-run + Mesa). The earlier glfwInit error was just for\n# the *interactive* GLFW context, not the offscreen render path BS uses for thumbnails.\n# \n# So the user's premise that \"no thumbnails\" caused the import error is FALSE \u2014 thumbnails ARE present in this 3mf.\n# \n# Important course-correction. Let me re-read the user's question.\n# \n# User said: \"the CLI one has Metadata/plate_1.gcode (the actual print job, ~3.97 MB) and NO thumbnails\"\n# \n# But the workdir clearly has plate_1.png (19.7 KB) and plate_1_small.png (4.4 KB). These are real renders.\n# \n# So either (a) the user's statement is mistaken, or (b) the workdir contains a different/already-fixed CLI 3mf,\n# or (c) the workdir is the GUI-made one extracted (but the GUI-made one would have no plate_1.gcode...)\n# \n# The workdir has BOTH plate_1.gcode AND plate_1.png. That's unusual. It might be the result of the CLI 3mf\n# AFTER it has been opened/processed by another tool, OR Marcus' CLI run actually DID produce thumbnails.\n# \n# Either way, the thumbnails being present here means we cannot blame \"no thumbnails\" for the import failure.\n# \n# Let me also note: the workdir has [Content_Types].xml that lists png. Good.\n# Let me actually check if there's a mismatch: BS expects rel-2 for thumbnail to be /Metadata/plate_1.png. We have that.\n# All looks fine.\n\n# So if these files were indeed extracted from the failing .gcode.3mf, the file is structurally complete.\n# That points to my earlier hypothesis: the GUI's drag-import path branches on extension '.gcode.3mf' specifically,\n# and treats it as \"print file\" (per #1479 dev statement) \u2014 even when the underlying file actually does have geometry.\n# When the print-file path is taken, it expects the format that \"Export plate sliced file\" produces (no geometry,\n# only gcode + thumbnails + settings) and hits a different read path that probably can't extract a usable model.\n\n# Let me also verify by searching for the exact string in the source\nfrom e14c.web import web_search\nres = await web_search('BambuStudio \"is_gcode_3mf\" OR \"load_gcode_3mf\" source', num_results=10)\nfor r in res:\n print(' -', r.get('title','')[:80], '::', r.get('url',''))\n print(' ', (r.get('snippet') or '').replace('\\n',' ')[:200])\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": " - src/libslic3r/Format/bbs_3mf.hpp at fda63da8 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/blob/fda63da8/src/libslic3r/Format/bbs_3mf.hpp\n # File: bambulab/BambuStudio/src/libslic3r/Format/bbs_3mf.hpp [...] ```hpp #ifndef BBS_3MF_hpp_ #define BBS_3MF_hpp_ #include \"../GCode/ThumbnailData.hpp\" #include \"libslic3r/ProjectTask.hpp\" #includ\n - 3MF Project File Handling | bambulab/BambuStudio | DeepWiki :: https://deepwiki.com/bambulab/BambuStudio/2.3-3mf-project-file-handling\n * [3MF Project File Handling](https://deepwiki.com/ [...] file-handling) [...] # 3MF Project File Handling [...] Relevant source files [...] * [src/BambuStudio.cpp](https://github.com/bambulab/BambuSt\n - G-code Export and Processing | bambulab/BambuStudio | DeepWiki :: https://deepwiki.com/bambulab/BambuStudio/6.4-g-code-export-and-processing\n * [G-code Export and Processing](https://deepwiki.com/bambulab/BambuStudio/6.4-g-code-export-and-processing) [...] # G-code Export and Processing [...] Relevant source files [...] * [src/libslic3r/Ext\n - src/libslic3r/GCode/GCodeProcessor.cpp at fda63da8 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/blob/fda63da8/src/libslic3r/GCode/GCodeProcessor.cpp\n # File: bambulab/BambuStudio/src/libslic3r/GCode/GCodeProcessor.cpp [...] ```cpp #include \"libslic3r/libslic3r.h\" #include \"libslic3r/Utils.hpp\" #include \"libslic3r/Print.hpp\" #include \"libslic3r/Loca\n - G-code Viewer | bambulab/BambuStudio | DeepWiki :: https://deepwiki.com/bambulab/BambuStudio/4.4-g-code-viewer\n * [3MF Project File Handling](https://deepwiki.com/bambulab/BambuStudio/2.3-3mf-project-file-handling) [...] * [G-code Viewer](https://deepwiki.com/bambulab/BambuStudio/4.4-g-code-viewer) [...] # G-co\n - CLI generated gcode.3mf not imported correctly (sliced) \u00b7 Issue #2930 \u00b7 bambulab :: https://github.com/bambulab/BambuStudio/issues/2930\n ## CLI generated gcode.3mf not imported correctly (sliced) [...] 1. Generate any 3mf from 3mf or stl file using the CLI 2. import into bambu studio as a project file [...] Needs to be re-sliced, even \n - BambuStudio crashes upon opening old gcode.3mf file \u00b7 Issue #8518 \u00b7 bambulab/Bam :: https://github.com/bambulab/BambuStudio/issues/8518\n ## BambuStudio crashes upon opening old gcode.3mf file [...] 1. Doubleclick the attached .gcode.3mf file [...] [crash.gcode.3mf.zip](https://github.com/user-attachments/files/23097500/crash.gcode.3mf.\n - \"Export plate sliced file\" exports a 3MF file with the extension \".gcode.3mf\" \u00b7 :: https://github.com/bambulab/BambuStudio/issues/1479\n ## \"Export plate sliced file\" exports a 3MF file with the extension \".gcode.3mf\" [...] The file selection dialog automatically appends the .gcode extension. The file that is actually exported is a bin\n - [BUG] Force .gcode.3mf file extension when saving/exporting gcode file \u00b7 Issue # :: https://github.com/bambulab/BambuStudio/issues/3375\n ## [BUG] Force .gcode.3mf file extension when saving/exporting gcode file [...] 1. Export a sliced file as Gcode [...] If you type any name, it's saved as .3mf YOU MAY HAVE OVERWRITTEN THE .GCODE. por\n - src/BambuStudio.hpp at fda63da8 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/blob/fda63da8/src/BambuStudio.hpp\n namespace IO { \tenum ExportFormat : int { AMF, OBJ, STL, // SVG, TMF, Gcode }; } [...] private: DynamicPrintAndCLIConfig m_config; Dynami\n" + } + ] + }, + { + "id": "d9a62c8a", + "cell_type": "code", + "metadata": {}, + "execution_count": 44, + "source": "# Look at bbs_3mf.hpp for the load API\ncontent_bbs = await fetch_parsed_page(\"https://github.com/bambulab/BambuStudio/blob/master/src/libslic3r/Format/bbs_3mf.hpp\")\nimport re\n# Find load_bbs_3mf function signature\nm = re.search(r'(?s)(load_bbs_3mf[^;]{0,2000})', content_bbs)\nif m:\n print(m.group()[:2000])\nprint(\"---\")\nm = re.search(r'(?s)(LoadStrategy[^}]{0,500})', content_bbs)\nif m:\n print(m.group())\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "---\nLoadStrategy strategy = LoadStrategy::Default, BBLProject \\*project = nullptr\n\n, int\n\nplate\\_id = 0\n\n,\n\nstd::map>\\* color\\_group\\_map = nullptr\n\n, VolumeColorInfoMap\\* volume\\_color\\_data = nullptr\n\n);\n\nextern\n\nstd::string bbs\\_3mf\\_get\\_thumbnail\n\n(const\n\nchar\n\n\\* path);\n\nextern\n\nbool\n\nload\\_gcode\\_3mf\\_from\\_stream\n\n(std::istream & data, DynamicPrintConfig\\* config, Model\\* model, PlateDataPtrs\\* plate\\_data\\_list,\n\nSemver\\* file\\_version);\n\n//\n\nBBS: add plate data list re\n" + } + ] + }, + { + "id": "a208eb22", + "cell_type": "code", + "metadata": {}, + "execution_count": 45, + "source": "# GREAT \u2014 there is a separate `load_gcode_3mf_from_stream` function. So BS DOES have a dedicated codepath\n# specifically for .gcode.3mf files. This confirms the hypothesis.\n# Let's look more\nimport re\n# find more about load_gcode_3mf in the .hpp\nm = re.search(r'(?s)(load_gcode_3mf[\\s\\S]{0,2000})', content_bbs)\nif m: print(m.group()[:2500])\nprint(\"---\")\n# Find LoadStrategy enum values\nm = re.search(r'(?s)(LoadStrategy[\\s\\S]{0,2000})', content_bbs)\nif m: print(m.group()[:2500])\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "---\nLoadStrategy strategy = LoadStrategy::Default, BBLProject \\*project = nullptr\n\n, int\n\nplate\\_id = 0\n\n,\n\nstd::map>\\* color\\_group\\_map = nullptr\n\n, VolumeColorInfoMap\\* volume\\_color\\_data = nullptr\n\n);\n\nextern\n\nstd::string bbs\\_3mf\\_get\\_thumbnail\n\n(const\n\nchar\n\n\\* path);\n\nextern\n\nbool\n\nload\\_gcode\\_3mf\\_from\\_stream\n\n(std::istream & data, DynamicPrintConfig\\* config, Model\\* model, PlateDataPtrs\\* plate\\_data\\_list,\n\nSemver\\* file\\_version);\n\n//\n\nBBS: add plate data list related logic\n\n//\n\nadd backup logic\n\n//\n\nSave the given model and the config data contained in the given Print into a 3mf file.\n\n//\n\nThe model could be modified during the export process if meshes are not repaired or have no shared vertices\n\n/\\*\n\nextern bool store\\_bbs\\_3mf(const char\\* path,\n\nModel\\* model,\n\nPlateDataPtrs& plate\\_data\\_list,\n\nstd::vector\\& project\\_presets,\n\nconst DynamicPrintConfig\\* config,\n\nbool fullpath\\_sources,\n\nconst std::vector\\& thumbnail\\_data,\n\nbool zip64 = true,\n\nbool skip\\_static = false,\n\nExport3mfProgressFn proFn = nullptr,\n\nbool silence = true);\n\n\\*/\n\nextern\n\nbool\n\nstore\\_bbs\\_3mf\n\n(StoreParams& store\\_params);\n\nextern\n\nvoid\n\nrelease\\_PlateData\\_list\n\n(PlateDataPtrs& plate\\_data\\_list);\n\n//\n\nbackup & restore project\n\nextern\n\nvoid\n\nsave\\_object\\_mesh\n\n(ModelObject& object);\n\nextern\n\nvoid\n\ndelete\\_object\\_mesh\n\n(ModelObject& object);\n\nextern\n\nvoid\n\nbackup\\_soon\n\n();\n\nextern\n\nvoid\n\nremove\\_backup\n\n(Model& model, bool\n\nremoveAll);\n\nextern\n\nvoid\n\nset\\_backup\\_interval\n\n(long\n\ninterval);\n\nextern\n\nvoid\n\nset\\_backup\\_callback\n\n(std::function callback);\n\nextern\n\nvoid\n\nrun\\_backup\\_ui\\_tasks\n\n();\n\nextern\n\nbool\n\nhas\\_restore\\_data\n\n(std::string & path, std::string & origin);\n\nextern\n\nvoid\n\nput\\_other\\_changes\n\n();\n\nextern\n\nvoid\n\nclear\\_other\\_changes\n\n(bool\n\nbackup);\n\nextern\n\nbool\n\nhas\\_other\\_changes\n\n(bool\n\nbackup);\n\nclass\n\nSaveObjectGaurd\n\n{\n\npublic:\n\nSaveObjectGaurd\n\n(ModelObject& object);\n\n~SaveObjectGaurd\n\n(\n" + } + ] + }, + { + "id": "cb4b7b45", + "cell_type": "code", + "metadata": {}, + "execution_count": 46, + "source": "# Confirmed: BS has separate `load_gcode_3mf_from_stream` for .gcode.3mf files.\n# This function reads gcode + plate config, but does NOT call the full mesh import path.\n# So when our CLI-produced .gcode.3mf \u2014 which DOES contain a mesh \u2014 is opened via this path, the mesh is ignored\n# and only gcode data is loaded. If the gcode loader hits any issue (or rejects the mixed format), the user sees\n# \"no geometry data\".\n# \n# Now I have enough. Let me also confirm the user's recommended fix (File\u2192Open Project on .gcode.3mf): does that\n# actually work? Per Issue #2930 the user did `import into bambu studio as a project file` and the result was\n# \"Needs to be re-sliced\". So File\u2192Open Project on .gcode.3mf opens the geometry but DOES re-slice (loses gcode).\n# That's actually the OPPOSITE of what one wants for the LAN-print workflow.\n# \n# The cleanest fix: produce two files:\n# 1. project .3mf (no slicing) for opening in GUI as project (matches Marcus' workflow)\n# 2. .gcode.3mf (sliced) for sending to printer over LAN/MQTT (no GUI re-import needed)\n# This is exactly what the user already does (slices/t3-prism.H2D.3mf for GUI; .gcode.3mf for printer).\n\n# Now let me address the print-quality / risks question (Question 2).\n# I already computed:\n# - 0.20 mm \u00d7 385 layers = 77 mm \u2713\n# - Bottom triangle plane @ build z 3.5 mm = layer 18 \u2713\n# - Top triangle plane @ build z 73.5 mm = layer 368\n# - Top cable bottom @ build z 72.3 mm = layer 362 \u2190 BRIDGE STARTS\n# - Top cable top @ build z 74.7 mm = layer 374 \u2190 BRIDGE ENDS \n# - 3 horizontal bridges, 43.30 mm \u00d7 2.4 mm each\n# - Top joint sphere bottom @ build z 70.0 mm = layer 350 (where the top \"platform\" begins forming)\n# - Strut/saddle lean: 19.65\u00b0 from vertical (70.35\u00b0 from horizontal) \u2014 well within self-supporting range\n\n# Let me also consider: is the joint sphere lower hemisphere a cantilever? At z = 66.5 (model), only a 1.25-mm-radius\n# strut surface and a 1.25-mm-radius saddle surface are present beneath T_i. The joint sphere lower hemisphere will\n# be deposited on top of these \u2014 but wait, for ALL three angles. Let me check 360\u00b0 around T_i:\n\n# Around T_i at horizontal radius r, what's the closest material at slightly lower z?\n# At z=66.5 model coords, in a horizontal cross-section, what's around T_i?\nimport math\nB = [(0, 25, 0), (-21.65, -12.5, 0), (21.65, -12.5, 0)]\nT = [(-21.65, 12.5, 70), (0, -25, 70), (21.65, 12.5, 70)]\ndef lerp(a, b, t): return tuple(a[k] + t*(b[k]-a[k]) for k in range(3))\n\n# At z=66.5, the strut B0->T0 has center at 1.25 mm from T0, with radius 3.\n# The saddle B1->T0 has center at 1.25 mm from T0, with radius 1.2.\n# \n# At T0 = (-21.65, 12.5, 70):\nT0 = T[0]\n# At z=66.5, what fraction along strut B0->T0? z_normalized = 66.5/70 = 0.95\nt = 66.5/70\nstrut_xy = (B[0][0] + t*(T0[0]-B[0][0]), B[0][1] + t*(T0[1]-B[0][1]))\nsaddle_xy = (B[1][0] + t*(T0[0]-B[1][0]), B[1][1] + t*(T0[1]-B[1][1]))\nprint(f\"At z=66.5 (model), 3.5 mm below T0:\")\nprint(f\" Strut surface center xy: {strut_xy} (radius 3)\")\nprint(f\" Saddle surface center xy: {saddle_xy} (radius 1.2)\")\n# Bearings from T0:\nimport math\ndef bearing(c):\n return math.degrees(math.atan2(c[1]-T0[1], c[0]-T0[0]))\nprint(f\" Strut center bearing from T0: {bearing(strut_xy):.1f}\u00b0\")\nprint(f\" Saddle center bearing from T0: {bearing(saddle_xy):.1f}\u00b0\")\n# The joint sphere at z=66.5 has cross-section radius sqrt(3.5\u00b2 - 3.5\u00b2) = 0 \u2192 just a point at T0\n# At z=67 (3 layers up): joint sphere radius = sqrt(3.5\u00b2-3\u00b2) = 1.80 mm\n# Compare: at z=67, strut+saddle still cover a region around T0, what's the gap?\nfor z in [66.5, 67, 67.5, 68, 69, 70]:\n t = z/70\n s_xy = (B[0][0]+t*(T0[0]-B[0][0]), B[0][1]+t*(T0[1]-B[0][1]))\n sa_xy = (B[1][0]+t*(T0[0]-B[1][0]), B[1][1]+t*(T0[1]-B[1][1]))\n s_dist = math.sqrt((s_xy[0]-T0[0])**2 + (s_xy[1]-T0[1])**2)\n sa_dist = math.sqrt((sa_xy[0]-T0[0])**2 + (sa_xy[1]-T0[1])**2)\n js_radius = math.sqrt(max(0, 3.5**2 - (70-z)**2))\n print(f\"z={z:5.1f}: strut_dist={s_dist:.2f} (r=3), saddle_dist={sa_dist:.2f} (r=1.2), joint_sphere_r={js_radius:.2f}\")\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "At z=66.5 (model), 3.5 mm below T0:\n Strut surface center xy: (-20.5675, 13.125) (radius 3)\n Saddle surface center xy: (-21.65, 11.25) (radius 1.2)\n Strut center bearing from T0: 30.0\u00b0\n Saddle center bearing from T0: -90.0\u00b0\nz= 66.5: strut_dist=1.25 (r=3), saddle_dist=1.25 (r=1.2), joint_sphere_r=0.00\nz= 67.0: strut_dist=1.07 (r=3), saddle_dist=1.07 (r=1.2), joint_sphere_r=1.80\nz= 67.5: strut_dist=0.89 (r=3), saddle_dist=0.89 (r=1.2), joint_sphere_r=2.45\nz= 68.0: strut_dist=0.71 (r=3), saddle_dist=0.71 (r=1.2), joint_sphere_r=2.87\nz= 69.0: strut_dist=0.36 (r=3), saddle_dist=0.36 (r=1.2), joint_sphere_r=3.35\nz= 70.0: strut_dist=0.00 (r=3), saddle_dist=0.00 (r=1.2), joint_sphere_r=3.50\n" + } + ] + }, + { + "id": "14bb2aa3", + "cell_type": "code", + "metadata": {}, + "execution_count": 47, + "source": "# At z=66.5, joint_sphere_r=0 (single point at T0), but strut at distance 1.25 with radius 3 covers a circle from r=0 to r=4.25 around T0.\n# So the joint sphere starts as a point ON the strut surface \u2014 anchored.\n# At z=67, joint sphere has radius 1.80 covering a disk around T0; strut surface center 1.07 with radius 3 \u2192 strut covers disk extending up to 1.07+3=4.07 from T0, i.e. fully covers the joint sphere disk.\n# In fact, every layer where joint_sphere_r < strut_dist + 3, the strut fully supports the joint sphere from below. \u2713\n# So no overhang issue at the top vertex.\n# \n# Final analysis: the BRIDGE (top cable, 43.3 mm \u00d7 2.4 mm) is the only true unsupported feature.\n# The saddles are NOT bridges \u2014 they are leaning columns at 19.65\u00b0 from vertical, anchored to bottom joint and growing upward.\n\n# One more thing: where might a \"cantilever warning\" actually fire?\n# In Bambu Studio, the cantilever warning is for tall thin columns whose tip mass-to-base ratio is high\n# (resonance/tipping risk). It uses the part's \"moment arm\".\n# For the saddles: 70 mm tall, 2.4 mm thick (radius 1.2 mm) \u2192 very thin column. But it's not isolated;\n# it's bonded to the bottom joint and bottom cables. Still, BS may flag it as a long thin \"cantilever-like\" feature.\n# \n# Actually re-reading: \"Bambu Studio is showing a 'cantilever warning' upcoming as the saddle/top features start\"\n# \u2014 this matches the saddles becoming long thin features that BS detects.\n# \n# Saddle becomes a free-standing column once its base layer ends (at z just above bottom triangle, ~layer 24).\n# So the saddle \"cantilever\" condition starts at layer ~30 and remains until layer ~360 when joints fuse.\n# \n# Now let me also note: does the GUI .3mf print have supports? User said \"currently printing without supports\".\n# So Marcus' GUI 3mf is also no-supports. The print is succeeding so far at layer 26.\n\n# Let me write down recommendations now and submit.\nprint(\"Ready to draft answer.\")\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Ready to draft answer.\n" + } + ] + }, + { + "id": "0ad07e0a", + "cell_type": "code", + "metadata": {}, + "execution_count": 48, + "source": "# One more thing - let me look briefly at the slice_info.config to confirm slicer settings\nWD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\nfor f in ['slice_info.config', 'plate_1.json', 'filament_sequence.json']:\n print('===', f, '===')\n with open(f\"{WD}/Metadata/{f}\") as fp:\n print(fp.read())\n print()\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "=== slice_info.config ===\n\n\n
\n \n \n
\n
\n\n\n=== plate_1.json ===\n{\"bbox_all\":[160.65308000000002,136.50243,201.84692,183.49755000000002],\"bbox_objects\":[{\"area\":6.520945072174072,\"bbox\":[160.65308000000002,136.50243,201.84692,183.49755000000002],\"id\":28,\"layer_height\":0.20000000298023224,\"name\":\"t3-prism.stl\"}],\"bed_type\":\"textured_plate\",\"filament_colors\":[\"#00AE42\"],\"filament_ids\":[0],\"first_extruder\":0,\"first_layer_time\":0.0,\"is_seq_print\":false,\"nozzle_diameter\":0.4000000059604645,\"version\":2}\n\n=== filament_sequence.json ===\n{\"plate_1\":{\"nozzle_sequence\":[],\"optimal_assignment\":[],\"sequence\":[]}}\n\n" + } + ] + }, + { + "id": "2465df93", + "cell_type": "code", + "metadata": {}, + "execution_count": 49, + "source": "# Good \u2014 confirms 0.20 mm layer height, 0.4 mm nozzle, PETG green filament, single object.\n# slice_info.config is suspiciously empty \u2014 it lacks the usual entries with layer_count, etc.\n# This MAY actually be the additional reason the GUI rejects it \u2014 the slicer's metadata expected by load_gcode_3mf is incomplete.\n# Let's look at a normal slice_info.config to compare. Search:\nfrom e14c.web import web_search\nres = await web_search('\"slice_info.config\" Bambu .gcode.3mf format example \"\"', num_results=8)\nfor r in res:\n print(' -', r.get('title','')[:80], '::', r.get('url',''))\n print(' ', (r.get('snippet') or '').replace('\\n',' ')[:200])\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": " - printer-setup.md at master \u00b7 ansonl/mfm :: https://github.com/ansonl/mfm/blob/master/printer-setup.md\n 1. Make sure your slicer project has at least 4 distinct filament colors enabled that correspond to the color indices used by MFM. 2. Export your original project as a Plate Sliced 3MF with File > Ex\n - Bambu Lab Slicing Guide | 3DQue Docs :: https://docs.3dque.com/docs/getting-started/slicing-for-auto-ejection/bambu-lab-slicing-guide/\n - Sliced with the \u201cSmooth PEI Plate / High Temp Plate\u201d setting in Bambu Studio. - Disabled the layer fan for the first 3 layers. - Ensured AMS slots in your slicer correspond correctly to the order in\n - bambox v0.1.0 :: https://pypi.org/project/bambox/\n **bambox** (this project) owns two things: (1) the `.gcode.3mf` archive format that Bambu firmware requires, and (2) the slicer settings blob (`project_settings.config`) that would normally come from \n - src/libslic3r/Format/bbs_3mf.hpp at fda63da8 \u00b7 bambulab/BambuStudio :: https://github.com/bambulab/BambuStudio/blob/fda63da8/src/libslic3r/Format/bbs_3mf.hpp\n #define GCODE_FILE_FORMAT \"Metadata/plate_%1%.gcode\" #define THUMBNAIL_FILE_FORMAT \"Metadata/plate_%1%.png\" #define NO_LIGHT_THUMBNAIL_FILE_FORMAT \"Metadata/plate_no_light_%1%\n - estampo/bambox :: https://github.com/estampo/bambox\n Package plain G-code into Bambu Lab .gcode.3mf files [...] Package plain G-code into Bambu Lab `.gcode.3mf` files \u2014 no OrcaSlicer required. [...] bambox is a **Bambu Lab packaging layer**. It takes G-\n - bambuuzle v0.1.0 :: https://pypi.org/project/bambuuzle/\n Bambu Lab printers use `.gcode.3mf` files \u2014 which are just ZIP archives containing G-code, metadata, thumbnails, and MD5 checksums. **bambuuzle** lets you: [...] - **Extract** plate G-code for editing\n - Server-Side Slicing - Bambuddy Documentation :: https://wiki.bambuddy.cool/features/slicer-api/\n s the modal shows a plate picker first; pick the plate you want to slice, then the preset dropdowns appear for that plate's filament needs. [...] ### How Bambuddy knows the per-plate filament list\u00b6 [.\n - Slicers | 3DQue Docs :: https://docs.3dque.com/docs/getting-started/compatibility/slicers/\n AutoFarm3D accepts`.gcode.3mf` files for Bambu Lab printers and`.gcode` files for all other compatible printers. See a list of compatible slicers below. [...] ## For Bambu Printers (.gcode.3mf format)\n" + } + ] + }, + { + "id": "8a30f8fb", + "cell_type": "code", + "metadata": {}, + "execution_count": 50, + "source": "# The \"bambox\" package is interesting - it specifically packages G-code into .gcode.3mf. Let me check what it\n# expects in slice_info.config.\nfrom e14c.web import fetch_parsed_page\ncontent_bambox = await fetch_parsed_page(\"https://github.com/estampo/bambox\")\nimport re\nm = re.search(r'(?s)(slice_info[\\s\\S]{0,2000})', content_bambox)\nif m: print(m.group()[:2500])\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "slice_info=info)\n```\n\n### With generated settings (no OrcaSlicer)\n\n```\nfrombambox.settingsimportbuild_project_settingssettings=build_project_settings(\n filaments=[\"PETG-CF\"],\n machine=\"p1s\",\n filament_colors=[\"2850E0FF\"],\n overrides={\"layer_height\": \"0.2\"},\n)\n\npack_gcode_3mf(\n gcode,\n Path(\"output.gcode.3mf\"),\n slice_info=info,\n project_settings=settings,\n)\n```\n\n### Archive validation\n\n```\nfrombambox.validateimportvalidate_3mf\n```\n\n## Modules\n\n| Module | Purpose |\n| ------------ | -------------------- |\n| pack | Core.gcode.3mfarchiv |\n| settings | 544-keyproject_setti |\n| validate | Archive validation c |\n| cli | Typer CLI commands \u2014 |\n| cura | CuraEngine Docker in |\n| templates | OrcaSlicer-to-Jinja2 |\n| assemble | G-code component ass |\n| thumbnail | G-code-to-PNG render |\n| toolpath | Synthetic toolpath g |\n| gcode_compat | G-code rewriting for |\n| ui | Rich console formatt |\n\n## BBL `.gcode.3mf` Format\n\nA `.gcode.3mf` is a ZIP archive containing 13-17 files:\n\n| File | Purpose |\n| -------------------- | -------------------- |\n| Metadata/plate_1.gco | The actual G-code |\n| Metadata/slice_info. | Print metadata (time |\n| Metadata/project_set | Full slicer settings |\n| Metadata/model_setti | Per-plate filament m |\n| Metadata/plate_1.png | Thumbnail (required |\n| 3D/3dmodel.model | OPC/3MF model XML |\n| [Content_Types].xml | OPC content types |\n| _rels/.rels | OPC relationships |\n\nBambuStudio adds: `cut_information.xml`, `filament_sequence.json`, `top_N.png`, `pick_N.png`.\n\nAll files include MD5 checksums validated by the printer firmware.\n\nSee [`docs/gcode-3mf-format.md`](https://github.com/estampo/bambox/blob/main/docs/gcode-3mf-format.md) for the full format\nspecification.\n\n## Development\n\n```\nuv sync --extra dev\nuv run ruff check src tests\nuv run ruff format --check src tests\nuv run mypy src/bambox\nuv run pytest\n```\n\n## Credits and attribution\n" + } + ] + }, + { + "id": "fb9e9a73", + "cell_type": "code", + "metadata": {}, + "execution_count": 51, + "source": "# Check the spec for slice_info.config \u2014 it's \"print metadata (time, etc)\". Our file has only headers, no plate data.\n# Let's view the full spec.\ncontent_spec = await fetch_parsed_page(\"https://github.com/estampo/bambox/blob/main/docs/gcode-3mf-format.md\")\nimport re\nm = re.search(r'(?s)(slice_info[\\s\\S]{0,3000})', content_spec)\nif m: print(m.group()[:4000])\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "slice_info.config` is missing several keys that BambuStudio 02.05\nalways emits. The complete working structure:\n\n```\n\n\n
\n \n \n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n```\n\nKeys OrcaSlicer omits that must be added:\n\n* `X-BBL-Client-Version` \u2014 OrcaSlicer leaves this blank; set to `02.05.00.66`\n* `extruder_type` \u2014 must appear **before**`printer_model_id`\n* `nozzle_volume_type` \u2014 must appear **before**`printer_model_id`\n* `limit_filament_maps` \u2014 space-separated zeros, one per AMS slot\n\nOrcaSlicer also emits `weight=\"\"` (empty string) when `filament_density = 0` in\nthe profile. Bambu Connect ignores the weight value, but a blank value causes\nparse errors in some tooling. Compute it from the G-code footer:\n\n```\n; filament used [g] = 8.51 \u2190 use this if present\n; filament used [cm3] = 6.86 \u2190 multiply by filament density as fallback\n```\n\n### Fix 4: Thumbnails (Optional but Recommended)\n\nThe CLI can't render thumbnails in headless mode. Without them, Bambu Connect\nshows a blank/broken image. Adding placeholder PNGs at `Metadata/plate_1.png` and `Metadata/plate_1_small.png` gives a cleaner appearance.\n\nReal thumbnails can be generated from the G-code toolpath using matplotlib or\nsimilar.\n\n## Complete Archive Structure\n\nA valid `.gcode.3mf` for Bambu Connect contains:\n\n```\nplate_sliced.gcode.3mf\n [Content_Types].xml -- Standard OPC content types\n _rels/.rels -- Relationship to 3dmodel.model\n 3D/3dmodel.model -- Empty model (no mesh data)\n Metadata/plate_1.gcode -- The actual gcode\n Metadata/plate_1.gcode.md5 -- MD5 hex digest of gcode (uppercase hex)\n Metadata/model_settings.config -- Plate config XML (with all required keys)\n Metadata/_rels/model_settings.config.rels -- Links gcode to plate\n Metadata/slice_info.config -- Print time, weight, filament info\n Metadata/project_settings.config -- Full slicer settings JSON (~553 keys)\n Metadata/plate_1.json -- Plate bounding box / layout data\n Metadata/plate_1.png -- Thumbnail (optional, but refs required)\n Metadata/plate_1_small.png -- Small thumbnail (optional)\n```\n\n### What Doesn't Matter\n\nThrough testing, we confi\n" + } + ] + }, + { + "id": "b0897c00", + "cell_type": "code", + "metadata": {}, + "execution_count": 52, + "source": "# EXCELLENT \u2014 third-party reverse-engineering says:\n# - 3D/3dmodel.model is EMPTY in a typical .gcode.3mf (no mesh data)\n# - The slice_info.config must contain a complete block with metadata keys\n# \n# Our file is unusual: 3D/Objects/object_1.model HAS the full mesh, AND slice_info.config has NO block.\n# So it's a hybrid file that doesn't match either format BS expects:\n# - Doesn't match the \"stripped print-file\" format (it has geometry it shouldn't)\n# - Doesn't match the \"project file\" format (slice_info.config is missing the metadata)\n# \n# BS's load_gcode_3mf path expects the print-file format and gets confused by the geometry presence + missing plate metadata.\n# That's another (more specific) reason for the failure.\n\n# Check the project_settings.config briefly to see if filament map mode setting was applied\nWD = '/workspace/25c1c897-6d66-462f-bfdb-7df05ff12d9b'\nimport json\nwith open(f\"{WD}/Metadata/project_settings.config\") as f:\n txt = f.read()\n# project_settings is JSON\ntry:\n cfg = json.loads(txt)\n print(\"Keys (sample):\", list(cfg.keys())[:30])\n for k in ['filament_map_mode', 'filament_map', 'sparse_infill_density', 'sparse_infill_pattern', 'wall_loops', 'layer_height', 'nozzle_diameter', 'support_type', 'enable_support']:\n if k in cfg:\n print(f\" {k} = {cfg[k]}\")\nexcept Exception as e:\n print(\"Not valid JSON:\", e)\n print(txt[:500])\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Keys (sample): ['accel_to_decel_enable', 'accel_to_decel_factor', 'activate_air_filtration', 'additional_cooling_fan_speed', 'additional_fan_full_speed_layer', 'apply_scarf_seam_on_circles', 'auxiliary_fan', 'avoid_crossing_wall_includes_support', 'bed_custom_model', 'bed_custom_texture', 'bed_exclude_area', 'bed_temperature_formula', 'before_layer_change_gcode', 'best_object_pos', 'bottom_color_penetration_layers', 'bottom_shell_layers', 'bottom_shell_thickness', 'bottom_surface_density', 'bottom_surface_pattern', 'bridge_angle', 'bridge_flow', 'bridge_no_support', 'bridge_speed', 'brim_object_gap', 'brim_type', 'brim_width', 'chamber_temperatures', 'change_filament_gcode', 'circle_compensation_manual_offset', 'circle_compensation_speed']\n filament_map_mode = Auto For Flush\n filament_map = ['1']\n sparse_infill_density = 15%\n sparse_infill_pattern = grid\n wall_loops = 2\n layer_height = 0.2\n nozzle_diameter = ['0.4', '0.4']\n support_type = tree(auto)\n enable_support = 0\n" + } + ] + }, + { + "id": "4b2c75d7", + "cell_type": "code", + "metadata": {}, + "execution_count": 53, + "source": "# IMPORTANT NEW FINDINGS!\n# The user said: \"0.20 mm layer height, 25% gyroid infill, 3 walls\"\n# But the actual settings in the CLI 3mf are:\n# layer_height = 0.2 \u2713\n# sparse_infill_density = 15% (NOT 25%) \u274c\n# sparse_infill_pattern = grid (NOT gyroid) \u274c\n# wall_loops = 2 (NOT 3) \u274c\n# enable_support = 0 (no supports) \u2713\n# filament_map_mode = \"Auto For Flush\" (NOT \"Manual\" \u2014 but user passed --filament-map-mode Manual)\n#\n# This is a different print profile than the user thinks they're getting via CLI!\n# \n# But wait \u2014 the print currently running is Marcus' GUI 3mf (cad/t3-prism/t3-prism.3mf), not the CLI 3mf.\n# The user's question 2 specifies \"0.20 mm layer height, 25% gyroid infill, 3 walls\" as HIS settings \u2014 those are the\n# GUI print's settings (which we don't have direct evidence of).\n# \n# So my layer-by-layer analysis still applies (geometry-based) but I should note that the CLI 3mf has weaker walls\n# (2 vs 3) and lower infill (15% vs 25%). For the 3 mm strut, more walls is more important than infill in this\n# geometry \u2014 at 2 walls \u00d7 0.4 mm = 0.8 mm wall thickness on a 6 mm strut, infill matters; at 3 walls \u00d7 0.42 mm = 1.26 mm\n# wall, infill matters less. For 2.4 mm cables, 2 walls \u00d7 0.4 = 0.8 mm \u2248 entire cross-section is wall (radius 1.2 mm).\n# So the cables are essentially solid PETG either way \u2014 good.\n\n# Now let me examine support_type a bit more\nprint(\"CLI 3mf settings vs user-described GUI 3mf settings:\")\nprint(\" layer_height: 0.2 \u2713\")\nprint(\" walls: 2 (CLI) vs 3 (GUI per user)\")\nprint(\" infill: 15% grid (CLI) vs 25% gyroid (user)\")\nprint(\" supports: none (both)\")\nprint()\nprint(\"These differences DON'T affect the geometry-based risk analysis.\")\nprint(\"But they DO affect strength: GUI's 3-wall + 25% gyroid is stronger than CLI's 2-wall + 15% grid.\")\nprint(\"For the in-progress GUI print, the analysis is conservative if anything.\")\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "CLI 3mf settings vs user-described GUI 3mf settings:\n layer_height: 0.2 \u2713\n walls: 2 (CLI) vs 3 (GUI per user)\n infill: 15% grid (CLI) vs 25% gyroid (user)\n supports: none (both)\n\nThese differences DON'T affect the geometry-based risk analysis.\nBut they DO affect strength: GUI's 3-wall + 25% gyroid is stronger than CLI's 2-wall + 15% grid.\nFor the in-progress GUI print, the analysis is conservative if anything.\n" + } + ] + }, + { + "id": "32b30af3", + "cell_type": "code", + "metadata": {}, + "execution_count": 54, + "source": "# Let me also confirm filament_map_mode vs filament_map override:\n# The user said --filament-map-mode Manual --filament-map 1 was passed to CLI.\n# But the settings show filament_map_mode = \"Auto For Flush\". Strange \u2014 the CLI flag may have been ignored\n# or the project_settings.config retains the original value while the actual mode is overridden at slice time.\n# \n# This is NOT central to the user's question, but worth flagging in case they're debugging.\n\n# OK now I have a thorough analysis. Time to write the answer.\nprint(\"Drafting final answer...\")\n", + "outputs": [ + { + "output_type": "stream", + "name": "stdout", + "text": "Drafting final answer...\n" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/edison-trajectories/2026-05-08-t3-prism-bambu-import-25c1c897.md b/edison-trajectories/2026-05-08-t3-prism-bambu-import-25c1c897.md new file mode 100644 index 00000000..58b5ce5a --- /dev/null +++ b/edison-trajectories/2026-05-08-t3-prism-bambu-import-25c1c897.md @@ -0,0 +1,119 @@ +# T3-prism Bambu H2D — `.gcode.3mf` import + cantilever risk (Edison ANALYSIS) + +- task_id: `25c1c897-6d66-462f-bfdb-7df05ff12d9b` +- job_name: `job-futurehouse-data-analysis-crow-high` +- status: `success` +- created_at: `2026-05-08T18:22:37.440150Z` + +## Query + +We are preparing a Bambu Lab H2D PETG print of a T3-prism (3-strut +tensegrity, single-piece, pure PETG, no supports) and producing the +print-prep artifacts headlessly with the official BambuStudio +v02.06.00.51 Linux AppImage CLI under xvfb-run + software GL, following +the recipe verified in vertical-cloud-lab/powder-doser PR #23. + +Two questions, please address both: + +1. **Bambu Studio import error on the sliced `.gcode.3mf`.** When we + try to drag/import `slices/t3-prism.H2D-PETG.gcode.3mf` (attached; + produced by `bambu-studio --slice 1 --export-3mf` with + `--filament-map-mode Manual --filament-map 1`), Bambu Studio shows + "The file does not contain any geometry data" + "Loading of a model file failed" + The hand-made project `cad/t3-prism/t3-prism.3mf` (also attached, + uploaded by Marcus from Bambu Studio GUI; currently printing + without supports on the H2D) opens fine. Diff'ing the two zips: + the GUI one has thumbnails (`Metadata/plate_*.png`) and NO + `Metadata/plate_1.gcode`; the CLI one has `Metadata/plate_1.gcode` + (the actual print job, ~3.97 MB) and NO thumbnails. The 3D model + parts (`3D/3dmodel.model`, `3D/Objects/object_1.model`) are + structurally identical (same UUIDs, same `` ref). + - Confirm or refute our hypothesis: Bambu Studio's drag/import + code path treats `.gcode.3mf` as a printer-side artifact and + refuses to re-import it as a model — and the correct workflow + is to use *File → Open Project* (which still works on + `.gcode.3mf`), or to load a separate project `.3mf` produced + without `--slice` (we now also generate + `slices/t3-prism.H2D.3mf` — attached — for that purpose). + - Are there other plausible causes (corrupted `object_1.model`, + missing `[Content_Types].xml` entry, missing thumbnails being + load-blocking, etc.)? The CLI ran without GL (`glfwInit return + error, code 65544` — Wayland not available) so thumbnails were + never rendered; could that be the trigger? + - Cite Bambu Lab community-forum / GitHub-issues threads if you + find any that describe the same symptom and resolution. + +2. **Print quality / risks for the in-progress H2D print.** The + print is currently at ~13% (layer 26 / 385) on the H2D, no + supports. The geometry has three angled struts (~70 mm tall, + 60° twist) and 9 thin (2.4 mm) cables — most of the cables are + either at the bottom triangle (printed first), the top triangle + (printed last as a small bridge), or saddles connecting the two + (steep diagonals). Bambu Studio is showing a "cantilever + warning" upcoming as the saddle/top features start. + - Given 0.20 mm layer height, 25% gyroid infill, 3 walls, + auto-orient (the part was placed flat), what is the highest- + risk feature and at what layer (rough estimate) do we expect + it? The total height is ~77 mm. + - Is the top triangle a true bridge or a free cantilever + (i.e., are the 60°-rotated saddles long enough to count as + supports for the top cables)? + - Recommendations to maximize success on the current print AND + for the next iteration (orientation, layer height, support + strategy, cable diameter, joint sphere diameter, etc.) — + remembering the constraint is single-piece, single-material + PETG, no manual support removal between materials. + +Please be specific, cite forum threads / docs / papers where you +can, and reference the attached files explicitly when relevant +(filenames are in their attachment metadata). + + +## Answer + +Here are the answers to both questions based on the part geometry, slicer mechanics, and Bambu Studio (BS) internal behavior. + +### 1. Bambu Studio import error on the sliced `.gcode.3mf` + +**Your hypothesis is correct:** Bambu Studio's drag-and-drop / normal "Open Project" code paths actively discriminate based on the `.gcode.3mf` extension, treating it as a **printer-bound artifact** rather than an editable project. + +The "no geometry data" error is a direct consequence of this branched logic, but the error message itself is highly misleading in your specific CLI context. Here is exactly what is happening: + +* **Bambu Studio's dual file formats:** A standard BS project `.3mf` contains CAD geometry (the mesh in `3D/3dmodel.model`). However, when BS exports a sliced plate via the GUI (*Export plate sliced file*), it creates a `.gcode.3mf` that strips out the heavy mesh data entirely to save space for LAN/SD-card transfer, leaving only the raw G-code, settings, and thumbnails. +* **The CLI hybrid:** When you run `bambu-studio --slice 1 --export-3mf` and name the output `*.gcode.3mf` (or if the CLI auto-appends it), the CLI actually produces a "hybrid" file. Unlike the GUI export, it *keeps* the full mesh (your `object_1.model` is intact at ~1 MB with 6,526 vertices) *and* adds the G-code. +* **The import failure:** When you drag a `.gcode.3mf` into BS, the internal `load_gcode_3mf_from_stream` function takes over. It expects the GUI-style "stripped" format and specifically looks for print metadata in `Metadata/slice_info.config` (e.g., a `` block with weight, time, and filament mapping). Because the CLI `slice_info.config` is missing this block, the G-code loader aborts. The loader never even attempts to read the `3D/3dmodel.model` mesh, resulting in the fallback error: "The file does not contain any geometry data." + +**Refuting the thumbnail hypothesis:** The lack of a Wayland/GL display (`glfwInit error 65544`) did *not* block thumbnail generation. Your `xvfb-run` + software GL setup worked: the `Metadata/plate_1.png` inside your CLI `.gcode.3mf` is a valid 19.7 KB render of the prism. Thumbnails are not the cause of the import failure. + +**Resolution / Workflow:** +Your current two-file workaround is the correct, intended workflow: +1. Generate `slices/t3-prism.H2D.3mf` (without `--slice`) to act as your editable project file for GUI work. +2. Generate `slices/t3-prism.H2D-PETG.gcode.3mf` (with `--slice`) strictly to be pushed headlessly to the printer via MQTT/FTP. Do not attempt to re-import this file into BS. + +*(For related community documentation of this behavior, see GitHub Issues [BambuStudio #1479](https://github.com/bambulab/BambuStudio/issues/1479) where a maintainer explicitly states `.gcode.3mf` is a "print file for individual plate" and not a project file, and [#2930](https://github.com/bambulab/BambuStudio/issues/2930) documenting CLI-generated 3MF re-import issues).* + +*** + +### 2. Print quality / risks for the in-progress H2D print + +The T3-prism geometry is highly optimized for FDM, but it does contain one severe overhang risk. At 13% (layer 26 of 385), you have successfully cleared the bottom cables (which act as a ~2.3 mm-high bridge over the bed) and are currently building the lower halves of the struts and saddles. + +**The highest-risk feature:** +The point of failure will not be the cantilevered saddles or the top joint spheres; **it will be the first few layers of the three top cables.** These begin printing at roughly **layer 362** (Z ≈ 72.3 mm in build coordinates). + +**Is the top triangle a true bridge or a free cantilever?** +It is a **true bridge**. +* The struts ($B_i \to T_i$) and saddles ($B_{i+1} \to T_i$) both arrive at the top vertices ($T_i$) by layer 368. +* Because the top joint spheres start forming at layer 350 (growing outward as a dome supported perfectly by the strut beneath it), the $T_i$ anchor points are fully solid and established by the time the top cable starts. +* However, the top cable runs *horizontally* between two top joints ($T_i \to T_{i+1}$) across a span of **43.30 mm**. + +**Why it's high risk:** +Because the cable is a 2.4 mm horizontal cylinder, the very first layer of that 43.3 mm bridge is not the full 2.4 mm width—it is a tiny "sliver" chord of the cylinder's bottom edge, only about ~0.96 mm wide (roughly two perimeters). Suspending a <1 mm wide strand of PETG across a 43 mm gap with no underlying mass is notoriously difficult. If that first strand sags or snaps, the subsequent layers of the cylinder will have nothing to adhere to, leading to a "spaghetti" failure of the top tension ring. + +**Recommendations for the next iteration (single-piece, no supports):** + +1. **Bridge tuning (Current orientation):** If you keep the flat orientation, you must ensure your bridge settings are dialed in for PETG. High cooling (100% fan) and specific bridge speeds are mandatory for that 43 mm span. +2. **Increase cable diameter:** Bumping the `cable_d` from 2.4 mm to 3.0 mm or 4.0 mm will dramatically increase the width of that critical first bridge layer, giving the slicer more perimeters to anchor across the gap. +3. **Change orientation (Best mechanical fix):** Flat orientation forces the top cables to be 43 mm bridges and the struts to be columns. If you orient the prism so that **one of the struts lies perfectly flat on the build plate**, you eliminate the long 43 mm bridges entirely. The cables will instead print at steep diagonals (roughly 30°–60°), which modern slicers handle easily as self-supporting overhangs. This also aligns the layer lines *along* the cables rather than perpendicular to them, vastly increasing the tensile strength of the PETG tension members. +4. **Slicer settings discrepancy:** Note that your CLI-generated `.gcode.3mf` used 2 walls and 15% grid infill, whereas your GUI print is using 3 walls and 25% gyroid. Your GUI settings are much stronger, but ensure your headless pipeline is actually applying the overrides you expect.