Lineale oben + links in allen 2D-Ansichten (Vectorworks-Stil)

Screen-fixe Lineal-Leisten über dem sichtbaren Ausschnitt: Major-Ticks mit
Meter-Beschriftung ('5.000m', Schrittweite 1/2/5·10^n nach Zoom), Minor-Ticks,
gedrehte Beschriftung am linken Lineal, gelber Cursor-Marker je Achse, Ecke
oben links. pointerEvents none — Klicks gehen durch. Gilt für Grundriss,
Schnitt, Ansicht und Zeichnungs-Ebenen (PlanRulers in PlanView, Default an,
per showRulers-Prop abschaltbar). 737/737 grün; headless verifiziert.
This commit is contained in:
2026-07-11 02:22:14 +02:00
parent 4c4c9907af
commit 1945fecd11
2 changed files with 197 additions and 4 deletions
+165 -4
View File
@@ -359,6 +359,8 @@ export interface PlanViewProps {
* und zeichnet den zurückgegebenen `draft` als Overlay (Vorschau + Snap-Marker).
*/
toolHandlers?: ToolHandlers;
/** Lineale (oben + links, VW-Stil) einblenden. Default an. */
showRulers?: boolean;
/**
* Feld-Status der Befehlszeile fürs Cursor-HUD (Tab-Feld-Zyklus §2.7):
* gelockte/live Werte je Feld, aktives Tab-Ziel und der gerade getippte
@@ -455,6 +457,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
activeTool,
toolInputActive,
toolHandlers,
showRulers = true,
hudFields,
grips,
edgeGrips,
@@ -885,11 +888,16 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
alt: e.altKey,
});
// Cursor in Modell-Metern nach oben melden (für die Status-Leiste).
// Cursor in Modell-Metern nach oben melden (für die Status-Leiste) und
// lokal für die Lineal-Marker halten (App re-rendert bei jedem Move ohnehin
// über onCursor → X/Y-Anzeige; das lokale State-Update kostet nichts extra).
const [rulerCursor, setRulerCursor] = useState<Vec2 | null>(null);
const reportCursor = (clientX: number, clientY: number) => {
if (!onCursor) return;
const v = clientToView(clientX, clientY, view);
onCursor(viewToModel(v.x, v.y));
const m = viewToModel(v.x, v.y);
setRulerCursor(m);
if (!onCursor) return;
onCursor(m);
};
// Trefferprüfung: liefert den Index des obersten Polygon-Primitivs unter dem
@@ -2005,7 +2013,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onPointerLeave={() => onCursor?.(null)}
onPointerLeave={() => { setRulerCursor(null); onCursor?.(null); }}
onWheel={onWheel}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenuHandler}
@@ -2324,6 +2332,27 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
hudFields={hudFields ?? null}
/>
)}
{/* Lineale (oben + links, VW-Stil): Modell-Meter mit Ticks, Beschriftung
und Cursor-Marker — screen-fix über den sichtbaren Ausschnitt gelegt. */}
{showRulers && (() => {
const m = measure(view);
if (!m.rect) return null;
const scale = m.scale || 1;
const vb = {
x0: view.x - m.offX / scale,
y0: view.y - m.offY / scale,
w: (m.rect.width || 1) / scale,
h: (m.rect.height || 1) / scale,
};
return (
<PlanRulers
vb={vb}
vbPerPx={1 / scale}
pxPerMeter={scale * PX_PER_M}
cursor={rulerCursor}
/>
);
})()}
</svg>
</div>
);
@@ -3745,3 +3774,135 @@ function renderPrimitive(
}
}
}
/** Schrittweite (Meter) fürs Lineal: 1/2/5·10^n, sodass Major-Ticks ≥ ~64 px auseinander liegen. */
function rulerStep(pxPerMeter: number): number {
const minPx = 64;
let step = Math.pow(10, Math.ceil(Math.log10(minPx / pxPerMeter)));
for (const f of [0.2, 0.5, 1]) {
if (step * f * pxPerMeter >= minPx) return step * f;
}
return step;
}
/**
* Lineale oben + links (Vectorworks-Stil): helle Leisten mit Major-Ticks +
* Meter-Beschriftung („5.000m"), Minor-Ticks und einem Cursor-Marker je Achse.
* Alles screen-fix (Grössen über vbPerPx), pointerEvents none — die Leisten
* liegen optisch über dem Plan, schlucken aber keine Klicks.
*/
function PlanRulers({
vb,
vbPerPx,
pxPerMeter,
cursor,
}: {
/** Sichtbarer Ausschnitt in viewBox-Einheiten. */
vb: { x0: number; y0: number; w: number; h: number };
vbPerPx: number;
pxPerMeter: number;
cursor: Vec2 | null;
}) {
const TH = 20 * vbPerPx; // Leisten-Dicke (screen-px → viewBox)
const step = rulerStep(pxPerMeter);
const minor = step / 5;
const font = 9 * vbPerPx;
// Negative Null vermeiden („−0.000m" bei Startwerten knapp unter 0).
const label = (v: number): string => `${(Math.abs(v) < 1e-9 ? 0 : v).toFixed(3)}m`;
// Sichtbarer Modellbereich (Plan-Y nach oben: vbY = y·PX_PER_M).
const mx0 = vb.x0 / PX_PER_M;
const mx1 = (vb.x0 + vb.w) / PX_PER_M;
const myTop = -vb.y0 / PX_PER_M;
const myBottom = -(vb.y0 + vb.h) / PX_PER_M;
const xTicks: { vbX: number; model: number; major: boolean }[] = [];
const startX = Math.floor(mx0 / minor) * minor;
for (let v = startX; v <= mx1 + 1e-9; v += minor) {
const major = Math.abs(v / step - Math.round(v / step)) < 1e-6;
xTicks.push({ vbX: v * PX_PER_M, model: v, major });
}
const yTicks: { vbY: number; model: number; major: boolean }[] = [];
const startY = Math.floor(myBottom / minor) * minor;
for (let v = startY; v <= myTop + 1e-9; v += minor) {
const major = Math.abs(v / step - Math.round(v / step)) < 1e-6;
yTicks.push({ vbY: -v * PX_PER_M, model: v, major });
}
const x0 = vb.x0;
const y0 = vb.y0;
return (
<g className="plan-ruler" pointerEvents="none">
{/* Leisten + Trennlinien. */}
<rect className="plan-ruler-strip" x={x0} y={y0} width={vb.w} height={TH} />
<rect className="plan-ruler-strip" x={x0} y={y0} width={TH} height={vb.h} />
<line className="plan-ruler-border" x1={x0} y1={y0 + TH} x2={x0 + vb.w} y2={y0 + TH} />
<line className="plan-ruler-border" x1={x0 + TH} y1={y0} x2={x0 + TH} y2={y0 + vb.h} />
{/* Oben: Ticks + Beschriftung. */}
{xTicks.map((t, i) =>
t.vbX < x0 + TH ? null : (
<g key={`x${i}`}>
<line
className="plan-ruler-tick"
x1={t.vbX}
y1={y0 + (t.major ? TH * 0.45 : TH * 0.72)}
x2={t.vbX}
y2={y0 + TH}
/>
{t.major && (
<text className="plan-ruler-text" x={t.vbX + 3 * vbPerPx} y={y0 + TH * 0.42} fontSize={font}>
{label(t.model)}
</text>
)}
</g>
),
)}
{/* Links: Ticks + gedrehte Beschriftung. */}
{yTicks.map((t, i) =>
t.vbY < y0 + TH ? null : (
<g key={`y${i}`}>
<line
className="plan-ruler-tick"
x1={x0 + (t.major ? TH * 0.45 : TH * 0.72)}
y1={t.vbY}
x2={x0 + TH}
y2={t.vbY}
/>
{t.major && (
<text
className="plan-ruler-text"
x={x0 + TH * 0.42}
y={t.vbY - 3 * vbPerPx}
fontSize={font}
transform={`rotate(-90 ${x0 + TH * 0.42} ${t.vbY - 3 * vbPerPx})`}
>
{label(t.model)}
</text>
)}
</g>
),
)}
{/* Cursor-Marker (VW-gelb) je Achse. */}
{cursor && (
<>
<rect
className="plan-ruler-cursor"
x={cursor.x * PX_PER_M - 1.5 * vbPerPx}
y={y0}
width={3 * vbPerPx}
height={TH}
/>
<rect
className="plan-ruler-cursor"
x={x0}
y={-cursor.y * PX_PER_M - 1.5 * vbPerPx}
width={TH}
height={3 * vbPerPx}
/>
</>
)}
{/* Ecke oben links. */}
<rect className="plan-ruler-corner" x={x0} y={y0} width={TH} height={TH} />
</g>
);
}
+32
View File
@@ -2058,6 +2058,38 @@ body {
font-weight: 700;
}
/* Lineale (oben + links, VW-Stil): helle Papier-Leisten, Tinte-Ticks,
gelber Cursor-Marker. */
.plan-svg .plan-ruler-strip {
fill: #f7f6f3;
fill-opacity: 0.96;
}
.plan-svg .plan-ruler-border {
stroke: #b9b5ae;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.plan-svg .plan-ruler-tick {
stroke: #6d6a64;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.plan-svg .plan-ruler-text {
fill: #3c3a36;
font-family: var(--mono, ui-monospace, monospace);
font-weight: 500;
}
.plan-svg .plan-ruler-cursor {
fill: #e8c33c;
fill-opacity: 0.9;
}
.plan-svg .plan-ruler-corner {
fill: #f0efec;
stroke: #b9b5ae;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
/* Weicher Winkel-Snap: gestrichelte Führungslinie (dezent) + Winkel-Badge in
derselben dunklen Dropdown-Pille wie das Cursor-HUD. */
.plan-svg .plan-angle-guide-line {