Added Configuration from Item and Plate
This commit is contained in:
parent
50e677c729
commit
c5d2e32355
75
internal/layout/layout.go
Normal file
75
internal/layout/layout.go
Normal 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
|
||||
}
|
||||
46
internal/layout/layout_test.go
Normal file
46
internal/layout/layout_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
40
internal/model/configuration.go
Normal file
40
internal/model/configuration.go
Normal 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"`
|
||||
}
|
||||
@ -5,4 +5,5 @@ const (
|
||||
DataDir = "data"
|
||||
PlatesDir = "data/plates"
|
||||
SVGTemplateDir = "data/svg_template"
|
||||
ConfigurationsDir = "data/configurations"
|
||||
)
|
||||
|
||||
@ -151,6 +151,63 @@
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
@ -275,6 +332,88 @@
|
||||
</form>
|
||||
</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">
|
||||
<h2>Items</h2>
|
||||
<template x-if="items.length === 0 && !itemsLoading">
|
||||
@ -335,6 +474,20 @@
|
||||
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) {
|
||||
return `${this.apiBase.replace(/\/$/, '')}${path}`;
|
||||
},
|
||||
@ -358,7 +511,7 @@
|
||||
async onApiBaseChange() {
|
||||
await this.checkAPI();
|
||||
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.';
|
||||
await this.loadPlates();
|
||||
this.refreshLayoutPreview();
|
||||
} catch (e) {
|
||||
this.plateError = String(e.message || e);
|
||||
} 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) {
|
||||
if (!confirm('Platte wirklich löschen?')) return;
|
||||
if (!this.confirmDeletePlate(id)) return;
|
||||
this.plateError = '';
|
||||
try {
|
||||
const res = await fetch(this.apiUrl('/plates/' + id), { method: 'DELETE' });
|
||||
@ -435,7 +623,11 @@
|
||||
const text = await res.text();
|
||||
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) {
|
||||
this.plateError = String(e.message || e);
|
||||
}
|
||||
@ -467,6 +659,7 @@
|
||||
this.itemSuccess = 'Item erzeugt.';
|
||||
this.itemForm.name = '';
|
||||
await this.loadItems();
|
||||
this.refreshLayoutPreview();
|
||||
} catch (e) {
|
||||
this.itemError = String(e.message || e);
|
||||
} finally {
|
||||
@ -475,7 +668,7 @@
|
||||
},
|
||||
|
||||
async deleteItem(id) {
|
||||
if (!confirm('Item inkl. SVG wirklich löschen?')) return;
|
||||
if (!this.confirmDeleteItem(id)) return;
|
||||
this.itemError = '';
|
||||
try {
|
||||
const res = await fetch(this.apiUrl('/items/' + id), { method: 'DELETE' });
|
||||
@ -483,15 +676,133 @@
|
||||
const text = await res.text();
|
||||
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) {
|
||||
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) {
|
||||
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, '&').replace(/</g, '<');
|
||||
|
||||
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) {
|
||||
return `${w} × ${h} mm`;
|
||||
},
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
func NewHandler() http.Handler {
|
||||
plates := store.NewPlateStore("")
|
||||
items := store.NewItemStore("")
|
||||
configs := store.NewConfigurationStore("")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", health)
|
||||
@ -26,6 +27,7 @@ func NewHandler() http.Handler {
|
||||
mux.HandleFunc("POST /items", saveItem(items))
|
||||
mux.HandleFunc("DELETE /items/{id}", deleteItem(items))
|
||||
mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items))
|
||||
registerConfigurationRoutes(mux, configs, plates, items)
|
||||
return withCORS(mux)
|
||||
}
|
||||
|
||||
|
||||
218
internal/server/api/configuration.go
Normal file
218
internal/server/api/configuration.go
Normal 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
|
||||
}
|
||||
65
internal/store/configuration.go
Normal file
65
internal/store/configuration.go
Normal 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))
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user