Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)

Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+217 -13
View File
@@ -133,6 +133,12 @@ export interface PlanViewHandle {
pxPerMeter: () => number;
/** Mitte des aktuellen Ausschnitts in Modell-Metern (z. B. für Import-Platzierung). */
viewCenterModel: () => Vec2;
/**
* Auf eine beliebige Punktmenge (Modell-Meter) einpassen, z. B. die Bounding-
* Box neu importierter Kontext-Objekte (Auto-Zoom nach Geo-/DXF-Import).
* Leere Punktmenge → No-op (kein Fit, kein Crash).
*/
fitBounds: (pts: Vec2[]) => void;
}
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
@@ -175,6 +181,17 @@ export interface PlanSelection {
* sonst `null`.
*/
roomId: string | null;
/**
* ID des getroffenen extrudierten Körpers (truck-Integration, Footprint-
* Umriss), wenn der Klick ihn traf und KEIN Element höherer Priorität
* (Wand/2D/Decke/Öffnung/Treppe); sonst `null`.
*/
extrudedSolidId: string | null;
/**
* ID der getroffenen Stütze (Column, Grundriss-Querschnitt), wenn der Klick sie
* traf und KEIN Element höherer Priorität (Öffnung/Wand); sonst `null`.
*/
columnId: string | null;
/**
* War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die
* Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu
@@ -263,6 +280,17 @@ export interface PlanViewProps {
* Fläche JEDES dieser Räume mit einer Akzentkontur.
*/
selectedRoomIds?: string[];
/**
* Aktuell ausgewählte IDs extrudierter Körper (truck-Integration; kontrolliert
* von App). Die Ansicht markiert den Footprint-Umriss JEDES dieser Körper mit
* einer Akzentkontur.
*/
selectedExtrudedSolidIds?: string[];
/**
* Aktuell ausgewählte Stützen-IDs (Tragwerk; kontrolliert von App). Die Ansicht
* markiert den Querschnitt-Umriss JEDER dieser Stützen mit einer Akzentkontur.
*/
selectedColumnIds?: string[];
/**
* Der Stempel-Griff des (einzeln) gewählten Raums: sein Anker (Meter) + ID.
* PlanView zeichnet einen ziehbaren Griff und meldet Bewegung/Doppelklick.
@@ -273,6 +301,8 @@ export interface PlanViewProps {
onStampMove?: (roomId: string, delta: Vec2) => void;
/** Doppelklick auf einen Raum-Stempel: Editor öffnen. */
onStampEdit?: (roomId: string) => void;
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
onEditDrawingText?: (drawingId: string) => void;
/**
* Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach
* der CAD-Konvention (siehe {@link MarqueeSelection}). Optional.
@@ -348,7 +378,11 @@ export interface GripHandlers {
* wird und koinzidente Enden der übrigen gewählten Elemente mitwandern. Fehlt
* `target`, gilt das einzeln selektierte Element (Rückwärtskompatibilität).
*/
onMoveBody(delta: Vec2, target?: { wallId?: string; drawingId?: string }): void;
onMoveBody(
delta: Vec2,
target?: { wallId?: string; drawingId?: string },
mods?: ToolMods,
): void;
/**
* Eine SEITE (Kante) ziehen: verschiebt beide Vertices der Kante senkrecht
* (nur die Normal-Komponente von `raw` wirkt; siehe App). `raw` ist der rohe
@@ -374,9 +408,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
selectedOpeningIds,
selectedStairIds,
selectedRoomIds,
selectedExtrudedSolidIds,
selectedColumnIds,
roomStamp,
onStampMove,
onStampEdit,
onEditDrawingText,
activeTool,
toolInputActive,
toolHandlers,
@@ -638,6 +675,10 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const v = viewRef.current;
return { x: (v.x + v.w / 2) / PX_PER_M, y: -(v.y + v.h / 2) / PX_PER_M };
},
fitBounds: (pts) => {
if (pts.length === 0) return; // leerer Import → kein Fit, kein Crash
setView(fitBoxFor(pts, toScreenRef.current));
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
@@ -841,7 +882,34 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
bestId = p.drawingId;
}
}
return bestId;
if (bestId) return bestId;
// Kein Linien-Treffer: Text-Elemente prüfen (eigene Bounding-Box-Logik).
return pickDrawingText(clientX, clientY);
};
// Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell-
// Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen);
// Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null.
const pickDrawingText = (clientX: number, clientY: number): string | null => {
const m = rawModelAt(clientX, clientY);
for (const p of plan.primitives) {
if (p.kind !== "drawingText" || !p.drawingId) continue;
const singleW = Math.max(1, p.text.length) * p.heightM * 0.6;
// Textspalte: Breite = wrapWidth, Höhe = geschätzte Zeilenzahl · Zeilenhöhe.
const w = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth : singleW;
const lines =
p.wrapWidth && p.wrapWidth > 0 ? Math.max(1, Math.ceil(singleW / p.wrapWidth)) : 1;
const pad = p.heightM * 0.6;
const minX = p.at.x - pad;
const maxX = p.at.x + w + pad;
// Baseline am Anker; Text läuft nach unten (Zeilen) und etwas nach oben (Oberlängen).
const minY = p.at.y - lines * p.heightM * 1.25 - pad;
const maxY = p.at.y + p.heightM * 0.4 + pad;
if (m.x >= minX && m.x <= maxX && m.y >= minY && m.y <= maxY) {
return p.drawingId;
}
}
return null;
};
// Nächstgelegene Öffnung (Fenster/Tür) zum Cursor: Linien-/Bogen-Primitive mit
@@ -917,6 +985,35 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return null;
};
// Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss
// ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon
// trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie).
const pickExtrudedSolid = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.extrudedSolidId && pointInPolygon(m, p.pts)) {
return p.extrudedSolidId;
}
}
return null;
};
// Getroffene Stütze (Tragwerk): ihr Querschnitt ist eine gefüllte Poché
// (Polygon mit columnId) — per Punkt-in-Polygon als FLÄCHE anklickbar.
const pickColumn = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.columnId && pointInPolygon(m, p.pts)) {
return p.columnId;
}
}
return null;
};
// Liegt der Cursor auf dem KÖRPER eines GEWÄHLTEN Elements (Wand-Poché bzw.
// 2D-Linie)? Liefert das gegriffene Element (Wand-ID ODER Drawing-ID) für das
// Parallel-Verschieben — oder null. Bei MEHRFACHAUSWAHL wird das Element UNTER
@@ -1188,7 +1285,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
if (md && md.pointerId === p.pointerId) {
const delta = { x: raw.x - md.last.x, y: raw.y - md.last.y };
if (delta.x !== 0 || delta.y !== 0) {
gripHandlers!.onMoveBody(delta, md.target);
gripHandlers!.onMoveBody(delta, md.target, mods);
md.last = raw;
}
return;
@@ -1402,28 +1499,46 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const openingId = pickOpening(e.clientX, e.clientY);
const wallId =
openingId ? null : hit && hit.kind === "polygon" ? hit.wallId ?? null : null;
// Traf der Klick KEINE Wand/Öffnung: zuerst eine getroffene gefüllte 2D-Fläche
// (Polygon mit drawingId), sonst die 2D-Linien (Drawing2D) per Nähe.
// Stütze (Tragwerk): ihre Querschnitt-Poché liegt ÜBER der Wand-Poché —
// ein direkter Klick auf sie trifft zuerst die Stütze (hit.columnId), sonst
// die Fläche per Punkt-in-Polygon. Priorität direkt nach Öffnung/Wand.
const polyColumnId =
hit && hit.kind === "polygon" ? hit.columnId ?? null : null;
const columnId =
openingId || wallId
? null
: polyColumnId ?? pickColumn(e.clientX, e.clientY);
// Traf der Klick KEINE Wand/Öffnung/Stütze: zuerst eine getroffene gefüllte
// 2D-Fläche (Polygon mit drawingId), sonst die 2D-Linien (Drawing2D) per Nähe.
const polyDrawingId =
hit && hit.kind === "polygon" ? hit.drawingId ?? null : null;
const drawingId =
openingId || wallId
openingId || wallId || columnId
? null
: polyDrawingId ?? pickDrawing(e.clientX, e.clientY);
// Extrudierter Körper (truck-Integration): sein Footprint-Umriss ersetzt
// die frühere Drawing2D-Fläche, also gleiche Priorität wie drawingId.
const polyExtrudedSolidId =
hit && hit.kind === "polygon" ? hit.extrudedSolidId ?? null : null;
const extrudedSolidId =
openingId || wallId || columnId || drawingId
? null
: polyExtrudedSolidId ?? pickExtrudedSolid(e.clientX, e.clientY);
// Decke nur, wenn weder Öffnung/Wand noch 2D-Element getroffen wurde. Da die
// Decken-Polygone UNTER der Wand-Poché liegen, trifft ein direkter Klick
// auf die Wand zuerst die Wand; freie Deckenfläche trifft die Decke.
const hitCeilingId =
hit && hit.kind === "polygon" ? hit.ceilingId ?? null : null;
const ceilingId = openingId || wallId || drawingId ? null : hitCeilingId;
const ceilingId =
openingId || wallId || columnId || drawingId || extrudedSolidId ? null : hitCeilingId;
// Treppe nur, wenn nichts anderes getroffen wurde (Tritte liegen frei).
const stairId =
openingId || wallId || drawingId || ceilingId
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId
? null
: pickStair(e.clientX, e.clientY);
// Raum als letzte Priorität: die Raum-Fläche liegt UNTER allem anderen.
const roomId =
openingId || wallId || drawingId || ceilingId || stairId
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId
? null
: pickRoom(e.clientX, e.clientY);
onSelect({
@@ -1435,6 +1550,8 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
openingId,
stairId,
roomId,
extrudedSolidId,
columnId,
shift: e.shiftKey,
});
}
@@ -1490,6 +1607,14 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
toolHandlers!.onToolCommit();
return;
}
// Doppelklick auf ein Text-2D-Element → Inhalt bearbeiten (Vorrang vor Raum).
if (onEditDrawingText) {
const tid = pickDrawingText(e.clientX, e.clientY);
if (tid) {
onEditDrawingText(tid);
return;
}
}
// Doppelklick auf einen Raum (Stempel-Griff ODER Fläche) → Stempel-Editor.
if (onStampEdit) {
const rid =
@@ -1614,6 +1739,33 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
);
}, [plan, selectedRoomIds]);
// Hervorhebung der selektierten extrudierten Körper (truck-Integration): der
// Footprint-Umriss (Polygon mit extrudedSolidId) mit Akzentkontur überzeichnen
// — analog zur Raum-Auswahl.
const highlightExtrudedSolidPolys = useMemo(() => {
const ids =
selectedExtrudedSolidIds && selectedExtrudedSolidIds.length
? new Set(selectedExtrudedSolidIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.extrudedSolidId != null && ids.has(p.extrudedSolidId),
);
}, [plan, selectedExtrudedSolidIds]);
// Hervorhebung der selektierten Stützen (Tragwerk): der Querschnitt-Umriss
// (Polygon mit columnId) mit Akzentkontur überzeichnen — analog Extrusion/Raum.
const highlightColumnPolys = useMemo(() => {
const ids =
selectedColumnIds && selectedColumnIds.length ? new Set(selectedColumnIds) : null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.columnId != null && ids.has(p.columnId),
);
}, [plan, selectedColumnIds]);
// Zusammenhängende 2D-Zeichen-Linien (gleiche drawingId + Stil) zu Zügen
// bündeln, damit sie als EIN <polyline>/<polygon> gezeichnet werden können —
// so gehren dicke Ecken sauber (kein Butt-Cap-Sprung). generatePlan schiebt die
@@ -1819,6 +1971,30 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten extrudierten Körper (truck-Integration). */}
{highlightExtrudedSolidPolys.map((poly, i) => (
<polygon
key={`selex-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Stützen (Tragwerk). */}
{highlightColumnPolys.map((poly, i) => (
<polygon
key={`selcol-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Hervorhebung der selektierten Öffnungen (Fenster/Tür-Symbole). */}
{highlightOpeningPrims.map((p, i) =>
p.kind === "line" ? (
@@ -2024,7 +2200,7 @@ function DraftOverlay({
snapColor: string;
}) {
const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich)
const markSize = 4.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (gleich groß wie Stützpunkt)
const markSize = 6.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (grösser = besser sichtbar)
return (
<g className="tool-overlay" pointerEvents="none">
{/* Vorschau-Formen. */}
@@ -3081,12 +3257,32 @@ function renderPrimitive(
);
}
case "drawingText": {
// Modellverankerter Einzeltext (DXF-Import): Höhe in Metern → viewBox-
// Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
// Modellverankerter Einzeltext (DXF-Import / Text-Werkzeug): Höhe in Metern →
// viewBox-Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
const c = toScreen(p.at);
const fontSize = p.heightM * PX_PER_M;
// Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg.
const deg = -(p.angle * 180) / Math.PI;
// Spaltentext: bei gesetzter `wrapWidth` den Text wortweise auf die Breite
// umbrechen (Zeichenbreite ≈ 0.55·fontSize geschätzt), sonst eine Zeile.
const lineH = fontSize * 1.25;
const lines: string[] = [];
if (p.wrapWidth && p.wrapWidth > 0) {
const maxPx = p.wrapWidth * PX_PER_M;
const charPx = fontSize * 0.55;
const maxChars = Math.max(1, Math.floor(maxPx / charPx));
for (const paragraph of p.text.split("\n")) {
let line = "";
for (const word of paragraph.split(/\s+/)) {
if (line === "") line = word;
else if ((line.length + 1 + word.length) <= maxChars) line += " " + word;
else { lines.push(line); line = word; }
}
lines.push(line);
}
} else {
lines.push(p.text);
}
return (
<text
x={c.x}
@@ -3099,7 +3295,15 @@ function renderPrimitive(
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
style={{ pointerEvents: "none", userSelect: "none" }}
>
{p.text}
{lines.length <= 1 ? (
p.text
) : (
lines.map((ln, li) => (
<tspan key={li} x={c.x} dy={li === 0 ? 0 : lineH}>
{ln === "" ? "" : ln}
</tspan>
))
)}
</text>
);
}