From ecb0ddb49b9fc5b3993b9c8b581c0e7e286d3ffd Mon Sep 17 00:00:00 2001 From: Matt Voska Date: Sun, 11 Oct 2026 17:47:34 -0300 Subject: [PATCH] fix(slice): reject slicer warnings in strict mode --- CHANGELOG.md | 1 + SPEC.md | 4 +- docs/operator-migration.md | 3 +- internal/cmd/slice.go | 4 +- internal/slicer/parity_test.go | 75 +++++++++++++++++++++++++++++++++- internal/slicer/slice.go | 48 +++++++++++++--------- 6 files changed, 110 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e5e10..e66cb8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ JSON field names and exit codes are part of the public API. - `preflight` `single_filament` now fails only when the number of `--slot`s differs from the number of filaments in the plate. Single-filament jobs send the same payload as before. ### Fixed +- `slice --strict` rejects slicer warnings, including floating regions, with exit 11 before publishing new output files. JSON errors include the warnings and slicer log path; slicing without `--strict` still reports warnings in the summary. - `slice --set` now preserves JSON list values, including commas inside quoted entries; comma-separated batches and repeated overrides remain supported. - `print pause` and `print stop` now verify PAUSE or an idle state before exiting 0; `--wait` defaults to 30s and must be positive. - A failed retry cannot reuse a prior successful slice. Every slice verifies its requested filament changes, including an empty change list. diff --git a/SPEC.md b/SPEC.md index f9b245f..7eb776f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -230,7 +230,9 @@ All recipes use `brim_type = no_brim`. **Brims are opt-in** (`--set brim_type=ou `preflight` and `print send` accept `--strict`, retaining the Python operator's gates while keeping the existing Go default: material variants must match exactly, temperature bounds must both be finite and valid, and unknown nozzle material produces a WARN rather than an assumed match. `preflight.strict` records the selected mode. `slice --strict` -requires an extracted plate preview before publishing the new 3MF. All slices reject unexpected tool changes and stale +requires an extracted plate preview and rejects slicer warnings (including floating regions) with exit 11 before +publishing any new slice outputs. The JSON error includes the warning text and slicer log path. Without `--strict`, +warnings remain in the successful slice summary. All slices reject unexpected tool changes and stale output from a previous invocation. | gate | FAIL when | WARN when | diff --git a/docs/operator-migration.md b/docs/operator-migration.md index 09e270f..cc42ff7 100644 --- a/docs/operator-migration.md +++ b/docs/operator-migration.md @@ -31,7 +31,8 @@ remain in place. A multi-filament `slice --slot` records one suggested slot; act Use `--strict` for operator slice/preflight/send. Default Go gate behavior stays compatible, including same-family material warnings and omitted temperature bounds. Strict mode fails unequal material types and unknown or malformed -temperature bounds. A different product/profile remains a WARN in both tools. The strict slice requires a preview. +temperature bounds. A different product/profile remains a WARN in both tools. The strict slice requires a preview +and rejects slicer warnings with exit 11 before publishing new outputs. Go's slice JSON is the summary directly, without Python's `slice` wrapper. It includes `objects[].footprint_incl_brim_mm`, the first plate's dimensions, and `suggested_slot`. `status` keeps a `status` wrapper diff --git a/internal/cmd/slice.go b/internal/cmd/slice.go index 808d9e4..a440538 100644 --- a/internal/cmd/slice.go +++ b/internal/cmd/slice.go @@ -27,7 +27,7 @@ type SliceCmd struct { Out string `short:"o" help:"Output directory (default: config output_dir, else current dir)." type:"path"` AutoOrient bool `name:"auto-orient" help:"Let the slicer re-orient the model (default: keep the model's orientation)."` Slot string `help:"Record the intended AMS slot in the summary; send still requires --slot."` - Strict bool `help:"Require a plate preview as part of the slice evidence."` + Strict bool `help:"Require a plate preview and reject slicer warnings before publishing outputs."` } // Run executes the command. @@ -82,7 +82,7 @@ func (c *SliceCmd) Run(g *Globals) error { Model: c.Model, Recipe: r, MachinePreset: m.MachinePreset(p.NozzleString()), Plate: p.Plate, Filaments: c.Filament, Colors: c.Color, ChangeZs: c.ChangeZ, Sets: c.Set, Name: name, OutDir: out, WorkDir: filepath.Join(cache, "bambu", "slice", name), AutoOrient: c.AutoOrient, - StepPython: expand(cfg.Slicer.StepPython), SuggestedSlot: c.Slot, RequirePreview: c.Strict, + StepPython: expand(cfg.Slicer.StepPython), SuggestedSlot: c.Slot, Strict: c.Strict, }) if err != nil { return err diff --git a/internal/slicer/parity_test.go b/internal/slicer/parity_test.go index d081f93..11df7ad 100644 --- a/internal/slicer/parity_test.go +++ b/internal/slicer/parity_test.go @@ -9,9 +9,11 @@ import ( "path/filepath" "reflect" "runtime" + "strings" "testing" "github.com/voska/bambu/internal/errfmt" + "github.com/voska/bambu/internal/testutil" ) func TestRunDoesNotReuseStaleSlice(t *testing.T) { @@ -84,7 +86,7 @@ func TestStrictSliceNeedsPreview(t *testing.T) { t.Skip("shell fake") } _, rq, err := twoFilamentRun(t, func(r *Request) { - r.RequirePreview = true + r.Strict = true zr, err := zip.OpenReader(os.Getenv("FAKE_3MF")) if err != nil { t.Fatal(err) @@ -126,6 +128,77 @@ func TestStrictSliceNeedsPreview(t *testing.T) { } } +func TestStrictSliceWarnings(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fake") + } + const floating = "It seems object CadQuery Component has floating regions. Please re-orient the object or enable support generation." + for _, tc := range []struct { + name string + strict bool + warning string + }{ + {"strict floating regions", true, floating}, + {"strict other warning", true, "Unsupported regions detected."}, + {"strict clean slice", true, ""}, + {"default floating regions", false, floating}, + } { + t.Run(tc.name, func(t *testing.T) { + sum, rq, err := twoFilamentRun(t, func(r *Request) { + r.Strict = tc.strict + r.Filaments, r.Colors, r.ChangeZs = nil, nil, nil + t.Setenv("FAKE_3MF", testutil.Write(t, t.TempDir(), "prepared", testutil.Opts{})) + b, err := os.ReadFile(os.Getenv("FAKE_RESULT")) + if err != nil { + t.Fatal(err) + } + var res map[string]any + if err := json.Unmarshal(b, &res); err != nil { + t.Fatal(err) + } + res["sliced_plates"].([]any)[0].(map[string]any)["warning_message"] = tc.warning + b, err = json.Marshal(res) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "warning-result.json") + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_RESULT", path) + }) + if tc.strict && tc.warning != "" { + if err == nil { + t.Fatal("strict slice accepted a slicer warning") + } + e := errfmt.As(err) + if e.Code != errfmt.ExitSliceFailed || !strings.Contains(e.Message, tc.warning) || + !reflect.DeepEqual(e.Data["warnings"], []string{tc.warning}) || e.Data["log"] == "" { + t.Fatalf("warning must be reported as a structured slice failure: %+v", e) + } + if sum != nil { + t.Fatal("rejected slice returned a success summary") + } + entries, err := os.ReadDir(rq.OutDir) + if err != nil || len(entries) != 0 { + t.Fatalf("rejected slice must not publish any output: %v %v", entries, err) + } + return + } + if err != nil { + t.Fatal(err) + } + want := []string{} + if tc.warning != "" { + want = append(want, tc.warning) + } + if !reflect.DeepEqual(sum.Warnings, want) || !exists(sum.Gcode3MF) || !exists(sum.SummaryJSON) { + t.Fatalf("successful slice must preserve warnings and publish outputs: %+v", sum) + } + }) + } +} + func TestSTEPConversion(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell fake") diff --git a/internal/slicer/slice.go b/internal/slicer/slice.go index 5253878..eef55cc 100644 --- a/internal/slicer/slice.go +++ b/internal/slicer/slice.go @@ -42,21 +42,21 @@ var CLIErrors = map[int]string{ // Request is one slice job. type Request struct { - Model string - Recipe recipe.Recipe - MachinePreset string // e.g. "Bambu Lab X1 Carbon 0.4 nozzle" - Plate string // installed plate id, e.g. "textured_plate" - Filaments []string // filament presets in print order (default: the recipe's) - Colors []string // spool colour per filament (RRGGBB); Studio sizes the purge between filaments from them - ChangeZs []float64 // filament n+1 starts on the first layer above ChangeZs[n-1] - Sets []string // key=value overrides - Name string // output base name - OutDir string // where the .gcode.3mf/.png/.summary.json go - WorkDir string // scratch dir for configs and slicer output - AutoOrient bool - StepPython string // interpreter with CadQuery for STEP input (default python3) - SuggestedSlot string - RequirePreview bool + Model string + Recipe recipe.Recipe + MachinePreset string // e.g. "Bambu Lab X1 Carbon 0.4 nozzle" + Plate string // installed plate id, e.g. "textured_plate" + Filaments []string // filament presets in print order (default: the recipe's) + Colors []string // spool colour per filament (RRGGBB); Studio sizes the purge between filaments from them + ChangeZs []float64 // filament n+1 starts on the first layer above ChangeZs[n-1] + Sets []string // key=value overrides + Name string // output base name + OutDir string // where the .gcode.3mf/.png/.summary.json go + WorkDir string // scratch dir for configs and slicer output + AutoOrient bool + StepPython string // interpreter with CadQuery for STEP input (default python3) + SuggestedSlot string + Strict bool // require a preview and reject slicer warnings before publishing outputs } // Summary is the slice result (the --json contract). @@ -498,12 +498,23 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err WithHint("%s; fix the recipe/--set values; log: %s", or(CLIErrors[res.ReturnCode], "see log"), logPath). WithData("return_code", res.ReturnCode).WithData("details", details).WithData("log", logPath) } + warnings := []string{} + for _, p := range res.SlicedPlates { + if p.WarningMessage != "" { + warnings = append(warnings, p.WarningMessage) + } + } + if req.Strict && len(warnings) > 0 { + return nil, errfmt.New(errfmt.ExitSliceFailed, "strict slice rejected slicer warnings: %s", strings.Join(warnings, "; ")). + WithHint("no new slice outputs were published; resolve the warnings and inspect the G-code before printing; log: %s", logPath). + WithData("warnings", warnings).WithData("log", logPath) + } if err := checkChanges(filepath.Join(work, threemf), tops, logPath); err != nil { return nil, err } png := filepath.Join(out, name+".png") if err := extract(filepath.Join(work, threemf), "Metadata/plate_1.png", png); err != nil { - if req.RequirePreview { + if req.Strict { return nil, errfmt.Wrap(errfmt.ExitSliceFailed, err, "slicer did not produce a plate preview") } png = "" @@ -522,7 +533,7 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err TimeS: j.PredictionS, Time: j.TotalTime, WeightG: j.WeightG, Layers: j.Layers, Filaments: j.Filaments, FilamentChanges: j.FilamentChanges, FilamentPresets: cfgs.FilamentPresets, BedType: j.BedType, BedTemp: j.BedTemp, NozzleTemp: j.NozzleTemp, NozzleFirst: j.NozzleTempFirst, - Presets: cfgs.Presets, Overrides: cfgs.Applied, Notes: cfgs.Notes, Warnings: []string{}, Settings: j.Settings, + Presets: cfgs.Presets, Overrides: cfgs.Applied, Notes: cfgs.Notes, Warnings: warnings, Settings: j.Settings, AutoOrient: req.AutoOrient, SlicerLog: logPath, StudioVersion: st.Version, SuggestedSlot: req.SuggestedSlot, Objects: []Object{}, } @@ -538,9 +549,6 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err } } } - if p.WarningMessage != "" { - sum.Warnings = append(sum.Warnings, p.WarningMessage) - } sum.PrimeTower = sum.PrimeTower || p.FeatureTypeTimes["Prime tower"] > 0 for _, u := range p.Filaments { for i := range sum.Filaments {