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
+382 -63
View File
@@ -19,6 +19,7 @@ import { t } from "../i18n";
import { formatM } from "../model/types";
import type {
SiaCategory,
SliceTermination,
StairShape,
VerticalAnchor,
WallReferenceLine,
@@ -28,6 +29,8 @@ import { Dropdown } from "../ui/Dropdown";
import { usePanelHost } from "./host";
import type {
CeilingInfo,
ColumnInfo,
ExtrudedSolidInfo,
OpeningInfo,
RoomInfo,
StairInfo,
@@ -68,11 +71,24 @@ export function ObjectInfoPanel() {
const width = bbox.maxX - bbox.minX;
const height = bbox.maxY - bbox.minY;
// X/Y des gewählten Bezugspunkts (Modell-Meter). Read-only: Der Kontrakt
// bietet keinen Positions-Setter, daher wird hier kein Verschieben gefaket.
// X/Y des gewählten Bezugspunkts (Modell-Meter). Editierbar: Commit
// verschiebt die Selektion so, dass der gewählte Bezugspunkt auf die
// eingegebene Koordinate wandert (Delta = neu aktuell → generischer Move
// über den Host). Bei Elementarten, die der Transform-Kern nicht kennt
// (Decke/Öffnung/Treppe/Raum), ist der Host-Aufruf ein No-op — das Feld
// zeigt danach wieder den unveränderten Wert (wie bei Breite/Höhe oben).
const px = bbox.minX + anchor.fx * width;
const py = bbox.minY + anchor.fy * height;
// Rohe Drawing2D-Geometrie der Selektion (nur bei kind==="drawing2d") — für
// die Linien-Länge/Kreis-Radius-Felder unten; die Selection-Sicht trägt
// keine Geometrieform, daher direkter (Lese-)Zugriff aufs Projekt.
const rawDrawing =
sel.kind === "drawing2d"
? host.project.drawings2d.find((d) => d.id === sel.id)
: undefined;
const drawingShape = rawDrawing?.geom.shape;
// ── Maß-Commit ──────────────────────────────────────────────────────────────
// Liest beide aktuellen Maße aus den Feldern und skaliert um den aktiven
// Anker. Negative/ungültige Werte werden ignoriert (kein Resize).
@@ -94,44 +110,57 @@ export function ObjectInfoPanel() {
<div className="objinfo-sep" />
{/* Bezugspunkt-Würfel (3×3). */}
{/* Bezugspunkt-Würfel (3×3, immer quadratisch) links, X/Y/Z als Spalte
rechts daneben (Nutzer-Wunsch). */}
<div className="objinfo-section-label">{t("objinfo.refpoint")}</div>
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
{FRACTIONS.map((fy) =>
FRACTIONS.map((fx) => {
const active = anchor.fx === fx && anchor.fy === fy;
return (
<button
key={anchorKey(fx, fy)}
type="button"
className={"objinfo-anchor" + (active ? " active" : "")}
title={t("objinfo.setRefpoint")}
aria-pressed={active}
onClick={() => setAnchor({ fx, fy })}
>
<span className="objinfo-dot" />
</button>
);
}),
)}
</div>
<div className="objinfo-refrow">
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
{FRACTIONS.map((fy) =>
FRACTIONS.map((fx) => {
const active = anchor.fx === fx && anchor.fy === fy;
return (
<button
key={anchorKey(fx, fy)}
type="button"
className={"objinfo-anchor" + (active ? " active" : "")}
title={t("objinfo.setRefpoint")}
aria-pressed={active}
onClick={() => setAnchor({ fx, fy })}
>
<span className="objinfo-dot" />
</button>
);
}),
)}
</div>
{/* X / Y des gewählten Bezugspunkts (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.x")}</span>
<span className="objinfo-fval">{formatM(px)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.y")}</span>
<span className="objinfo-fval">{formatM(py)}</span>
</div>
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
Daher „—" statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.z")}</span>
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
</span>
{/* X / Y des gewählten Bezugspunkts (editierbar) + Z (read-only) + ein
Dreh-Feld, als Spalte. */}
<div className="objinfo-xyzcol">
<DimensionField
label={t("objinfo.x")}
value={px}
min={-Infinity}
onCommit={(nx) => host.onMoveSelectionBy(nx - px, 0)}
/>
<DimensionField
label={t("objinfo.y")}
value={py}
min={-Infinity}
onCommit={(ny) => host.onMoveSelectionBy(0, ny - py)}
/>
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
Daher „—" statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.z")}</span>
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
</span>
</div>
{/* Drehen: Delta-Winkel (Grad) um den gewählten Bezugspunkt — kein
persistenter Zustand, das Feld zeigt nach dem Commit wieder „0". */}
<RotateField onCommit={(deg) => host.onRotateSelectionAround(px, py, deg)} />
</div>
</div>
<div className="objinfo-sep" />
@@ -148,6 +177,23 @@ export function ObjectInfoPanel() {
value={height}
onCommit={(h) => commitSize(width, h)}
/>
{/* Linien-Länge bzw. Kreis-Radius (nur wenn die Selektion genau eine
Linie/ein Kreis ist — das Modell gibt das direkt her, andere Formen
bleiben ohne Zusatzfeld). */}
{drawingShape === "line" && (
<DimensionField
label={t("objinfo.drawing.length")}
value={Math.hypot(width, height)}
onCommit={(v) => host.onSetDrawingLineLength(v)}
/>
)}
{drawingShape === "circle" && (
<DimensionField
label={t("objinfo.drawing.radius")}
value={width / 2}
onCommit={(v) => host.onSetDrawingCircleRadius(v)}
/>
)}
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
die exportierten *Section-Komponenten). */}
@@ -228,6 +274,153 @@ export function RoomSection({
);
}
// ── Extrusions-Attribut-Abschnitt (truck-Integration) ───────────────────────
// Höhe editierbar (löst Re-Extrusion aus); Grundfläche + Geschoss read-only.
export function ExtrudedSolidSection({
solid,
host,
}: {
solid: ExtrudedSolidInfo;
host: ReturnType<typeof usePanelHost>;
}) {
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.extrudedSolid.section")}</div>
{/* Höhe (editierbar, > 0) — löst eine Re-Extrusion (truck-WASM) aus. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.height")}</span>
<input
type="number"
className="objinfo-text"
step={0.01}
min={0.01}
value={solid.height}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v) && v > 0) host.onSetExtrudedSolidHeight(v);
}}
title={t("objinfo.extrudedSolid.height")}
/>
</div>
{/* Verjüngung (editierbar, 0..1) — löst eine Re-Extrusion (truck-WASM) aus. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.taper")}</span>
<input
type="number"
className="objinfo-text"
step={0.05}
min={0}
max={1}
value={solid.taper}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetExtrudedSolidTaper(v);
}}
title={t("objinfo.extrudedSolid.taper")}
/>
</div>
{/* Grundfläche (read-only, abgeleitet). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.area")}</span>
<span className="objinfo-fval">{solid.area.toFixed(2)} m²</span>
</div>
{/* Geschoss (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.floor")}</span>
<span className="objinfo-fval">{solid.floorName}</span>
</div>
</>
);
}
// ── Stützen-Attribut-Abschnitt (Tragwerk) ───────────────────────────────────
// Profil (Rechteck/Kreis) + Masse, Höhe, Drehung editierbar; Geschoss + UK/OK
// read-only.
export function ColumnSection({
column,
host,
}: {
column: ColumnInfo;
host: ReturnType<typeof usePanelHost>;
}) {
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.column.section")}</div>
{/* Profil-Dropdown (Rechteck/Kreis). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.column.profile")}</span>
<Dropdown
value={column.profile.kind}
onChange={(v) => host.onSetColumnProfileKind(v as "rect" | "round")}
options={[
{ value: "rect", label: t("objinfo.column.profile.rect") },
{ value: "round", label: t("objinfo.column.profile.round") },
]}
title={t("objinfo.column.profile")}
width={130}
/>
</div>
{/* Editierbare Masse je Profil. */}
{column.profile.kind === "round" ? (
<DimensionField
label={t("objinfo.column.radius")}
value={column.profile.radius}
onCommit={(v) => host.onSetColumnRadius(v)}
/>
) : (
<>
<DimensionField
label={t("objinfo.column.width")}
value={column.profile.kind === "rect" ? column.profile.width : 0}
onCommit={(v) => host.onSetColumnWidth(v)}
/>
<DimensionField
label={t("objinfo.column.depth")}
value={column.profile.kind === "rect" ? column.profile.depth : 0}
onCommit={(v) => host.onSetColumnDepth(v)}
/>
</>
)}
<DimensionField
label={t("objinfo.column.height")}
value={column.height}
onCommit={(v) => host.onSetColumnHeight(v)}
/>
{/* Drehung (Grad). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.column.rotation")}</span>
<input
type="number"
className="objinfo-text"
step={1}
value={Number(column.rotationDeg.toFixed(1))}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetColumnRotation(v);
}}
title={t("objinfo.column.rotation")}
/>
</div>
{/* Geschoss (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.column.floor")}</span>
<span className="objinfo-fval">{column.floorName}</span>
</div>
</>
);
}
// ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
@@ -243,6 +436,24 @@ export function StairSection({
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.stair.section")}</div>
{/* Treppentyp (Bibliothek) — treibt u. a. die 3D-Tragart (massiv/offen). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.type")}</span>
<Dropdown
value={stair.typeId ?? ""}
onChange={(v) => host.onSetStairType(v)}
options={[
{ value: "", label: t("objinfo.type.none") },
...(host.project.stairTypes ?? []).map((st) => ({
value: st.id,
label: st.name,
})),
]}
title={t("objinfo.stair.type")}
width={130}
/>
</div>
{/* Grundform-Dropdown. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.shape")}</span>
@@ -360,6 +571,24 @@ export function OpeningSection({
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.opening.section")}</div>
{/* Tür-/Fenstertyp (Bibliothek) — je nach Art aus doorTypes bzw. windowTypes. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.type")}</span>
<Dropdown
value={opening.typeId ?? ""}
onChange={(v) => host.onSetOpeningType(v)}
options={[
{ value: "", label: t("objinfo.type.none") },
...(isDoor
? host.project.doorTypes ?? []
: host.project.windowTypes ?? []
).map((ty) => ({ value: ty.id, label: ty.name })),
]}
title={t("objinfo.opening.type")}
width={130}
/>
</div>
{/* Art-Segment Fenster/Tür. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.kind")}</span>
@@ -596,11 +825,15 @@ export function CeilingSection({
<span className="objinfo-fval">{ceiling.floorName}</span>
</div>
{/* Fläche (read-only). */}
{/* Fläche + Volumen (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.area")}</span>
<span className="objinfo-fval">{ceiling.area.toFixed(2)} m²</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.volume")}</span>
<span className="objinfo-fval">{ceiling.volume.toFixed(2)} m³</span>
</div>
<div className="objinfo-sep" />
@@ -614,11 +847,16 @@ export function CeilingSection({
onChange={(a) => host.onSetCeilingTop(a)}
/>
{/* UK = OK Dicke (abgeleitet, read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.bottom")}</span>
<span className="objinfo-fval">{formatM(ceiling.zBottom)}</span>
</div>
{/* UK der Decke: an Geschoss gebunden ODER eigene Höhe; ohne Override
bleibt es beim heutigen Verhalten UK = OK Dicke. */}
<VerticalAnchorRow
label={t("objinfo.ceiling.bottom")}
anchor={ceiling.bottom}
defaultZ={ceiling.zBottom}
floors={ceiling.floors}
defaultFloorId={ceiling.floorId}
onChange={(a) => host.onSetCeilingBottom?.(a)}
/>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.height")}</span>
<span className="objinfo-fval">{formatM(ceiling.zTop - ceiling.zBottom)}</span>
@@ -678,6 +916,23 @@ export function WallSection({
/>
</div>
{/* Terminierung am Deckenanschluss (Zuschnitt, NICHT Priorität): ob die Wand
an der Decke endet oder oberhalb wieder auftaucht. Default "both". */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.sliceTermination")}</span>
<Dropdown
value={wall.sliceTermination}
onChange={(v) => host.onSetWallSliceTermination(v as SliceTermination)}
options={[
{ value: "both", label: t("objinfo.wall.sliceTermination.both") },
{ value: "below", label: t("objinfo.wall.sliceTermination.below") },
{ value: "above", label: t("objinfo.wall.sliceTermination.above") },
]}
title={t("objinfo.wall.sliceTermination")}
width={120}
/>
</div>
{/* Aufbau-Typ-Segment. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
@@ -730,6 +985,26 @@ export function WallSection({
<span className="objinfo-fval">{wall.floorName}</span>
</div>
{/* Länge + Volumen (read-only). Volumen ist NETTO — Fenster/Türen
abgezogen; der Titel zeigt zusätzlich das Bruttovolumen. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.length")}</span>
<span className="objinfo-fval">{formatM(wall.length)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.volume")}</span>
<span
className="objinfo-fval"
title={
wall.openingVolume > 0
? `${t("objinfo.wall.volumeGross")}: ${wall.grossVolume.toFixed(2)} ${wall.openingVolume.toFixed(2)}`
: undefined
}
>
{wall.netVolume.toFixed(2)} m³
</span>
</div>
{/* Verknüpfung zum oberen Geschoss: OK an nächstes Geschoss binden. */}
<label className="objinfo-field objinfo-check-field">
<span className="objinfo-flabel">{t("objinfo.wall.linkAbove")}</span>
@@ -755,15 +1030,8 @@ export function WallSection({
<div className="objinfo-sep" />
{/* UK & OK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". */}
<VerticalAnchorRow
label={t("objinfo.wall.bottom")}
anchor={wall.bottom}
defaultZ={wall.zBottom}
floors={wall.floors}
defaultFloorId={wall.floorId}
onChange={(a) => host.onSetWallBottom(a)}
/>
{/* OK & UK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". OK
(oben) steht oben, UK (unten) darunter — räumlich passend (Nutzer). */}
<VerticalAnchorRow
label={t("objinfo.wall.top")}
anchor={wall.top}
@@ -772,6 +1040,14 @@ export function WallSection({
defaultFloorId={wall.floorAbove?.id ?? wall.floorId}
onChange={(a) => host.onSetWallTop(a)}
/>
<VerticalAnchorRow
label={t("objinfo.wall.bottom")}
anchor={wall.bottom}
defaultZ={wall.zBottom}
floors={wall.floors}
defaultFloorId={wall.floorId}
onChange={(a) => host.onSetWallBottom(a)}
/>
{/* Höhe = OK UK (abgeleitet, read-only). */}
<div className="objinfo-field">
@@ -836,25 +1112,27 @@ function VerticalAnchorRow({
</button>
</div>
</div>
{/* Unterzeile in normaler Label-/Wert-Struktur: Label links, Control in
der Wert-Spalte (Nutzer-Wunsch — vorher hing das Control ohne Label
im Einzug der linken Spalte). */}
{mode === "floor" ? (
<div className="objinfo-field objinfo-subfield">
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.anchor.refFloor")}</span>
<Dropdown
value={anchor?.mode === "floor" ? anchor.floorId : defaultFloorId}
onChange={(id) => onChange({ mode: "floor", floorId: id })}
options={floors.map((f) => ({ value: f.id, label: f.name }))}
title={label}
title={t("objinfo.anchor.refFloor")}
width={150}
/>
</div>
) : (
<div className="objinfo-subfield">
<DimensionField
label={t("objinfo.z")}
value={anchor?.mode === "custom" ? anchor.z : defaultZ}
min={-Infinity}
onCommit={(z) => onChange({ mode: "custom", z })}
/>
</div>
<DimensionField
label={t("objinfo.anchor.customHeight")}
value={anchor?.mode === "custom" ? anchor.z : defaultZ}
min={-Infinity}
onCommit={(z) => onChange({ mode: "custom", z })}
/>
)}
</div>
);
@@ -915,3 +1193,44 @@ function DimensionField({
</label>
);
}
// ── Dreh-Feld (Delta-Winkel in Grad) ─────────────────────────────────────────
// Anders als DimensionField zeigt dieses Feld KEINEN persistenten Host-Wert —
// eine „aktuelle Rotation" gibt es für die Selektion generell nicht (Wand ist
// nur eine Achse, Drawing2D/Extrusion tragen keinen Rotationswinkel). Das Feld
// ist daher ein Delta: Eingabe = Drehung ab jetzt, danach zeigt es wieder „0".
function RotateField({ onCommit }: { onCommit: (deg: number) => void }) {
const [draft, setDraft] = useState<string | null>(null);
const shown = draft ?? "0";
function commit() {
if (draft === null) return;
const v = Number(draft);
setDraft(null);
if (isFinite(v) && v !== 0) onCommit(v);
}
return (
<label className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.rotate")}</span>
<input
className="objinfo-input"
type="number"
step={1}
value={shown}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
commit();
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(null);
(e.target as HTMLInputElement).blur();
}
}}
/>
<span className="objinfo-unit">°</span>
</label>
);
}