bambu-rs 0.1.0

AI-agent-friendly Bambu Lab 3D printer CLI & library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
import { useEffect, useRef, useState } from "react";

type Mode = "mesh" | "toolpath";
type THREE = typeof import("three");

// 3D viewer with two modes:
//  • mesh     — the solid model, parsed from the .3mf's embedded object meshes
//               (three's 3MFLoader won't follow Bambu's external-component refs,
//               so the server hands us the mesh XML at /api/file/mesh and we
//               build the geometry ourselves).
//  • toolpath — the sliced gcode path, with playback: a scrubber + play/pause
//               that reveals the extrusion trail as a head marker walks the path,
//               so you can watch how the head moves.
// three.js is dynamically imported (a lazy chunk) only when the viewer opens.
//
// `ModelView` is the embeddable engine (mode toggle + canvas + playback) with no
// modal chrome, so it can be dropped into the file-detail screen and the
// pre-print dialog as well as a standalone modal.
export function ModelView({ path }: { path: string }) {
  const is3mf = path.toLowerCase().endsWith(".3mf");
  const mount = useRef<HTMLDivElement>(null);
  const [mode, setMode] = useState<Mode>(is3mf ? "mesh" : "toolpath");
  const [status, setStatus] = useState("loading…");
  // `noMesh` = this .3mf embeds no mesh, so the mesh toggle is disabled (rather
  // than silently bouncing back to toolpath). `dims` is the model's size in mm.
  const [noMesh, setNoMesh] = useState(false);
  const [dims, setDims] = useState<{ x: number; y: number; z: number } | null>(null);

  // Playback state. The render loop owns `head` (a ref); React state mirrors it
  // for the slider. `seek` lets the slider jump the loop; `playing` gates it.
  const [playing, setPlaying] = useState(true);
  const [progress, setProgress] = useState(0); // 0..1
  const [scrubbable, setScrubbable] = useState(false);
  const headRef = useRef(0);
  const totalRef = useRef(0);
  const playingRef = useRef(true);
  const seekRef = useRef<number | null>(null);

  // Reset mesh-availability only when the file changes — not on a mode switch
  // (which would clear the "no mesh" flag the moment we act on it).
  useEffect(() => {
    setNoMesh(false);
  }, [path]);

  useEffect(() => {
    let disposed = false;
    let cleanup = () => {};
    void (async () => {
      try {
        setStatus("loading…");
        setScrubbable(false);
        const THREE = await import("three");
        const { OrbitControls } = await import("three/addons/controls/OrbitControls.js");

        // Build the object (+ playback handle) for the active mode.
        let object: import("three").Object3D;
        let pb: Playback | null = null;
        if (mode === "mesh") {
          const r = await fetch(`/api/file/mesh?name=${encodeURIComponent(path)}`);
          if (!r.ok) return setStatus(`couldn't load mesh (HTTP ${r.status})`);
          const models = ((await r.json()) as { models?: string[] }).models ?? [];
          if (!models.length) {
            // This .3mf embeds no mesh (e.g. a plate-only slice) — mark mesh
            // unavailable so its toggle is disabled, and show the toolpath.
            if (!disposed) {
              setNoMesh(true);
              setMode("toolpath");
            }
            return;
          }
          object = buildMesh(models, THREE);
        } else {
          const url = is3mf
            ? `/api/file/gcode?name=${encodeURIComponent(path)}&plate=1`
            : `/api/file/raw?name=${encodeURIComponent(path)}`;
          const r = await fetch(url);
          if (!r.ok) return setStatus(`couldn't load toolpath (HTTP ${r.status})`);
          const built = buildToolpath(await r.text(), THREE);
          if (!built) return setStatus("no toolpath in this file");
          object = built.object;
          pb = built.playback;
        }
        if (disposed || !mount.current) return;

        const el = mount.current;
        const w = el.clientWidth || 600;
        const h = el.clientHeight || 420;
        const scene = new THREE.Scene();
        const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 100000);
        // Transparent clear so the CSS white→grey gradient backdrop shows through.
        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
        renderer.setClearColor(0x000000, 0);
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.setSize(w, h);
        el.appendChild(renderer.domElement);
        scene.add(new THREE.HemisphereLight(0xffffff, 0xb6bac2, 1.1));
        const key = new THREE.DirectionalLight(0xffffff, 0.85);
        key.position.set(120, -160, 220);
        scene.add(key);
        const fill = new THREE.DirectionalLight(0xffffff, 0.35);
        fill.position.set(-150, 130, 90);
        scene.add(fill);

        // Lay the model on a build plate centred at the origin (so the plate
        // centre is the view centre), Z up like the printer.
        const BED = 180; // A1 mini build plate (mm)
        const CELL = 20; // grid cell size (mm) — the scale reference
        const box = new THREE.Box3().setFromObject(object);
        const size = box.getSize(new THREE.Vector3());
        const ctr = box.getCenter(new THREE.Vector3());
        setDims({ x: size.x, y: size.y, z: size.z });
        if (pb) {
          // gcode is in absolute bed coordinates — shift so the bed centre is 0.
          object.position.set(-BED / 2, -BED / 2, 0);
        } else {
          // mesh is in local coordinates — centre its footprint, rest base on bed.
          object.position.set(-ctr.x, -ctr.y, -box.min.z);
        }
        scene.add(object);

        // The build plate: a soft surface + a grid that conveys scale.
        const plate = new THREE.Mesh(
          new THREE.PlaneGeometry(BED, BED),
          new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.5 }),
        );
        plate.position.z = -0.05;
        scene.add(plate);
        const grid = new THREE.GridHelper(BED, BED / CELL, 0x8b93a0, 0xccd1d8);
        grid.rotation.x = Math.PI / 2; // GridHelper sits in XZ; lay it flat in XY (Z up)
        scene.add(grid);

        // Frame the whole plate, looking at its centre.
        camera.up.set(0, 0, 1);
        const R = BED * 0.62;
        const vFov = (camera.fov * Math.PI) / 180;
        let dist = R / Math.sin(vFov / 2);
        if (camera.aspect < 1) dist /= camera.aspect;
        dist *= 1.05;
        camera.position.copy(new THREE.Vector3(0.9, -0.9, 0.72).normalize().multiplyScalar(dist));
        camera.near = Math.max(dist / 100, 0.1);
        camera.far = dist + BED * 4;
        camera.lookAt(0, 0, 0);
        camera.updateProjectionMatrix();
        const controls = new OrbitControls(camera, renderer.domElement);
        controls.target.set(0, 0, 0);
        controls.enableDamping = true;

        // Wire up playback (toolpath only).
        if (pb) {
          totalRef.current = pb.lastPoint;
          headRef.current = 0;
          playingRef.current = true;
          setPlaying(true);
          setScrubbable(true);
          pb.apply(0);
        }
        setStatus("");

        let raf = 0;
        let frame = 0;
        const step = pb ? Math.max(1, pb.lastPoint / 720) : 0; // ~12 s full play
        const tick = () => {
          raf = requestAnimationFrame(tick);
          controls.update();
          if (pb) {
            if (seekRef.current != null) {
              headRef.current = seekRef.current;
              seekRef.current = null;
            } else if (playingRef.current) {
              headRef.current = Math.min(pb.lastPoint, headRef.current + step);
              if (headRef.current >= pb.lastPoint) {
                playingRef.current = false;
                setPlaying(false);
              }
            }
            pb.apply(headRef.current);
            if (frame++ % 4 === 0) setProgress(headRef.current / (pb.lastPoint || 1));
          }
          renderer.render(scene, camera);
        };
        tick();

        const onResize = () => {
          const nw = el.clientWidth || 600;
          const nh = el.clientHeight || 420;
          camera.aspect = nw / nh;
          camera.updateProjectionMatrix();
          renderer.setSize(nw, nh);
        };
        window.addEventListener("resize", onResize);
        cleanup = () => {
          cancelAnimationFrame(raf);
          window.removeEventListener("resize", onResize);
          controls.dispose();
          renderer.dispose();
          renderer.domElement.remove();
        };
      } catch {
        setStatus("couldn't render this model");
      }
    })();
    return () => {
      disposed = true;
      cleanup();
    };
  }, [path, mode, is3mf]);

  const togglePlay = () => {
    const np = !playing;
    if (np && headRef.current >= totalRef.current) {
      headRef.current = 0; // restart from the beginning
      seekRef.current = 0;
    }
    playingRef.current = np;
    setPlaying(np);
  };
  const onScrub = (v: number) => {
    seekRef.current = v;
    playingRef.current = false;
    setPlaying(false);
    setProgress(totalRef.current ? v / totalRef.current : 0);
  };

  return (
    <div className="modelview">
      {is3mf && (
        <div className="viewer__modes" role="group" aria-label="view mode">
          <button
            className={`btn btn--sm${mode === "mesh" ? " is-active" : ""}`}
            aria-pressed={mode === "mesh"}
            disabled={noMesh}
            title={noMesh ? "this file has no embedded mesh (toolpath only)" : undefined}
            onClick={() => setMode("mesh")}
            data-testid="viewer-mode-mesh"
          >
            mesh
          </button>
          <button
            className={`btn btn--sm${mode === "toolpath" ? " is-active" : ""}`}
            aria-pressed={mode === "toolpath"}
            onClick={() => setMode("toolpath")}
            data-testid="viewer-mode-toolpath"
          >
            toolpath
          </button>
        </div>
      )}
      <div className="viewer__canvas" ref={mount} data-testid="viewer-canvas" />
      {mode === "toolpath" && scrubbable && (
        <div className="viewer__play" data-testid="viewer-play-bar">
          <button
            className="btn btn--sm"
            onClick={togglePlay}
            data-testid="viewer-play"
            aria-label={playing ? "pause" : "play"}
          >
            {playing ? "⏸" : "▶"}
          </button>
          <input
            className="viewer__scrub"
            type="range"
            min={0}
            max={totalRef.current || 1}
            value={Math.round(progress * (totalRef.current || 1))}
            onChange={(e) => onScrub(Number(e.target.value))}
            data-testid="viewer-scrub"
            aria-label="playback position"
          />
          <span className="viewer__pct mono">{Math.round(progress * 100)}%</span>
        </div>
      )}
      {dims && !status && (
        <div className="dim viewer__dims" data-testid="viewer-dims">
          {fmtMm(dims.x)} × {fmtMm(dims.y)} × {fmtMm(dims.z)} mm · 180 mm plate, 20 mm grid
        </div>
      )}
      {status && (
        <div className="dim viewer__status" data-testid="viewer-status">
          {status}
        </div>
      )}
    </div>
  );
}

