diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffe3ab1..8155c8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.11"] + python-version: ["3.10", "3.12"] steps: - name: Checkout repository diff --git a/.gitignore b/.gitignore index 9653cec..7b79ede 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ test_out* build_*/ *.spec dist/ + +# Claude Code worktrees / local settings +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5e741d2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +## v0.3.0 (2026-10-28) + +### New +- **OpenStitch Micro: an embroidery font for small lettering from 2.5 mm.** Single-stroke glyphs are stitched as a triple run below 5.5 mm and as narrow satin columns above, the way commercial small-text fonts work. Outline fonts converted from TrueType fill in below about 4.5 mm. It covers A–Z, a–z, 0–9, punctuation and German/French/Spanish accents. Pick it in the text dialog; it is listed first. +- **Real satin columns.** The satin tool (`S`) now stitches exactly perpendicular to the line you draw, with adjustable width, density, pull compensation and underlay. Short stitches on tight curves and corner handling are built in. Previously the line was thickened to a fixed 3.2 mm polygon and its centre line re-guessed from pixels. + +### Stitch quality fixes +- **No more stray stitches across the design.** After a trim inside one colour, the next run was split from the *old* needle position, which sewed stitches of up to 50 mm straight across finished areas. This affected every export with more than one run per colour. +- **Far fewer trims.** Underlay, the travel to the top-fill entry point, and satin end caps are now joined with running stitches hidden under the later stitching. A single fill or satin object needs 1 trim instead of 3. +- **Clean fill edges.** The density guard removed tatami row-turn points as "micro-stitches", which made fill outlines ragged. Turn points are now always kept. +- **JEF (Janome) export now cuts threads.** pyembroidery drops trims in JEF files by default; they are now written as three zero-length jumps (the Janome convention). +- **Satin underlay types**, chosen automatically by column width like commercial software: centre walk (≤ 2 mm), edge walk (≤ 4.5 mm), edge walk + zigzag (wider). Each style can be picked per object. + +### Editor +- Stitch plans are built in a **background thread**, so editing stays responsive on large designs. +- The simulator draws only new stitches each frame (about 7× faster), and the heatmap is cached. +- Undo/redo keeps object identity and selection, so undo no longer regenerates the whole design. Dragging a spin box counts as one undo step, and show/hide can be undone. +- The inspector no longer loses your input while you type. +- Node editing no longer jumps between nodes, drops parts of multi-part shapes, or moves text to the origin. +- Fit-to-hoop and Scale keep text rotation and size. +- Hoop and machine selection follow the loaded design. Auto-digitize uses the current hoop and can be cancelled. +- Unsaved-changes prompt on New/Open/Import/Drop/Close. Project saves are atomic, and save/export errors are reported. + +- **Colour sort that respects stacking:** "Optimise colour order" only moves an object to an earlier block of the same colour when nothing it skips overlaps it. +- **Round-hoop checks use the real stitch radius.** Bounding-box corners overestimated round designs by up to 41 %. + +### SVG import (rewritten) +- `transform` on elements and groups (matrix, translate, scale, rotate, skew). +- Full path syntax, including arcs (`A`), smooth curves (`S`/`T`) and compact number notation. +- Style inheritance, `fill="none"`, gradients (first stop colour), the full CSS colour-name list, `display:none`. +- ``/``, ellipse, line, rounded rect and polyline. ``, `` and `` content is no longer stitched. +- evenodd and nonzero fill rules. Strokes become a bean run (< 1.2 mm) or a satin outline (wider). +- Optional real-world size from width/height/viewBox. Protection against `` expansion bombs. + +### Melco floppy images (FAT12) +- Adding a file is transactional: if it fails, the image is left unchanged. +- Long names get Windows-style `~1` short names with VFAT long-name entries. +- Zero-byte files work, and corrupt or cyclic FAT chains are detected. +- The floppy dialog asks before replacing a file with the same 8.3 name. + +### Languages +- All dialogs, message boxes, inspector parameters, hoop and machine names, the Quality Doctor and the PDF production sheet are now translated (DE/EN/FR/ES). +- Numbers use each language's own thousands separator. + +### Project +- CI tests on Python 3.10 and 3.12. diff --git a/README.de.md b/README.de.md index fdd33d4..e0e60b9 100644 --- a/README.de.md +++ b/README.de.md @@ -45,7 +45,11 @@ * **3D-Puff-Schaumstoff-Modus (für Caps):** Erzeugt hochdichte Satin-Säulen (0.24 mm) mit speziell perforierenden **Pinch-End-Caps** zum sauberen Durchtrennen von 2–3 mm 3D-Stick-Schaumstoff. * **1-Klick Applikation (Appliqué):** Erzeugt automatisch Platzierungslinie (mit Maschinen-Stopp), Stoff-Fixiernaht (Tack-Down) und breite Satin-Deckkante. * **PhotoStitch:** Verwandelt Porträt- und Landschaftsfotos in Monochrom-, Sepia- oder 4-Farb-CMYK-Wellenstickerei. -* **SVG-Vektor-Import & TTF/OTF-Schrift-Generator** (inkl. Bogentext & automatischer Satin/Tatami-Wahl). +* **Satinsäulen entlang der gezeichneten Mittellinie** (`S`): Stiche stehen senkrecht zur Linie, mit einstellbarer Breite, Kurzstich in engen Kurven, Eckenbehandlung und automatischer Unterlage (Mittellauf / Kantenlauf / Zickzack je nach Säulenbreite). +* **OpenStitch Micro – kleine Schrift ab 2.5 mm:** Eingebaute Einlinien-Stickschrift, gestickt wie die Kleinschriften kommerzieller Programme. TrueType-Umrisse laufen unter ~4.5 mm zu, Micro bleibt lesbar: + + ![OpenStitch Micro vs. TrueType bei 2.5 und 3 mm](./assets/micro_font_comparison.png) +* **SVG-Vektor-Import** (Transformationen, Bögen, Stile, ``, Füllregeln) & **TTF/OTF-Schrift-Generator** (inkl. Bogentext & automatischer Satin/Tatami-Wahl). ### 3. 🩺 Stichdichte-Wächter, Heatmap & Qualitäts-Doktor (`F7`) * **Automatischer Anti-Fadenbruch-Filter (`density_guard`):** Verhindert Nadelkollisionen, Fadennester („Birdnesting“) und Stofflöcher. @@ -70,10 +74,10 @@ ## 🚀 Quickstart & Installation ### Option A: 1-Klick Direkt-Download (ohne Python-Installation!) -Lade dir auf der **[GitHub Release-Seite (v0.2.0)](https://github.com/lb2002/openstitch/releases/tag/v0.2.0)** direkt die fertige Anwendung für dein System herunter: -* 🪟 **Windows (64-Bit Einzeldatei):** **[`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-Windows-x64.exe)** *(einfach herunterladen & doppelklicken, kein Entpacken nötig!)* -* 🍏 **macOS (Apple Silicon M1 / M2 / M3 / M4):** **[`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip)** *(`OpenStitch Studio.app`)* -* 🍎 **macOS (Intel x64):** **[`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-Intel-x64.zip)** *(`OpenStitch Studio.app`)* +Lade dir auf der **[GitHub Release-Seite (latest)](https://github.com/lb2002/openstitch/releases/latest)** direkt die fertige Anwendung für dein System herunter: +* 🪟 **Windows (64-Bit Einzeldatei):** **[`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-Windows-x64.exe)** *(einfach herunterladen & doppelklicken, kein Entpacken nötig!)* +* 🍏 **macOS (Apple Silicon M1 / M2 / M3 / M4):** **[`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip)** *(`OpenStitch Studio.app`)* +* 🍎 **macOS (Intel x64):** **[`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-Intel-x64.zip)** *(`OpenStitch Studio.app`)* --- diff --git a/README.es.md b/README.es.md index 9c59f6e..7fff0fd 100644 --- a/README.es.md +++ b/README.es.md @@ -20,7 +20,7 @@ ## ✨ Características * 🪄 **Auto-Digitalizador (imagen / logotipo → archivo de bordado):** reducción de color en CIE-Lab, correspondencia con catálogos reales (**Madeira Polyneon 40, Isacord 40, Gunold Poly 40, Brother**), eliminación automática del fondo, detección del tipo de puntada (pespunte triple, **satén automático**, **tatami** / relleno de contorno), compensación de tirantez, entretela y solape de 0,7 mm entre colores. -* 🎨 **Digitalización manual:** herramientas Tatami (`F`), Satén (`S`), Pespunte (`R`), editor de nodos (`N`), inspector paramétrico en vivo, texto TTF/OTF con arco, importación SVG y arrastrar y soltar. +* 🎨 **Digitalización manual:** herramientas Tatami (`F`), Satén (`S`), Pespunte (`R`), editor de nodos (`N`), inspector paramétrico en vivo, columnas de satén a lo largo de la línea central dibujada, fuente de bordado **OpenStitch Micro** legible desde 2,5 mm, texto TTF/OTF con arco, importación SVG y arrastrar y soltar. * 🧵 **Herramientas pro:** modo espuma 3D (gorras), **aplique en 1 clic**, **PhotoStitch** (foto → bordado monocromo, sepia o CMYK). * 🩺 **Control de calidad:** filtro anti-rotura de hilo, mapa de densidad en vivo, **doctor de calidad (`F7`)** y **verificación binaria** de cada archivo exportado. * 🏭 **Neutral respecto a fabricantes:** DST, PES, EXP, JEF, VP3, U01; perfiles para Tajima, Brother, Barudan, Ricoma/ZSK, Janome/Bernina, Pfaff/Husqvarna y Melco (gestor de disquete FAT12 `.IMG` incluido). @@ -32,10 +32,10 @@ ## 🚀 Inicio rápido & instalación ### Opción A: descarga con 1 clic (sin Python) -Desde la **[página de releases (v0.2.0)](https://github.com/lb2002/openstitch/releases/tag/v0.2.0)**: -* 🪟 **Windows (64 bits, archivo único):** [`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-Windows-x64.exe) -* 🍏 **macOS (Apple Silicon M1–M4):** [`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip) -* 🍎 **macOS (Intel x64):** [`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-Intel-x64.zip) +Desde la **[página de releases (latest)](https://github.com/lb2002/openstitch/releases/latest)**: +* 🪟 **Windows (64 bits, archivo único):** [`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-Windows-x64.exe) +* 🍏 **macOS (Apple Silicon M1–M4):** [`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip) +* 🍎 **macOS (Intel x64):** [`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-Intel-x64.zip) ### Opción B: desde el código fuente Requiere **Python 3.10+**. diff --git a/README.fr.md b/README.fr.md index 2d9b282..561fed1 100644 --- a/README.fr.md +++ b/README.fr.md @@ -20,7 +20,7 @@ ## ✨ Fonctionnalités * 🪄 **Auto-Numériseur (image / logo → fichier de broderie) :** réduction des couleurs en CIE-Lab, correspondance avec de vrais nuanciers (**Madeira Polyneon 40, Isacord 40, Gunold Poly 40, Brother**), suppression automatique du fond, détection du type de point (point droit triple, **satin automatique**, **tatami** / remplissage de contour), compensation de tirage, sous-couche et recouvrement de 0,7 mm entre couleurs. -* 🎨 **Numérisation manuelle :** outils Tatami (`F`), Satin (`S`), Point droit (`R`), éditeur de nœuds (`N`), inspecteur paramétrique en direct, lettrage TTF/OTF avec texte en arc, import SVG, glisser-déposer. +* 🎨 **Numérisation manuelle :** outils Tatami (`F`), Satin (`S`), Point droit (`R`), éditeur de nœuds (`N`), inspecteur paramétrique en direct, colonnes satin le long de la ligne centrale dessinée, police de broderie **OpenStitch Micro** lisible dès 2,5 mm, lettrage TTF/OTF avec texte en arc, import SVG, glisser-déposer. * 🧵 **Outils pro :** mode mousse 3D (casquettes), **appliqué en 1 clic**, **PhotoStitch** (photo → broderie monochrome, sépia ou CMJN). * 🩺 **Contrôle qualité :** filtre anti-casse de fil, carte de densité en direct, **docteur qualité (`F7`)** et **vérification binaire** de chaque fichier exporté. * 🏭 **Neutre vis-à-vis des fabricants :** DST, PES, EXP, JEF, VP3, U01 ; profils pour Tajima, Brother, Barudan, Ricoma/ZSK, Janome/Bernina, Pfaff/Husqvarna et Melco (gestionnaire de disquette FAT12 `.IMG` inclus). @@ -32,10 +32,10 @@ ## 🚀 Démarrage rapide & installation ### Option A : téléchargement en 1 clic (sans Python) -Depuis la **[page des releases (v0.2.0)](https://github.com/lb2002/openstitch/releases/tag/v0.2.0)** : -* 🪟 **Windows (64 bits, fichier unique) :** [`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-Windows-x64.exe) -* 🍏 **macOS (Apple Silicon M1–M4) :** [`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip) -* 🍎 **macOS (Intel x64) :** [`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-Intel-x64.zip) +Depuis la **[page des releases (latest)](https://github.com/lb2002/openstitch/releases/latest)** : +* 🪟 **Windows (64 bits, fichier unique) :** [`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-Windows-x64.exe) +* 🍏 **macOS (Apple Silicon M1–M4) :** [`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip) +* 🍎 **macOS (Intel x64) :** [`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-Intel-x64.zip) ### Option B : depuis les sources Nécessite **Python 3.10+**. diff --git a/README.md b/README.md index 498829c..0a4e53b 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,11 @@ * **3D puff foam mode (caps):** dense satin (0.24 mm) with perforating pinch end-caps for 2–3 mm foam. * **1-click appliqué:** placement line (with stop), tack-down and satin cover border. * **PhotoStitch:** portrait/landscape photos → monochrome, sepia or 4-colour CMYK wave embroidery. -* **SVG import & TTF/OTF lettering** (arc text, automatic satin/tatami choice). +* **Satin columns along your drawn centre line** (`S`): stitches stay perpendicular to the line, with adjustable width, short stitches on tight curves, corner handling and automatic underlay (centre walk / edge walk / zigzag, chosen by column width). +* **OpenStitch Micro: small lettering from 2.5 mm.** A built-in single-stroke embroidery font, stitched like commercial small-text fonts. TrueType outlines fill in below ~4.5 mm; Micro stays readable: + + ![OpenStitch Micro vs. TrueType at 2.5 and 3 mm](./assets/micro_font_comparison.png) +* **SVG import** (transforms, arcs, styles, ``, fill rules) & **TTF/OTF lettering** (arc text, automatic satin/tatami choice). ### 3. 🩺 Density guard, heatmap & quality doctor (`F7`) * **Anti-thread-break filter** prevents needle collisions, bird-nesting and fabric holes. @@ -67,10 +71,10 @@ ## 🚀 Quickstart & Installation ### Option A: 1-click download (no Python required) -Get the ready-made app from the **[GitHub release page (v0.2.0)](https://github.com/lb2002/openstitch/releases/tag/v0.2.0)**: -* 🪟 **Windows (64-bit, single file):** [`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-Windows-x64.exe) *(download & double-click, nothing to unpack)* -* 🍏 **macOS (Apple Silicon M1 / M2 / M3 / M4):** [`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip) -* 🍎 **macOS (Intel x64):** [`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-Intel-x64.zip) +Get the ready-made app from the **[GitHub release page (latest)](https://github.com/lb2002/openstitch/releases/latest)**: +* 🪟 **Windows (64-bit, single file):** [`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-Windows-x64.exe) *(download & double-click, nothing to unpack)* +* 🍏 **macOS (Apple Silicon M1 / M2 / M3 / M4):** [`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip) +* 🍎 **macOS (Intel x64):** [`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-Intel-x64.zip) ### Option B: run from source (Windows, macOS & Linux) Requires **Python 3.10+**. diff --git a/assets/micro_font_comparison.png b/assets/micro_font_comparison.png new file mode 100644 index 0000000..7f4e675 Binary files /dev/null and b/assets/micro_font_comparison.png differ diff --git a/openstitch/__init__.py b/openstitch/__init__.py index 31de333..9891e2f 100644 --- a/openstitch/__init__.py +++ b/openstitch/__init__.py @@ -1,6 +1,6 @@ """OpenStitch Studio – Universal Open-Source Embroidery & Digitizing Suite.""" -__version__ = "0.2.0" +__version__ = "0.3.0" __author__ = "Linus Baumberger (@lb2002)" __license__ = "AGPL-3.0-or-later" APP_NAME = "OpenStitch" diff --git a/openstitch/core/design.py b/openstitch/core/design.py index fb5f3d4..1af7bae 100644 --- a/openstitch/core/design.py +++ b/openstitch/core/design.py @@ -11,10 +11,10 @@ from shapely.geometry import LineString, MultiLineString, MultiPolygon, Point, Polygon, box from shapely.ops import unary_union -from ..engine.fill import fill_runs +from ..engine.fill import fill_runs, join_covered_runs from ..engine.geom import Pt, clean, dist, lines_of, main_axis_angle, polygons_of, directional_grow from ..engine.running import bean_stitch, running_stitch, tie_stitches -from ..engine.satin import satin_runs +from ..engine.satin import UNDERLAY_STYLES, satin_runs from .stitchplan import Cmd, ColorBlock, Stitch, StitchPlan, Thread IDENTITY = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) @@ -102,6 +102,10 @@ def clone(self): c.id = _new_id() return c + def snapshot(self): + """Kopie mit gleicher id (für Undo/Redo) – so bleibt der Stich-Cache gültig.""" + return copy.deepcopy(self) + @dataclass class FillObject(EmbObject): @@ -164,7 +168,13 @@ def runs(self, start=None): # Rahmen-abhängiger Zuschlag auf die Zugkompensation ("Trampolin-Effekt" in grossen Rahmen / Kappen) hoop_pc_boost = getattr(self, "_hoop_pc_boost", 1.0) top_geom = directional_grow(g, self.pull_comp * hoop_pc_boost, ang) - out.extend(fill_runs(top_geom, ang, self.spacing, self.stitch_len, self.pattern, start=cur)) + top = fill_runs(top_geom, ang, self.spacing, self.stitch_len, self.pattern, start=cur) + # Unterlage und Weg zum Deckstich-Einstieg liegen unter dem Deckstich → verbinden statt trimmen + under = [r for r in out if len(r) >= 2] + if under and top: + out = join_covered_runs(under + top[:1], g) + top[1:] + else: + out = under + top return [r for r in out if len(r) >= 2] @@ -176,12 +186,14 @@ class SatinObject(EmbObject): max_stitch: float = 7.0 underlay: bool = True puff_3d: bool = False + underlay_style: str = "auto" kind: str = "satin" PARAMS = (("spacing", "Dichte (mm)", float, 0.2, 1.2, 0.05), ("pull_comp", "Zugkompensation (mm)", float, 0.0, 0.8, 0.05), ("max_stitch", "Max. Stich / Split (mm)", float, 4.0, 12.0, 0.5), ("underlay", "Unterlage", bool, 0, 0, 0), + ("underlay_style", "Unterlage-Art", UNDERLAY_STYLES, 0, 0, 0), ("puff_3d", "3D-Puff (Cap-Modus)", bool, 0, 0, 0)) def local_geom(self): @@ -194,14 +206,20 @@ def runs(self, start=None): eff_spacing = min(self.spacing, 0.25) if self.puff_3d else self.spacing eff_pc = max(self.pull_comp, 0.32) if self.puff_3d else self.pull_comp res = satin_runs( - g, eff_spacing, eff_pc, self.max_stitch, self.underlay, start, puff_3d=self.puff_3d + g, eff_spacing, eff_pc, self.max_stitch, self.underlay, start, puff_3d=self.puff_3d, + underlay_style=self.underlay_style, ) out = [] if not self.puff_3d and res.leftover is not None and not res.leftover.is_empty: ang = main_axis_angle(res.leftover) + 90 out.extend(fill_runs(res.leftover.buffer(0.15), ang, 0.42, 3.0, "standard", start=start)) + n_left = len(out) out.extend(res.runs) - return [r for r in out if len(r) >= 2] + out = [r for r in out if len(r) >= 2] + if n_left and len(out) > n_left: + # Wege zwischen Füll-Resten (z. B. Säulen-Enden) und Satinstart werden vom Satin verdeckt + out = join_covered_runs(out[:n_left + 1], g) + out[n_left + 1:] + return out @dataclass @@ -244,6 +262,53 @@ def runs(self, start=None): return out +@dataclass +class ColumnObject(EmbObject): + """Manuelle Satinsäule: gezeichnete Mittellinie + Breite. + + Die Stiche stehen exakt senkrecht zur gezeichneten Linie (wie „Column A/B“ in kommerziellen + Programmen) – kein Rückraten der Mittellinie per Skelett wie beim Flächen-Satin.""" + geom: Any = None # LineString / MultiLineString (Mittellinie) + width: float = 3.0 + spacing: float = 0.4 + pull_comp: float = 0.15 + underlay: bool = True + kind: str = "column" + + PARAMS = (("width", "Säulenbreite (mm)", float, 0.6, 12.0, 0.1), + ("spacing", "Dichte (mm)", float, 0.2, 1.2, 0.05), + ("pull_comp", "Zugkompensation (mm)", float, 0.0, 0.8, 0.05), + ("underlay", "Unterlage", bool, 0, 0, 0)) + + def local_geom(self): + return self.geom + + def bounds(self): + g = self.world_geom() + if g is None or g.is_empty: + return (0, 0, 0, 0) + return g.buffer(self.width * m_scale(self.transform) / 2.0).bounds + + def runs(self, start=None): + from ..engine.column import column_runs + g = self.world_geom() + w = self.width * m_scale(self.transform) + out: list[list[Pt]] = [] + cur = start + lines = lines_of(g) + while lines: + if cur is not None: + lines.sort(key=lambda l: min(dist(cur, l.coords[0]), dist(cur, l.coords[-1]))) + l = lines.pop(0) + rs = column_runs(list(l.coords), w, spacing=self.spacing, pull_comp=self.pull_comp, + underlay="center" if self.underlay else "none", start=cur) + rs = [r for r in rs if len(r) >= 2] + if rs: + out.extend(rs) + cur = rs[-1][-1] + return out + + @dataclass class StitchesObject(EmbObject): """Importierte Stiche (z. B. aus DST/EXP). Stiche sind fix, können aber transformiert werden.""" @@ -264,7 +329,7 @@ def to_dict(self): # Textobjekt ist in text.py definiert (benötigt Font-Engine), Registrierung dort. -OBJECT_TYPES: dict[str, type] = {"fill": FillObject, "satin": SatinObject, "run": RunObject, +OBJECT_TYPES: dict[str, type] = {"fill": FillObject, "satin": SatinObject, "run": RunObject, "column": ColumnObject, "stitches": StitchesObject} @@ -292,7 +357,7 @@ def _geom_dict(obj: EmbObject, d: dict) -> dict: return d -for _cls in (FillObject, SatinObject, RunObject): +for _cls in (FillObject, SatinObject, RunObject, ColumnObject): _orig = _cls.to_dict def _to_dict(self, _orig=_orig): @@ -300,6 +365,36 @@ def _to_dict(self, _orig=_orig): _cls.to_dict = _to_dict +def color_sorted(objects: list[EmbObject]) -> list[EmbObject]: + """Farbwechsel minimieren, ohne die Überdeckung zu verändern. + + Ein Objekt darf nur dann nach vorne zu einem früheren Block gleicher Farbe rutschen, wenn es + keines der übersprungenen Objekte berührt – sonst würde es plötzlich darunter statt darüber + gestickt (das naive Gruppieren nach Farbe zerstört genau das).""" + geoms = {} + + def footprint(o): + if o.id not in geoms: + g = o.world_geom() + geoms[o.id] = None if g is None or g.is_empty else g.buffer(0.3) + return geoms[o.id] + + out: list[EmbObject] = [] + for o in objects: + pos = len(out) + # rückwärts nach dem letzten Objekt gleicher Farbe suchen, solange nichts überlappt + for k in range(len(out) - 1, -1, -1): + prev = out[k] + if prev.thread.hex == o.thread.hex: + pos = k + 1 + break + fo, fp = footprint(o), footprint(prev) + if fo is None or fp is None or fo.intersects(fp): + break + out.insert(pos, o) + return out + + # --------------------------------------------------------------------------- # Design # --------------------------------------------------------------------------- @@ -325,13 +420,20 @@ def pc_boost(self) -> float: @property def strategy_description(self) -> str: - return { - "small": "Straffe Spannung · feine Details (-10% Zugkompensation, Standard-Unterlage)", - "medium": "Ausgewogen · Standard-Brust-/Ärmelrahmen (100% Zugkompensation)", - "large": "Anti-Trampolin · verstärkte Unterlage (+20% Zugkompensation + Kreuz-Gitter bei grossen Flächen)", - "jacket": "Jackenrücken-Stabilisierung (+35% Zugkompensation, Doppel-Gitter-Unterlage, max. 750 SPM)", - "cap": "Kappen-Modus (Center-Out & Bottom-Up vom Schirm nach oben, +30% Zugkompensation)", - }.get(self.mode, "Standard") + """Beschreibung der Rahmen-Strategie in der aktuellen UI-Sprache.""" + from ..i18n import tr # spät importieren: vermeidet Import-Zyklen + if self.mode in ("small", "medium", "large", "jacket", "cap"): + return tr(f"hoop_strat_{self.mode}") + return tr("hoop_strat_default") + + @property + def display_name(self) -> str: + """Übersetzter Anzeigename. `name` selbst bleibt unverändert (Projektdateien & Abgleich).""" + key = HOOP_NAME_KEYS.get(self.name) + if key is None: + return self.name + from ..i18n import tr + return tr(key) HOOPS = [ @@ -347,6 +449,20 @@ def strategy_description(self) -> str: Hoop("Grossfeld 40 × 30 cm (Industrie)", 400, 300, False, "jacket", 6.0), ] +# Übersetzungsschlüssel für die Anzeige der Rahmennamen (Hoop.name ist der gespeicherte Schlüssel) +HOOP_NAME_KEYS: dict[str, str] = { + "Rund 9 cm (Klein/Brust)": "hoop_round9", + "Rund 12 cm (Brustlogo)": "hoop_round12", + "Rund 15 cm (Standard)": "hoop_round15", + "Rund 18 cm (Gross)": "hoop_round18", + "Rund 21 cm (XL)": "hoop_round21", + "Kappe / Cap 13 × 5 cm": "hoop_cap", + "Wide-Cap 30 × 7 cm": "hoop_widecap", + "Rechteck 13 × 18 cm (Haushalt)": "hoop_rect13x18", + "Jackenrücken 30 × 30 cm": "hoop_jacket", + "Grossfeld 40 × 30 cm (Industrie)": "hoop_field40x30", +} + @dataclass class Settings: @@ -399,6 +515,10 @@ def build_plan(self, progress=None) -> StitchPlan: block: ColorBlock | None = None s = self.settings vis = [o for o in self.objects if o.visible] + # Cache-Einträge gelöschter/ersetzter Objekte verwerfen (sonst wächst er mit jedem Undo) + live = {o.id for o in self.objects} + for k in [k for k in self._cache if k[0] not in live]: + del self._cache[k] for i, obj in enumerate(vis): if progress: progress(i, len(vis)) @@ -430,14 +550,14 @@ def build_plan(self, progress=None) -> StitchPlan: def _connect(self, block: ColorBlock, cur: Pt | None, run: list[Pt]) -> Pt: s = self.settings st = block.stitches + joined = cur is not None and dist(cur, run[0]) <= s.min_trim if cur is None: st.append(Stitch(run[0][0], run[0][1], Cmd.JUMP)) if s.tie_in: for p in tie_stitches(run[0], run[1] if len(run) > 1 else None)[1:]: st.append(Stitch(p[0], p[1])) else: - d = dist(cur, run[0]) - if d <= s.min_trim: + if joined: pass # direkt weitersticken else: if s.tie_off and len(st) > 2: @@ -449,8 +569,9 @@ def _connect(self, block: ColorBlock, cur: Pt | None, run: list[Pt]) -> Pt: if s.tie_in: for p in tie_stitches(run[0], run[1] if len(run) > 1 else None)[1:]: st.append(Stitch(p[0], p[1])) - prev = cur if cur is not None else run[0] - for p in run[(0 if cur is not None and dist(cur, run[0]) <= s.min_trim else 1):]: + # Nach Trim + Sprung steht die Nadel auf run[0], nicht mehr auf `cur` + prev = cur if joined else run[0] + for p in run[(0 if joined else 1):]: # Stiche länger als Maschinengrenze teilen dd = dist(prev, p) if dd > s.max_stitch: diff --git a/openstitch/engine/column.py b/openstitch/engine/column.py new file mode 100644 index 0000000..f2031c0 --- /dev/null +++ b/openstitch/engine/column.py @@ -0,0 +1,533 @@ +"""Satinsäule entlang einer Mittellinie (wiederverwendbarer Säulen-Generator). + +Wird von der Kleinschrift „OpenStitch Micro“ benutzt und ist als Grundlage für ein +manuelles Satinsäulen-Werkzeug gedacht: aus einer Mittellinie und einer (optional +variablen) Breite entsteht ein echter Satin-Zickzack mit + +* Querschnitten im Abstand spacing/2 (Einstiche auf derselben Kante → exakt `spacing`, + gleiche Konvention wie ``satin._zigzag``), +* geglätteter Stichrichtung (senkrecht zur lokalen Tangente), +* Kurzstichen auf der Innenseite enger Kurven, +* Eckbehandlung wie in kommerziellen Programmen: Gehrung (Mitre) mit weich drehenden + Querschnitten bei mittleren Ecken, beschnittene Gehrung bei sehr spitzen Ecken + (beide Schenkel werden senkrecht gestickt und an der Winkelhalbierenden abgeschnitten, + Spitze auf MITRE_LIMIT gekappt) – nie hunderte Einstiche in ein Loch, +* Mittellauf-Unterlage (hin), Satin zurück, +* Rückfall auf Dreifachstich (Bean) bei sehr schmalen Säulen. + +Koordinaten in mm, Y zeigt nach unten (Bildschirm-Konvention); die Geometrie selbst ist +orientierungsunabhängig. +""" +from __future__ import annotations + +import math + +from .geom import Pt, dist, resample_path +from .running import bean_stitch + +CORNER_DEG = 40.0 # ab dieser Richtungsänderung gilt ein Stützpunkt als Ecke + # (Gehrung schon ab 40° – sauberer als reine Glättung) +MITRE_MAX_DEG = 130.0 # bis hierhin weiche Gehrung, darüber beschnittene Gehrung +MITRE_LIMIT = 1.45 # max. Gehrungslänge (× halbe Breite) auf der Aussenseite +MIN_SATIN_W = 0.7 # schmaler → Dreifachstich statt Satin +UNDERLAY_MIN_W = 1.0 # schmaler → keine Unterlage (würde nur auftragen) +FINE = 0.04 # Feinraster der Mittellinie (mm) +SAME_HOLE = 0.12 # Einstiche näher als das gelten als „selbes Loch“ + + +# --------------------------------------------------------------------------- +# Öffentliche API +# --------------------------------------------------------------------------- +def column_runs(centerline: list[Pt], width: float | list[float], spacing: float = 0.4, + pull_comp: float = 0.1, underlay: str = "center", start: Pt | None = None, + max_stitch: float = 7.0, run_len: float = 1.0) -> list[list[Pt]]: + """Satinsäule entlang `centerline` (Polylinie in mm). + + width Säulenbreite (mm) – Zahl oder Liste je Stützpunkt der Mittellinie. + spacing Abstand zweier Einstiche auf derselben Kante (Dichte). + pull_comp Zugkompensation je Seite (mm). + underlay "center" (Mittellauf hin, Satin zurück) oder "none"; unter 1.0 mm Breite + wird automatisch keine Unterlage genäht. + start Nadelposition davor – die Säule beginnt am näheren Ende (bei geschlossenen + Linien am nächsten Stützpunkt). + max_stitch längere Satinstiche werden geteilt (Split-Satin). + run_len Stichlänge des Dreifachstichs, falls die Säule schmaler als 0.7 mm ist. + + Liefert eine Liste zusammenhängender Läufe (normalerweise genau einen).""" + pts, ws = _prepare(centerline, width) + if len(pts) < 2: + return [] + closed = len(pts) > 3 and dist(pts[0], pts[-1]) < 1e-6 + if start is not None: + if closed: + k = min(range(len(pts) - 1), key=lambda i: dist(pts[i], start)) + pts = pts[k:-1] + pts[:k + 1] + ws = ws[k:-1] + ws[:k + 1] + elif dist(start, pts[-1]) < dist(start, pts[0]): + pts.reverse() + ws.reverse() + if max(ws) < MIN_SATIN_W: + return [bean_run(pts, run_len, closed)] + cross = _cross_sections(pts, ws, spacing, pull_comp, closed) + if len(cross) < 2: + return [bean_run(pts, run_len, closed)] + wavg = sum(ws) / len(ws) + if underlay == "center" and wavg >= UNDERLAY_MIN_W: + hw0 = wavg / 2 + walk = _even(_trim_ends(pts, 0.0 if closed else min(0.4, hw0)), 1.6) + zz = _zigzag(list(reversed(cross)), spacing, max_stitch) + run = walk + zz + else: + run = _zigzag(cross, spacing, max_stitch) + return [run] if len(run) >= 2 else [] + + +def bean_run(pts: list[Pt], run_len: float = 1.0, closed: bool = False) -> list[Pt]: + """Dreifachstich entlang einer Linie mit gleichmässigen Stichen (Ecken bleiben erhalten).""" + ev = _even(pts, run_len) + if len(ev) < 2: + return ev + # Stiche sind bereits ≤ run_len → bean_stitch unterteilt nicht erneut + return bean_stitch(ev, stitch_len=run_len * 1.6, repeats=1) + + +# --------------------------------------------------------------------------- +# Hilfen +# --------------------------------------------------------------------------- +def _prepare(centerline, width): + pts = [(float(p[0]), float(p[1])) for p in centerline] + if isinstance(width, (int, float)): + ws = [float(width)] * len(pts) + else: + ws = [float(w) for w in width] + if len(ws) != len(pts): + raise ValueError("width-Liste muss so lang wie die Mittellinie sein") + out_p, out_w = [], [] + for p, w in zip(pts, ws): + if not (math.isfinite(p[0]) and math.isfinite(p[1])): + continue + if out_p and dist(out_p[-1], p) < 1e-6: + continue + out_p.append(p) + out_w.append(max(0.0, w)) + # geschlossene Linie: Endpunkt exakt auf den Anfang legen + if len(out_p) > 3 and dist(out_p[0], out_p[-1]) < 1e-4: + out_p[-1] = out_p[0] + return out_p, out_w + + +def _unit(dx, dy): + L = math.hypot(dx, dy) + if L < 1e-12: + return (0.0, 0.0) + return (dx / L, dy / L) + + +def _turn(a: Pt, b: Pt, c: Pt) -> float: + """Vorzeichenbehaftete Richtungsänderung bei b (Grad, + = gegen den Uhrzeigersinn in x/y).""" + u = _unit(b[0] - a[0], b[1] - a[1]) + v = _unit(c[0] - b[0], c[1] - b[1]) + cr = u[0] * v[1] - u[1] * v[0] + dt = u[0] * v[0] + u[1] * v[1] + return math.degrees(math.atan2(cr, dt)) + + +def _cum(pts): + out = [0.0] + for a, b in zip(pts, pts[1:]): + out.append(out[-1] + dist(a, b)) + return out + + +def _at(pts, cum, s): + """Punkt bei Bogenlänge s (geklemmt).""" + if s <= 0: + return pts[0] + if s >= cum[-1]: + return pts[-1] + lo, hi = 0, len(cum) - 1 + while hi - lo > 1: + mid = (lo + hi) // 2 + if cum[mid] <= s: + lo = mid + else: + hi = mid + seg = cum[hi] - cum[lo] + t = 0.0 if seg < 1e-12 else (s - cum[lo]) / seg + a, b = pts[lo], pts[hi] + return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) + + +def _interp_w(cum, ws, s): + if s <= 0: + return ws[0] + if s >= cum[-1]: + return ws[-1] + for i in range(1, len(cum)): + if cum[i] >= s: + seg = cum[i] - cum[i - 1] + t = 0.0 if seg < 1e-12 else (s - cum[i - 1]) / seg + return ws[i - 1] + (ws[i] - ws[i - 1]) * t + return ws[-1] + + +def _even(pts: list[Pt], step: float, corner_deg: float = 35.0) -> list[Pt]: + """Gleichmässige Teilung je Abschnitt zwischen Ecken (ohne Mikrostiche an dichten Kurvenpunkten).""" + if len(pts) < 2: + return list(pts) + parts, cur = [], [pts[0]] + for i in range(1, len(pts) - 1): + cur.append(pts[i]) + if abs(_turn(pts[i - 1], pts[i], pts[i + 1])) > corner_deg: + parts.append(cur) + cur = [pts[i]] + cur.append(pts[-1]) + parts.append(cur) + out = [pts[0]] + for part in parts: + cum = _cum(part) + L = cum[-1] + if L < 1e-9: + continue + n = max(1, int(math.ceil(L / step - 1e-9))) + for k in range(1, n + 1): + out.append(_at(part, cum, L * k / n)) + return out + + +def _trim_ends(pts, d): + """Kürzt eine offene Linie an beiden Enden um d (für versteckte Unterlage).""" + cum = _cum(pts) + L = cum[-1] + if d <= 0 or L <= 2.5 * d: + return pts + a, b = d, L - d + out = [_at(pts, cum, a)] + out += [p for p, s in zip(pts, cum) if a < s < b] + out.append(_at(pts, cum, b)) + return out + + +# --------------------------------------------------------------------------- +# Querschnitte +# --------------------------------------------------------------------------- +class _Corner: + """Ecke der Mittellinie. kind: "mitre" (weich drehende Querschnitte) oder "clip" + (senkrechte Querschnitte, an der Winkelhalbierenden beschnitten).""" + __slots__ = ("idx", "theta", "sign", "kind", "lam", "r", "u", "m", "hw", "K") + + def __init__(self, idx, theta, sign): + self.idx, self.theta, self.sign = idx, theta, sign + self.kind, self.lam, self.r, self.m, self.hw = "clip", 0.0, (0.0, 0.0), 1.0, 0.0 + self.u, self.K = (1.0, 0.0), (0.0, 0.0) + + +def _dir_near(pts, cum, s, d, forward): + """Mittlere Richtung der Linie über eine kurze Strecke d ab Bogenlänge s.""" + if forward: + a, b = _at(pts, cum, s), _at(pts, cum, s + d) + else: + a, b = _at(pts, cum, s - d), _at(pts, cum, s) + return _unit(b[0] - a[0], b[1] - a[1]) + + +def _cross_sections(pts, ws, spacing, pc, closed): + """Liste von (Mitte, L, R) im Abstand spacing/2 entlang der ganzen Säule.""" + cum = _cum(pts) + hws = [w / 2 + pc for w in ws] + n = len(pts) + # --- Ecken finden -------------------------------------------------------- + corners = [] + for i in range(1, n - 1): + t = _turn(pts[i - 1], pts[i], pts[i + 1]) + if abs(t) > CORNER_DEG: + corners.append(_Corner(i, abs(t), 1 if t > 0 else -1)) + if closed: + t = _turn(pts[-2], pts[0], pts[1]) + if abs(t) > CORNER_DEG: + corners.insert(0, _Corner(0, abs(t), 1 if t > 0 else -1)) + if closed and corners: + # Naht auf die erste Ecke legen → wie offene Linie mit Ecke am Anfang/Ende + k = corners[0].idx + pts = pts[k:-1] + pts[:k + 1] + hws = hws[k:-1] + hws[:k + 1] + for c in corners: + c.idx = (c.idx - k) % (n - 1) + seam = corners[0] + corners = corners[1:] + cum = _cum(pts) + bounds = [0] + [c.idx for c in corners] + [len(pts) - 1] + joints = [seam] + corners + [seam] + else: + bounds = [0] + [c.idx for c in corners] + [n - 1] + joints = [None] + corners + [None] + loop_smooth = closed and not corners + + # --- Eckart bestimmen (weiche oder beschnittene Gehrung) -------------------- + seg_len = [cum[bounds[k + 1]] - cum[bounds[k]] for k in range(len(bounds) - 1)] + for j, c in enumerate(joints): + if c is None: + continue + k_in = j - 1 if j > 0 else len(seg_len) - 1 # Abschnitt vor der Ecke + k_out = j if j < len(seg_len) else 0 # Abschnitt nach der Ecke + s_k = cum[bounds[j]] + hw = hws[bounds[j]] + half = math.radians(c.theta) / 2 + s_off = hw * math.tan(half) + lam = min(2.0 * s_off + 0.5 * hw, 0.5 * min(seg_len[k_in], seg_len[k_out])) + c.hw = hw + c.K = pts[bounds[j]] + d = max(1e-3, min(hw, 0.5 * seg_len[k_in], 0.5 * seg_len[k_out])) + if j == 0 or j == len(joints) - 1: # Naht einer geschlossenen Linie + ta = _dir_near(pts, cum, cum[-1], d, False) + tb = _dir_near(pts, cum, 0.0, d, True) + else: + ta = _dir_near(pts, cum, s_k, d, False) + tb = _dir_near(pts, cum, s_k, d, True) + na = (-ta[1], ta[0]) + nb = (-tb[1], tb[0]) + # Winkelhalbierende der Normalen (Gehrungslinie) und Normale dazu (Richtung a→b) + r = _unit(na[0] + nb[0], na[1] + nb[1]) + c.r = r if r != (0.0, 0.0) else ta # Haarnadel: Achse = Laufrichtung + u = _unit(ta[0] + tb[0], ta[1] + tb[1]) + c.u = u if u != (0.0, 0.0) else na + c.m = 1.0 / max(math.cos(half), 1e-3) + if c.theta <= MITRE_MAX_DEG and lam >= 1.3 * s_off + 0.05: + c.kind = "mitre" + c.lam = lam + else: + c.kind = "clip" + + # --- Abschnitte ------------------------------------------------------------ + out = [] + for k in range(len(bounds) - 1): + i0, i1 = bounds[k], bounds[k + 1] + piece = pts[i0:i1 + 1] + phw = hws[i0:i1 + 1] + cs = joints[k] # Ecke am Anfang + ce = joints[k + 1] # Ecke am Ende + cross = _piece_cross(piece, phw, spacing, cs, ce, loop_smooth) + if not cross: + continue + if cs is not None and cs.kind == "mitre" and out and k > 0: + cross = cross[1:] # gemeinsamer Gehrungs-Querschnitt nur einmal + out.extend(cross) + return out + + +def _piece_cross(piece, phw, spacing, cs, ce, loop_smooth): + cum = _cum(piece) + Lp = cum[-1] + if Lp < 1e-6: + return [] + hwm = max(phw) + win = min(0.8, max(0.2, 0.6 * hwm)) + # Feinraster der Mittellinie + nfine = max(2, int(math.ceil(Lp / FINE)) + 1) + ss = [Lp * j / (nfine - 1) for j in range(nfine)] + C, Lr, Rr, T = [], [], [], [] + for s in ss: + c = _at(piece, cum, s) + if loop_smooth: + a = _at(piece, cum, (s - win) % Lp) + b = _at(piece, cum, (s + win) % Lp) + else: + a = _at(piece, cum, max(0.0, s - win)) + b = _at(piece, cum, min(Lp, s + win)) + # an Ecken nicht über die Ecke hinweg glätten: Richtung des eigenen Abschnitts + t = _unit(b[0] - a[0], b[1] - a[1]) + if t == (0.0, 0.0): + t = T[-1] if T else (1.0, 0.0) + h = _interp_w(cum, phw, s) + nx, ny = -t[1], t[0] + C.append(c) + T.append(t) + Lr.append((c[0] + nx * h, c[1] + ny * h)) + Rr.append((c[0] - nx * h, c[1] - ny * h)) + + # Beschnittene Gehrung: Säule geradlinig über die Ecke hinaus verlängern; die Querschnitte + # werden danach an der Winkelhalbierenden (und der Spitzenkappung) abgeschnitten. + if ce is not None and ce.kind == "clip": + _extend(C, Lr, Rr, ss, T[-1], piece[-1], phw[-1], ce, at_end=True, Lp=Lp) + if cs is not None and cs.kind == "clip": + _extend(C, Lr, Rr, ss, T[0], piece[0], phw[0], cs, at_end=False, Lp=Lp) + + # Gehrung: Querschnitte drehen weich zur Winkelhalbierenden, Innenkante wird linear + # auf den Gehrungspunkt geführt → keine Stichhäufung in der Ecke + if ce is not None and ce.kind == "mitre": + _blend(C, Lr, Rr, ss, ce, at_end=True, Lp=Lp) + if cs is not None and cs.kind == "mitre": + _blend(C, Lr, Rr, ss, cs, at_end=False, Lp=Lp) + + # Querschnitte nach Fortschritt der längeren Kante setzen (Aussenkante = spacing) + prog = [0.0] + for j in range(1, len(C)): + prog.append(prog[-1] + max(dist(Lr[j - 1], Lr[j]), dist(Rr[j - 1], Rr[j]), 1e-9)) + M = prog[-1] + step = spacing / 2.0 + N = max(1, int(round(M / step))) + out = [] + j = 0 + last = N - 1 if loop_smooth else N + for k in range(last + 1): + m = M * k / N + while j < len(prog) - 2 and prog[j + 1] < m: + j += 1 + seg = prog[j + 1] - prog[j] + t = 0.0 if seg < 1e-12 else min(1.0, max(0.0, (m - prog[j]) / seg)) + + def lerp(A, B): + return (A[0] + (B[0] - A[0]) * t, A[1] + (B[1] - A[1]) * t) + out.append((lerp(C[j], C[j + 1]), lerp(Lr[j], Lr[j + 1]), lerp(Rr[j], Rr[j + 1]))) + if ce is not None and ce.kind == "clip": + out = _clip(out, ce, keep_before=True) + if cs is not None and cs.kind == "clip": + out = _clip(out, cs, keep_before=False) + return out + + +def _extend(C, Lr, Rr, ss, t, P, h, corner, at_end, Lp): + """Verlängert die Feinpunkte geradlinig über die Ecke hinaus (bzw. vor den Anfang).""" + ext = corner.hw * min(4.0, math.tan(math.radians(corner.theta) / 2)) + 0.1 + nx, ny = -t[1], t[0] + steps = max(1, int(math.ceil(ext / FINE))) + sg = 1.0 if at_end else -1.0 + add = [] + for j in range(1, steps + 1): + e = ext * j / steps + c = (P[0] + sg * t[0] * e, P[1] + sg * t[1] * e) + add.append((c, (c[0] + nx * h, c[1] + ny * h), (c[0] - nx * h, c[1] - ny * h), + Lp + e if at_end else -e)) + if not at_end: + add.reverse() + cs_, ls_, rs_, ss_ = (list(v) for v in zip(*add)) + if at_end: + C.extend(cs_) + Lr.extend(ls_) + Rr.extend(rs_) + ss.extend(ss_) + else: + C[:0] = cs_ + Lr[:0] = ls_ + Rr[:0] = rs_ + ss[:0] = ss_ + + +def _clip(cross, corner, keep_before: bool): + """Querschnitte auf die eigene Seite der Winkelhalbierenden beschneiden und die + Aussenspitze auf MITRE_LIMIT × halbe Breite kappen. Zu kurze Reste entfallen.""" + K, (ux, uy), (rx, ry) = corner.K, corner.u, corner.r + ox, oy = -corner.sign * rx, -corner.sign * ry # Aussenrichtung der Ecke + lim = corner.hw * MITRE_LIMIT + min_len = max(0.3, 0.35 * corner.hw) + planes = ((ux, uy, 0.0, 1.0 if keep_before else -1.0), (ox, oy, lim, 1.0)) + # nur lokal an der Ecke beschneiden (Halbebenen sind unendlich – ein Bogen, der später + # wieder nahe an die Ecke kommt, darf nicht mitbeschnitten werden) + reach = corner.hw * (min(4.0, math.tan(math.radians(corner.theta) / 2)) + 2.0) + 0.2 + seq = list(reversed(cross)) if keep_before else list(cross) + n_local = 0 + for c, _, _ in seq: + if dist(c, K) > reach: + break + n_local += 1 + rest = seq[n_local:] + out = [] + for c, L, R in seq[:n_local]: + lo, hi = 0.0, 1.0 + ok = True + for ax, ay, bound, sgn in planes: + # Bedingung: sgn · (p − K)·a ≤ bound für p = L + t (R − L) + f0 = sgn * ((L[0] - K[0]) * ax + (L[1] - K[1]) * ay) - bound + f1 = sgn * ((R[0] - K[0]) * ax + (R[1] - K[1]) * ay) - bound + if f0 <= 0 and f1 <= 0: + continue + if f0 > 0 and f1 > 0: + ok = False + break + tz = f0 / (f0 - f1) + if f0 > 0: + lo = max(lo, tz) + else: + hi = min(hi, tz) + if not ok or hi - lo <= 1e-9: + continue + L2 = (L[0] + (R[0] - L[0]) * lo, L[1] + (R[1] - L[1]) * lo) + R2 = (L[0] + (R[0] - L[0]) * hi, L[1] + (R[1] - L[1]) * hi) + if dist(L2, R2) < min_len: + continue + out.append(((0.5 * (L2[0] + R2[0]), 0.5 * (L2[1] + R2[1])), L2, R2)) + out += rest + return list(reversed(out)) if keep_before else out + + +def _blend(C, Lr, Rr, ss, corner, at_end, Lp): + lam = corner.lam + K = C[-1] if at_end else C[0] + h = corner.hw + rx, ry = corner.r + inner = h * corner.m + outer = h * min(corner.m, MITRE_LIMIT) + # r zeigt in Richtung der Normalen (+n = L-Seite); Innenseite = Seite der Drehung + if corner.sign > 0: + L_end = (K[0] + rx * inner, K[1] + ry * inner) + R_end = (K[0] - rx * outer, K[1] - ry * outer) + else: + L_end = (K[0] + rx * outer, K[1] + ry * outer) + R_end = (K[0] - rx * inner, K[1] - ry * inner) + if at_end: + idx = [j for j, s in enumerate(ss) if s >= Lp - lam] + if not idx: + return + j0 = max(0, idx[0] - 1) + L0, R0, s0 = Lr[j0], Rr[j0], ss[j0] + span = max(Lp - s0, 1e-9) + for j in idx: + u = min(1.0, (ss[j] - s0) / span) + Lr[j] = (L0[0] + (L_end[0] - L0[0]) * u, L0[1] + (L_end[1] - L0[1]) * u) + Rr[j] = (R0[0] + (R_end[0] - R0[0]) * u, R0[1] + (R_end[1] - R0[1]) * u) + else: + idx = [j for j, s in enumerate(ss) if s <= lam] + if not idx: + return + j1 = min(len(ss) - 1, idx[-1] + 1) + L1, R1, s1 = Lr[j1], Rr[j1], ss[j1] + span = max(s1, 1e-9) + for j in idx: + u = min(1.0, (s1 - ss[j]) / span) + Lr[j] = (L1[0] + (L_end[0] - L1[0]) * u, L1[1] + (L_end[1] - L1[1]) * u) + Rr[j] = (R1[0] + (R_end[0] - R1[0]) * u, R1[1] + (R_end[1] - R1[1]) * u) + + +# --------------------------------------------------------------------------- +# Zickzack mit Kurzstichen +# --------------------------------------------------------------------------- +def _zigzag(cross, spacing: float, max_stitch: float) -> list[Pt]: + """Wechselt je Querschnitt die Seite L ↔ R. Ist der Abstand zum letzten vollen Einstich + derselben Kante kleiner als spacing/2 (Innenseite enger Kurven/Ecken), wird der Einstich + Richtung Mitte gezogen (Kurzstich). Einstiche ins selbe Loch werden ausgelassen.""" + pts: list[Pt] = [] + last_full: list[Pt | None] = [None, None] + last_any: list[Pt | None] = [None, None] + for i, (c, L, R) in enumerate(cross): + side = i % 2 + tgt, opp = (L, R) if side == 0 else (R, L) + lf = last_full[side] + if lf is not None and dist(lf, tgt) < 0.5 * spacing: + tgt = (tgt[0] + (opp[0] - tgt[0]) * 0.45, tgt[1] + (opp[1] - tgt[1]) * 0.45) + else: + last_full[side] = tgt + la = last_any[side] + if la is not None and dist(la, tgt) < SAME_HOLE: + continue + last_any[side] = tgt + if pts: + prev = pts[-1] + w = dist(prev, tgt) + if w > max_stitch: + nseg = int(math.ceil(w / max_stitch)) + shift = 0.35 if (i % 2) else -0.35 + for k in range(1, nseg): + t = max(0.15, min(0.85, (k + shift * 0.5) / nseg)) + pts.append((prev[0] + (tgt[0] - prev[0]) * t, prev[1] + (tgt[1] - prev[1]) * t)) + pts.append(tgt) + return pts diff --git a/openstitch/engine/density_guard.py b/openstitch/engine/density_guard.py index 5a9a19f..f056a87 100644 --- a/openstitch/engine/density_guard.py +++ b/openstitch/engine/density_guard.py @@ -20,6 +20,15 @@ from ..core.stitchplan import Cmd, Stitch, StitchPlan +def _is_turn(a: tuple[float, float], s: Stitch, nxt: Stitch) -> bool: + """Wendepunkt (> 60° Richtungswechsel), z. B. Reihenwechsel im Tatami oder Satin-Kante. + + Solche Einstiche definieren die Kontur – wer sie als „Mikrostich“ entfernt, franst die Kante aus.""" + ux, uy = s.x - a[0], s.y - a[1] + vx, vy = nxt.x - s.x, nxt.y - s.y + return ux * vx + uy * vy < 0.5 * math.hypot(ux, uy) * math.hypot(vx, vy) + + def optimize_stitches_for_melco( stitches: list[Stitch], min_stitch_mm: float = 0.45, @@ -46,7 +55,7 @@ def optimize_stitches_for_melco( if last_xy is not None: d = math.hypot(s.x - last_xy[0], s.y - last_xy[1]) - if d < min_stitch_mm and not is_last_of_run: + if d < min_stitch_mm and not is_last_of_run and not _is_turn(last_xy, s, stitches[i + 1]): continue cell = (int(math.floor(s.x / cell_size_mm)), int(math.floor(s.y / cell_size_mm))) diff --git a/openstitch/engine/fill.py b/openstitch/engine/fill.py index bc0803b..1da36c6 100644 --- a/openstitch/engine/fill.py +++ b/openstitch/engine/fill.py @@ -211,6 +211,34 @@ def _travel(poly: Polygon, prepared, rings, a: Pt, b: Pt, stitch_len: float) -> return resample_path(path, stitch_len)[1:] +def join_covered_runs(runs: list[list[Pt]], geom, stitch_len: float = 2.5) -> list[list[Pt]]: + """Verbindet aufeinanderfolgende Läufe mit Laufstichen innerhalb von `geom` statt Trim + Sprung. + + Nur für Läufe, die anschliessend noch überstickt werden (Unterlage → Deckstich, + Satin-Endkappen → Satinsäule): die Wege liegen dann verdeckt unter dem Deckstich.""" + if len(runs) < 2 or geom is None or geom.is_empty: + return runs + area = geom.buffer(0.05) + prepared = prep(area) + rings = [] + for p in polygons_of(geom.buffer(-0.3)) or polygons_of(geom): + rings.append(p.exterior) + rings.extend(p.interiors) + out = [list(runs[0])] + for r in runs[1:]: + a, b = out[-1][-1], r[0] + if dist(a, b) < 1e-6 or prepared.contains(LineString([a, b])): + tr = subdivide(a, b, stitch_len) + else: + tr = _travel(area, prepared, rings, a, b, stitch_len) + if tr is None: + out.append(list(r)) + else: + out[-1].extend(tr) + out[-1].extend(r[1:]) + return out + + def fill_runs(geom, angle: float = 45.0, spacing: float = 0.4, stitch_len: float = 3.5, pattern: str = "standard", start: Pt | None = None, min_len: float = 0.6) -> list[list[Pt]]: diff --git a/openstitch/engine/pro_tools.py b/openstitch/engine/pro_tools.py index 6ddf88f..18ae46e 100644 --- a/openstitch/engine/pro_tools.py +++ b/openstitch/engine/pro_tools.py @@ -13,6 +13,7 @@ from ..core.design import Design, EmbObject, FillObject, RunObject, SatinObject from ..core.stitchplan import Cmd, StitchPlan, Thread from ..engine.geom import clean, main_axis_angle, polygons_of +from ..i18n import fmt_float, fmt_int, tr # =========================================================================== @@ -46,32 +47,132 @@ def create_applique_objects(base_obj: EmbObject, border_width_mm: float = 3.2) - # =========================================================================== -# 2. SVG-Vektor-Importer (ohne Zusatzabhängigkeiten, liest path/rect/circle/ellipse/polygon) +# 2. SVG-Vektor-Importer (ohne Zusatzabhängigkeiten) +# path/rect/circle/ellipse/line/polyline/polygon/use, transform=, Stil-Vererbung, Einheiten # =========================================================================== -def _parse_svg_color(val: str | None) -> tuple[int, int, int] | None: - if not val or val.strip().lower() == "none": +# Vollständige CSS-Farbnamen (CSS Color Module Level 4) +_CSS_COLORS = {k: (int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16)) for k, v in (p.split(":") for p in """ +aliceblue:f0f8ff antiquewhite:faebd7 aqua:00ffff aquamarine:7fffd4 azure:f0ffff beige:f5f5dc bisque:ffe4c4 +black:000000 blanchedalmond:ffebcd blue:0000ff blueviolet:8a2be2 brown:a52a2a burlywood:deb887 cadetblue:5f9ea0 +chartreuse:7fff00 chocolate:d2691e coral:ff7f50 cornflowerblue:6495ed cornsilk:fff8dc crimson:dc143c cyan:00ffff +darkblue:00008b darkcyan:008b8b darkgoldenrod:b8860b darkgray:a9a9a9 darkgreen:006400 darkgrey:a9a9a9 +darkkhaki:bdb76b darkmagenta:8b008b darkolivegreen:556b2f darkorange:ff8c00 darkorchid:9932cc darkred:8b0000 +darksalmon:e9967a darkseagreen:8fbc8f darkslateblue:483d8b darkslategray:2f4f4f darkslategrey:2f4f4f +darkturquoise:00ced1 darkviolet:9400d3 deeppink:ff1493 deepskyblue:00bfff dimgray:696969 dimgrey:696969 +dodgerblue:1e90ff firebrick:b22222 floralwhite:fffaf0 forestgreen:228b22 fuchsia:ff00ff gainsboro:dcdcdc +ghostwhite:f8f8ff gold:ffd700 goldenrod:daa520 gray:808080 green:008000 greenyellow:adff2f grey:808080 +honeydew:f0fff0 hotpink:ff69b4 indianred:cd5c5c indigo:4b0082 ivory:fffff0 khaki:f0e68c lavender:e6e6fa +lavenderblush:fff0f5 lawngreen:7cfc00 lemonchiffon:fffacd lightblue:add8e6 lightcoral:f08080 lightcyan:e0ffff +lightgoldenrodyellow:fafad2 lightgray:d3d3d3 lightgreen:90ee90 lightgrey:d3d3d3 lightpink:ffb6c1 +lightsalmon:ffa07a lightseagreen:20b2aa lightskyblue:87cefa lightslategray:778899 lightslategrey:778899 +lightsteelblue:b0c4de lightyellow:ffffe0 lime:00ff00 limegreen:32cd32 linen:faf0e6 magenta:ff00ff maroon:800000 +mediumaquamarine:66cdaa mediumblue:0000cd mediumorchid:ba55d3 mediumpurple:9370db mediumseagreen:3cb371 +mediumslateblue:7b68ee mediumspringgreen:00fa9a mediumturquoise:48d1cc mediumvioletred:c71585 +midnightblue:191970 mintcream:f5fffa mistyrose:ffe4e1 moccasin:ffe4b5 navajowhite:ffdead navy:000080 +oldlace:fdf5e6 olive:808000 olivedrab:6b8e23 orange:ffa500 orangered:ff4500 orchid:da70d6 palegoldenrod:eee8aa +palegreen:98fb98 paleturquoise:afeeee palevioletred:db7093 papayawhip:ffefd5 peachpuff:ffdab9 peru:cd853f +pink:ffc0cb plum:dda0dd powderblue:b0e0e6 purple:800080 rebeccapurple:663399 red:ff0000 rosybrown:bc8f8f +royalblue:4169e1 saddlebrown:8b4513 salmon:fa8072 sandybrown:f4a460 seagreen:2e8b57 seashell:fff5ee +sienna:a0522d silver:c0c0c0 skyblue:87ceeb slateblue:6a5acd slategray:708090 slategrey:708090 snow:fffafa +springgreen:00ff7f steelblue:4682b4 tan:d2b48c teal:008080 thistle:d8bfd8 tomato:ff6347 turquoise:40e0d0 +violet:ee82ee wheat:f5deb3 white:ffffff whitesmoke:f5f5f5 yellow:ffff00 yellowgreen:9acd32 +""".split())} + +_NUM_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?") +_GRADIENT_GREY = (128, 128, 128) # Verlauf ohne lesbare Stopp-Farbe -> neutrales Grau +_IDENT = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) # SVG-Matrix (a, b, c, d, e, f) + + +def _parse_svg_color(val: str | None, gradients: dict | None = None, + current: tuple[int, int, int] | None = None) -> tuple[int, int, int] | None: + """Liest eine SVG/CSS-Farbe. None = keine Farbe ("none", "transparent"). + url(#verlauf) -> erste Stopp-Farbe des Verlaufs (sonst Grau), currentColor -> `current`.""" + if val is None: return None - v = val.strip().lower() - named = { - "black": (15, 15, 15), "white": (255, 255, 255), "red": (210, 25, 25), - "blue": (25, 75, 205), "green": (30, 150, 55), "yellow": (245, 205, 20), - "orange": (245, 125, 20), "purple": (130, 45, 165), "gray": (128, 128, 128), - "grey": (128, 128, 128), "navy": (20, 36, 77), "gold": (225, 175, 30), - } - if v in named: - return named[v] + raw = val.replace("!important", "").strip() + v = raw.lower() + if not v or v in ("none", "transparent"): + return None + if v.startswith("url("): + m = re.match(r"url\(\s*['\"]?#?([^)'\"]*)['\"]?\s*\)\s*(.*)", raw) + if m and gradients and m.group(1) in gradients: + return gradients[m.group(1)] + if m and m.group(2).strip(): # Fallback-Farbe hinter url(...) + return _parse_svg_color(m.group(2), None, current) + return _GRADIENT_GREY + if v == "currentcolor": + return current if current is not None else (0, 0, 0) + if v in _CSS_COLORS: + return _CSS_COLORS[v] if v.startswith("#"): h = v[1:] - if len(h) == 3: - h = "".join(c * 2 for c in h) - if len(h) >= 6: + if len(h) in (3, 4) and all(c in "0123456789abcdef" for c in h): + h = "".join(c * 2 for c in h[:3]) + if len(h) in (6, 8) and all(c in "0123456789abcdef" for c in h): return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) - m = re.match(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", v) + m = re.match(r"rgba?\(([^)]*)\)", v) + if m: + parts = [p for p in re.split(r"[\s,/]+", m.group(1).strip()) if p][:3] + if len(parts) == 3: + try: + comps = [float(p[:-1]) * 255.0 / 100.0 if p.endswith("%") else float(p) for p in parts] + return tuple(int(round(max(0.0, min(255.0, c)))) for c in comps) + except ValueError: + pass + m = re.match(r"hsla?\(([^)]*)\)", v) if m: - return (int(m.group(1)), int(m.group(2)), int(m.group(3))) - return (30, 30, 30) + nums = [float(x) for x in _NUM_RE.findall(m.group(1))[:3]] + if len(nums) == 3: + import colorsys + r, g, b = colorsys.hls_to_rgb((nums[0] % 360) / 360.0, nums[2] / 100.0, nums[1] / 100.0) + return (int(round(r * 255)), int(round(g * 255)), int(round(b * 255))) + return (30, 30, 30) # unbekannte Angabe -> dunkelgrau statt Abbruch + + +# --------------------------------------------------------------- Transformationen +def _mat_mul(m, n): + """Matrixprodukt m·n (SVG-Reihenfolge: n wird zuerst angewendet).""" + a1, b1, c1, d1, e1, f1 = m + a2, b2, c2, d2, e2, f2 = n + return (a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, a1 * c2 + c1 * d2, b1 * c2 + d1 * d2, + a1 * e2 + c1 * f2 + e1, b1 * e2 + d1 * f2 + f1) + + +def _parse_transform(s: str | None): + """transform="…" -> Matrix; matrix/translate/scale/rotate(a [cx cy])/skewX/skewY, auch mehrere.""" + m = _IDENT + for name, args in re.findall(r"([A-Za-z]+)\s*\(([^)]*)\)", s or ""): + v = [float(x) for x in _NUM_RE.findall(args)] + name = name.lower() + if not v: + continue + if name == "matrix" and len(v) >= 6: + t = tuple(v[:6]) + elif name == "translate": + t = (1.0, 0.0, 0.0, 1.0, v[0], v[1] if len(v) > 1 else 0.0) + elif name == "scale": + t = (v[0], 0.0, 0.0, v[1] if len(v) > 1 else v[0], 0.0, 0.0) + elif name == "rotate": + a = math.radians(v[0]) + t = (math.cos(a), math.sin(a), -math.sin(a), math.cos(a), 0.0, 0.0) + if len(v) >= 3: # um (cx, cy) drehen + t = _mat_mul(_mat_mul((1.0, 0.0, 0.0, 1.0, v[1], v[2]), t), (1.0, 0.0, 0.0, 1.0, -v[1], -v[2])) + elif name == "skewx": + t = (1.0, 0.0, math.tan(math.radians(v[0])), 1.0, 0.0, 0.0) + elif name == "skewy": + t = (1.0, math.tan(math.radians(v[0])), 0.0, 1.0, 0.0, 0.0) + else: + continue + m = _mat_mul(m, t) + return m + +def _apply_mat(m, pts): + a, b, c, d, e, f = m + return [(a * x + c * y + e, b * x + d * y + f) for x, y in pts] + +# --------------------------------------------------------------- Pfad-Parser def _sample_cubic(p0, p1, p2, p3, steps=10): out = [] for i in range(1, steps + 1): @@ -84,158 +185,426 @@ def _sample_cubic(p0, p1, p2, p3, steps=10): return out -def _parse_svg_path_d(d: str) -> list[tuple[list[tuple[float, float]], bool]]: - """Parst SVG-Pfadbefehle (M, L, H, V, C, Q, Z, absolut & relativ) in Punktlisten.""" - tokens = re.findall(r"[A-Za-z]|[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", d) - subpaths: list[tuple[list[tuple[float, float]], bool]] = [] - cur: list[tuple[float, float]] = [] - cx = cy = sx = sy = 0.0 - i = 0 - cmd = "M" - while i < len(tokens): - if tokens[i].isalpha(): - cmd = tokens[i] +def _sample_quad(p0, p1, p2, steps=12): + out = [] + for k in range(1, steps + 1): + t = k / steps + mt = 1.0 - t + out.append((mt * mt * p0[0] + 2 * mt * t * p1[0] + t * t * p2[0], + mt * mt * p0[1] + 2 * mt * t * p1[1] + t * t * p2[1])) + return out + + +def _sample_arc(x1, y1, rx, ry, phi_deg, large, sweep, x2, y2): + """Elliptischer Bogen (SVG-Endpunkt-Form) -> Punkte; Umrechnung in Mittelpunkt-Form nach SVG F.6.5/F.6.6.""" + if abs(x1 - x2) < 1e-12 and abs(y1 - y2) < 1e-12: + return [] + rx, ry = abs(rx), abs(ry) + if rx < 1e-12 or ry < 1e-12: + return [(x2, y2)] # Radius 0 -> gerade Linie + phi = math.radians(phi_deg % 360.0) + cp, sp = math.cos(phi), math.sin(phi) + dx, dy = (x1 - x2) / 2.0, (y1 - y2) / 2.0 + x1p, y1p = cp * dx + sp * dy, -sp * dx + cp * dy + lam = (x1p / rx) ** 2 + (y1p / ry) ** 2 + if lam > 1.0: # Radien zu klein -> hochskalieren + s = math.sqrt(lam) + rx, ry = rx * s, ry * s + num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p + den = rx * rx * y1p * y1p + ry * ry * x1p * x1p + coef = math.sqrt(max(0.0, num / den)) if den > 0 else 0.0 + if bool(large) == bool(sweep): + coef = -coef + cxp, cyp = coef * rx * y1p / ry, -coef * ry * x1p / rx + ccx = cp * cxp - sp * cyp + (x1 + x2) / 2.0 + ccy = sp * cxp + cp * cyp + (y1 + y2) / 2.0 + ux, uy = (x1p - cxp) / rx, (y1p - cyp) / ry + vx, vy = (-x1p - cxp) / rx, (-y1p - cyp) / ry + th1 = math.atan2(uy, ux) + dth = math.atan2(ux * vy - uy * vx, ux * vx + uy * vy) + if not sweep and dth > 0: + dth -= 2 * math.pi + elif sweep and dth < 0: + dth += 2 * math.pi + n = max(2, int(math.ceil(abs(dth) / (2 * math.pi / 72)))) # 5°-Schritte + out = [] + for k in range(1, n + 1): + t = th1 + dth * k / n + ex, ey = rx * math.cos(t), ry * math.sin(t) + out.append((cp * ex - sp * ey + ccx, sp * ex + cp * ey + ccy)) + out[-1] = (x2, y2) + return out + + +_PATH_ARGC = {"M": 2, "L": 2, "H": 1, "V": 1, "C": 6, "S": 4, "Q": 4, "T": 2, "A": 7, "Z": 0} + + +def _path_tokens(d: str) -> list[tuple[str, list[float]]]: + """Zerlegt path-d in (Befehl, Zahlen) inkl. impliziter Wiederholungen, '1.5.5', '-1-2' und + Bogen-Flags ohne Trenner ('a1 1 0 011 1'). Bei einem Syntaxfehler endet der Pfad dort (wie im Browser).""" + out: list[tuple[str, list[float]]] = [] + i, n = 0, len(d) + cmd = None + while True: + while i < n and d[i] in " \t\r\n,": i += 1 - if cmd in ("Z", "z"): - if len(cur) >= 2: - subpaths.append((cur, True)) - cur = [] - cx, cy = sx, sy + if i >= n: + break + if d[i].isalpha(): + if d[i].upper() not in _PATH_ARGC: + break # unbekannter Befehl -> Rest verwerfen statt Token-Strom zu verschieben + cmd = d[i] + i += 1 + if cmd in "Zz": + out.append((cmd, [])) continue - rel = cmd.islower() - c_up = cmd.upper() + elif cmd is None or cmd in "Zz": + break + args: list[float] = [] + for k in range(_PATH_ARGC[cmd.upper()]): + while i < n and d[i] in " \t\r\n,": + i += 1 + if cmd in "Aa" and k in (3, 4): # Flags: genau ein Zeichen 0/1 + if i < n and d[i] in "01": + args.append(float(d[i])) + i += 1 + continue + return out + m = _NUM_RE.match(d, i) + if not m: + return out + args.append(float(m.group())) + i = m.end() + out.append((cmd, args)) + if cmd in "Mm": # weitere Koordinaten nach M sind implizite L + cmd = "l" if cmd == "m" else "L" + return out - def nf(): - nonlocal i - v = float(tokens[i]) - i += 1 - return v - if c_up == "M": - x, y = nf(), nf() - if rel: - x += cx - y += cy +def _parse_svg_path_d(d: str) -> list[tuple[list[tuple[float, float]], bool]]: + """Parst SVG-Pfadbefehle (M L H V C S Q T A Z, absolut & relativ) in Punktlisten (Teilpfade).""" + subpaths: list[tuple[list[tuple[float, float]], bool]] = [] + cur: list[tuple[float, float]] = [] + cx = cy = sx = sy = 0.0 + ctrl = (0.0, 0.0) # letzter Kontrollpunkt (für S/T-Spiegelung) + prev = "" + for cmd, a in _path_tokens(d): + up = cmd.upper() + ox, oy = (cx, cy) if cmd != up else (0.0, 0.0) + if up == "Z": + if len(cur) >= 2: + subpaths.append((cur, True)) + cur = [] + cx, cy = sx, sy + prev = up + continue + if up == "M": if len(cur) >= 2: subpaths.append((cur, False)) - cx, cy = sx, sy = x, y + cx, cy = sx, sy = a[0] + ox, a[1] + oy cur = [(cx, cy)] - cmd = "l" if rel else "L" - elif c_up == "L": - x, y = nf(), nf() - if rel: - x += cx - y += cy - cx, cy = x, y - cur.append((cx, cy)) - elif c_up == "H": - x = nf() - cx = cx + x if rel else x - cur.append((cx, cy)) - elif c_up == "V": - y = nf() - cy = cy + y if rel else y - cur.append((cx, cy)) - elif c_up == "C": - x1, y1, x2, y2, x, y = nf(), nf(), nf(), nf(), nf(), nf() - if rel: - x1, y1, x2, y2, x, y = x1 + cx, y1 + cy, x2 + cx, y2 + cy, x + cx, y + cy - cur.extend(_sample_cubic((cx, cy), (x1, y1), (x2, y2), (x, y), 10)) - cx, cy = x, y - elif c_up == "Q": - x1, y1, x, y = nf(), nf(), nf(), nf() - if rel: - x1, y1, x, y = x1 + cx, y1 + cy, x + cx, y + cy - p0 = (cx, cy) - for k in range(1, 9): - t = k / 8.0 - mt = 1.0 - t - cur.append((mt * mt * p0[0] + 2 * mt * t * x1 + t * t * x, - mt * mt * p0[1] + 2 * mt * t * y1 + t * t * y)) - cx, cy = x, y - else: - i += 1 + prev = up + continue + if not cur: # Zeichenbefehl direkt nach Z: neuer Teilpfad am Startpunkt + cur = [(cx, cy)] + sx, sy = cx, cy + if up == "L": + x, y = a[0] + ox, a[1] + oy + cur.append((x, y)) + elif up == "H": + x, y = a[0] + ox, cy + cur.append((x, y)) + elif up == "V": + x, y = cx, a[0] + oy + cur.append((x, y)) + elif up in ("C", "S"): + if up == "C": + p1 = (a[0] + ox, a[1] + oy) + a = a[2:] + else: + p1 = (2 * cx - ctrl[0], 2 * cy - ctrl[1]) if prev in ("C", "S") else (cx, cy) + ctrl = (a[0] + ox, a[1] + oy) + x, y = a[2] + ox, a[3] + oy + cur.extend(_sample_cubic((cx, cy), p1, ctrl, (x, y), 16)) + elif up in ("Q", "T"): + if up == "Q": + ctrl = (a[0] + ox, a[1] + oy) + a = a[2:] + else: + ctrl = (2 * cx - ctrl[0], 2 * cy - ctrl[1]) if prev in ("Q", "T") else (cx, cy) + x, y = a[0] + ox, a[1] + oy + cur.extend(_sample_quad((cx, cy), ctrl, (x, y))) + else: # A + x, y = a[5] + ox, a[6] + oy + cur.extend(_sample_arc(cx, cy, a[0], a[1], a[2], a[3], a[4], x, y)) + cx, cy = x, y + prev = up if len(cur) >= 2: subpaths.append((cur, False)) return subpaths -def import_svg(path: str, target_width_mm: float = 95.0) -> Design: - """Importiert eine SVG-Vektordatei direkt als editierbare Stick-Objekte (Satin/Tatami/Run).""" +# --------------------------------------------------------------- Formen & Stil +_SVG_SKIP = {"defs", "clipPath", "mask", "symbol", "marker", "pattern", "style", "metadata", "title", + "desc", "script", "linearGradient", "radialGradient", "filter", "font", "foreignObject"} +_SVG_INHERITED = ("fill", "stroke", "stroke-width", "fill-rule", "visibility", "color", "fill-opacity", + "stroke-opacity", "stroke-linejoin", "stroke-linecap") +_UNIT_PX = {"": 1.0, "px": 1.0, "mm": 96 / 25.4, "cm": 96 / 2.54, "in": 96.0, "pt": 96 / 72, "pc": 16.0, + "em": 16.0, "ex": 8.0} +_XLINK_HREF = "{http://www.w3.org/1999/xlink}href" + + +def _local(tag) -> str: + return tag.split("}")[-1] if isinstance(tag, str) else "" + + +def _svg_len(val, default: float = 0.0) -> float: + """Längenangabe in SVG-Benutzereinheiten (px@96 dpi); % wird ignoriert.""" + m = re.match(r"\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)\s*([a-z%]*)", str(val or "").lower()) + if not m or m.group(2) not in _UNIT_PX: + return default + return float(m.group(1)) * _UNIT_PX[m.group(2)] + + +def _svg_style(el) -> dict: + """Präsentationsattribute + style="…" eines Elements (style gewinnt).""" + st = {k: el.attrib[k].strip() for k in (*_SVG_INHERITED, "display", "opacity") if k in el.attrib} + for part in el.attrib.get("style", "").split(";"): + if ":" in part: + k, v = part.split(":", 1) + st[k.strip().lower()] = v.replace("!important", "").strip() + return st + + +def _shape_subpaths(tag: str, el): + """Element -> (Teilpfade, füllbar) in lokalen Koordinaten.""" + g = lambda k, dflt=0.0: _svg_len(el.attrib.get(k), dflt) # noqa: E731 + if tag == "path": + return _parse_svg_path_d(el.attrib.get("d", "")), True + if tag == "rect": + x, y, w, h = g("x"), g("y"), g("width"), g("height") + if w <= 0 or h <= 0: + return [], True + rx, ry = el.attrib.get("rx"), el.attrib.get("ry") + rx = _svg_len(rx if rx not in (None, "auto") else ry) + ry = _svg_len(ry if ry not in (None, "auto") else el.attrib.get("rx")) + rx, ry = min(max(rx, 0.0), w / 2), min(max(ry, 0.0), h / 2) + if rx <= 0 or ry <= 0: + return [([(x, y), (x + w, y), (x + w, y + h), (x, y + h)], True)], True + d = (f"M{x + rx},{y} H{x + w - rx} A{rx},{ry} 0 0 1 {x + w},{y + ry} V{y + h - ry} " + f"A{rx},{ry} 0 0 1 {x + w - rx},{y + h} H{x + rx} A{rx},{ry} 0 0 1 {x},{y + h - ry} " + f"V{y + ry} A{rx},{ry} 0 0 1 {x + rx},{y} Z") + return _parse_svg_path_d(d), True + if tag in ("circle", "ellipse"): + cx, cy = g("cx"), g("cy") + rx, ry = (g("r"), g("r")) if tag == "circle" else (g("rx"), g("ry")) + if tag == "ellipse": # fehlender Radius = der andere (SVG 2 "auto") + rx, ry = rx or ry, ry or rx + if rx <= 0 or ry <= 0: + return [], True + pts = [(cx + rx * math.cos(2 * math.pi * k / 72), cy + ry * math.sin(2 * math.pi * k / 72)) for k in range(72)] + return [(pts, True)], True + if tag == "line": + return [([(g("x1"), g("y1")), (g("x2"), g("y2"))], False)], False + if tag in ("polygon", "polyline"): + nums = [float(v) for v in _NUM_RE.findall(el.attrib.get("points", ""))] + pts = list(zip(nums[0::2], nums[1::2])) + return ([(pts, tag == "polygon")] if len(pts) >= 2 else []), True + return [], False + + +def _signed_area(pts) -> float: + return 0.5 * sum(x0 * y1 - x1 * y0 for (x0, y0), (x1, y1) in zip(pts, pts[1:] + pts[:1])) + + +def _fill_geometry(rings: list, rule: str): + """Füllfläche aus Teilpfaden. evenodd: XOR; nonzero: Vereinigung, aber ein vollständig enthaltener + Teilpfad mit entgegengesetztem Umlaufsinn (Windungszahl 0) wird zum Loch.""" + polys = [] + for pts in rings: + try: + p = Polygon(pts) + p = p if p.is_valid else p.buffer(0) + except Exception: + continue + if not p.is_empty and p.area > 0: + polys.append((p, 1 if _signed_area(pts) >= 0 else -1)) + if not polys: + return None + if rule == "evenodd": + acc = polys[0][0] + for p, _ in polys[1:]: + acc = acc.symmetric_difference(p) + return acc + polys.sort(key=lambda t: -t[0].area) # Eltern vor Kindern + acc = None + for i, (p, s) in enumerate(polys): + w_out = sum(s2 for p2, s2 in polys[:i] if p2.covers(p)) + w_in = w_out + s + if w_in != 0 and w_out == 0: + acc = p if acc is None else acc.union(p) + elif w_in == 0 and w_out != 0 and acc is not None: + acc = acc.difference(p) + return acc + + +def _svg_gradients(root) -> dict: + """id -> erste Stopp-Farbe von linear-/radialGradient (folgt href auf andere Verläufe).""" + by_id = {el.get("id"): el for el in root.iter() if _local(el.tag) in ("linearGradient", "radialGradient") + and el.get("id")} + out = {} + for gid, el in by_id.items(): + seen = set() + while el is not None and id(el) not in seen: + seen.add(id(el)) + stops = [s for s in el if _local(s.tag) == "stop"] + if stops: + st = _svg_style(stops[0]) + st.setdefault("stop-color", stops[0].get("stop-color", "black")) + col = _parse_svg_color(st["stop-color"]) + if col is not None: + out[gid] = col + break + ref = (el.get("href") or el.get(_XLINK_HREF) or "").lstrip("#") + el = by_id.get(ref) + return out + + +def _svg_mm_per_unit(root) -> float | None: + """Physische Grösse einer Benutzereinheit in mm aus width/height (+ viewBox), sonst None.""" + def mm(val): + m = re.match(r"\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)\s*([a-z]*)\s*$", str(val or "").lower()) + if not m or m.group(2) not in _UNIT_PX or float(m.group(1)) <= 0: + return None + return float(m.group(1)) * _UNIT_PX[m.group(2)] * 25.4 / 96.0 + w_mm, h_mm = mm(root.get("width")), mm(root.get("height")) + vb = [float(v) for v in _NUM_RE.findall(root.get("viewBox", ""))] + if len(vb) == 4 and vb[2] > 0 and vb[3] > 0: + if w_mm: + return w_mm / vb[2] + if h_mm: + return h_mm / vb[3] + return None + return 25.4 / 96.0 if (w_mm or h_mm) else None + + +_CAPS = {"butt": 2, "square": 3, "round": 1} +_JOINS = {"miter": 2, "miter-clip": 2, "arcs": 1, "bevel": 3, "round": 1} + + +_SVG_MAX_ELEMENTS = 50_000 + + +def import_svg(path: str, target_width_mm: float | None = 95.0) -> Design: + """Importiert eine SVG-Vektordatei direkt als editierbare Stick-Objekte (Satin/Tatami/Run). + target_width_mm=None: echte physische Grösse aus width/height/viewBox (falls angegeben, sonst 95 mm).""" tree = ET.parse(path) root = tree.getroot() - raw_items = [] # (fill_rgb, stroke_rgb, geom_or_line, is_closed) - - for el in root.iter(): - tag = el.tag.split("}")[-1] - style_dict = {} - if "style" in el.attrib: - for part in el.attrib["style"].split(";"): - if ":" in part: - k, v = part.split(":", 1) - style_dict[k.strip()] = v.strip() - fill_str = style_dict.get("fill", el.attrib.get("fill", "#222222")) - stroke_str = style_dict.get("stroke", el.attrib.get("stroke", "none")) - fill_rgb = _parse_svg_color(fill_str) - stroke_rgb = _parse_svg_color(stroke_str) - - if tag == "path" and "d" in el.attrib: - subs = _parse_svg_path_d(el.attrib["d"]) - poly_accum = None - for pts, closed in subs: - if (closed or (fill_rgb is not None and len(pts) >= 3)) and len(pts) >= 3: - try: - p = Polygon(pts).buffer(0) - poly_accum = p if poly_accum is None else poly_accum.symmetric_difference(p) - except Exception: - pass - elif len(pts) >= 2: - raw_items.append((None, stroke_rgb or fill_rgb or (20, 20, 20), LineString(pts), False)) - if poly_accum is not None and not poly_accum.is_empty: - raw_items.append((fill_rgb, stroke_rgb, poly_accum, True)) - elif tag == "rect": - x = float(el.attrib.get("x", 0)) - y = float(el.attrib.get("y", 0)) - w = float(el.attrib.get("width", 0)) - h = float(el.attrib.get("height", 0)) - if w > 0 and h > 0: - from shapely.geometry import box - raw_items.append((fill_rgb, stroke_rgb, box(x, y, x + w, y + h), True)) - elif tag == "circle": - cx = float(el.attrib.get("cx", 0)) - cy = float(el.attrib.get("cy", 0)) - r = float(el.attrib.get("r", 0)) - if r > 0: - from shapely.geometry import Point - raw_items.append((fill_rgb, stroke_rgb, Point(cx, cy).buffer(r), True)) - elif tag in ("polygon", "polyline") and "points" in el.attrib: - nums = [float(v) for v in re.findall(r"[-+]?\d*\.?\d+", el.attrib["points"])] - pts = list(zip(nums[0::2], nums[1::2])) - if tag == "polygon" and len(pts) >= 3: - raw_items.append((fill_rgb, stroke_rgb, Polygon(pts).buffer(0), True)) - elif len(pts) >= 2: - raw_items.append((None, stroke_rgb or fill_rgb or (20, 20, 20), LineString(pts), False)) + ids = {el.get("id"): el for el in root.iter() if isinstance(el.tag, str) and el.get("id")} + gradients = _svg_gradients(root) + raw_items = [] # (fill_rgb, fill_geom, stroke_rgb, [LineString], stroke_w, cap, join) in Dokumentreihenfolge + + def emit(subs, fillable, m, style, opacity): + a, b, c, dd, _, _ = m + det = a * dd - b * c + if abs(det) < 1e-12 or not subs: + return + tsubs = [(_apply_mat(m, pts), closed) for pts, closed in subs] + current = _parse_svg_color(style.get("color", "black")) + + def op(key): + try: + return float(style.get(key, "1").rstrip("%")) / (100.0 if style.get(key, "").endswith("%") else 1.0) + except ValueError: + return 1.0 + fill_rgb = _parse_svg_color(style.get("fill", "black"), gradients, current) if fillable else None + if opacity * op("fill-opacity") <= 0: + fill_rgb = None + stroke_rgb = _parse_svg_color(style.get("stroke", "none"), gradients, current) + sw = _svg_len(style.get("stroke-width", "1"), 1.0) * math.sqrt(abs(det)) + if opacity * op("stroke-opacity") <= 0 or sw <= 0: + stroke_rgb = None + fill_geom = None + if fill_rgb is not None: + fill_geom = _fill_geometry([p for p, _ in tsubs if len(p) >= 3], + style.get("fill-rule", "nonzero").strip().lower()) + if fill_geom is None or fill_geom.is_empty: + fill_rgb, fill_geom = None, None + lines = [] + if stroke_rgb is not None: + for pts, closed in tsubs: + pts = pts + [pts[0]] if closed and pts[0] != pts[-1] else pts + if len(pts) >= 2 and LineString(pts).length > 0: + lines.append(LineString(pts)) + if fill_rgb is None and not lines: + return + raw_items.append((fill_rgb, fill_geom, stroke_rgb if lines else None, lines, sw, + _CAPS.get(style.get("stroke-linecap", "butt"), 2), + _JOINS.get(style.get("stroke-linejoin", "miter"), 2))) + + # Budget gegen „Billion Laughs“-SVGs: verschachtelte können sich exponentiell vervielfachen + visited = [0] + + def walk(el, ctm, inherited, opacity, active, as_group=False): + tag = _local(el.tag) + if not tag or (tag in _SVG_SKIP and not as_group) or len(active) > 32: + return + visited[0] += 1 + if visited[0] > _SVG_MAX_ELEMENTS: + raise ValueError("SVG ist zu komplex (zu viele verschachtelte Elemente / -Kopien).") + st = _svg_style(el) + if st.get("display", "").lower() == "none": + return + style = dict(inherited) + style.update({k: v for k, v in st.items() if k in _SVG_INHERITED and v != "inherit"}) + try: + opacity *= float(st.get("opacity", "1")) + except ValueError: + pass + m = _mat_mul(ctm, _parse_transform(el.get("transform"))) + if tag == "use": + ref = ids.get((el.get("href") or el.get(_XLINK_HREF) or "").strip().lstrip("#")) + if ref is None or id(ref) in active: + return + m = _mat_mul(m, (1.0, 0.0, 0.0, 1.0, _svg_len(el.get("x")), _svg_len(el.get("y")))) + walk(ref, m, style, opacity, active | {id(ref)}, as_group=_local(ref.tag) == "symbol") + return + if el is not root and tag == "svg": # verschachteltes : nur x/y-Versatz + m = _mat_mul(m, (1.0, 0.0, 0.0, 1.0, _svg_len(el.get("x")), _svg_len(el.get("y")))) + if tag in ("svg", "g", "a", "switch", "symbol"): + for ch in el: + walk(ch, m, style, opacity, active) + return + if style.get("visibility", "visible").lower() in ("hidden", "collapse") or opacity <= 0: + return + subs, fillable = _shape_subpaths(tag, el) + emit(subs, fillable, m, style, opacity) + + walk(root, _IDENT, {}, 1.0, frozenset()) if not raw_items: raise ValueError("Keine unterstützten Vektorpfade in der SVG-Datei gefunden.") - # Auf Zielbreite (mm) skalieren & zentrieren - all_geoms = [g for _, _, g, _ in raw_items if g is not None and not g.is_empty] - u = unary_union(all_geoms) - x0, y0, x1, y1 = u.bounds - scale = target_width_mm / max(x1 - x0, 1e-3) + # Auf Zielbreite (mm) bzw. physische Grösse skalieren & zentrieren + bnds = [g.bounds for it in raw_items for g in ([it[1]] if it[1] is not None else []) + it[3]] + x0, y0 = min(b[0] for b in bnds), min(b[1] for b in bnds) + x1, y1 = max(b[2] for b in bnds), max(b[3] for b in bnds) + mpu = _svg_mm_per_unit(root) if target_width_mm is None else None + scale = mpu if mpu else (target_width_mm or 95.0) / max(x1 - x0, 1e-3) cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 from shapely import affinity + from shapely.geometry import MultiLineString from ..auto.digitize import _fast_stroke_width + def to_mm(geom): + return affinity.scale(affinity.translate(geom, -cx, -cy), xfact=scale, yfact=scale, origin=(0, 0)) + d = Design(name="SVG_Import") - for fill_rgb, stroke_rgb, geom, is_closed in raw_items: - g = affinity.translate(geom, -cx, -cy) - g = affinity.scale(g, xfact=scale, yfact=scale, origin=(0, 0)) - if is_closed and fill_rgb is not None: + for fill_rgb, fill_geom, stroke_rgb, lines, sw, cap, join in raw_items: + if fill_rgb is not None: # SVG malt erst die Füllung, dann die Kontur th = nearest_thread(fill_rgb) - for pg in polygons_of(clean(g, 0.2)): + for pg in polygons_of(clean(to_mm(fill_geom), 0.2)): typ, maxw = _fast_stroke_width(pg) if maxw <= 6.2 and pg.area < 450.0: d.objects.append(SatinObject(th, name="SVG-Satin", geom=pg)) @@ -244,9 +613,16 @@ def import_svg(path: str, target_width_mm: float = 95.0) -> Design: d.objects.append(FillObject(th, name="SVG-Fläche", geom=pg, angle=round(ang, 1))) if stroke_rgb is not None: th = nearest_thread(stroke_rgb) - line = g if isinstance(g, LineString) else LineString(polygons_of(g)[0].exterior.coords) if polygons_of(g) else None - if line is not None and line.length > 0.8: - d.objects.append(RunObject(th, name="SVG-Kontur", geom=line, repeats=1)) + mm_lines = [to_mm(l) for l in lines] + w_mm = sw * scale + if w_mm < 1.2: # dünne Kontur -> Bohnenstich + geom = mm_lines[0] if len(mm_lines) == 1 else MultiLineString(mm_lines) + if geom.length > 0.8: + d.objects.append(RunObject(th, name="SVG-Kontur", geom=geom, repeats=1)) + else: # breite Kontur -> Satinband entlang der Linie + band = clean(unary_union([l.buffer(w_mm / 2.0, cap_style=cap, join_style=join) for l in mm_lines]), 0.05) + if not band.is_empty: + d.objects.append(SatinObject(th, name="SVG-Kontur (Satin)", geom=band)) return d @@ -260,46 +636,36 @@ def run_quality_doctor(design: Design, plan: StitchPlan, max_needles: int = 10) x0, y0, x1, y1 = plan.bounds() if plan.stitch_count == 0: - return ["Das Design enthält noch keine Stiche."] + return [tr("doc_empty")] # 1. Rahmengrenzen-Check (Kollisionsgefahr Nadel/Presserfuss mit dem Rahmen!) if design.hoop.round: - r_max = max(math.hypot(x0, y0), math.hypot(x1, y1), math.hypot(x0, y1), math.hypot(x1, y0)) + # tatsächliche Nadelpositionen statt Bounding-Box-Ecken (die überschätzen runde Motive um bis zu 41 %) + r_max = max((math.hypot(s.x, s.y) for b in plan.blocks for s in b.stitches + if s.cmd in (Cmd.STITCH, Cmd.JUMP)), default=0.0) if r_max > hw - 2.0: - issues.append( - f"⚠ RAHMEN-KOLLISIONSGEFAHR: Das Motiv (Radius {r_max:.1f} mm) ragt an den Rand des Rundrahmens " - f"({design.hoop.name}, Radius {hw:.0f} mm). Wähle einen grösseren Rahmen oder zentriere/verkleinere das Motiv!" - ) + issues.append(tr("doc_hoop_round").format(r=fmt_float(r_max), hoop=design.hoop.display_name, + hr=fmt_float(hw, 0))) else: if x0 < -hw + 2 or x1 > hw - 2 or y0 < -hh + 2 or y1 > hh - 2: - issues.append( - f"⚠ RAHMEN-KOLLISIONSGEFAHR: Das Motiv ({x1 - x0:.1f}×{y1 - y0:.1f} mm) überschreitet das sichere Stickfeld " - f"des Rahmens ({design.hoop.width:.0f}×{design.hoop.height:.0f} mm)." - ) + issues.append(tr("doc_hoop_rect").format(w=fmt_float(x1 - x0), h=fmt_float(y1 - y0), + hw=fmt_float(design.hoop.width, 0), + hh=fmt_float(design.hoop.height, 0))) # 2. Nadelanzahl-Check unique_colors = len({b.thread.hex for b in plan.blocks}) if unique_colors > max_needles: - issues.append( - f"⚠ ZU VIELE FARBEN: Das Design verwendet {unique_colors} Garnfarben, deine Maschine hat aber " - f"{max_needles} Nadeln. Reduziere die Farben oder plane einen manuellen Garnwechsel ein." - ) + issues.append(tr("doc_colors").format(n=unique_colors, needles=max_needles)) # 3. Objekt-Checks (zu kleine Schrift / zu schmale Satin-Säulen) for o in design.objects: if not o.visible: continue - if o.kind == "text" and getattr(o, "height", 10.0) < 4.5: - issues.append( - f"⚠ KLEINE SCHRIFT ({o.name}, {o.height:.1f} mm): Normale TrueType-Schriften unter 4.5 mm " - f"laufen auf Stoff oft zu. Tipp: Nutze unter 5 mm eine kräftige serifenlose Schrift mit " - f"Dichte 0.45 mm oder Dreifachstich!" - ) + # Die Micro-Schrift ist genau für kleine Höhen digitalisiert → keine Warnung + if o.kind == "text" and getattr(o, "height", 10.0) < 4.5 and getattr(o, "font", "") != "builtin:micro": + issues.append(tr("doc_small_text").format(name=o.name or getattr(o, "text", ""), h=fmt_float(o.height))) if o.kind == "satin" and getattr(o, "spacing", 0.4) < 0.28: - issues.append( - f"⚠ HOHE SATIN-DICHTE ({o.name}, {o.spacing:.2f} mm): Dichte unter 0.28 mm kann zu " - f"Fadenbruch führen (empfohlen: 0.36–0.42 mm)." - ) + issues.append(tr("doc_satin_density").format(name=o.name, sp=fmt_float(o.spacing, 2))) # 4. Lokale Stichdichte prüfen cells: dict[tuple[int, int], int] = defaultdict(int) @@ -309,14 +675,9 @@ def run_quality_doctor(design: Design, plan: StitchPlan, max_needles: int = 10) cells[(int(math.floor(s.x)), int(math.floor(s.y)))] += 1 hot_cells = sum(1 for c in cells.values() if c > 14) if hot_cells > 5: - issues.append( - f"⚠ DICHTE-SPITZEN: An {hot_cells} Stellen (1×1 mm) liegen mehr als 14 Stiche übereinander " - f"(Schalte mit Strg+H die Heatmap ein, um sie rot zu sehen)." - ) + issues.append(tr("doc_density").format(n=hot_cells)) if not issues: - issues.append( - f"✅ PERFEKT: Keine Probleme gefunden! ({plan.stitch_count:,} Stiche, {unique_colors} von {max_needles} Nadeln, " - f"liegt sicher im Rahmen {design.hoop.name}).".replace(",", "'") - ) + issues.append(tr("doc_ok").format(st=fmt_int(plan.stitch_count), n=unique_colors, needles=max_needles, + hoop=design.hoop.display_name)) return issues diff --git a/openstitch/engine/satin.py b/openstitch/engine/satin.py index 9e68750..5de6dcc 100644 --- a/openstitch/engine/satin.py +++ b/openstitch/engine/satin.py @@ -298,11 +298,42 @@ def _zigzag(cross, max_stitch: float, short_stitch: bool = True) -> list[Pt]: return pts -def _underlay(cross, width_avg: float, spacing: float) -> list[Pt]: - """Unterlage: Mittellauf hin, bei breiteren Säulen Zickzack zurück.""" +UNDERLAY_STYLES = ["auto", "mitte", "kante", "zickzack", "kante+zickzack"] + + +def auto_underlay_style(width_avg: float) -> str: + """Wie kommerzielle Software: schmal → Mittellauf, mittel → Kantenlauf, breit → Kante + Zickzack.""" + if width_avg <= 2.0: + return "mitte" + if width_avg <= 4.5: + return "kante" + return "kante+zickzack" + + +def _edge_walk(cross, inset: float = 0.45) -> list[Pt]: + """Kantenlauf: entlang der linken Kante hin, entlang der rechten zurück (stabilisiert beide Ränder).""" + def ins(c, p): + d = dist(c, p) + if d < inset * 2: + return c + return (c[0] + (p[0] - c[0]) * (d - inset) / d, c[1] + (p[1] - c[1]) * (d - inset) / d) + left = [ins(c, L) for c, L, _ in cross] + right = [ins(c, R) for c, _, R in reversed(cross)] + return resample_path(left + right, 2.0) + + +def _underlay(cross, width_avg: float, spacing: float, style: str = "auto") -> list[Pt]: + """Unterlage je nach Stil: Mittellauf, Kantenlauf, Zickzack oder Kante + Zickzack.""" + if style == "auto": + style = auto_underlay_style(width_avg) + if style == "kante": + return _edge_walk(cross) centers = [c for c, _, _ in cross] - out = resample_path(centers, 2.0) - if width_avg > 3.0: + out = _edge_walk(cross) if style == "kante+zickzack" else resample_path(centers, 2.0) + if style == "kante+zickzack": + # Kantenlauf endet am Start → Zickzack läuft hin (Satin kommt dann zurück) + cross = list(reversed(cross)) + if style in ("zickzack", "kante+zickzack"): back = list(reversed(cross)) zz = [] step = max(1, int(round(2.2 / (spacing / 2)))) @@ -356,7 +387,8 @@ def _puff_perforation_underlay(cross) -> list[Pt]: def satin_runs(geom, spacing: float = 0.4, pull_comp: float = 0.15, max_stitch: float = 7.0, - underlay: bool = True, start: Pt | None = None, puff_3d: bool = False) -> SatinResult: + underlay: bool = True, start: Pt | None = None, puff_3d: bool = False, + underlay_style: str = "auto") -> SatinResult: res = SatinResult() leftovers = [] from .fill import _travel # lokale Abhängigkeit vermeiden @@ -412,8 +444,8 @@ def satin_runs(geom, spacing: float = 0.4, pull_comp: float = 0.15, max_stitch: seq.extend(_puff_perforation_underlay(cross)) seq.extend(_zigzag(cross, max_stitch=max(max_stitch, 10.0), short_stitch=False)) else: - if underlay and wavg > 1.6: - ul = _underlay(cross, wavg, spacing) + if underlay and wavg > 1.2: + ul = _underlay(cross, wavg, spacing, underlay_style) seq.extend(ul) # Satin startet dort, wo die Unterlage endet if dist(ul[-1], cross[0][0]) > dist(ul[-1], cross[-1][0]): diff --git a/openstitch/fonts_micro.py b/openstitch/fonts_micro.py new file mode 100644 index 0000000..e68c605 --- /dev/null +++ b/openstitch/fonts_micro.py @@ -0,0 +1,552 @@ +"""OpenStitch Micro – eingebaute Einstrich-Kleinschrift (2.5 – 12 mm Versalhöhe). + +TrueType-Umrisse sind unter ~6 mm zu dünn für Satin, die Buchstaben laufen zu. Wie die +Kleinschriften kommerzieller Programme ist „OpenStitch Micro“ deshalb als *Mittellinien* +digitalisiert: jedes Zeichen besteht aus wenigen Strichen (Polylinien), die als schmale +Satinsäule (``engine.column.column_runs``) oder – ganz klein – als Dreifachstich gestickt +werden. + +Datenformat (Einheits-Box): Versalhöhe = 1.0, Grundlinie y = 0, y zeigt nach OBEN, +x-Höhe 0.70, Unterlänge −0.27. Je Zeichen: (Vorschub, [Strich, Strich, …]); ein Strich +ist eine Liste von Punkten in Stickreihenfolge, ein Strich mit nur einem Punkt ist ein +Punkt (i-Punkt, Satzzeichen). Die Reihenfolge der Striche ist so gewählt, dass möglichst +ohne Sprung gestickt werden kann; Lücken innerhalb eines Zeichens werden – wenn möglich – +mit einem Verbindungslauf entlang der Mittellinien überbrückt (bevorzugt unter noch nicht +gestickten Strichen, wie die „Unterlauf“-Verbindungen kommerzieller Schriften). +""" +from __future__ import annotations + +import heapq +import math +import unicodedata + +from .engine.column import MIN_SATIN_W, _even, bean_run, column_runs +from .engine.geom import Pt, dist + +MICRO_SPEC = "builtin:micro" +MICRO_NAME = "OpenStitch Micro" + +XH = 0.70 # x-Höhe +DESC = -0.27 # Unterlänge + + +# --------------------------------------------------------------------------- +# Zeichen-Hilfen (Einheits-Box, y nach oben) +# --------------------------------------------------------------------------- +def _arc(cx, cy, rx, ry, a0, a1, step=10.0): + """Ellipsenbogen von Winkel a0 nach a1 (Grad, mathematisch positiv = gegen Uhrzeiger).""" + n = max(2, int(math.ceil(abs(a1 - a0) / step)) + 1) + out = [] + for i in range(n): + a = math.radians(a0 + (a1 - a0) * i / (n - 1)) + out.append((cx + rx * math.cos(a), cy + ry * math.sin(a))) + return out + + +def _cat(*parts): + out = [] + for part in parts: + for p in part: + p = (round(p[0], 4), round(p[1], 4)) + if out and abs(out[-1][0] - p[0]) < 1e-4 and abs(out[-1][1] - p[1]) < 1e-4: + continue + out.append(p) + return out + + +def _mirror_x(strokes, adv): + return [[(adv - x, y) for x, y in s] for s in strokes] + + +def _rot180(strokes, adv, cy): + """180°-Drehung um (adv/2, cy) – z. B. ? → ¿ und ! → ¡.""" + return [[(adv - x, 2 * cy - y) for x, y in s] for s in strokes] + + +def _shift(strokes, dx=0.0, dy=0.0): + return [[(x + dx, y + dy) for x, y in s] for s in strokes] + + +# --------------------------------------------------------------------------- +# Glyphen +# --------------------------------------------------------------------------- +def _build() -> dict[str, tuple[float, list[list[Pt]]]]: + G: dict[str, tuple[float, list]] = {} + + def g(ch, adv, *strokes): + G[ch] = (adv, [_cat(s) for s in strokes]) + + # --- Versalien ----------------------------------------------------------- + g("A", 0.76, [(0.06, 0), (0.38, 1), (0.70, 0)], [(0.604, 0.30), (0.156, 0.30)]) + g("B", 0.66, _cat([(0.10, 0), (0.10, 1), (0.33, 1)], _arc(0.33, 0.765, 0.235, 0.235, 90, -90)), + _cat([(0.10, 0.53), (0.36, 0.53)], _arc(0.36, 0.265, 0.265, 0.265, 90, -90), [(0.10, 0)])) + g("C", 0.72, _arc(0.40, 0.5, 0.31, 0.5, 45, 315)) + g("D", 0.74, _cat([(0.10, 0), (0.10, 1), (0.30, 1)], _arc(0.30, 0.5, 0.34, 0.5, 90, -90), [(0.10, 0)])) + g("E", 0.62, [(0.54, 1), (0.10, 1), (0.10, 0.52)], [(0.47, 0.52), (0.10, 0.52), (0.10, 0), (0.54, 0)]) + g("F", 0.58, [(0.52, 1), (0.10, 1), (0.10, 0.52)], [(0.45, 0.52), (0.10, 0.52), (0.10, 0)]) + g("G", 0.76, _cat(_arc(0.41, 0.5, 0.33, 0.5, 45, 360), [(0.46, 0.5)])) + g("H", 0.74, [(0.10, 0), (0.10, 1)], [(0.10, 0.52), (0.64, 0.52)], [(0.64, 1), (0.64, 0)]) + g("I", 0.26, [(0.13, 1), (0.13, 0)]) + g("J", 0.52, _cat([(0.42, 1), (0.42, 0.28)], _arc(0.25, 0.28, 0.17, 0.28, 0, -165))) + g("K", 0.68, [(0.10, 0), (0.10, 1)], [(0.60, 1), (0.11, 0.40), (0.62, 0)]) + g("L", 0.56, [(0.10, 1), (0.10, 0), (0.52, 0)]) + g("M", 0.88, [(0.10, 0), (0.10, 1), (0.44, 0.28), (0.78, 1), (0.78, 0)]) + g("N", 0.76, [(0.10, 0), (0.10, 1), (0.66, 0), (0.66, 1)]) + g("O", 0.82, _arc(0.41, 0.5, 0.33, 0.5, 180, 540)) + g("P", 0.64, _cat([(0.10, 0), (0.10, 1), (0.34, 1)], _arc(0.34, 0.75, 0.25, 0.25, 90, -90), [(0.10, 0.5)])) + g("Q", 0.82, _arc(0.41, 0.5, 0.33, 0.5, 180, 540), [(0.50, 0.24), (0.76, -0.06)]) + g("R", 0.66, _cat([(0.10, 0), (0.10, 1), (0.34, 1)], _arc(0.34, 0.755, 0.245, 0.245, 90, -90), + [(0.10, 0.51)]), [(0.32, 0.51), (0.62, 0)]) + g("S", 0.66, _cat(_arc(0.33, 0.75, 0.22, 0.25, 25, 270), _arc(0.33, 0.25, 0.25, 0.25, 90, -155))) + g("T", 0.66, [(0.33, 0), (0.33, 1)], [(0.05, 1), (0.61, 1)]) + g("U", 0.74, _cat([(0.10, 1), (0.10, 0.32)], _arc(0.37, 0.32, 0.27, 0.32, 180, 360), [(0.64, 1)])) + g("V", 0.72, [(0.06, 1), (0.36, 0), (0.66, 1)]) + g("W", 1.00, [(0.05, 1), (0.27, 0), (0.50, 0.72), (0.73, 0), (0.95, 1)]) + g("X", 0.70, [(0.06, 0), (0.64, 1)], [(0.06, 1), (0.64, 0)]) + g("Y", 0.70, [(0.06, 1), (0.35, 0.48), (0.64, 1)], [(0.35, 0.48), (0.35, 0)]) + g("Z", 0.66, [(0.08, 1), (0.58, 1), (0.08, 0), (0.60, 0)]) + + # --- Gemeine (x-Höhe 0.70) ----------------------------------------------- + bowl_r = _cat([(0.54, 0.56)], _arc(0.31, 0.35, 0.23, 0.35, 40, 320), [(0.54, 0.14)]) # Bauch links vom Stamm + bowl_l = _cat([(0.10, 0.14)], _arc(0.33, 0.35, 0.23, 0.35, 220, 500), [(0.10, 0.56)]) # Bauch rechts vom Stamm + g("a", 0.64, [(0.54, 0), (0.54, XH)], bowl_r) + g("b", 0.64, [(0.10, 1), (0.10, 0)], bowl_l) + g("c", 0.58, _arc(0.32, 0.35, 0.24, 0.35, 42, 318)) + g("d", 0.64, bowl_r, [(0.54, 1), (0.54, 0)]) + g("e", 0.62, _cat([(0.10, 0.37), (0.54, 0.37)], _arc(0.32, 0.35, 0.22, 0.35, 5, 318))) + g("f", 0.42, _cat([(0.20, 0), (0.20, 0.78)], _arc(0.34, 0.78, 0.14, 0.20, 180, 40)), + [(0.04, 0.66), (0.38, 0.66)]) + g("g", 0.64, bowl_r, _cat([(0.54, XH), (0.54, -0.06)], _arc(0.32, -0.06, 0.22, 0.21, 0, -160))) + g("h", 0.64, [(0.10, 0), (0.10, 1)], _cat([(0.10, 0.47)], _arc(0.31, 0.47, 0.21, 0.23, 180, 0), [(0.52, 0)])) + g("i", 0.26, [(0.13, XH), (0.13, 0)], [(0.13, 1.0)]) + g("j", 0.32, _cat([(0.20, XH), (0.20, -0.10)], _arc(0.07, -0.10, 0.13, 0.16, 0, -160)), [(0.20, 1.0)]) + g("k", 0.60, [(0.10, 0), (0.10, 1)], [(0.50, XH), (0.11, 0.30), (0.53, 0)]) + g("l", 0.26, [(0.13, 1), (0.13, 0)]) + g("m", 0.92, _cat([(0.10, 0), (0.10, 0.47)], _arc(0.255, 0.47, 0.155, 0.23, 180, 0)), + _cat([(0.41, 0), (0.41, 0.47)], _arc(0.565, 0.47, 0.155, 0.23, 180, 0), [(0.72, 0)])) + g("n", 0.62, _cat([(0.10, 0), (0.10, 0.47)], _arc(0.31, 0.47, 0.21, 0.23, 180, 0), [(0.52, 0)])) + g("o", 0.64, _arc(0.32, 0.35, 0.23, 0.355, 180, 540)) + g("p", 0.64, [(0.10, DESC), (0.10, XH)], _cat([(0.10, 0.56)], _arc(0.33, 0.35, 0.23, 0.35, 140, -140), [(0.10, 0.14)])) + g("q", 0.64, bowl_r, [(0.54, XH), (0.54, DESC)]) + g("r", 0.44, _cat([(0.10, 0), (0.10, 0.45)], _arc(0.30, 0.45, 0.20, 0.25, 180, 50))) + g("s", 0.56, _cat(_arc(0.28, 0.525, 0.18, 0.175, 25, 270), _arc(0.28, 0.175, 0.20, 0.175, 90, -155))) + g("t", 0.40, [(0.04, 0.68), (0.36, 0.68)], _cat([(0.18, 0.92), (0.18, 0.12)], _arc(0.30, 0.12, 0.12, 0.12, 180, 310))) + g("u", 0.62, _cat([(0.10, XH), (0.10, 0.23)], _arc(0.31, 0.23, 0.21, 0.23, 180, 360), [(0.52, XH)])) + g("v", 0.58, [(0.06, XH), (0.29, 0), (0.52, XH)]) + g("w", 0.84, [(0.05, XH), (0.22, 0), (0.42, 0.55), (0.62, 0), (0.79, XH)]) + g("x", 0.58, [(0.07, 0), (0.51, XH)], [(0.07, XH), (0.51, 0)]) + g("y", 0.58, [(0.06, XH), (0.29, 0.05)], [(0.52, XH), (0.18, DESC)]) + g("z", 0.56, [(0.08, XH), (0.48, XH), (0.08, 0), (0.50, 0)]) + + # --- Ziffern (Tabellenziffern, Breite 0.62) ------------------------------ + six = _cat(_arc(0.33, 0.31, 0.25, 0.69, 70, 180), _arc(0.31, 0.31, 0.23, 0.31, 180, 540)) + g("0", 0.62, _arc(0.31, 0.5, 0.23, 0.5, 90, 450)) + g("1", 0.62, [(0.12, 0.78), (0.36, 1), (0.36, 0)]) + g("2", 0.62, _cat(_arc(0.31, 0.72, 0.23, 0.26, 155, -35), [(0.08, 0), (0.56, 0)])) + g("3", 0.62, _cat(_arc(0.30, 0.76, 0.21, 0.24, 150, -90), _arc(0.30, 0.26, 0.25, 0.26, 90, -150))) + g("4", 0.62, [(0.44, 0), (0.44, 1), (0.04, 0.30), (0.58, 0.30)]) + g("5", 0.62, _cat([(0.53, 1), (0.13, 1), (0.12, 0.56)], _arc(0.31, 0.33, 0.25, 0.33, 135, -150))) + g("6", 0.62, six) + g("7", 0.62, [(0.06, 1), (0.56, 1), (0.24, 0)]) + g("8", 0.62, _cat(_arc(0.31, 0.765, 0.19, 0.235, 270, 630), _arc(0.31, 0.265, 0.235, 0.265, 90, -270))) + g("9", 0.62, *_rot180([six], 0.62, 0.5)) + + # --- Satz- und Sonderzeichen --------------------------------------------- + G[" "] = (0.30, []) + g(".", 0.24, [(0.12, 0.06)]) + g(",", 0.24, [(0.14, 0.08), (0.08, -0.15)]) + g(":", 0.24, [(0.12, 0.60)], [(0.12, 0.06)]) + g(";", 0.24, [(0.12, 0.60)], [(0.14, 0.08), (0.08, -0.15)]) + g("!", 0.26, [(0.13, 1), (0.13, 0.36)], [(0.13, 0.06)]) + qm = [_cat(_arc(0.28, 0.74, 0.21, 0.24, 155, -70), [(0.28, 0.44), (0.28, 0.36)]), [(0.28, 0.06)]] + g("?", 0.56, *qm) + g("-", 0.44, [(0.08, 0.38), (0.36, 0.38)]) + g("_", 0.58, [(0.04, -0.12), (0.54, -0.12)]) + g("'", 0.20, [(0.10, 1), (0.10, 0.72)]) + g('"', 0.36, [(0.10, 1), (0.10, 0.72)], [(0.26, 1), (0.26, 0.72)]) + paren = _arc(0.46, 0.42, 0.30, 0.66, 118, 242) + g("(", 0.38, paren) + g(")", 0.38, *_mirror_x([list(reversed(paren))], 0.38)) + g("/", 0.46, [(0.06, -0.05), (0.40, 1.0)]) + g("&", 0.74, _cat([(0.66, 0), (0.24, 0.58)], _arc(0.31, 0.80, 0.14, 0.19, 215, -40), + _arc(0.30, 0.24, 0.20, 0.24, 160, 380), [(0.66, 0.44)])) + g("+", 0.58, [(0.29, 0.14), (0.29, 0.62)], [(0.06, 0.38), (0.52, 0.38)]) + g("@", 1.00, _arc(0.46, 0.40, 0.15, 0.20, 0, 360), + _cat([(0.61, 0.60), (0.61, 0.28)], _arc(0.71, 0.30, 0.10, 0.10, 180, 360), _arc(0.48, 0.42, 0.38, 0.53, 10, 300))) + g("#", 0.70, [(0.22, 0), (0.30, 0.92)], [(0.06, 0.62), (0.64, 0.62)], [(0.52, 0.92), (0.44, 0)], + [(0.62, 0.32), (0.04, 0.32)]) + g("%", 0.80, _arc(0.18, 0.78, 0.12, 0.18, 270, 630), [(0.66, 1.0), (0.14, 0)], + _arc(0.62, 0.22, 0.12, 0.18, 90, 450)) + g("*", 0.56, [(0.28, 0.94), (0.28, 0.50)], [(0.09, 0.83), (0.47, 0.61)], [(0.09, 0.61), (0.47, 0.83)]) + g("¿", 0.56, *_rot180(qm, 0.56, 0.40)) + g("¡", 0.26, *_rot180([[(0.13, 1), (0.13, 0.36)], [(0.13, 0.06)]], 0.26, 0.46)) + g("ß", 0.66, _cat([(0.10, 0), (0.10, 0.76)], _arc(0.29, 0.76, 0.19, 0.24, 180, -60), + _arc(0.33, 0.28, 0.24, 0.28, 80, -125))) + # punktloses i als Basis für î ï + g("ı", 0.26, [(0.13, XH), (0.13, 0)]) + + # --- Akzente (aus Grundzeichen + Zeichen zusammengesetzt) ----------------- + def acute(cx, y0): + return [[(cx - 0.05, y0), (cx + 0.08, y0 + 0.15)]] + + def grave(cx, y0): + return [[(cx - 0.08, y0 + 0.15), (cx + 0.05, y0)]] + + def circ(cx, y0): + return [[(cx - 0.12, y0), (cx, y0 + 0.14), (cx + 0.12, y0)]] + + def tilde(cx, y0): + return [[(cx + 0.18 * t / 4, y0 + 0.065 + 0.055 * math.sin(math.pi * t / 4)) for t in range(-4, 5)]] + + def cedilla(cx, y0): + return [_cat([(cx, 0.0), (cx, -0.07)], _arc(cx, -0.14, 0.07, 0.07, 90, -150))] + + def compose(ch, base, mark, *, cx=None, upper=None, adv=None, dots=False): + b_adv, b_strokes = G[base] + if upper is None: + upper = base.isupper() + y0 = 1.15 if upper else 0.82 + if cx is None: + cx = b_adv / 2 + if dots: # Trema: ein Punkt vor, einer nach dem Grundzeichen → lange, getrimmte Wege + d, yd = 0.14, (1.28 if upper else 0.99) # hoch genug für Kleinstgrössen + strokes = [[(cx - d, yd)]] + b_strokes + [[(cx + d, yd)]] + else: + strokes = b_strokes + mark(cx, y0) + MARKS[ch] = set(range(len(b_strokes), len(strokes))) + G[ch] = (adv or b_adv, strokes) + + compose("Ä", "A", None, dots=True) + compose("Ö", "O", None, dots=True) + compose("Ü", "U", None, dots=True) + compose("ä", "a", None, dots=True, cx=0.32) + compose("ö", "o", None, dots=True) + compose("ü", "u", None, dots=True, cx=0.31) + compose("É", "E", acute, cx=0.32) + compose("È", "E", grave, cx=0.32) + compose("Ê", "E", circ, cx=0.32) + compose("À", "A", grave) + compose("Ç", "C", cedilla, cx=0.40) + compose("Ñ", "N", tilde) + compose("é", "e", acute, cx=0.32) + compose("è", "e", grave, cx=0.32) + compose("ê", "e", circ, cx=0.32) + compose("à", "a", grave, cx=0.32) + compose("â", "a", circ, cx=0.32) + compose("ç", "c", cedilla, cx=0.32) + compose("ô", "o", circ) + compose("î", "ı", circ, adv=0.34, cx=0.13) + compose("ï", "ı", None, dots=True, adv=0.34, cx=0.13) + compose("ñ", "n", tilde, cx=0.31) + # î / ï etwas breiter: Grundstrich mittig setzen + for ch in ("î", "ï"): + adv, st = G[ch] + G[ch] = (adv, _shift(st, 0.04)) + # Kleinschrift braucht mehr Luft: Vorbreite/Nachbreite je +SIDE + return {ch: (adv + 2 * SIDE, _shift(st, SIDE)) for ch, (adv, st) in G.items()} + + +SIDE = 0.04 # zusätzliche Vor-/Nachbreite (Einheiten der Versalhöhe) + + +MARKS: dict[str, set[int]] = {} # Akzent-Striche je Zeichen (werden schmaler gestickt) +GLYPHS: dict[str, tuple[float, list[list[Pt]]]] = _build() + + +def mark_strokes(ch: str) -> set[int]: + """Indizes der Akzent-Striche eines Zeichens (Strich-Reihenfolge wie in ``glyph``).""" + return MARKS.get(ch, set()) if ch in GLYPHS else set() + +#: Alle Zeichen, die die Schrift garantiert enthält +CHARSET = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,:;!?-_'\"()/&+@#%*" + "ÄÖÜäöüßÉÈÊÀÇéèêàâçôîïñÑ¿¡") + + +def glyph(ch: str): + """(Vorschub, Striche) eines Zeichens; unbekannte Akzentbuchstaben fallen auf den + Grundbuchstaben zurück, sonst auf '?'.""" + if ch in GLYPHS: + return GLYPHS[ch] + base = unicodedata.normalize("NFD", ch)[:1] + if base and base in GLYPHS: + return GLYPHS[base] + return GLYPHS["?"] + + +# --------------------------------------------------------------------------- +# Satz +# --------------------------------------------------------------------------- +BEAN_BELOW_MM = 5.5 # unter dieser Versalhöhe: Dreifachstich statt Satin + + +def stroke_width(cap_mm: float) -> float: + """Säulenbreite (mm) für eine Versalhöhe. Unter 5.5 mm wird mit Dreifachstich gestickt + (Rückgabe < 0.7 mm → ``column_runs`` fällt auf Bean zurück), darüber Satin mit + 0.7 – 2.2 mm (≈ 11 % der Versalhöhe). Nach Augenmass abgestimmt: schmalere Satinsäulen + sind nicht stickbar, breitere lassen die Innenräume bei 6 mm zulaufen.""" + if cap_mm < BEAN_BELOW_MM: + return min(0.6, 0.12 * cap_mm) + return min(2.2, max(0.7, 0.11 * cap_mm)) + + +def pull_comp_for(width: float, pull_comp: float) -> float: + """Schmale Säulen ziehen kaum ein → Zugkompensation höchstens 8 % der Breite je Seite.""" + return min(pull_comp, 0.08 * width) + + +def auto_tracking(cap_mm: float) -> float: + """Zusätzlicher Zeichenabstand (mm), damit kräftige Satinsäulen nicht zusammenlaufen.""" + w = stroke_width(cap_mm) + vis = 0.45 if w < MIN_SATIN_W else w * 1.16 + 0.35 # sichtbare Strichbreite inkl. Faden + return max(0.0, 0.6 * (vis - 0.12 * cap_mm)) + + +def run_length(cap_mm: float) -> float: + """Stichlänge für Dreifachstich/Verbindungsläufe – kurz genug für enge Bögen.""" + return min(1.2, max(0.55, 0.22 * cap_mm)) + + +def layout(text: str, height: float, letter_spacing: float = 0.0, line_spacing: float = 1.25): + """Liefert [(zeichen, [Striche in mm, Y nach unten], Mittelpunkt-x)] – Zeile zentriert, + Grundlinie der ersten Zeile bei y = 0 (wie ``text.glyph_shapes``). Zum Zeichenabstand + kommt die automatische Laufweite für kräftige Säulen (``auto_tracking``).""" + letter_spacing = letter_spacing + auto_tracking(height) + out = [] + for li, line in enumerate(text.split("\n")): + x = 0.0 + y_off = li * height * line_spacing * 1.35 + row = [] + for ch in line: + adv, strokes = glyph(ch) + if strokes: + st = [[(x + px * height, y_off - py * height) for px, py in s] for s in strokes] + row.append((ch, st, x + adv * height / 2)) + x += adv * height + letter_spacing + shift = -x / 2 + for ch, st, cx in row: + out.append((ch, [[(px + shift, py) for px, py in s] for s in st], cx + shift)) + return out + + +# --------------------------------------------------------------------------- +# Sticken eines Zeichens +# --------------------------------------------------------------------------- +class _Network: + """Graph aus den Mittellinien eines Zeichens für Verbindungsläufe.""" + + def __init__(self, strokes: list[list[Pt]], step: float, tol: float): + self.nodes: list[Pt] = [] + self.sid: list[int] = [] + self.adj: list[list[tuple[int, float]]] = [] + self.first: dict[int, int] = {} + for si, s in enumerate(strokes): + if len(s) < 2: + continue + pts = _even(s, step, corner_deg=20.0) + base = len(self.nodes) + self.first[si] = base + for k, p in enumerate(pts): + self.nodes.append(p) + self.sid.append(si) + self.adj.append([]) + if k: + i, j = base + k - 1, base + k + d = dist(self.nodes[i], self.nodes[j]) + self.adj[i].append((j, d)) + self.adj[j].append((i, d)) + n = len(self.nodes) + for i in range(n): + for j in range(i + 1, n): + if self.sid[i] != self.sid[j]: + d = dist(self.nodes[i], self.nodes[j]) + if d <= tol: + self.adj[i].append((j, d)) + self.adj[j].append((i, d)) + + def nearest(self, p: Pt, only: int | None = None): + best, bi = None, -1 + for i, q in enumerate(self.nodes): + if only is not None and self.sid[i] != only: + continue + d = dist(p, q) + if best is None or d < best: + best, bi = d, i + return bi, (best if best is not None else 1e9) + + def path(self, a: int, b: int, done: set[int], over_w: float = 2.5): + """Dijkstra; Wege über bereits gestickte Striche kosten `over_w`-fach.""" + INF = float("inf") + cost = [INF] * len(self.nodes) + prev = [-1] * len(self.nodes) + cost[a] = 0.0 + pq = [(0.0, a)] + while pq: + c, i = heapq.heappop(pq) + if c > cost[i]: + continue + if i == b: + break + for j, d in self.adj[i]: + same = self.sid[i] == self.sid[j] + w = d * (over_w if (same and self.sid[i] in done) else 1.0) + if c + w < cost[j]: + cost[j] = c + w + prev[j] = i + heapq.heappush(pq, (c + w, j)) + if cost[b] == INF: + return None, INF + seq = [b] + while seq[-1] != a: + seq.append(prev[seq[-1]]) + seq.reverse() + return [self.nodes[i] for i in seq], cost[b] + + +def _dot_run(p: Pt, width: float, spacing: float, pull_comp: float, upright: bool = False) -> list[Pt]: + """Punkt (i-Punkt, Trema, Satzzeichen). Klein: Stiche ≥ 0.48 mm (kürzere filtert der + Dichte-Wächter) – als runder Sternchen-Punkt bzw. als senkrechter Mini-Dreifachstich + (upright), wenn ein Nachbarpunkt daneben liegt (Trema bleibt so zweigeteilt).""" + if width < MIN_SATIN_W: + if upright: + h = 0.24 + a, b = (p[0], p[1] - h), (p[0], p[1] + h) + return [a, b, a, b] + # Sternchen aus drei Durchmessern (alle Stiche ≥ 0.47 mm) → gefüllter, runder Punkt + r = 0.27 + return [(p[0] + r * math.cos(math.radians(a)), p[1] + r * math.sin(math.radians(a))) + for a in (90, 270, 150, 330, 30, 210)] + h = max(0.5, 0.85 * width) / 2 + rs = column_runs([(p[0], p[1] - h), (p[0], p[1] + h)], width, spacing, pull_comp, underlay="none") + return rs[0] if rs else [p] + + +def glyph_runs(strokes: list[list[Pt]], width: float, spacing: float = 0.4, pull_comp: float = 0.08, + run_len: float = 0.8, min_trim: float = 1.6, thin: set[int] | None = None) -> list[list[Pt]]: + """Stickt die Striche eines Zeichens (Welt-Koordinaten, mm) in Datenreihenfolge. + + Zwischen zwei Strichen wird – falls die Striche über das Zeichen verbunden sind – ein + Verbindungslauf entlang der Mittellinien genäht (bevorzugt unter noch nicht gestickten + Strichen, die ihn danach verdecken); sonst beginnt ein neuer Lauf (Sprung/Trim macht + ``Design``). Striche mit Index in `thin` (Akzente) werden schmaler gestickt – kurze + Akzente als volle Satinsäule wären nur ein Klumpen.""" + thin = thin or set() + keep = [i for i, s in enumerate(strokes) if s] + thin = {k for k, i in enumerate(keep) if i in thin} + strokes = [strokes[i] for i in keep] + if not strokes: + return [] + net = _Network(strokes, step=max(0.25, min(0.6, width * 0.6)), tol=max(0.3, 0.6 * max(width, 0.5))) + travel_len = max(0.6, min(1.6, run_len * 1.4)) + runs: list[list[Pt]] = [] + run: list[Pt] = [] + done: set[int] = set() + for si, s in enumerate(strokes): + if len(s) == 1: + p = s[0] + twin = any(len(o) == 1 and o is not s and abs(o[0][1] - p[1]) < 0.3 and abs(o[0][0] - p[0]) < 1.6 + for o in strokes) + seq = _dot_run(p, width, spacing, pull_comp, upright=twin) + else: + w, rl = (width * 0.75, min(run_len, 0.6)) if si in thin else (width, run_len) + rs = column_runs(s, w, spacing, pull_comp, underlay="center", run_len=rl) + seq = [p for r in rs for p in r] + if not seq: + continue + if not run: + run = list(seq) + else: + cur = run[-1] + gap = dist(cur, seq[0]) + joined = False + if gap <= 0.35: + run.extend(seq) + joined = True + elif len(s) >= 2 and si in net.first and si > 0 and len(strokes[si - 1]) >= 2: + a, da = net.nearest(cur) + b = net.first[si] + if a >= 0 and da <= max(0.35, 0.75 * width): + path, cost = net.path(a, b, done) + if path is not None: + plen = sum(dist(p, q) for p, q in zip(path, path[1:])) + ok = cost <= 1.8 * gap + 0.4 or (gap <= min_trim + 0.2 and plen <= 4 * gap + 1.0) + if ok: + tr = _even([cur] + path, travel_len)[1:] + run.extend(tr) + run.extend(seq) + joined = True + if not joined: + runs.append(run) + run = list(seq) + done.add(si) + if run: + runs.append(run) + return [r for r in runs if len(r) >= 2] + + +def _gap_cost(g: float, min_trim: float) -> float: + """Kosten einer Verbindung: sichtbarer Verbindungsstich (zwischen ~0.35 mm und der + Trimm-Schwelle) ist am schlechtesten, ein Trim kostet nur Zeit.""" + if g <= 0.35: + return 0.0 + if g <= min_trim + 0.15: + return 5.0 + g + # Trim: kürzere Sprünge bevorzugen; über ~12 mm teilt Design._connect den Sprung zudem + # in Stiche auf (Maschinengrenze) → deutlich teurer + return 2.5 + 0.02 * g + (4.0 if g > 11.5 else 0.0) + + +def _variants(strokes, thin: set[int]): + """Alternative Stickreihenfolgen eines Zeichens: Datenreihenfolge, komplett rückwärts, + und bei Trema (Punkt zuerst und zuletzt) mit vertauschten Punkten. + Liefert [(Striche, Indizes der schmalen Striche)].""" + n = len(strokes) + fwd = list(range(n)) + orders = [(fwd, False), (fwd[::-1], True)] + if n >= 3 and len(strokes[0]) == 1 and len(strokes[-1]) == 1: + sw = [n - 1] + fwd[1:-1] + [0] + orders += [(sw, False), (sw[::-1], True)] + out = [] + for order, rev in orders: + st = [list(reversed(strokes[i])) if rev else strokes[i] for i in order] + out.append((st, {k for k, i in enumerate(order) if i in thin})) + return out + + +def text_runs(glyph_strokes: list[list[list[Pt]]], width: float, spacing: float = 0.4, + pull_comp: float = 0.08, run_len: float = 0.8, min_trim: float = 1.6, + glyph_thin: list[set[int]] | None = None) -> list[list[Pt]]: + """Stickt eine ganze Zeichenfolge (je Zeichen die Striche in Welt-Koordinaten). + + Je Zeichen wird die Reihenfolge (vorwärts/rückwärts, Trema-Punkte vertauscht) so + gewählt, dass möglichst keine kurzen, sichtbaren Verbindungsstiche entstehen – weder + innerhalb des Zeichens noch zum vorigen/nächsten Zeichen (Ausblick ein Zeichen). + `glyph_thin`: je Zeichen die Indizes schmal zu stickender Striche (siehe ``mark_strokes``).""" + out: list[list[Pt]] = [] + thins = glyph_thin or [set()] * len(glyph_strokes) + pairs = [(st, th) for st, th in zip(glyph_strokes, thins) if any(st)] + glyphs = [st for st, _ in pairs] + for gi, (strokes, thin) in enumerate(pairs): + cur = out[-1][-1] if out else None + nxt = glyphs[gi + 1] if gi + 1 < len(glyphs) else None + best = None + for cand, cthin in _variants(strokes, thin): + rs = glyph_runs(cand, width, spacing, pull_comp, run_len, min_trim, thin=cthin) + if not rs: + continue + cost = 0.0 if cur is None else _gap_cost(dist(cur, rs[0][0]), min_trim) + for a, b in zip(rs, rs[1:]): + cost += _gap_cost(dist(a[-1], b[0]), min_trim) + if nxt is not None: + end = rs[-1][-1] + cost += 0.8 * min(_gap_cost(dist(end, nxt[0][0]), min_trim), + _gap_cost(dist(end, nxt[-1][-1]), min_trim)) + if best is None or cost < best[0] - 1e-9: + best = (cost, rs) + if best is None: + continue + for r in best[1]: + if out and dist(out[-1][-1], r[0]) <= 0.35: + out[-1].extend(r[1:]) + else: + out.append(r) + return out diff --git a/openstitch/i18n.py b/openstitch/i18n.py index 56bde4a..2dca76e 100644 --- a/openstitch/i18n.py +++ b/openstitch/i18n.py @@ -74,7 +74,7 @@ def set_lang(lang: str) -> None: "tool_satin": ("Satin (S)", "Satin (S)", "Satin (S)", "Satén (S)"), "tool_run": ("Stepp (R)", "Run (R)", "Point droit (R)", "Pespunte (R)"), "tool_heatmap": ("Heatmap", "Heatmap", "Carte densité", "Mapa calor"), - "tool_zoom": ("Zoom 100%", "Zoom Fit", "Zoom 100%", "Zoom 100%"), + "tool_zoom": ("Rahmen einpassen", "Fit to hoop", "Ajuster au cadre", "Ajustar al bastidor"), # HUD auf der Leinwand "hud_select": ( @@ -199,5 +199,674 @@ def key_for(german_text: str) -> str | None: return None +_TABLE.update({ + "status_computing": ("Stiche werden berechnet …", "Computing stitches …", "Calcul des points …", + "Calculando puntadas …"), + "dlg_unsaved_title": ("Ungespeicherte Änderungen", "Unsaved changes", "Modifications non enregistrées", + "Cambios sin guardar"), + "dlg_unsaved_text": ("Das aktuelle Design hat ungespeicherte Änderungen. Jetzt speichern?", + "The current design has unsaved changes. Save them now?", + "Le motif actuel contient des modifications non enregistrées. Les enregistrer ?", + "El diseño actual tiene cambios sin guardar. ¿Guardarlos ahora?"), + "status_plan_error": ("Fehler bei der Stichberechnung", "Stitch generation failed", + "Erreur lors du calcul des points", "Error al calcular las puntadas"), +}) + + +# Satinsäule & Diskette +_TABLE.update({ + "kind_column": ("Satinsäule", "Satin column", "Colonne satin", "Columna satén"), + "param_column_width": ("Säulenbreite (mm)", "Column width (mm)", "Largeur de colonne (mm)", + "Ancho de columna (mm)"), + "msg_overwrite_title": ("Datei ersetzen?", "Replace file?", "Remplacer le fichier ?", "¿Reemplazar archivo?"), + "msg_overwrite_text": ("Auf der Diskette gibt es bereits {name}. Ersetzen?", + "{name} already exists on the floppy image. Replace it?", + "{name} existe déjà sur la disquette. Le remplacer ?", + "{name} ya existe en el disquete. ¿Reemplazarlo?"), +}) + + +# Qualitäts-Doktor +_TABLE.update({ + "doc_empty": ("Das Design enthält noch keine Stiche.", "The design has no stitches yet.", + "Le motif ne contient encore aucun point.", "El diseño aún no tiene puntadas."), + "doc_hoop_round": ( + "⚠ RAHMEN-KOLLISIONSGEFAHR: Das Motiv (Radius {r} mm) reicht an den Rand des Rundrahmens ({hoop}, Radius {hr} mm). " + "Wähle einen grösseren Rahmen oder zentriere/verkleinere das Motiv.", + "⚠ HOOP COLLISION RISK: the design (radius {r} mm) reaches the edge of the round hoop ({hoop}, radius {hr} mm). " + "Pick a larger hoop or centre/shrink the design.", + "⚠ RISQUE DE COLLISION AVEC LE CADRE : le motif (rayon {r} mm) atteint le bord du cadre rond ({hoop}, rayon {hr} mm). " + "Choisissez un cadre plus grand ou centrez/réduisez le motif.", + "⚠ RIESGO DE CHOQUE CON EL BASTIDOR: el diseño (radio {r} mm) llega al borde del bastidor redondo ({hoop}, radio {hr} mm). " + "Elige un bastidor mayor o centra/reduce el diseño."), + "doc_hoop_rect": ( + "⚠ RAHMEN-KOLLISIONSGEFAHR: Das Motiv ({w}×{h} mm) überschreitet das sichere Stickfeld des Rahmens ({hw}×{hh} mm).", + "⚠ HOOP COLLISION RISK: the design ({w}×{h} mm) exceeds the hoop's safe sewing field ({hw}×{hh} mm).", + "⚠ RISQUE DE COLLISION AVEC LE CADRE : le motif ({w}×{h} mm) dépasse la zone de broderie sûre du cadre ({hw}×{hh} mm).", + "⚠ RIESGO DE CHOQUE CON EL BASTIDOR: el diseño ({w}×{h} mm) supera el área segura del bastidor ({hw}×{hh} mm)."), + "doc_colors": ( + "⚠ ZU VIELE FARBEN: Das Design verwendet {n} Garnfarben, die Maschine hat {needles} Nadeln. " + "Reduziere die Farben oder plane einen manuellen Garnwechsel ein.", + "⚠ TOO MANY COLOURS: the design uses {n} thread colours but the machine has {needles} needles. " + "Reduce colours or plan a manual thread change.", + "⚠ TROP DE COULEURS : le motif utilise {n} couleurs de fil, la machine a {needles} aiguilles. " + "Réduisez les couleurs ou prévoyez un changement de fil manuel.", + "⚠ DEMASIADOS COLORES: el diseño usa {n} colores de hilo y la máquina tiene {needles} agujas. " + "Reduce los colores o planifica un cambio de hilo manual."), + "doc_small_text": ( + "⚠ KLEINE SCHRIFT ({name}, {h} mm): Umriss-Schriften unter 4.5 mm laufen auf Stoff oft zu. " + "Tipp: Für kleine Schrift die Schriftart „OpenStitch Micro“ verwenden.", + "⚠ SMALL LETTERING ({name}, {h} mm): outline fonts below 4.5 mm tend to fill in on fabric. " + "Tip: use the “OpenStitch Micro” font for small lettering.", + "⚠ PETIT TEXTE ({name}, {h} mm) : sous 4,5 mm, les polices à contour se bouchent souvent sur le tissu. " + "Astuce : utilisez la police « OpenStitch Micro » pour les petits textes.", + "⚠ TEXTO PEQUEÑO ({name}, {h} mm): las fuentes de contorno por debajo de 4,5 mm suelen cerrarse en la tela. " + "Consejo: usa la fuente «OpenStitch Micro» para texto pequeño."), + "doc_satin_density": ( + "⚠ HOHE SATIN-DICHTE ({name}, {sp} mm): Dichte unter 0.28 mm kann zu Fadenbruch führen (empfohlen: 0.36–0.42 mm).", + "⚠ HIGH SATIN DENSITY ({name}, {sp} mm): spacing below 0.28 mm can break threads (recommended: 0.36–0.42 mm).", + "⚠ DENSITÉ SATIN ÉLEVÉE ({name}, {sp} mm) : un espacement sous 0,28 mm peut casser le fil (conseillé : 0,36–0,42 mm).", + "⚠ DENSIDAD DE SATÉN ALTA ({name}, {sp} mm): un espaciado inferior a 0,28 mm puede romper el hilo " + "(recomendado: 0,36–0,42 mm)."), + "doc_density": ( + "⚠ DICHTE-SPITZEN: An {n} Stellen (1×1 mm) liegen mehr als 14 Stiche übereinander " + "(Strg+H zeigt die Heatmap).", + "⚠ DENSITY HOT SPOTS: {n} places (1×1 mm) have more than 14 stitches on top of each other " + "(Ctrl+H shows the heatmap).", + "⚠ PICS DE DENSITÉ : {n} zones (1×1 mm) comptent plus de 14 points superposés " + "(Ctrl+H affiche la carte de densité).", + "⚠ PICOS DE DENSIDAD: en {n} puntos (1×1 mm) hay más de 14 puntadas superpuestas " + "(Ctrl+H muestra el mapa de calor)."), + "doc_ok": ( + "✅ Keine Probleme gefunden ({st} Stiche, {n} von {needles} Nadeln, sicher im Rahmen {hoop}).", + "✅ No problems found ({st} stitches, {n} of {needles} needles, safely inside {hoop}).", + "✅ Aucun problème détecté ({st} points, {n} aiguilles sur {needles}, bien dans le cadre {hoop}).", + "✅ No se encontraron problemas ({st} puntadas, {n} de {needles} agujas, dentro del bastidor {hoop})."), +}) + + def tr_all(key: str) -> list[str]: return list(_TABLE.get(key, (key,) * 4)) + + +def tr_opt(key: str, fallback: str) -> str: + """Wie tr(), aber mit eigenem Rückfallwert, falls der Schlüssel fehlt.""" + return tr(key) if key in _TABLE else fallback + + +# Zahlenformat je Sprache: Tausendertrennzeichen und Dezimalzeichen +# (de: Schweizer Apostroph, fr: schmales geschütztes Leerzeichen) +_GROUP_SEP = {"de": "’", "en": ",", "fr": " ", "es": "."} +_DEC_SEP = {"de": ".", "en": ".", "fr": ",", "es": ","} + + +def fmt_int(n) -> str: + """Ganzzahl mit sprachüblichem Tausendertrennzeichen (de 1’234, en 1,234, fr 1 234, es 1.234).""" + return f"{int(round(n)):,}".replace(",", _GROUP_SEP.get(_cur_lang[0], ",")) + + +def fmt_float(x: float, decimals: int = 1) -> str: + """Dezimalzahl mit sprachüblichen Trennzeichen (z. B. fr 1 423,5).""" + int_part, _, frac = f"{x:,.{decimals}f}".partition(".") + lang = _cur_lang[0] + out = int_part.replace(",", _GROUP_SEP.get(lang, ",")) + return out + _DEC_SEP.get(lang, ".") + frac if frac else out + + +# --------------------------------------------------------------------------- +# Allgemeine Schaltflächen, Dateifilter & Fehlertitel +# --------------------------------------------------------------------------- +_TABLE.update({ + "label_colon": (":", ":", " :", ":"), + "btn_cancel": ("Abbrechen", "Cancel", "Annuler", "Cancelar"), + "btn_close": ("Schliessen", "Close", "Fermer", "Cerrar"), + "btn_apply": ("Übernehmen", "Apply", "Appliquer", "Aplicar"), + "btn_save": ("Speichern", "Save", "Enregistrer", "Guardar"), + "btn_discard": ("Verwerfen", "Discard", "Ne pas enregistrer", "Descartar"), + "dlg_error": ("Fehler", "Error", "Erreur", "Error"), + "dlg_notice": ("Hinweis", "Notice", "Remarque", "Aviso"), + "filter_all_files": ("Alle Dateien", "All files", "Tous les fichiers", "Todos los archivos"), + "filter_all_images": ("Alle Bilder", "All images", "Toutes les images", "Todas las imágenes"), + "filter_images": ("Bilder", "Images", "Images", "Imágenes"), + "filter_all_img_vec": ("Alle Bild- & Vektordateien", "All image & vector files", + "Tous les fichiers image et vectoriels", "Todos los archivos de imagen y vectoriales"), + "filter_raster": ("Rasterbilder", "Raster images", "Images matricielles", "Imágenes de mapa de bits"), + "filter_svg": ("SVG-Vektorgrafik", "SVG vector graphic", "Graphique vectoriel SVG", "Gráfico vectorial SVG"), + "filter_photos": ("Fotos & Bilder", "Photos & images", "Photos et images", "Fotos e imágenes"), + "filter_project": ("OpenStitch-Projekt", "OpenStitch project", "Projet OpenStitch", "Proyecto OpenStitch"), + "filter_emb": ("Stickdateien", "Embroidery files", "Fichiers de broderie", "Archivos de bordado"), + "filter_png": ("PNG-Bild", "PNG image", "Image PNG", "Imagen PNG"), + "filter_floppy": ("Diskettenabbild", "Floppy image", "Image de disquette", "Imagen de disquete"), + "err_open_title": ("Fehler beim Öffnen", "Error opening file", "Erreur d'ouverture", "Error al abrir"), + "err_load_title": ("Fehler beim Laden", "Error loading file", "Erreur de chargement", "Error al cargar"), + "err_svg_title": ("SVG-Import-Fehler", "SVG import error", "Erreur d'import SVG", "Error al importar SVG"), + "err_import_title": ("Import-Fehler", "Import error", "Erreur d'importation", "Error de importación"), + "err_save_title": ("Speicherfehler", "Save error", "Erreur d'enregistrement", "Error al guardar"), + "err_export_title": ("Export-Fehler", "Export error", "Erreur d'exportation", "Error de exportación"), + "err_pdf_title": ("PDF-Fehler", "PDF error", "Erreur PDF", "Error de PDF"), + "err_png_title": ("PNG-Fehler", "PNG error", "Erreur PNG", "Error de PNG"), + "err_png_text": ("Bild konnte nicht gespeichert werden:\n{}", "Could not save the image:\n{}", + "Impossible d'enregistrer l'image :\n{}", "No se pudo guardar la imagen:\n{}"), + "err_image_title": ("Bild-Fehler", "Image error", "Erreur d'image", "Error de imagen"), +}) + + +# --------------------------------------------------------------------------- +# Hauptfenster: Tooltips, Objektliste, Statusleiste, Datei-Dialoge, Meldungen +# --------------------------------------------------------------------------- +_TABLE.update({ + "app_tagline": ("Universelle Open-Source-Stick- & Digitalisier-Suite", + "Universal Open-Source Embroidery & Digitizing Suite", + "Suite universelle open source de broderie et de numérisation", + "Suite universal de bordado y digitalización de código abierto"), + "tb_title_studio": ("Studio-Leiste", "Studio toolbar", "Barre du studio", "Barra del estudio"), + "tb_title_tools": ("Zeichenwerkzeuge", "Drawing tools", "Outils de dessin", "Herramientas de dibujo"), + "tip_autofit": ("Design optimal in den gewählten Rahmen einpassen (Ctrl+F)", + "Fit the design optimally into the selected hoop (Ctrl+F)", + "Ajuster le motif de façon optimale au cadre choisi (Ctrl+F)", + "Ajustar el diseño de forma óptima al bastidor elegido (Ctrl+F)"), + "tip_fabric": ("Stoffhintergrund im Stickrahmen umschalten (Dunkel-Schiefer / Warm-Sand / Hell)", + "Switch the fabric background in the hoop (dark slate / warm sand / light)", + "Changer le fond de tissu dans le cadre (ardoise sombre / sable chaud / clair)", + "Cambiar el fondo de tela en el bastidor (pizarra oscura / arena cálida / clara)"), + "tip_tool_select": ("Objekte auswählen, verschieben & skalieren (Taste V)", + "Select, move & scale objects (key V)", + "Sélectionner, déplacer et redimensionner des objets (touche V)", + "Seleccionar, mover y escalar objetos (tecla V)"), + "tip_tool_nodes": ("Konturknoten & Stichwinkel-Achse direkt ziehen (Taste N)", + "Drag contour nodes & the stitch angle axis directly (key N)", + "Déplacer directement les nœuds du contour et l'axe d'angle de point (touche N)", + "Arrastrar directamente los nodos del contorno y el eje del ángulo de puntada (tecla N)"), + "tip_tool_fill": ("Neue Tatami-Füllfläche auf der Leinwand zeichnen (Taste F)", + "Draw a new tatami fill area on the canvas (key F)", + "Dessiner une nouvelle zone de remplissage tatami sur le canevas (touche F)", + "Dibujar una nueva área de relleno tatami en el lienzo (tecla F)"), + "tip_tool_satin": ("Neue Satin-Säule auf der Leinwand zeichnen (Taste S)", + "Draw a new satin column on the canvas (key S)", + "Dessiner une nouvelle colonne de point satin sur le canevas (touche S)", + "Dibujar una nueva columna de puntada satén en el lienzo (tecla S)"), + "tip_tool_run": ("Neuen Steppstich-Pfad zeichnen (Taste R)", + "Draw a new running stitch path (key R)", + "Dessiner un nouveau tracé au point droit (touche R)", + "Dibujar un nuevo trazado de puntada recta (tecla R)"), + "tip_tool_heatmap": ("Stichdichte-Heatmap (Grün/Gelb/Rot) ein-/ausblenden (Ctrl+H)", + "Show/hide the stitch density heatmap (green/yellow/red) (Ctrl+H)", + "Afficher/masquer la carte de densité (vert/jaune/rouge) (Ctrl+H)", + "Mostrar/ocultar el mapa de densidad (verde/amarillo/rojo) (Ctrl+H)"), + "tip_tool_zoom": ("Ansicht in den Stickrahmen einpassen (Ctrl+0)", "Fit the view to the hoop (Ctrl+0)", + "Ajuster la vue au cadre (Ctrl+0)", "Ajustar la vista al bastidor (Ctrl+0)"), + "tip_obj_up": ("Objekt in der Stickreihenfolge nach oben", "Move object up in the stitch order", + "Monter l'objet dans l'ordre de broderie", "Subir el objeto en el orden de bordado"), + "tip_obj_down": ("Objekt in der Stickreihenfolge nach unten", "Move object down in the stitch order", + "Descendre l'objet dans l'ordre de broderie", "Bajar el objeto en el orden de bordado"), + "tip_obj_delete": ("Ausgewähltes Objekt löschen (Entf)", "Delete selected object (Del)", + "Supprimer l'objet sélectionné (Suppr)", "Eliminar el objeto seleccionado (Supr)"), + "sim_all": ("Alle", "All", "Tout", "Todo"), + "sim_speed_suffix": (" St./Tick", " st./tick", " pts/tick", " punt./tick"), + "abbr_stitches": ("St.", "st.", "pts", "punt."), + "stats_trims": ("Fadenschnitte", "Trims", "Coupes", "Cortes"), + "kind_fill": ("Tatami", "Fill", "Remplissage", "Relleno"), + "kind_satin": ("Satin", "Satin", "Satin", "Satén"), + "kind_run": ("Stepp", "Running stitch", "Point droit", "Pespunte"), + "kind_text": ("Schrift", "Lettering", "Lettrage", "Texto"), + "kind_stitches": ("Stiche", "Stitches", "Points", "Puntadas"), + "obj_hidden": ("[Aus]", "[Hidden]", "[Masqué]", "[Oculto]"), + "btn_font_arc": ("Schriftart & Bogen anpassen …", "Adjust font & arc …", + "Modifier la police et l'arc …", "Ajustar fuente y arco …"), + "status_hoop_strategy": ("Rahmen-Strategie aktiv: {}", "Hoop strategy active: {}", + "Stratégie de cadre active : {}", "Estrategia de bastidor activa: {}"), + "status_saved": ("Gespeichert: {}", "Saved: {}", "Enregistré : {}", "Guardado: {}"), + "color_dialog_title": ("Garnfarbe wählen", "Choose thread color", "Choisir la couleur du fil", + "Elegir color de hilo"), + "default_design_name": ("Neues_Design", "New_Design", "Nouveau_motif", "Nuevo_diseño"), + "fd_open_project": ("Projekt öffnen", "Open project", "Ouvrir un projet", "Abrir proyecto"), + "fd_save_project": ("Projekt speichern", "Save project", "Enregistrer le projet", "Guardar proyecto"), + "fd_import_emb": ("Stickdatei importieren", "Import embroidery file", "Importer un fichier de broderie", + "Importar archivo de bordado"), + "fd_import_svg": ("SVG-Vektorgrafik importieren", "Import SVG vector graphic", + "Importer un graphique vectoriel SVG", "Importar gráfico vectorial SVG"), + "fd_export_emb": ("Stickdatei exportieren", "Export embroidery file", "Exporter le fichier de broderie", + "Exportar archivo de bordado"), + "fd_save_pdf": ("Produktionsblatt (PDF) speichern", "Save production sheet (PDF)", + "Enregistrer la fiche de production (PDF)", "Guardar hoja de producción (PDF)"), + "pdf_file_suffix": ("Produktionsblatt", "production_sheet", "fiche_production", "hoja_produccion"), + "fd_save_png": ("3D-Stichvorschau speichern", "Save 3D stitch preview", "Enregistrer l'aperçu 3D des points", + "Guardar vista previa 3D de puntadas"), + "fd_pick_image": ("Bild, Grafik oder Logo wählen (Downloads / Bilder / Desktop)", + "Choose an image, graphic or logo (Downloads / Pictures / Desktop)", + "Choisir une image, un graphique ou un logo (Téléchargements / Images / Bureau)", + "Elegir una imagen, gráfico o logotipo (Descargas / Imágenes / Escritorio)"), + "fd_pick_photo": ("Porträt- oder Landschaftsfoto wählen (Downloads / Bilder / Desktop)", + "Choose a portrait or landscape photo (Downloads / Pictures / Desktop)", + "Choisir une photo de portrait ou de paysage (Téléchargements / Images / Bureau)", + "Elegir una foto de retrato o paisaje (Descargas / Imágenes / Escritorio)"), + "msg_applique_title": ("Applikation (Appliqué)", "Appliqué", "Appliqué", "Aplique"), + "msg_applique_text": ("Bitte zuerst eine Fläche auf der Leinwand auswählen.", + "Please select a fill or satin shape on the canvas first.", + "Veuillez d'abord sélectionner une forme sur le canevas.", + "Primero selecciona una forma en el lienzo."), + "msg_doctor_title": ("Qualitäts- & Maschinen-Doktor", "Quality & Machine Doctor", + "Docteur qualité & machine", "Doctor de calidad y máquina"), + "msg_export_verified_title": ("Export & Binär-Verifikation erfolgreich", "Export & binary verification successful", + "Export et vérification binaire réussis", + "Exportación y verificación binaria correctas"), + "msg_export_verified_text": ( + "✅ Stickdatei gespeichert und im Simulator verifiziert:\n{path}\n\n" + "• Dateigrösse: {size} Bytes\n• Verifizierte Nähstiche: {stitches}\n" + "• Abmessung: {w:.1f} × {h:.1f} mm\n• Max. Schrittweite: {step:.1f} mm\n" + "• Farbwechsel: {colors} · Sprünge: {jumps}", + "✅ Embroidery file saved and verified in the simulator:\n{path}\n\n" + "• File size: {size} bytes\n• Verified stitches: {stitches}\n" + "• Dimensions: {w:.1f} × {h:.1f} mm\n• Max. step length: {step:.1f} mm\n" + "• Color changes: {colors} · Jumps: {jumps}", + "✅ Fichier de broderie enregistré et vérifié dans le simulateur :\n{path}\n\n" + "• Taille du fichier : {size} octets\n• Points vérifiés : {stitches}\n" + "• Dimensions : {w:.1f} × {h:.1f} mm\n• Pas max. : {step:.1f} mm\n" + "• Changements de couleur : {colors} · Sauts : {jumps}", + "✅ Archivo de bordado guardado y verificado en el simulador:\n{path}\n\n" + "• Tamaño del archivo: {size} bytes\n• Puntadas verificadas: {stitches}\n" + "• Dimensiones: {w:.1f} × {h:.1f} mm\n• Paso máx.: {step:.1f} mm\n" + "• Cambios de color: {colors} · Saltos: {jumps}", + ), + "msg_export_ok_title": ("Export erfolgreich", "Export successful", "Export réussi", "Exportación correcta"), + "msg_export_ok_text": ("Datei wurde gespeichert:\n{}", "File saved:\n{}", "Fichier enregistré :\n{}", + "Archivo guardado:\n{}"), + "msg_pdf_ok_title": ("PDF erstellt", "PDF created", "PDF créé", "PDF creado"), + "msg_pdf_ok_text": ("Produktionsblatt gespeichert:\n{}", "Production sheet saved:\n{}", + "Fiche de production enregistrée :\n{}", "Hoja de producción guardada:\n{}"), + "msg_puff_title": ("3D-Puff-Schaumstoff", "3D puff foam", "Mousse 3D (puff)", "Espuma 3D (puff)"), + "msg_puff_text": ("Bitte wähle ein Satin-Objekt aus.", "Please select a satin object.", + "Veuillez sélectionner un objet en point satin.", "Selecciona un objeto de puntada satén."), + "dlg_scale_title": ("Design skalieren (Stiche werden neu berechnet!)", + "Scale design (stitches will be recalculated!)", + "Redimensionner le motif (les points seront recalculés !)", + "Escalar diseño (¡las puntadas se recalcularán!)"), + "dlg_scale_label": ("Skalierungsfaktor (%):", "Scale factor (%):", "Facteur d'échelle (%) :", + "Factor de escala (%):"), +}) + + +# --------------------------------------------------------------------------- +# Stickrahmen: Namen (nur Anzeige – Hoop.name bleibt unverändert) & Strategien +# --------------------------------------------------------------------------- +_TABLE.update({ + "hoop_round9": ("Rund 9 cm (Klein/Brust)", "Round 9 cm (small/chest)", "Rond 9 cm (petit/poitrine)", + "Redondo 9 cm (pequeño/pecho)"), + "hoop_round12": ("Rund 12 cm (Brustlogo)", "Round 12 cm (chest logo)", "Rond 12 cm (logo poitrine)", + "Redondo 12 cm (logo de pecho)"), + "hoop_round15": ("Rund 15 cm (Standard)", "Round 15 cm (standard)", "Rond 15 cm (standard)", + "Redondo 15 cm (estándar)"), + "hoop_round18": ("Rund 18 cm (Gross)", "Round 18 cm (large)", "Rond 18 cm (grand)", "Redondo 18 cm (grande)"), + "hoop_round21": ("Rund 21 cm (XL)", "Round 21 cm (XL)", "Rond 21 cm (XL)", "Redondo 21 cm (XL)"), + "hoop_cap": ("Kappe / Cap 13 × 5 cm", "Cap 13 × 5 cm", "Casquette 13 × 5 cm", "Gorra 13 × 5 cm"), + "hoop_widecap": ("Wide-Cap 30 × 7 cm", "Wide cap 30 × 7 cm", "Casquette large 30 × 7 cm", "Gorra ancha 30 × 7 cm"), + "hoop_rect13x18": ("Rechteck 13 × 18 cm (Haushalt)", "Rectangle 13 × 18 cm (home)", + "Rectangle 13 × 18 cm (domestique)", "Rectangular 13 × 18 cm (doméstico)"), + "hoop_jacket": ("Jackenrücken 30 × 30 cm", "Jacket back 30 × 30 cm", "Dos de veste 30 × 30 cm", + "Espalda de chaqueta 30 × 30 cm"), + "hoop_field40x30": ("Grossfeld 40 × 30 cm (Industrie)", "Large field 40 × 30 cm (industrial)", + "Grand champ 40 × 30 cm (industriel)", "Campo grande 40 × 30 cm (industrial)"), + "hoop_strat_small": ( + "Straffe Spannung · feine Details (-10% Zugkompensation, Standard-Unterlage)", + "Firm tension · fine details (-10% pull compensation, standard underlay)", + "Tension ferme · détails fins (-10 % de compensation de traction, sous-couche standard)", + "Tensión firme · detalles finos (-10 % de compensación de tirón, refuerzo estándar)", + ), + "hoop_strat_medium": ( + "Ausgewogen · Standard-Brust-/Ärmelrahmen (100% Zugkompensation)", + "Balanced · standard chest/sleeve hoop (100% pull compensation)", + "Équilibré · cadre standard poitrine/manche (100 % de compensation de traction)", + "Equilibrado · bastidor estándar de pecho/manga (100 % de compensación de tirón)", + ), + "hoop_strat_large": ( + "Anti-Trampolin · verstärkte Unterlage (+20% Zugkompensation + Kreuz-Gitter bei grossen Flächen)", + "Anti-trampoline · reinforced underlay (+20% pull compensation + cross grid on large areas)", + "Anti-trampoline · sous-couche renforcée (+20 % de compensation de traction + grille croisée " + "sur les grandes surfaces)", + "Antitrampolín · refuerzo reforzado (+20 % de compensación de tirón + rejilla cruzada en áreas grandes)", + ), + "hoop_strat_jacket": ( + "Jackenrücken-Stabilisierung (+35% Zugkompensation, Doppel-Gitter-Unterlage, max. 750 SPM)", + "Jacket-back stabilization (+35% pull compensation, double grid underlay, max. 750 SPM)", + "Stabilisation dos de veste (+35 % de compensation de traction, sous-couche en double grille, max. 750 SPM)", + "Estabilización para espalda de chaqueta (+35 % de compensación de tirón, refuerzo de doble rejilla, " + "máx. 750 SPM)", + ), + "hoop_strat_cap": ( + "Kappen-Modus (Center-Out & Bottom-Up vom Schirm nach oben, +30% Zugkompensation)", + "Cap mode (center-out & bottom-up from the brim upward, +30% pull compensation)", + "Mode casquette (du centre vers l'extérieur et de la visière vers le haut, +30 % de compensation de traction)", + "Modo gorra (del centro hacia fuera y desde la visera hacia arriba, +30 % de compensación de tirón)", + ), + "hoop_strat_default": ("Standard", "Standard", "Standard", "Estándar"), +}) + + +# --------------------------------------------------------------------------- +# Maschinenprofile: Anzeigenamen (machine_; MachineProfile.name bleibt der gespeicherte Wert) +# --------------------------------------------------------------------------- +_TABLE.update({ + "machine_universal_multi": ("Universal Standard (DST / PES / EXP · 15 Nadeln)", + "Universal standard (DST / PES / EXP · 15 needles)", + "Standard universel (DST / PES / EXP · 15 aiguilles)", + "Estándar universal (DST / PES / EXP · 15 agujas)"), + "machine_tajima_tmez": ("Tajima TMEZ / TFMX / TMBR (15 Nadeln)", "Tajima TMEZ / TFMX / TMBR (15 needles)", + "Tajima TMEZ / TFMX / TMBR (15 aiguilles)", "Tajima TMEZ / TFMX / TMBR (15 agujas)"), + "machine_brother_pr": ("Brother PR-Serie / Persona (6–10 Nadeln)", "Brother PR series / Persona (6–10 needles)", + "Brother série PR / Persona (6–10 aiguilles)", "Brother serie PR / Persona (6–10 agujas)"), + "machine_brother_home": ("Brother / Baby Lock Haushalt (1 Nadel · PES)", "Brother / Baby Lock home (1 needle · PES)", + "Brother / Baby Lock domestique (1 aiguille · PES)", + "Brother / Baby Lock doméstica (1 aguja · PES)"), + "machine_barudan_bext": ("Barudan BEKT / BEXT (15 Nadeln)", "Barudan BEKT / BEXT (15 needles)", + "Barudan BEKT / BEXT (15 aiguilles)", "Barudan BEKT / BEXT (15 agujas)"), + "machine_ricoma_happy_zsk": ("Ricoma / HappyJapan / SWF / ZSK (12–15 Nadeln)", + "Ricoma / HappyJapan / SWF / ZSK (12–15 needles)", + "Ricoma / HappyJapan / SWF / ZSK (12–15 aiguilles)", + "Ricoma / HappyJapan / SWF / ZSK (12–15 agujas)"), + "machine_janome_elna": ("Janome / Elna / Bernina Haushalt (JEF / EXP)", "Janome / Elna / Bernina home (JEF / EXP)", + "Janome / Elna / Bernina domestique (JEF / EXP)", + "Janome / Elna / Bernina doméstica (JEF / EXP)"), + "machine_pfaff_husqvarna": ("Pfaff / Husqvarna Viking (VP3)", "Pfaff / Husqvarna Viking (VP3)", + "Pfaff / Husqvarna Viking (VP3)", "Pfaff / Husqvarna Viking (VP3)"), + "machine_melco_emt10_4": ("Melco EMT10/4 (4-Kopf / 10 Nadeln)", "Melco EMT10/4 (4-head / 10 needles)", + "Melco EMT10/4 (4 têtes / 10 aiguilles)", "Melco EMT10/4 (4 cabezales / 10 agujas)"), + "machine_melco_amaya_bravo": ("Melco Amaya / EMT16X / Bravo (16 Nadeln)", "Melco Amaya / EMT16X / Bravo (16 needles)", + "Melco Amaya / EMT16X / Bravo (16 aiguilles)", + "Melco Amaya / EMT16X / Bravo (16 agujas)"), +}) + + +def machine_display_name(name: str) -> str: + """Übersetzter Anzeigename eines Maschinenprofils (anhand des gespeicherten deutschen Namens).""" + from .core.machines import MACHINE_PROFILES # spät importieren: vermeidet Import-Zyklen + mp = next((m for m in MACHINE_PROFILES if m.name == name), None) + return tr_opt(f"machine_{mp.id}", name) if mp else name + + +# --------------------------------------------------------------------------- +# Objekt-Inspektor: Parameter-Labels (param_ bzw. param__) & Optionswerte (opt_) +# --------------------------------------------------------------------------- +_TABLE.update({ + "param_angle": ("Stichwinkel (°)", "Stitch angle (°)", "Angle de point (°)", "Ángulo de puntada (°)"), + "param_fill_spacing": ("Reihenabstand (mm)", "Row spacing (mm)", "Espacement des rangs (mm)", + "Separación de filas (mm)"), + "param_satin_spacing": ("Dichte (mm)", "Density (mm)", "Densité (mm)", "Densidad (mm)"), + "param_stitch_len": ("Stichlänge (mm)", "Stitch length (mm)", "Longueur de point (mm)", + "Longitud de puntada (mm)"), + "param_pattern": ("Muster", "Pattern", "Motif de remplissage", "Patrón"), + "param_underlay": ("Unterlage", "Underlay", "Sous-couche", "Refuerzo"), + "param_pull_comp": ("Zugkompensation (mm)", "Pull compensation (mm)", "Compensation de traction (mm)", + "Compensación de tirón (mm)"), + "param_max_stitch": ("Max. Stich / Split (mm)", "Max. stitch / split (mm)", "Point max. / division (mm)", + "Puntada máx. / división (mm)"), + "param_underlay_style": ("Unterlage-Art", "Underlay type", "Type de sous-couche", "Tipo de refuerzo"), + "param_puff_3d": ("3D-Puff (Cap-Modus)", "3D puff (cap mode)", "Puff 3D (mode casquette)", "Puff 3D (modo gorra)"), + "param_repeats": ("Wiederholungen (0=einfach, 1=dreifach)", "Repeats (0 = single, 1 = triple)", + "Répétitions (0 = simple, 1 = triple)", "Repeticiones (0 = simple, 1 = triple)"), + "param_text": ("Text", "Text", "Texte", "Texto"), + "param_font": ("Schrift", "Font", "Police", "Fuente"), + "param_height": ("Buchstabenhöhe (mm)", "Letter height (mm)", "Hauteur des lettres (mm)", "Altura de letra (mm)"), + "param_letter_spacing": ("Zeichenabstand (mm)", "Letter spacing (mm)", "Espacement des lettres (mm)", + "Espaciado entre letras (mm)"), + "param_arc_radius": ("Bogenradius (mm, 0 = gerade)", "Arc radius (mm, 0 = straight)", + "Rayon de l'arc (mm, 0 = droit)", "Radio del arco (mm, 0 = recto)"), + "param_mode": ("Stichart", "Stitch type", "Type de point", "Tipo de puntada"), + "param_density": ("Dichte (mm)", "Density (mm)", "Densité (mm)", "Densidad (mm)"), + # Füllmuster + "opt_standard": ("Standard", "Standard", "Standard", "Estándar"), + "opt_diagonal": ("Diagonal", "Diagonal", "Diagonal", "Diagonal"), + "opt_ziegel": ("Ziegel", "Brick", "Brique", "Ladrillo"), + "opt_wellen": ("Wellen", "Waves", "Vagues", "Ondas"), + "opt_kontur": ("Kontur", "Contour", "Contour", "Contorno"), + # Satin-Unterlagen + "opt_auto": ("Automatisch", "Automatic", "Automatique", "Automático"), + "opt_mitte": ("Mittellauf", "Center walk", "Passage central", "Pasada central"), + "opt_kante": ("Kantenlauf", "Edge walk", "Passage de bord", "Pasada de borde"), + "opt_zickzack": ("Zickzack", "Zigzag", "Zigzag", "Zigzag"), + "opt_kante+zickzack": ("Kantenlauf + Zickzack", "Edge walk + zigzag", "Passage de bord + zigzag", + "Pasada de borde + zigzag"), + # Schrift-Stichart + "opt_satin": ("Satinstich", "Satin stitch", "Point satin", "Puntada satén"), + "opt_fill": ("Tatami-Füllstich", "Tatami fill", "Remplissage tatami", "Relleno tatami"), +}) + + +# --------------------------------------------------------------------------- +# Dialoge: Auto-Digitizer, Schrift, Maschinen-Transfer, PhotoStitch +# --------------------------------------------------------------------------- +_TABLE.update({ + # Auto-Digitizer + "dlg_auto_title": ("Bild automatisch digitalisieren (Auto-Digitizer)", "Auto-digitize image (Auto-Digitizer)", + "Numériser automatiquement une image (Auto-Numériseur)", + "Digitalizar imagen automáticamente (Auto-Digitalizador)"), + "lbl_orig_image": ("Originalbild ({})", "Original image ({})", "Image d'origine ({})", "Imagen original ({})"), + "btn_other_image": ("📂 Anderes Bild wählen (Downloads / Desktop / Bilder …)", + "📂 Choose another image (Downloads / Desktop / Pictures …)", + "📂 Choisir une autre image (Téléchargements / Bureau / Images …)", + "📂 Elegir otra imagen (Descargas / Escritorio / Imágenes …)"), + "grp_auto_settings": ("Stick-Einstellungen & Automatik", "Embroidery settings & automation", + "Réglages de broderie et automatisme", "Ajustes de bordado y automatización"), + "lbl_target_width_motif": ("Zielbreite des Motivs:", "Target design width:", "Largeur cible du motif :", + "Ancho objetivo del diseño:"), + "lbl_max_colors": ("Max. Garnfarben (Nadeln):", "Max. thread colors (needles):", + "Couleurs de fil max. (aiguilles) :", "Máx. colores de hilo (agujas):"), + "cb_remove_bg": ("Bildhintergrund automatisch entfernen", "Remove image background automatically", + "Supprimer automatiquement l'arrière-plan", "Eliminar el fondo de la imagen automáticamente"), + "fabric_opt_normal": ("Standard (Baumwolle / Twill / Universal)", "Standard (cotton / twill / universal)", + "Standard (coton / sergé / universel)", "Estándar (algodón / sarga / universal)"), + "fabric_opt_strick": ("Piqué / Polo / Strick (mehr Zugkompensation)", + "Piqué / polo / knit (more pull compensation)", + "Piqué / polo / maille (plus de compensation de traction)", + "Piqué / polo / punto (más compensación de tirón)"), + "fabric_opt_frottee": ("Frottee / Handtuch (dichter + starke Unterlage)", + "Terry / towel (denser + strong underlay)", + "Éponge / serviette (plus dense + sous-couche renforcée)", + "Rizo / toalla (más denso + refuerzo fuerte)"), + "fabric_opt_cap": ("Kappe / Cap (hohe Zugkompensation)", "Cap (high pull compensation)", + "Casquette (forte compensation de traction)", "Gorra (alta compensación de tirón)"), + "fabric_opt_leicht": ("Leichter Stoff / Hemd (geringere Dichte)", "Lightweight fabric / shirt (lower density)", + "Tissu léger / chemise (densité réduite)", "Tela ligera / camisa (menor densidad)"), + "lbl_fabric_type": ("Stoffart / Material:", "Fabric / material:", "Type de tissu / matière :", + "Tipo de tela / material:"), + "lbl_density_row": ("Stichdichte (Reihenabstand):", "Stitch density (row spacing):", + "Densité de points (espacement des rangs) :", "Densidad de puntada (separación de filas):"), + "lbl_satin_limit": ("Satin-Grenze (schmaler = Satin):", "Satin limit (narrower = satin):", + "Limite satin (plus étroit = satin) :", "Límite satén (más estrecho = satén):"), + "lbl_min_area": ("Kleinstteile filtern unter:", "Filter out tiny areas below:", "Filtrer les petites zones sous :", + "Filtrar zonas diminutas por debajo de:"), + "cb_outline": ("Zusätzliche Satinkontur um das Motiv legen", "Add a satin outline around the design", + "Ajouter un contour en point satin autour du motif", + "Añadir un contorno de puntada satén alrededor del diseño"), + "cb_palette": ("Farben an Stickgarn-Palette anpassen", "Match colors to the thread palette", + "Adapter les couleurs à la palette de fils", "Ajustar los colores a la paleta de hilos"), + "status_ready": ("Bereit.", "Ready.", "Prêt.", "Listo."), + "btn_digitize_apply": ("Jetzt digitalisieren && übernehmen", "Digitize && apply now", + "Numériser && appliquer", "Digitalizar && aplicar ahora"), + "fd_pick_logo": ("Bild oder Logo wählen (Downloads / Bilder / Desktop)", + "Choose an image or logo (Downloads / Pictures / Desktop)", + "Choisir une image ou un logo (Téléchargements / Images / Bureau)", + "Elegir una imagen o logotipo (Descargas / Imágenes / Escritorio)"), + "fd_pick_logo_short": ("Bild oder Logo wählen", "Choose an image or logo", "Choisir une image ou un logo", + "Elegir una imagen o logotipo"), + "err_digitize_title": ("Fehler beim Digitalisieren", "Digitizing failed", "Échec de la numérisation", + "Error al digitalizar"), + "prog_computing_stitches": ("Stiche berechnen ({}/{}) …", "Computing stitches ({}/{}) …", + "Calcul des points ({}/{}) …", "Calculando puntadas ({}/{}) …"), + # Fortschrittsmeldungen des Auto-Digitizers (Engine meldet deutsch, Dialog übersetzt) + "prog_prepare": ("Bild vorbereiten …", "Preparing image …", "Préparation de l'image …", "Preparando imagen …"), + "prog_reduce": ("Farben reduzieren …", "Reducing colors …", "Réduction des couleurs …", "Reduciendo colores …"), + "prog_clean": ("Flächen bereinigen …", "Cleaning up areas …", "Nettoyage des zones …", "Limpiando áreas …"), + "prog_vectorize": ("Konturen vektorisieren …", "Vectorizing contours …", "Vectorisation des contours …", + "Vectorizando contornos …"), + "prog_done": ("Fertig", "Done", "Terminé", "Listo"), + "prog_classify": ("Fläche {}/{} klassifizieren …", "Classifying area {}/{} …", + "Classification de la zone {}/{} …", "Clasificando área {}/{} …"), + # Schrift / Monogramm + "dlg_text_title": ("Schrift / Monogramm erstellen", "Create lettering / monogram", + "Créer un lettrage / monogramme", "Crear texto / monograma"), + "lbl_text": ("Text:", "Text:", "Texte :", "Texto:"), + "lbl_font_file": ("Schriftart (TTF/OTF):", "Font (TTF/OTF):", "Police (TTF/OTF) :", "Fuente (TTF/OTF):"), + "lbl_letter_height": ("Buchstabenhöhe (Versalhöhe):", "Letter height (cap height):", + "Hauteur des lettres (hauteur de capitale) :", "Altura de letra (altura de mayúscula):"), + "lbl_letter_spacing": ("Zeichenabstand:", "Letter spacing:", "Espacement des lettres :", + "Espaciado entre letras:"), + "sfx_arc": (" mm (0 = gerade)", " mm (0 = straight)", " mm (0 = droit)", " mm (0 = recto)"), + "lbl_arc_radius": ("Bogenradius:", "Arc radius:", "Rayon de l'arc :", "Radio del arco:"), + "text_mode_auto": ("Automatisch (Satin bis 7 mm, darüber Tatami)", "Automatic (satin up to 7 mm, tatami above)", + "Automatique (satin jusqu'à 7 mm, tatami au-delà)", + "Automático (satén hasta 7 mm, tatami por encima)"), + "text_mode_satin": ("Immer Satinstich (Glanz-Schrift)", "Always satin stitch (glossy lettering)", + "Toujours point satin (lettrage brillant)", "Siempre puntada satén (texto brillante)"), + "text_mode_fill": ("Immer Tatami-Füllstich", "Always tatami fill", "Toujours remplissage tatami", + "Siempre relleno tatami"), + "lbl_stitch_type": ("Stichart:", "Stitch type:", "Type de point :", "Tipo de puntada:"), + "lbl_stitch_density": ("Stichdichte:", "Stitch density:", "Densité de points :", "Densidad de puntada:"), + "msg_enter_text": ("Bitte einen Text eingeben.", "Please enter some text.", "Veuillez saisir un texte.", + "Introduce un texto."), + "text_obj_name": ("Text „{}“", "Text “{}”", "Texte « {} »", "Texto «{}»"), + # Maschinen-Transfer + "dlg_transfer_title": ("Maschinen-Transfer (USB / 1.44 MB Disketten-Emulator / WLAN NetBridge)", + "Machine transfer (USB / 1.44 MB floppy emulator / Wi-Fi NetBridge)", + "Transfert machine (USB / émulateur de disquette 1,44 Mo / NetBridge Wi-Fi)", + "Transferencia a máquina (USB / emulador de disquete de 1,44 MB / NetBridge Wi-Fi)"), + "grp_transfer_1": ("1. Maschinen- & Trim-Einstellungen (EXP / Industrie-Format)", + "1. Machine & trim settings (EXP / industrial format)", + "1. Réglages machine et coupe de fil (EXP / format industriel)", + "1. Ajustes de máquina y corte (EXP / formato industrial)"), + "lbl_dos_name": ("DOS-Dateiname (8.3-Format):", "DOS file name (8.3 format):", "Nom de fichier DOS (format 8.3) :", + "Nombre de archivo DOS (formato 8.3):"), + "trim_both": ("Kombi: EXP-Trim-Code + 3 Sprünge (höchste Kompatibilität)", + "Combined: EXP trim code + 3 jumps (highest compatibility)", + "Combiné : code de coupe EXP + 3 sauts (compatibilité maximale)", + "Combinado: código de corte EXP + 3 saltos (máxima compatibilidad)"), + "trim_code": ("Standard EXP-Trim-Code (80 80 07 00)", "Standard EXP trim code (80 80 07 00)", + "Code de coupe EXP standard (80 80 07 00)", "Código de corte EXP estándar (80 80 07 00)"), + "trim_jumps": ("Nur Sprungfolge (für ältere EMT-Firmware)", "Jump sequence only (for older EMT firmware)", + "Séquence de sauts uniquement (anciens firmwares EMT)", + "Solo secuencia de saltos (para firmware EMT antiguo)"), + "lbl_trim_mode": ("Fadenschnitt (Trim)-Modus:", "Trim mode:", "Mode de coupe de fil :", "Modo de corte:"), + "grp_transfer_2": ("2. Direkt auf USB-Stick / Laufwerk oder über WLAN-NetBridge senden", + "2. Send directly to a USB stick / drive or via Wi-Fi NetBridge", + "2. Envoyer directement sur clé USB / lecteur ou via NetBridge Wi-Fi", + "2. Enviar directamente a memoria USB / unidad o mediante NetBridge Wi-Fi"), + "btn_save_exp_usb": ("EXP direkt auf USB-Stick / Ordner speichern …", "Save EXP directly to USB stick / folder …", + "Enregistrer l'EXP directement sur clé USB / dossier …", + "Guardar EXP directamente en memoria USB / carpeta …"), + "lbl_netbridge_url": ("NetBridge-URL:", "NetBridge URL:", "URL NetBridge :", "URL de NetBridge:"), + "btn_send_netbridge": ("Über WLAN an Melco NetBridge senden", "Send to Melco NetBridge via Wi-Fi", + "Envoyer à Melco NetBridge via Wi-Fi", "Enviar a Melco NetBridge por Wi-Fi"), + "grp_transfer_3": ("3. 1.44 MB FAT12-Diskettenabbild (.IMG) für USB-Emulator (GoTek / HxC)", + "3. 1.44 MB FAT12 floppy image (.IMG) for USB emulator (GoTek / HxC)", + "3. Image de disquette FAT12 1,44 Mo (.IMG) pour émulateur USB (GoTek / HxC)", + "3. Imagen de disquete FAT12 de 1,44 MB (.IMG) para emulador USB (GoTek / HxC)"), + "lbl_img_initial": ("Aktuelles Abbild: Neues leeres 1.44-MB-Abbild (FAT12)", + "Current image: new empty 1.44 MB image (FAT12)", + "Image actuelle : nouvelle image vide de 1,44 Mo (FAT12)", + "Imagen actual: nueva imagen vacía de 1,44 MB (FAT12)"), + "btn_open_img": ("Bestehendes .IMG öffnen …", "Open existing .IMG …", "Ouvrir un .IMG existant …", + "Abrir .IMG existente …"), + "btn_add_cur_img": ("Aktuelles Design ins Abbild einfügen", "Add current design to image", + "Ajouter le motif actuel à l'image", "Añadir el diseño actual a la imagen"), + "btn_del_file": ("Ausgewählte Datei löschen", "Delete selected file", "Supprimer le fichier sélectionné", + "Eliminar el archivo seleccionado"), + "btn_save_img": (".IMG-Datei speichern …", "Save .IMG file …", "Enregistrer le fichier .IMG …", + "Guardar archivo .IMG …"), + "unit_bytes": ("Bytes", "bytes", "octets", "bytes"), + "img_new": ("Neues 1.44-MB-Abbild", "New 1.44 MB image", "Nouvelle image 1,44 Mo", "Nueva imagen de 1,44 MB"), + "img_free": ("{src} — Freier Speicher: {free} KB von {total} KB", "{src} — Free space: {free} KB of {total} KB", + "{src} — Espace libre : {free} Ko sur {total} Ko", + "{src} — Espacio libre: {free} KB de {total} KB"), + "fd_save_exp": ("Melco EXP speichern", "Save Melco EXP", "Enregistrer Melco EXP", "Guardar Melco EXP"), + "msg_saved_title": ("Gespeichert", "Saved", "Enregistré", "Guardado"), + "msg_exp_saved": ("Melco-Stickdatei gespeichert:\n{path}\n({size} Bytes)", + "Melco embroidery file saved:\n{path}\n({size} bytes)", + "Fichier de broderie Melco enregistré :\n{path}\n({size} octets)", + "Archivo de bordado Melco guardado:\n{path}\n({size} bytes)"), + "msg_wifi_ok_title": ("WLAN-Übertragung erfolgreich", "Wi-Fi transfer successful", "Transfert Wi-Fi réussi", + "Transferencia Wi-Fi correcta"), + "msg_wifi_ok_text": ("Gesendet an {url}:\n{body}", "Sent to {url}:\n{body}", "Envoyé à {url} :\n{body}", + "Enviado a {url}:\n{body}"), + "msg_netbridge_fail_title": ("NetBridge nicht erreichbar", "NetBridge unreachable", "NetBridge injoignable", + "NetBridge no accesible"), + "msg_netbridge_fail_text": ("Konnte {url} nicht erreichen:\n{err}", "Could not reach {url}:\n{err}", + "Impossible de joindre {url} :\n{err}", "No se pudo acceder a {url}:\n{err}"), + "fd_open_img": ("FAT12-Diskettenabbild öffnen", "Open FAT12 floppy image", "Ouvrir une image de disquette FAT12", + "Abrir imagen de disquete FAT12"), + "fd_save_img": ("1.44 MB FAT12-Diskettenabbild speichern", "Save 1.44 MB FAT12 floppy image", + "Enregistrer l'image de disquette FAT12 1,44 Mo", "Guardar imagen de disquete FAT12 de 1,44 MB"), + "msg_img_saved_title": ("Diskettenabbild gespeichert", "Floppy image saved", "Image de disquette enregistrée", + "Imagen de disquete guardada"), + "msg_img_saved_text": ( + "1.44-MB-FAT12-Abbild gespeichert:\n{path}\n\n" + "Tipp für GoTek/FlashFloppy: Benenne die Bilder auf dem USB-Stick z. B. DSKA0000.IMG, DSKA0001.IMG " + "oder kopiere die .EXP-Datei direkt, falls FlashFloppy im Direct-File-Modus läuft.", + "1.44 MB FAT12 image saved:\n{path}\n\n" + "Tip for GoTek/FlashFloppy: name the images on the USB stick e.g. DSKA0000.IMG, DSKA0001.IMG, " + "or copy the .EXP file directly if FlashFloppy runs in direct-file mode.", + "Image FAT12 de 1,44 Mo enregistrée :\n{path}\n\n" + "Astuce GoTek/FlashFloppy : nommez les images sur la clé USB p. ex. DSKA0000.IMG, DSKA0001.IMG, " + "ou copiez directement le fichier .EXP si FlashFloppy fonctionne en mode fichier direct.", + "Imagen FAT12 de 1,44 MB guardada:\n{path}\n\n" + "Consejo para GoTek/FlashFloppy: nombra las imágenes en la memoria USB p. ej. DSKA0000.IMG, DSKA0001.IMG, " + "o copia directamente el archivo .EXP si FlashFloppy funciona en modo de archivo directo.", + ), + # PhotoStitch + "dlg_photo_title": ("PhotoStitch — Porträt- & Foto-Stickerei erzeugen", + "PhotoStitch — create portrait & photo embroidery", + "PhotoStitch — créer une broderie de portrait et de photo", + "PhotoStitch — crear bordado de retrato y foto"), + "lbl_orig_photo": ("Originalfoto ({})", "Original photo ({})", "Photo d'origine ({})", "Foto original ({})"), + "btn_other_photo": ("📂 Anderes Foto wählen (Downloads / Desktop / Bilder …)", + "📂 Choose another photo (Downloads / Desktop / Pictures …)", + "📂 Choisir une autre photo (Téléchargements / Bureau / Images …)", + "📂 Elegir otra foto (Descargas / Escritorio / Imágenes …)"), + "grp_photo_settings": ("PhotoStitch-Einstellungen", "PhotoStitch settings", "Réglages PhotoStitch", + "Ajustes de PhotoStitch"), + "lbl_target_width": ("Zielbreite:", "Target width:", "Largeur cible :", "Ancho objetivo:"), + "photo_mode_mono": ("Monochrom-Gravur (1 Garnfarbe auf hellem Stoff)", + "Monochrome engraving (1 thread color on light fabric)", + "Gravure monochrome (1 couleur de fil sur tissu clair)", + "Grabado monocromo (1 color de hilo sobre tela clara)"), + "photo_mode_sepia": ("Sepia-Porträt (2 Garnfarben: Hellbraun + Dunkelbraun)", + "Sepia portrait (2 thread colors: light brown + dark brown)", + "Portrait sépia (2 couleurs de fil : brun clair + brun foncé)", + "Retrato sepia (2 colores de hilo: marrón claro + marrón oscuro)"), + "photo_mode_color4": ("4-Farb CMYK-Halbton (Gelb, Magenta, Cyan, Schwarz)", + "4-color CMYK halftone (yellow, magenta, cyan, black)", + "Demi-teinte CMJN 4 couleurs (jaune, magenta, cyan, noir)", + "Semitono CMYK de 4 colores (amarillo, magenta, cian, negro)"), + "lbl_color_mode": ("Farbmodus:", "Color mode:", "Mode couleur :", "Modo de color:"), + "lbl_row_spacing_fine": ("Zeilenabstand (Feinheit):", "Row spacing (fineness):", + "Espacement des lignes (finesse) :", "Separación de líneas (finura):"), + "lbl_contrast": ("Kontrast-Verstärkung:", "Contrast boost:", "Renforcement du contraste :", + "Refuerzo de contraste:"), + "btn_photo_apply": ("PhotoStitch berechnen && übernehmen", "Compute && apply PhotoStitch", + "Calculer && appliquer PhotoStitch", "Calcular && aplicar PhotoStitch"), +}) + + +# --------------------------------------------------------------------------- +# PDF-Produktionsblatt +# --------------------------------------------------------------------------- +_TABLE.update({ + "pdf_title": ("{} — Produktions- & Einrichteblatt", "{} — Production & setup sheet", + "{} — Fiche de production et de réglage", "{} — Hoja de producción y preparación"), + "pdf_created": ("Erstellt mit {app} am {date}", "Created with {app} on {date}", "Créé avec {app} le {date}", + "Creado con {app} el {date}"), + "pdf_date_fmt": ("%d.%m.%Y %H:%M", "%Y-%m-%d %H:%M", "%d/%m/%Y %H:%M", "%d/%m/%Y %H:%M"), + "pdf_total_stitches": ("Stiche gesamt", "Total stitches", "Points au total", "Puntadas totales"), + "pdf_size": ("Grösse (B × H)", "Size (W × H)", "Taille (L × H)", "Tamaño (an. × al.)"), + "pdf_color_changes": ("Farbwechsel / Blöcke", "Color changes / blocks", "Changements de couleur / blocs", + "Cambios de color / bloques"), + "pdf_blocks_val": ("{} ({} Blöcke)", "{} ({} blocks)", "{} ({} blocs)", "{} ({} bloques)"), + "pdf_trims": ("Fadenschnitte (Trims)", "Thread trims", "Coupes de fil", "Cortes de hilo"), + "pdf_hoop": ("Stickrahmen", "Hoop", "Cadre", "Bastidor"), + "pdf_runtime": ("Laufzeit (ca. {} SPM)", "Run time (approx. {} SPM)", "Durée (env. {} SPM)", + "Duración (aprox. {} SPM)"), + "pdf_thread_usage": ("Oberfadenbedarf", "Top thread usage", "Consommation de fil supérieur", + "Consumo de hilo superior"), + "pdf_machine": ("Maschinenprofil", "Machine profile", "Profil machine", "Perfil de máquina"), + "pdf_sequence": ("Farbfolge & Nadelzuordnung ({} Nadeln)", "Color sequence & needle assignment ({} needles)", + "Séquence de couleurs et attribution des aiguilles ({} aiguilles)", + "Secuencia de colores y asignación de agujas ({} agujas)"), +}) diff --git a/openstitch/io/fat12.py b/openstitch/io/fat12.py index bd25b30..c1dd1c2 100644 --- a/openstitch/io/fat12.py +++ b/openstitch/io/fat12.py @@ -2,6 +2,8 @@ Damit lassen sich .IMG-Dateien für USB-Disketten-Emulatoren (GoTek mit FlashFloppy, HxC) erzeugen oder bestehende Abbilder ergänzen, ohne Admin-Rechte oder Zusatztools. +Lange Dateinamen werden wie unter Windows als 8.3-Name (ggf. NAME~1.EXT) plus +VFAT-Langname abgelegt; die Maschine sieht nur den 8.3-Namen. """ from __future__ import annotations @@ -21,19 +23,41 @@ CLUSTERS = TOTAL_SECTORS - DATA_START # 2847 IMAGE_SIZE = TOTAL_SECTORS * SECTOR # 1 474 560 +_REAL_EXT = re.compile(r"(?=.*[A-Za-z])[A-Za-z0-9]{1,3}") # "exp", "u01" – aber nicht "5" aus "v1.5" +_LFN_BAD = re.compile(r'[\x00-\x1f\\/:*?"<>|]') + + +def _split_name(name: str, default_ext: str) -> tuple[str, str]: + """Trennt Basis und Endung: nur der LETZTE Punkt zählt, und nur bei einer echten Endung.""" + name = name.strip() + base, dot, ext = name.rpartition(".") + if not dot or not base.strip(". ") or not _REAL_EXT.fullmatch(ext): + return name, default_ext + return base, ext + + +def _clean83(s: str) -> str: + s = s.upper() + for k, v in {"Ä": "AE", "Ö": "OE", "Ü": "UE", "ß": "SS", "ẞ": "SS"}.items(): + s = s.replace(k, v) + return re.sub(r"[^A-Z0-9_\-]", "", s) + def dos_name(name: str, default_ext: str = "EXP") -> str: - """Erzeugt einen gültigen 8.3-Namen (Grossbuchstaben).""" - base, _, ext = name.rpartition(".") - if not base: - base, ext = ext, default_ext - repl = {"Ä": "AE", "Ö": "OE", "Ü": "UE", "ß": "SS"} - base = base.upper() - for k, v in repl.items(): - base = base.replace(k, v) - base = re.sub(r"[^A-Z0-9_\-]", "", base)[:8] or "DESIGN" - ext = re.sub(r"[^A-Z0-9]", "", ext.upper())[:3] or default_ext - return f"{base}.{ext}" + """Erzeugt einen gültigen 8.3-Namen (Grossbuchstaben, nur A-Z 0-9 _ -).""" + base, ext = _split_name(name, default_ext) + base = _clean83(base)[:8] or "DESIGN" + ext = _clean83(ext)[:3] or _clean83(default_ext)[:3] + return f"{base}.{ext}" if ext else base + + +def _long_name(name: str, default_ext: str = "EXP") -> str: + """Langer (VFAT-)Name wie vom Aufrufer gemeint; ohne echte Endung wird die Standardendung angehängt.""" + n = _LFN_BAD.sub("", name).strip().rstrip(". ") + base, _ = _split_name(n, default_ext) + if base == n and default_ext: + n = f"{n}.{default_ext.lower()}" + return n[:255] or dos_name(name, default_ext) def _83(name: str) -> bytes: @@ -41,6 +65,36 @@ def _83(name: str) -> bytes: return (b.ljust(8)[:8] + e.ljust(3)[:3]).encode("ascii") +def _lfn_checksum(short11: bytes) -> int: + s = 0 + for c in short11: + s = (((s & 1) << 7) + (s >> 1) + c) & 0xFF + return s + + +def _lfn_entries(long: str, short11: bytes) -> list[bytes]: + """VFAT-Langnamen-Einträge in Disk-Reihenfolge (letzter Teil zuerst), wie Windows sie schreibt.""" + raw = long.encode("utf-16-le") + units = [raw[i:i + 2] for i in range(0, len(raw), 2)] + n = -(-len(units) // 13) + if len(units) % 13: + units.append(b"\x00\x00") + units += [b"\xFF\xFF"] * (n * 13 - len(units)) + chk = _lfn_checksum(short11) + out = [] + for seq in range(n, 0, -1): + part = units[(seq - 1) * 13:seq * 13] + e = bytearray(32) + e[0] = seq | (0x40 if seq == n else 0) + e[1:11] = b"".join(part[0:5]) + e[11] = 0x0F + e[13] = chk + e[14:26] = b"".join(part[5:11]) + e[28:32] = b"".join(part[11:13]) + out.append(bytes(e)) + return out + + def _dos_datetime(t: _dt.datetime): date = ((t.year - 1980) << 9) | (t.month << 5) | t.day time = (t.hour << 11) | (t.minute << 5) | (t.second // 2) @@ -100,6 +154,19 @@ def _set_fat(self, n: int, val: int): self.data[off] = v & 0xFF self.data[off + 1] = (v >> 8) & 0xFF + def _chain(self, start: int, strict: bool = True) -> list[int]: + """Clusterkette ab `start`; begrenzt gegen Schleifen/ungültige Cluster aus fremden .IMG-Dateien.""" + out, seen, cl = [], set(), start + while 2 <= cl < 0xFF8: + if cl >= CLUSTERS + 2 or cl in seen: + if strict: + raise ValueError("Beschädigtes Abbild: FAT-Kette enthält eine Schleife oder einen ungültigen Cluster.") + break + seen.add(cl) + out.append(cl) + cl = self._get_fat(cl) + return out + def free_bytes(self) -> int: return sum(1 for c in range(2, CLUSTERS + 2) if self._get_fat(c) == 0) * SECTOR @@ -110,79 +177,152 @@ def _entries(self): off = base + i * 32 yield i, off, self.data[off:off + 32] - def list_files(self) -> list[tuple[str, int]]: - out = [] - for _, _, e in self._entries(): + def _scan(self) -> list[dict]: + """Alle Dateieinträge des Stammverzeichnisses inkl. VFAT-Langname und belegter Slots.""" + files, parts, lfn_slots, chk = [], {}, [], None + for i, off, e in self._entries(): if e[0] == 0: break - if e[0] == 0xE5 or e[11] & 0x08 or e[11] == 0x0F: + if e[0] == 0xE5: + parts, lfn_slots = {}, [] continue - name = e[0:8].decode("ascii", "replace").rstrip() - ext = e[8:11].decode("ascii", "replace").rstrip() - size = struct.unpack_from(" list[tuple[str, int]]: + """(8.3-Name, Grösse) – genau diese Namen akzeptieren read_file/delete_file unverändert.""" + return [(f["short"], f["size"]) for f in self._scan()] + + def _lookup(self, name: str, default_ext: str = "EXP", legacy: bool = True, files=None): + """Sucht einen Eintrag: 1. exakter 8.3-Name wie gelistet, 2. Langname (VFAT), + 3. (legacy) umgewandelter 8.3-Name bei Einträgen ohne Langname. -> (Eintrag, exakt?)""" + files = self._scan() if files is None else files + up = name.strip().upper() + for f in files: + if f["short"] == up: + return f, True + lng = _long_name(name, default_ext).casefold() + for f in files: + if f["long"] and f["long"].casefold() == lng: + return f, False + if legacy: + short = dos_name(name, default_ext) + for f in files: + if f["short"] == short and not f["long"]: + return f, False + return None, False def read_file(self, name: str) -> bytes: - key = _83(dos_name(name)) - for _, _, e in self._entries(): - if e[0] == 0: - break - if bytes(e[0:11]) == key and e[0] != 0xE5: - cl = struct.unpack_from(" bool: - key = _83(dos_name(name)) - for _, off, e in self._entries(): - if e[0] == 0: - break - if bytes(e[0:11]) == key: - cl = struct.unpack_from(" str: - name = dos_name(name) - self.delete_file(name) - need = max(1, -(-len(content) // SECTOR)) - free = [c for c in range(2, CLUSTERS + 2) if self._get_fat(c) == 0] - if len(free) < need: - raise IOError("Nicht genug Platz auf der Diskette (1.44 MB).") + """Löscht per 8.3-Name (wie von list_files geliefert) oder per Langname.""" + f, _ = self._lookup(name) + if f is None: + return False + self._remove(f) + return True + + def _unique_short(self, long: str, default_ext: str, files: list[dict]) -> str: + """8.3-Name wie bei Windows: gekürzter Name, bei Kollision NAME~1.EXT, ~2, …""" + taken = {f["short"] for f in files} + short = dos_name(long, default_ext) + if short not in taken: + return short + base, ext = _split_name(long, default_ext) + base = _clean83(base) or "DESIGN" + ext = _clean83(ext)[:3] + for n in range(1, 100000): + tail = f"~{n}" + cand = base[:8 - len(tail)] + tail + (f".{ext}" if ext else "") + if cand not in taken: + return cand + raise ValueError("Kein freier 8.3-Name mehr verfügbar.") + + def add_file(self, name: str, content: bytes, default_ext: str = "EXP") -> str: + """Legt eine Datei an (gleicher Name ersetzt die alte Datei) und liefert den 8.3-Namen. + Transaktional: reicht Platz oder Verzeichnis nicht, bleibt das Abbild unverändert (ValueError).""" + content = bytes(content) + files = self._scan() + old, exact = self._lookup(name, default_ext, legacy=False, files=files) + if old is not None: + short = old["short"] + long = short if exact else old["long"] + else: + long = _long_name(name, default_ext) + short = self._unique_short(long, default_ext, files) + lfn = _lfn_entries(long, _83(short)) if long.upper() != short else [] + + # 1. Platz prüfen (Cluster der ersetzten Datei zählen als frei) + old_chain = self._chain(old["cluster"], strict=False) if old else [] + free = sorted({c for c in range(2, CLUSTERS + 2) if self._get_fat(c) == 0} | set(old_chain)) + need = -(-len(content) // SECTOR) + if need > len(free): + raise ValueError(f"Nicht genug Platz auf der Diskette: {short} braucht {need * SECTOR:,} Bytes, " + f"frei sind nur {len(free) * SECTOR:,} Bytes.".replace(",", "'")) + # 2. zusammenhängende freie Verzeichnis-Slots suchen (Langname-Teile + 8.3-Eintrag) + old_slots = set(old["slots"]) if old else set() + usable, ended = [], False + for i, _, e in self._entries(): + ended = ended or e[0] == 0 + usable.append(ended or e[0] == 0xE5 or i in old_slots) + want = len(lfn) + 1 + first = next((i for i in range(ROOT_ENTRIES - want + 1) if all(usable[i:i + want])), None) + if first is None: + raise ValueError(f"Stammverzeichnis voll (max. {ROOT_ENTRIES} Einträge): {short} wurde nicht gespeichert.") + + # 3. ab hier kann nichts mehr scheitern: alte Datei entfernen, Daten schreiben + if old: + self._remove(old) chain = free[:need] for i, c in enumerate(chain): self._set_fat(c, chain[i + 1] if i + 1 < len(chain) else 0xFFF) s = (DATA_START + c - 2) * SECTOR - chunk = content[i * SECTOR:(i + 1) * SECTOR] - self.data[s:s + SECTOR] = chunk.ljust(SECTOR, b"\x00") - slot = None - for _, off, e in self._entries(): - if e[0] in (0x00, 0xE5): - slot = off - break - if slot is None: - raise IOError("Stammverzeichnis voll (max. 224 Dateien).") + self.data[s:s + SECTOR] = content[i * SECTOR:(i + 1) * SECTOR].ljust(SECTOR, b"\x00") e = bytearray(32) - e[0:11] = _83(name) + e[0:11] = _83(short) e[11] = 0x20 # Archiv date, time = _dos_datetime(_dt.datetime.now()) - e[13] = 0 - struct.pack_into(" bytes: return bytes(self.data) diff --git a/openstitch/io/formats.py b/openstitch/io/formats.py index c4a9990..cba46cf 100644 --- a/openstitch/io/formats.py +++ b/openstitch/io/formats.py @@ -62,7 +62,10 @@ def export_plan(plan: StitchPlan, path: str, **kw) -> None: write_exp(plan, f, **kw) return pat = plan_to_pattern(plan) - pe.write(pat, path) + # pyembroidery lässt Trims im JEF-Writer standardmässig weg → Janome würde keinen Faden schneiden. + # Mit trims=True werden sie als 3 Null-Sprünge geschrieben (Janome-Konvention). + settings = {"trims": True} if ext == "jef" else None + pe.write(pat, path, settings) def import_file(path: str) -> Design: diff --git a/openstitch/io/project.py b/openstitch/io/project.py index 0fd839a..1699423 100644 --- a/openstitch/io/project.py +++ b/openstitch/io/project.py @@ -3,6 +3,7 @@ import datetime as _dt import json +import os from PySide6.QtCore import QRectF, Qt from PySide6.QtGui import QColor, QFont, QImage, QPageLayout, QPageSize, QPainter, QPdfWriter, QPen @@ -10,6 +11,7 @@ from .. import APP_NAME, __version__ from ..core.design import HOOPS, Design, Hoop, Settings, object_from_dict from ..core.stitchplan import StitchPlan +from ..i18n import fmt_int, machine_display_name, tr from ..render import render_image @@ -29,12 +31,15 @@ def save_project(design: Design, path: str) -> None: "settings": design.settings.__dict__, "objects": [o.to_dict() for o in design.objects], } - with open(path, "w", encoding="utf-8") as f: + # Erst in eine Temp-Datei schreiben: ein Fehler mitten im Schreiben darf das alte Projekt nicht zerstören + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, path) def load_project(path: str) -> Design: - with open(path, "r", encoding="utf-8") as f: + with open(path, "r", encoding="utf-8-sig") as f: data = json.load(f) h = data.get("hoop", {}) hoop = Hoop( @@ -53,6 +58,7 @@ def load_project(path: str) -> Design: def export_worksheet_pdf(design: Design, plan: StitchPlan, path: str, machine_name: str | None = None) -> None: """Erzeugt ein druckfertiges Produktionsblatt (PDF) für alle Stickmaschinen.""" mach_str = machine_name or getattr(design.settings, "machine_name", "Universal Standard (15 Nadeln)") + mach_str = machine_display_name(mach_str) max_nd = max(1, getattr(design.settings, "needles", 15)) pdf = QPdfWriter(path) pdf.setPageSize(QPageSize(QPageSize.A4)) @@ -64,11 +70,11 @@ def export_worksheet_pdf(design: Design, plan: StitchPlan, path: str, machine_na # Titelzeile p.setPen(QColor(25, 28, 34)) p.setFont(QFont("Segoe UI", 16, QFont.Bold)) - p.drawText(m, m + 60, f"{design.name} — Produktions- & Einrichteblatt") + p.drawText(m, m + 60, tr("pdf_title").format(design.name)) p.setFont(QFont("Segoe UI", 9)) p.setPen(QColor(100, 105, 115)) - now_str = _dt.datetime.now().strftime("%d.%m.%Y %H:%M") - p.drawText(m, m + 115, f"Erstellt mit {APP_NAME} Studio {__version__} am {now_str}") + now_str = _dt.datetime.now().strftime(tr("pdf_date_fmt")) + p.drawText(m, m + 115, tr("pdf_created").format(app=f"{APP_NAME} Studio {__version__}", date=now_str)) # Kennzahlen-Box x0, y0, x1, y1 = plan.bounds() @@ -80,14 +86,15 @@ def export_worksheet_pdf(design: Design, plan: StitchPlan, path: str, machine_na p.drawRoundedRect(m, box_y, W - 2 * m, box_h, 18, 18) stats = [ - ("Stiche gesamt", f"{plan.stitch_count:,}".replace(",", "'")), - ("Grösse (B × H)", f"{bw:.1f} × {bh:.1f} mm"), - ("Farbwechsel / Blöcke", f"{plan.color_changes} ({len(plan.blocks)} Blöcke)"), - ("Fadenschnitte (Trims)", f"{plan.trim_count}"), - ("Stickrahmen", f"{design.hoop.name} ({design.hoop.width:.0f}×{design.hoop.height:.0f} mm)"), - (f"Laufzeit (ca. {design.settings.machine_spm} SPM)", f"{plan.estimated_minutes(design.settings.machine_spm):.1f} Min."), - ("Oberfadenbedarf", f"{plan.thread_length_m():.1f} m"), - ("Maschinenprofil", mach_str[:28]), + (tr("pdf_total_stitches"), fmt_int(plan.stitch_count)), + (tr("pdf_size"), f"{bw:.1f} × {bh:.1f} mm"), + (tr("pdf_color_changes"), tr("pdf_blocks_val").format(plan.color_changes, len(plan.blocks))), + (tr("pdf_trims"), f"{plan.trim_count}"), + (tr("pdf_hoop"), f"{design.hoop.display_name} ({design.hoop.width:.0f}×{design.hoop.height:.0f} mm)"), + (tr("pdf_runtime").format(design.settings.machine_spm), + f"{plan.estimated_minutes(design.settings.machine_spm):.1f} {tr('stats_min')}"), + (tr("pdf_thread_usage"), f"{plan.thread_length_m():.1f} m"), + (tr("pdf_machine"), mach_str[:28]), ] col_w = (W - 2 * m - 60) // 4 for idx, (k, v) in enumerate(stats): @@ -118,7 +125,7 @@ def export_worksheet_pdf(design: Design, plan: StitchPlan, path: str, machine_na tw = W - m - tx p.setFont(QFont("Segoe UI", 11, QFont.Bold)) p.setPen(QColor(25, 28, 34)) - p.drawText(tx, prev_y + 40, f"Farbfolge & Nadelzuordnung ({max_nd} Nadeln)") + p.drawText(tx, prev_y + 40, tr("pdf_sequence").format(max_nd)) # eindeutige Farben → Nadeln 1..max_nd needle_map: dict[str, int] = {} @@ -142,10 +149,10 @@ def export_worksheet_pdf(design: Design, plan: StitchPlan, path: str, machine_na p.setFont(QFont("Segoe UI", 9, QFont.Bold)) label = b.thread.name or b.thread.hex code = f" ({b.thread.code})" if b.thread.code else "" - p.drawText(tx + 70, ry + 24, f"{bi + 1}. Nadel {needle}: {label}{code}") + p.drawText(tx + 70, ry + 24, f"{bi + 1}. {tr('needle')} {needle}: {label}{code}") p.setFont(QFont("Segoe UI", 8)) p.setPen(QColor(100, 105, 115)) - p.drawText(tx + 70, ry + 50, f"{n_st} Stiche · {b.thread.hex.upper()}") + p.drawText(tx + 70, ry + 50, f"{fmt_int(n_st)} {tr('stats_stitches')} · {b.thread.hex.upper()}") ry += row_h p.end() diff --git a/openstitch/render.py b/openstitch/render.py index 7c607c6..0f7afe8 100644 --- a/openstitch/render.py +++ b/openstitch/render.py @@ -17,8 +17,10 @@ def _shade(c: QColor, f: float) -> QColor: int(c.blue() + (255 - c.blue()) * t)) -def iter_segments(plan: StitchPlan, upto: int | None = None): - """Liefert je Farbblock die genähten Segmente: (block, [(x0,y0,x1,y1), ...], jumps).""" +def iter_segments(plan: StitchPlan, upto: int | None = None, start: int = 0): + """Liefert je Farbblock die genähten Segmente: (block, [(x0,y0,x1,y1), ...], jumps). + + `start`: nur Segmente nach dem start-ten Stich (für inkrementelles Weiterzeichnen im Simulator).""" n = 0 for block in plan.blocks: segs, jumps = [], [] @@ -28,11 +30,11 @@ def iter_segments(plan: StitchPlan, upto: int | None = None): break if s.cmd == Cmd.STITCH: n += 1 - if prev is not None: + if prev is not None and n > start: segs.append((prev[0], prev[1], s.x, s.y)) prev = (s.x, s.y) elif s.cmd == Cmd.JUMP: - if prev is not None: + if prev is not None and n >= start: jumps.append((prev[0], prev[1], s.x, s.y)) prev = (s.x, s.y) yield block, segs, jumps @@ -41,11 +43,11 @@ def iter_segments(plan: StitchPlan, upto: int | None = None): def draw_plan(p: QPainter, plan: StitchPlan, upto: int | None = None, realistic: bool = True, - thread_w: float = 0.38, show_jumps: bool = False, px_per_mm: float = 4.0): + thread_w: float = 0.38, show_jumps: bool = False, px_per_mm: float = 4.0, start: int = 0): """Zeichnet in mm-Koordinaten (Painter muss bereits skaliert sein).""" p.setRenderHint(QPainter.Antialiasing, True) last_pt = None - for block, segs, jumps in iter_segments(plan, upto): + for block, segs, jumps in iter_segments(plan, upto, start): if not segs: continue col = QColor(*block.thread.color) diff --git a/openstitch/text.py b/openstitch/text.py index 5065e2b..74ba962 100644 --- a/openstitch/text.py +++ b/openstitch/text.py @@ -2,6 +2,12 @@ Die Glyphen-Umrisse werden mit fontTools gelesen, Kurven geglättet und je Buchstabe automatisch als Satin (schmale Striche) oder Tatami (breite Striche) gestickt. + +Zusätzlich gibt es die eingebaute Einstrich-Kleinschrift „OpenStitch Micro“ +(Spezifikation ``builtin:micro``, siehe ``fonts_micro``): Mittellinien-Glyphen, die als +schmale Satinsäulen bzw. Dreifachstich gestickt werden. Empfehlung: unter ~6 mm +Versalhöhe liefert sie deutlich lesbarere Schrift als TrueType-Umrisse (die Standard- +Schrift bleibt trotzdem die System-Schrift – die Micro-Schrift wird nur angeboten). """ from __future__ import annotations @@ -15,12 +21,24 @@ from fontTools.pens.basePen import BasePen from fontTools.ttLib import TTCollection, TTFont from shapely import affinity -from shapely.geometry import MultiPolygon, Polygon +from shapely.geometry import GeometryCollection, LineString, MultiPolygon, Point, Polygon from shapely.ops import unary_union -from .core.design import OBJECT_TYPES, EmbObject, FillObject, SatinObject +from .core.design import OBJECT_TYPES, EmbObject, FillObject, SatinObject, m_scale from .core.stitchplan import Thread from .engine.geom import clean, dist, main_axis_angle, polygons_of +from .fonts_micro import MICRO_NAME, MICRO_SPEC +from .fonts_micro import layout as micro_layout +from .fonts_micro import mark_strokes as micro_mark_strokes +from .fonts_micro import pull_comp_for as micro_pull_comp +from .fonts_micro import text_runs as micro_text_runs +from .fonts_micro import run_length as micro_run_length +from .fonts_micro import stroke_width as micro_stroke_width + + +def is_builtin_font(spec: str | None) -> bool: + """Eingebaute Schrift (kein TTF-Pfad), z. B. ``builtin:micro``.""" + return bool(spec) and str(spec).startswith("builtin:") class _FlattenPen(BasePen): @@ -73,7 +91,10 @@ def font_dirs() -> list[str]: @lru_cache(maxsize=1) def list_fonts() -> dict[str, str]: - """Anzeigename → Pfad (Index bei TTC: 'pfad#index').""" + """Anzeigename → Pfad (Index bei TTC: 'pfad#index'). + + Die eingebaute Kleinschrift „OpenStitch Micro“ steht immer an erster Stelle + (Spezifikation ``builtin:micro`` statt Dateipfad).""" out: dict[str, str] = {} bundled = os.path.join(os.path.dirname(__file__), "fonts") for d in [bundled] + font_dirs(): @@ -91,7 +112,8 @@ def list_fonts() -> dict[str, str]: out.setdefault(_font_name(f), f"{path}#{i}") except Exception: continue - return dict(sorted(out.items(), key=lambda kv: kv[0].lower())) + out.pop(MICRO_NAME, None) + return {MICRO_NAME: MICRO_SPEC, **dict(sorted(out.items(), key=lambda kv: kv[0].lower()))} def _font_name(f: TTFont) -> str: @@ -103,6 +125,8 @@ def _font_name(f: TTFont) -> str: @lru_cache(maxsize=16) def _load_font(spec: str) -> TTFont: + if is_builtin_font(spec): + raise ValueError(f"{spec!r} ist eine eingebaute Schrift, keine TTF/OTF-Datei") if "#" in spec: p, i = spec.rsplit("#", 1) return TTFont(p, fontNumber=int(i)) @@ -127,12 +151,18 @@ def default_font() -> str: "Verdana Bold", "Arial", "Helvetica", "DejaVu Sans Bold", "DejaVu Sans"): if pref in fonts: return fonts[pref] - return next(iter(fonts.values()), "") + # Eingebaute Micro-Schrift nie stillschweigend zum Standard machen + return next((v for v in fonts.values() if not is_builtin_font(v)), "") def glyph_shapes(text: str, font_spec: str, height_mm: float, letter_spacing: float = 0.0, line_spacing: float = 1.25): """Liefert Liste (zeichen, Polygon-Geometrie, Mittelpunkt-x) in mm; Grundlinie bei y=0.""" + if is_builtin_font(font_spec): + # Micro-Schrift: Striche mit typischer Säulenbreite als Flächen + hw = max(micro_stroke_width(height_mm), 0.4) / 2 + return [(ch, unary_union([_stroke_geom(s).buffer(hw) for s in st]), cx) + for ch, st, cx in micro_layout(text, height_mm, letter_spacing, line_spacing)] font = _load_font(font_spec) gs = font.getGlyphSet() cmap = font.getBestCmap() or {} @@ -187,6 +217,16 @@ def glyph_shapes(text: str, font_spec: str, height_mm: float, letter_spacing: fl return out +def _stroke_geom(stroke): + return Point(stroke[0]) if len(stroke) == 1 else LineString(stroke) + + +def micro_glyph_geoms(text: str, height_mm: float, letter_spacing: float = 0.0): + """Micro-Schrift: Liste (zeichen, GeometryCollection der Striche in Stickreihenfolge, cx).""" + return [(ch, GeometryCollection([_stroke_geom(s) for s in st]), cx) + for ch, st, cx in micro_layout(text, height_mm, letter_spacing)] + + def arc_layout(glyphs, radius: float): """Buchstaben entlang eines Bogens anordnen. radius>0: Bogen oben, <0: unten.""" if not radius: @@ -222,7 +262,7 @@ class TextObject(EmbObject): PARAMS = (("text", "Text", str, 0, 0, 0), ("font", "Schrift", "font", 0, 0, 0), - ("height", "Buchstabenhöhe (mm)", float, 3.0, 200.0, 0.5), + ("height", "Buchstabenhöhe (mm)", float, 2.0, 200.0, 0.5), ("letter_spacing", "Zeichenabstand (mm)", float, -5.0, 20.0, 0.1), ("arc_radius", "Bogenradius (mm, 0 = gerade)", float, -500.0, 500.0, 5.0), ("mode", "Stichart", ["auto", "satin", "fill"], 0, 0, 0), @@ -233,19 +273,46 @@ def __post_init__(self): if not self.font: self.font = default_font() + @property + def is_micro(self) -> bool: + return is_builtin_font(self.font) + def _glyphs(self): key = (self.text, self.font, self.height, self.letter_spacing, self.arc_radius) if getattr(self, "_gkey", None) != key: - g = glyph_shapes(self.text, self.font, self.height, self.letter_spacing) if self.font else [] + if self.is_micro: + g = micro_glyph_geoms(self.text, self.height, self.letter_spacing) + else: + g = glyph_shapes(self.text, self.font, self.height, self.letter_spacing) if self.font else [] self._gcache = arc_layout(g, self.arc_radius) self._gkey = key return self._gcache def local_geom(self): + if self.is_micro: + # Striche mit Säulenbreite gepuffert → Auswahlrahmen & Trefferprüfung + hw = max(micro_stroke_width(self.height), 0.4) / 2 + gs = [g.buffer(hw) for _, g, _ in self._glyphs() if not g.is_empty] + return unary_union(gs) if gs else MultiPolygon() gs = [g for _, g, _ in self._glyphs()] return unary_union(gs) if gs else MultiPolygon() + def _micro_runs(self, start=None): + """Micro-Schrift: Mittellinien je Zeichen als schmale Satinsäulen / Dreifachstich.""" + cap = self.height * m_scale(self.transform) # wirksame Versalhöhe in mm + w = micro_stroke_width(cap) + pc = micro_pull_comp(w, self.pull_comp) # schmale Säulen: wenig Zugkompensation + glyph_strokes, thin = [], [] + for ch, g, _ in self._glyphs(): + wg = affinity.affine_transform(g, self.transform) + glyph_strokes.append([[tuple(c) for c in p.coords] for p in wg.geoms]) + thin.append(micro_mark_strokes(ch)) + return micro_text_runs(glyph_strokes, w, spacing=self.density, pull_comp=pc, + run_len=micro_run_length(cap), glyph_thin=thin) + def runs(self, start=None): + if self.is_micro: + return self._micro_runs(start) out = [] cur = start for ch, g, _ in self._glyphs(): diff --git a/openstitch/ui/canvas.py b/openstitch/ui/canvas.py index d1cc218..09c3e95 100644 --- a/openstitch/ui/canvas.py +++ b/openstitch/ui/canvas.py @@ -19,10 +19,12 @@ ) from PySide6.QtOpenGLWidgets import QOpenGLWidget from PySide6.QtWidgets import QWidget -from shapely.geometry import LineString, Point, Polygon +from shapely.geometry import LineString, MultiLineString, Point, Polygon +from shapely.ops import unary_union -from ..core.design import Design, EmbObject, FillObject, RunObject, SatinObject, compose +from ..core.design import ColumnObject, Design, EmbObject, FillObject, RunObject, SatinObject, compose, m_scale from ..core.stitchplan import Cmd, StitchPlan, Thread +from ..i18n import tr from ..render import draw_plan @@ -110,6 +112,21 @@ def _ensure_stitch_cache(self): key = (self._plan_version, q_ppm, self.realistic, self.show_jumps, self.sim_limit) if self._cache_key == key and self._cache_img is not None: return + # Simulator läuft vorwärts: nur die neuen Stiche auf das vorhandene Bild zeichnen, + # statt bei jedem Timer-Tick den ganzen Plan neu zu rendern + old = self._cache_key + if (old is not None and self._cache_img is not None and old[:4] == key[:4] + and old[4] is not None and (key[4] is None or key[4] > old[4])): + sp = QPainter(self._cache_img) + sp.scale(self._cache_px_per_mm, self._cache_px_per_mm) + sp.translate(-self._cache_origin_mm[0], -self._cache_origin_mm[1]) + pt = draw_plan(sp, self.plan, upto=key[4], realistic=self.realistic, show_jumps=self.show_jumps, + px_per_mm=self._cache_px_per_mm, start=old[4]) + sp.end() + if pt is not None: + self._last_sim_pt = pt + self._cache_key = key + return x0, y0, x1, y1 = self.plan.bounds() pad = 6.0 w_mm = max(10.0, (x1 - x0) + 2 * pad) @@ -137,21 +154,24 @@ def _ensure_stitch_cache(self): self._cache_key = key def _draw_heatmap(self, p: QPainter): - counts: dict[tuple[int, int], int] = defaultdict(int) - for b in self.plan.blocks: - for s in b.stitches: - if s.cmd == Cmd.STITCH: - counts[(int(math.floor(s.x)), int(math.floor(s.y)))] += 1 + # Zellzählung nur einmal pro Plan-Version, nicht bei jedem Repaint (Mausbewegung!) + if getattr(self, "_heat_key", None) != self._plan_version: + counts: dict[tuple[int, int], int] = defaultdict(int) + for b in self.plan.blocks: + for s in b.stitches: + if s.cmd == Cmd.STITCH: + counts[(int(math.floor(s.x)), int(math.floor(s.y)))] += 1 + buckets: list[list[QRectF]] = [[], [], []] + for (gx, gy), c in counts.items(): + buckets[0 if c <= 6 else 1 if c <= 12 else 2].append(QRectF(gx, gy, 1.0, 1.0)) + self._heat_rects = buckets + self._heat_key = self._plan_version p.setPen(Qt.NoPen) - for (gx, gy), c in counts.items(): - if c <= 6: - col = QColor(40, 200, 90, 95) - elif c <= 12: - col = QColor(255, 195, 0, 145) - else: - col = QColor(255, 35, 35, 190) - p.setBrush(col) - p.drawRect(QRectF(gx, gy, 1.0, 1.0)) + for col, rects in zip((QColor(40, 200, 90, 95), QColor(255, 195, 0, 145), QColor(255, 35, 35, 190)), + self._heat_rects): + if rects: + p.setBrush(col) + p.drawRects(rects) # ----------------------------------------------------------- Zeichnen def paintEvent(self, event): @@ -379,6 +399,10 @@ def mousePressEvent(self, event): if math.hypot(mm[0] - nx, mm[1] - ny) <= 8.0 / self.px_per_mm: self.about_to_modify.emit("Knoten verschieben") self._dragging_node_idx = idx + # Knotenliste für die ganze Ziehbewegung einfrieren, sonst verschieben sich + # die Indizes durch simplify()/buffer(0) bei jedem Mausschritt + self._node_drag_geom = self.selected.world_geom() + self._node_drag_coords = nodes return if self.tool not in ("select", "edit_nodes"): @@ -476,6 +500,8 @@ def mouseReleaseEvent(self, event): self.design_modified.emit("Stichwinkel drehen") if self._dragging_node_idx is not None: self._dragging_node_idx = None + self._node_drag_geom = None + self._node_drag_coords = None self.design_modified.emit("Knoten verschieben") if self._dragging_obj: self._dragging_obj = False @@ -483,6 +509,9 @@ def mouseReleaseEvent(self, event): self.design_modified.emit("Objekt transformieren") def _get_editable_nodes(self, obj: EmbObject) -> list[tuple[float, float]]: + # Nur Objekte mit eigener Vektor-Geometrie (Text/Stiche werden anders erzeugt) + if obj.kind not in ("fill", "satin", "run", "column"): + return [] g = obj.world_geom() if g is None or g.is_empty: return [] @@ -502,31 +531,32 @@ def _get_editable_nodes(self, obj: EmbObject) -> list[tuple[float, float]]: def _move_editable_node(self, obj: EmbObject, idx: int, new_mm: tuple[float, float]): from ..core.design import IDENTITY from ..engine.geom import polygons_of, lines_of - g = obj.world_geom() + g = getattr(self, "_node_drag_geom", None) or obj.world_geom() + coords = list(getattr(self, "_node_drag_coords", None) or self._get_editable_nodes(obj)) + if not (0 <= idx < len(coords)): + return + coords[idx] = new_mm polys = polygons_of(g) if polys: - simp = polys[0].simplify(0.45, preserve_topology=True) - base_poly = simp if (isinstance(simp, Polygon) and not simp.is_empty) else polys[0] - coords = list(base_poly.exterior.coords) - if 0 <= idx < len(coords): - coords[idx] = new_mm - if idx == 0 or idx == len(coords) - 1: - coords[0] = coords[-1] = new_mm - try: - new_p = Polygon(coords, list(base_poly.interiors)).buffer(0) - if not new_p.is_empty: - obj.geom = new_p - obj.transform = IDENTITY - except Exception: - pass + if idx == 0 or idx == len(coords) - 1: + coords[0] = coords[-1] = new_mm + try: + new_p = Polygon(coords, list(polys[0].interiors)).buffer(0) + except Exception: + return + if new_p.is_empty: + return + # Weitere Teilflächen eines MultiPolygons unverändert übernehmen + obj.geom = unary_union([new_p] + polys[1:]) if len(polys) > 1 else new_p + obj.transform = IDENTITY return lns = lines_of(g) if lns: - coords = list(lns[0].coords) - if 0 <= idx < len(coords): - coords[idx] = new_mm - obj.geom = LineString(coords) - obj.transform = IDENTITY + if obj.kind == "column": + obj.width *= m_scale(obj.transform) # Skalierung in die Breite übernehmen + rest = lns[1:] + obj.geom = MultiLineString([LineString(coords)] + rest) if rest else LineString(coords) + obj.transform = IDENTITY def keyPressEvent(self, event): if event.key() in (Qt.Key_Return, Qt.Key_Enter) and self._draw_pts: @@ -566,7 +596,7 @@ def _finish_drawing(self): poly = Polygon(pts).buffer(0) if not poly.is_empty and poly.area >= 1.0: self.about_to_modify.emit("Fläche zeichnen") - obj = FillObject(self.active_thread, name="Freihand-Fläche", geom=poly) + obj = FillObject(self.active_thread, name=tr("kind_fill"), geom=poly) self.design.objects.append(obj) self.selected = obj self.selection_changed.emit(obj) @@ -575,7 +605,8 @@ def _finish_drawing(self): line = LineString(pts) if line.length >= 1.0: self.about_to_modify.emit("Satin zeichnen") - obj = SatinObject(self.active_thread, name="Freihand-Satin", geom=line.buffer(1.6, cap_style=2)) + # Gezeichnete Linie = Säulen-Mittellinie; Stiche stehen senkrecht dazu + obj = ColumnObject(self.active_thread, name=tr("kind_column"), geom=line, width=3.2) self.design.objects.append(obj) self.selected = obj self.selection_changed.emit(obj) @@ -584,7 +615,7 @@ def _finish_drawing(self): line = LineString(pts) if line.length >= 1.0: self.about_to_modify.emit("Steppstich zeichnen") - obj = RunObject(self.active_thread, name="Steppstich-Pfad", geom=line, repeats=1) + obj = RunObject(self.active_thread, name=tr("kind_run"), geom=line, repeats=1) self.design.objects.append(obj) self.selected = obj self.selection_changed.emit(obj) diff --git a/openstitch/ui/dialogs.py b/openstitch/ui/dialogs.py index 7542dd9..d8d4654 100644 --- a/openstitch/ui/dialogs.py +++ b/openstitch/ui/dialogs.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import re import numpy as np from PySide6.QtCore import Qt, QThread, Signal @@ -32,6 +33,7 @@ from ..core.stitchplan import StitchPlan, Thread from ..io.exp import exp_bytes from ..io.fat12 import Fat12Image, dos_name +from ..i18n import fmt_float, fmt_int, key_for, tr from ..text import TextObject, default_font, list_fonts @@ -46,32 +48,67 @@ def _np_to_pixmap(rgb: np.ndarray, max_side: int = 360) -> QPixmap: return QPixmap.fromImage(qimg).scaled(max_side, max_side, Qt.KeepAspectRatio, Qt.SmoothTransformation) +class _Cancelled(Exception): + pass + + +_CLASSIFY_RE = re.compile(r"^Fläche (\d+)/(\d+) klassifizieren …$") + + +def _tr_progress(msg: str) -> str: + """Übersetzt die (deutschen) Fortschrittsmeldungen der Auto-Digitizer-Engine in die UI-Sprache.""" + m = _CLASSIFY_RE.match(msg) + if m: + return tr("prog_classify").format(m.group(1), m.group(2)) + key = key_for(msg) + return tr(key) if key else msg + + +def _image_filter(all_key: str = "filter_all_images") -> str: + exts = "*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff" + return f"{tr(all_key)} ({exts});;{tr('filter_all_files')} (*.*)" + + class _DigitizeWorker(QThread): progress = Signal(str, int) finished_ok = Signal(object, object) # AutoResult, StitchPlan failed = Signal(str) - def __init__(self, rgba: np.ndarray, params: AutoParams): + def __init__(self, rgba: np.ndarray, params: AutoParams, hoop=None): super().__init__() self.rgba = rgba self.params = params + self.hoop = hoop + self.cancelled = False + + def _tick(self, msg: str, val: int): + # Abbruch wird an jedem Fortschrittspunkt geprüft (Bildschritte bzw. je Objekt) + if self.cancelled: + raise _Cancelled() + self.progress.emit(_tr_progress(msg), val) def run(self): try: res = auto_digitize( self.rgba, self.params, - progress=lambda msg, v: self.progress.emit(msg, int(v * 0.55)), + progress=lambda msg, v: self._tick(msg, int(v * 0.55)), ) - total = max(1, len(res.design.objects)) + if self.hoop is not None: + # Plan gleich für den Rahmen des Hauptfensters rechnen + res.design.hoop = self.hoop plan = res.design.build_plan( - progress=lambda i, n: self.progress.emit( - f"Stiche berechnen ({i}/{n}) …", 55 + int(45 * i / max(1, n)) + progress=lambda i, n: self._tick( + tr("prog_computing_stitches").format(i, n), 55 + int(45 * i / max(1, n)) ) ) - self.finished_ok.emit(res, plan) + if not self.cancelled: + self.finished_ok.emit(res, plan) + except _Cancelled: + pass except Exception as exc: - self.failed.emit(str(exc)) + if not self.cancelled: + self.failed.emit(str(exc)) # =========================================================================== @@ -80,7 +117,7 @@ def run(self): class AutoDigitizeDialog(QDialog): def __init__(self, image_path: str, parent=None): super().__init__(parent) - self.setWindowTitle("Bild automatisch digitalisieren (Auto-Digitizer)") + self.setWindowTitle(tr("dlg_auto_title")) self.resize(860, 560) self.rgba = load_image(image_path) self.result_data: AutoResult | None = None @@ -92,14 +129,14 @@ def __init__(self, image_path: str, parent=None): top = QHBoxLayout() # Vorschau Original v1 = QVBoxLayout() - lbl_t1 = QLabel(f"Originalbild ({os.path.basename(image_path)})") + lbl_t1 = QLabel(tr("lbl_orig_image").format(os.path.basename(image_path))) lbl_t1.setAlignment(Qt.AlignCenter) self.lbl_orig_title = lbl_t1 self.lbl_orig = QLabel() self.lbl_orig.setAlignment(Qt.AlignCenter) self.lbl_orig.setMinimumSize(320, 320) self.lbl_orig.setPixmap(_np_to_pixmap(self.rgba, 340)) - btn_change_img = QPushButton("📂 Anderes Bild wählen (Downloads / Desktop / Bilder …)") + btn_change_img = QPushButton(tr("btn_other_image")) btn_change_img.clicked.connect(self._choose_other_image) v1.addWidget(lbl_t1) v1.addWidget(self.lbl_orig, 1) @@ -107,57 +144,54 @@ def __init__(self, image_path: str, parent=None): top.addLayout(v1, 1) # Einstellungen rechts - grp = QGroupBox("Stick-Einstellungen & Automatik") + grp = QGroupBox(tr("grp_auto_settings")) form = QFormLayout(grp) self.sp_width = QDoubleSpinBox() self.sp_width.setRange(15.0, 350.0) self.sp_width.setValue(95.0) self.sp_width.setSuffix(" mm") - form.addRow("Zielbreite des Motivs:", self.sp_width) + form.addRow(tr("lbl_target_width_motif"), self.sp_width) self.sp_colors = QSpinBox() self.sp_colors.setRange(1, 16) self.sp_colors.setValue(6) - form.addRow("Max. Garnfarben (Nadeln):", self.sp_colors) + form.addRow(tr("lbl_max_colors"), self.sp_colors) - self.cb_bg = QCheckBox("Bildhintergrund automatisch entfernen") + self.cb_bg = QCheckBox(tr("cb_remove_bg")) self.cb_bg.setChecked(True) form.addRow("", self.cb_bg) self.cmb_fabric = QComboBox() - self.cmb_fabric.addItem("Standard (Baumwolle / Twill / Universal)", "normal") - self.cmb_fabric.addItem("Piqué / Polo / Strick (mehr Zugkompensation)", "strick") - self.cmb_fabric.addItem("Frottee / Handtuch (dichter + starke Unterlage)", "frottee") - self.cmb_fabric.addItem("Kappe / Cap (hohe Zugkompensation)", "cap") - self.cmb_fabric.addItem("Leichter Stoff / Hemd (geringere Dichte)", "leicht") - form.addRow("Stoffart / Material:", self.cmb_fabric) + for fab in ("normal", "strick", "frottee", "cap", "leicht"): + self.cmb_fabric.addItem(tr(f"fabric_opt_{fab}"), fab) + form.addRow(tr("lbl_fabric_type"), self.cmb_fabric) self.sp_density = QDoubleSpinBox() self.sp_density.setRange(0.28, 1.0) self.sp_density.setSingleStep(0.02) self.sp_density.setValue(0.40) self.sp_density.setSuffix(" mm") - form.addRow("Stichdichte (Reihenabstand):", self.sp_density) + form.addRow(tr("lbl_density_row"), self.sp_density) self.sp_satin = QDoubleSpinBox() self.sp_satin.setRange(2.0, 10.0) self.sp_satin.setSingleStep(0.5) self.sp_satin.setValue(6.5) self.sp_satin.setSuffix(" mm") - form.addRow("Satin-Grenze (schmaler = Satin):", self.sp_satin) + form.addRow(tr("lbl_satin_limit"), self.sp_satin) self.sp_min_area = QDoubleSpinBox() self.sp_min_area.setRange(0.3, 15.0) self.sp_min_area.setSingleStep(0.5) self.sp_min_area.setValue(1.5) self.sp_min_area.setSuffix(" mm²") - form.addRow("Kleinstteile filtern unter:", self.sp_min_area) + form.addRow(tr("lbl_min_area"), self.sp_min_area) - self.cb_outline = QCheckBox("Zusätzliche Satinkontur um das Motiv legen") + self.cb_outline = QCheckBox(tr("cb_outline")) form.addRow("", self.cb_outline) - self.cb_palette = QCheckBox("Farben an Stickgarn-Palette anpassen") + self.cb_palette = QCheckBox(tr("cb_palette")) self.cb_palette.setChecked(True) form.addRow("", self.cb_palette) @@ -167,14 +201,14 @@ def __init__(self, image_path: str, parent=None): self.progress = QProgressBar() self.progress.setRange(0, 100) self.progress.setValue(0) - self.lbl_status = QLabel("Bereit.") + self.lbl_status = QLabel(tr("status_ready")) root.addWidget(self.lbl_status) root.addWidget(self.progress) self.btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) self.btn_ok = self.btns.button(QDialogButtonBox.Ok) - self.btn_ok.setText("Jetzt digitalisieren && übernehmen") - self.btns.button(QDialogButtonBox.Cancel).setText("Abbrechen") + self.btn_ok.setText(tr("btn_digitize_apply")) + self.btns.button(QDialogButtonBox.Cancel).setText(tr("btn_cancel")) self.btns.accepted.connect(self._run) self.btns.rejected.connect(self.reject) root.addWidget(self.btns) @@ -182,22 +216,20 @@ def __init__(self, image_path: str, parent=None): def _choose_other_image(self): p = self.parent() if p is not None and hasattr(p, "_ask_open_file"): - path = p._ask_open_file( - "Bild oder Logo wählen (Downloads / Bilder / Desktop)", - "Alle Bilder (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff);;Alle Dateien (*.*)", - ) + path = p._ask_open_file(tr("fd_pick_logo"), _image_filter()) else: path, _ = QFileDialog.getOpenFileName( - self, "Bild oder Logo wählen", "", "Bilder (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff)" + self, tr("fd_pick_logo_short"), "", + f"{tr('filter_images')} (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff)", ) if not path: return try: self.rgba = load_image(path) - self.lbl_orig_title.setText(f"Originalbild ({os.path.basename(path)})") + self.lbl_orig_title.setText(tr("lbl_orig_image").format(os.path.basename(path))) self.lbl_orig.setPixmap(_np_to_pixmap(self.rgba, 340)) except Exception as exc: - QMessageBox.critical(self, "Fehler beim Laden", str(exc)) + QMessageBox.critical(self, tr("err_load_title"), str(exc)) def _run(self): if self._worker is not None and self._worker.isRunning(): @@ -214,7 +246,9 @@ def _run(self): use_palette=self.cb_palette.isChecked(), ) self.btn_ok.setEnabled(False) - self._worker = _DigitizeWorker(self.rgba, p) + parent = self.parent() + hoop = parent.canvas.design.hoop if parent is not None and hasattr(parent, "canvas") else None + self._worker = _DigitizeWorker(self.rgba, p, hoop) self._worker.progress.connect(self._on_prog) self._worker.finished_ok.connect(self._on_done) self._worker.failed.connect(self._on_fail) @@ -232,7 +266,21 @@ def _on_done(self, res: AutoResult, plan: StitchPlan): def _on_fail(self, err: str): self.btn_ok.setEnabled(True) - QMessageBox.critical(self, "Fehler beim Digitalisieren", err) + QMessageBox.critical(self, tr("err_digitize_title"), err) + + def reject(self): + # Abbrechen/Esc/Schliessen stoppt auch die laufende Berechnung + w = self._worker + if w is not None and w.isRunning(): + w.cancelled = True + for sig in (w.progress, w.finished_ok, w.failed): + try: + sig.disconnect() + except (RuntimeError, TypeError): + pass + w.wait(15000) + self._worker = None + super().reject() # =========================================================================== @@ -241,7 +289,7 @@ def _on_fail(self, err: str): class TextDialog(QDialog): def __init__(self, thread: Thread, existing: TextObject | None = None, parent=None): super().__init__(parent) - self.setWindowTitle("Schrift / Monogramm erstellen") + self.setWindowTitle(tr("dlg_text_title")) self.resize(520, 380) self.thread = thread self.existing = existing @@ -251,7 +299,7 @@ def __init__(self, thread: Thread, existing: TextObject | None = None, parent=No form = QFormLayout() self.ed_text = QLineEdit(existing.text if existing else "OPENSTITCH") - form.addRow("Text:", self.ed_text) + form.addRow(tr("lbl_text"), self.ed_text) self.cmb_font = QComboBox() self._fonts = list_fonts() @@ -262,50 +310,49 @@ def __init__(self, thread: Thread, existing: TextObject | None = None, parent=No if spec == cur_font: cur_idx = idx self.cmb_font.setCurrentIndex(cur_idx) - form.addRow("Schriftart (TTF/OTF):", self.cmb_font) + form.addRow(tr("lbl_font_file"), self.cmb_font) self.sp_height = QDoubleSpinBox() self.sp_height.setRange(4.0, 150.0) self.sp_height.setValue(existing.height if existing else 16.0) self.sp_height.setSuffix(" mm") - form.addRow("Buchstabenhöhe (Versalhöhe):", self.sp_height) + form.addRow(tr("lbl_letter_height"), self.sp_height) self.sp_spacing = QDoubleSpinBox() self.sp_spacing.setRange(-4.0, 20.0) self.sp_spacing.setSingleStep(0.2) self.sp_spacing.setValue(existing.letter_spacing if existing else 0.6) self.sp_spacing.setSuffix(" mm") - form.addRow("Zeichenabstand:", self.sp_spacing) + form.addRow(tr("lbl_letter_spacing"), self.sp_spacing) self.sp_arc = QDoubleSpinBox() self.sp_arc.setRange(-400.0, 400.0) self.sp_arc.setSingleStep(5.0) self.sp_arc.setValue(existing.arc_radius if existing else 0.0) - self.sp_arc.setSuffix(" mm (0 = gerade)") - form.addRow("Bogenradius:", self.sp_arc) + self.sp_arc.setSuffix(tr("sfx_arc")) + form.addRow(tr("lbl_arc_radius"), self.sp_arc) self.cmb_mode = QComboBox() - self.cmb_mode.addItem("Automatisch (Satin bis 7 mm, darüber Tatami)", "auto") - self.cmb_mode.addItem("Immer Satinstich (Glanz-Schrift)", "satin") - self.cmb_mode.addItem("Immer Tatami-Füllstich", "fill") + for mode in ("auto", "satin", "fill"): + self.cmb_mode.addItem(tr(f"text_mode_{mode}"), mode) if existing: idx = self.cmb_mode.findData(existing.mode) if idx >= 0: self.cmb_mode.setCurrentIndex(idx) - form.addRow("Stichart:", self.cmb_mode) + form.addRow(tr("lbl_stitch_type"), self.cmb_mode) self.sp_density = QDoubleSpinBox() self.sp_density.setRange(0.25, 0.9) self.sp_density.setSingleStep(0.02) self.sp_density.setValue(existing.density if existing else 0.38) self.sp_density.setSuffix(" mm") - form.addRow("Stichdichte:", self.sp_density) + form.addRow(tr("lbl_stitch_density"), self.sp_density) root.addLayout(form) btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - btns.button(QDialogButtonBox.Ok).setText("Übernehmen") - btns.button(QDialogButtonBox.Cancel).setText("Abbrechen") + btns.button(QDialogButtonBox.Ok).setText(tr("btn_apply")) + btns.button(QDialogButtonBox.Cancel).setText(tr("btn_cancel")) btns.accepted.connect(self._apply) btns.rejected.connect(self.reject) root.addWidget(btns) @@ -313,11 +360,11 @@ def __init__(self, thread: Thread, existing: TextObject | None = None, parent=No def _apply(self): txt = self.ed_text.text().strip() if not txt: - QMessageBox.warning(self, "Hinweis", "Bitte einen Text eingeben.") + QMessageBox.warning(self, tr("dlg_notice"), tr("msg_enter_text")) return self.created_object = TextObject( thread=self.thread, - name=f'Text „{txt[:18]}“', + name=tr("text_obj_name").format(txt[:18]), text=txt, font=self.cmb_font.currentData(), height=self.sp_height.value(), @@ -335,7 +382,7 @@ def _apply(self): class MelcoDiskDialog(QDialog): def __init__(self, plan: StitchPlan, design_name: str, parent=None): super().__init__(parent) - self.setWindowTitle("Maschinen-Transfer (USB / 1.44 MB Disketten-Emulator / WLAN NetBridge)") + self.setWindowTitle(tr("dlg_transfer_title")) self.resize(680, 520) self.plan = plan self.image = Fat12Image() @@ -344,51 +391,50 @@ def __init__(self, plan: StitchPlan, design_name: str, parent=None): root = QVBoxLayout(self) # Abschnitt 1: Dateiname & Trim-Modus - g1 = QGroupBox("1. Maschinen- & Trim-Einstellungen (EXP / Industrie-Format)") + g1 = QGroupBox(tr("grp_transfer_1")) f1 = QFormLayout(g1) self.ed_name = QLineEdit(dos_name(design_name, "EXP")) - f1.addRow("DOS-Dateiname (8.3-Format):", self.ed_name) + f1.addRow(tr("lbl_dos_name"), self.ed_name) self.cmb_trim = QComboBox() - self.cmb_trim.addItem("Kombi: EXP-Trim-Code + 3 Sprünge (höchste Kompatibilität)", "both") - self.cmb_trim.addItem("Standard EXP-Trim-Code (80 80 07 00)", "code") - self.cmb_trim.addItem("Nur Sprungfolge (für ältere EMT-Firmware)", "jumps") - f1.addRow("Fadenschnitt (Trim)-Modus:", self.cmb_trim) + for mode in ("both", "code", "jumps"): + self.cmb_trim.addItem(tr(f"trim_{mode}"), mode) + f1.addRow(tr("lbl_trim_mode"), self.cmb_trim) root.addWidget(g1) # Abschnitt 2: Direkt auf USB-Stick / Diskettenlaufwerk oder per WLAN-NetBridge senden - g2 = QGroupBox("2. Direkt auf USB-Stick / Laufwerk oder über WLAN-NetBridge senden") + g2 = QGroupBox(tr("grp_transfer_2")) v2 = QVBoxLayout(g2) - btn_folder = QPushButton("EXP direkt auf USB-Stick / Ordner speichern …") + btn_folder = QPushButton(tr("btn_save_exp_usb")) btn_folder.clicked.connect(self._save_direct_exp) v2.addWidget(btn_folder) hb_net = QHBoxLayout() - hb_net.addWidget(QLabel("NetBridge-URL:")) + hb_net.addWidget(QLabel(tr("lbl_netbridge_url"))) self.ed_net_url = QLineEdit("http://raspberrypi.local:8070") hb_net.addWidget(self.ed_net_url, 1) - btn_net = QPushButton("Über WLAN an Melco NetBridge senden") + btn_net = QPushButton(tr("btn_send_netbridge")) btn_net.clicked.connect(self._send_netbridge) hb_net.addWidget(btn_net) v2.addLayout(hb_net) root.addWidget(g2) # Abschnitt 3: 1.44 MB FAT12 Diskettenabbild (.IMG) für GoTek / FlashFloppy / HxC - g3 = QGroupBox("3. 1.44 MB FAT12-Diskettenabbild (.IMG) für USB-Emulator (GoTek / HxC)") + g3 = QGroupBox(tr("grp_transfer_3")) v3 = QVBoxLayout(g3) - self.lbl_img = QLabel("Aktuelles Abbild: Neues leeres 1.44-MB-Abbild (FAT12)") + self.lbl_img = QLabel(tr("lbl_img_initial")) v3.addWidget(self.lbl_img) self.list_files = QListWidget() v3.addWidget(self.list_files, 1) hb = QHBoxLayout() - btn_open_img = QPushButton("Bestehendes .IMG öffnen …") + btn_open_img = QPushButton(tr("btn_open_img")) btn_open_img.clicked.connect(self._open_img) - btn_add_cur = QPushButton("Aktuelles Design ins Abbild einfügen") + btn_add_cur = QPushButton(tr("btn_add_cur_img")) btn_add_cur.clicked.connect(self._add_current_to_img) - btn_del = QPushButton("Ausgewählte Datei löschen") + btn_del = QPushButton(tr("btn_del_file")) btn_del.clicked.connect(self._delete_selected) - btn_save_img = QPushButton(".IMG-Datei speichern …") + btn_save_img = QPushButton(tr("btn_save_img")) btn_save_img.clicked.connect(self._save_img) hb.addWidget(btn_open_img) hb.addWidget(btn_add_cur) @@ -398,6 +444,7 @@ def __init__(self, plan: StitchPlan, design_name: str, parent=None): root.addWidget(g3, 1) close_box = QDialogButtonBox(QDialogButtonBox.Close) + close_box.button(QDialogButtonBox.Close).setText(tr("btn_close")) close_box.rejected.connect(self.reject) root.addWidget(close_box) @@ -413,19 +460,20 @@ def _current_exp_bytes(self) -> tuple[str, bytes]: def _refresh_list(self): self.list_files.clear() for fname, size in self.image.list_files(): - self.list_files.addItem(f"{fname:<14} ({size:,} Bytes)".replace(",", "'")) + self.list_files.addItem(f"{fname:<14} ({fmt_int(size)} {tr('unit_bytes')})") free_kb = self.image.free_bytes() / 1024 - src = self.img_path or "Neues 1.44-MB-Abbild" - self.lbl_img.setText(f"{src} — Freier Speicher: {free_kb:.1f} KB von 1'423.5 KB") + src = self.img_path or tr("img_new") + self.lbl_img.setText(tr("img_free").format(src=src, free=fmt_float(free_kb), total=fmt_float(1423.5))) def _save_direct_exp(self): name, data = self._current_exp_bytes() - path, _ = QFileDialog.getSaveFileName(self, "Melco EXP speichern", name, "Melco EXP (*.EXP *.exp)") + path, _ = QFileDialog.getSaveFileName(self, tr("fd_save_exp"), name, "Melco EXP (*.EXP *.exp)") if not path: return with open(path, "wb") as f: f.write(data) - QMessageBox.information(self, "Gespeichert", f"Melco-Stickdatei gespeichert:\n{path}\n({len(data)} Bytes)") + QMessageBox.information(self, tr("msg_saved_title"), + tr("msg_exp_saved").format(path=path, size=fmt_int(len(data)))) def _send_netbridge(self): import urllib.request @@ -435,13 +483,14 @@ def _send_netbridge(self): try: with urllib.request.urlopen(req, timeout=5) as resp: body = resp.read().decode("utf-8", "replace") - QMessageBox.information(self, "WLAN-Übertragung erfolgreich", f"Gesendet an {url}:\n{body}") + QMessageBox.information(self, tr("msg_wifi_ok_title"), tr("msg_wifi_ok_text").format(url=url, body=body)) except Exception as exc: - QMessageBox.critical(self, "NetBridge nicht erreichbar", f"Konnte {url} nicht erreichen:\n{exc}") + QMessageBox.critical(self, tr("msg_netbridge_fail_title"), + tr("msg_netbridge_fail_text").format(url=url, err=exc)) def _open_img(self): path, _ = QFileDialog.getOpenFileName( - self, "FAT12-Diskettenabbild öffnen", "", "Diskettenabbild (*.img *.IMG *.ima *.dsk)" + self, tr("fd_open_img"), "", f"{tr('filter_floppy')} (*.img *.IMG *.ima *.dsk)" ) if not path: return @@ -451,15 +500,21 @@ def _open_img(self): self.img_path = path self._refresh_list() except Exception as exc: - QMessageBox.critical(self, "Fehler", str(exc)) + QMessageBox.critical(self, tr("dlg_error"), str(exc)) def _add_current_to_img(self): try: name, data = self._current_exp_bytes() + # 8.3-Namen sind kurz: zwei Designs landen leicht auf demselben Namen → nachfragen + if any(f == name for f, _ in self.image.list_files()): + ans = QMessageBox.question(self, tr("msg_overwrite_title"), + tr("msg_overwrite_text").format(name=name)) + if ans != QMessageBox.Yes: + return self.image.add_file(name, data) self._refresh_list() except Exception as exc: - QMessageBox.critical(self, "Fehler", str(exc)) + QMessageBox.critical(self, tr("dlg_error"), str(exc)) def _delete_selected(self): item = self.list_files.currentItem() @@ -472,7 +527,7 @@ def _delete_selected(self): def _save_img(self): default = self.img_path or "DSKA0000.IMG" path, _ = QFileDialog.getSaveFileName( - self, "1.44 MB FAT12-Diskettenabbild speichern", default, "Diskettenabbild (*.IMG *.img *.ima)" + self, tr("fd_save_img"), default, f"{tr('filter_floppy')} (*.IMG *.img *.ima)" ) if not path: return @@ -482,11 +537,8 @@ def _save_img(self): self._refresh_list() QMessageBox.information( self, - "Diskettenabbild gespeichert", - f"1.44-MB-FAT12-Abbild gespeichert:\n{path}\n\n" - "Tipp für GoTek/FlashFloppy: Benenne die Bilder auf dem USB-Stick z. B. " - "DSKA0000.IMG, DSKA0001.IMG oder kopiere die .EXP-Datei direkt, falls FlashFloppy " - "im Direct-File-Modus läuft.", + tr("msg_img_saved_title"), + tr("msg_img_saved_text").format(path=path), ) @@ -496,7 +548,7 @@ def _save_img(self): class PhotoStitchDialog(QDialog): def __init__(self, image_path: str, parent=None): super().__init__(parent) - self.setWindowTitle("PhotoStitch — Porträt- & Foto-Stickerei erzeugen") + self.setWindowTitle(tr("dlg_photo_title")) self.resize(740, 440) self.rgba = load_image(image_path) self.result_design: Design | None = None @@ -505,51 +557,51 @@ def __init__(self, image_path: str, parent=None): top = QHBoxLayout() v1 = QVBoxLayout() - self.lbl_orig_title = QLabel(f"Originalfoto ({os.path.basename(image_path)})") + self.lbl_orig_title = QLabel(tr("lbl_orig_photo").format(os.path.basename(image_path))) self.lbl_orig_title.setAlignment(Qt.AlignCenter) self.lbl_orig = QLabel() self.lbl_orig.setAlignment(Qt.AlignCenter) self.lbl_orig.setPixmap(_np_to_pixmap(self.rgba, 300)) - btn_change_img = QPushButton("📂 Anderes Foto wählen (Downloads / Desktop / Bilder …)") + btn_change_img = QPushButton(tr("btn_other_photo")) btn_change_img.clicked.connect(self._choose_other_image) v1.addWidget(self.lbl_orig_title) v1.addWidget(self.lbl_orig, 1) v1.addWidget(btn_change_img) top.addLayout(v1, 1) - grp = QGroupBox("PhotoStitch-Einstellungen") + grp = QGroupBox(tr("grp_photo_settings")) form = QFormLayout(grp) self.sp_width = QDoubleSpinBox() self.sp_width.setRange(40.0, 280.0) self.sp_width.setValue(110.0) self.sp_width.setSuffix(" mm") - form.addRow("Zielbreite:", self.sp_width) + form.addRow(tr("lbl_target_width"), self.sp_width) self.cmb_mode = QComboBox() - self.cmb_mode.addItem("Monochrom-Gravur (1 Garnfarbe auf hellem Stoff)", "mono") - self.cmb_mode.addItem("Sepia-Porträt (2 Garnfarben: Hellbraun + Dunkelbraun)", "sepia") - self.cmb_mode.addItem("4-Farb CMYK-Halbton (Gelb, Magenta, Cyan, Schwarz)", "color4") - form.addRow("Farbmodus:", self.cmb_mode) + for mode in ("mono", "sepia", "color4"): + self.cmb_mode.addItem(tr(f"photo_mode_{mode}"), mode) + form.addRow(tr("lbl_color_mode"), self.cmb_mode) self.sp_row = QDoubleSpinBox() self.sp_row.setRange(0.9, 3.0) self.sp_row.setSingleStep(0.1) self.sp_row.setValue(1.5) self.sp_row.setSuffix(" mm") - form.addRow("Zeilenabstand (Feinheit):", self.sp_row) + form.addRow(tr("lbl_row_spacing_fine"), self.sp_row) self.sp_contrast = QDoubleSpinBox() self.sp_contrast.setRange(0.7, 2.2) self.sp_contrast.setSingleStep(0.1) self.sp_contrast.setValue(1.25) - form.addRow("Kontrast-Verstärkung:", self.sp_contrast) + form.addRow(tr("lbl_contrast"), self.sp_contrast) top.addWidget(grp, 1) root.addLayout(top, 1) btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - btns.button(QDialogButtonBox.Ok).setText("PhotoStitch berechnen && übernehmen") + btns.button(QDialogButtonBox.Ok).setText(tr("btn_photo_apply")) + btns.button(QDialogButtonBox.Cancel).setText(tr("btn_cancel")) btns.accepted.connect(self._run) btns.rejected.connect(self.reject) root.addWidget(btns) @@ -557,22 +609,20 @@ def __init__(self, image_path: str, parent=None): def _choose_other_image(self): p = self.parent() if p is not None and hasattr(p, "_ask_open_file"): - path = p._ask_open_file( - "Bild oder Logo wählen (Downloads / Bilder / Desktop)", - "Alle Bilder (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff);;Alle Dateien (*.*)", - ) + path = p._ask_open_file(tr("fd_pick_logo"), _image_filter()) else: path, _ = QFileDialog.getOpenFileName( - self, "Bild oder Logo wählen", "", "Bilder (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff)" + self, tr("fd_pick_logo_short"), "", + f"{tr('filter_images')} (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff)", ) if not path: return try: self.rgba = load_image(path) - self.lbl_orig_title.setText(f"Originalbild ({os.path.basename(path)})") + self.lbl_orig_title.setText(tr("lbl_orig_photo").format(os.path.basename(path))) self.lbl_orig.setPixmap(_np_to_pixmap(self.rgba, 340)) except Exception as exc: - QMessageBox.critical(self, "Fehler beim Laden", str(exc)) + QMessageBox.critical(self, tr("err_load_title"), str(exc)) def _run(self): p = PhotoStitchParams( @@ -585,5 +635,5 @@ def _run(self): self.result_design = generate_photostitch(self.rgba, p) self.accept() except Exception as exc: - QMessageBox.critical(self, "Fehler", str(exc)) + QMessageBox.critical(self, tr("dlg_error"), str(exc)) diff --git a/openstitch/ui/main_window.py b/openstitch/ui/main_window.py index fc618ad..de46c5c 100644 --- a/openstitch/ui/main_window.py +++ b/openstitch/ui/main_window.py @@ -38,8 +38,10 @@ from .. import APP_NAME, __version__ from ..auto.palette import CATALOG_PALETTES, active_palette, nearest_thread, set_active_palette -from ..i18n import LANGUAGES, current_lang, init_lang, key_for, set_lang, tr -from ..core.design import HOOPS, Design, EmbObject, FillObject, Hoop, RunObject, SatinObject +from ..i18n import ( + LANGUAGES, current_lang, fmt_int, init_lang, key_for, machine_display_name, set_lang, tr, tr_opt, +) +from ..core.design import HOOPS, Design, EmbObject, FillObject, Hoop, RunObject, SatinObject, color_sorted from ..core.machines import MACHINE_PROFILES, MachineProfile from ..core.stitchplan import Thread from ..engine.pro_tools import create_applique_objects, import_svg, run_quality_doctor @@ -50,6 +52,7 @@ from .canvas import StitchCanvas from .dialogs import AutoDigitizeDialog, MelcoDiskDialog, PhotoStitchDialog, TextDialog from .onboarding import InAppTourOverlay, OnboardingTourDialog, create_openstitch_logo_design, studio_icon +from .plan_worker import PlanBuilder STUDIO_STYLE = """ QMainWindow, QDialog, QDockWidget, QWidget { @@ -220,7 +223,7 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): super().__init__() init_lang() self._tr_bindings: list[tuple] = [] - self.setWindowTitle(f"{APP_NAME} Studio {__version__} — Universal Open-Source Embroidery & Digitizing Suite") + self._bind(self.setWindowTitle, "app_tagline", fmt=lambda s: f"{APP_NAME} Studio {__version__} — {s}") self.resize(1540, 920) self.setStyleSheet(STUDIO_STYLE) self.setAcceptDrops(True) @@ -228,6 +231,10 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): self._tour_overlay: InAppTourOverlay | None = None self.canvas = StitchCanvas(self) + self._planner = PlanBuilder(self) + self._planner.finished.connect(self._on_plan_finished) + self._applied_gen = 0 + self._dirty = False central = QWidget(self) c_lay = QVBoxLayout(central) c_lay.setContentsMargins(0, 0, 0, 0) @@ -250,7 +257,7 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): self.cmb_machine = QComboBox() self.cmb_machine.setMinimumWidth(225) for mp in MACHINE_PROFILES: - self.cmb_machine.addItem(mp.name, mp) + self.cmb_machine.addItem(machine_display_name(mp.name), mp) self.cmb_machine.currentIndexChanged.connect(self._on_machine_changed) ctx_hb.addWidget(self.cmb_machine) @@ -262,7 +269,7 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): self.cmb_hoop = QComboBox() self.cmb_hoop.setMinimumWidth(200) for h in HOOPS: - self.cmb_hoop.addItem(f"{h.name} ({h.width:.0f}×{h.height:.0f} mm)", h) + self.cmb_hoop.addItem(self._hoop_label(h), h) self.cmb_hoop.setCurrentIndex(2) # Rund 15 cm Standard self.cmb_hoop.currentIndexChanged.connect(self._on_hoop_changed) ctx_hb.addWidget(self.cmb_hoop) @@ -272,13 +279,13 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): btn_fit.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) btn_fit.setText("Auto-Fit") self._bind(btn_fit.setText, "btn_autofit") - btn_fit.setToolTip("Design optimal in den gewählten Rahmen einpassen (Ctrl+F)") + self._bind(btn_fit.setToolTip, "tip_autofit") btn_fit.clicked.connect(self.action_fit_to_hoop) ctx_hb.addWidget(btn_fit) self.btn_fabric = QToolButton() - self.btn_fabric.setText("Stoff: Dunkel") - self.btn_fabric.setToolTip("Stoffhintergrund im Stickrahmen umschalten (Dunkel-Schiefer / Warm-Sand / Hell)") + self.btn_fabric.setText(tr("fabric_slate")) + self._bind(self.btn_fabric.setToolTip, "tip_fabric") self.btn_fabric.clicked.connect(self.cycle_fabric_theme) ctx_hb.addWidget(self.btn_fabric) @@ -295,7 +302,7 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): self.cmb_lang.currentIndexChanged.connect(self._on_language_changed) ctx_hb.addWidget(self.cmb_lang) ctx_hb.addSpacing(6) - self.lbl_hoop_strategy = QLabel("Strategie: Ausgewogen · 100% Zugkompensation") + self.lbl_hoop_strategy = QLabel() # Text setzt retranslate_ui() self.lbl_hoop_strategy.setStyleSheet( "color: #81c995; background-color: rgba(129, 201, 149, 0.12); " "border: 1px solid rgba(129, 201, 149, 0.28); border-radius: 5px; padding: 3px 8px; font-size: 8.5pt;" @@ -313,7 +320,8 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): self._rebuild_timer = QTimer(self) self._rebuild_timer.setSingleShot(True) self._rebuild_timer.setInterval(120) - self._rebuild_timer.timeout.connect(self.rebuild_plan) + self._rebuild_timer.timeout.connect(lambda: self.rebuild_plan(refresh_props=False)) + self._last_edit_key = None self.sim_timer = QTimer(self) self.sim_timer.setInterval(25) @@ -350,18 +358,34 @@ def _apply_binding(setter, key, escape, fmt): txt = fmt(txt) setter(txt) + def _update_strategy_label(self): + if hasattr(self, "lbl_hoop_strategy"): + self.lbl_hoop_strategy.setText( + f"{tr('lbl_strategy')}{tr('label_colon')} {self.canvas.design.hoop.strategy_description}" + ) + + @staticmethod + def _hoop_label(h: Hoop) -> str: + return f"{h.display_name} ({h.width:.0f}×{h.height:.0f} mm)" + def retranslate_ui(self): for setter, key, escape, fmt in self._tr_bindings: self._apply_binding(setter, key, escape, fmt) + for i in range(self.cmb_hoop.count()): + self.cmb_hoop.setItemText(i, self._hoop_label(self.cmb_hoop.itemData(i))) + for i in range(self.cmb_machine.count()): + self.cmb_machine.setItemText(i, machine_display_name(self.cmb_machine.itemData(i).name)) + self._refresh_object_list() + total = self.canvas.plan.stitch_count + if total: # Zahlenformat des Simulator-Zählers an die Sprache anpassen + cur = total if self.canvas.sim_limit is None else self.canvas.sim_limit + self.lbl_sim.setText(f"{fmt_int(cur)} / {fmt_int(total)}") lbl = {"slate": "fabric_slate", "sand": "fabric_sand", "light": "fabric_light"} self.btn_fabric.setText(tr(lbl.get(self.canvas.fabric_theme, "fabric_slate"))) self._refresh_stats() self._refresh_needle_list() self._populate_properties(self.canvas.selected) - if hasattr(self, "lbl_hoop_strategy"): - self.lbl_hoop_strategy.setText( - f"{tr('lbl_strategy')}: {self.canvas.design.hoop.strategy_description}" - ) + self._update_strategy_label() self.btn_play.setText(tr("btn_pause") if self.btn_play.isChecked() else tr("btn_play")) self.canvas.update() @@ -426,7 +450,8 @@ def _build_menus_and_header(self): (m_create, "menu_create"), (m_machine, "menu_machine"), (m_help, "menu_help")): self._bind(_m.setTitle, _k, fmt=lambda s: s.replace("&&", "&&")) - tb = QToolBar("Studio-Leiste", self) + tb = QToolBar(tr("tb_title_studio"), self) + self._bind(tb.setWindowTitle, "tb_title_studio") self.top_toolbar = tb tb.setMovable(False) tb.setIconSize(QSize(16, 16)) @@ -514,7 +539,8 @@ def add_act(menu, text, slot, shortcut=None, toolbar_label=None, icon_kind=None, # ================================= 2. Linke Vertikal-Werkzeugleiste (Tool-Rail) def _build_left_tool_rail(self): - rail = QToolBar("Zeichenwerkzeuge", self) + rail = QToolBar(tr("tb_title_tools"), self) + self._bind(rail.setWindowTitle, "tb_title_tools") self.left_rail = rail rail.setMovable(False) rail.setOrientation(Qt.Vertical) @@ -522,12 +548,10 @@ def _build_left_tool_rail(self): rail.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.addToolBar(Qt.LeftToolBarArea, rail) - def rail_btn(label: str, icon_kind: str, tip: str, slot, checkable: bool = True) -> QAction: - a = QAction(studio_icon(icon_kind, 17), label, self) - _k = key_for(label) - if _k: - self._bind(a.setText, _k, escape=True) - a.setToolTip(tip) + def rail_btn(key: str, icon_kind: str, tip_key: str, slot, checkable: bool = True) -> QAction: + a = QAction(studio_icon(icon_kind, 17), tr(key), self) + self._bind(a.setText, key, escape=True) + self._bind(a.setToolTip, tip_key) a.setCheckable(checkable) a.triggered.connect(slot) rail.addAction(a) @@ -536,15 +560,15 @@ def rail_btn(label: str, icon_kind: str, tip: str, slot, checkable: bool = True) w_btn.setMinimumWidth(116) return a - self.act_tool_select = rail_btn("Auswahl (V)", "select", "Objekte auswählen, verschieben & skalieren (Taste V)", lambda: self._set_tool("select")) - self.act_tool_nodes = rail_btn("Knoten (N)", "nodes", "Konturknoten & Stichwinkel-Achse direkt ziehen (Taste N)", lambda: self._set_tool("edit_nodes")) + self.act_tool_select = rail_btn("tool_select", "select", "tip_tool_select", lambda: self._set_tool("select")) + self.act_tool_nodes = rail_btn("tool_nodes", "nodes", "tip_tool_nodes", lambda: self._set_tool("edit_nodes")) rail.addSeparator() - self.act_tool_fill = rail_btn("Tatami (F)", "fill", "Neue Tatami-Füllfläche auf der Leinwand zeichnen (Taste F)", lambda: self._set_tool("draw_fill")) - self.act_tool_satin = rail_btn("Satin (S)", "satin", "Neue Satin-Säule auf der Leinwand zeichnen (Taste S)", lambda: self._set_tool("draw_satin")) - self.act_tool_run = rail_btn("Stepp (R)", "run", "Neuen Steppstich-Pfad zeichnen (Taste R)", lambda: self._set_tool("draw_run")) + self.act_tool_fill = rail_btn("tool_fill", "fill", "tip_tool_fill", lambda: self._set_tool("draw_fill")) + self.act_tool_satin = rail_btn("tool_satin", "satin", "tip_tool_satin", lambda: self._set_tool("draw_satin")) + self.act_tool_run = rail_btn("tool_run", "run", "tip_tool_run", lambda: self._set_tool("draw_run")) rail.addSeparator() - self.act_rail_heatmap = rail_btn("Heatmap", "heatmap", "Stichdichte-Heatmap (Grün/Gelb/Rot) ein-/ausblenden (Ctrl+H)", self._toggle_heatmap_from_rail) - rail_btn("Zoom 100%", "zoom", "Auf Rahmengrösse zoomen (Ctrl+0)", self.canvas.zoom_to_fit, checkable=False) + self.act_rail_heatmap = rail_btn("tool_heatmap", "heatmap", "tip_tool_heatmap", self._toggle_heatmap_from_rail) + rail_btn("tool_zoom", "zoom", "tip_tool_zoom", self.canvas.zoom_to_fit, checkable=False) self.act_tool_select.setChecked(True) def _toggle_heatmap_from_rail(self, checked: bool): @@ -644,10 +668,10 @@ def _build_right_dock(self): hb = QHBoxLayout() btn_up = QPushButton("▲") - btn_up.setToolTip("Objekt in der Stickreihenfolge nach oben") + self._bind(btn_up.setToolTip, "tip_obj_up") btn_up.clicked.connect(lambda: self._move_selected_object(-1)) btn_dn = QPushButton("▼") - btn_dn.setToolTip("Objekt in der Stickreihenfolge nach unten") + self._bind(btn_dn.setToolTip, "tip_obj_down") btn_dn.clicked.connect(lambda: self._move_selected_object(1)) btn_vis = QPushButton("👁 Sichtbar") self._bind(btn_vis.setText, "btn_visible") @@ -656,7 +680,7 @@ def _build_right_dock(self): self._bind(btn_dup.setText, "btn_copy") btn_dup.clicked.connect(self.action_duplicate_selected) btn_del = QPushButton("🗑") - btn_del.setToolTip("Ausgewähltes Objekt löschen (Entf)") + self._bind(btn_del.setToolTip, "tip_obj_delete") btn_del.clicked.connect(self.action_delete_selected) for b in (btn_up, btn_dn, btn_vis, btn_dup, btn_del): hb.addWidget(b) @@ -713,7 +737,7 @@ def _build_bottom_bar(self): self.slider_sim.valueChanged.connect(self._on_sim_slider) hb.addWidget(self.slider_sim, 1) - self.lbl_sim = QLabel("Alle") + self.lbl_sim = QLabel(tr("sim_all")) self.lbl_sim.setMinimumWidth(115) hb.addWidget(self.lbl_sim) @@ -723,7 +747,7 @@ def _build_bottom_bar(self): self.sp_sim_speed = QSpinBox() self.sp_sim_speed.setRange(5, 500) self.sp_sim_speed.setValue(45) - self.sp_sim_speed.setSuffix(" St./Tick") + self._bind(self.sp_sim_speed.setSuffix, "sim_speed_suffix") hb.addWidget(self.sp_sim_speed) dock.setWidget(w) @@ -760,31 +784,91 @@ def action_show_tour(self): def set_design(self, d: Design, reset_undo: bool = False, prebuilt_plan=None): self.canvas.design = d self.canvas.selected = d.objects[0] if d.objects else None + self.canvas.sim_limit = None + if hasattr(self, "btn_play"): + self.btn_play.setChecked(False) if reset_undo: self._undo_stack.clear() self._redo_stack.clear() + self._dirty = False + else: + self._dirty = True + self._last_edit_key = None + self._sync_hoop_machine_ui() self.rebuild_plan(prebuilt_plan=prebuilt_plan) + def _sync_hoop_machine_ui(self): + """Rahmen-/Maschinen-Auswahl an das (neu geladene) Design anpassen, ohne Signale auszulösen.""" + d = self.canvas.design + if hasattr(self, "cmb_hoop"): + idx = next((i for i in range(self.cmb_hoop.count()) + if self.cmb_hoop.itemData(i).name == d.hoop.name), -1) + if idx >= 0: + # gleiche Instanz verwenden wie die Auswahlliste + d.hoop = self.cmb_hoop.itemData(idx) + self.cmb_hoop.blockSignals(True) + self.cmb_hoop.setCurrentIndex(idx) + self.cmb_hoop.blockSignals(False) + self._update_strategy_label() + if hasattr(self, "cmb_machine"): + idx = next((i for i in range(self.cmb_machine.count()) + if self.cmb_machine.itemData(i).name == d.settings.machine_name), -1) + if idx >= 0: + self.cmb_machine.blockSignals(True) + self.cmb_machine.setCurrentIndex(idx) + self.cmb_machine.blockSignals(False) + def _snapshot(self, desc: str): - self._undo_stack.append((desc, [o.clone() for o in self.canvas.design.objects])) + self._dirty = True + self._last_edit_key = None + self._undo_stack.append((desc, [o.snapshot() for o in self.canvas.design.objects])) if len(self._undo_stack) > 30: self._undo_stack.pop(0) self._redo_stack.clear() - def rebuild_plan(self, prebuilt_plan=None): - self.canvas.plan = prebuilt_plan if prebuilt_plan is not None else self.canvas.design.build_plan() + def rebuild_plan(self, prebuilt_plan=None, refresh_props: bool = True): + """Objektliste/Inspektor sofort aktualisieren, Stiche im Hintergrund neu berechnen.""" + self._refresh_object_list() + if refresh_props: + self._populate_properties(self.canvas.selected) + if prebuilt_plan is not None: + # laufende Berechnungen sind damit überholt + self._applied_gen = self._planner.supersede() + self._apply_plan(prebuilt_plan) + return + self._planner.request(self.canvas.design) + if hasattr(self, "status"): + self.status.showMessage(tr("status_computing")) + + def _on_plan_finished(self, gen: int, plan, err: str): + if gen != self._planner.generation or gen <= self._applied_gen: + return # veraltet – eine neuere Berechnung läuft schon + self._applied_gen = gen + if plan is None: + self.status.showMessage(f"{tr('status_plan_error')}: {err}", 12000) + return + self.status.clearMessage() + self._apply_plan(plan) + + def wait_for_plan(self): + """Wartet auf die laufende Berechnung (vor Export, Prüfung & in Tests).""" + res = self._planner.wait() + if res is not None: + self._on_plan_finished(*res) + return self.canvas.plan + + def _apply_plan(self, plan): + self.canvas.plan = plan n = max(1, self.canvas.plan.stitch_count) self.slider_sim.blockSignals(True) self.slider_sim.setRange(0, n) if self.canvas.sim_limit is None: self.slider_sim.setValue(n) - self.lbl_sim.setText(f"{n:,} / {n:,}".replace(",", "'")) + self.lbl_sim.setText(f"{fmt_int(n)} / {fmt_int(n)}") self.slider_sim.blockSignals(False) - self._refresh_object_list() self._refresh_needle_list() self._refresh_stats() - self._populate_properties(self.canvas.selected) self.canvas.invalidate_render_cache() def _refresh_stats(self): @@ -794,24 +878,23 @@ def _refresh_stats(self): w, h = x1 - x0, y1 - y0 kin = analyze_kinematics(p, self.canvas.design.settings.machine_spm) self.lbl_stats.setText( - f"{tr('stats_stitches')}: {p.stitch_count:,} · " - f"{tr('stats_size')}: {w:.1f} × {h:.1f} mm · " - f"{tr('stats_avg')}: {kin['avg_len']:.1f} mm · " - f"{tr('stats_spm')}: ~{kin['eff_spm']} SPM · " - f"{tr('stats_peak')}: {kin['max_per_mm2']}/mm² · " - f"{tr('stats_colors')}: {p.color_changes} · " - f"Trims: {p.trim_count} · " - f"{tr('stats_runtime')} {kin['real_minutes']:.1f} {tr('stats_min')}".replace(",", "'") + f"{tr('stats_stitches')}{tr('label_colon')} {fmt_int(p.stitch_count)} · " + f"{tr('stats_size')}{tr('label_colon')} {w:.1f} × {h:.1f} mm · " + f"{tr('stats_avg')}{tr('label_colon')} {kin['avg_len']:.1f} mm · " + f"{tr('stats_spm')}{tr('label_colon')} ~{kin['eff_spm']} SPM · " + f"{tr('stats_peak')}{tr('label_colon')} {kin['max_per_mm2']}/mm² · " + f"{tr('stats_colors')}{tr('label_colon')} {p.color_changes} · " + f"{tr('stats_trims')}{tr('label_colon')} {p.trim_count} · " + f"{tr('stats_runtime')} {kin['real_minutes']:.1f} {tr('stats_min')}" ) def _refresh_object_list(self): self.list_objects.blockSignals(True) self.list_objects.clear() - kind_label = {"fill": "Tatami", "satin": "Satin", "run": "Stepp", "text": "Schrift", "stitches": "Stiche"} sel_row = -1 for i, o in enumerate(self.canvas.design.objects): - vis = "" if o.visible else "[Aus] " - kl = kind_label.get(o.kind, o.kind) + vis = "" if o.visible else f"{tr('obj_hidden')} " + kl = tr_opt(f"kind_{o.kind}", o.kind) it = QListWidgetItem(_color_icon(o.thread.color), f"{i + 1}. {vis}{kl} · {o.name or o.thread.name}") self.list_objects.addItem(it) if o is self.canvas.selected: @@ -833,7 +916,8 @@ def _refresh_needle_list(self): cnt = sum(1 for s in b.stitches if s.cmd == 0) it = QListWidgetItem( _color_icon(b.thread.color), - f"#{bi + 1} ({tr('needle')} {nd}): {b.thread.name or b.thread.hex} · {cnt} St.{warn}", + f"#{bi + 1} ({tr('needle')} {nd}): {b.thread.name or b.thread.hex} · " + f"{fmt_int(cnt)} {tr('abbr_stitches')}{warn}", ) self.list_needles.addItem(it) @@ -854,22 +938,26 @@ def _populate_properties(self, obj: EmbObject | None): ed_name = QLineEdit(obj.name) ed_name.setCursorPosition(0) ed_name.editingFinished.connect(lambda: self._set_obj_attr(obj, "name", ed_name.text())) - self.prop_layout.addRow(tr("lbl_obj_name") + ":", ed_name) + colon = tr("label_colon") # fr: geschütztes Leerzeichen vor dem Doppelpunkt + self.prop_layout.addRow(tr("lbl_obj_name") + colon, ed_name) + kind = getattr(obj, "kind", "") for attr, label, typ, vmin, vmax, step in getattr(obj, "PARAMS", ()): val = getattr(obj, attr) + # Labels werden erst bei der Anzeige übersetzt (PARAMS enthält das deutsche Original) + label = tr_opt(f"param_{kind}_{attr}", tr_opt(f"param_{attr}", label)) if typ is bool: cb = QCheckBox() cb.setChecked(bool(val)) cb.toggled.connect(lambda v, a=attr: self._set_obj_attr(obj, a, v)) - self.prop_layout.addRow(label + ":", cb) + self.prop_layout.addRow(label + colon, cb) elif typ is int: sp = QSpinBox() sp.setRange(int(vmin), int(vmax)) sp.setSingleStep(int(step or 1)) sp.setValue(int(val)) sp.valueChanged.connect(lambda v, a=attr: self._set_obj_attr(obj, a, int(v))) - self.prop_layout.addRow(label + ":", sp) + self.prop_layout.addRow(label + colon, sp) elif typ is float: sp = QDoubleSpinBox() sp.setDecimals(2) @@ -877,30 +965,34 @@ def _populate_properties(self, obj: EmbObject | None): sp.setSingleStep(float(step or 0.1)) sp.setValue(float(val)) sp.valueChanged.connect(lambda v, a=attr: self._set_obj_attr(obj, a, float(v))) - self.prop_layout.addRow(label + ":", sp) + self.prop_layout.addRow(label + colon, sp) elif typ is str: ed = QLineEdit(str(val)) ed.editingFinished.connect(lambda e=ed, a=attr: self._set_obj_attr(obj, a, e.text())) - self.prop_layout.addRow(label + ":", ed) + self.prop_layout.addRow(label + colon, ed) elif isinstance(typ, list): cmb = QComboBox() for opt in typ: - cmb.addItem(str(opt), opt) + # Datenwert bleibt unverändert (wird gespeichert), nur der Anzeigetext ist übersetzt + cmb.addItem(tr_opt(f"opt_{opt}", str(opt)), opt) if val in typ: cmb.setCurrentIndex(typ.index(val)) cmb.currentIndexChanged.connect(lambda _, c=cmb, a=attr: self._set_obj_attr(obj, a, c.currentData())) - self.prop_layout.addRow(label + ":", cmb) + self.prop_layout.addRow(label + colon, cmb) elif typ == "font": - btn_font = QPushButton("Schriftart & Bogen anpassen …") + btn_font = QPushButton(tr("btn_font_arc")) btn_font.clicked.connect(lambda: self._edit_text_object(obj)) - self.prop_layout.addRow(label + ":", btn_font) + self.prop_layout.addRow(label + colon, btn_font) self._updating_props = False def _set_obj_attr(self, obj: EmbObject, attr: str, value): - if self._updating_props: + if self._updating_props or getattr(obj, attr, None) == value: return - self._snapshot(f"{attr} ändern") + key = (obj.id, attr) + if self._last_edit_key != key: + self._snapshot(f"{attr} ändern") + self._last_edit_key = key setattr(obj, attr, value) self._rebuild_timer.start() @@ -910,6 +1002,10 @@ def _edit_text_object(self, obj: TextObject): self._snapshot("Text bearbeiten") new_o = dlg.created_object new_o.transform = obj.transform + new_o.name = obj.name or new_o.name + new_o.visible = obj.visible + if hasattr(obj, "pull_comp") and hasattr(new_o, "pull_comp"): + new_o.pull_comp = obj.pull_comp idx = self.canvas.design.objects.index(obj) self.canvas.design.objects[idx] = new_o self.canvas.selected = new_o @@ -938,12 +1034,11 @@ def _on_hoop_changed(self): self.canvas.design.hoop = self.cmb_hoop.currentData() if self.canvas.design.hoop.mode == "cap": self._apply_cap_center_out_order() - if hasattr(self, "lbl_hoop_strategy"): - self.lbl_hoop_strategy.setText(f"{tr('lbl_strategy')}: {self.canvas.design.hoop.strategy_description}") + self._update_strategy_label() self.rebuild_plan() self.canvas.zoom_to_fit() self.status.showMessage( - f"Rahmen-Strategie aktiv: {self.canvas.design.hoop.strategy_description}", 7000 + tr("status_hoop_strategy").format(self.canvas.design.hoop.strategy_description), 7000 ) def _apply_cap_center_out_order(self): @@ -955,7 +1050,10 @@ def _apply_cap_center_out_order(self): def action_auto_select_hoop(self): x0, y0, x1, y1 = self.canvas.design.bounds() w, h = x1 - x0, y1 - y0 - r_need = max(math.hypot(x0, y0), math.hypot(x1, y1), math.hypot(x0, y1), math.hypot(x1, y0)) + # Rundrahmen: echter Radius der Nadelpositionen (Bounding-Box-Ecken überschätzen runde Motive) + plan = self.wait_for_plan() + r_need = max((math.hypot(s.x, s.y) for b in plan.blocks for s in b.stitches if s.cmd in (0, 1)), + default=max(math.hypot(x0, y0), math.hypot(x1, y1), math.hypot(x0, y1), math.hypot(x1, y0))) for idx, hp in enumerate(HOOPS): mg = hp.margin_mm if hp.round and r_need <= (hp.width / 2.0 - mg): @@ -990,7 +1088,7 @@ def action_fit_to_hoop(self): o.height *= s o.letter_spacing *= s o.arc_radius *= s - o.transform = (1, 0, 0, 1, nx, ny) + o.transform = (a, bb, d, e, nx, ny) self.rebuild_plan() self.canvas.zoom_to_fit() @@ -1012,7 +1110,7 @@ def _apply_thread(self, t: Thread): self.rebuild_plan() def _pick_custom_color(self): - col = QColorDialog.getColor(QColor(*self.canvas.active_thread.color), self, "Garnfarbe wählen") + col = QColorDialog.getColor(QColor(*self.canvas.active_thread.color), self, tr("color_dialog_title")) if not col.isValid(): return t = nearest_thread((col.red(), col.green(), col.blue())) @@ -1030,8 +1128,34 @@ def _on_object_row_changed(self, row: int): self.canvas.update() def _on_design_modified(self, desc: str): + self._dirty = True self.rebuild_plan() + def _confirm_discard(self) -> bool: + """Fragt nach, bevor ungespeicherte Änderungen verworfen werden. True = weitermachen.""" + if not self._dirty or not self.isVisible(): + return True + box = QMessageBox( + QMessageBox.Question, tr("dlg_unsaved_title"), tr("dlg_unsaved_text"), + QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, self, + ) + box.setDefaultButton(QMessageBox.Save) + for std, key in ((QMessageBox.Save, "btn_save"), (QMessageBox.Discard, "btn_discard"), + (QMessageBox.Cancel, "btn_cancel")): + box.button(std).setText(tr(key)) + box.exec() + ans = box.standardButton(box.clickedButton()) + if ans == QMessageBox.Save: + return self.action_save_project() + return ans == QMessageBox.Discard + + def closeEvent(self, event): + if not self._confirm_discard(): + event.ignore() + return + self._planner.wait(5.0) + super().closeEvent(event) + def _on_cursor_moved(self, x: float, y: float): self.lbl_cursor.setText(f"X: {x:6.1f} mm, Y: {y:6.1f} mm") @@ -1050,6 +1174,7 @@ def _move_selected_object(self, delta: int): def _toggle_selected_visibility(self): obj = self.canvas.selected if obj is not None: + self._snapshot("Sichtbarkeit") obj.visible = not obj.visible self.rebuild_plan() @@ -1138,12 +1263,14 @@ def dropEvent(self, event: QDropEvent): return self._remember_dir(path) ext = os.path.splitext(path)[1].lower().lstrip(".") + if (ext == "ostitch" or ext == "svg" or ext in READ_EXT) and not self._confirm_discard(): + return if ext == "ostitch": try: self.set_design(load_project(path), reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "Fehler beim Öffnen", str(exc)) + QMessageBox.critical(self, tr("err_open_title"), str(exc)) elif ext == "svg": try: d = import_svg(path, target_width_mm=95.0) @@ -1151,42 +1278,59 @@ def dropEvent(self, event: QDropEvent): self.set_design(d, reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "SVG-Import-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_svg_title"), str(exc)) elif ext in READ_EXT: try: d = import_file(path) self.set_design(d, reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "Import-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_import_title"), str(exc)) elif ext in ("png", "jpg", "jpeg", "bmp", "webp", "tif", "tiff", "gif"): self._open_auto_digitize_for_path(path) # -------------------------------------------------------- Datei-Aktionen def action_new(self): - self.set_design(Design(name="Neues_Design"), reset_undo=True) + if not self._confirm_discard(): + return + self.set_design(Design(name=tr("default_design_name")), reset_undo=True) def action_open_project(self): - path = self._ask_open_file("Projekt öffnen", "OpenStitch-Projekt (*.ostitch);;Alle Dateien (*.*)") + if not self._confirm_discard(): + return + path = self._ask_open_file( + tr("fd_open_project"), f"{tr('filter_project')} (*.ostitch);;{tr('filter_all_files')} (*.*)" + ) if not path: return try: self.set_design(load_project(path), reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "Fehler beim Öffnen", str(exc)) + QMessageBox.critical(self, tr("err_open_title"), str(exc)) def action_save_project(self): path = self._ask_save_file( - "Projekt speichern", f"{self.canvas.design.name}.ostitch", "OpenStitch-Projekt (*.ostitch)" + tr("fd_save_project"), f"{self.canvas.design.name}.ostitch", f"{tr('filter_project')} (*.ostitch)" ) if not path: - return - save_project(self.canvas.design, path) + return False + try: + save_project(self.canvas.design, path) + except Exception as exc: + QMessageBox.critical(self, tr("err_save_title"), str(exc)) + return False + self._dirty = False + self.status.showMessage(tr("status_saved").format(path), 5000) + return True def action_import_embroidery(self): + if not self._confirm_discard(): + return exts = " ".join(f"*.{e} *.{e.upper()}" for e in READ_EXT) - path = self._ask_open_file("Stickdatei importieren", f"Stickdateien ({exts});;Alle Dateien (*.*)") + path = self._ask_open_file( + tr("fd_import_emb"), f"{tr('filter_emb')} ({exts});;{tr('filter_all_files')} (*.*)" + ) if not path: return try: @@ -1194,10 +1338,14 @@ def action_import_embroidery(self): self.set_design(d, reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "Import-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_import_title"), str(exc)) def action_import_svg(self): - path = self._ask_open_file("SVG-Vektorgrafik importieren", "SVG-Vektordatei (*.svg);;Alle Dateien (*.*)") + if not self._confirm_discard(): + return + path = self._ask_open_file( + tr("fd_import_svg"), f"{tr('filter_svg')} (*.svg);;{tr('filter_all_files')} (*.*)" + ) if not path: return try: @@ -1206,14 +1354,12 @@ def action_import_svg(self): self.set_design(d, reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "SVG-Import-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_svg_title"), str(exc)) def action_make_applique(self): obj = self.canvas.selected if obj is None or obj.kind not in ("fill", "satin"): - QMessageBox.information( - self, "Applikation (Appliqué)", "Bitte zuerst eine Fläche auf der Leinwand auswählen." - ) + QMessageBox.information(self, tr("msg_applique_title"), tr("msg_applique_text")) return app_objs = create_applique_objects(obj) if not app_objs: @@ -1225,12 +1371,13 @@ def action_make_applique(self): self.rebuild_plan() def action_quality_doctor(self): + self.wait_for_plan() issues = run_quality_doctor( self.canvas.design, self.canvas.plan, max_needles=self.canvas.design.settings.needles ) QMessageBox.information( self, - "Qualitäts- & Maschinen-Doktor", + tr("msg_doctor_title"), "\n\n".join(issues), ) @@ -1239,10 +1386,11 @@ def action_export_embroidery(self): ext_default = mp.native_ext.upper() if mp else "EXP" filters = ";;".join(f"{FORMAT_NAMES.get(e, e.upper())} (*.{e})" for e in WRITE_EXT) path = self._ask_save_file( - "Stickdatei exportieren", f"{self.canvas.design.name}.{ext_default}", filters + tr("fd_export_emb"), f"{self.canvas.design.name}.{ext_default}", filters ) if not path: return + self.wait_for_plan() try: export_plan(self.canvas.plan, path) ext = os.path.splitext(path)[1].lower().lstrip(".") @@ -1250,44 +1398,50 @@ def action_export_embroidery(self): rep = verify_exported_file(path, self.canvas.plan) QMessageBox.information( self, - "Export & Binär-Verifikation erfolgreich", - f"✅ Stickdatei gespeichert und im Simulator verifiziert:\n{path}\n\n" - f"• Dateigrösse: {rep['bytes']:,} Bytes\n" - f"• Verifizierte Nähstiche: {rep['stitches']:,}\n" - f"• Abmessung: {rep['width_mm']:.1f} × {rep['height_mm']:.1f} mm\n" - f"• Max. Schrittweite: {rep['max_step_mm']:.1f} mm\n" - f"• Farbwechsel: {rep['color_changes']} · Sprünge: {rep['jumps']}".replace(",", "'"), + tr("msg_export_verified_title"), + tr("msg_export_verified_text").format( + path=path, size=fmt_int(rep["bytes"]), stitches=fmt_int(rep["stitches"]), + w=rep["width_mm"], h=rep["height_mm"], step=rep["max_step_mm"], + colors=rep["color_changes"], jumps=rep["jumps"], + ), ) else: - QMessageBox.information(self, "Export erfolgreich", f"Datei wurde gespeichert:\n{path}") + QMessageBox.information(self, tr("msg_export_ok_title"), tr("msg_export_ok_text").format(path)) except Exception as exc: - QMessageBox.critical(self, "Export-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_export_title"), str(exc)) def action_export_pdf(self): path = self._ask_save_file( - "Produktionsblatt (PDF) speichern", f"{self.canvas.design.name}_Produktionsblatt.pdf", "PDF (*.pdf)" + tr("fd_save_pdf"), f"{self.canvas.design.name}_{tr('pdf_file_suffix')}.pdf", "PDF (*.pdf)" ) if not path: return - export_worksheet_pdf(self.canvas.design, self.canvas.plan, path) - QMessageBox.information(self, "PDF erstellt", f"Produktionsblatt gespeichert:\n{path}") + self.wait_for_plan() + try: + export_worksheet_pdf(self.canvas.design, self.canvas.plan, path) + except Exception as exc: + QMessageBox.critical(self, tr("err_pdf_title"), str(exc)) + return + QMessageBox.information(self, tr("msg_pdf_ok_title"), tr("msg_pdf_ok_text").format(path)) def action_export_png(self): path = self._ask_save_file( - "3D-Stichvorschau speichern", f"{self.canvas.design.name}.png", "PNG-Bild (*.png)" + tr("fd_save_png"), f"{self.canvas.design.name}.png", f"{tr('filter_png')} (*.png)" ) if not path: return - render_image(self.canvas.plan, px_per_mm=10.0).save(path) + self.wait_for_plan() + if not render_image(self.canvas.plan, px_per_mm=10.0).save(path): + QMessageBox.critical(self, tr("err_png_title"), tr("err_png_text").format(path)) # ---------------------------------------------- Digitalisier-Assistenten def action_auto_digitize(self): path = self._ask_open_file( - "Bild, Grafik oder Logo wählen (Downloads / Bilder / Desktop)", - "Alle Bild- & Vektordateien (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff *.svg);;" - "Rasterbilder (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff);;" - "SVG-Vektorgrafik (*.svg);;" - "Alle Dateien (*.*)", + tr("fd_pick_image"), + f"{tr('filter_all_img_vec')} (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff *.svg);;" + f"{tr('filter_raster')} (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff);;" + f"{tr('filter_svg')} (*.svg);;" + f"{tr('filter_all_files')} (*.*)", ) if not path: return @@ -1298,31 +1452,43 @@ def action_auto_digitize(self): self.set_design(d, reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "SVG-Import-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_svg_title"), str(exc)) return self._open_auto_digitize_for_path(path) def _open_auto_digitize_for_path(self, path: str): - dlg = AutoDigitizeDialog(path, self) + try: + dlg = AutoDigitizeDialog(path, self) + except Exception as exc: + QMessageBox.critical(self, tr("err_image_title"), str(exc)) + return if dlg.exec() == AutoDigitizeDialog.Accepted and dlg.result_data is not None: self._snapshot("Auto-Digitalisieren") res_d = dlg.result_data.design + same_mode = res_d.hoop.mode == self.canvas.design.hoop.mode res_d.hoop = self.canvas.design.hoop - self.set_design(res_d, prebuilt_plan=dlg.result_plan) + res_d.settings = self.canvas.design.settings + # Vorberechneter Plan gilt nur, wenn die Rahmen-Strategie identisch ist + self.set_design(res_d, prebuilt_plan=dlg.result_plan if same_mode else None) self.canvas.zoom_to_fit() def action_photostitch(self): path = self._ask_open_file( - "Porträt- oder Landschaftsfoto wählen (Downloads / Bilder / Desktop)", - "Fotos & Bilder (*.png *.jpg *.jpeg *.webp *.bmp *.tif *.tiff);;Alle Dateien (*.*)", + tr("fd_pick_photo"), + f"{tr('filter_photos')} (*.png *.jpg *.jpeg *.webp *.bmp *.tif *.tiff);;{tr('filter_all_files')} (*.*)", ) if not path: return - dlg = PhotoStitchDialog(path, self) + try: + dlg = PhotoStitchDialog(path, self) + except Exception as exc: + QMessageBox.critical(self, tr("err_image_title"), str(exc)) + return if dlg.exec() == PhotoStitchDialog.Accepted and dlg.result_design is not None: self._snapshot("PhotoStitch") res_d = dlg.result_design res_d.hoop = self.canvas.design.hoop + res_d.settings = self.canvas.design.settings self.set_design(res_d) self.canvas.zoom_to_fit() @@ -1335,7 +1501,7 @@ def action_toggle_3d_puff(self): return satins = [o for o in self.canvas.design.objects if isinstance(o, SatinObject)] if not satins: - QMessageBox.information(self, "3D-Puff-Schaumstoff", "Bitte wähle ein Satin-Objekt aus.") + QMessageBox.information(self, tr("msg_puff_title"), tr("msg_puff_text")) return self._snapshot("3D-Puff für alle Satin-Objekte") new_state = not satins[0].puff_3d @@ -1352,19 +1518,17 @@ def action_add_text(self): self.rebuild_plan() def action_melco_disk(self): + self.wait_for_plan() dlg = MelcoDiskDialog(self.canvas.plan, self.canvas.design.name, self) dlg.exec() def action_optimize_color_order(self): - self._snapshot("Farbwechsel optimieren") objs = self.canvas.design.objects - grouped: list[EmbObject] = [] - seen_hex: list[str] = [] - for o in objs: - if o.thread.hex not in seen_hex: - seen_hex.append(o.thread.hex) - for hx in seen_hex: - grouped.extend([o for o in objs if o.thread.hex == hx]) + # Nur umsortieren, wo sich nichts überdeckt – die Stapelreihenfolge bleibt sichtbar gleich + grouped = color_sorted(objs) + if [o.id for o in grouped] == [o.id for o in objs]: + return + self._snapshot("Farbwechsel optimieren") self.canvas.design.objects = grouped self.rebuild_plan() @@ -1373,18 +1537,22 @@ def action_undo(self): if not self._undo_stack: return desc, prev_objs = self._undo_stack.pop() - self._redo_stack.append((desc, [o.clone() for o in self.canvas.design.objects])) + self._redo_stack.append((desc, [o.snapshot() for o in self.canvas.design.objects])) + self._last_edit_key = None + sel_id = self.canvas.selected.id if self.canvas.selected is not None else None self.canvas.design.objects = prev_objs - self.canvas.selected = prev_objs[0] if prev_objs else None + self.canvas.selected = next((o for o in prev_objs if o.id == sel_id), prev_objs[0] if prev_objs else None) self.rebuild_plan() def action_redo(self): if not self._redo_stack: return desc, next_objs = self._redo_stack.pop() - self._undo_stack.append((desc, [o.clone() for o in self.canvas.design.objects])) + self._undo_stack.append((desc, [o.snapshot() for o in self.canvas.design.objects])) + self._last_edit_key = None + sel_id = self.canvas.selected.id if self.canvas.selected is not None else None self.canvas.design.objects = next_objs - self.canvas.selected = next_objs[0] if next_objs else None + self.canvas.selected = next((o for o in next_objs if o.id == sel_id), next_objs[0] if next_objs else None) self.rebuild_plan() def action_duplicate_selected(self): @@ -1418,9 +1586,16 @@ def action_center_design(self): def action_scale_design(self): from PySide6.QtWidgets import QInputDialog - pct, ok = QInputDialog.getDouble( - self, "Design skalieren (Stiche werden neu berechnet!)", "Skalierungsfaktor (%):", 100.0, 20.0, 400.0, 1 - ) + dlg = QInputDialog(self) + dlg.setInputMode(QInputDialog.DoubleInput) + dlg.setWindowTitle(tr("dlg_scale_title")) + dlg.setLabelText(tr("dlg_scale_label")) + dlg.setDoubleDecimals(1) + dlg.setDoubleRange(20.0, 400.0) + dlg.setDoubleValue(100.0) + dlg.setCancelButtonText(tr("btn_cancel")) + ok = dlg.exec() == QInputDialog.Accepted + pct = dlg.doubleValue() if not ok or abs(pct - 100.0) < 1e-3: return self._snapshot("Design skalieren") @@ -1432,7 +1607,7 @@ def action_scale_design(self): o.height *= s o.letter_spacing *= s o.arc_radius *= s - o.transform = (1, 0, 0, 1, x * s, y * s) + o.transform = (a, b, d, e, x * s, y * s) self.rebuild_plan() # ------------------------------------------------------------ Simulator @@ -1458,21 +1633,21 @@ def _sim_step(self): self.slider_sim.blockSignals(True) self.slider_sim.setValue(cur) self.slider_sim.blockSignals(False) - self.lbl_sim.setText(f"{cur:,} / {total:,}".replace(",", "'")) - self.canvas.invalidate_render_cache() + self.lbl_sim.setText(f"{fmt_int(cur)} / {fmt_int(total)}") + self.canvas.update() # sim_limit ist Teil des Cache-Schlüssels def _on_sim_slider(self, val: int): total = self.canvas.plan.stitch_count if val >= total: self.canvas.sim_limit = None - self.lbl_sim.setText(f"{total:,} / {total:,}".replace(",", "'")) + self.lbl_sim.setText(f"{fmt_int(total)} / {fmt_int(total)}") else: self.canvas.sim_limit = val - self.lbl_sim.setText(f"{val:,} / {total:,}".replace(",", "'")) - self.canvas.invalidate_render_cache() + self.lbl_sim.setText(f"{fmt_int(val)} / {fmt_int(total)}") + self.canvas.update() # sim_limit ist Teil des Cache-Schlüssels def _reset_sim(self): self.btn_play.setChecked(False) self.canvas.sim_limit = None self.slider_sim.setValue(self.canvas.plan.stitch_count) - self.canvas.invalidate_render_cache() + self.canvas.update() # sim_limit ist Teil des Cache-Schlüssels diff --git a/openstitch/ui/plan_worker.py b/openstitch/ui/plan_worker.py new file mode 100644 index 0000000..f0fb6c8 --- /dev/null +++ b/openstitch/ui/plan_worker.py @@ -0,0 +1,84 @@ +"""Stichplan-Berechnung im Hintergrund-Thread, damit die Oberfläche beim Editieren flüssig bleibt. + +Es läuft höchstens ein Rechen-Thread gleichzeitig (der Objekt-Cache des Designs wird so nie +parallel benutzt). Kommen während einer Berechnung neue Änderungen, wird danach genau einmal +mit dem neuesten Stand nachgerechnet – Zwischenstände werden übersprungen. +""" +from __future__ import annotations + +import copy +import threading + +from PySide6.QtCore import QObject, Signal + +from ..core.design import Design + + +class PlanBuilder(QObject): + # (Generation, StitchPlan | None, Fehlertext) + finished = Signal(int, object, str) + + def __init__(self, parent=None): + super().__init__(parent) + self._lock = threading.Lock() + self._thread: threading.Thread | None = None + self._pending: tuple[int, Design] | None = None + self._result: tuple[int, object, str] | None = None + self.generation = 0 + + @staticmethod + def _detach(design: Design) -> Design: + """Flache Kopien der Objekte: Parameter/Geometrien sind unveränderlich bzw. werden nur + ersetzt, so kann der Haupt-Thread weiter editieren, ohne die Berechnung zu stören.""" + return Design(objects=[copy.copy(o) for o in design.objects], hoop=design.hoop, + settings=copy.copy(design.settings), name=design.name, _cache=design._cache) + + def request(self, design: Design) -> int: + with self._lock: + self.generation += 1 + self._pending = (self.generation, self._detach(design)) + if self._thread is None: + self._start_locked() + return self.generation + + def supersede(self) -> int: + """Markiert alle laufenden/angeforderten Berechnungen als veraltet.""" + with self._lock: + self.generation += 1 + self._pending = None + return self.generation + + def busy(self) -> bool: + with self._lock: + return self._thread is not None + + def _start_locked(self): + gen, d = self._pending + self._pending = None + self._thread = threading.Thread(target=self._run, args=(gen, d), daemon=True, + name="openstitch-plan") + self._thread.start() + + def _run(self, gen: int, d: Design): + try: + plan, err = d.build_plan(), "" + except Exception as exc: # Fehler im Stichgenerator dürfen die App nicht abstürzen lassen + plan, err = None, f"{type(exc).__name__}: {exc}" + with self._lock: + self._result = (gen, plan, err) + self._thread = None + if self._pending is not None: + self._start_locked() + self.finished.emit(gen, plan, err) + + def wait(self, timeout: float | None = None) -> tuple[int, object, str] | None: + """Blockiert, bis alle angeforderten Berechnungen fertig sind (für Export & Tests).""" + while True: + with self._lock: + t = self._thread + if t is None and self._pending is None: + return self._result + if t is not None: + t.join(timeout) + if timeout is not None and t.is_alive(): + return None diff --git a/pyproject.toml b/pyproject.toml index e46deb0..10b6200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openstitch" -version = "0.2.0" +version = "0.3.0" description = "OpenStitch Studio — Universal Open-Source Embroidery & Digitizing Suite (DST, PES, EXP, JEF, VP3, U01)" readme = "README.md" license = {text = "AGPL-3.0-or-later"} diff --git a/tests/test_fat12.py b/tests/test_fat12.py new file mode 100644 index 0000000..097586e --- /dev/null +++ b/tests/test_fat12.py @@ -0,0 +1,119 @@ +"""Tests für das FAT12-Diskettenabbild (8.3-Namen, Transaktionen, defekte Abbilder).""" +import struct + +import pytest + +from openstitch.io.fat12 import CLUSTERS, IMAGE_SIZE, ROOT_ENTRIES, ROOT_START, SECTOR, Fat12Image, dos_name + + +def test_dos_name_extension_and_chars(): + assert dos_name("mein_logo.exp") == "MEIN_LOG.EXP" + assert dos_name("Logo v1.5") == "LOGOV15.EXP" # ".5" ist keine Endung + assert dos_name("Logo v1.5.dst") == "LOGOV15.DST" # nur der letzte Punkt zählt + assert dos_name("Größe:*?.exp") == "GROESSE.EXP" + assert dos_name("design") == "DESIGN.EXP" + assert dos_name("x.pes", "DST") == "X.PES" + assert dos_name("noext", "DST") == "NOEXT.DST" + assert dos_name("???.exp") == "DESIGN.EXP" + + +def test_unique_short_names_and_replace(): + img = Fat12Image() + a = img.add_file("Logo_Front_A.exp", b"A" * 700) + b = img.add_file("Logo_Front_B.exp", b"B" * 700) + c = img.add_file("Logo_Front_C.exp", b"C" * 10) + assert (a, b, c) == ("LOGO_FRO.EXP", "LOGO_F~1.EXP", "LOGO_F~2.EXP") + assert [n for n, _ in img.list_files()] == ["LOGO_FRO.EXP", "LOGO_F~1.EXP", "LOGO_F~2.EXP"] + # gleicher Langname ersetzt (auch nach Neuladen des Abbilds) + img = Fat12Image(img.to_bytes()) + free_before = img.free_bytes() + assert img.add_file("Logo_Front_B.exp", b"b" * 100) == "LOGO_F~1.EXP" + assert len(img.list_files()) == 3 + assert img.free_bytes() == free_before + SECTOR # 2 Cluster -> 1 Cluster + assert img.read_file("Logo_Front_B.exp") == b"b" * 100 + assert img.read_file("LOGO_F~1.EXP") == b"b" * 100 + assert img.read_file("Logo_Front_A.exp") == b"A" * 700 + # gleicher 8.3-Name ersetzt ebenfalls + assert img.add_file("LOGO_FRO.EXP", b"x") == "LOGO_FRO.EXP" + assert img.read_file("LOGO_FRO.EXP") == b"x" + assert len(img.list_files()) == 3 + + +def test_delete_accepts_listed_names(): + img = Fat12Image() + img.add_file("Logo_Front_A.exp", b"1") + img.add_file("Logo_Front_B.exp", b"2") + # Eintrag ohne Endung (z. B. von anderem Gerät geschrieben) direkt anlegen + e = bytearray(32) + e[0:11] = b"README " + e[11] = 0x20 + slot = next(i for i in range(ROOT_ENTRIES) if img.data[ROOT_START * SECTOR + 32 * i] == 0) + off = ROOT_START * SECTOR + 32 * slot + img.data[off:off + 32] = e + names = [n for n, _ in img.list_files()] + assert "README" in names + for n in names: + assert img.delete_file(n) is True, n + assert img.list_files() == [] + assert img.free_bytes() == CLUSTERS * SECTOR + # Langname funktioniert weiterhin + img.add_file("Mein langer Name.exp", b"z") + assert img.delete_file("Mein langer Name.exp") is True + assert img.delete_file("gibts_nicht.exp") is False + + +def test_add_file_is_transactional_when_full(): + img = Fat12Image() + img.add_file("keep.exp", b"K" * 1000) + big = b"\x00" * (CLUSTERS * SECTOR + 1) + snap = img.to_bytes() + with pytest.raises(ValueError, match="Nicht genug Platz"): + img.add_file("keep.exp", big) # Ersetzen scheitert -> alte Datei bleibt + assert img.to_bytes() == snap + assert img.read_file("KEEP.EXP") == b"K" * 1000 + + # Verzeichnis voll: 222 weitere Einträge (Slot 0 = Volume-Label, 1 = KEEP.EXP) + for i in range(ROOT_ENTRIES - 2): + img.add_file(f"F{i}.EXP", b"") + snap = img.to_bytes() + with pytest.raises(ValueError, match="Stammverzeichnis voll"): + img.add_file("NEU.EXP", b"neu") + assert img.to_bytes() == snap + # Ersetzen derselben Datei geht weiter (Slot wird wiederverwendet) + img.add_file("F5.EXP", b"neu") + assert img.read_file("F5.EXP") == b"neu" + + +def test_zero_byte_file_allocates_no_cluster(): + img = Fat12Image() + free = img.free_bytes() + assert img.add_file("leer.exp", b"") == "LEER.EXP" + assert img.free_bytes() == free + e = img.data[ROOT_START * SECTOR + 32:ROOT_START * SECTOR + 64] + assert struct.unpack_from(" 3 -> 4 -> 2 … + with pytest.raises(ValueError, match="Schleife"): + img2.read_file("LOOP.EXP") + img2._set_fat(4, 0xEEE) # ungültiger Cluster ausserhalb des Datenbereichs + with pytest.raises(ValueError): + img2.read_file("LOOP.EXP") + assert img2.delete_file("LOOP.EXP") is True # Löschen bleibt robust + assert len(img2.to_bytes()) == IMAGE_SIZE + + +def test_lfn_entries_are_hidden_from_listing(): + img = Fat12Image() + img.add_file("ein sehr langer Dateiname für die Stickmaschine.exp", b"data") + assert img.list_files() == [("EINSEHRL.EXP", 4)] + # Langname-Einträge tragen Attribut 0x0F (für DOS/Melco unsichtbar) + first = ROOT_START * SECTOR + 32 + assert img.data[first + 11] == 0x0F diff --git a/tests/test_i18n_coverage.py b/tests/test_i18n_coverage.py new file mode 100644 index 0000000..0ddb2bc --- /dev/null +++ b/tests/test_i18n_coverage.py @@ -0,0 +1,226 @@ +"""Prüft, dass die Oberfläche vollständig übersetzbar ist (DE/EN/FR/ES).""" +from __future__ import annotations + +import re +import string +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QAbstractButton, QApplication, QComboBox, QDockWidget, QGroupBox, QLabel +from shapely.geometry import LineString, box + +from openstitch import i18n +from openstitch.core.design import HOOP_NAME_KEYS, HOOPS, FillObject, Hoop, RunObject, SatinObject +from openstitch.core.stitchplan import Thread +from openstitch.i18n import _TABLE, fmt_int, set_lang, tr +from openstitch.text import TextObject +from openstitch.ui.main_window import MainWindow + +_app = QApplication.instance() or QApplication(sys.argv) + +SRC = Path(__file__).resolve().parent.parent / "openstitch" + +# Wörter, die nur im deutschen UI vorkommen dürfen (keine Fremd-/Fachwörter, die auch englisch sind) +GERMAN_ONLY = ( + "Stichwinkel", "Unterlage", "Abbrechen", "Rahmen", "Speichern", "Fehler", "Füllung", "Zugkompensation", + "Reihenabstand", "Stichlänge", "Muster", "Dichte", "Bezeichnung", "Buchstabenhöhe", "Zeichenabstand", + "Bogenradius", "Stichart", "Wiederholungen", "Schriftart", "Strategie", "Ausgewogen", "Stiche", + "Fadenschnitte", "Sichtbar", "Kopie", "Fortschritt", "Tempo", "Garnkatalog", "Nadel", "Maschine", + "Stickrahmen", "Sprache", "Auswahl", "Knoten", "Stepp", "Ebenen", "Objekt", "Mittellauf", "Kantenlauf", + "Ziegel", "Wellen", "Automatisch", "Rund ", "Schliessen", "Übernehmen", "Taste", +) + + +def _source_files(): + return [p for p in SRC.rglob("*.py") if "__pycache__" not in p.parts] + + +def test_all_literal_tr_keys_exist(): + tr_re = re.compile(r"""\btr\(\s*(['"])([A-Za-z0-9_+]+)\1\s*\)""") + bind_re = re.compile(r"""\._bind\(\s*[\w.]+\s*,\s*(['"])([A-Za-z0-9_+]+)\1""") + missing = [] + for path in _source_files(): + src = path.read_text(encoding="utf-8") + for rx in (tr_re, bind_re): + for m in rx.finditer(src): + if m.group(2) not in _TABLE: + missing.append(f"{path.relative_to(SRC.parent)}: {m.group(2)}") + assert not missing, "Fehlende Übersetzungsschlüssel:\n" + "\n".join(missing) + + +def test_table_rows_complete_and_consistent(): + fmt = string.Formatter() + for key, row in _TABLE.items(): + assert isinstance(row, tuple) and len(row) == 4, key + assert all(isinstance(s, str) and s.strip() for s in row), key + if key == "pdf_date_fmt": + continue + # Platzhalter müssen in allen Sprachen identisch sein, sonst schlägt .format() fehl + fields = [sorted((f or "#") for _, f, _, _ in fmt.parse(s) if f is not None) for s in row] + assert all(f == fields[0] for f in fields), f"{key}: {fields}" + + +def test_dynamic_keys_exist(): + # Inspektor-Labels & Optionswerte + for cls in (FillObject, SatinObject, RunObject, TextObject): + kind = cls.kind + for attr, _label, typ, *_ in cls.PARAMS: + assert f"param_{kind}_{attr}" in _TABLE or f"param_{attr}" in _TABLE, (cls.__name__, attr) + if isinstance(typ, list): + for opt in typ: + assert f"opt_{opt}" in _TABLE, (cls.__name__, attr, opt) + for kind in ("fill", "satin", "run", "text", "stitches"): + assert f"kind_{kind}" in _TABLE + # Rahmen: jeder Name hat eine Übersetzung, jede Strategie eine Beschreibung + assert {h.name for h in HOOPS} == set(HOOP_NAME_KEYS) + for key in HOOP_NAME_KEYS.values(): + assert key in _TABLE + for h in HOOPS: + assert _TABLE[HOOP_NAME_KEYS[h.name]][0] == h.name # deutscher Anzeigename == gespeicherter Name + assert f"hoop_strat_{h.mode}" in _TABLE + from openstitch.core.machines import MACHINE_PROFILES + for mp in MACHINE_PROFILES: + assert _TABLE[f"machine_{mp.id}"][0] == mp.name + # Fortschrittsmeldungen der Auto-Digitizer-Engine werden im Dialog übersetzt + engine_src = (SRC / "auto" / "digitize.py").read_text(encoding="utf-8") + for msg in re.findall(r'step\("([^"]+)"', engine_src): + assert i18n.key_for(msg) is not None, msg + + +def test_hoop_strategy_and_names_follow_language(): + h = HOOPS[2] + try: + set_lang("en") + assert "pull compensation" in h.strategy_description + assert h.display_name == "Round 15 cm (standard)" + assert Hoop("Eigener Rahmen", 100, 100).display_name == "Eigener Rahmen" + assert Hoop("x", 100, 100, mode="unknown").strategy_description == "Standard" + set_lang("fr") + assert "compensation de traction" in h.strategy_description + finally: + set_lang("de") + assert h.name == "Rund 15 cm (Standard)" + assert "Zugkompensation" in h.strategy_description + + +def test_fmt_int_per_language(): + expected = {"de": "1’234’567", "en": "1,234,567", "fr": "1 234 567", "es": "1.234.567"} + try: + for lang, exp in expected.items(): + set_lang(lang) + assert fmt_int(1234567) == exp + assert fmt_int(999) == "999" + finally: + set_lang("de") + + +def _visible_texts(win: MainWindow) -> list[str]: + texts = [win.windowTitle()] + for w in win.findChildren(QLabel): + texts.append(w.text()) + for w in win.findChildren(QAbstractButton): # QPushButton, QCheckBox, QToolButton + texts.append(w.text()) + texts.append(w.toolTip()) + for w in win.findChildren(QComboBox): + texts.extend(w.itemText(i) for i in range(w.count())) + for w in win.findChildren(QDockWidget): + texts.append(w.windowTitle()) + for a in win.findChildren(QAction): + texts.append(a.text()) + texts.append(a.toolTip()) + for m in win.menuBar().actions(): + texts.append(m.text()) + texts.append(win.sp_sim_speed.suffix()) + texts.extend(win.list_objects.item(i).text() for i in range(win.list_objects.count())) + texts.extend(win.list_needles.item(i).text() for i in range(win.list_needles.count())) + return [t for t in texts if t] + + +def _assert_no_german(texts: list[str], where: str): + hits = sorted({f"{w!r} in {t!r}" for t in texts for w in GERMAN_ONLY if w in t}) + assert not hits, f"Deutscher Text in {where}:\n" + "\n".join(hits) + + +def test_main_window_and_inspector_fully_translated(): + win = MainWindow(show_tour_on_init=False, defer_starter=True) + th = Thread((200, 30, 40), "Red", "1147", "Madeira") + fill = FillObject(thread=th, name="F1", geom=box(-20, -20, 20, 20)) + satin = SatinObject(thread=th, name="S1", geom=LineString([(-20, 30), (20, 30)]).buffer(2.0)) + run = RunObject(thread=th, name="R1", geom=LineString([(-20, -30), (20, -30)])) + text = TextObject(thread=th, name="T1", text="AB", height=12.0) + run.visible = False # "[Aus]"-Markierung in der Objektliste prüfen + win.canvas.design.objects[:] = [fill, satin, run, text] + win.rebuild_plan() + win.wait_for_plan() + try: + idx = next(i for i in range(win.cmb_lang.count()) if win.cmb_lang.itemData(i) == "en") + win.cmb_lang.setCurrentIndex(idx) + assert i18n.current_lang() == "en" + assert win.lbl_hoop_strategy.text().startswith("Strategy: ") + assert "Fit to hoop" in [a.text() for a in win.left_rail.actions()] + for obj in (fill, satin, run, text, None): + win.canvas.selected = obj + win._populate_properties(obj) + _assert_no_german(_visible_texts(win), f"Inspektor ({type(obj).__name__})") + # Optionswerte: Anzeige übersetzt, Daten unverändert + win._populate_properties(satin) + combos = win.prop_container.findChildren(QComboBox) + assert combos and combos[0].itemData(1) == "mitte" and combos[0].itemText(1) == "Center walk" + assert not any("Stitch angle" in t for t in _visible_texts(win)) # Satin hat keinen Stichwinkel + win._populate_properties(fill) + assert any(t.startswith("Stitch angle") for t in _visible_texts(win)) + # Zurück auf Deutsch: Texte wechseln zurück + idx = next(i for i in range(win.cmb_lang.count()) if win.cmb_lang.itemData(i) == "de") + win.cmb_lang.setCurrentIndex(idx) + win._populate_properties(fill) + assert any(t.startswith("Stichwinkel") for t in _visible_texts(win)) + assert win.cmb_hoop.currentText().startswith("Rund 15 cm") + finally: + set_lang("de") + win._dirty = False + win.close() + + +def _dialog_texts(dlg) -> list[str]: + texts = [dlg.windowTitle()] + texts += [w.text() for w in dlg.findChildren(QLabel)] + texts += [w.text() for w in dlg.findChildren(QAbstractButton)] + texts += [w.title() for w in dlg.findChildren(QGroupBox)] + for c in dlg.findChildren(QComboBox): + texts += [c.itemText(i) for i in range(c.count())] + return [t for t in texts if t] + + +def test_dialogs_and_pdf_translated(tmp_path): + import cv2 + import numpy as np + + from openstitch.core.design import Design + from openstitch.io.project import export_worksheet_pdf + from openstitch.ui.dialogs import AutoDigitizeDialog, MelcoDiskDialog, PhotoStitchDialog, TextDialog, _tr_progress + + img = np.full((80, 100, 3), 255, dtype=np.uint8) + cv2.circle(img, (50, 40), 30, (40, 40, 210), -1) + img_path = str(tmp_path / "logo.png") + cv2.imwrite(img_path, img) + th = Thread((200, 30, 40), "Red") + d = Design(objects=[FillObject(thread=th, geom=box(-10, -10, 10, 10))]) + plan = d.build_plan() + try: + for lang in ("en", "fr", "es"): + set_lang(lang) + for dlg in (AutoDigitizeDialog(img_path), PhotoStitchDialog(img_path), TextDialog(th), + MelcoDiskDialog(plan, "TEST")): + _assert_no_german(_dialog_texts(dlg), f"{type(dlg).__name__} ({lang})") + dlg.deleteLater() + # Fortschrittsmeldungen der Engine werden übersetzt + assert _tr_progress("Fläche 3/7 klassifizieren …") == tr("prog_classify").format(3, 7) + assert _tr_progress("Farben reduzieren …") == tr("prog_reduce") + out = tmp_path / f"sheet_{lang}.pdf" + export_worksheet_pdf(d, plan, str(out)) + assert out.stat().st_size > 1000 + finally: + set_lang("de") diff --git a/tests/test_micro_font.py b/tests/test_micro_font.py new file mode 100644 index 0000000..2a497da --- /dev/null +++ b/tests/test_micro_font.py @@ -0,0 +1,147 @@ +"""Tests für die Einstrich-Kleinschrift „OpenStitch Micro“ und den Säulen-Generator.""" +from __future__ import annotations + +import math +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import pytest + +from openstitch.core.design import Design, object_from_dict +from openstitch.core.stitchplan import Cmd, Thread +from openstitch.engine.column import column_runs +from openstitch.fonts_micro import CHARSET, GLYPHS, MICRO_SPEC, glyph +from openstitch.text import TextObject, default_font, list_fonts + +SAMPLE = "Micro 2.5: Äpfel & Öl für 12 € – ß?" + + +def _plan_stats(plan): + longest, nan, prev = 0.0, 0, None + for b in plan.blocks: + prev = None + for s in b.stitches: + if not (math.isfinite(s.x) and math.isfinite(s.y)): + nan += 1 + continue + if s.cmd == Cmd.STITCH: + if prev is not None: + longest = max(longest, math.hypot(s.x - prev[0], s.y - prev[1])) + prev = (s.x, s.y) + elif s.cmd == Cmd.JUMP: + prev = (s.x, s.y) + return longest, nan + + +def test_every_listed_character_has_glyph_data(): + required = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + " .,:;!?-_'\"()/&+@#%*ÄÖÜäöüßÉÈÊÀÇéèêàâçôîïñÑ¿¡") + for ch in required: + assert ch in CHARSET + assert ch in GLYPHS, f"Glyphe fehlt: {ch!r}" + adv, strokes = GLYPHS[ch] + assert adv > 0 + if ch != " ": + assert strokes, f"keine Striche: {ch!r}" + for s in strokes: + assert s and all(math.isfinite(x) and math.isfinite(y) for x, y in s) + # Einheits-Box: Versalhöhe 1.0, kleine Über-/Unterlängen erlaubt + assert all(-0.45 <= y <= 1.45 and -0.2 <= x <= adv + 0.2 for x, y in s), ch + # unbekannte Akzentbuchstaben fallen auf den Grundbuchstaben zurück + assert glyph("ú") is GLYPHS["u"] + + +def test_micro_font_listed_first_and_not_default(): + fonts = list_fonts() + first_name, first_spec = next(iter(fonts.items())) + assert first_name == "OpenStitch Micro" and first_spec == MICRO_SPEC + assert default_font() != MICRO_SPEC + + +@pytest.mark.parametrize("height", [2.5, 8.0]) +def test_micro_text_builds_clean_plan(height): + d = Design() + t = TextObject(Thread((20, 30, 90), "Blau"), text=SAMPLE.replace("€", "EUR"), font=MICRO_SPEC, + height=height, letter_spacing=0.0) + d.objects.append(t) + plan = d.build_plan() + longest, nan = _plan_stats(plan) + assert nan == 0 + assert longest <= 7.0, f"Stich zu lang: {longest:.2f} mm" + n = plan.stitch_count + n_chars = len(SAMPLE.replace(" ", "")) + # grob: 20 – 400 Stiche je Zeichen (Dreifachstich klein, Satin gross) + assert 20 * n_chars < n < 400 * n_chars + # Auswahlrahmen/Trefferprüfung: Geometrie deckt die Schrift ab + x0, y0, x1, y1 = t.bounds() + assert x1 - x0 > height * 5 and y1 - y0 >= height * 0.9 + + +def test_micro_text_respects_transform_and_arc(): + t = TextObject(Thread((0, 0, 0)), text="ARC", font=MICRO_SPEC, height=6.0, arc_radius=30.0) + runs = t.runs() + assert runs and all(len(r) >= 2 for r in runs) + w0 = max(p[0] for r in runs for p in r) - min(p[0] for r in runs for p in r) + t.transform = (2.0, 0.0, 0.0, 2.0, 10.0, 5.0) + xs = [p[0] for r in t.runs() for p in r] + assert abs((max(xs) + min(xs)) / 2 - 10.0) < 1.5 # zentriert um die Verschiebung + assert 1.8 * w0 < max(xs) - min(xs) < 2.3 * w0 # skaliert (inkl. breiterer Säule) + + +def test_column_straight_line_rail_spacing(): + run = column_runs([(0.0, 0.0), (10.0, 0.0)], 1.0, spacing=0.4, pull_comp=0.1, underlay="none")[0] + # Einstiche wechseln die Seite; auf derselben Kante liegen sie `spacing` auseinander + left = [p for p in run if p[1] > 0] + right = [p for p in run if p[1] < 0] + assert abs(len(left) - len(right)) <= 1 + for rail in (left, right): + gaps = [b[0] - a[0] for a, b in zip(rail, rail[1:])] + assert all(abs(g - 0.4) < 0.02 for g in gaps) + # Breite inkl. Zugkompensation + assert all(abs(abs(p[1]) - 0.6) < 1e-6 for p in run) + + +def test_column_underlay_and_bean_fallback(): + with_ul = column_runs([(0, 0), (8, 0)], 1.6, underlay="center")[0] + no_ul = column_runs([(0, 0), (8, 0)], 1.6, underlay="none")[0] + assert len(with_ul) > len(no_ul) + # Unterlage = Mittellauf: beginnt auf der Mittellinie + assert abs(with_ul[0][1]) < 1e-6 + narrow = column_runs([(0, 0), (8, 0)], 0.9, underlay="center")[0] + assert all(abs(abs(p[1]) - 0.55) < 1e-6 for p in narrow) # < 1.0 mm: keine Unterlage + bean = column_runs([(0, 0), (8, 0)], 0.5)[0] + assert all(abs(p[1]) < 1e-9 for p in bean) # < 0.7 mm: Dreifachstich + assert len(bean) > 3 * 8 / 1.0 + + +def test_column_start_chooses_direction(): + run = column_runs([(0, 0), (8, 0)], 1.0, underlay="none", start=(9, 0))[0] + assert run[0][0] > 7.5 and run[-1][0] < 0.5 + + +@pytest.mark.parametrize("opening", [30, 50, 70, 100]) +@pytest.mark.parametrize("width", [0.8, 1.5]) +def test_sharp_v_corner_has_no_needle_pileup(opening, width): + a = math.radians(opening / 2) + V = [(-5 * math.sin(a), -5 * math.cos(a)), (0.0, 0.0), (5 * math.sin(a), -5 * math.cos(a))] + pts = [p for r in column_runs(V, width, 0.4, 0.1, "center") for p in r] + assert pts + # in keinem Kreis von 0.5 mm Durchmesser mehr als 6 Einstiche + worst = max(sum(1 for q in pts if math.dist(p, q) <= 0.25) for p in pts) + assert worst <= 6 + # und die Spitze ist überhaupt gestickt + assert min(math.dist(p, (0, 0)) for p in pts) < width + + +def test_micro_text_save_load_roundtrip(): + t = TextObject(Thread((200, 10, 10), "Rot"), text="Grüße", font=MICRO_SPEC, height=4.0, + letter_spacing=0.2, arc_radius=-40.0, density=0.38) + d = t.to_dict() + assert d["font"] == MICRO_SPEC + t2 = object_from_dict(d) + assert isinstance(t2, TextObject) + assert (t2.text, t2.font, t2.height, t2.letter_spacing, t2.arc_radius, t2.density) == \ + ("Grüße", MICRO_SPEC, 4.0, 0.2, -40.0, 0.38) + assert t2.runs() == t.runs() diff --git a/tests/test_openstitch.py b/tests/test_openstitch.py index 176dd05..46489d1 100644 --- a/tests/test_openstitch.py +++ b/tests/test_openstitch.py @@ -81,6 +81,7 @@ def test_auto_digitizer_and_gui(): assert len(exp_bytes(plan)) > 600 win = MainWindow() + win.wait_for_plan() assert win.canvas.plan.stitch_count > 500 issues = run_quality_doctor(win.canvas.design, win.canvas.plan, max_needles=10) assert len(issues) >= 1 @@ -101,7 +102,7 @@ def test_auto_digitizer_and_gui(): def test_all_machine_formats_binary_verification(tmp_path): """Exportiert ein komplexes Design in alle wichtigen Binärformate und verifiziert sie unabhängig.""" win = MainWindow(show_tour_on_init=False) - plan = win.canvas.plan + plan = win.wait_for_plan() assert plan.stitch_count > 4000 for ext in ("exp", "dst", "pes", "jef", "vp3", "xxx", "u01"): @@ -181,3 +182,208 @@ def test_language_switch_and_thread_catalogs(): assert win.swatch_grid.count() >= 25 set_lang("de") win.close() + + +def _max_stitch_len(plan): + import math + from openstitch.core.stitchplan import Cmd + longest = 0.0 + for b in plan.blocks: + prev = None + for s in b.stitches: + if s.cmd == Cmd.STITCH and prev is not None: + longest = max(longest, math.hypot(s.x - prev[0], s.y - prev[1])) + if s.cmd in (Cmd.STITCH, Cmd.JUMP): + prev = (s.x, s.y) + return longest + + +def test_no_stray_stitches_after_trim(): + """Nach Trim + Sprung darf die Nadel nicht von der alten Position aus weiternähen.""" + t = Thread((200, 30, 30), "Rot") + for obj in (SatinObject(t, geom=LineString([(-30, 0), (30, 0)]).buffer(2.5)), + FillObject(t, geom=Point(0, 0).buffer(30), angle=30)): + d = Design() + d.objects.append(obj) + plan = d.build_plan() + assert plan.trim_count >= 1 + assert _max_stitch_len(plan) <= d.settings.max_stitch + 1e-6 + + +def test_undo_keeps_ids_and_coalesces_spinbox_edits(): + win = MainWindow(show_tour_on_init=False) + d = win.canvas.design + obj = next(o for o in d.objects if o.kind == "fill") + win.canvas.selected = obj + ids_before = [o.id for o in d.objects] + depth = len(win._undo_stack) + # Spinbox-Ziehen: viele valueChanged-Signale → genau ein Undo-Schritt + for v in (0.45, 0.46, 0.47, 0.48): + win._set_obj_attr(obj, "spacing", v) + win._set_obj_attr(obj, "spacing", 0.48) # unverändert → kein Snapshot + assert len(win._undo_stack) == depth + 1 + win.action_undo() + assert [o.id for o in d.objects] == ids_before + assert win.canvas.selected.id == obj.id + restored = next(o for o in d.objects if o.id == obj.id) + assert restored.spacing != 0.48 + win.close() + + +def test_loaded_design_syncs_hoop_combo(tmp_path): + from openstitch.core.design import HOOPS + win = MainWindow(show_tour_on_init=False) + d = Design(name="Jacke") + d.hoop = HOOPS[8] + d.objects.append(FillObject(Thread((10, 10, 10)), geom=Point(0, 0).buffer(10))) + path = str(tmp_path / "jacke.ostitch") + save_project(d, path) + win.set_design(load_project(path), reset_undo=True) + assert win.cmb_hoop.currentData() is win.canvas.design.hoop + assert win.canvas.design.hoop.mode == "jacket" + win.close() + + +def test_fit_to_hoop_keeps_text_scale(): + win = MainWindow(show_tour_on_init=False) + t = TextObject(Thread((0, 0, 0)), text="AB", height=10.0) + t.transform = (2.0, 0.0, 0.0, 2.0, 0.0, 0.0) # per Griff auf 200 % gezogen + win.set_design(Design(objects=[t]), reset_undo=True) + from openstitch.core.design import m_scale + h_before = t.height + w_before = t.bounds()[2] - t.bounds()[0] + win.action_fit_to_hoop() + obj = win.canvas.design.objects[0] + s = obj.height / h_before + # Der Einpass-Faktor landet in height, die per Griff gezogenen 200 % bleiben im Transform + assert abs(m_scale(obj.transform) - 2.0) < 1e-6 + w_after = obj.bounds()[2] - obj.bounds()[0] + assert abs(w_after - w_before * s) < 0.05 * w_after + win.close() + + +def test_single_objects_need_no_internal_trims(): + """Unterlage → Deckstich und Satin-Endkappen → Säule laufen verdeckt, ohne Trim.""" + t = Thread((200, 30, 30), "Rot") + for obj in (FillObject(t, geom=Point(0, 0).buffer(30), angle=30), + SatinObject(t, geom=LineString([(-30, 0), (30, 0)]).buffer(2.5))): + d = Design() + d.objects.append(obj) + plan = d.build_plan() + assert plan.trim_count == 1 # nur der Schlussschnitt + assert _max_stitch_len(plan) <= d.settings.max_stitch + 1e-6 + + +def test_plan_is_built_in_background(): + """Editieren blockiert nicht: der Plan wird im Hintergrund gerechnet und danach übernommen.""" + win = MainWindow(show_tour_on_init=False) + win.wait_for_plan() + before = win.canvas.plan + obj = next(o for o in win.canvas.design.objects if o.kind == "fill") + win._snapshot("test") + obj.spacing = 0.8 + win.rebuild_plan() + plan = win.wait_for_plan() + assert plan is not before and plan.stitch_count < before.stitch_count + # mehrere schnelle Änderungen: nur der neueste Stand wird übernommen + for sp in (0.5, 0.6, 0.7): + obj.spacing = sp + win.rebuild_plan() + final = win.wait_for_plan() + d = Design(objects=[o.snapshot() for o in win.canvas.design.objects], hoop=win.canvas.design.hoop) + assert final.stitch_count == d.build_plan().stitch_count + win.close() + + +def test_density_guard_keeps_fill_turn_points(): + """Reihenwende-Punkte am Rand sind keine Mikrostiche – sonst franst die Kontur aus.""" + import math + from openstitch.core.stitchplan import Cmd, Stitch + from openstitch.engine.density_guard import optimize_stitches_for_melco + zig = [Stitch(0, -1, Cmd.JUMP)] + for k in range(20): # Tatami-artige Reihen im Abstand 0.4 mm + y = k * 0.4 + xs = (0.0, 10.0) if k % 2 == 0 else (10.0, 0.0) + zig += [Stitch(xs[0], y), Stitch(5.0, y), Stitch(xs[1], y)] + out = optimize_stitches_for_melco(zig) + ends = [s for s in out if s.cmd == Cmd.STITCH and s.x in (0.0, 10.0)] + assert len(ends) == 40 + # echte Mikrostiche auf gerader Strecke werden weiterhin entfernt + line = [Stitch(0, 0, Cmd.JUMP), Stitch(1, 0), Stitch(1.1, 0), Stitch(2, 0), Stitch(3, 0)] + assert len(optimize_stitches_for_melco(line)) == 4 + + +def test_satin_underlay_styles(): + from openstitch.engine.satin import auto_underlay_style + assert auto_underlay_style(1.5) == "mitte" + assert auto_underlay_style(3.5) == "kante" + assert auto_underlay_style(6.0) == "kante+zickzack" + t = Thread((20, 40, 200), "Blau") + counts = {} + for style in ("mitte", "kante", "zickzack", "kante+zickzack"): + o = SatinObject(t, geom=LineString([(-20, 0), (20, 0)]).buffer(3.0), underlay_style=style) + d = Design(objects=[o]) + plan = d.build_plan() + counts[style] = plan.stitch_count + assert plan.trim_count == 1 and _max_stitch_len(plan) <= d.settings.max_stitch + # Kantenlauf läuft hin und zurück → mehr Stiche als nur die Mitte + assert counts["kante"] > counts["mitte"] and counts["kante+zickzack"] > counts["kante"] + # alte Projekte ohne underlay_style laden weiter (Default "auto") + from openstitch.core.design import object_from_dict + dct = SatinObject(t, geom=Point(0, 0).buffer(2)).to_dict() + dct.pop("underlay_style") + assert object_from_dict(dct).underlay_style == "auto" + + +def test_jef_export_keeps_trims(tmp_path): + import pyembroidery as pe + d = Design() + t = Thread((200, 30, 30), "Rot") + for x in (-30, 0, 30): # drei getrennte Flächen gleicher Farbe → Trims dazwischen + d.objects.append(FillObject(t, geom=Point(x, 0).buffer(6))) + plan = d.build_plan() + path = str(tmp_path / "t.jef") + export_plan(plan, path) + raw = open(path, "rb").read() + assert raw.count(b"\x80\x02\x00\x00\x80\x02\x00\x00\x80\x02\x00\x00") >= plan.trim_count - 1 + + +def test_color_sort_respects_overlaps(): + from openstitch.core.design import color_sorted + red, blue = Thread((200, 0, 0)), Thread((0, 0, 200)) + a = FillObject(red, geom=Point(-30, 0).buffer(5)) + b = FillObject(blue, geom=Point(0, 0).buffer(5)) + c = FillObject(red, geom=Point(30, 0).buffer(5)) + assert [o.id for o in color_sorted([a, b, c])] == [a.id, c.id, b.id] + # c liegt jetzt auf b → darf nicht unter b rutschen + c2 = FillObject(red, geom=Point(4, 0).buffer(5)) + assert [o.id for o in color_sorted([a, b, c2])] == [a.id, b.id, c2.id] + + +def test_quality_doctor_round_hoop_uses_real_radius(): + from openstitch.core.design import HOOPS + d = Design(objects=[FillObject(Thread((1, 1, 1)), geom=Point(0, 0).buffer(64))]) + d.hoop = HOOPS[2] # rund 15 cm → Radius 75 mm; Kreis r=64 passt (Bounding-Box-Ecke wäre 90 mm) + issues = run_quality_doctor(d, d.build_plan(), max_needles=10) + assert len(issues) == 1 and issues[0].startswith("✅") + + +def test_column_object_stitches_perpendicular_and_round_trips(tmp_path): + import math + from openstitch.core.design import ColumnObject + from openstitch.core.stitchplan import Cmd + line = LineString([(-20, 0), (0, 0), (15, 10)]) + col = ColumnObject(Thread((10, 120, 60), "Grün"), geom=line, width=3.0) + d = Design(objects=[col]) + plan = d.build_plan() + assert plan.stitch_count > 100 and _max_stitch_len(plan) <= d.settings.max_stitch + 1e-6 + # Auf dem geraden Stück (x < -3) stehen die Satinstiche senkrecht zur Linie (also vertikal) + pts = [(s.x, s.y) for s in plan.blocks[0].stitches if s.cmd == Cmd.STITCH] + vertical = [abs(b[1] - a[1]) / max(1e-9, math.dist(a, b)) for a, b in zip(pts, pts[1:]) + if -17 < a[0] < -3 and -17 < b[0] < -3 and math.dist(a, b) > 2.5] + assert vertical and sum(vertical) / len(vertical) > 0.95 + assert col.bounds()[3] - col.bounds()[1] > 3.0 * 0.9 + 10 # Breite fliesst in die Bounds ein + path = str(tmp_path / "col.ostitch") + save_project(d, path) + c2 = load_project(path).objects[0] + assert c2.kind == "column" and abs(c2.width - 3.0) < 1e-9 diff --git a/tests/test_svg_import.py b/tests/test_svg_import.py new file mode 100644 index 0000000..0a4f6a8 --- /dev/null +++ b/tests/test_svg_import.py @@ -0,0 +1,235 @@ +"""Tests für den SVG-Importer (transform, Pfadbefehle, Stil-Vererbung, Füllregeln, Einheiten).""" +import math + +import pytest +from shapely.ops import unary_union + +from openstitch.auto.palette import nearest_thread +from openstitch.core.design import FillObject, RunObject, SatinObject +from openstitch.engine.pro_tools import _parse_svg_color, _parse_svg_path_d, import_svg + +# 1 Benutzereinheit = 1 mm, damit Flächen/Grössen direkt prüfbar sind +MM_ROOT = 'width="200mm" height="200mm" viewBox="0 0 200 200"' + + +def _svg(tmp_path, body, root_attrs=MM_ROOT, name="t.svg"): + p = tmp_path / name + p.write_text( + f'' + f"{body}", encoding="utf-8") + return str(p) + + +def _load(tmp_path, body, **kw): + kw.setdefault("target_width_mm", None) + return import_svg(_svg(tmp_path, body), **kw) + + +def _areas(d): + return [o.geom for o in d.objects if isinstance(o, (FillObject, SatinObject))] + + +def _wh(g): + x0, y0, x1, y1 = g.bounds + return x1 - x0, y1 - y0 + + +def test_translated_and_nested_groups(tmp_path): + d = _load(tmp_path, '' + '' + '') + a, b, c = _areas(d) + assert b.centroid.x - a.centroid.x == pytest.approx(50, abs=1e-6) + assert b.centroid.y - a.centroid.y == pytest.approx(20, abs=1e-6) + assert _wh(c) == pytest.approx((20, 20), abs=1e-6) + assert c.centroid.y - a.centroid.y == pytest.approx(65, abs=1e-6) + + +def test_rotated_rect_and_default_scaling(tmp_path): + body = '' + (g,) = _areas(_load(tmp_path, body)) + assert _wh(g) == pytest.approx((10, 40), abs=1e-6) + assert g.area == pytest.approx(400, rel=1e-6) + # Standard: auf 95 mm Breite skaliert (bisheriges Verhalten) + (g95,) = _areas(import_svg(_svg(tmp_path, body, name="d.svg"))) + assert _wh(g95) == pytest.approx((95, 380), rel=1e-6) + + +def test_arc_circle_area(tmp_path): + d = _load(tmp_path, '') + (g,) = _areas(d) + assert g.area == pytest.approx(math.pi * 100, rel=0.03) + assert _wh(g) == pytest.approx((20, 20), abs=0.05) + # kompakte Flags ohne Trenner + relative Bögen: Halbkreis-Paar + subs = _parse_svg_path_d("M0 0a10 10 0 0120 0a10 10 0 01-20 0z") + from shapely.geometry import Polygon + assert Polygon(subs[0][0]).area == pytest.approx(math.pi * 100, rel=0.03) + + +def _flat(pts): + """Punktliste → flache Zahlenliste (pytest.approx unterstützt vor 9.x keine verschachtelten Tupel).""" + return [v for p in pts for v in p] + + +def test_smooth_curves_s_and_t(): + s = _parse_svg_path_d("M0 0 C0 20 20 20 20 0 S40 -20 40 0") + c = _parse_svg_path_d("M0 0 C0 20 20 20 20 0 C20 -20 40 -20 40 0") + assert _flat(s[0][0]) == pytest.approx(_flat(c[0][0])) + t = _parse_svg_path_d("M0 0 Q10 20 20 0 T40 0 t20 0") + q = _parse_svg_path_d("M0 0 Q10 20 20 0 Q30 -20 40 0 Q50 20 60 0") + assert _flat(t[0][0]) == pytest.approx(_flat(q[0][0])) + # S ohne vorheriges C: erster Kontrollpunkt = aktueller Punkt + s2 = _parse_svg_path_d("M0 0 S10 10 20 0") + c2 = _parse_svg_path_d("M0 0 C0 0 10 10 20 0") + assert _flat(s2[0][0]) == pytest.approx(_flat(c2[0][0])) + + +def test_path_number_syntax_and_unknown_command(): + (pts, closed), = _parse_svg_path_d("M1.5.5L-1-2 3e1,0z") + assert closed and _flat(pts) == pytest.approx([1.5, 0.5, -1, -2, 30, 0]) + # implizit wiederholte Koordinaten bei allen Befehlen + (pts, _), = _parse_svg_path_d("m0 0 10 0 0 10 h5 5 v5 5") + assert _flat(pts) == pytest.approx(_flat([(0, 0), (10, 0), (10, 10), (15, 10), (20, 10), (20, 15), (20, 20)])) + # unbekannter Befehl beendet den Pfad statt den Token-Strom zu verschieben + (pts, _), = _parse_svg_path_d("M0 0 L10 0 X 99 99 L5 5") + assert _flat(pts) == pytest.approx([0, 0, 10, 0]) + + +def test_fill_none_with_stroke_run_and_satin(tmp_path): + d = _load(tmp_path, '') + assert len(d.objects) == 1 + run = d.objects[0] + assert isinstance(run, RunObject) + assert run.geom.length == pytest.approx(400, rel=1e-6) # beide Teilpfade, nicht nur der erste + assert d.build_plan().stitch_count > 100 + d2 = _load(tmp_path, '') + (sat,) = d2.objects + assert isinstance(sat, SatinObject) + assert _wh(sat.geom) == pytest.approx((50, 4), abs=1e-6) + assert d2.build_plan().stitch_count > 50 + + +def test_display_none_and_visibility(tmp_path): + d = _load(tmp_path, '' + '' + '' + '' + '') + (g,) = _areas(d) + assert _wh(g) == pytest.approx((10, 10)) + + +def test_defs_and_non_rendered_containers_skipped(tmp_path): + hidden = ('' + '' + '' + '') + with pytest.raises(ValueError): + _load(tmp_path, hidden) + d = _load(tmp_path, hidden + '') + (g,) = _areas(d) + assert g.area == pytest.approx(math.pi * 225, rel=0.02) + + +def test_use_href_and_symbol(tmp_path): + d = _load(tmp_path, '' + '' + '' + '' + '') + a, b, c = _areas(d) + assert _wh(a) == pytest.approx((10, 10)) and _wh(b) == pytest.approx((10, 10)) + assert b.centroid.x - a.centroid.x == pytest.approx(-30) + assert b.centroid.y - a.centroid.y == pytest.approx(40) + assert c.centroid.x - a.centroid.x == pytest.approx(30, abs=0.05) + assert d.objects[0].thread == nearest_thread((0, 128, 0)) + + +def test_fill_rules(tmp_path): + outer = "M0 0 H40 V40 H0 Z" + inner_same = "M10 10 H30 V30 H10 Z" + inner_rev = "M10 10 V30 H30 V10 Z" + # evenodd: gleicher Umlaufsinn -> Loch + (g,) = _areas(_load(tmp_path, f'')) + assert g.area == pytest.approx(1200) + # nonzero, gleicher Umlaufsinn -> kein Loch + (g,) = _areas(_load(tmp_path, f'')) + assert g.area == pytest.approx(1600) + # nonzero, entgegengesetzter Umlaufsinn -> Loch; Insel im Loch wieder gefüllt + (g,) = _areas(_load(tmp_path, f'')) + assert g.area == pytest.approx(1200) + assert len(g.interiors) == 1 + gs = _areas(_load(tmp_path, f'')) + assert sum(x.area for x in gs) == pytest.approx(1300) + # nonzero: überlappende Teilpfade werden vereinigt (nicht XOR) + (g,) = _areas(_load(tmp_path, '')) + assert g.area == pytest.approx(600) + + +def test_colours_and_style_inheritance(tmp_path): + assert _parse_svg_color("cornflowerblue") == (100, 149, 237) + assert _parse_svg_color("RebeccaPurple") == (102, 51, 153) + assert _parse_svg_color("darkslategrey") == (47, 79, 79) + assert _parse_svg_color("#0f0") == (0, 255, 0) + assert _parse_svg_color("#11223380") == (0x11, 0x22, 0x33) + assert _parse_svg_color("rgb(100%, 50%, 0%)") == (255, 128, 0) + assert _parse_svg_color("rgb(10 20 30)") == (10, 20, 30) + assert _parse_svg_color("none") is None + assert _parse_svg_color("url(#nix)") == (128, 128, 128) + d = _load(tmp_path, + '' + '' + '' + '' + '' + '' + '') + cols = [o.thread for o in d.objects] + assert cols == [nearest_thread(c) for c in [(255, 165, 0), (0, 0, 128), (0, 128, 128), (0, 0, 0), (255, 0, 0)]] + + +def test_more_shapes(tmp_path): + d = _load(tmp_path, '' + '' + '' + '') + ell, rr = _areas(d) + assert ell.area == pytest.approx(math.pi * 300, rel=0.01) + assert rr.area == pytest.approx(40 * 30 - (4 - math.pi) * 25, rel=0.01) + runs = [o for o in d.objects if isinstance(o, RunObject)] + assert [r.geom.length for r in runs] == pytest.approx([80, 60]) + + +def test_physical_size_and_document_order(tmp_path): + body = '' + p = _svg(tmp_path, body, root_attrs='width="50mm" height="20mm" viewBox="0 0 250 100"') + d = import_svg(p, target_width_mm=None) + g = unary_union(_areas(d)) + assert _wh(g) == pytest.approx((50, 20), rel=1e-6) + assert [o.thread for o in d.objects][0] == nearest_thread((255, 0, 0)) + assert d.objects[-1].thread == nearest_thread((0, 0, 255)) # später = obenauf + assert _wh(unary_union(_areas(import_svg(p)))) == pytest.approx((95, 38), rel=1e-6) + # Zoll / Pixel ohne viewBox + p2 = _svg(tmp_path, '', root_attrs='width="96" height="96"', name="px.svg") + assert _wh(unary_union(_areas(import_svg(p2, target_width_mm=None)))) == pytest.approx((25.4, 12.7), rel=1e-6) + p3 = _svg(tmp_path, '', root_attrs='width="2in" height="1in" viewBox="0 0 2 1"', name="in.svg") + assert _wh(unary_union(_areas(import_svg(p3, target_width_mm=None)))) == pytest.approx((50.8, 25.4), rel=1e-6) + + +def test_use_bomb_is_rejected(tmp_path): + """Exponentiell verschachtelte -Referenzen dürfen den Import nicht aufhängen.""" + import time + from openstitch.engine.pro_tools import import_svg + parts = ['', + ''] + for i in range(1, 30): + parts.append(f'') + parts.append('') + f = tmp_path / "bomb.svg" + f.write_text("".join(parts), encoding="utf-8") + t = time.perf_counter() + try: + import_svg(str(f)) + except ValueError: + pass + assert time.perf_counter() - t < 10