diff --git a/CHANGELOG.md b/CHANGELOG.md index 000388a..a3d55db 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 --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. - Continuous monitoring preserves FAILED/PAUSE outcome codes on timeout and reports final state on interruption. Footprints correspond to plate 1, matching the inspected job and preview. diff --git a/internal/cmd/settings_test.go b/internal/cmd/settings_test.go new file mode 100644 index 0000000..3928169 --- /dev/null +++ b/internal/cmd/settings_test.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "io" + "reflect" + "testing" + + "github.com/alecthomas/kong" + + "github.com/voska/bambu/internal/recipe" + "github.com/voska/bambu/internal/slicer" +) + +func settingConfigs(t *testing.T, sets []string) *slicer.Configs { + t.Helper() + var cli CLI + parser, err := kong.New(&cli, kong.Writers(io.Discard, io.Discard), kong.Vars{"models": "X1C"}) + if err != nil { + t.Fatal(err) + } + args := []string{"slice", "part.stl", "--recipe", "prototype-pla"} + for _, value := range sets { + args = append(args, "--set", value) + } + if _, err := parser.Parse(args); err != nil { + t.Fatal(err) + } + ix, err := slicer.LoadIndex("../slicer/testdata/resources") + if err != nil { + t.Fatal(err) + } + cfg, err := slicer.Build(ix, slicer.Request{Recipe: recipe.Recipe{Process: "0.20mm Standard", Filament: "Bambu PLA Basic"}, MachinePreset: "Test Printer 0.4 nozzle", Plate: "textured_plate", Sets: cli.Slice.Set}) + if err != nil { + t.Fatalf("settings %v: %v", sets, err) + } + return cfg +} + +func TestSliceSettingLegacyEscapesAndTrailingSeparator(t *testing.T) { + for _, sets := range [][]string{{`machine_start_gcode=G28 ; first\,second,wall_loops=4`}, {"wall_loops=4,"}} { + cfg := settingConfigs(t, sets) + if cfg.Process["wall_loops"] != "4" { + t.Fatalf("lost wall override: %v", cfg.Applied) + } + if len(cfg.Applied) == 2 && cfg.Machine["machine_start_gcode"] != "G28 ; first,second" { + t.Fatalf("escaped comma changed: %v", cfg.Applied) + } + } +} + +func TestSliceSettingJSONEscapes(t *testing.T) { + cfg := settingConfigs(t, []string{`nozzle_temperature=["250,260","quoted \"text\"","back\\slash"]`}) + if !reflect.DeepEqual(cfg.Filaments[0]["nozzle_temperature"], []any{"250,260", `quoted "text"`, `back\slash`}) { + t.Fatalf("JSON escapes changed: %v", cfg.Applied) + } +} + +func TestSliceSettingScalarBrackets(t *testing.T) { + for _, value := range []string{`; [banner`, `; "banner`, `; banner=[text`} { + cfg := settingConfigs(t, []string{"machine_start_gcode=" + value + ",wall_loops=4"}) + if cfg.Machine["machine_start_gcode"] != value || cfg.Process["wall_loops"] != "4" { + t.Fatalf("scalar mistaken for JSON: %v", cfg.Applied) + } + } +} + +func TestSliceSettingListsAndBatches(t *testing.T) { + for _, sets := range [][]string{ + {`nozzle_temperature=["250","250"]`, "wall_loops=4", "sparse_infill_density=30%"}, + {`nozzle_temperature=["250","250"],wall_loops=4,sparse_infill_density=30%`}, + {`wall_loops=4,sparse_infill_density=30%,nozzle_temperature=["250","250"]`}, + {`nozzle_temperature=["250"\,"250"],wall_loops=4,sparse_infill_density=30%`}, + } { + cfg := settingConfigs(t, sets) + if !reflect.DeepEqual(cfg.Filaments[0]["nozzle_temperature"], []any{"250", "250"}) || cfg.Process["wall_loops"] != "4" || cfg.Process["sparse_infill_density"] != "30%" { + t.Fatalf("overrides lost: %v", cfg.Applied) + } + } +} diff --git a/internal/cmd/slice.go b/internal/cmd/slice.go index 5ab6317..808d9e4 100644 --- a/internal/cmd/slice.go +++ b/internal/cmd/slice.go @@ -22,7 +22,7 @@ type SliceCmd struct { Filament []string `short:"f" sep:"none" help:"Filament preset to use instead of the recipe's (e.g. \"Generic PLA\", \"Bambu PLA Matte\"). Repeat once per filament, in print order, for a multi-colour job."` Color []string `name:"color" aliases:"colour" sep:"none" placeholder:"RRGGBB" help:"Spool colour per --filament, same order (e.g. FFFFFF). Required with two or more filaments: Bambu Studio sizes the purge between them from the colours."` ChangeZ []float64 `name:"filament-change-z" sep:"none" placeholder:"Z" help:"The next --filament starts on the first layer above Z mm (the top of the previous filament's last layer); the AMS swaps automatically. Once per filament after the first."` - Set []string `short:"s" help:"Override a Bambu Studio setting: key=value (repeatable)." placeholder:"KEY=VALUE"` + Set []string `short:"s" sep:"none" help:"Override a Bambu Studio setting: key=value (repeatable; comma-separated batches and JSON lists supported)." placeholder:"KEY=VALUE"` Name string `short:"n" help:"Output base name (default -)."` 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)."` diff --git a/internal/slicer/settings.go b/internal/slicer/settings.go new file mode 100644 index 0000000..b4cc7be --- /dev/null +++ b/internal/slicer/settings.go @@ -0,0 +1,63 @@ +package slicer + +import "strings" + +// splitSets preserves JSON values while retaining Kong's batch and escaped-comma behavior. +func splitSets(values []string) []string { + var out []string + for _, value := range values { + var token strings.Builder + depth := 0 + quoted, escaped := false, false + for i, ch := range value { + switch { + case escaped: + if ch != ',' { + token.WriteRune('\\') + } + token.WriteRune(ch) + escaped = false + continue + case ch == '\\' && i < len(value)-1: + escaped = true + continue + } + if quoted { + token.WriteRune(ch) + if ch == '"' { + quoted = false + } + continue + } + switch ch { + case '"': + quoted = depth > 0 + case '[': + if depth > 0 { + depth++ + } else if _, prefix, ok := strings.Cut(token.String(), "="); ok && strings.TrimSpace(prefix) == "" { + depth = 1 // a JSON list starts at the beginning of the setting value + } + case '{': + if depth > 0 { + depth++ + } + case ']', '}': + if depth > 0 { + depth-- + } + case ',': + if depth == 0 { + out = append(out, token.String()) + token.Reset() + continue + } + } + token.WriteRune(ch) + } + if token.Len() > 0 { + out = append(out, token.String()) + } + } + return out +} diff --git a/internal/slicer/slice.go b/internal/slicer/slice.go index d5af317..5253878 100644 --- a/internal/slicer/slice.go +++ b/internal/slicer/slice.go @@ -157,7 +157,7 @@ func Build(ix *Index, req Request) (*Configs, error) { } apply([]map[string]any{process}, req.Recipe.ProcessOverrides) apply(c.Filaments, req.Recipe.FilamentOverrides) - for _, s := range req.Sets { + for _, s := range splitSets(req.Sets) { k, v, ok := strings.Cut(s, "=") k, v = strings.TrimSpace(k), strings.TrimSpace(v) if !ok || k == "" {