Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29234ea906 | |||
| 4398c675e4 |
+4
-2
@@ -667,6 +667,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
|||||||
|
|
||||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-31 | CAD-9 | Stueckliste (BOM) fuer Traversen: (1) bomService NEU — buildBOM: gruppiert alle truss-Elemente nach (Laenge x trussWidth) und boxcorner nach (armLength x trussWidth) in EINE Position je Mass mit Stueckzahl; Gesamt-Traverse-Meter mit Eck-Konvention BEIDE Arme zaehlen (50er-Ecke = 1.00m — eigener Test verifiziert die Konvention); Sortierung: Traversen vor Eck-Traversen, je Typ Laenge absteigend; Nicht-Traversen (chair etc.) still ignoriert; leerer Bestand → leere Liste ohne Crash; (2) exportBOMCsv: Semikolon-Separatoren (Excel-DE oeffnet direkt mehrspaltig), Spalten Pos/Typ/Laenge_cm/Breite_cm/Anzahl/Gesamt_cm/Bezeichnung, SUMME-Zeile nur wenn Positionen vorhanden (leere BOM = nur Header); (3) BOMDialog-Komponente: Modal-Tabelle mit allen Spalten + Summenzeile hervorgehoben, CSV-Download als Datei (UTF-8-BOM fuer Excel-Umlaute, Datum im Dateinamen), Empty-State mit Hinweis auf Traversen-Tools; (4) Ribbon-Button „Stueckliste“ in Extras-Gruppe neben Gäste, App-Verdrahtung ueber documentService.getActiveDocument().getAllElements(); Test-Zwischenfaelle selbst korrigiert: Gesamtlaenge-Erwartung 7.0m war falsch gerechnet (Ecken zaehlen beide Arme → 8.0m korrekt, konsistent mit Both-Arms-Test) + SUMME-Zeile bei leerer BOM unterdrueckt | Frontend: 845/845 Tests (+13 bomExport.test.ts), tsc 0, Build EXIT 0 | 4398c67 | CAD-9 COMPLETE |
|
||||||
| 2026-08-31 | CAD-8 | Traversen-Upgrade (4 Nutzer-Anforderungen): (1) LAENGEN-PRESETS: Select auf genau 50/100/200/300 cm umgestellt; (2) BREITE: neue trussWidth-Option (number 10-60, Standard 29cm) — in makeTrussElement (rotationsbewusste BBox) + Rendering + Status-Meldungen; (3) DREHBAR: Truss-Koerper komplett als ROTIERTES Polygon neu implementiert (4 Ecken über Längs-/Normalen-Richtung statt achsenparalleles rect — vorher sah 90° falsch aus) + Gurtlinien mitgedreht; Boxcorner genauso rotationsbewusst (L-Form-Polygon mit 6 Punkten aus zwei Armen); Drehung im PropertiesPanel (rotation-Feld existierte bereits) wirkt jetzt korrekt in der Geometrie; (4) BOXCORNER: neues ElementType + Eck-Traverse-Tool (armLength/trussWidth-Optionen, Drag-Richtung, L-Schwerpunkt am Vertex) + boxcornerEndpoints (Armenden bei rot und rot+90) + cornerDockToDock (Ecke dockt an Traversen-Enden UND Ecken-Armenden, Gegenrichtung am Truss-Start) + snapTrussToDock erweitert: Traversen docken an Ecken-Armenden mit ARM-SPEZIFISCHER Richtung (Arm1=rot, Arm2=rot+90 statt pauschal rot+180 — Test deckte auf) — Rahmen aus Traversen+Ecken bauen sich komplett magnetisch; Zwischenfaelle: Python-Anchor-Abbruch (Unicode) kostete einen Lauf (alle event-tools-Edits neu mit ASCII-Anchor), L-Form hat 6 nicht 8 Punkte (natuerliche Geometrie, Test korrigiert) | Frontend: 832/832 Tests (+25 trussUpgrade.test.ts), tsc 0, Build EXIT 0 | 3ecc99a | CAD-8 COMPLETE |
|
| 2026-08-31 | CAD-8 | Traversen-Upgrade (4 Nutzer-Anforderungen): (1) LAENGEN-PRESETS: Select auf genau 50/100/200/300 cm umgestellt; (2) BREITE: neue trussWidth-Option (number 10-60, Standard 29cm) — in makeTrussElement (rotationsbewusste BBox) + Rendering + Status-Meldungen; (3) DREHBAR: Truss-Koerper komplett als ROTIERTES Polygon neu implementiert (4 Ecken über Längs-/Normalen-Richtung statt achsenparalleles rect — vorher sah 90° falsch aus) + Gurtlinien mitgedreht; Boxcorner genauso rotationsbewusst (L-Form-Polygon mit 6 Punkten aus zwei Armen); Drehung im PropertiesPanel (rotation-Feld existierte bereits) wirkt jetzt korrekt in der Geometrie; (4) BOXCORNER: neues ElementType + Eck-Traverse-Tool (armLength/trussWidth-Optionen, Drag-Richtung, L-Schwerpunkt am Vertex) + boxcornerEndpoints (Armenden bei rot und rot+90) + cornerDockToDock (Ecke dockt an Traversen-Enden UND Ecken-Armenden, Gegenrichtung am Truss-Start) + snapTrussToDock erweitert: Traversen docken an Ecken-Armenden mit ARM-SPEZIFISCHER Richtung (Arm1=rot, Arm2=rot+90 statt pauschal rot+180 — Test deckte auf) — Rahmen aus Traversen+Ecken bauen sich komplett magnetisch; Zwischenfaelle: Python-Anchor-Abbruch (Unicode) kostete einen Lauf (alle event-tools-Edits neu mit ASCII-Anchor), L-Form hat 6 nicht 8 Punkte (natuerliche Geometrie, Test korrigiert) | Frontend: 832/832 Tests (+25 trussUpgrade.test.ts), tsc 0, Build EXIT 0 | 3ecc99a | CAD-8 COMPLETE |
|
||||||
| 2026-08-31 | CAD-7 | Magnetisches Traversen-Tool: (1) ElementType „truss“ NEU + (2) trussEndpoints (Endpunkte rotationsbewusst über Grad-Winkel) + snapTrussToDock (magnetisches Andocken, rein): sucht unter allen truss-Elementen den Endpunkt in 25er Toleranz, uebernimmt dessen Rotation UND dockt exakt an — am ENDPUNKT-0 (Start) in GEGENRICHTUNG (rot+180, weg von der Anker-Traverse), am Endpunkt-1 vorwärts (Reihen bauen sich selbst); bei mehreren Kandidaten gewinnt der nächstgelegene; (3) trussTool: Drag-Interaktion (down startet, move zeigt magnetische Live-Vorschau — dockt der Start an einem Traversen-Ende wird sofort die gedockte Geometrie angezeigt, up committet in EINER Transaktion), Laengen-Option 100/200/290cm, ohne bestehende Traversen freie Platzierung mit Richtung aus Drag; (4) Rendering: truss-Case im Pixi-Drawer — Balken-Rechteck + 2 Gurtlinien entlang der Rotation (Fachwerk-Profil-Look); Zwischenfall selbst erkannt: erster Docking-Ansatz extendede IMMER vorwärts → am Startpunkt überlappte die neue Traverse die alte (Test deckte auf: Rotation 180 erwartet, 0 geliefert) — Endpunkt-Index-Unterscheidung eingeführt | Frontend: 807/807 Tests (+18 trussTool.test.ts), tsc 0, Build EXIT 0 | cefba89 | CAD-7 COMPLETE — CAD-6+Cad-7 decken beide Nutzer-Wuensche (Grundrisse + Traversen) ab |
|
| 2026-08-31 | CAD-7 | Magnetisches Traversen-Tool: (1) ElementType „truss“ NEU + (2) trussEndpoints (Endpunkte rotationsbewusst über Grad-Winkel) + snapTrussToDock (magnetisches Andocken, rein): sucht unter allen truss-Elementen den Endpunkt in 25er Toleranz, uebernimmt dessen Rotation UND dockt exakt an — am ENDPUNKT-0 (Start) in GEGENRICHTUNG (rot+180, weg von der Anker-Traverse), am Endpunkt-1 vorwärts (Reihen bauen sich selbst); bei mehreren Kandidaten gewinnt der nächstgelegene; (3) trussTool: Drag-Interaktion (down startet, move zeigt magnetische Live-Vorschau — dockt der Start an einem Traversen-Ende wird sofort die gedockte Geometrie angezeigt, up committet in EINER Transaktion), Laengen-Option 100/200/290cm, ohne bestehende Traversen freie Platzierung mit Richtung aus Drag; (4) Rendering: truss-Case im Pixi-Drawer — Balken-Rechteck + 2 Gurtlinien entlang der Rotation (Fachwerk-Profil-Look); Zwischenfall selbst erkannt: erster Docking-Ansatz extendede IMMER vorwärts → am Startpunkt überlappte die neue Traverse die alte (Test deckte auf: Rotation 180 erwartet, 0 geliefert) — Endpunkt-Index-Unterscheidung eingeführt | Frontend: 807/807 Tests (+18 trussTool.test.ts), tsc 0, Build EXIT 0 | cefba89 | CAD-7 COMPLETE — CAD-6+Cad-7 decken beide Nutzer-Wuensche (Grundrisse + Traversen) ab |
|
||||||
| 2026-08-31 | CAD-6 | Hintergrund-Grundrisse (mit Maßstab) — echter BUG-Fix: (1) Befund: Die komplette Infrastruktur existierte (BackgroundImport-Dialog, BackgroundService mit calibrateScale-Maßstabs-Kalibrierung, bgConfig in Yjs via bgConfig-Map inkl. CRDT-Sync, bgConfig als CanvasArea-Prop) — ABER CanvasArea nutzte bgConfig NIE: kein setOptions(backgroundSrc), keine Pixi-Zeichnung; importierte Grundrisse wurden im Dialog konfiguriert, aber auf der Zeichenfläche GAR NICHT gerendert; (2) Fix: CanvasArea-Effect erweitert — renderEngine.setOptions mit backgroundSrc (visible-Filter), backgroundScale, backgroundOffsetX/Y, backgroundRotation (Grad→Radiant), backgroundOpacity + render()-Trigger; bgConfig in die useEffect-Dependencies aufgenommen (Position/Scale/Opacity-Änderungen rendern sofort); (3) PixiRenderer.drawBackground: echte Sprite-Implementierung statt Stub — Bild-Cache (Neuaufbau nur bei Quellwechsel), addChildAt(sprite, 0) unter ALLEN Elementen, alpha/scale/position/rotation aus BackgroundDrawConfig, ohne Bild wird das Sprite sauber entfernt (Hintergrund aus); Legacy-RenderEngine.drawBackground hatte bereits korrekte Transform-Parität (Zeichenreihenfolge Grid→Background→Elemente); Layer-Baumansicht: bereits vorhanden (LayerPanel baut echten Baum via parentId mit Unterebenen/Gruppen/Elementen) — keine Änderung nötig; tsc-Fix selbst erkannt: Container heißt root nicht world | Frontend: 789/789 Tests (+5 backgroundRendering.test.ts), tsc 0, Build EXIT 0 | siehe git | CAD-6 COMPLETE; nächster Task CAD-7 (magnetisches Traversen-Tool) |
|
| 2026-08-31 | CAD-6 | Hintergrund-Grundrisse (mit Maßstab) — echter BUG-Fix: (1) Befund: Die komplette Infrastruktur existierte (BackgroundImport-Dialog, BackgroundService mit calibrateScale-Maßstabs-Kalibrierung, bgConfig in Yjs via bgConfig-Map inkl. CRDT-Sync, bgConfig als CanvasArea-Prop) — ABER CanvasArea nutzte bgConfig NIE: kein setOptions(backgroundSrc), keine Pixi-Zeichnung; importierte Grundrisse wurden im Dialog konfiguriert, aber auf der Zeichenfläche GAR NICHT gerendert; (2) Fix: CanvasArea-Effect erweitert — renderEngine.setOptions mit backgroundSrc (visible-Filter), backgroundScale, backgroundOffsetX/Y, backgroundRotation (Grad→Radiant), backgroundOpacity + render()-Trigger; bgConfig in die useEffect-Dependencies aufgenommen (Position/Scale/Opacity-Änderungen rendern sofort); (3) PixiRenderer.drawBackground: echte Sprite-Implementierung statt Stub — Bild-Cache (Neuaufbau nur bei Quellwechsel), addChildAt(sprite, 0) unter ALLEN Elementen, alpha/scale/position/rotation aus BackgroundDrawConfig, ohne Bild wird das Sprite sauber entfernt (Hintergrund aus); Legacy-RenderEngine.drawBackground hatte bereits korrekte Transform-Parität (Zeichenreihenfolge Grid→Background→Elemente); Layer-Baumansicht: bereits vorhanden (LayerPanel baut echten Baum via parentId mit Unterebenen/Gruppen/Elementen) — keine Änderung nötig; tsc-Fix selbst erkannt: Container heißt root nicht world | Frontend: 789/789 Tests (+5 backgroundRendering.test.ts), tsc 0, Build EXIT 0 | siehe git | CAD-6 COMPLETE; nächster Task CAD-7 (magnetisches Traversen-Tool) |
|
||||||
@@ -761,8 +762,8 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
|||||||
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
||||||
|
|
||||||
---
|
---
|
||||||
*Post-2.0 CAD-Ausbau: CAD-1 bis CAD-8 fertig (inkl. Traversen-Upgrade: Laengen-Presets, einstellbare Breite, rotierbar, Boxcorner-Ecken magnetisch). Neue Anforderungen ueber neue Roadmap-Eintraege.*
|
*Post-2.0 CAD-Ausbau: CAD-1 bis CAD-9 fertig (inkl. Stueckliste mit CSV-Export). Neue Anforderungen ueber neue Roadmap-Eintraege.*
|
||||||
*Status 2026-08-31: RELEASE 2.0.0 LIVE + Post-2.0 CAD-Ausbau CAD-1 bis CAD-8 fertig (Traversen komplett: Presets/Breite/Drehbarkeit/Boxcorner). FE 832/832 Tests, BE 287/287, tsc/Build grün, Prod-Deploy verifiziert. Tag v2.0.0 auf Forgejo main.*
|
*Status 2026-08-31: RELEASE 2.0.0 LIVE + Post-2.0 CAD-Ausbau CAD-1 bis CAD-9 fertig (inkl. Stueckliste/CSV). FE 845/845 Tests, BE 287/287, tsc/Build grün, Prod-Deploy verifiziert. Tag v2.0.0 auf Forgejo main.*
|
||||||
|
|
||||||
## Post-2.0 CAD-Ausbau (neue Task-Reihe)
|
## Post-2.0 CAD-Ausbau (neue Task-Reihe)
|
||||||
|
|
||||||
@@ -774,4 +775,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
|||||||
- [x] CAD-6 Hintergrund-Grundrisse mit Maßstab rendern *(2026-08-31: BUG-Fix — bgConfig wurde von App an CanvasArea uebergeben aber NIE genutzt: Hintergrund-Import funktionierte im Dialog, wurde aber nie gezeichnet; CanvasArea-Effect setzt jetzt backgroundSrc/Scale/OffsetX/Y/Rotation/Opacity via renderEngine.setOptions + render; PixiRenderer.drawBackground als echte Sprite-Implementierung (Bild-Cache, addChildAt Index 0 unter allen Elementen, alpha/rotation/scale); Layer-Baumansicht existierte bereits mit Unterebenen; Log CAD-6)*
|
- [x] CAD-6 Hintergrund-Grundrisse mit Maßstab rendern *(2026-08-31: BUG-Fix — bgConfig wurde von App an CanvasArea uebergeben aber NIE genutzt: Hintergrund-Import funktionierte im Dialog, wurde aber nie gezeichnet; CanvasArea-Effect setzt jetzt backgroundSrc/Scale/OffsetX/Y/Rotation/Opacity via renderEngine.setOptions + render; PixiRenderer.drawBackground als echte Sprite-Implementierung (Bild-Cache, addChildAt Index 0 unter allen Elementen, alpha/rotation/scale); Layer-Baumansicht existierte bereits mit Unterebenen; Log CAD-6)*
|
||||||
- [x] CAD-7 Magnetisches Traversen-Tool (Truss) *(2026-08-31: trussTool mit Drag-Richtung, Laengen-Optionen 100/200/290cm, snapTrussToDock fuer magnetisches End-zu-End-Andocken mit Rotations-Vererbung + Gegenrichtung am Startpunkt, truss-ElementType + Rendering mit Gurtlinien; Log CAD-7)*
|
- [x] CAD-7 Magnetisches Traversen-Tool (Truss) *(2026-08-31: trussTool mit Drag-Richtung, Laengen-Optionen 100/200/290cm, snapTrussToDock fuer magnetisches End-zu-End-Andocken mit Rotations-Vererbung + Gegenrichtung am Startpunkt, truss-ElementType + Rendering mit Gurtlinien; Log CAD-7)*
|
||||||
- [x] CAD-8 Traversen-Upgrade *(2026-08-31: Laengen-Presets 50/100/200/300cm, Breite einstellbar Standard 29cm, ROTIERBARES Rendering (Koerper als richtungs-Polygon statt achsenparallel), Boxcorner-Eck-Traverse als eigenes magnetisches Element mit beidseitigem Andocken; Log CAD-8)*
|
- [x] CAD-8 Traversen-Upgrade *(2026-08-31: Laengen-Presets 50/100/200/300cm, Breite einstellbar Standard 29cm, ROTIERBARES Rendering (Koerper als richtungs-Polygon statt achsenparallel), Boxcorner-Eck-Traverse als eigenes magnetisches Element mit beidseitigem Andocken; Log CAD-8)*
|
||||||
|
- [x] CAD-9 Stueckliste Traversen (BOM) *(2026-08-31: bomService mit buildBOM (Gruppierung Traversen nach Laenge x Breite, Eck-Traversen nach armLength x Breite, Stueckzahlen, Gesamt-Traverse-Meter mit BEIDEN Ecken-Armen, Sortierung Traversen vor Ecken nach Laenge absteigend), exportBOMCsv (Semikolon-CSV Excel-DE-freundlich, SUMME-Zeile nur bei Inhalt), BOMDialog-Komponente (Tabelle + Summenzeile + CSV-Download, BOM-freundliche UTF-8-BOM-Datei), Ribbon-Button Stueckliste in Extras; Log CAD-9)*
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Task CAD-9 – Stuecklisten-Dialog fuer Traversen-Systeme.
|
||||||
|
*
|
||||||
|
* Zeigt die gruppierte Stueckliste (Traversen + Eck-Traversen)
|
||||||
|
* als Tabelle mit Summenzeile; CSV-Download als Datei.
|
||||||
|
*/
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { buildBOM, exportBOMCsv } from '../services/bomService';
|
||||||
|
|
||||||
|
interface BOMDialogProps {
|
||||||
|
elements: import('../types/cad.types').CADElement[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOMDialog: React.FC<BOMDialogProps> = ({ elements, onClose }) => {
|
||||||
|
const bom = useMemo(() => buildBOM(elements), [elements]);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const csv = exportBOMCsv(bom);
|
||||||
|
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `stueckliste-traversen-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bom-dialog-overlay" onClick={onClose}>
|
||||||
|
<div className="bom-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="bom-dialog-header">
|
||||||
|
<h3>Stückliste – Traversen</h3>
|
||||||
|
<button className="bom-close" onClick={onClose} title="Schließen">✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{bom.rows.length === 0 ? (
|
||||||
|
<div className="bom-empty">
|
||||||
|
Keine Traversen oder Eck-Traversen in der Zeichnung gefunden.
|
||||||
|
<br />
|
||||||
|
Platziere zuerst Traversen mit dem Traverse- oder Eck-Traverse-Werkzeug.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<table className="bom-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Pos</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Länge (cm)</th>
|
||||||
|
<th>Breite (cm)</th>
|
||||||
|
<th>Anzahl</th>
|
||||||
|
<th>Gesamt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bom.rows.map((r) => (
|
||||||
|
<tr key={`${r.pos}-${r.label}`}>
|
||||||
|
<td>{r.pos}</td>
|
||||||
|
<td>{r.type}</td>
|
||||||
|
<td>{r.length}</td>
|
||||||
|
<td>{r.width}</td>
|
||||||
|
<td>{r.count}</td>
|
||||||
|
<td>{(r.totalCm / 100).toFixed(2)} m</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="bom-sum-row">
|
||||||
|
<td colSpan={5}>
|
||||||
|
<strong>SUMME</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{bom.totalLengthM.toFixed(2)} m</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="bom-dialog-actions">
|
||||||
|
<button className="bom-btn-primary" onClick={handleDownload}>
|
||||||
|
CSV herunterladen
|
||||||
|
</button>
|
||||||
|
<button className="bom-btn-secondary" onClick={onClose}>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BOMDialog;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Task CAD-9 – Stueckliste (BOM) fuer Traversen-Systeme.
|
||||||
|
*
|
||||||
|
* buildBOM gruppiert Traversen + Eck-Traversen nach Typ + Mass
|
||||||
|
* (Laenge x Breite), zaehlt Stueckzahlen und summiert die
|
||||||
|
* Gesamt-Traversemeter (Boxcorner zaehlt BEIDE Arme).
|
||||||
|
* exportBOMCsv: Semikolon-separierte CSV (Excel-DE-freundlich).
|
||||||
|
*/
|
||||||
|
import type { CADElement } from '../types/cad.types';
|
||||||
|
|
||||||
|
export interface BOMRow {
|
||||||
|
pos: number;
|
||||||
|
type: 'Traverse' | 'Eck-Traverse';
|
||||||
|
/** Laenge in cm (Traverse: length; Eck-Traverse: armLength). */
|
||||||
|
length: number;
|
||||||
|
/** Breite in cm (trussWidth). */
|
||||||
|
width: number;
|
||||||
|
count: number;
|
||||||
|
/** Gesamtlaenge dieser Position in cm (count * length, Ecken * 2). */
|
||||||
|
totalCm: number;
|
||||||
|
/** Bezeichnung, z.B. "Traverse 200 x 29". */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BOMResult {
|
||||||
|
rows: BOMRow[];
|
||||||
|
/** Gesamte Traversenlaenge in Metern (Ecken zaehlen beide Arme). */
|
||||||
|
totalLengthM: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupKey {
|
||||||
|
type: 'Traverse' | 'Eck-Traverse';
|
||||||
|
length: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die Stueckliste aus allen Zeichnungselementen.
|
||||||
|
* Nur truss- und boxcorner-Elemente fliessen ein; alles andere
|
||||||
|
* wird still ignoriert.
|
||||||
|
*/
|
||||||
|
export function buildBOM(elements: CADElement[]): BOMResult {
|
||||||
|
const groups = new Map<string, { key: GroupKey; count: number }>();
|
||||||
|
|
||||||
|
for (const el of elements) {
|
||||||
|
if (String(el.type) === 'truss' && el.properties.truss === true) {
|
||||||
|
const length = (el.properties.length as number | undefined) ?? el.width ?? 0;
|
||||||
|
const width = (el.properties.trussWidth as number | undefined) ?? el.height ?? 29;
|
||||||
|
const key: GroupKey = { type: 'Traverse', length, width };
|
||||||
|
const id = `T|${length}|${width}`;
|
||||||
|
const g = groups.get(id) ?? { key, count: 0 };
|
||||||
|
g.count++;
|
||||||
|
groups.set(id, g);
|
||||||
|
} else if (String(el.type) === 'boxcorner' && el.properties.boxcorner === true) {
|
||||||
|
const length = (el.properties.armLength as number | undefined) ?? 50;
|
||||||
|
const width = (el.properties.trussWidth as number | undefined) ?? 29;
|
||||||
|
const key: GroupKey = { type: 'Eck-Traverse', length, width };
|
||||||
|
const id = `C|${length}|${width}`;
|
||||||
|
const g = groups.get(id) ?? { key, count: 0 };
|
||||||
|
g.count++;
|
||||||
|
groups.set(id, g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortierung: Traversen vor Ecken, innerhalb Typ nach Laenge absteigend
|
||||||
|
const entries = Array.from(groups.values());
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (a.key.type !== b.key.type) {
|
||||||
|
return a.key.type === 'Traverse' ? -1 : 1;
|
||||||
|
}
|
||||||
|
return b.key.length - a.key.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: BOMRow[] = entries.map((g, i) => {
|
||||||
|
// Ecken haben 2 Arme: Gesamtlaenge = count * length * 2
|
||||||
|
const armFactor = g.key.type === 'Eck-Traverse' ? 2 : 1;
|
||||||
|
const totalCm = g.count * g.key.length * armFactor;
|
||||||
|
return {
|
||||||
|
pos: i + 1,
|
||||||
|
type: g.key.type,
|
||||||
|
length: g.key.length,
|
||||||
|
width: g.key.width,
|
||||||
|
count: g.count,
|
||||||
|
totalCm,
|
||||||
|
label: `${g.key.type} ${g.key.length} \u00d7 ${g.key.width}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCmAll = rows.reduce((s, r) => s + r.totalCm, 0);
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
totalLengthM: totalCmAll / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exportiert die Stueckliste als Semikolon-CSV (Excel-DE oeffnet
|
||||||
|
* das direkt mehrspaltig). Spalten: Pos;Typ;Laenge_cm;Breite_cm;
|
||||||
|
* Anzahl;Gesamt_cm;Bezeichnung + Summenzeile am Ende.
|
||||||
|
*/
|
||||||
|
export function exportBOMCsv(bom: BOMResult): string {
|
||||||
|
const lines: string[] = ['Pos;Typ;Laenge_cm;Breite_cm;Anzahl;Gesamt_cm;Bezeichnung'];
|
||||||
|
for (const r of bom.rows) {
|
||||||
|
lines.push(
|
||||||
|
`${r.pos};${r.type};${r.length};${r.width};${r.count};${r.totalCm};${r.label}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (bom.rows.length > 0) {
|
||||||
|
lines.push(`;;;SUMME;;;${bom.totalLengthM.toFixed(2)} m`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -2974,3 +2974,106 @@ body.drawer-open { overflow: hidden; }
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.layout-bar-print:hover { color: #4a90d9; background: rgba(74, 144, 217, 0.15); }
|
.layout-bar-print:hover { color: #4a90d9; background: rgba(74, 144, 217, 0.15); }
|
||||||
|
|
||||||
|
/* ─── Task CAD-9 – BOM-Dialog ─── */
|
||||||
|
.bom-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.bom-dialog {
|
||||||
|
background: #1e1e2e;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
min-width: 640px;
|
||||||
|
max-width: 800px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.bom-dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.bom-dialog-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.bom-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.bom-close:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.bom-empty {
|
||||||
|
color: #888;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.bom-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.bom-table th,
|
||||||
|
.bom-table td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.bom-table th {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #9aa0a6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.bom-table td {
|
||||||
|
color: #d0d0d0;
|
||||||
|
}
|
||||||
|
.bom-sum-row td {
|
||||||
|
background: rgba(74, 144, 217, 0.1);
|
||||||
|
}
|
||||||
|
.bom-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.bom-btn-primary {
|
||||||
|
background: #4a90d9;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.bom-btn-primary:hover {
|
||||||
|
background: #5ba0e9;
|
||||||
|
}
|
||||||
|
.bom-btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #9aa0a6;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.bom-btn-secondary:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Task CAD-9 - Stueckliste (BOM) fuer Traversen.
|
||||||
|
*
|
||||||
|
* buildBOM: gruppiert Traversen + Boxcorner nach Typ + Mass
|
||||||
|
* (Laenge x Breite), zaehlt Stueckzahlen, summiert Gesamt-meter.
|
||||||
|
* exportBOMCsv: saubere CSV mit Kopfzeile fuer Excel-Import.
|
||||||
|
* Dialog zeigt Tabelle + CSV-Download.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildBOM, exportBOMCsv, type BOMRow } from '../src/services/bomService';
|
||||||
|
import type { CADElement } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function truss(id: string, x: number, y: number, length: number, rotation: number, width = 29): CADElement {
|
||||||
|
return {
|
||||||
|
id, type: 'truss', layerId: 'l', x, y, width: length, height: width,
|
||||||
|
properties: { length, rotation, trussWidth: width, truss: true },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function corner(id: string, x: number, y: number, armLength: number, rotation: number, width = 29): CADElement {
|
||||||
|
return {
|
||||||
|
id, type: 'boxcorner', layerId: 'l', x, y, width: 80, height: 80,
|
||||||
|
properties: { armLength, rotation, trussWidth: width, boxcorner: true },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chair(id: string): CADElement {
|
||||||
|
return {
|
||||||
|
id, type: 'chair', layerId: 'l', x: 0, y: 0, width: 45, height: 45,
|
||||||
|
properties: {},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── buildBOM: Gruppierung ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('CAD-9: buildBOM (Gruppierung + Zaehlung)', () => {
|
||||||
|
it('zwei identische Traversen (200x29) ergeben EINE Position mit Anzahl 2', () => {
|
||||||
|
const els = [
|
||||||
|
truss('t1', 0, 0, 200, 0),
|
||||||
|
truss('t2', 100, 100, 200, 90),
|
||||||
|
];
|
||||||
|
const bom = buildBOM(els);
|
||||||
|
const rows = bom.rows.filter((r) => r.type === 'Traverse');
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unterschiedliche Laengen ergetrennte Positionen', () => {
|
||||||
|
const els = [
|
||||||
|
truss('t1', 0, 0, 200, 0),
|
||||||
|
truss('t2', 0, 0, 100, 0),
|
||||||
|
truss('t3', 0, 0, 300, 0),
|
||||||
|
];
|
||||||
|
const bom = buildBOM(els);
|
||||||
|
const trussRows = bom.rows.filter((r) => r.type === 'Traverse');
|
||||||
|
expect(trussRows).toHaveLength(3);
|
||||||
|
const counts = trussRows.map((r) => r.count);
|
||||||
|
expect(counts).toEqual([1, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unterschiedliche Breiten = getrennte Positionen (100x29 vs 100x40)', () => {
|
||||||
|
const els = [
|
||||||
|
truss('t1', 0, 0, 100, 0, 29),
|
||||||
|
truss('t2', 0, 0, 100, 0, 40),
|
||||||
|
];
|
||||||
|
const bom = buildBOM(els);
|
||||||
|
expect(bom.rows.filter((r) => r.type === 'Traverse')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Boxcorner als eigener Typ mit armLength als Laenge', () => {
|
||||||
|
const els = [
|
||||||
|
corner('c1', 0, 0, 50, 0),
|
||||||
|
corner('c2', 100, 100, 50, 90),
|
||||||
|
corner('c3', 200, 200, 75, 0),
|
||||||
|
];
|
||||||
|
const bom = buildBOM(els);
|
||||||
|
const cornerRows = bom.rows.filter((r) => r.type === 'Eck-Traverse');
|
||||||
|
expect(cornerRows).toHaveLength(2); // 50cm x2 + 75cm x1
|
||||||
|
const r50 = cornerRows.find((r) => r.length === 50)!;
|
||||||
|
expect(r50.count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Nicht-Traversen (chair etc.) werden ignoriert', () => {
|
||||||
|
const els = [chair('s1'), truss('t1', 0, 0, 200, 0)];
|
||||||
|
const bom = buildBOM(els);
|
||||||
|
expect(bom.rows.every((r) => r.type !== 'chair')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leere Zeichnung: leere Stueckliste ohne Crash', () => {
|
||||||
|
const bom = buildBOM([]);
|
||||||
|
expect(bom.rows).toHaveLength(0);
|
||||||
|
expect(bom.totalLengthM).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gesamtlaenge: 200+100+300cm Traversen + 2x50cm Ecken = 7.00m', () => {
|
||||||
|
const els = [
|
||||||
|
truss('t1', 0, 0, 200, 0),
|
||||||
|
truss('t2', 0, 0, 100, 0),
|
||||||
|
truss('t3', 0, 0, 300, 0),
|
||||||
|
corner('c1', 0, 0, 50, 0),
|
||||||
|
corner('c2', 0, 0, 50, 90),
|
||||||
|
];
|
||||||
|
const bom = buildBOM(els);
|
||||||
|
// Traversen 6.00m + 2 Ecken je 2 Arme = 8.00m
|
||||||
|
expect(bom.totalLengthM).toBeCloseTo(8.0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Boxcorner zaehlt BEIDE Arme in die Gesamtlaenge (50er-Ecke = 1.00m)', () => {
|
||||||
|
const els = [corner('c1', 0, 0, 50, 0)];
|
||||||
|
const bom = buildBOM(els);
|
||||||
|
expect(bom.totalLengthM).toBeCloseTo(1.0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Positionen sind sortiert: Traversen vor Ecken, dann nach Laenge absteigend', () => {
|
||||||
|
const els = [
|
||||||
|
corner('c1', 0, 0, 50, 0),
|
||||||
|
truss('t1', 0, 0, 100, 0),
|
||||||
|
truss('t2', 0, 0, 300, 0),
|
||||||
|
];
|
||||||
|
const bom = buildBOM(els);
|
||||||
|
const types = bom.rows.map((r) => r.type);
|
||||||
|
// Traversen zuerst (nach Laenge absteigend), dann Ecken
|
||||||
|
expect(types.indexOf('Traverse')).toBeLessThan(types.indexOf('Eck-Traverse'));
|
||||||
|
const trussLens = bom.rows.filter((r) => r.type === 'Traverse').map((r) => r.length);
|
||||||
|
expect(trussLens[0]).toBeGreaterThanOrEqual(trussLens[trussLens.length - 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── exportBOMCsv ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CAD-9: exportBOMCsv', () => {
|
||||||
|
it('CSV hat Kopfzeile und Zeilen pro Position mit Semikolon/ Komma', () => {
|
||||||
|
const els = [truss('t1', 0, 0, 200, 0), truss('t2', 0, 0, 200, 90)];
|
||||||
|
const bom = buildBOM(els);
|
||||||
|
const csv = exportBOMCsv(bom);
|
||||||
|
const lines = csv.split('\n');
|
||||||
|
expect(lines[0]).toContain('Pos');
|
||||||
|
expect(lines[0]).toContain('Typ');
|
||||||
|
expect(lines[0]).toContain('Anzahl');
|
||||||
|
expect(lines).toHaveLength(2 + 1); // Header + 1 Position + ggf. Summenzeile
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSV enthaelt Laenge und Anzahl der Position', () => {
|
||||||
|
const els = [truss('t1', 0, 0, 200, 0), truss('t2', 0, 0, 200, 0)];
|
||||||
|
const csv = exportBOMCsv(buildBOM(els));
|
||||||
|
expect(csv).toContain('200');
|
||||||
|
expect(csv).toContain('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSV: Semikolon-Separatoren (Excel-DE-freundlich) oder Komma + konsistent', () => {
|
||||||
|
const csv = exportBOMCsv(buildBOM([truss('t1', 0, 0, 200, 0)]));
|
||||||
|
// Konsistenz: Jede Datenzeile hat dieselbe Anzahl Separatoren
|
||||||
|
const lines = csv.trim().split('\n');
|
||||||
|
const sepCounts = lines.map((l) => (l.match(/;/g) || []).length);
|
||||||
|
expect(new Set(sepCounts).size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leere Stueckliste: nur Kopfzeile', () => {
|
||||||
|
const csv = exportBOMCsv(buildBOM([]));
|
||||||
|
expect(csv.trim().split('\n')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user