Compare commits
2 Commits
21e18b81d8
...
5cfd29cf1a
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cfd29cf1a | |||
| b210ccc5b8 |
+4
-3
@@ -642,7 +642,7 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
|
|||||||
- [x] H2 Projekt-Templates (Halle/OpenAir/Saal/Messe/Buero/Garten): JSON Presets (grid unit, layers, enabled plugins, library folders seed) *(2026-08-29: projectTemplates-Service mit 6 Presets je gridSize/unit/4 Layer/enabledPlugins/libraryFolderSeed + applyProjectTemplate über CADDocument.replaceLayers; Log H2)*
|
- [x] H2 Projekt-Templates (Halle/OpenAir/Saal/Messe/Buero/Garten): JSON Presets (grid unit, layers, enabled plugins, library folders seed) *(2026-08-29: projectTemplates-Service mit 6 Presets je gridSize/unit/4 Layer/enabledPlugins/libraryFolderSeed + applyProjectTemplate über CADDocument.replaceLayers; Log H2)*
|
||||||
- [x] H3 Rollen: library-admin (global write) vs user (own folders) — an bestehendes auth anbinden, Backend guard *(2026-08-29: requireLibraryAdmin an bestehende admin-Rolle gebunden; alle 8 schreibenden Global-Library-Routen guarded (POST/PATCH/DELETE folders+blocks, import, import-zip), Lesen/Export für authentifizierte offen; Log H3)*
|
- [x] H3 Rollen: library-admin (global write) vs user (own folders) — an bestehendes auth anbinden, Backend guard *(2026-08-29: requireLibraryAdmin an bestehende admin-Rolle gebunden; alle 8 schreibenden Global-Library-Routen guarded (POST/PATCH/DELETE folders+blocks, import, import-zip), Lesen/Export für authentifizierte offen; Log H3)*
|
||||||
- [x] H4 i18n Grundlage: strings.ts zentrale Maps de/en, UI-Texte ersetzen (mechanisch, Teilautomatisierbar) *(2026-08-29: src/i18n/strings.ts — de/en-Maps, t() mit Key-Fallback, Sprachpersistenz (webcad.language, Default de), subscribeLanguage + useT-Hook; Kern-UI-Texte LayoutBar + Topbar-Modus umgestellt, Live-Wechsel-Test; vollständige Ersetzung aller Komponenten = teilautomatisierbares Follow-up; Log H4)*
|
- [x] H4 i18n Grundlage: strings.ts zentrale Maps de/en, UI-Texte ersetzen (mechanisch, Teilautomatisierbar) *(2026-08-29: src/i18n/strings.ts — de/en-Maps, t() mit Key-Fallback, Sprachpersistenz (webcad.language, Default de), subscribeLanguage + useT-Hook; Kern-UI-Texte LayoutBar + Topbar-Modus umgestellt, Live-Wechsel-Test; vollständige Ersetzung aller Komponenten = teilautomatisierbares Follow-up; Log H4)*
|
||||||
- [ ] H5 Flächenberechnung Polygon (m²) Info im PropertiesPanel + measure-area Tool
|
- [x] H5 Flächenberechnung Polygon (m²) Info im PropertiesPanel + measure-area Tool *(2026-08-29: polygonArea via Shoelace (Absolutwert, CW/CCW egal), formatArea mit QUADRATISCHER Skalierung, measure-area Tool (Klick-Sequenz, ENTER ab 3 Punkten, Live-Fläche), PropertiesPanel zeigt Fläche für polygon/polyline ab 3 Punkten; Log H5)*
|
||||||
|
|
||||||
## Phase I — Qualität & Release
|
## Phase I — Qualität & Release
|
||||||
- [ ] I1 CI: forgejo workflow erweitern um performance gate (playwright bench: 10k interaktionsbudget), typecheck, beide Suites
|
- [ ] I1 CI: forgejo workflow erweitern um performance gate (playwright bench: 10k interaktionsbudget), typecheck, beide Suites
|
||||||
@@ -667,6 +667,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
|||||||
|
|
||||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-29 | H5 | Flächenberechnung: (1) polygonArea NEU in geometry.ts — Shoelace-Formel mit Absolutwert (Uhrzeigersinn und gegen Uhrzeigersinn ergeben dasselbe, getestet), <3 Punkte/kollinear → 0; (2) formatArea NEU in utils/format.ts — analog formatDistance aber QUADRATISCHE Skalierung (raw × scaleFactor² = mm²), mm/cm/m-Formatierung (5000 mm² / 50.0 cm² / 5.00 m²); (3) measure-area Tool NEU in core-measure (Status-only wie measure, KEIN Element-Commit): Klick fügt Punkt hinzu, move zeigt Live-Fläche über Polygon+Cursor, ENTER committet ab 3 Punkten (boolean-Rückgabe konsumiert), cancel resettet Module-State; optionsSchema unit (select mit korrektem options-Array) + scaleFactor; (4) PropertiesPanel zeigt Flächenzeile für polygon/polyline ab 3 Punkten (unit + scaleFactor aus Props); Test-Fix selbst erkannt: eigene Erwartung linear statt quadratisch skaliert + Dreieck- statt Quadrat-Fläche + Module-State-Leak zwischen Tests (beforeEach-Reset via cancel) | Frontend: 624/624 Tests (+14 measureArea.test.ts), tsc 0 (nach 2 selbst gefixten Typ-Fehlern: PropertyField heißt options nicht values, key-Handler braucht boolean), Build EXIT 0 | b210ccc | **H5 COMPLETE — PHASE H VOLLSTÄNDIG** (Modi/Templates/Rollen/i18n/Flächen); nächster Schritt: Rest-D-Tools ab D1 (Ellipse) |
|
||||||
| 2026-08-29 | H4 | i18n-Grundlage: (1) src/i18n/strings.ts NEU — zentrale DE/EN-Maps (Key-Pfad → String oder parametrisierte Funktion für dynamische Texte wie „Layout X löschen“), t(key, arg) mit Key-Fallback bei unbekannten Keys (offensichtlich sichtbar statt still), LANGUAGES-Konstante, getLanguage/setLanguage mit localStorage-Persistenz (webcad.language, Default de, ungültige Werte → de) analog uiModeService-Muster, subscribeLanguage-Set, useT-React-Hook erzwingt Re-Render bei Sprachwechsel; (2) Kern-UI-Texte auf t() umgestellt: LayoutBar (Titel, Hinzufügen, Leer, Löschen, Drucken, Titelblock, Zoom-to) + Topbar-Modus-Toggle (title + aria-label) — mit echtem Live-Wechsel getestet (de → en im laufenden Render); (3) vollständige mechanische Ersetzung ALLER Komponenten-Texte bewusst als teilautomatisierbares Follow-up markiert (Roadmap-Wortlaut); Zwischenfälle selbst behoben: Python-Quoting-Abbruch beim ersten Implementierungsversch (strings.ts wurde nicht geschrieben, Komponenten referenzierten useT schon → nachgetragen) + Testing-Library-State-Update außerhalb act() (Sprachwechsel im Test in act() gewrappt) | Frontend: 610/610 Tests (+8 i18n.test.tsx), tsc 0, Build EXIT 0 | e6fdb39 | H4 COMPLETE; nächster Task H5 (Flächenberechnung Polygon m² + measure-area Tool) |
|
| 2026-08-29 | H4 | i18n-Grundlage: (1) src/i18n/strings.ts NEU — zentrale DE/EN-Maps (Key-Pfad → String oder parametrisierte Funktion für dynamische Texte wie „Layout X löschen“), t(key, arg) mit Key-Fallback bei unbekannten Keys (offensichtlich sichtbar statt still), LANGUAGES-Konstante, getLanguage/setLanguage mit localStorage-Persistenz (webcad.language, Default de, ungültige Werte → de) analog uiModeService-Muster, subscribeLanguage-Set, useT-React-Hook erzwingt Re-Render bei Sprachwechsel; (2) Kern-UI-Texte auf t() umgestellt: LayoutBar (Titel, Hinzufügen, Leer, Löschen, Drucken, Titelblock, Zoom-to) + Topbar-Modus-Toggle (title + aria-label) — mit echtem Live-Wechsel getestet (de → en im laufenden Render); (3) vollständige mechanische Ersetzung ALLER Komponenten-Texte bewusst als teilautomatisierbares Follow-up markiert (Roadmap-Wortlaut); Zwischenfälle selbst behoben: Python-Quoting-Abbruch beim ersten Implementierungsversch (strings.ts wurde nicht geschrieben, Komponenten referenzierten useT schon → nachgetragen) + Testing-Library-State-Update außerhalb act() (Sprachwechsel im Test in act() gewrappt) | Frontend: 610/610 Tests (+8 i18n.test.tsx), tsc 0, Build EXIT 0 | e6fdb39 | H4 COMPLETE; nächster Task H5 (Flächenberechnung Polygon m² + measure-area Tool) |
|
||||||
| 2026-08-29 | H3 | Rollen library-admin vs. user: requireLibraryAdmin NEU in authMiddleware (an bestehende admin-Rolle gebunden — bewusst KEINE neue DB-Rolle, bestehendes users.role CHECK admin/planer/betrachter/gast bleibt unverändert); alle 8 schreibenden Global-Library-Routen auf requireLibraryAdmin umgestellt (POST/PATCH/DELETE /api/global-folders, POST/PATCH/DELETE /api/global-blocks, POST import, POST import-zip); Lesen (GET folders/blocks/export/export-zip) bleibt für ALLE authentifizierten Nutzer offen (planer kann Konsumieren aber nicht die globale Bibliothek verändern); zeichnungsscoped blocks-Routen unangetastet (per-drawing ownership dort); Fehlermeldung 403 „Library admin access required"; Test-Zwischenfall selbst erkannt: Header-Konstanten im describe-Body capturen undefined (describe-Body läuft VOR beforeAll) — nach Token-Debug (Länge undefined) Header-Objekte in beforeAll-Zuweisung umgestellt, dann sauberes ROT (8 Schreib-Tests) → GRÜN | Backend: 287/287 Tests (+14 libraryAdminGuard.test.ts: 3 Lese-für-planer, 8×403-Schreiben-für-planer, 2 Admin-201, 1 Unauth-401), BE tsc 0 | d403040 | H3 COMPLETE; nächster Task H4 (i18n-Grundlage) |
|
| 2026-08-29 | H3 | Rollen library-admin vs. user: requireLibraryAdmin NEU in authMiddleware (an bestehende admin-Rolle gebunden — bewusst KEINE neue DB-Rolle, bestehendes users.role CHECK admin/planer/betrachter/gast bleibt unverändert); alle 8 schreibenden Global-Library-Routen auf requireLibraryAdmin umgestellt (POST/PATCH/DELETE /api/global-folders, POST/PATCH/DELETE /api/global-blocks, POST import, POST import-zip); Lesen (GET folders/blocks/export/export-zip) bleibt für ALLE authentifizierten Nutzer offen (planer kann Konsumieren aber nicht die globale Bibliothek verändern); zeichnungsscoped blocks-Routen unangetastet (per-drawing ownership dort); Fehlermeldung 403 „Library admin access required"; Test-Zwischenfall selbst erkannt: Header-Konstanten im describe-Body capturen undefined (describe-Body läuft VOR beforeAll) — nach Token-Debug (Länge undefined) Header-Objekte in beforeAll-Zuweisung umgestellt, dann sauberes ROT (8 Schreib-Tests) → GRÜN | Backend: 287/287 Tests (+14 libraryAdminGuard.test.ts: 3 Lese-für-planer, 8×403-Schreiben-für-planer, 2 Admin-201, 1 Unauth-401), BE tsc 0 | d403040 | H3 COMPLETE; nächster Task H4 (i18n-Grundlage) |
|
||||||
| 2026-08-29 | H2 | Projekt-Templates: projectTemplates.ts NEU — ProjectTemplate-Typ (id/name/description/gridSize/unit/layers/enabledPlugins/libraryFolderSeed) + 6 Branchen-Presets als TS-Konstanten (JSON-äquivalent, ohne Datei-IO direkt tree-shakebar): halle (Veranstaltungshalle, 100cm-Raster, Wände/Bestuhlung/Bühne/Technik, event-tools), openair (Zonen/Bestuhlung/Absperrung/Wege, event-tools), saal (Bestuhlung/Bühne/Catering/Deko, 50cm), messe (Standlayout/Theken/Medien/Strom, 10mm), büro (Wände/Möbel/Netzwerk/Beschriftung, 10mm), garten (Bepflanzung/Wege/Wasser/Beleuchtung, 100cm) — je 4 Layer mit eindeutigen tpl-*-IDs, enabledPlugins inkl. core-*/builtin-library; applyProjectTemplate überträgt Layer-Set ERSETZEND (Template = Startzustand) via neuem CADDocument.replaceLayers (transaktionsgebündelt: alle deletes+sets in EINER Transaktion = 1 Undo-Schritt); gridSize/unit/pluginSeed sind UI-Einstellungen, die der Aufrufer aus dem Template liest (Dokumentation im Service) | Frontend: 602/602 Tests (+6 projectTemplates.test.ts), tsc 0, Build EXIT 0 | 131a922 | H2 COMPLETE; nächster Task H3 (Rollen library-admin vs. user, Backend-Guard) |
|
| 2026-08-29 | H2 | Projekt-Templates: projectTemplates.ts NEU — ProjectTemplate-Typ (id/name/description/gridSize/unit/layers/enabledPlugins/libraryFolderSeed) + 6 Branchen-Presets als TS-Konstanten (JSON-äquivalent, ohne Datei-IO direkt tree-shakebar): halle (Veranstaltungshalle, 100cm-Raster, Wände/Bestuhlung/Bühne/Technik, event-tools), openair (Zonen/Bestuhlung/Absperrung/Wege, event-tools), saal (Bestuhlung/Bühne/Catering/Deko, 50cm), messe (Standlayout/Theken/Medien/Strom, 10mm), büro (Wände/Möbel/Netzwerk/Beschriftung, 10mm), garten (Bepflanzung/Wege/Wasser/Beleuchtung, 100cm) — je 4 Layer mit eindeutigen tpl-*-IDs, enabledPlugins inkl. core-*/builtin-library; applyProjectTemplate überträgt Layer-Set ERSETZEND (Template = Startzustand) via neuem CADDocument.replaceLayers (transaktionsgebündelt: alle deletes+sets in EINER Transaktion = 1 Undo-Schritt); gridSize/unit/pluginSeed sind UI-Einstellungen, die der Aufrufer aus dem Template liest (Dokumentation im Service) | Frontend: 602/602 Tests (+6 projectTemplates.test.ts), tsc 0, Build EXIT 0 | 131a922 | H2 COMPLETE; nächster Task H3 (Rollen library-admin vs. user, Backend-Guard) |
|
||||||
@@ -741,5 +742,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
|||||||
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
||||||
|
|
||||||
---
|
---
|
||||||
*Nächster offener Schritt: **H5** (Flächenberechnung Polygon m² im PropertiesPanel + measure-area Tool). Danach: Rest-D-Tools (D1–D4/D9/D10-path/D11/D12), Phase I (Docs/CI/Release).*
|
*Nächster offener Schritt: **D1** (Ellipse+Bogen-Tool: cx,cy,rx,ry,rotation; Interaktion Center→RadiusX→RadiusY). Danach: D2 Spline, D3 Point/XLine/Ray, D4 MTEXT, D9 GripEditing, D10 array-path, D11 Snaps, D12 BlockAttribute. Anschließend: Phase I (Docs/CI/Release).*
|
||||||
*Status 2026-08-29: Phasen B+A+C+D+E+F+G komplett, H1–H4 fertig (Modi, Templates, Rollen, i18n-Grundlage), BUG-1-Kollisionsklasse geschlossen. FE 610/610 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main.*
|
*Status 2026-08-29: Phasen B+A+C+D+E+F+G+H komplett (H: simple/pro, Templates, Rollen-Guard, i18n-Grundlage, Flächen), BUG-1-Kollisionsklasse geschlossen. FE 624/624 Tests, BE 287/287, tsc/Build grün. Alle Commits auf Forgejo main. Offen: Rest-D-Tools (D1–D4/D9/D10-path/D11/D12), Phase I (Release).*
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { polygonArea } from '../tools/modification/geometry';
|
||||||
|
import { formatArea } from '../utils/format';
|
||||||
import type { PropertiesPanelProps } from '../types/ui.types';
|
import type { PropertiesPanelProps } from '../types/ui.types';
|
||||||
import { formatInputValue, parseDistance, type UnitType } from '../utils/format';
|
import { formatInputValue, parseDistance, type UnitType } from '../utils/format';
|
||||||
|
|
||||||
@@ -69,7 +71,13 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|||||||
<>
|
<>
|
||||||
<div className="panel-section">
|
<div className="panel-section">
|
||||||
<div className="panel-section-title">
|
<div className="panel-section-title">
|
||||||
<span>Auswahl {el ? `· ${el.type}` : ''}</span>
|
<span>Auswahl {el ? `· ${el.type}
|
||||||
|
{((el.type === 'polygon' || el.type === 'polyline') && ((el.properties.points as Array<{ x: number; y: number }> | undefined)?.length ?? 0) >= 3) && (
|
||||||
|
<div className="prop-row">
|
||||||
|
<label className="prop-label">Fläche</label>
|
||||||
|
<span className="prop-value">{formatArea(polygonArea(el.properties.points as Array<{ x: number; y: number }>), unit, scaleFactor)}</span>
|
||||||
|
</div>
|
||||||
|
)}` : ''}</span>
|
||||||
<div style={{ display: 'flex', gap: '4px' }}>
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
{onDelete && el && (
|
{onDelete && el && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* Distanzformatierung via bestehendem formatDistance(unit, scaleFactor).
|
* Distanzformatierung via bestehendem formatDistance(unit, scaleFactor).
|
||||||
*/
|
*/
|
||||||
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
|
||||||
import { formatDistance } from '../../../utils/format';
|
import { formatDistance, formatArea } from '../../../utils/format';
|
||||||
|
import { polygonArea } from '../../../tools/modification/geometry';
|
||||||
|
|
||||||
type MeasurePhase = 0 | 1; // 0=wartet auf Start, 1=wartet auf Ende
|
type MeasurePhase = 0 | 1; // 0=wartet auf Start, 1=wartet auf Ende
|
||||||
let measureStart: import('../../../tools/modification/geometry').Pt | null = null;
|
let measureStart: import('../../../tools/modification/geometry').Pt | null = null;
|
||||||
@@ -46,6 +47,70 @@ export const measureTool: ToolExtensionV2 = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** V2-Plugin: Messwerkzeuge. */
|
/** V2-Plugin: Messwerkzeuge. */
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task H5: measure-area — Polygon-Fläche messen (Status-only).
|
||||||
|
// Klick: Punkt hinzufügen; ENTER: committet Fläche ab 3 Punkten;
|
||||||
|
// ESC/Rechtsklick: Abbruch. Kein Element-Commit (wie measure).
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
let areaPts: Array<{ x: number; y: number }> = [];
|
||||||
|
|
||||||
|
export const measureAreaTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'measure-area',
|
||||||
|
label: 'Fläche messen',
|
||||||
|
icon: '\u25a9',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Polygon-Fläche messen: Punkte klicken, ENTER schließt ab',
|
||||||
|
},
|
||||||
|
optionsSchema: [
|
||||||
|
{ key: 'unit', label: 'Einheit', type: 'select', options: [
|
||||||
|
{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }, { value: 'm', label: 'm' },
|
||||||
|
] },
|
||||||
|
{ key: 'scaleFactor', label: 'Maßstab (Welt→mm)', type: 'number' },
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
areaPts.push({ x: e.world.x, y: e.world.y });
|
||||||
|
if (areaPts.length === 1) {
|
||||||
|
ctx.setStatus(`Flächenmessung gestartet (1 Punkt) — weitere Punkte klicken, ENTER schließt ab`);
|
||||||
|
} else if (areaPts.length >= 3) {
|
||||||
|
const unit = (ctx.options.unit as string | undefined) ?? 'mm';
|
||||||
|
const scale = (ctx.options.scaleFactor as number | undefined) ?? 1;
|
||||||
|
ctx.setStatus(`Fläche: ${formatArea(polygonArea(areaPts), unit as never, scale)} — weiter klicken oder ENTER`);
|
||||||
|
} else {
|
||||||
|
ctx.setStatus(`${areaPts.length} Punkte — mindestens 3 benötigt, weitere Punkte klicken`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
if (areaPts.length < 2) return;
|
||||||
|
const probe = [...areaPts, { x: e.world.x, y: e.world.y }];
|
||||||
|
if (probe.length < 3) return;
|
||||||
|
const unit = (ctx.options.unit as string | undefined) ?? 'mm';
|
||||||
|
const scale = (ctx.options.scaleFactor as number | undefined) ?? 1;
|
||||||
|
ctx.setStatus(`Fläche: ${formatArea(polygonArea(probe), unit as never, scale)}`);
|
||||||
|
},
|
||||||
|
key(k, ctx) {
|
||||||
|
if (k.key === 'Enter' || k.key === 'ENTER') {
|
||||||
|
if (areaPts.length >= 3) {
|
||||||
|
const unit = (ctx.options.unit as string | undefined) ?? 'mm';
|
||||||
|
const scale = (ctx.options.scaleFactor as number | undefined) ?? 1;
|
||||||
|
ctx.setStatus(`Flächenmessung: ${formatArea(polygonArea(areaPts), unit as never, scale)} (${areaPts.length} Punkte)`);
|
||||||
|
areaPts = [];
|
||||||
|
} else {
|
||||||
|
ctx.setStatus(`Flächenmessung benötigt mindestens 3 Punkte (aktuell ${areaPts.length})`);
|
||||||
|
}
|
||||||
|
return true; // ENTER konsumiert
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
areaPts = [];
|
||||||
|
ctx.setStatus('Flächenmessung abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const coreMeasurePlugin: PluginV2 = {
|
export const coreMeasurePlugin: PluginV2 = {
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'core-measure',
|
id: 'core-measure',
|
||||||
@@ -56,5 +121,5 @@ export const coreMeasurePlugin: PluginV2 = {
|
|||||||
category: 'tools',
|
category: 'tools',
|
||||||
enabledByDefault: true,
|
enabledByDefault: true,
|
||||||
},
|
},
|
||||||
tools: [measureTool],
|
tools: [measureTool, measureAreaTool],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -588,3 +588,19 @@ export function distance(p1: { x: number; y: number }, p2: { x: number; y: numbe
|
|||||||
export function angleBetween(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
|
export function angleBetween(p1: { x: number; y: number }, p2: { x: number; y: number }): number {
|
||||||
return (Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180) / Math.PI;
|
return (Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180) / Math.PI;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fläche eines Polygons via Shoelace-Formel (Task H5).
|
||||||
|
* Absolutwert — Uhrzeigersinn und gegen Uhrzeigersinn ergeben dasselbe.
|
||||||
|
* Weniger als 3 Punkte oder kollineare Punkte → 0.
|
||||||
|
*/
|
||||||
|
export function polygonArea(pts: Array<{ x: number; y: number }>): number {
|
||||||
|
if (!Array.isArray(pts) || pts.length < 3) return 0;
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const a = pts[i];
|
||||||
|
const b = pts[(i + 1) % pts.length];
|
||||||
|
sum += a.x * b.y - b.x * a.y;
|
||||||
|
}
|
||||||
|
return Math.abs(sum / 2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,3 +92,28 @@ export function formatInputValue(
|
|||||||
return `${(mm / 1000).toFixed(3)}`;
|
return `${(mm / 1000).toFixed(3)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a raw world-unit squared area value (Task H5).
|
||||||
|
* @param rawSquared squared world-units (e.g. polygonArea result)
|
||||||
|
* @param unit target display unit
|
||||||
|
* @param scaleFactor world-units → mm conversion (like formatDistance)
|
||||||
|
* @returns formatted string like "5000 mm²", "50.0 cm²", "5.00 m²"
|
||||||
|
*/
|
||||||
|
export function formatArea(
|
||||||
|
rawSquared: number,
|
||||||
|
unit: UnitType,
|
||||||
|
scaleFactor: number = 1,
|
||||||
|
): string {
|
||||||
|
const mmSquared = rawSquared * scaleFactor * scaleFactor;
|
||||||
|
switch (unit) {
|
||||||
|
case 'mm':
|
||||||
|
return `${mmSquared.toFixed(0)} mm²`;
|
||||||
|
case 'cm':
|
||||||
|
return `${(mmSquared / 100).toFixed(1)} cm²`;
|
||||||
|
case 'm':
|
||||||
|
return `${(mmSquared / 1000000).toFixed(2)} m²`;
|
||||||
|
default:
|
||||||
|
return `${mmSquared.toFixed(0)} mm²`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Task H5 – Flächenberechnung Polygon (m²) + measure-area Tool.
|
||||||
|
*
|
||||||
|
* polygonArea: Shoelace-Formel (Absolutwert, CW/CCW egal),
|
||||||
|
* formatArea: Einheiten-Formatierung analog formatDistance,
|
||||||
|
* measureAreaTool: Klick-Sequenz, Live-Fläche im Status, ENTER
|
||||||
|
* committet ab 3 Punkten, cancel setzt zurück.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { polygonArea } from '../src/tools/modification/geometry';
|
||||||
|
import { formatArea } from '../src/utils/format';
|
||||||
|
import { measureAreaTool } from '../src/plugins/builtin/core-measure';
|
||||||
|
import type { CADElement } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
describe('H5: polygonArea (Shoelace)', () => {
|
||||||
|
it('Rechteck 100×50 = 5000 Welt-Einheiten²', () => {
|
||||||
|
const pts = [
|
||||||
|
{ x: 0, y: 0 }, { x: 100, y: 0 },
|
||||||
|
{ x: 100, y: 50 }, { x: 0, y: 50 },
|
||||||
|
];
|
||||||
|
expect(polygonArea(pts)).toBeCloseTo(5000, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Dreieck 100×50/2 = 2500', () => {
|
||||||
|
const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 0, y: 50 }];
|
||||||
|
expect(polygonArea(pts)).toBeCloseTo(2500, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Uhrzeigersinn und gegen Uhrzeigersinn ergeben denselben Absolutwert', () => {
|
||||||
|
const ccw = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }];
|
||||||
|
const cw = [...ccw].reverse();
|
||||||
|
expect(polygonArea(cw)).toBeCloseTo(polygonArea(ccw), 6);
|
||||||
|
expect(polygonArea(cw)).toBeCloseTo(10000, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weniger als 3 Punkte → 0; kollineare Punkte → 0', () => {
|
||||||
|
expect(polygonArea([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toBe(0);
|
||||||
|
expect(polygonArea([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }])).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('H5: formatArea', () => {
|
||||||
|
it('mm: 5000 → "5000 mm²" (scaleFactor 1)', () => {
|
||||||
|
expect(formatArea(5000, 'mm', 1)).toBe('5000 mm²');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cm: 5000mm² → "50.0 cm²"', () => {
|
||||||
|
expect(formatArea(5000, 'cm', 1)).toBe('50.0 cm²');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('m: 5000000mm² → "5.00 m²"', () => {
|
||||||
|
expect(formatArea(5000000, 'm', 1)).toBe('5.00 m²');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Flächen skalieren QUADRATISCH: 5 WE² bei scaleFactor 1000 = 5000000 mm²', () => {
|
||||||
|
// 1 Welt-Einheit = 1000mm → 1 WE² = 1.000.000 mm²
|
||||||
|
expect(formatArea(5, 'mm', 1000)).toBe('5000000 mm²');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('H5: measureAreaTool', () => {
|
||||||
|
// Module-State areaPts leakt zwischen Tests — vor jedem Test resetten:
|
||||||
|
beforeEach(() => {
|
||||||
|
measureAreaTool.handlers.cancel?.({ setStatus: () => {} } as never);
|
||||||
|
});
|
||||||
|
function fireSeq(events: Array<{ type: 'down' | 'move' | 'key' | 'cancel'; x?: number; y?: number; key?: string }>, options: Record<string, unknown> = {}) {
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
options,
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: (_el: CADElement | null) => {},
|
||||||
|
doc: null,
|
||||||
|
};
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.type === 'down') measureAreaTool.handlers.down!({ world: { x: ev.x!, y: ev.y! } } as never, ctx);
|
||||||
|
if (ev.type === 'move') measureAreaTool.handlers.move!({ world: { x: ev.x!, y: ev.y! } } as never, ctx);
|
||||||
|
if (ev.type === 'key' && ev.key) measureAreaTool.handlers.key?.({ key: ev.key } as never, ctx);
|
||||||
|
if (ev.type === 'cancel') measureAreaTool.handlers.cancel?.(ctx);
|
||||||
|
}
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('erster Klick startet Messung, Status fordert weitere Punkte', () => {
|
||||||
|
const s = fireSeq([{ type: 'down', x: 0, y: 0 }]);
|
||||||
|
expect(s[0]).toContain('Fläche');
|
||||||
|
expect(s[s.length - 1]).toContain('klicken');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Live-Fläche im Status ab 3 Punkten bei move', () => {
|
||||||
|
const s = fireSeq([
|
||||||
|
{ type: 'down', x: 0, y: 0 },
|
||||||
|
{ type: 'down', x: 100, y: 0 },
|
||||||
|
{ type: 'down', x: 100, y: 50 },
|
||||||
|
{ type: 'move', x: 0, y: 50 },
|
||||||
|
]);
|
||||||
|
expect(s.some((m) => m.includes('5000'))).toBe(true);
|
||||||
|
expect(s[s.length - 1]).toContain('mm²');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ENTER committet ab 3 Punkten mit Endfläche im Status', () => {
|
||||||
|
const s = fireSeq([
|
||||||
|
{ type: 'down', x: 0, y: 0 },
|
||||||
|
{ type: 'down', x: 100, y: 0 },
|
||||||
|
{ type: 'down', x: 100, y: 100 },
|
||||||
|
{ type: 'key', key: 'Enter' },
|
||||||
|
]);
|
||||||
|
// Dreieck (0,0),(100,0),(100,100): Shoelace = 1/2 · 100 · 100 = 5000
|
||||||
|
expect(s[s.length - 1]).toContain('5000');
|
||||||
|
expect(s[s.length - 1]).toContain('3 Punkte');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ENTER mit weniger als 3 Punkten committet NICHT (Status erklärt)', () => {
|
||||||
|
const s = fireSeq([
|
||||||
|
{ type: 'down', x: 0, y: 0 },
|
||||||
|
{ type: 'down', x: 100, y: 0 },
|
||||||
|
{ type: 'key', key: 'Enter' },
|
||||||
|
]);
|
||||||
|
expect(s[s.length - 1]).toContain('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel setzt Messung zurück', () => {
|
||||||
|
const s = fireSeq([
|
||||||
|
{ type: 'down', x: 0, y: 0 },
|
||||||
|
{ type: 'down', x: 50, y: 50 },
|
||||||
|
{ type: 'cancel' },
|
||||||
|
{ type: 'down', x: 0, y: 0 },
|
||||||
|
]);
|
||||||
|
// Nach cancel startet eine frische Messung (kein altes Polygon)
|
||||||
|
expect(s[s.length - 1]).toContain('klicken');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Manifest: id measure-area, tags basic, ribbonTab canvas', () => {
|
||||||
|
expect(measureAreaTool.manifest.id).toBe('measure-area');
|
||||||
|
expect(measureAreaTool.manifest.ribbonTab).toBe('canvas');
|
||||||
|
expect((measureAreaTool.manifest.tags ?? [])).toContain('basic');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user