task(CAD-8): truss upgrade - length presets 50/100/200/300cm, adjustable width (default 29cm), rotatable rendering as direction polygon, boxcorner corner element with bidirectional magnetic docking

This commit is contained in:
Agent Zero
2026-08-31 20:06:51 +02:00
parent ee4142b6d1
commit 3ecc99a5e8
4 changed files with 562 additions and 47 deletions
+192 -29
View File
@@ -466,29 +466,34 @@ export function snapTrussToDock(
): 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 isTruss = String(el.type) === 'truss' && el.properties.truss === true;
const isCorner = String(el.type) === 'boxcorner' && el.properties.boxcorner === true;
if (!isTruss && !isCorner) 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);
const ends = isTruss ? trussEndpoints(el) : boxcornerEndpoints(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;
// Weg-Richtung VOM Dock-Ende (arm-spezifisch):
// Truss-Start (idx 0) = Gegenrichtung (rot+180), Truss-Ende (idx 1) = rot,
// Boxcorner-Arm1 (idx 0) = rot, Boxcorner-Arm2 (idx 1) = rot+90.
let dirDeg: number;
if (isTruss) {
dirDeg = endIdx === 0 ? (rotDeg + 180) % 360 : rotDeg;
} else {
dirDeg = endIdx === 0 ? rotDeg : (rotDeg + 90) % 360;
}
const dirRad = (dirDeg * Math.PI) / 180;
const half = newLength / 2;
const centerX = end.x + Math.cos(dirRad) * half;
const centerY = end.y + Math.sin(dirRad) * half;
best = {
dist,
dock: {
center: { x: centerX, y: centerY },
rotationDeg: newRot,
rotationDeg: dirDeg,
dockedTo: el.id,
},
};
@@ -505,19 +510,23 @@ function makeTrussElement(
length: number,
rotationDeg: number,
layerId: string,
trussWidth = 29,
): 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;
const w = Math.abs(length * Math.cos(rad)) + Math.abs(trussWidth * Math.sin(rad));
const h = Math.abs(length * Math.sin(rad)) + Math.abs(trussWidth * Math.cos(rad));
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' },
width: Math.max(w, trussWidth),
height: Math.max(h, trussWidth),
properties: {
length, rotation: rotationDeg, trussWidth, truss: true,
stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280',
},
} as unknown as CADElement;
}
@@ -534,10 +543,12 @@ export const trussTool: ToolExtensionV2 = {
},
optionsSchema: [
{ key: 'length', label: 'Länge (cm)', type: 'select', options: [
{ value: '50', label: '50 cm' },
{ value: '100', label: '100 cm' },
{ value: '200', label: '200 cm' },
{ value: '290', label: '290 cm' },
{ value: '300', label: '300 cm' },
] },
{ key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 },
],
handlers: {
down(e, ctx) {
@@ -548,13 +559,14 @@ export const trussTool: ToolExtensionV2 = {
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 trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
// Magnet-Vorschau: dockt der Cursor an ein Traversen-/Ecken-Ende?
const existing = c.doc ? (c.doc.getAllElements() as CADElement[]).filter(
(el) => String(el.type) === 'truss',
(el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner',
) : [];
const dock = snapTrussToDock(existing, trussDragStart, length, DOCK_TOLERANCE);
if (dock) {
ctx.setPreview?.(makeTrussElement(dock.center, length, dock.rotationDeg, 'layer-0'));
ctx.setPreview?.(makeTrussElement(dock.center, length, dock.rotationDeg, 'layer-0', trussWidth));
return;
}
// Frei: Richtung aus Drag
@@ -570,22 +582,23 @@ export const trussTool: ToolExtensionV2 = {
ctx.setPreview?.(null);
if (!start || !c.doc) return;
const length = Number((c.options.length as string | undefined) ?? 200) || 200;
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
// MAGNETISCH: Dock-Pruefung am Drag-Startpunkt
// MAGNETISCH: Dock-Pruefung am Drag-Startpunkt (Traversen UND Ecken)
const existing = (c.doc.getAllElements() as CADElement[]).filter(
(el) => String(el.type) === 'truss',
(el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner',
);
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)`);
el = makeTrussElement(dock.center, length, dock.rotationDeg, layerId, trussWidth);
ctx.setStatus(`Traverse angedockt an ${dock.dockedTo} (${length}cm, Breite ${trussWidth}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)`);
el = makeTrussElement({ x: cx, y: cy }, length, ang, layerId, trussWidth);
ctx.setStatus(`Traverse platziert (${length}cm, Breite ${trussWidth}cm)`);
}
c.doc.transact(() => { c.doc!.addElement(el); });
},
@@ -597,6 +610,156 @@ export const trussTool: ToolExtensionV2 = {
},
};
// ─────────────────────────────────────────────────────────────
// Task CAD-8: Boxcorner (Eck-Traverse) — L-foermiges Element
// mit zwei Armen (rot und rot+90). Magnetisch: dockt an
// Traversen-Enden und andere Ecken; Traversen docken an die
// Armenden. DREHBAR via properties.rotation (PropertiesPanel).
// ─────────────────────────────────────────────────────────────
/** Armenden einer Boxcorner: Endpunkte beider Arme vom Eckpunkt. */
export function boxcornerEndpoints(el: CADElement): [Pt, Pt] {
const armLength = (el.properties.armLength as number | undefined) ?? 50;
const rotDeg = (el.properties.rotation as number | undefined) ?? 0;
const rot = (rotDeg * Math.PI) / 180;
const rot2 = ((rotDeg + 90) * Math.PI) / 180;
return [
{ x: el.x + Math.cos(rot) * armLength, y: el.y + Math.sin(rot) * armLength },
{ x: el.x + Math.cos(rot2) * armLength, y: el.y + Math.sin(rot2) * armLength },
];
}
export interface CornerDock {
vertex: Pt;
rotationDeg: number;
dockedTo: string;
}
/**
* Magnetisches Andocken einer Ecke (rein): dockt an Traversen-
* Enden und Ecken-Armenden; am Traversen-Start-Ende wird die
* Gegenrichtung uebernommen (rot+180), damit Reihen nach aussen
* wachsen.
*/
export function cornerDockToDock(
existing: CADElement[],
cursor: Pt,
tolerance: number,
): CornerDock | null {
let best: { dock: CornerDock; dist: number } | null = null;
for (const el of existing) {
const isTruss = String(el.type) === 'truss' && el.properties.truss === true;
const isCorner = String(el.type) === 'boxcorner' && el.properties.boxcorner === true;
if (!isTruss && !isCorner) continue;
const rotDeg = (el.properties.rotation as number | undefined) ?? 0;
const ends = isTruss ? trussEndpoints(el) : boxcornerEndpoints(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;
const dir = isTruss && endIdx === 0 ? -1 : 1;
const newRot = isTruss && endIdx === 0 ? (rotDeg + 180) % 360 : rotDeg;
void dir;
best = {
dist,
dock: { vertex: { x: end.x, y: end.y }, rotationDeg: newRot, dockedTo: el.id },
};
}
}
return best ? best.dock : null;
}
function makeBoxcornerElement(
vertex: Pt,
armLength: number,
rotationDeg: number,
trussWidth: number,
layerId: string,
): CADElement {
return {
id: `boxcorner_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`,
type: 'boxcorner',
layerId,
x: vertex.x,
y: vertex.y,
width: armLength + trussWidth,
height: armLength + trussWidth,
properties: {
armLength, trussWidth, rotation: rotationDeg, boxcorner: true,
stroke: '#9aa0a6', strokeWidth: 2, fill: '#6b7280',
},
} as unknown as CADElement;
}
let cornerDragStart: Pt | null = null;
export const boxCornerTool: ToolExtensionV2 = {
manifest: {
id: 'boxcorner',
label: 'Eck-Traverse (magnetisch)',
icon: '╭',
ribbonTab: 'insert',
tags: ['basic'],
description: 'Eck-Traverse (Boxcorner): Drag = Richtung, dockt magnetisch an (CAD-8)',
},
optionsSchema: [
{ key: 'armLength', label: 'Armlänge (cm)', type: 'number', min: 20, max: 150 },
{ key: 'trussWidth', label: 'Breite (cm)', type: 'number', min: 10, max: 60 },
],
handlers: {
down(e, ctx) {
cornerDragStart = e.world;
ctx.setStatus('Eck-Traverse: Richtung ziehen (dockt automatisch an)');
},
move(e, ctx) {
const c = ctx as FullToolContext;
if (!cornerDragStart) return;
const armLength = (c.options.armLength as number | undefined) ?? 50;
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
const existing = c.doc ? (c.doc.getAllElements() as CADElement[]).filter(
(el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner',
) : [];
const dock = cornerDockToDock(existing, cornerDragStart, DOCK_TOLERANCE);
if (dock) {
ctx.setPreview?.(makeBoxcornerElement(dock.vertex, armLength, dock.rotationDeg, trussWidth, 'layer-0'));
return;
}
const ang = (Math.atan2(e.world.y - cornerDragStart.y, e.world.x - cornerDragStart.x) * 180) / Math.PI;
ctx.setPreview?.(makeBoxcornerElement(cornerDragStart, armLength, ang, trussWidth, 'layer-0'));
},
up(e, ctx) {
const c = ctx as FullToolContext;
const start = cornerDragStart;
cornerDragStart = null;
ctx.setPreview?.(null);
if (!start || !c.doc) return;
const armLength = (c.options.armLength as number | undefined) ?? 50;
const trussWidth = (c.options.trussWidth as number | undefined) ?? 29;
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0';
const existing = (c.doc.getAllElements() as CADElement[]).filter(
(el) => String(el.type) === 'truss' || String(el.type) === 'boxcorner',
);
const dock = cornerDockToDock(existing, start, DOCK_TOLERANCE);
let el: CADElement;
if (dock) {
el = makeBoxcornerElement(dock.vertex, armLength, dock.rotationDeg, trussWidth, layerId);
ctx.setStatus(`Eck-Traverse angedockt an ${dock.dockedTo}`);
} else {
const ang = (Math.atan2(e.world.y - start.y, e.world.x - start.x) * 180) / Math.PI;
el = makeBoxcornerElement(start, armLength, ang, trussWidth, layerId);
ctx.setStatus(`Eck-Traverse platziert (${armLength}cm Arme, Breite ${trussWidth}cm)`);
}
c.doc.transact(() => { c.doc!.addElement(el); });
},
cancel(ctx) {
cornerDragStart = null;
ctx.setPreview?.(null);
ctx.setStatus('Eck-Traverse abgebrochen');
},
},
};
export const eventToolsV2Plugin: PluginV2 = {
manifest: {
id: 'event-tools',
@@ -607,5 +770,5 @@ export const eventToolsV2Plugin: PluginV2 = {
category: 'elements',
enabledByDefault: true,
},
tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool, trussTool],
tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool, arcRowTool, kinoTemplateTool, banquetTemplateTool, standingTableTemplateTool, podiumTemplateTool, curtainTool, spotlightTool, barrierTool, trussTool, boxCornerTool],
};
+56 -17
View File
@@ -400,39 +400,78 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri
}
case 'truss': {
// Traverse (CAD-7): Balken als Rechteck + 2 Gurtlinien entlang
// der Rotation (wie ein Fachwerk-Profil)
// Traverse (CAD-7/8): ROTIERTES Rechteck-Polygon + Gurtlinien
// entlang der Richtung (Fachwerk-Profil), Breite = trussWidth.
const length = (props.length as number | undefined) ?? el.width;
const tWidth = (props.trussWidth as number | undefined) ?? el.height ?? 29;
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 ux = Math.cos(rad), uy = Math.sin(rad); // Laengsrichtung
const nx = -Math.sin(rad), ny = Math.cos(rad); // Normalenrichtung
const hl = length / 2, hw = tWidth / 2;
const c = { x: el.x, y: el.y };
return [
const corner = (su: number, sv: number): { x: number; y: number } => ({
x: c.x + ux * (hl * su) + nx * (hw * sv),
y: c.y + uy * (hl * su) + ny * (hw * sv),
});
const pts = [corner(-1, -1), corner(1, -1), corner(1, 1), corner(-1, 1)];
const strokeCol = (props.stroke as string | undefined) ?? '#9aa0a6';
const out: ShapePrimitive[] = [
{
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',
kind: 'polyline',
points: pts,
close: true,
stroke: strokeCol,
strokeWidth: 2,
},
// Gurtlinien oben/unten entlang der Traverse
// Gurtlinien oben/unten
{
kind: 'line',
from: { x: c.x - dx + nx, y: c.y - dy + ny },
to: { x: c.x + dx + nx, y: c.y + dy + ny },
from: corner(-1, -1),
to: corner(1, -1),
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 },
from: corner(-1, 1),
to: corner(1, 1),
stroke: '#4b5563', strokeWidth: 1,
},
];
return out;
}
case 'boxcorner': {
// Eck-Traverse (CAD-8): L-foermiges Polygon (6 Punkte) aus zwei
// Armen (rot und rot+90) um den Eckpunkt, Breite = trussWidth.
const armLength = (props.armLength as number | undefined) ?? 50;
const bWidth = (props.trussWidth as number | undefined) ?? 29;
const rotDeg = (props.rotation as number | undefined) ?? 0;
const rad = (rotDeg * Math.PI) / 180;
const ux = Math.cos(rad), uy = Math.sin(rad);
const nx = -Math.sin(rad), ny = Math.cos(rad);
const v = { x: el.x, y: el.y };
const px = (a: number, b: number): { x: number; y: number } => ({
x: v.x + ux * a + nx * b,
y: v.y + uy * a + ny * b,
});
// L-Geometrie: Arm1 entlang +u, Arm2 entlang +n (rot+90)
const pts = [
px(0, 0),
px(armLength, 0),
px(armLength, bWidth),
px(bWidth, bWidth),
px(bWidth, armLength),
px(0, armLength),
];
const strokeCol = (props.stroke as string | undefined) ?? '#9aa0a6';
return [{
kind: 'polyline',
points: pts,
close: true,
stroke: strokeCol,
strokeWidth: 2,
}];
}
case 'barrier': {
+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' | 'truss';
| 'curtain' | 'spotlight' | 'barrier' | 'truss' | 'boxcorner';
export type LineType = 'solid' | 'dashed' | 'dotted';