task(H5): polygon area calculation - shoelace polygonArea, formatArea with quadratic scaling, measure-area tool, properties panel area display

This commit is contained in:
Agent Zero
2026-08-29 03:09:35 +02:00
parent 21e18b81d8
commit b210ccc5b8
5 changed files with 254 additions and 3 deletions
+9 -1
View File
@@ -1,4 +1,6 @@
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 { formatInputValue, parseDistance, type UnitType } from '../utils/format';
@@ -69,7 +71,13 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
<>
<div className="panel-section">
<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' }}>
{onDelete && el && (
<button
@@ -6,7 +6,8 @@
* Distanzformatierung via bestehendem formatDistance(unit, scaleFactor).
*/
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
let measureStart: import('../../../tools/modification/geometry').Pt | null = null;
@@ -46,6 +47,70 @@ export const measureTool: ToolExtensionV2 = {
};
/** 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 = {
manifest: {
id: 'core-measure',
@@ -56,5 +121,5 @@ export const coreMeasurePlugin: PluginV2 = {
category: 'tools',
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 {
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);
}
+25
View File
@@ -92,3 +92,28 @@ export function formatInputValue(
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)}`;
default:
return `${mmSquared.toFixed(0)} mm²`;
}
}