task(CAD-7): magnetic truss tool - drag placement with length options, snapTrussToDock end-to-end docking with rotation inheritance, opposite direction at start endpoints, truss rendering with belt lines

This commit is contained in:
Agent Zero
2026-08-31 08:47:04 +02:00
parent 44a9aec107
commit cefba89ebb
4 changed files with 433 additions and 2 deletions
@@ -421,6 +421,182 @@ export const barrierTool: ToolExtensionV2 = {
},
};
// ─────────────────────────────────────────────────────────────
// Task CAD-7: Magnetisches Traversen-Tool (Truss).
// Rechteckiger Balken (Laengen-Option 100/200/290cm), Drag
// definiert die Richtung. MAGNETISCH: Nahe dem Ende einer
// bestehenden Traverse dockt die neue exakt an (Position UND
// Rotation uebernommen) - Traversen-Reihen bauen sich selbst.
// ─────────────────────────────────────────────────────────────
/** Dock-Ergebnis des magnetischen Andockens. */
export interface TrussDock {
center: Pt;
rotationDeg: number;
dockedTo: string;
}
/**
* Endpunkte einer Traverse in Weltkoordinaten (CAD-7, rein).
* Rotation in Grad um das Zentrum (el.x/el.y).
*/
export function trussEndpoints(el: CADElement): [Pt, Pt] {
const length = (el.properties.length as number | undefined) ?? el.width ?? 0;
const rot = (((el.properties.rotation as number | undefined) ?? 0) * Math.PI) / 180;
const half = length / 2;
const dx = Math.cos(rot) * half;
const dy = Math.sin(rot) * half;
return [
{ x: el.x - dx, y: el.y - dy },
{ x: el.x + dx, y: el.y + dy },
];
}
/**
* Magnetisches Andocken (CAD-7, rein): Sucht unter allen Traversen
* den Endpunkt in Dock-Toleranz und liefert die Geometrie der
* neuen Traverse (Zentrum + Rotation), die exakt dort andockt.
* dockedTo = ID der Anker-Traverse; null = kein Dock in Reichweite.
*/
export function snapTrussToDock(
existing: CADElement[],
cursor: Pt,
newLength: number,
tolerance: number,
): TrussDock | null {
let best: { dock: TrussDock; dist: number } | null = null;
for (const el of existing) {
if (String(el.type) !== 'truss' || el.properties.truss !== true) continue;
const rotDeg = (el.properties.rotation as number | undefined) ?? 0;
const rot = (rotDeg * Math.PI) / 180;
const dx = Math.cos(rot);
const dy = Math.sin(rot);
const half = newLength / 2;
const ends = trussEndpoints(el);
for (let endIdx = 0; endIdx < ends.length; endIdx++) {
const end = ends[endIdx];
const dist = Math.hypot(cursor.x - end.x, cursor.y - end.y);
if (dist > tolerance) continue;
if (best && dist >= best.dist) continue;
// Neue Traverse: fuehrt VOM Dock-Ende WEG von der Anker-Traverse.
// Endpunkt 0 (Start) -> Gegenrichtung (rot+180), Endpunkt 1 -> vorwaerts.
const dir = endIdx === 0 ? -1 : 1;
const centerX = end.x + dx * half * dir;
const centerY = end.y + dy * half * dir;
const newRot = endIdx === 0 ? (rotDeg + 180) % 360 : rotDeg;
best = {
dist,
dock: {
center: { x: centerX, y: centerY },
rotationDeg: newRot,
dockedTo: el.id,
},
};
}
}
return best ? best.dock : null;
}
/** Traversen-Drag-Sitzung (CAD-7). */
let trussDragStart: Pt | null = null;
function makeTrussElement(
center: Pt,
length: number,
rotationDeg: number,
layerId: string,
): CADElement {
const rad = (rotationDeg * Math.PI) / 180;
const w = Math.abs(length * Math.cos(rad)) || length;
const h = Math.abs(length * Math.sin(rad)) || 20;
return {
id: `truss_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`,
type: 'truss',
layerId,
x: center.x,
y: center.y,
width: Math.max(w, 20),
height: 20,
properties: { length, rotation: rotationDeg, truss: true, stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280' },
} as unknown as CADElement;
}
const DOCK_TOLERANCE = 25;
export const trussTool: ToolExtensionV2 = {
manifest: {
id: 'truss',
label: 'Traverse (magnetisch)',
icon: '\u2550',
ribbonTab: 'insert',
tags: ['basic'],
description: 'Traverse platzieren: Drag = Richtung, dockt magnetisch an bestehenden Traversen an (CAD-7)',
},
optionsSchema: [
{ key: 'length', label: 'Länge (cm)', type: 'select', options: [
{ value: '100', label: '100 cm' },
{ value: '200', label: '200 cm' },
{ value: '290', label: '290 cm' },
] },
],
handlers: {
down(e, ctx) {
trussDragStart = e.world;
ctx.setStatus('Traverse: Richtung ziehen (dockt automatisch an Traversen an)');
},
move(e, ctx) {
const c = ctx as FullToolContext;
if (!trussDragStart) return;
const length = Number((c.options.length as string | undefined) ?? 200) || 200;
// Magnet-Vorschau: dockt der Cursor an ein Traversen-Ende?
const existing = c.doc ? (c.doc.getAllElements() as CADElement[]).filter(
(el) => String(el.type) === 'truss',
) : [];
const dock = snapTrussToDock(existing, trussDragStart, length, DOCK_TOLERANCE);
if (dock) {
ctx.setPreview?.(makeTrussElement(dock.center, length, dock.rotationDeg, 'layer-0'));
return;
}
// Frei: Richtung aus Drag
const ang = (Math.atan2(e.world.y - trussDragStart.y, e.world.x - trussDragStart.x) * 180) / Math.PI;
const cx = (trussDragStart.x + e.world.x) / 2;
const cy = (trussDragStart.y + e.world.y) / 2;
ctx.setPreview?.(makeTrussElement({ x: cx, y: cy }, length, ang, 'layer-0'));
},
up(e, ctx) {
const c = ctx as FullToolContext;
const start = trussDragStart;
trussDragStart = null;
ctx.setPreview?.(null);
if (!start || !c.doc) return;
const length = Number((c.options.length as string | undefined) ?? 200) || 200;
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
// MAGNETISCH: Dock-Pruefung am Drag-Startpunkt
const existing = (c.doc.getAllElements() as CADElement[]).filter(
(el) => String(el.type) === 'truss',
);
const dock = snapTrussToDock(existing, start, length, DOCK_TOLERANCE);
let el: CADElement;
if (dock) {
el = makeTrussElement(dock.center, length, dock.rotationDeg, layerId);
ctx.setStatus(`Traverse angedockt an ${dock.dockedTo} (${length}cm)`);
} else {
const ang = (Math.atan2(e.world.y - start.y, e.world.x - start.x) * 180) / Math.PI;
const cx = (start.x + e.world.x) / 2;
const cy = (start.y + e.world.y) / 2;
el = makeTrussElement({ x: cx, y: cy }, length, ang, layerId);
ctx.setStatus(`Traverse platziert (${length}cm)`);
}
c.doc.transact(() => { c.doc!.addElement(el); });
},
cancel(ctx) {
trussDragStart = null;
ctx.setPreview?.(null);
ctx.setStatus('Traverse abgebrochen');
},
},
};
export const eventToolsV2Plugin: PluginV2 = {
manifest: {
id: 'event-tools',
@@ -431,5 +607,5 @@ export const eventToolsV2Plugin: PluginV2 = {
category: 'elements',
enabledByDefault: true,
},
tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool],
tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool, trussTool],
};
@@ -399,6 +399,42 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
return [];
}
case 'truss': {
// Traverse (CAD-7): Balken als Rechteck + 2 Gurtlinien entlang
// der Rotation (wie ein Fachwerk-Profil)
const length = (props.length as number | undefined) ?? el.width;
const rotDeg = (props.rotation as number | undefined) ?? 0;
const rad = (rotDeg * Math.PI) / 180;
const dx = Math.cos(rad) * (length / 2);
const dy = Math.sin(rad) * (length / 2);
const nx = -Math.sin(rad) * 10; // Normalen-Offset (halbe Hoehe)
const ny = Math.cos(rad) * 10;
const c = { x: el.x, y: el.y };
return [
{
kind: 'rect',
x: c.x - dx, y: c.y - dy,
w: length, h: 20,
fill: fillOrNull(props.fill) ?? '#6b7280',
stroke: (props.stroke as string | undefined) ?? '#9aa0a6',
strokeWidth: 2,
},
// Gurtlinien oben/unten entlang der Traverse
{
kind: 'line',
from: { x: c.x - dx + nx, y: c.y - dy + ny },
to: { x: c.x + dx + nx, y: c.y + dy + ny },
stroke: '#4b5563', strokeWidth: 1,
},
{
kind: 'line',
from: { x: c.x - dx - nx, y: c.y - dy - ny },
to: { x: c.x + dx - nx, y: c.y + dy - ny },
stroke: '#4b5563', strokeWidth: 1,
},
];
}
case 'barrier': {
// Absperrung (Task E7): Kette aus Kreis-Paaren (properties.circles).
const circles = Array.isArray(props.circles)
+1 -1
View File
@@ -7,7 +7,7 @@ export type ElementType =
| 'polyline' | 'text' | 'dimension' | 'block_instance' | 'chair'
| 'seating-row' | 'seating-block' | 'table' | 'stage'
| 'leader' | 'revcloud' | 'image'
| 'curtain' | 'spotlight' | 'barrier';
| 'curtain' | 'spotlight' | 'barrier' | 'truss';
export type LineType = 'solid' | 'dashed' | 'dotted';