From 8014d1febe63647e31c66c59e808f28063cf7b4f Mon Sep 17 00:00:00 2001 From: Matt Voska Date: Fri, 6 Nov 2026 16:35:04 -0300 Subject: [PATCH 1/2] feat: default timelapse and arrange safe multi-model plates --- CHANGELOG.md | 4 + README.md | 18 ++ SPEC.md | 30 +- docs/protocol.md | 2 +- internal/cmd/print.go | 31 ++- internal/cmd/print_defaults_test.go | 81 ++++++ internal/cmd/slice.go | 34 ++- internal/config/config.go | 9 + internal/config/config_test.go | 23 ++ internal/job/clearance.go | 262 ++++++++++++++++++ internal/job/clearance_test.go | 56 ++++ internal/job/job.go | 2 + .../testdata/single_filament/plate_1.gcode | 6 + .../job/testdata/two_filament/plate_1.gcode | 6 + internal/preflight/clearance_test.go | 35 +++ internal/preflight/preflight.go | 33 ++- internal/slicer/arrange.go | 56 ++++ internal/slicer/multi_test.go | 49 ++++ internal/slicer/slice.go | 83 ++++-- internal/testutil/threemf.go | 17 +- 20 files changed, 788 insertions(+), 49 deletions(-) create mode 100644 internal/cmd/print_defaults_test.go create mode 100644 internal/job/clearance.go create mode 100644 internal/job/clearance_test.go create mode 100644 internal/preflight/clearance_test.go create mode 100644 internal/slicer/arrange.go create mode 100644 internal/slicer/multi_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c0d7fb9..5f647d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ JSON field names and exit codes are part of the public API. ## [Unreleased] ### Added +- Multi-model `slice` keeps separate objects and stored orientation, using Studio's arrange with `--part-spacing` (default/minimum 3 mm). Batches must fit on one plate. Summaries add `models` and `part_spacing_mm`; `model` remains the first input. +- X1C `start_line_clearance` preflight gate checks first-layer object lines and arcs against the front startup zones with a 1 mm margin. Collisions fail; unverified toolpaths fail in strict mode and send dry runs. Job JSON adds `first_layer_clearance` evidence. +- `[print] timelapse` sets the recording default (on when omitted). `print send --no-timelapse` opts out for one print; the existing `--timelapse` explicitly opts in. - `ams set` records non-RFID spool metadata while idle, with physical filament detection, confirmation, dry-run support, and verified read-back. Status trays include nullable `present` physical detection without changing `loaded`. - Repeatable `--filament-remaining A2=50g` on `preflight` and `print send` for a checked spool when AMS remaining telemetry is wrong. Sufficient estimates WARN with the stated amount and original AMS reading; insufficient estimates still FAIL against the existing 15% + 5 g reserve, including with `--strict`. Estimates apply only to the current invocation. JSON amount gates include an optional `operator_estimate` with both sources and the required grams. - Operator parity with `bambu-op`: verified `light status|on|off`, chamber-light telemetry, and `--light` snapshots that restore the previous light mode even on failure or interruption. @@ -25,6 +28,7 @@ JSON field names and exit codes are part of the public API. - job: `filament_changes` ### Changed +- `print send` records a timelapse by default. - `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 diff --git a/README.md b/README.md index f32df8b..fd5644e 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,30 @@ It is never printed, and never written to the config. bambu recipe list # prototype-pla, solid-pla, functional-pla, functional-petg bambu slice part.3mf --recipe solid-pla # STL / 3MF / OBJ bambu slice part.stl --recipe prototype-pla --set wall_loops=3 --filament "Bambu PLA Matte" +bambu slice one.stl two.stl three.stl --recipe solid-pla --part-spacing 3 bambu camera snapshot -o plate.jpg # look at the plate bambu preflight part-solid-pla.gcode.3mf --slot 4 # PASS/WARN/FAIL gates bambu print send part-solid-pla.gcode.3mf --slot 4 --confirm bambu monitor --snapshot-at-layer 2 --exec 'notify-send "$BAMBU_EVENT $BAMBU_STATE"' ``` +Multiple inputs remain separate objects. Studio arranges them on one plate with at least +3 mm between parts; `--part-spacing` can increase that gap. Orientation is preserved +unless `--auto-orient` is given. Batches that need several plates fail before publishing. + +`print send` records a timelapse by default. `--no-timelapse` disables it for one print; +`--timelapse` explicitly enables it. To change the default in `config.toml`: + +```toml +[print] +timelapse = false +``` + +X1C preflight checks first-layer object extrusion against the startup lines at +X18–240 / Y0–12 and the X0–18 / Y0–28 corner, with a 1 mm margin. Collisions fail. +Missing or unsupported toolpaths report NOT VERIFIED and fail with `--strict` or +`print send --dry-run`. Travel and startup extrusion are excluded. + ### Two colours A two-filament job changes colour at a layer height, and the AMS swaps by itself: no pause, nobody at the printer. diff --git a/SPEC.md b/SPEC.md index 0b2a0a9..14f933d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -50,9 +50,9 @@ Top-level verbs are kept for the high-frequency, single-purpose actions an opera ``` bambu status [--watch] # read-only; --watch streams NDJSON until Ctrl-C -bambu slice --recipe R [--filament PRESET] [--set k=v]... [--name N] [--out DIR] [--auto-orient] +bambu slice ... --recipe R [--part-spacing 3] [--filament PRESET] [--set k=v]... [--name N] [--out DIR] [--auto-orient] bambu preflight --slot S [--slot S2]... [--no-ftp] -bambu print send --slot S [--slot S2]... (--dry-run | --confirm) [--timelapse] [--wait 180s] +bambu print send --slot S [--slot S2]... (--dry-run | --confirm) [--timelapse | --no-timelapse] [--wait 180s] bambu print pause|stop --confirm bambu print resume --confirm [--wait 30s] # exit 0 only once the printer reports RUNNING bambu filament load --slot S --confirm [--timeout 5m] # paused or idle; exit 0 only once S is in the toolhead @@ -155,7 +155,7 @@ Precedence: {"print":{"sequence_id":"","command":"project_file","param":"Metadata/plate_1.gcode", "url":"file:///sdcard/.gcode.3mf","subtask_name":"","md5":"", "project_id":"0","profile_id":"0","task_id":"0","subtask_id":"0","bed_type":"auto", - "timelapse":false,"bed_leveling":true,"flow_cali":true,"vibration_cali":true,"layer_inspect":true, + "timelapse":true,"bed_leveling":true,"flow_cali":true,"vibration_cali":true,"layer_inspect":true, "use_ams":true,"ams_mapping":[]}} ``` @@ -203,6 +203,15 @@ Missing or malformed detection remains unknown, rather than evidence of an empty ## 8. Slicing +Multiple inputs stay separate objects. Studio arranges with rotations disabled and +`--orient 0` unless explicitly requested. `--part-spacing` defaults to 3 mm and +must be finite and at least 3. Studio's CLI exposes spacing through sequential +object clearance, so a batch first exports an arrange-only project using that +clearance, then slices with the original machine/process presets, `--arrange 0` +and `--orient 0`. Temporary sequential settings never reach the sliced file. +A batch requiring multiple sliced plates fails before publishing. `model` in the +summary remains the first input; `models` lists all absolute input paths. + **Slicer discovery**, in order: 1. `BAMBU_STUDIO_PATH` / `slicer.path` 2. macOS: `/Applications/BambuStudio.app`, `~/Applications/BambuStudio.app` @@ -334,3 +343,18 @@ internal/errfmt/ exit codes, typed errors with hints 4. 3MF inspection + preflight + payload; `print send --dry-run`. 5. FTPS client; `print send`, control commands; `monitor`, `camera`. 6. Discovery, schema, docs, site, skill, release config. + +### Recording defaults and X1C startup clearance + +`print send` resolves timelapse as explicit `--timelapse` / `--no-timelapse`, then +`[print] timelapse`, then true. Giving both flags is a usage error. Config saves +preserve an explicit false; the MQTT `timelapse` field remains boolean. + +`job.first_layer_clearance` reports verified status, object extrusion and collision +counts, the 1 mm margin, and an optional unverified reason. The parser follows +layer-1 `OBJECT_ID` markers, G90/G91 XY, M82/M83 E, G92 E resets and G0/G1/G2/G3 +paths (relative I/J arc centres). Startup, travel, retractions and non-object +paths are excluded. X1C `start_line_clearance` fails intersections with X18–240, +Y0–12 or X0–18, Y0–28, expanded by 1 mm. Missing markers or unsupported object +arcs are NOT VERIFIED: WARN normally, FAIL in strict mode or send dry runs. +Other printer models do not acquire these X1C zones. diff --git a/docs/protocol.md b/docs/protocol.md index 9dabe5f..fde4c3d 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -45,7 +45,7 @@ Repeating the same settings published nothing. Other printer models remain unver {"print":{"sequence_id":"","command":"project_file","param":"Metadata/plate_1.gcode", "url":"file:///sdcard/.gcode.3mf","subtask_name":"","md5":"", "project_id":"0","profile_id":"0","task_id":"0","subtask_id":"0","bed_type":"auto", - "timelapse":false,"bed_leveling":true,"flow_cali":true,"vibration_cali":true,"layer_inspect":true, + "timelapse":true,"bed_leveling":true,"flow_cali":true,"vibration_cali":true,"layer_inspect":true, "use_ams":true,"ams_mapping":[3]}} ``` diff --git a/internal/cmd/print.go b/internal/cmd/print.go index 0261226..2a69c2e 100644 --- a/internal/cmd/print.go +++ b/internal/cmd/print.go @@ -15,6 +15,8 @@ import ( "strings" "time" + "github.com/alecthomas/kong" + "github.com/voska/bambu/internal/config" "github.com/voska/bambu/internal/errfmt" "github.com/voska/bambu/internal/ftps" @@ -35,7 +37,7 @@ type PreflightCmd struct { // Run executes the command. func (c *PreflightCmd) Run(g *Globals) error { - pc, err := runPreflight(g, c.File, c.Slot, c.FilamentRemaining, !c.NoFTP, c.Strict) + pc, err := runPreflight(g, c.File, c.Slot, c.FilamentRemaining, !c.NoFTP, c.Strict, false) if err != nil { return err } @@ -58,7 +60,7 @@ type preflightCtx struct { code string } -func runPreflight(g *Globals, file string, slots, estimates []string, checkFTP, strict bool) (*preflightCtx, error) { +func runPreflight(g *Globals, file string, slots, estimates []string, checkFTP, strict, requireClearance bool) (*preflightCtx, error) { trays := make([]int, 0, len(slots)) for _, slot := range slots { tray, err := printer.ParseSlot(slot) @@ -96,7 +98,7 @@ func runPreflight(g *Globals, file string, slots, estimates []string, checkFTP, conn.Close() return nil, err } - in := preflight.Input{Printer: p, Job: j, Status: printer.Summarize(raw), TrayIDs: trays, Strict: strict, Remaining: remaining} + in := preflight.Input{Printer: p, Job: j, Status: printer.Summarize(raw), TrayIDs: trays, Strict: strict, RequireClearance: requireClearance, Remaining: remaining} if checkFTP { in.FTPSCheck = func() error { ctx, cancel := context.WithTimeout(g.Ctx, 30*time.Second) @@ -192,7 +194,8 @@ type SendCmd struct { Slot []string `short:"s" required:"" sep:"none" help:"AMS slot: 1-4 (first AMS) or A1-D4. Once per filament, in filament order (--slot A4 --slot A1)."` Confirm bool `help:"Required to actually print. Only pass it after a human approved this exact file and confirmed the plate is clear."` DryRun bool `short:"n" name:"dry-run" help:"Run preflight and show the exact upload path and MQTT payload; send nothing."` - Timelapse bool `help:"Record a timelapse."` + Timelapse bool `xor:"timelapse" help:"Record a timelapse (default: on, or [print] timelapse in config)."` + NoTimelapse bool `xor:"timelapse" help:"Disable timelapse recording for this print."` Wait time.Duration `default:"180s" help:"How long to wait for the job to start."` Strict bool `help:"Require exact filament material and valid temperature bounds (bambu-op operator gates)."` FilamentRemaining []string `sep:"none" placeholder:"SLOT=Ng" help:"Grams left on a spool you checked, e.g. A2=50g, for this invocation only. Repeat per slot; sufficient estimates WARN (also with --strict), insufficient estimates FAIL."` @@ -226,12 +229,12 @@ type sendResult struct { } // Run executes the command. -func (c *SendCmd) Run(g *Globals) error { +func (c *SendCmd) Run(g *Globals, parsed *kong.Context) error { if !c.DryRun && !c.Confirm { return errfmt.New(errfmt.ExitGate, "refusing to print without --confirm"). WithHint("preview with --dry-run; pass --confirm only after a human approved this file and confirmed the plate is clear") } - pc, err := runPreflight(g, c.File, c.Slot, c.FilamentRemaining, true, c.Strict) + pc, err := runPreflight(g, c.File, c.Slot, c.FilamentRemaining, true, c.Strict, c.DryRun) if err != nil { return err } @@ -243,7 +246,21 @@ func (c *SendCmd) Run(g *Globals) error { if err != nil { return errfmt.Wrap(errfmt.ExitError, err, "read %s", pc.job.File) } - body := job.ProjectFile(pc.job, remote, pc.result.TrayIDs, c.Timelapse) + cfg, err := g.ConfigFile() + if err != nil { + return err + } + timelapse := cfg.Print.RecordTimelapse() + for _, trace := range parsed.Path { + if trace.Flag != nil && trace.Flag.Name == "timelapse" { + timelapse = c.Timelapse + break + } + } + if c.NoTimelapse { + timelapse = false + } + body := job.ProjectFile(pc.job, remote, pc.result.TrayIDs, timelapse) plan.MQTT.Topic, plan.MQTT.QoS = "device/"+pc.p.Serial+"/request", 1 plan.MQTT.Payload = map[string]any{"print": withSeq(body)} res := sendResult{Printer: pc.p.Name, DryRun: c.DryRun, Preflight: pc.result, Plan: plan} diff --git a/internal/cmd/print_defaults_test.go b/internal/cmd/print_defaults_test.go new file mode 100644 index 0000000..439a787 --- /dev/null +++ b/internal/cmd/print_defaults_test.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "os" + "testing" + + "github.com/voska/bambu/internal/errfmt" + "github.com/voska/bambu/internal/testutil" +) + +func TestSendTimelapseDefaults(t *testing.T) { + for _, tt := range []struct { + name, config string + flags []string + want bool + code int + }{ + {name: "default", want: true}, + {name: "opt out", flags: []string{"--no-timelapse"}}, + {name: "explicit false", flags: []string{"--timelapse=false"}}, + {name: "configured off", config: "[print]\ntimelapse = false\n"}, + {name: "configured on", config: "[print]\ntimelapse = true\n", want: true}, + {name: "explicit on", config: "[print]\ntimelapse = false\n", flags: []string{"--timelapse"}, want: true}, + {name: "explicit off", config: "[print]\ntimelapse = true\n", flags: []string{"--no-timelapse"}}, + {name: "conflicting flags", flags: []string{"--timelapse", "--no-timelapse"}, code: errfmt.ExitUsage}, + } { + t.Run(tt.name, func(t *testing.T) { + h := newHarness(t, nil) + f := h.job(testutil.Opts{Filaments: []string{"PLA:GFL99:13.5"}}) + cfg, err := os.OpenFile(os.Getenv("BAMBU_CONFIG"), os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + _, err = cfg.WriteString(tt.config) + if err != nil { + t.Fatal(err) + } + _ = cfg.Close() + args := append([]string{"print", "send", f, "--slot", "A4", "--dry-run", "--json"}, tt.flags...) + if code := h.run(args...); code != tt.code { + t.Fatalf("exit=%d want=%d: %s %s", code, tt.code, h.stdout.String(), h.stderr.String()) + } + if tt.code != 0 { + return + } + var r sendResult + h.json(&r) + if got := r.Plan.MQTT.Payload["print"].(map[string]any)["timelapse"]; got != tt.want { + t.Fatalf("timelapse=%v want=%v", got, tt.want) + } + if len(h.conn.Commands) != 0 { + t.Fatal("dry run published") + } + if _, ok := h.ftp.File("/part.gcode.3mf"); ok { + t.Fatal("dry run uploaded") + } + }) + } +} + +func TestSendClearanceBlocksWrites(t *testing.T) { + for _, gcode := range []string{ + "G90\nM83\n; layer num/total_layer_count: 1/2\n; OBJECT_ID: 1\nG1 X50 Y10\nG1 X60 Y10 E1\n", + "G28\n", + } { + for _, flags := range [][]string{{"--dry-run"}, {"--dry-run", "--strict"}, {"--confirm", "--strict"}} { + h := newHarness(t, nil) + f := h.job(testutil.Opts{Gcode: gcode, Filaments: []string{"PLA:GFL99:13.5"}}) + args := append([]string{"print", "send", f, "--slot", "A4", "--json"}, flags...) + if code := h.run(args...); code != errfmt.ExitGate { + t.Fatalf("exit %d: %s %s", code, h.stdout.String(), h.stderr.String()) + } + if len(h.conn.Commands) != 0 { + t.Fatal("rejected send published") + } + if _, ok := h.ftp.File("/part.gcode.3mf"); ok { + t.Fatal("rejected send uploaded") + } + } + } +} diff --git a/internal/cmd/slice.go b/internal/cmd/slice.go index a440538..c844711 100644 --- a/internal/cmd/slice.go +++ b/internal/cmd/slice.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "math" "os" "path/filepath" "sort" @@ -17,21 +18,26 @@ import ( // SliceCmd slices a model. type SliceCmd struct { - Model string `arg:"" help:"Model file (.stl, .3mf, .obj, .amf, .step/.stp with CadQuery)." type:"path"` - Recipe string `short:"r" required:"" help:"Recipe name (see: bambu recipe list)."` - 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" 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)."` - Slot string `help:"Record the intended AMS slot in the summary; send still requires --slot."` - Strict bool `help:"Require a plate preview and reject slicer warnings before publishing outputs."` + Models []string `arg:"" name:"model" help:"Model files (.stl, .3mf, .obj, .amf, .step/.stp with CadQuery)." type:"path"` + Recipe string `short:"r" required:"" help:"Recipe name (see: bambu recipe list)."` + 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" 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"` + PartSpacingMM float64 `name:"part-spacing" default:"3" placeholder:"MM" help:"Minimum spacing between objects in a batch, in mm (at least 3)."` + 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 and reject slicer warnings before publishing outputs."` } // Run executes the command. func (c *SliceCmd) Run(g *Globals) error { + if c.PartSpacingMM < 3 || math.IsNaN(c.PartSpacingMM) || math.IsInf(c.PartSpacingMM, 0) { + return errfmt.New(errfmt.ExitUsage, "--part-spacing must be finite and at least 3 mm") + } + if c.Slot != "" { tray, err := printer.ParseSlot(c.Slot) if err != nil { @@ -58,7 +64,7 @@ func (c *SliceCmd) Run(g *Globals) error { cfg, _ := g.ConfigFile() name := c.Name if name == "" { - stem := strings.TrimSuffix(filepath.Base(c.Model), filepath.Ext(c.Model)) + stem := strings.TrimSuffix(filepath.Base(c.Models[0]), filepath.Ext(c.Models[0])) name = stem + "-" + r.Name } name = slicer.SafeName(name) @@ -76,10 +82,10 @@ func (c *SliceCmd) Run(g *Globals) error { if err != nil { cache = os.TempDir() } - g.Out.Hint("slicing %s with %s for %s (%s %s mm)…", filepath.Base(c.Model), r.Name, p.Name, m.Alias, p.NozzleString()) + g.Out.Hint("slicing %s with %s for %s (%s %s mm)…", filepath.Base(c.Models[0]), r.Name, p.Name, m.Alias, p.NozzleString()) st.DetectVersion(g.Ctx) sum, err := slicer.Run(g.Ctx, st, ix, slicer.Request{ - Model: c.Model, Recipe: r, MachinePreset: m.MachinePreset(p.NozzleString()), Plate: p.Plate, + Models: c.Models, PartSpacingMM: c.PartSpacingMM, 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, Strict: c.Strict, diff --git a/internal/config/config.go b/internal/config/config.go index 7b88733..67ef11a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,14 @@ type Ntfy struct { Topic string `toml:"topic,omitempty"` } +// Print holds defaults for print send. A nil Timelapse means enabled. +type Print struct { + Timelapse *bool `toml:"timelapse,omitempty"` +} + +// RecordTimelapse resolves an omitted config key to the default, true. +func (p Print) RecordTimelapse() bool { return p.Timelapse == nil || *p.Timelapse } + // Config is the whole config file. type Config struct { DefaultPrinter string `toml:"default_printer,omitempty"` @@ -51,6 +59,7 @@ type Config struct { RecipesDir string `toml:"recipes_dir,omitempty"` Slicer Slicer `toml:"slicer,omitempty"` Ntfy Ntfy `toml:"ntfy,omitempty"` + Print Print `toml:"print,omitempty"` Printers map[string]*Printer `toml:"printers,omitempty"` path string diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4847bf6..e4fc563 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -91,3 +91,26 @@ func TestBadTOML(t *testing.T) { t.Fatalf("want config error, got %v", err) } } + +func TestPrintTimelapseRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + c, err := Load(path) + if err != nil { + t.Fatal(err) + } + if !c.Print.RecordTimelapse() { + t.Fatal("missing config defaults off") + } + off := false + c.Print.Timelapse = &off + if err := c.Save(); err != nil { + t.Fatal(err) + } + c, err = Load(path) + if err != nil { + t.Fatal(err) + } + if c.Print.RecordTimelapse() { + t.Fatal("explicit false lost on save/load") + } +} diff --git a/internal/job/clearance.go b/internal/job/clearance.go new file mode 100644 index 0000000..0293c8c --- /dev/null +++ b/internal/job/clearance.go @@ -0,0 +1,262 @@ +package job + +import ( + "bufio" + "bytes" + "fmt" + "math" + "strconv" + "strings" +) + +// Clearance records what was read from first-layer object toolpaths, in bed coordinates. +type Clearance struct { + Verified bool `json:"verified"` + Extrusions int `json:"object_extrusions"` + Conflicts int `json:"conflicting_extrusions"` + MarginMM float64 `json:"margin_mm"` + Reason string `json:"reason,omitempty"` +} + +type ( + point struct{ x, y float64 } + rectangle struct{ min, max point } +) + +// X1C start lines occupy the front strip and front-left exclusion corner. +// The 1 mm margin also covers the width of an extrusion around its centreline. +var startZones = []rectangle{ + {point{17, -1}, point{241, 13}}, + {point{-1, -1}, point{19, 29}}, +} + +// FirstLayerClearance follows Bambu Studio object markers and G0/G1/G2/G3 paths. +// Startup, travel, retraction, and prime-tower/custom G-code are not object extrusion. +// Unsupported object motion stays unverified instead of claiming clear space. +func FirstLayerClearance(gcode []byte) Clearance { + out := Clearance{MarginMM: 1} + pos := point{math.NaN(), math.NaN()} + e := 0.0 + relative, relativeE := false, false + millimeters, xyPlane, relativeArc := true, true, true + layer, object := 0, false + scanner := bufio.NewScanner(bytes.NewReader(gcode)) + scanner.Buffer(make([]byte, 4096), 1<<20) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + switch { + case strings.HasPrefix(line, "; layer num/total_layer_count:"): + n, _, _ := strings.Cut(strings.TrimSpace(strings.TrimPrefix(line, "; layer num/total_layer_count:")), "/") + layer, _ = strconv.Atoi(n) + object = false + case strings.HasPrefix(line, "; OBJECT_ID:"): + id, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "; OBJECT_ID:"))) + object = err == nil && id >= 0 + case strings.HasPrefix(line, "; FEATURE: Custom"), strings.HasPrefix(line, "; FEATURE: Prime tower"): + object = false + } + code, _, _ := strings.Cut(line, ";") + fields := strings.Fields(code) + if len(fields) == 0 { + continue + } + switch fields[0] { + case "G20": + millimeters = false + continue + case "G21": + millimeters = true + continue + case "G17": + xyPlane = true + continue + case "G18", "G19": + xyPlane = false + continue + case "G90.1": + relativeArc = false + continue + case "G91.1": + relativeArc = true + continue + case "G90": + relative = false + continue + case "G91": + relative = true + continue + case "M82": + relativeE = false + continue + case "M83": + relativeE = true + continue + } + if fields[0] != "G0" && fields[0] != "G1" && fields[0] != "G2" && fields[0] != "G3" && fields[0] != "G92" { + continue + } + args := map[byte]float64{} + for _, f := range fields[1:] { + if len(f) < 2 { + continue + } + v, err := strconv.ParseFloat(f[1:], 64) + if err == nil && !math.IsInf(v, 0) && !math.IsNaN(v) { + args[f[0]] = v + } else if layer == 1 && object && strings.ContainsRune("XYEIJR", rune(f[0])) { + out.Reason = "object motion has invalid numeric coordinates" + } + } + if fields[0] == "G92" { + if v, ok := args['X']; ok { + pos.x = v + out.Reason = "G92 XY coordinate offsets are unsupported" + } + if v, ok := args['Y']; ok { + pos.y = v + out.Reason = "G92 XY coordinate offsets are unsupported" + } + if v, ok := args['E']; ok { + e = v + } + continue + } + next := pos + if x, ok := args['X']; ok { + next.x = x + if relative { + next.x += pos.x + } + } + if y, ok := args['Y']; ok { + next.y = y + if relative { + next.y += pos.y + } + } + extrusion := 0.0 + if v, ok := args['E']; ok { + extrusion = v + if !relativeE { + extrusion -= e + e = v + } else { + e += v + } + } + arc := fields[0] == "G2" || fields[0] == "G3" + if layer == 1 && object && extrusion > 0 && (next != pos || arc) { + out.Extrusions++ + if !millimeters || (arc && (!xyPlane || !relativeArc)) { + out.Reason = "object motion needs millimetres and XY arcs with relative I/J centres" + } else if math.IsNaN(pos.x) || math.IsNaN(pos.y) || math.IsNaN(next.x) || math.IsNaN(next.y) { + out.Reason = "object extrusion has unknown XY coordinates" + } else { + hit, err := pathHitsZones(pos, next, args, fields[0]) + if err != nil { + out.Reason = err.Error() + } + if hit { + out.Conflicts++ + } + } + } + pos = next + } + if err := scanner.Err(); err != nil { + out.Reason = "cannot read all G-code lines" + } + if out.Extrusions == 0 && out.Reason == "" { + out.Reason = "no first-layer object extrusions found (layer/OBJECT_ID markers required)" + } + out.Verified = out.Extrusions > 0 && out.Reason == "" + return out +} + +func pathHitsZones(a, b point, args map[byte]float64, command string) (bool, error) { + if command == "G0" || command == "G1" { + for _, zone := range startZones { + if segmentHits(a, b, zone) { + return true, nil + } + } + return false, nil + } + _, hasI := args['I'] + _, hasJ := args['J'] + if _, hasR := args['R']; hasR || (!hasI && !hasJ) { + return false, fmt.Errorf("object arc needs relative I/J centre offsets") + } + center := point{a.x + args['I'], a.y + args['J']} + radius := math.Hypot(a.x-center.x, a.y-center.y) + if radius == 0 || math.Abs(math.Hypot(b.x-center.x, b.y-center.y)-radius) > math.Max(.05, radius*.01) { + return false, fmt.Errorf("object arc has inconsistent centre/radius") + } + start, end := math.Atan2(a.y-center.y, a.x-center.x), math.Atan2(b.y-center.y, b.x-center.x) + onArc := func(p point) bool { + angle := math.Atan2(p.y-center.y, p.x-center.x) + if a == b { + return true + } + if command == "G2" { + return angleDistance(angle, start) <= angleDistance(end, start)+1e-8 + } + return angleDistance(start, angle) <= angleDistance(start, end)+1e-8 + } + for _, zone := range startZones { + if inside(a, zone) || inside(b, zone) { + return true, nil + } + for _, x := range []float64{zone.min.x, zone.max.x} { + d := radius*radius - (x-center.x)*(x-center.x) + if d >= 0 { + for _, y := range []float64{center.y - math.Sqrt(d), center.y + math.Sqrt(d)} { + p := point{x, y} + if inside(p, zone) && onArc(p) { + return true, nil + } + } + } + } + for _, y := range []float64{zone.min.y, zone.max.y} { + d := radius*radius - (y-center.y)*(y-center.y) + if d >= 0 { + for _, x := range []float64{center.x - math.Sqrt(d), center.x + math.Sqrt(d)} { + p := point{x, y} + if inside(p, zone) && onArc(p) { + return true, nil + } + } + } + } + } + return false, nil +} + +func angleDistance(from, to float64) float64 { return math.Mod(to-from+2*math.Pi, 2*math.Pi) } +func inside(p point, r rectangle) bool { + return p.x >= r.min.x-1e-8 && p.x <= r.max.x+1e-8 && p.y >= r.min.y-1e-8 && p.y <= r.max.y+1e-8 +} + +func segmentHits(a, b point, r rectangle) bool { + lo, hi := 0.0, 1.0 + for _, axis := range [][4]float64{{a.x, b.x, r.min.x, r.max.x}, {a.y, b.y, r.min.y, r.max.y}} { + delta := axis[1] - axis[0] + if delta == 0 { + if axis[0] < axis[2] || axis[0] > axis[3] { + return false + } + continue + } + x, y := (axis[2]-axis[0])/delta, (axis[3]-axis[0])/delta + if x > y { + x, y = y, x + } + lo = math.Max(lo, x) + hi = math.Min(hi, y) + if lo > hi { + return false + } + } + return true +} diff --git a/internal/job/clearance_test.go b/internal/job/clearance_test.go new file mode 100644 index 0000000..de1389f --- /dev/null +++ b/internal/job/clearance_test.go @@ -0,0 +1,56 @@ +package job + +import ( + "strings" + "testing" +) + +func TestFirstLayerClearance(t *testing.T) { + const prefix = "G90\nM83\nG1 X100 Y100\nG1 X18 Y2 E15\n; layer num/total_layer_count: 1/2\n" + for _, tt := range []struct { + name, code string + conflicts int + verified bool + }{ + {"clear", "; OBJECT_ID: 1\nG1 X50 Y50\nG1 X60 Y50 E1\n", 0, true}, + {"front strip", "; OBJECT_ID: 1\nG1 X50 Y10\nG1 X60 Y10 E1\n", 1, true}, + {"corner", "; OBJECT_ID: 1\nG1 X10 Y20\nG1 X11 Y20 E1\n", 1, true}, + {"margin", "; OBJECT_ID: 1\nG1 X50 Y12.5\nG1 X60 Y12.5 E1\n", 1, true}, + {"segment crosses with endpoints outside", "; OBJECT_ID: 1\nG1 X10 Y10\nG1 X250 Y10 E1\n", 1, true}, + {"travel and unretraction", "; OBJECT_ID: 1\nG1 X50 Y10\nG1 X60 Y10\nG1 E1\nG1 X70 Y10 E-1\nG1 X70 Y30\nG1 X80 Y30 E1\n", 0, true}, + {"non object ignored", "G1 X50 Y10\nG1 X60 Y10 E1\n; OBJECT_ID: 1\nG1 X50 Y50\nG1 X60 Y50 E1\n; OBJECT_ID: -1\nG1 X50 Y10 E1\n", 0, true}, + {"later layer ignored", "; OBJECT_ID: 1\nG1 X50 Y50\nG1 X60 Y50 E1\n; layer num/total_layer_count: 2/2\nG1 X50 Y10 E1\n", 0, true}, + {"clockwise arc crosses strip", "; OBJECT_ID: 1\nG1 X40 Y20\nG2 X60 Y20 I10 J0 E1\n", 0, true}, + {"counterclockwise arc crosses strip", "; OBJECT_ID: 1\nG1 X40 Y20\nG3 X60 Y20 I10 J0 E1\n", 1, true}, + {"full circle", "; OBJECT_ID: 1\nG1 X40 Y20\nG2 X40 Y20 I10 J0 E1\n", 1, true}, + {"absolute extrusion and reset", "M82\nG92 E10\n; OBJECT_ID: 1\nG1 X50 Y50\nG1 X60 Y50 E11\nG1 X50 Y10 E10\nG92 E0\nG1 X60 Y10 E1\n", 1, true}, + {"relative XY", "; OBJECT_ID: 1\nG1 X50 Y50\nG91\nG1 X10 E1\nG1 Y-40 E1\n", 1, true}, + {"unknown arc", "; OBJECT_ID: 1\nG1 X50 Y50\nG2 X60 Y50 R10 E1\n", 0, false}, + {"no object markers", "G1 X50 Y50 E1\n", 0, false}, + } { + t.Run(tt.name, func(t *testing.T) { + got := FirstLayerClearance([]byte(prefix + tt.code)) + if got.Conflicts != tt.conflicts || got.Verified != tt.verified { + t.Fatalf("got %+v, want conflicts=%d verified=%v", got, tt.conflicts, tt.verified) + } + }) + } + if got := FirstLayerClearance([]byte(strings.ReplaceAll(prefix, "1/2", "2/2"))); got.Verified { + t.Fatalf("missing first layer verified: %+v", got) + } +} + +func TestClearanceUnsupportedCoordinates(t *testing.T) { + for _, code := range []string{ + "G20\nG1 X2 Y2 E1\n", + "G90.1\nG2 X60 Y50 I55 J50 E1\n", + "G18\nG2 X60 Y50 I10 K0 E1\n", + "G1 Xbad Y10 E1\n", + "G92 X0\nG1 X60 Y50 E1\n", + } { + gcode := "G90\nM83\nG1 X50 Y50\n; layer num/total_layer_count: 1/2\n; OBJECT_ID: 1\nG1 X60 Y50 E1\n" + code + if got := FirstLayerClearance([]byte(gcode)); got.Verified { + t.Fatalf("unsupported coordinates verified: %s %+v", code, got) + } + } +} diff --git a/internal/job/job.go b/internal/job/job.go index 74a6d44..e6ce216 100644 --- a/internal/job/job.go +++ b/internal/job/job.go @@ -62,6 +62,7 @@ type FilamentChange struct { // Job is what bambu needs to know about a sliced plate. type Job struct { + FirstLayer Clearance `json:"first_layer_clearance"` File string `json:"file"` Plate int `json:"plate"` GcodeParam string `json:"gcode_param"` @@ -162,6 +163,7 @@ func Inspect(path string, plate int) (*Job, error) { j.TotalTime = strings.TrimSpace(m[1]) } j.FilamentChanges = FilamentChanges(gcode) + j.FirstLayer = FirstLayerClearance(gcode) ps := map[string]any{} if b, ok := read("Metadata/project_settings.config"); ok { diff --git a/internal/job/testdata/single_filament/plate_1.gcode b/internal/job/testdata/single_filament/plate_1.gcode index eda94c8..9d4111f 100644 --- a/internal/job/testdata/single_filament/plate_1.gcode +++ b/internal/job/testdata/single_filament/plate_1.gcode @@ -25,6 +25,12 @@ T1000 ; CHANGE_LAYER ; Z_HEIGHT: 0.2 ; layer num/total_layer_count: 1/27 +; Synthetic first-layer path for the trimmed fixture +G90 +M83 +; OBJECT_ID: 1 +G1 X50 Y50 +G1 X60 Y50 E1 ; CHANGE_LAYER ; Z_HEIGHT: 0.4 ; layer num/total_layer_count: 2/27 diff --git a/internal/job/testdata/two_filament/plate_1.gcode b/internal/job/testdata/two_filament/plate_1.gcode index 7e814ab..8501583 100644 --- a/internal/job/testdata/two_filament/plate_1.gcode +++ b/internal/job/testdata/two_filament/plate_1.gcode @@ -25,6 +25,12 @@ T1000 ; CHANGE_LAYER ; Z_HEIGHT: 0.2 ; layer num/total_layer_count: 1/27 +; Synthetic first-layer path for the trimmed fixture +G90 +M83 +; OBJECT_ID: 1 +G1 X50 Y50 +G1 X60 Y50 E1 ; CHANGE_LAYER ; Z_HEIGHT: 0.4 ; layer num/total_layer_count: 2/27 diff --git a/internal/preflight/clearance_test.go b/internal/preflight/clearance_test.go new file mode 100644 index 0000000..34735f7 --- /dev/null +++ b/internal/preflight/clearance_test.go @@ -0,0 +1,35 @@ +package preflight + +import ( + "testing" + + "github.com/voska/bambu/internal/job" + "github.com/voska/bambu/internal/testutil" +) + +func TestStartLineClearanceGate(t *testing.T) { + for _, tt := range []struct { + name, model, code, want string + strict bool + }{ + {"strict collision", "X1C", "G90\nM83\n; layer num/total_layer_count: 1/2\n; OBJECT_ID: 1\nG1 X50 Y10\nG1 X60 Y10 E1\n", Fail, true}, + {"default collision", "X1C", "G90\nM83\n; layer num/total_layer_count: 1/2\n; OBJECT_ID: 1\nG1 X50 Y10\nG1 X60 Y10 E1\n", Fail, false}, + {"strict clear", "X1C", "", Pass, true}, + {"strict unknown", "X1C", "G28\n", Fail, true}, + {"default unknown", "X1C", "G28\n", Warn, false}, + {"other printer", "P1S", "G28\n", "", true}, + } { + t.Run(tt.name, func(t *testing.T) { + j, err := job.Inspect(testutil.Write(t, t.TempDir(), "part", testutil.Opts{Gcode: tt.code}), 1) + if err != nil { + t.Fatal(err) + } + p := *shop + p.Model = tt.model + r := Run(Input{Printer: &p, Job: j, Status: idleStatus(t), TrayIDs: []int{0}, Strict: tt.strict}) + if got := status(r, "start_line_clearance"); got != tt.want { + t.Fatalf("gate=%s want=%s; %+v", got, tt.want, r) + } + }) + } +} diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index 1e07316..ebbee45 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -52,12 +52,13 @@ type Result struct { // Input bundles everything the gates look at. type Input struct { - Printer *config.Printer - Job *job.Job - Status printer.Status - TrayIDs []int // the AMS tray feeding each job filament, in filament order - Strict bool // retain bambu-op's exact material and known temperature requirements - Remaining map[int]float64 // operator estimates in grams, keyed by AMS tray ID; this invocation only + Printer *config.Printer + Job *job.Job + Status printer.Status + TrayIDs []int // the AMS tray feeding each job filament, in filament order + Strict bool // retain bambu-op's exact material and known temperature requirements + RequireClearance bool // dry-run requires verified geometry without changing the other strict gates + Remaining map[int]float64 // operator estimates in grams, keyed by AMS tray ID; this invocation only // FTPSCheck, if set, performs a read-only FTPS login + listing and returns its error. FTPSCheck func() error } @@ -106,6 +107,26 @@ func Run(in Input) Result { } g.add("sliced_for_printer", j.PrinterModel == want, Fail, fmt.Sprintf("file=%q printer=%q", j.PrinterModel, want), "re-slice for this printer (bambu slice --printer "+p.Name+")") + if model.Alias == "X1C" { + clearance := j.FirstLayer + status := Pass + detail := fmt.Sprintf("%d first-layer object extrusions; %d intersect X1C start zones (1 mm margin)", clearance.Extrusions, clearance.Conflicts) + if !clearance.Verified { + status = Warn + if in.Strict || in.RequireClearance { + status = Fail + } + detail = "NOT VERIFIED: " + clearance.Reason + } + if clearance.Conflicts > 0 { + status = Fail + } + gate := Gate{Gate: "start_line_clearance", Status: status, Detail: detail} + if status != Pass { + gate.Fix = "re-slice with separate objects clear of X18–240 Y0–12 and X0–18 Y0–28, plus 1 mm; inspect the first layer" + } + g = append(g, gate) + } used := len(j.Filaments) g.add("single_filament", used == len(in.TrayIDs), Fail, fmt.Sprintf("%d filament%s in plate, %d slot%s given", used, plural(used), len(in.TrayIDs), plural(len(in.TrayIDs))), "pass one --slot per filament, in filament order (e.g. --slot A4 --slot A1)") diff --git a/internal/slicer/arrange.go b/internal/slicer/arrange.go new file mode 100644 index 0000000..d1a4c8e --- /dev/null +++ b/internal/slicer/arrange.go @@ -0,0 +1,56 @@ +package slicer + +import ( + "context" + "encoding/json" + "errors" + "maps" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/voska/bambu/internal/errfmt" +) + +// Studio exposes object spacing only for sequential arrangement. Use that clearance +// in an arrange-only project, then slice its placements with the original presets. +// No sequential-print settings or reduced head clearances reach the sliced output. +func arrangeModels(ctx context.Context, st *Studio, req Request, cfgs *Configs, paths map[string]string, filPaths, sources []string, work string) (string, error) { + machine, process := maps.Clone(cfgs.Machine), maps.Clone(cfgs.Process) + machine["extruder_clearance_max_radius"] = str(req.PartSpacingMM + .01) + process["print_sequence"] = "by object" + for kind, data := range map[string]map[string]any{"arrange-machine": machine, "arrange-process": process} { + b, _ := json.MarshalIndent(data, "", " ") + paths[kind] = filepath.Join(work, kind+".json") + if err := os.WriteFile(paths[kind], b, 0o600); err != nil { + return "", errfmt.Wrap(errfmt.ExitConfig, err, "write arrangement config") + } + } + arranged := filepath.Join(work, "arranged.3mf") + if err := os.Remove(arranged); err != nil && !errors.Is(err, os.ErrNotExist) { + return "", errfmt.Wrap(errfmt.ExitConfig, err, "remove stale arrangement") + } + orient := "0" + if req.AutoOrient { + orient = "1" + } + args := []string{ + "--arrange", "1", "--orient", orient, "--allow-rotations=0", "--avoid-extrusion-cali-region", + "--load-settings", paths["arrange-machine"] + ";" + paths["arrange-process"], "--load-filaments", strings.Join(filPaths, ";"), + "--outputdir", work, "--export-3mf", "arranged.3mf", + } + args = append(args, sources...) + ctx, cancel := context.WithTimeout(ctx, 15*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, st.Binary, args...) //nolint:gosec // discovered slicer, validated file paths + cmd.Dir = work + data, err := cmd.CombinedOutput() + log := filepath.Join(work, "arrange.log") + _ = os.WriteFile(log, data, 0o600) + if err != nil || !exists(arranged) { + return "", errfmt.New(errfmt.ExitSliceFailed, "Studio arrangement failed").WithHint("split the batch or check input models; log: %s", log).WithData("log", log) + } + return arranged, nil +} diff --git a/internal/slicer/multi_test.go b/internal/slicer/multi_test.go new file mode 100644 index 0000000..34004d3 --- /dev/null +++ b/internal/slicer/multi_test.go @@ -0,0 +1,49 @@ +package slicer + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/voska/bambu/internal/errfmt" + "github.com/voska/bambu/internal/recipe" + "github.com/voska/bambu/internal/testutil" +) + +func TestMultiModelArrange(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fake") + } + dir := t.TempDir() + bin := filepath.Join(dir, "studio.sh") + _ = os.WriteFile(bin, []byte(fakeStudio), 0o755) + t.Setenv("FAKE_3MF", testutil.Write(t, dir, "prepared", testutil.Opts{Objects: []string{"one.stl", "two.stl", "three.stl"}})) + t.Setenv("FAKE_ARGS", filepath.Join(dir, "args")) + r := req(recipe.Recipe{Name: "t", Process: "0.20mm Standard", Filament: "Bambu PLA Basic"}) + r.Name, r.OutDir, r.WorkDir = "batch", filepath.Join(dir, "out"), filepath.Join(dir, "work") + for _, name := range []string{"one.stl", "two.stl", "three.stl"} { + path := filepath.Join(dir, name) + _ = os.WriteFile(path, []byte("solid x\nendsolid x\n"), 0o600) + r.Models = append(r.Models, path) + } + sum, err := Run(context.Background(), &Studio{Binary: bin}, index(t), r) + if err != nil { + t.Fatal(err) + } + args := readArgs(t) + if !strings.Contains(args, "--arrange\n0\n") || !strings.Contains(args, "arranged.3mf") || sum.PartSpacingMM < 3 || len(sum.Models) != 3 || sum.AutoOrient { + t.Fatalf("summary=%+v args=%s", sum, args) + } + r.PartSpacingMM = 2 + if _, err := Run(context.Background(), &Studio{Binary: bin}, index(t), r); errfmt.As(err).Code != errfmt.ExitUsage { + t.Fatalf("unsafe spacing: %v", err) + } + r.PartSpacingMM = 3 + r.Models = append(r.Models, filepath.Join(dir, "missing.stl")) + if _, err := Run(context.Background(), &Studio{Binary: bin}, index(t), r); errfmt.As(err).Code != errfmt.ExitNotFound { + t.Fatalf("missing input: %v", err) + } +} diff --git a/internal/slicer/slice.go b/internal/slicer/slice.go index eef55cc..8615f32 100644 --- a/internal/slicer/slice.go +++ b/internal/slicer/slice.go @@ -42,6 +42,8 @@ var CLIErrors = map[int]string{ // Request is one slice job. type Request struct { + Models []string // separate model files; Model remains for single-input callers + PartSpacingMM float64 Model string Recipe recipe.Recipe MachinePreset string // e.g. "Bambu Lab X1 Carbon 0.4 nozzle" @@ -65,6 +67,8 @@ type Summary struct { Recipe string `json:"recipe"` Description string `json:"recipe_description"` Model string `json:"model"` + Models []string `json:"models"` + PartSpacingMM float64 `json:"part_spacing_mm,omitempty"` Gcode3MF string `json:"gcode_3mf"` PreviewPNG string `json:"preview_png"` SummaryJSON string `json:"summary_json"` @@ -347,19 +351,35 @@ type result struct { // Run slices req with st and writes outputs into req.OutDir. func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, error) { - ext := strings.ToLower(filepath.Ext(req.Model)) - switch ext { - case ".stl", ".3mf", ".obj", ".amf", ".step", ".stp": - default: - return nil, errfmt.New(errfmt.ExitUsage, "unsupported model type %q", ext).WithHint("use .stl, .3mf or .obj") + models := req.Models + if len(models) == 0 { + models = []string{req.Model} } - model, err := filepath.Abs(req.Model) - if err != nil { - return nil, errfmt.Wrap(errfmt.ExitUsage, err, "resolve model path") + if req.PartSpacingMM == 0 { + req.PartSpacingMM = 3 } - if _, err := os.Stat(model); err != nil { - return nil, errfmt.New(errfmt.ExitNotFound, "model not found: %s", req.Model) + if req.PartSpacingMM < 3 || math.IsNaN(req.PartSpacingMM) || math.IsInf(req.PartSpacingMM, 0) { + return nil, errfmt.New(errfmt.ExitUsage, "--part-spacing must be finite and at least 3 mm") } + models = append([]string(nil), models...) + for i, path := range models { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".stl", ".3mf", ".obj", ".amf", ".step", ".stp": + default: + return nil, errfmt.New(errfmt.ExitUsage, "unsupported model type %q", ext).WithHint("use .stl, .3mf, .obj, .amf or .step/.stp") + } + abs, err := filepath.Abs(path) + if err != nil { + return nil, errfmt.Wrap(errfmt.ExitUsage, err, "resolve model path") + } + info, err := os.Stat(abs) + if err != nil || info.IsDir() { + return nil, errfmt.New(errfmt.ExitNotFound, "model not found: %s", path) + } + models[i] = abs + } + model := models[0] colors, err := checkFilaments(req) if err != nil { return nil, err @@ -417,19 +437,35 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err return nil, errfmt.Wrap(errfmt.ExitConfig, err, "remove stale slicer output %s", stale) } } - src := model - if ext == ".step" || ext == ".stp" { - src, err = prepareSTEP(ctx, req.StepPython, model, work) + sources := append([]string(nil), models...) + for i, path := range sources { + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".step" || ext == ".stp" { + stepWork := filepath.Join(work, fmt.Sprintf("input-%d", i+1)) + if err := os.MkdirAll(stepWork, 0o750); err != nil { + return nil, errfmt.Wrap(errfmt.ExitConfig, err, "create STEP work dir") + } + sources[i], err = prepareSTEP(ctx, req.StepPython, path, stepWork) + if err != nil { + return nil, err + } + } + } + arrange := "1" + if len(models) > 1 { + src, err := arrangeModels(ctx, st, req, cfgs, paths, filPaths, sources, work) if err != nil { return nil, err } + sources = []string{src} + arrange = "0" } orient := "0" - if req.AutoOrient { + if req.AutoOrient && len(models) == 1 { orient = "1" } args := []string{ - "--slice", "0", "--arrange", "1", "--orient", orient, + "--slice", "0", "--arrange", arrange, "--orient", orient, "--allow-rotations=0", "--avoid-extrusion-cali-region", "--load-settings", paths["machine"] + ";" + paths["process"], "--load-filaments", strings.Join(filPaths, ";"), } if len(colors) > 0 { @@ -458,7 +494,8 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err } args = append(args, "--load-custom-gcodes", paths["custom_gcode"]) } - args = append(args, "--outputdir", work, "--export-3mf", threemf, src) + args = append(args, "--outputdir", work, "--export-3mf", threemf) + args = append(args, sources...) ctx, cancel := context.WithTimeout(ctx, 15*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, st.Binary, args...) //nolint:gosec // discovered slicer binary, validated args @@ -498,6 +535,9 @@ 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) } + if len(models) > 1 && len(res.SlicedPlates) != 1 { + return nil, errfmt.New(errfmt.ExitSliceFailed, "batch must fit on one plate; slicer produced %d plates", len(res.SlicedPlates)).WithHint("split the batch or reduce model sizes; no outputs published") + } warnings := []string{} for _, p := range res.SlicedPlates { if p.WarningMessage != "" { @@ -512,6 +552,15 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err if err := checkChanges(filepath.Join(work, threemf), tops, logPath); err != nil { return nil, err } + if len(models) > 1 { + arrangedJob, err := job.Inspect(filepath.Join(work, threemf), 1) + if err != nil { + return nil, err + } + if len(arrangedJob.Objects) < len(models) { + return nil, errfmt.New(errfmt.ExitSliceFailed, "batch lost objects: %d inputs but only %d plate objects", len(models), len(arrangedJob.Objects)).WithHint("check the slicer log; no outputs published") + } + } png := filepath.Join(out, name+".png") if err := extract(filepath.Join(work, threemf), "Metadata/plate_1.png", png); err != nil { if req.Strict { @@ -528,7 +577,7 @@ func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, err return nil, err } sum := &Summary{ - Name: name, Recipe: req.Recipe.Name, Description: req.Recipe.Description, Model: model, + Name: name, Recipe: req.Recipe.Name, Description: req.Recipe.Description, Model: model, Models: models, PartSpacingMM: req.PartSpacingMM, Gcode3MF: final, PreviewPNG: png, SummaryJSON: filepath.Join(out, name+".summary.json"), TimeS: j.PredictionS, Time: j.TotalTime, WeightG: j.WeightG, Layers: j.Layers, Filaments: j.Filaments, FilamentChanges: j.FilamentChanges, FilamentPresets: cfgs.FilamentPresets, diff --git a/internal/testutil/threemf.go b/internal/testutil/threemf.go index 83cf637..451d3a7 100644 --- a/internal/testutil/threemf.go +++ b/internal/testutil/threemf.go @@ -15,6 +15,8 @@ import ( // Opts describes a synthetic sliced plate. type Opts struct { + Gcode string // optional synthetic toolpath + Objects []string // optional object names PrinterModel string Nozzle string BedType string @@ -58,6 +60,11 @@ func Write(t *testing.T, dir, name string, o Opts) string { _, _ = w.Write(b) } gcode := []byte("; HEADER_BLOCK_START\n; model printing time: 20m 1s; total estimated time: 27m 14s\n; total layer number: 113\n; HEADER_BLOCK_END\nG28\n") + if o.Gcode != "" { + gcode = []byte(o.Gcode) + } else { + gcode = append(gcode, []byte("G90\nM83\n; layer num/total_layer_count: 1/113\n; OBJECT_ID: 1\nG1 X50 Y50\nG1 X60 Y50 E1\n")...) + } if !o.NoGcode { add("Metadata/plate_1.gcode", gcode) sum := md5.Sum(gcode) //nolint:gosec // fixture @@ -97,7 +104,15 @@ func Write(t *testing.T, dir, name string, o Opts) string { total += g fmt.Fprintf(&fx, ``, i+1, id, typ, g) } - si := fmt.Sprintf(`%s`, total, fx.String()) + objects := o.Objects + if len(objects) == 0 { + objects = []string{"part.stl"} + } + var ox strings.Builder + for i, name := range objects { + fmt.Fprintf(&ox, ``, i+1, name) + } + si := fmt.Sprintf(`%s%s`, total, ox.String(), fx.String()) add("Metadata/slice_info.config", []byte(si)) add("Metadata/plate_1.png", []byte("\x89PNG\r\n\x1a\n")) if err := zw.Close(); err != nil { From 86624a07373ce1be4d96252adfe276c818ad6f1e Mon Sep 17 00:00:00 2001 From: Matt Voska Date: Fri, 6 Nov 2026 16:46:28 -0300 Subject: [PATCH 2/2] refactor(slice): use one model input field --- internal/slicer/slice.go | 6 +----- internal/slicer/slicer_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/internal/slicer/slice.go b/internal/slicer/slice.go index 8615f32..90cc7b9 100644 --- a/internal/slicer/slice.go +++ b/internal/slicer/slice.go @@ -42,9 +42,8 @@ var CLIErrors = map[int]string{ // Request is one slice job. type Request struct { - Models []string // separate model files; Model remains for single-input callers + Models []string PartSpacingMM float64 - 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" @@ -352,9 +351,6 @@ type result struct { // Run slices req with st and writes outputs into req.OutDir. func Run(ctx context.Context, st *Studio, ix *Index, req Request) (*Summary, error) { models := req.Models - if len(models) == 0 { - models = []string{req.Model} - } if req.PartSpacingMM == 0 { req.PartSpacingMM = 3 } diff --git a/internal/slicer/slicer_test.go b/internal/slicer/slicer_test.go index d58989f..ceb4359 100644 --- a/internal/slicer/slicer_test.go +++ b/internal/slicer/slicer_test.go @@ -226,7 +226,7 @@ func TestRunWithFakeStudio(t *testing.T) { st := &Studio{Binary: bin, Resources: "testdata/resources"} r := recipe.Recipe{Name: "t", Process: "0.20mm Standard", Filament: "Bambu PLA Basic"} rq := req(r) - rq.Model, rq.Name, rq.OutDir, rq.WorkDir = model, "part-t", filepath.Join(dir, "out"), filepath.Join(dir, "work") + rq.Models, rq.Name, rq.OutDir, rq.WorkDir = []string{model}, "part-t", filepath.Join(dir, "out"), filepath.Join(dir, "work") sum, err := Run(context.Background(), st, index(t), rq) if err != nil { t.Fatal(err) @@ -252,9 +252,9 @@ func TestRunWithFakeStudio(t *testing.T) { t.Fatalf("failure mapping: %+v", e) } - rq.Model = filepath.Join(dir, "part.step") + rq.Models = []string{filepath.Join(dir, "part.step")} rq.StepPython = filepath.Join(dir, "missing-python") - _ = os.WriteFile(rq.Model, []byte("ISO-10303-21;"), 0o600) + _ = os.WriteFile(rq.Models[0], []byte("ISO-10303-21;"), 0o600) if _, err := Run(context.Background(), st, index(t), rq); errfmt.As(err).Code != errfmt.ExitConfig { t.Fatalf("STEP needs a CadQuery interpreter: %v", err) } @@ -319,7 +319,7 @@ func twoFilamentRun(t *testing.T, mut func(*Request)) (*Summary, Request, error) model := filepath.Join(dir, "part.3mf") _ = os.WriteFile(model, []byte("PK"), 0o600) rq := req(recipe.Recipe{Name: "t", Process: "0.20mm Standard", Filament: "Bambu PLA Basic"}) - rq.Model, rq.Name, rq.OutDir, rq.WorkDir = model, "two", filepath.Join(dir, "out"), filepath.Join(dir, "work") + rq.Models, rq.Name, rq.OutDir, rq.WorkDir = []string{model}, "two", filepath.Join(dir, "out"), filepath.Join(dir, "work") rq.Filaments, rq.Colors, rq.ChangeZs = []string{"Generic PLA", "Bambu PLA Basic"}, []string{"ffffff", "#000000"}, []float64{4.0} if mut != nil { mut(&rq)