Added Configuration from Item and Plate

This commit is contained in:
simon 2026-05-26 16:26:31 +02:00
parent 50e677c729
commit c5d2e32355
8 changed files with 766 additions and 8 deletions

75
internal/layout/layout.go Normal file
View File

@ -0,0 +1,75 @@
package layout
import (
"printer.backend/internal/model"
)
// Footprint returns the physical width and height one item occupies on the plate (mm).
// Includes bleed on all sides and the item's margin as a safe zone.
func Footprint(spec model.ItemSpec) (width, height float64) {
w := spec.SizeMM + 2*spec.BleedMM + 2*spec.MarginMM
return w, w
}
// CellPitch returns width and height of one grid cell (footprint + spacing between items).
func CellPitch(spec model.ItemSpec, spacingMM float64) (width, height float64) {
fw, fh := Footprint(spec)
return fw + spacingMM, fh + spacingMM
}
// Pack computes the maximum grid of items that fit on the plate.
func Pack(plate model.Plate, spec model.ItemSpec, spacingMM float64) model.LayoutPreview {
pw := plate.PrintableWidth()
ph := plate.PrintableHeight()
fw, fh := Footprint(spec)
cw, ch := CellPitch(spec, spacingMM)
cols, rows := gridCount(pw, ph, cw, ch)
count := cols * rows
ox := plate.MarginLeft
oy := plate.MarginTop
positions := make([]model.LayoutPosition, 0, count)
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
positions = append(positions, model.LayoutPosition{
XMM: ox + float64(col)*cw,
YMM: oy + float64(row)*ch,
})
}
}
return model.LayoutPreview{
PlateWidthMM: plate.WidthMM,
PlateHeightMM: plate.HeightMM,
PrintableXMM: ox,
PrintableYMM: oy,
PrintableWMM: pw,
PrintableHMM: ph,
CellWidthMM: cw,
CellHeightMM: ch,
FootprintWMM: fw,
FootprintHMM: fh,
SpacingMM: spacingMM,
Columns: cols,
Rows: rows,
Count: count,
Positions: positions,
}
}
func gridCount(printableW, printableH, cellW, cellH float64) (cols, rows int) {
if printableW <= 0 || printableH <= 0 || cellW <= 0 || cellH <= 0 {
return 0, 0
}
cols = int(printableW / cellW)
rows = int(printableH / cellH)
if cols < 0 {
cols = 0
}
if rows < 0 {
rows = 0
}
return cols, rows
}

View File

@ -0,0 +1,46 @@
package layout
import (
"testing"
"printer.backend/internal/model"
)
func TestPack(t *testing.T) {
plate := model.Plate{
WidthMM: 300,
HeightMM: 400,
MarginTop: 10,
MarginRight: 10,
MarginBottom: 10,
MarginLeft: 10,
}
spec := model.ItemSpec{SizeMM: 80, BleedMM: 2, MarginMM: 5}
// footprint = 80+4+10 = 94, cell = 94+2 = 96
// printable 280x380 -> cols=2, rows=3 -> 6
preview := Pack(plate, spec, 2)
if preview.Columns != 2 {
t.Fatalf("columns: got %d want 2", preview.Columns)
}
if preview.Rows != 3 {
t.Fatalf("rows: got %d want 3", preview.Rows)
}
if preview.Count != 6 {
t.Fatalf("count: got %d want 6", preview.Count)
}
if len(preview.Positions) != 6 {
t.Fatalf("positions: got %d want 6", len(preview.Positions))
}
if preview.Positions[0].XMM != 10 || preview.Positions[0].YMM != 10 {
t.Fatalf("first position: got (%v,%v)", preview.Positions[0].XMM, preview.Positions[0].YMM)
}
}
func TestPackZeroCell(t *testing.T) {
plate := model.Plate{WidthMM: 10, HeightMM: 10}
spec := model.ItemSpec{SizeMM: 100}
preview := Pack(plate, spec, 0)
if preview.Count != 0 {
t.Fatalf("expected 0 items, got %d", preview.Count)
}
}

