@@ -259,8 +350,11 @@ function removeRow(entry: QueueEntry): void {
{{ row.descriptor.counts.done }} / {{ row.descriptor.counts.total }} printed
-
+
+
+ Edit drawer
+
@@ -332,6 +426,10 @@ function removeRow(entry: QueueEntry): void {
+
+
+ Edit
+
([]);
+
+async function downloadDesign(format: 'stl' | '3mf'): Promise {
+ saveError.value = null;
+ downloadWarnings.value = [];
+ try {
+ const product = designedProduct();
+ downloadWarnings.value =
+ format === 'stl'
+ ? await downloadProductStl(product)
+ : await downloadProduct3mf(product);
+ } catch (error) {
+ saveError.value = error instanceof Error ? error.message : 'The download failed.';
+ }
+}
+
function cancelEdit(): void {
app.stopEditing();
resetForm();
@@ -292,10 +323,24 @@ const { meshes, errorMessage } = useBinPreview(() => previewSpec.value, generate
{{ saveError }}
+
+ {{ warning }}
+
+
-
- {{ editingEntry !== null ? 'Save changes' : 'Add to queue' }}
-
+
([]);
+
+async function downloadDesign(format: 'stl' | '3mf'): Promise {
+ if (!formValid.value) return;
+ saveError.value = null;
+ downloadWarnings.value = [];
+ try {
+ for (const { product } of pending.value.batches) {
+ const warnings =
+ format === 'stl'
+ ? await downloadProductStl(product)
+ : await downloadProduct3mf(product);
+ downloadWarnings.value = [...downloadWarnings.value, ...warnings];
+ }
+ } catch (error) {
+ saveError.value = error instanceof Error ? error.message : 'The download failed.';
+ }
+}
+
function cancelEdit(): void {
app.stopEditing();
}
@@ -710,16 +765,14 @@ const { meshes, errorMessage } = useBinPreview(() => previewProduct.value, gener
-
- {{ editingEntry !== null ? 'Save changes' : 'Add to queue' }}
-
+ @add="addToQueue"
+ @add-to-plate="addToPlate"
+ @download="downloadDesign"
+ />
Cancel edit
@@ -730,6 +783,15 @@ const { meshes, errorMessage } = useBinPreview(() => previewProduct.value, gener
{{ saveError }}
+
+ {{ warning }}
+
([]);
+
+async function downloadDesign(format: 'stl' | '3mf'): Promise {
+ if (submitBlocker.value !== null) return;
+ saveError.value = null;
+ downloadWarnings.value = [];
+ try {
+ const product = designedProduct();
+ downloadWarnings.value =
+ format === 'stl'
+ ? await downloadProductStl(product)
+ : await downloadProduct3mf(product);
+ } catch (error) {
+ saveError.value = error instanceof Error ? error.message : 'The download failed.';
+ }
+}
+
function cancelEdit(): void {
app.stopEditing();
resetTab();
@@ -1116,16 +1149,14 @@ function editingTitle(entry: QueueEntry): string {
{{ editError }}
-
- {{ editingEntry !== null ? 'Save changes' : 'Add to queue' }}
-
+ @add="saveEntry"
+ @add-to-plate="addToPlate"
+ @download="downloadDesign"
+ />
Cancel edit
@@ -1136,6 +1167,15 @@ function editingTitle(entry: QueueEntry): string {
{{ saveError }}
+
+ {{ warning }}
+
{
return saved;
}
-async function addToQueue(): Promise {
- addError.value = null;
- photoNote.value = null;
- // The button is disabled while a blocker stands; this guard backs it up.
- if (submitBlocker.value !== null) {
- addError.value = submitBlocker.value;
- return;
- }
+/**
+ * The product the layout currently designs, built around the given stored
+ * trace sessions: the queueing paths pass the sessions they just stored, and
+ * a direct download passes none, since nothing references a photo then.
+ */
+function designedProduct(traceSessions: TraceSession[]): Product {
const params = pocketParams.value;
const pockets: BinPockets = { tools: params.tools, placements: params.placements };
- const cleanNotes = notes.value.trim();
- // Sessions must be stored before the queue mutation: persisting the plan
- // sweeps stored photos no entry references, so the reference and the photo
- // have to land in that order.
- const traceSessions = await storeReferencedSessions();
const bin: TracedBin = {
origin: 'traced',
gridX: params.gridX,
@@ -355,14 +350,28 @@ async function addToQueue(): Promise {
edits: trace.edits.map(cloneEdit),
traceSessions,
};
- let product: Product;
if (params.fusedLabel != null) {
- product = { kind: 'binWithInsert', bin, insert: params.fusedLabel, fused: true };
- } else if (params.insert !== null) {
- product = { kind: 'binWithInsert', bin, insert: params.insert };
- } else {
- product = { kind: 'bin', bin, labelSlot: params.labelSlot };
+ return { kind: 'binWithInsert', bin, insert: params.fusedLabel, fused: true };
+ }
+ if (params.insert !== null) {
+ return { kind: 'binWithInsert', bin, insert: params.insert };
}
+ return { kind: 'bin', bin, labelSlot: params.labelSlot };
+}
+
+async function addToQueue(): Promise {
+ addError.value = null;
+ photoNote.value = null;
+ // The button is disabled while a blocker stands; this guard backs it up.
+ if (submitBlocker.value !== null) {
+ addError.value = submitBlocker.value;
+ return;
+ }
+ const cleanNotes = notes.value.trim();
+ // Sessions must be stored before the queue mutation: persisting the plan
+ // sweeps stored photos no entry references, so the reference and the photo
+ // have to land in that order.
+ const product = designedProduct(await storeReferencedSessions());
if (props.editingEntry !== null) {
addError.value = queue.update(props.editingEntry.id, {
product,
@@ -372,14 +381,56 @@ async function addToQueue(): Promise {
if (addError.value !== null) return;
app.stopEditing();
} else {
- addError.value = queue.add(product, quantity.value, cleanNotes);
- if (addError.value !== null) return;
+ const result = queue.add(product, quantity.value, cleanNotes);
+ addError.value = result.problem;
+ if (result.problem !== null) return;
}
trace.reset();
quantity.value = 1;
emit('saved');
}
+/** Queues the bin and moves it onto the newest build plate in one step. */
+async function addToPlate(): Promise {
+ addError.value = null;
+ photoNote.value = null;
+ if (submitBlocker.value !== null) {
+ addError.value = submitBlocker.value;
+ return;
+ }
+ const cleanNotes = notes.value.trim();
+ const product = designedProduct(await storeReferencedSessions());
+ const result = queue.add(product, quantity.value, cleanNotes);
+ addError.value = result.problem;
+ if (result.problem !== null) return;
+ queue.addEntryToNewestBatch(result.id, quantity.value);
+ trace.reset();
+ quantity.value = 1;
+ emit('saved');
+}
+
+// Direct downloads of the designed bin, without touching the queue. The
+// warnings a generation can return are shown, never dropped.
+const downloadWarnings = ref([]);
+
+async function downloadDesign(format: 'stl' | '3mf'): Promise {
+ addError.value = null;
+ downloadWarnings.value = [];
+ if (submitBlocker.value !== null) {
+ addError.value = submitBlocker.value;
+ return;
+ }
+ try {
+ const product = designedProduct([]);
+ downloadWarnings.value =
+ format === 'stl'
+ ? await downloadProductStl(product)
+ : await downloadProduct3mf(product);
+ } catch (error) {
+ addError.value = error instanceof Error ? error.message : 'The download failed.';
+ }
+}
+
/** Display title of the entry being edited. */
function editingTitle(entry: QueueEntry): string {
const insert = insertOf(entry.product);
@@ -471,6 +522,15 @@ function cancelEdit(): void {
{{ photoNote }}
+
+ {{ warning }}
+
{{ trace.refinishNotice }}
@@ -481,14 +541,14 @@ function cancelEdit(): void {
-
- {{ editingEntry !== null ? 'Save changes' : 'Add to queue' }}
-
+ size="default"
+ @add="addToQueue"
+ @add-to-plate="addToPlate"
+ @download="downloadDesign"
+ />
diff --git a/web/src/engine/plan/batches.ts b/web/src/engine/plan/batches.ts
index 72ad314..0fb7e7a 100644
--- a/web/src/engine/plan/batches.ts
+++ b/web/src/engine/plan/batches.ts
@@ -120,6 +120,35 @@ export function createBatch(
};
}
+/**
+ * Appends one queue entry's amount to an existing batch as a new item. The
+ * product is snapshotted like createBatch does, so the batch keeps working
+ * after the entry is edited or deleted; the count is clamped to the entry's
+ * quantity the same way. The queue itself is not touched: the caller removes
+ * the taken amount from the entry, mirroring how createBatch returns updated
+ * entries.
+ */
+export function addEntryToBatch(
+ batch: PrintBatch,
+ entry: QueueEntry,
+ count: number,
+ id: string,
+): PrintBatch {
+ const taken = Math.min(Math.max(1, Math.floor(count)), entry.quantity);
+ return {
+ ...batch,
+ items: [
+ ...batch.items,
+ {
+ id,
+ product: snapshotProduct(entry.product),
+ count: taken,
+ sourceEntryId: entry.id,
+ },
+ ],
+ };
+}
+
/** Result of confirming an amount of a batch item. */
export interface ConfirmBatchItemResult {
/** The updated batch, or null when it emptied out. */
diff --git a/web/src/stores/binQueue.ts b/web/src/stores/binQueue.ts
index 70039ae..54ccbf9 100644
--- a/web/src/stores/binQueue.ts
+++ b/web/src/stores/binQueue.ts
@@ -30,6 +30,7 @@ import {
} from '../engine/baseplate/drawerFill';
import { CLIP_TOLERANCE_DEFAULT, type BaseplateBrim } from '../engine/baseplate/constants';
import {
+ addEntryToBatch,
confirmBatchItem,
createBatch,
failBatchItem,
@@ -47,6 +48,12 @@ import { useCutout } from './cutout';
const STORAGE_KEY = 'storeforge.plan';
+/**
+ * Result of an interactive add: the new entry's id on success, or the
+ * user-worded refusal with nothing queued. Exactly one of the two is set.
+ */
+export type AddResult = { id: string; problem: null } | { id: null; problem: string };
+
/**
* Checks a product and quantity with the plan file's own validators before an
* interactive queue mutation. Returns null when valid, otherwise the
@@ -304,12 +311,12 @@ export const useBinQueue = defineStore('binQueue', {
* add lands here, so this is the single place an invalid product or
* quantity is refused before it can be persisted; the file importer's
* validator is reused so the queue and the file format cannot disagree.
- * Returns null on success or the user-worded problem, in which case the
- * queue is not touched.
+ * Returns the new entry's id on success or the user-worded problem, in
+ * which case the queue is not touched.
*/
- add(product: Product, quantity = 1, notes?: string): string | null {
+ add(product: Product, quantity = 1, notes?: string): AddResult {
const problem = validateQueueMutation(product, quantity);
- if (problem !== null) return problem;
+ if (problem !== null) return { id: null, problem };
const entry: QueueEntry = {
id: crypto.randomUUID(),
quantity,
@@ -319,7 +326,7 @@ export const useBinQueue = defineStore('binQueue', {
if (notes !== undefined && notes !== '') entry.notes = notes;
this.entries.push(entry);
this.persist();
- return null;
+ return { id: entry.id, problem: null };
},
/**
* Applies partial changes to an existing entry. The product is replaced
@@ -437,6 +444,38 @@ export const useBinQueue = defineStore('binQueue', {
this.persist();
return result.batch.id;
},
+ /**
+ * Moves an amount of a queue entry onto the newest build plate batch. When
+ * no batch exists yet a new one is created for it, named the same way the
+ * queue page names a new plate. The taken amount leaves the queue exactly
+ * as batch creation does; nothing happens when the entry is missing.
+ */
+ addEntryToNewestBatch(entryId: string, count: number): void {
+ const entry = this.entryById(entryId);
+ if (entry === null) return;
+ const newest = this.batches.reduce(
+ (latest, batch) =>
+ latest === null || batch.createdAt > latest.createdAt ? batch : latest,
+ null,
+ );
+ if (newest === null) {
+ this.createBatch(
+ [{ entryId, count }],
+ `Build plate ${this.batches.length + 1}`,
+ );
+ return;
+ }
+ const taken = Math.min(Math.max(1, Math.floor(count)), entry.quantity);
+ const updated = addEntryToBatch(newest, entry, taken, crypto.randomUUID());
+ this.batches = this.batches.map((b) => (b.id === newest.id ? updated : b));
+ this.entries =
+ taken === entry.quantity
+ ? this.entries.filter((e) => e.id !== entryId)
+ : this.entries.map((e) =>
+ e.id === entryId ? { ...e, quantity: e.quantity - taken } : e,
+ );
+ this.persist();
+ },
/** Renames a batch. */
renameBatch(batchId: string, name: string) {
const batch = this.batchById(batchId);
diff --git a/web/tests/plan/batches.spec.ts b/web/tests/plan/batches.spec.ts
index 90760b5..9425d50 100644
--- a/web/tests/plan/batches.spec.ts
+++ b/web/tests/plan/batches.spec.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
+ addEntryToBatch,
confirmBatchItem,
createBatch,
failBatchItem,
@@ -95,6 +96,46 @@ describe('createBatch', () => {
});
});
+describe('addEntryToBatch', () => {
+ it('appends the entry as a new item and leaves existing items in place', () => {
+ const batch = makeBatch([entry()], [{ entryId: 'a1', count: 2 }]).batch!;
+ const other = entry({
+ id: 'b2',
+ quantity: 3,
+ product: { kind: 'binWithInsert', bin: manualBin(), insert: { text: 'M5 nuts', text2: '', icon: null } },
+ });
+ const updated = addEntryToBatch(batch, other, 3, 'item9');
+ expect(updated.items.map((i) => i.id)).toEqual(['item1', 'item9']);
+ expect(updated.items[1]).toEqual({
+ id: 'item9',
+ product: snapshotProduct(other.product),
+ count: 3,
+ sourceEntryId: 'b2',
+ });
+ });
+
+ it('does not mutate the given batch', () => {
+ const batch = makeBatch([entry()], [{ entryId: 'a1', count: 2 }]).batch!;
+ addEntryToBatch(batch, entry({ id: 'b2' }), 1, 'item9');
+ expect(batch.items.map((i) => i.id)).toEqual(['item1']);
+ });
+
+ it('clamps the count to the entry quantity and floors to at least 1', () => {
+ const batch = makeBatch([entry()], [{ entryId: 'a1', count: 1 }]).batch!;
+ expect(addEntryToBatch(batch, entry({ id: 'b2', quantity: 4 }), 99, 'x').items[1].count).toBe(4);
+ expect(addEntryToBatch(batch, entry({ id: 'b2', quantity: 4 }), 0, 'x').items[1].count).toBe(1);
+ });
+
+ it('snapshots the product so a later entry edit does not reach the batch', () => {
+ const source = entry();
+ const batch = makeBatch([entry()], [{ entryId: 'a1', count: 1 }]).batch!;
+ const updated = addEntryToBatch(batch, source, 1, 'item9');
+ (source.product as { insert: { text: string } }).insert.text = 'edited later';
+ const item = updated.items[1];
+ expect(item.product.kind === 'binWithInsert' && item.product.insert.text).toBe('M3 bolts');
+ });
+});
+
describe('confirmBatchItem', () => {
const batch = (): PrintBatch =>
makeBatch(