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:
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user