View File

@ -0,0 +1,40 @@
package model
import "time"
// Configuration links a plate and item with layout spacing for maximum packing.
type Configuration struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
PlateID string `json:"plate_id"`
ItemID string `json:"item_id"`
SpacingMM float64 `json:"spacing_mm"`
CreatedAt time.Time `json:"created_at"`
}
// LayoutPosition is the top-left corner of one item on the plate (mm).
type LayoutPosition struct {
XMM float64 `json:"x_mm"`
YMM float64 `json:"y_mm"`
}
// LayoutPreview describes how many items fit on a plate.
type LayoutPreview struct {
PlateID string `json:"plate_id"`
ItemID string `json:"item_id"`
SpacingMM float64 `json:"spacing_mm"`
PlateWidthMM float64 `json:"plate_width_mm"`
PlateHeightMM float64 `json:"plate_height_mm"`
PrintableXMM float64 `json:"printable_x_mm"`
PrintableYMM float64 `json:"printable_y_mm"`
PrintableWMM float64 `json:"printable_width_mm"`
PrintableHMM float64 `json:"printable_height_mm"`
CellWidthMM float64 `json:"cell_width_mm"`
CellHeightMM float64 `json:"cell_height_mm"`
FootprintWMM float64 `json:"footprint_width_mm"`
FootprintHMM float64 `json:"footprint_height_mm"`
Columns int `json:"columns"`
Rows int `json:"rows"`
Count int `json:"count"`
Positions []LayoutPosition `json:"positions"`
}

View File

@ -2,7 +2,8 @@ package paths
// Runtime data directories (relative to process working directory). // Runtime data directories (relative to process working directory).
const ( const (
DataDir = "data" DataDir = "data"
PlatesDir = "data/plates" PlatesDir = "data/plates"
SVGTemplateDir = "data/svg_template" SVGTemplateDir = "data/svg_template"
ConfigurationsDir = "data/configurations"
) )

View File