// Format a millimetre extent: whole numbers for big parts, one decimal for small.
function fmtMm(v: number): string {
  return v >= 10 ? String(Math.round(v)) : v.toFixed(1);
}

// A playback handle for the toolpath: reveal the extrusion trail up to point `k`
// and move the head marker there.
interface Playback {
  lastPoint: number;
  apply: (k: number) => void;
}

// Parse gcode into an ordered path and build a line (extrusion only) + a head
// marker, returning a Playback that reveals the line via drawRange.
function buildToolpath(
  text: string,
  THREE: THREE,
): { object: import("three").Object3D; playback: Playback } | null {
  let x = 0;
  let y = 0;
  let z = 0;
  let e = 0;
  let absXYZ = true;
  let absE = true;
  const pts: number[] = [0, 0, 0]; // point 0 = origin
  const extr: boolean[] = []; // extr[i] = segment point i → i+1 extrudes
  for (const raw of text.split("\n")) {
    const line = raw.split(";")[0].trim();
    if (!line) continue;
    const up = line.toUpperCase();
    const code = /^[GM]\d+/.exec(up)?.[0];
    if (!code) continue;
    if (code === "G90") absXYZ = true;
    else if (code === "G91") absXYZ = false;
    else if (code === "M82") absE = true;
    else if (code === "M83") absE = false;
    else if (code === "G92") {
      const ev = axis(up, "E");
      if (ev != null) e = ev;
    } else if (code === "G0" || code === "G1") {
      const nx = axis(up, "X");
      const ny = axis(up, "Y");
      const nz = axis(up, "Z");
      const ne = axis(up, "E");
      x = nx == null ? x : absXYZ ? nx : x + nx;
      y = ny == null ? y : absXYZ ? ny : y + ny;
      z = nz == null ? z : absXYZ ? nz : z + nz;
      let extruding = false;
      if (ne != null) {
        const de = absE ? ne - e : ne;
        extruding = de > 1e-6;
        e = absE ? ne : e + ne;
      }
      pts.push(x, y, z);
      extr.push(extruding && code === "G1");
    }
  }
  const nPoints = pts.length / 3;
  if (nPoints < 2) return null;

  // Extrusion segments + a per-point prefix of how many line vertices to reveal.
  const segPos: number[] = [];
  const reveal = new Array<number>(nPoints).fill(0);
  for (let i = 0; i < extr.length; i++) {
    if (extr[i]) {
      segPos.push(pts[i * 3], pts[i * 3 + 1], pts[i * 3 + 2]);
      segPos.push(pts[(i + 1) * 3], pts[(i + 1) * 3 + 1], pts[(i + 1) * 3 + 2]);
    }
    reveal[i + 1] = segPos.length / 3;
  }

  const group = new THREE.Group();
  const geo = new THREE.BufferGeometry();
  geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(segPos), 3));
  const lineMat = new THREE.LineBasicMaterial({ color: 0xb4801f }); // deep amber — reads on light
  const line = new THREE.LineSegments(geo, lineMat);
  geo.setDrawRange(0, 0);
  group.add(line);

  const sphereR = Math.max(0.6, span(pts) / 120);
  const marker = new THREE.Mesh(
    new THREE.SphereGeometry(sphereR, 16, 12),
    new THREE.MeshBasicMaterial({ color: 0x7c2d12 }), // dark — the head, visible on light
  );
  group.add(marker);

  const lastPoint = nPoints - 1;
  const apply = (k: number) => {
    const i = Math.min(lastPoint, Math.max(0, Math.floor(k)));
    geo.setDrawRange(0, reveal[i]);
    marker.position.set(pts[i * 3], pts[i * 3 + 1], pts[i * 3 + 2]);
  };
  return { object: group, playback: { lastPoint, apply } };
}

