task(D9): grip editing - vertex/center grips in select tool (line endpoints, polyline vertices, circle/arc center), drag modifies single point in one undo transaction, corner scaling preserved

This commit is contained in:
Agent Zero
2026-08-29 03:25:38 +02:00
parent 68b66ba190
commit c6f38eee5b
2 changed files with 399 additions and 0 deletions
@@ -82,6 +82,109 @@ function hitHandle(
return null;
}
// ─────────────────────────────────────────────────────────────
// Task D9: GripEditing — Vertex-/Center-Grips (reine Funktion).
// ─────────────────────────────────────────────────────────────
export interface GripPoint {
kind: 'vertex' | 'center' | 'corner';
point: Pt;
index: number;
}
/**
* Element-spezifische Griff-Punkte (Task D9):
* line = beide Endpunkte, polyline/polygon = alle Vertices,
* circle/arc = Zentrum, rect/default = 4 BBox-Ecken (C3fin-Skalierung).
*/
export function getGripPoints(el: CADElement): GripPoint[] {
const props = el.properties as Record<string, unknown>;
const type = String(el.type);
if (
type === 'line' &&
props.x1 !== undefined && props.y1 !== undefined &&
props.x2 !== undefined && props.y2 !== undefined
) {
return [
{ kind: 'vertex', point: { x: props.x1 as number, y: props.y1 as number }, index: 0 },
{ kind: 'vertex', point: { x: props.x2 as number, y: props.y2 as number }, index: 1 },
];
}
if (type === 'polyline' || type === 'polygon') {
const pts = (props.points as Pt[] | undefined) ?? [];
return pts.map((p, i) => ({ kind: 'vertex' as const, point: { x: p.x, y: p.y }, index: i }));
}
if (type === 'circle' || type === 'arc') {
return [{ kind: 'center', point: { x: el.x, y: el.y }, index: 0 }];
}
// rect und alle anderen: BBox-Ecken (Corner-Drag/Skalierung, C3fin)
const minX = el.x - el.width / 2;
const minY = el.y - el.height / 2;
const maxX = el.x + el.width / 2;
const maxY = el.y + el.height / 2;
return [
{ kind: 'corner', point: { x: minX, y: minY }, index: 0 },
{ kind: 'corner', point: { x: maxX, y: minY }, index: 1 },
{ kind: 'corner', point: { x: maxX, y: maxY }, index: 2 },
{ kind: 'corner', point: { x: minX, y: maxY }, index: 3 },
];
}
/**
* Wendet eine Vertex-/Center-Drag-Position an (Task D9) und liefert
* das aktualisierte Element (reine Funktion — für Preview UND Commit).
*/
function applyVertexDrag(
el: CADElement,
drag: { el: CADElement; gripKind: 'vertex' | 'center'; index: number },
wx: number,
wy: number,
): CADElement {
const props = { ...(el.properties as Record<string, unknown>) };
const type = String(el.type);
if (drag.gripKind === 'center') {
return { ...el, x: wx, y: wy, properties: props as CADElement['properties'] } as CADElement;
}
if (type === 'line') {
if (drag.index === 0) { props.x1 = wx; props.y1 = wy; }
else { props.x2 = wx; props.y2 = wy; }
// BBox aktualisieren
const x1 = props.x1 as number, y1 = props.y1 as number, x2 = props.x2 as number, y2 = props.y2 as number;
return {
...el,
x: (x1 + x2) / 2,
y: (y1 + y2) / 2,
width: Math.abs(x2 - x1),
height: Math.abs(y2 - y1),
properties: props as CADElement['properties'],
} as CADElement;
}
if (type === 'polyline' || type === 'polygon') {
const pts = [...((props.points as Pt[] | undefined) ?? [])];
if (drag.index < pts.length) pts[drag.index] = { x: wx, y: wy };
props.points = pts;
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
const minX = Math.min(...xs), minY = Math.min(...ys);
const maxX = Math.max(...xs), maxY = Math.max(...ys);
return {
...el,
x: minX + (maxX - minX) / 2,
y: minY + (maxY - minY) / 2,
width: maxX - minX,
height: maxY - minY,
properties: props as CADElement['properties'],
} as CADElement;
}
return el;
}
/** Vertex-/Center-Drag-Sitzung (Task D9). */
let vertexDrag: {
el: CADElement;
gripKind: 'vertex' | 'center';
index: number;
} | null = null;
export const selectTool: ToolExtensionV2 = {
manifest: {
id: 'select',
@@ -101,6 +204,28 @@ export const selectTool: ToolExtensionV2 = {
const subtractive = e.ctrl;
const hit = bridge.hitTest(e.world.x, e.world.y, 5);
// ── Task D9: Vertex-/Center-Grip-Prüfung VOR BBox-Corners ──
// (spezifischere Grips gewinnen gegenüber Corner-Skalierung)
if (!additive && !subtractive && c.doc) {
const GRIP_TOL = 8;
for (const id of bridge.getIds()) {
const el = c.doc.getElement(id);
if (!el) continue;
for (const g of getGripPoints(el)) {
if (g.kind === 'corner') continue; // Corner unten (C3fin)
if (
Math.abs(e.world.x - g.point.x) <= GRIP_TOL &&
Math.abs(e.world.y - g.point.y) <= GRIP_TOL
) {
vertexDrag = { el, gripKind: g.kind, index: g.index };
const what = g.kind === 'center' ? 'Zentrum' : `Punkt ${g.index + 1}`;
ctx.setStatus(`${what} gegriffen — ziehen zum Verschieben`);
return;
}
}
}
}
// ── Handle-Drag-Start (Task C3fin interaktiv) ──
// Wenn bereits Elemente selektiert sind und der Klick auf eine Ecke
// eines davon trifft → Handle-Drag starten statt neu zu wählen.
@@ -133,6 +258,15 @@ export const selectTool: ToolExtensionV2 = {
},
move(e, ctx) {
// ── Task D9: Vertex-Drag-Preview (nur der Punkt aktualisiert) ──
if (vertexDrag) {
const c = ctx as FullToolContext;
const el = c.doc?.getElement(vertexDrag.el.id);
if (!el) return;
const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y);
c.setPreview?.(updated);
return;
}
// Live-Skalierung während Handle-Drag:
if (!handleDrag) return;
const c = ctx as FullToolContext;
@@ -174,6 +308,26 @@ export const selectTool: ToolExtensionV2 = {
},
up(e, ctx) {
// ── Task D9: Vertex-Commit (nur der Punkt, 1 Undo-Schritt) ──
if (vertexDrag) {
const c = ctx as FullToolContext;
const el = c.doc?.getElement(vertexDrag.el.id);
if (el && c.doc) {
const updated = applyVertexDrag(el, vertexDrag, e.world.x, e.world.y);
c.doc.transact(() => {
c.doc!.updateElement(el.id, {
x: updated.x,
y: updated.y,
properties: updated.properties as CADElement['properties'],
});
});
const what = vertexDrag.gripKind === 'center' ? 'Zentrum' : `Punkt ${vertexDrag.index + 1}`;
ctx.setStatus(`${what} verschoben`);
}
vertexDrag = null;
ctx.setPreview?.(null);
return;
}
if (!handleDrag) return;
const c = ctx as FullToolContext;
if (!c.doc) { handleDrag = null; return; }
@@ -224,6 +378,7 @@ export const selectTool: ToolExtensionV2 = {
cancel(ctx) {
handleDrag = null;
vertexDrag = null;
ctx.setPreview?.(null);
},
},