@ -151,6 +151,63 @@
padding: 0 0.75rem 0.75rem; padding: 0 0.75rem 0.75rem;
} }
.row-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } .row-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
.layout-preview-wrap {
margin-top: 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
overflow: auto;
}
.layout-preview-wrap svg {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}
.layout-stats {
display: flex;
flex-wrap: wrap;
gap: 1rem 2rem;
margin-top: 0.75rem;
font-size: 0.85rem;
}
.layout-stats strong { color: var(--text); }
.layout-stats span { color: var(--muted); }
select {
width: 100%;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.875rem;
}
.config-list {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
.config-entry {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.config-entry header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0;
padding-bottom: 0.5rem;
border-bottom: none;
}
.config-entry header h3 {
font-size: 0.95rem;
font-weight: 600;
}
</style> </style>
</head> </head>
<body> <body>
@ -275,6 +332,88 @@
</form> </form>
</section> </section>
<section class="card wide">
<h2>Konfiguration — Layout-Vorschau</h2>
<p class="muted">Kombiniert Platte und Item; berechnet maximale Stückzahl unter Einhaltung aller Margins und Abstände.</p>
<form @submit.prevent="saveConfiguration()">
<div class="form-grid">
<div>
<label>Name (optional)</label>
<input type="text" x-model="configForm.name" placeholder="z.B. A3 × Sticker 80">
</div>
<div>
<label>Platte</label>
<select x-model="configForm.plate_id" @change="refreshLayoutPreview()" required>
<option value="">— wählen —</option>
<template x-for="p in plates" :key="p.id">
<option :value="p.id" x-text="labelPlate(p)"></option>
</template>
</select>
</div>
<div>
<label>Item</label>
<select x-model="configForm.item_id" @change="refreshLayoutPreview()" required>
<option value="">— wählen —</option>
<template x-for="it in items" :key="it.id">
<option :value="it.id" x-text="labelItem(it)"></option>
</template>
</select>
</div>
<div>
<label>Abstand zwischen Items (mm)</label>
<input type="number" step="0.1" min="0" x-model.number="configForm.spacing_mm" @input="refreshLayoutPreview()">
</div>
</div>
<button type="submit" :disabled="configSaving || !configForm.plate_id || !configForm.item_id">
<span x-text="configSaving ? 'Speichere…' : 'Konfiguration speichern'"></span>
</button>
<p class="msg-err" x-show="configError" x-text="configError"></p>
<p class="msg-ok" x-show="configSuccess" x-text="configSuccess"></p>
</form>
<template x-if="layoutPreview">
<div class="layout-preview-wrap">
<div x-html="layoutPreviewSvg()"></div>
<div class="layout-stats">
<span><strong x-text="layoutPreview.count"></strong> Items passen</span>
<span><strong x-text="layoutPreview.columns + ' × ' + layoutPreview.rows"></strong> Raster</span>
<span>Fußabdruck <strong x-text="layoutPreview.footprint_width_mm.toFixed(1) + ' mm'"></strong></span>
<span>Zellenabstand <strong x-text="layoutPreview.cell_width_mm.toFixed(1) + ' mm'"></strong></span>
<span>Druckfläche <strong x-text="layoutPreview.printable_width_mm.toFixed(1) + ' × ' + layoutPreview.printable_height_mm.toFixed(1) + ' mm'"></strong></span>
</div>
</div>
</template>
<p class="empty" x-show="!layoutPreview && configForm.plate_id && configForm.item_id && !layoutLoading">
Keine Vorschau (Platte zu klein oder API-Fehler).
</p>
<p class="muted" x-show="layoutLoading">Berechne Layout…</p>
</section>
<section class="card wide" x-show="configurations.length > 0">
<h2>Gespeicherte Konfigurationen</h2>
<div class="config-list">
<template x-for="c in configurations" :key="c.id">
<article class="config-entry">
<header>
<div>
<h3 x-text="c.name || ('Konfiguration ' + c.id.slice(0, 8))"></h3>
<p class="muted" x-text="configSummary(c)"></p>
</div>
<button type="button" class="danger" @click="deleteConfiguration(c.id)">Löschen</button>
</header>
<p class="msg-err" x-show="c.preview_error" x-text="c.preview_error"></p>
<div class="layout-preview-wrap" x-show="c.preview && !c.preview_error">
<div x-html="layoutPreviewSvg(c.preview)"></div>
<div class="layout-stats">
<span><strong x-text="c.preview.count"></strong> Items</span>
<span><strong x-text="c.preview.columns + ' × ' + c.preview.rows"></strong></span>
</div>
</div>
</article>
</template>
</div>
</section>
<section class="card wide"> <section class="card wide">
<h2>Items</h2> <h2>Items</h2>
<template x-if="items.length === 0 && !itemsLoading"> <template x-if="items.length === 0 && !itemsLoading">
@ -335,6 +474,20 @@
padding_mm: 3, padding_mm: 3,
}, },
configurations: [],
configSaving: false,
configError: '',
configSuccess: '',
configForm: {
name: '',
plate_id: '',
item_id: '',
spacing_mm: 2,
},
layoutPreview: null,
layoutLoading: false,
_layoutTimer: null,
apiUrl(path) { apiUrl(path) {
return `${this.apiBase.replace(/\/$/, '')}${path}`; return `${this.apiBase.replace(/\/$/, '')}${path}`;
}, },
@ -358,7 +511,7 @@
async onApiBaseChange() { async onApiBaseChange() {
await this.checkAPI(); await this.checkAPI();
if (this.apiOk) { if (this.apiOk) {
await Promise.all([this.loadPlates(), this.loadItems()]); await Promise.all([this.loadPlates(), this.loadItems(), this.loadConfigurations()]);
} }
}, },
@ -419,6 +572,7 @@
}); });
this.plateSuccess = 'Platte gespeichert.'; this.plateSuccess = 'Platte gespeichert.';
await this.loadPlates(); await this.loadPlates();
this.refreshLayoutPreview();
} catch (e) { } catch (e) {
this.plateError = String(e.message || e); this.plateError = String(e.message || e);
} finally { } finally {
@ -426,8 +580,42 @@
} }
}, },
configsUsingPlate(plateId) {
return (this.configurations || []).filter(c => c.plate_id === plateId);
},
configsUsingItem(itemId) {
return (this.configurations || []).filter(c => c.item_id === itemId);
},
confirmDeletePlate(id) {
const used = this.configsUsingPlate(id);
if (used.length === 0) {
return confirm('Platte wirklich löschen?');
}
const names = used.map(c => '• ' + this.configSummary(c)).join('\n');
return confirm(
'Diese Platte wird in ' + used.length + ' Konfiguration(en) verwendet:\n\n'
+ names + '\n\n'
+ 'Platte trotzdem wirklich löschen? Die Konfigurationen bleiben gespeichert, verweisen dann aber auf eine gelöschte Platte.'
);
},
confirmDeleteItem(id) {
const used = this.configsUsingItem(id);
if (used.length === 0) {
return confirm('Item inkl. SVG wirklich löschen?');
}
const names = used.map(c => '• ' + this.configSummary(c)).join('\n');
return confirm(
'Dieses Item wird in ' + used.length + ' Konfiguration(en) verwendet:\n\n'
+ names + '\n\n'
+ 'Item inkl. SVG trotzdem wirklich löschen? Die Konfigurationen bleiben gespeichert, verweisen dann aber auf ein gelöschtes Item.'
);
},
async deletePlate(id) { async deletePlate(id) {
if (!confirm('Platte wirklich löschen?')) return; if (!this.confirmDeletePlate(id)) return;
this.plateError = ''; this.plateError = '';
try { try {
const res = await fetch(this.apiUrl('/plates/' + id), { method: 'DELETE' }); const res = await fetch(this.apiUrl('/plates/' + id), { method: 'DELETE' });
@ -435,7 +623,11 @@
const text = await res.text(); const text = await res.text();
throw new Error(text || res.statusText); throw new Error(text || res.statusText);
} }
await this.loadPlates(); await Promise.all([this.loadPlates(), this.loadConfigurations()]);
if (this.configForm.plate_id === id) {
this.configForm.plate_id = '';
this.layoutPreview = null;
}
} catch (e) { } catch (e) {
this.plateError = String(e.message || e); this.plateError = String(e.message || e);
} }
@ -467,6 +659,7 @@
this.itemSuccess = 'Item erzeugt.'; this.itemSuccess = 'Item erzeugt.';
this.itemForm.name = ''; this.itemForm.name = '';
await this.loadItems(); await this.loadItems();
this.refreshLayoutPreview();
} catch (e) { } catch (e) {
this.itemError = String(e.message || e); this.itemError = String(e.message || e);
} finally { } finally {
@ -475,7 +668,7 @@
}, },
async deleteItem(id) { async deleteItem(id) {
if (!confirm('Item inkl. SVG wirklich löschen?')) return; if (!this.confirmDeleteItem(id)) return;
this.itemError = ''; this.itemError = '';
try { try {
const res = await fetch(this.apiUrl('/items/' + id), { method: 'DELETE' }); const res = await fetch(this.apiUrl('/items/' + id), { method: 'DELETE' });
@ -483,15 +676,133 @@
const text = await res.text(); const text = await res.text();
throw new Error(text || res.statusText); throw new Error(text || res.statusText);
} }
await this.loadItems(); await Promise.all([this.loadItems(), this.loadConfigurations()]);
if (this.configForm.item_id === id) {
this.configForm.item_id = '';
this.layoutPreview = null;
}
} catch (e) { } catch (e) {
this.itemError = String(e.message || e); this.itemError = String(e.message || e);
} }
}, },
async loadConfigurations() {
try {
this.configurations = await this.apiJSON('GET', '/configurations') || [];
} catch (e) {
this.configError = String(e.message || e);
}
},
async saveConfiguration() {
this.configSaving = true;
this.configError = '';
this.configSuccess = '';
try {
await this.apiJSON('POST', '/configurations', {
name: this.configForm.name,
plate_id: this.configForm.plate_id,
item_id: this.configForm.item_id,
spacing_mm: Number(this.configForm.spacing_mm) || 0,
});
this.configSuccess = 'Konfiguration gespeichert.';
await this.loadConfigurations();
} catch (e) {
this.configError = String(e.message || e);
} finally {
this.configSaving = false;
}
},
async deleteConfiguration(id) {
if (!confirm('Konfiguration wirklich löschen?')) return;
this.configError = '';
try {
const res = await fetch(this.apiUrl('/configurations/' + id), { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
await this.loadConfigurations();
} catch (e) {
this.configError = String(e.message || e);
}
},
refreshLayoutPreview() {
clearTimeout(this._layoutTimer);
this._layoutTimer = setTimeout(() => this.fetchLayoutPreview(), 200);
},
async fetchLayoutPreview() {
const plateId = this.configForm.plate_id;
const itemId = this.configForm.item_id;
if (!plateId || !itemId || !this.apiOk) {
this.layoutPreview = null;
return;
}
this.layoutLoading = true;
try {
const q = new URLSearchParams({
plate_id: plateId,
item_id: itemId,
spacing_mm: String(Number(this.configForm.spacing_mm) || 0),
});
this.layoutPreview = await this.apiJSON('GET', '/layout/preview?' + q);
} catch (e) {
this.layoutPreview = null;
} finally {
this.layoutLoading = false;
}
},
labelPlate(p) {
const n = p.name ? p.name + ' — ' : '';
return n + this.fmtSize(p.width_mm, p.height_mm);
},
labelItem(it) { labelItem(it) {
return it.name || it.svg_template; return it.name || it.svg_template;
}, },
configSummary(c) {
const plate = this.plates.find(p => p.id === c.plate_id);
const item = this.items.find(i => i.id === c.item_id);
const plateLabel = plate ? this.labelPlate(plate) : c.plate_id;
const itemLabel = item ? this.labelItem(item) : c.item_id;
return plateLabel + ' · ' + itemLabel + ' · Abstand ' + c.spacing_mm + ' mm';
},
layoutPreviewSvg(preview) {
const p = preview || this.layoutPreview;
if (!p || !p.plate_width_mm) return '';
const pw = p.plate_width_mm;
const ph = p.plate_height_mm;
const maxPx = 520;
const scale = Math.min(maxPx / pw, maxPx / ph, 2);
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
let items = '';
const fpW = p.footprint_width_mm;
const fpH = p.footprint_height_mm;
for (const pos of p.positions || []) {
items += `<rect x="${pos.x_mm * scale}" y="${pos.y_mm * scale}" width="${fpW * scale}" height="${fpH * scale}" rx="3" fill="#22c55e" fill-opacity="0.35" stroke="#22c55e" stroke-width="1"/>`;
}
const px = p.printable_x_mm * scale;
const py = p.printable_y_mm * scale;
const prW = p.printable_width_mm * scale;
const prH = p.printable_height_mm * scale;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${pw * scale}" height="${ph * scale}" viewBox="0 0 ${pw * scale} ${ph * scale}" role="img" aria-label="Layout-Vorschau">
<rect width="100%" height="100%" fill="#2d3a4d"/>
<rect x="${px}" y="${py}" width="${prW}" height="${prH}" fill="#1a2332" stroke="#8b9cb3" stroke-width="1" stroke-dasharray="4,3"/>
${items}
<text x="8" y="16" fill="#e7ecf3" font-size="11" font-family="system-ui,sans-serif">${esc(p.count)}× · ${esc(p.columns)}×${esc(p.rows)}</text>
</svg>`;
},
fmtSize(w, h) { fmtSize(w, h) {
return `${w} × ${h} mm`; return `${w} × ${h} mm`;
}, },

View File

@ -15,6 +15,7 @@ import (
func NewHandler() http.Handler { func NewHandler() http.Handler {
plates := store.NewPlateStore("") plates := store.NewPlateStore("")
items := store.NewItemStore("") items := store.NewItemStore("")
configs := store.NewConfigurationStore("")
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /health", health) mux.HandleFunc("GET /health", health)
@ -26,6 +27,7 @@ func NewHandler() http.Handler {
mux.HandleFunc("POST /items", saveItem(items)) mux.HandleFunc("POST /items", saveItem(items))
mux.HandleFunc("DELETE /items/{id}", deleteItem(items)) mux.HandleFunc("DELETE /items/{id}", deleteItem(items))
mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items)) mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items))
registerConfigurationRoutes(mux, configs, plates, items)
return withCORS(mux) return withCORS(mux)
} }

View File

@ -0,0 +1,218 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"printer.backend/internal/layout"
"printer.backend/internal/model"
"printer.backend/internal/store"
)
func registerConfigurationRoutes(mux *http.ServeMux, configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) {
mux.HandleFunc("GET /configurations", listConfigurations(configs, plates, items))
mux.HandleFunc("POST /configurations", saveConfiguration(configs, plates, items))
mux.HandleFunc("DELETE /configurations/{id}", deleteConfiguration(configs))
mux.HandleFunc("GET /configurations/{id}/preview", previewConfiguration(configs, plates, items))
mux.HandleFunc("GET /layout/preview", layoutPreview(plates, items))
}
type configurationResponse struct {
model.Configuration
Preview *model.LayoutPreview `json:"preview,omitempty"`
PreviewError string `json:"preview_error,omitempty"`
}
func listConfigurations(configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
list, err := configs.List()
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
if list == nil {
list = []model.Configuration{}
}
out := make([]configurationResponse, 0, len(list))
for _, c := range list {
out = append(out, configurationResponseFor(c, plates, items))
}
writeJSON(w, http.StatusOK, out)
}
}
type saveConfigurationRequest struct {
Name string `json:"name"`
PlateID string `json:"plate_id"`
ItemID string `json:"item_id"`
SpacingMM float64 `json:"spacing_mm"`
}
func saveConfiguration(configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req saveConfigurationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.PlateID == "" || req.ItemID == "" {
writeError(w, http.StatusBadRequest, errors.New("plate_id and item_id are required"))
return
}
if _, err := findPlate(plates, req.PlateID); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if _, err := items.Get(req.ItemID); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.SpacingMM < 0 {
writeError(w, http.StatusBadRequest, errors.New("spacing_mm must be non-negative"))
return
}
c := model.Configuration{
Name: req.Name,
PlateID: req.PlateID,
ItemID: req.ItemID,
SpacingMM: req.SpacingMM,
CreatedAt: time.Now().UTC(),
}
saved, err := configs.Save(c)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
resp := configurationResponseFor(saved, plates, items)
if resp.PreviewError != "" {
writeError(w, http.StatusInternalServerError, errors.New(resp.PreviewError))
return
}
writeJSON(w, http.StatusCreated, resp)
}
}
func deleteConfiguration(configs *store.ConfigurationStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, errors.New("id required"))
return
}
if err := configs.Delete(id); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func previewConfiguration(configs *store.ConfigurationStore, plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
c, err := configs.Get(id)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
preview, err := buildPreview(plates, items, c.PlateID, c.ItemID, c.SpacingMM)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
preview.PlateID = c.PlateID
preview.ItemID = c.ItemID
writeJSON(w, http.StatusOK, preview)
}
}
func layoutPreview(plates *store.PlateStore, items *store.ItemStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
plateID := r.URL.Query().Get("plate_id")
itemID := r.URL.Query().Get("item_id")
if plateID == "" || itemID == "" {
writeError(w, http.StatusBadRequest, errors.New("plate_id and item_id query params are required"))
return
}
spacing, err := parseSpacing(r.URL.Query().Get("spacing_mm"))
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
preview, err := buildPreview(plates, items, plateID, itemID, spacing)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
preview.PlateID = plateID
preview.ItemID = itemID
writeJSON(w, http.StatusOK, preview)
}
}
func configurationResponseFor(c model.Configuration, plates *store.PlateStore, items *store.ItemStore) configurationResponse {
resp := configurationResponse{Configuration: c}
preview, err := buildPreview(plates, items, c.PlateID, c.ItemID, c.SpacingMM)
if err != nil {
resp.PreviewError = err.Error()
return resp
}
preview.PlateID = c.PlateID
preview.ItemID = c.ItemID
resp.Preview = &preview
return resp
}
func buildPreview(plates *store.PlateStore, items *store.ItemStore, plateID, itemID string, spacingMM float64) (model.LayoutPreview, error) {
plate, err := findPlate(plates, plateID)
if err != nil {
return model.LayoutPreview{}, err
}
item, err := items.Get(itemID)
if err != nil {
return model.LayoutPreview{}, err
}
return layout.Pack(plate, item.Spec, spacingMM), nil
}
func findPlate(plates *store.PlateStore, id string) (model.Plate, error) {
list, err := plates.List()
if err != nil {
return model.Plate{}, err
}
plate, err := storeFindPlate(list, id)
if err != nil {
return model.Plate{}, errors.New("plate not found: " + id)
}
return plate, nil
}
func storeFindPlate(list []model.Plate, id string) (model.Plate, error) {
for _, p := range list {
if p.ID == id {
return p, nil
}
}
return model.Plate{}, errors.New("not found")
}
func parseSpacing(s string) (float64, error) {
if s == "" {
return 0, nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, errors.New("invalid spacing_mm")
}
if v < 0 {
return 0, errors.New("spacing_mm must be non-negative")
}
return v, nil
}

View File

@ -0,0 +1,65 @@
package store
import (
"fmt"
"path/filepath"
"time"
"github.com/google/uuid"
"printer.backend/internal/model"
"printer.backend/internal/paths"
)
// ConfigurationStore persists configurations as JSON files.
type ConfigurationStore struct {
dir string
}
// NewConfigurationStore creates a store under dir (default data/configurations).
func NewConfigurationStore(dir string) *ConfigurationStore {
if dir == "" {
dir = paths.ConfigurationsDir
}
return &ConfigurationStore{dir: dir}
}
// List returns all configurations sorted by creation time (newest first).
func (s *ConfigurationStore) List() ([]model.Configuration, error) {
return listFromDir(s.dir,
func(name string) bool { return filepath.Ext(name) == ".json" },
"configurations dir",
func(c model.Configuration) time.Time { return c.CreatedAt },
)
}
// Get returns a configuration by ID.
func (s *ConfigurationStore) Get(id string) (model.Configuration, error) {
list, err := s.List()
if err != nil {
return model.Configuration{}, err
}
return findByID(list, id, func(c model.Configuration) string { return c.ID })
}
// Save writes a new configuration and assigns an ID when empty.
func (s *ConfigurationStore) Save(c model.Configuration) (model.Configuration, error) {
if err := ensureDir(s.dir); err != nil {
return model.Configuration{}, err
}
if c.ID == "" {
c.ID = uuid.NewString()
}
c.CreatedAt = stampNew(&c.CreatedAt)
path := filepath.Join(s.dir, c.ID+".json")
if err := writeJSON(path, c); err != nil {
return model.Configuration{}, fmt.Errorf("write configuration: %w", err)
}
return c, nil
}
// Delete removes a configuration by ID.
func (s *ConfigurationStore) Delete(id string) error {
path := filepath.Join(s.dir, id+".json")
return removeFile(path, fmt.Sprintf("configuration not found: %s", id))
}