// Build a solid mesh from the .3mf's object model XML(s).
function buildMesh(models: string[], THREE: THREE): import("three").Object3D {
  const group = new THREE.Group();
  const mat = new THREE.MeshStandardMaterial({
    color: 0x8f98a8, // medium slate — reads as a 3D print on the light backdrop
    metalness: 0.05,
    roughness: 0.72,
  });
  for (const xml of models) {
    const doc = new DOMParser().parseFromString(xml, "application/xml");
    const verts = doc.getElementsByTagName("vertex");
    const tris = doc.getElementsByTagName("triangle");
    if (!verts.length || !tris.length) continue;
    const pos = new Float32Array(verts.length * 3);
    for (let i = 0; i < verts.length; i++) {
      pos[i * 3] = num(verts[i].getAttribute("x"));
      pos[i * 3 + 1] = num(verts[i].getAttribute("y"));
      pos[i * 3 + 2] = num(verts[i].getAttribute("z"));
    }
    const idx: number[] = [];
    for (let i = 0; i < tris.length; i++) {
      idx.push(num(tris[i].getAttribute("v1")), num(tris[i].getAttribute("v2")), num(tris[i].getAttribute("v3")));
    }
    const geo = new THREE.BufferGeometry();
    geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
    geo.setIndex(idx);
    geo.computeVertexNormals();
    group.add(new THREE.Mesh(geo, mat));
  }
  return group;
}

function axis(line: string, a: string): number | null {
  const m = new RegExp(`${a}(-?\\d*\\.?\\d+)`).exec(line);
  return m ? parseFloat(m[1]) : null;
}
function num(s: string | null): number {
  const v = s == null ? 0 : parseFloat(s);
  return Number.isFinite(v) ? v : 0;
}
// Rough overall extent of the path, for sizing the head marker.
function span(pts: number[]): number {
  let min = Infinity;
  let max = -Infinity;
  for (const v of pts) {
    if (v < min) min = v;
    if (v > max) max = v;
  }
  return Number.isFinite(max - min) ? max - min : 100;
}