// Inhalts-Panel „Objekt-Info" (Vectorworks-Stil) — kompakte Sicht auf die // aktuelle Auswahl: ein 3×3-Bezugspunkt-„Würfel", die X/Y-Koordinaten des // gewählten Bezugspunkts (read-only) sowie editierbare Maße Breite×Höhe. // // Bei Auswahl GENAU EINER Wand zeigt das Panel zusätzlich einen Wand-Abschnitt // (Referenzlinie, Aufbau-Typ/Dicke/Preset, Referenzgeschoss + UK/OK) — analog // Vectorworks. Die Referenzlinie sitzt rechts oben im Kopf. // // Alle Daten/Handler kommen über usePanelHost (kein Prop-Drilling, kein // Modellzugriff). Der gewählte Bezugspunkt (fx,fy ∈ {0,0.5,1}) ist lokaler // State und wird als Anker an onResizeSelection durchgereicht: Beim Ändern // der Maße bleibt genau dieser Punkt fix. // // Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren // Texte über t(...). import { useState } from "react"; import { t } from "../i18n"; import type { TranslationKey } from "../i18n"; import { formatM } from "../model/types"; import type { RoofShape, SiaCategory, SliceTermination, StairShape, VerticalAnchor, WallReferenceLine, } from "../model/types"; import { SIA_CATEGORIES, siaLabelFull } from "../geometry/roomArea"; import { Dropdown } from "../ui/Dropdown"; import { usePanelHost } from "./host"; import type { CeilingInfo, ColumnInfo, ExtrudedSolidInfo, OpeningInfo, RoofInfo, RoomInfo, StairInfo, WallInfo, } from "../state/selectionInfo"; import type { MeasurementReadout } from "../tools/types"; // ── Bezugspunkt-Raster ─────────────────────────────────────────────────────── // Neun Anker als 3×3-Raster. Zeile 0 = oben (fy=0), Spalte 0 = links (fx=0). // Reihenfolge des Arrays = Lesereihenfolge des Grids (links→rechts, oben→unten). const FRACTIONS = [0, 0.5, 1] as const; /** Stabiler Schlüssel je Anker (z. B. "0-0.5"), unabhängig von Float-Anzeige. */ function anchorKey(fx: number, fy: number): string { return `${fx}-${fy}`; } export function ObjectInfoPanel() { const host = usePanelHost(); const sel = host.selection; // Gewählter Bezugspunkt — Default Mitte (Vectorworks-Standardanker beim // Skalieren). Bleibt erhalten, solange das Panel montiert ist. const [anchor, setAnchor] = useState<{ fx: number; fy: number }>({ fx: 0.5, fy: 0.5, }); // ── Mess-Werkzeug: Live-Messwerte (Länge/Fläche/Winkel) ───────────────────── // Werden angezeigt, sobald gemessen wird — unabhängig von einer Selektion // (das Mess-Werkzeug erzeugt kein Element). Hat Vorrang vor dem Leerzustand. const m = host.measurement; // ── Leerzustand ─────────────────────────────────────────────────────────── if (sel === null) { return (
{m ? :
{t("objinfo.empty")}
}
); } const { bbox } = sel; const width = bbox.maxX - bbox.minX; const height = bbox.maxY - bbox.minY; // 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). function commitSize(nextWidth: number, nextHeight: number) { if (!isFinite(nextWidth) || !isFinite(nextHeight)) return; if (nextWidth < 0 || nextHeight < 0) return; host.onResizeSelection(nextWidth, nextHeight, anchor); } return (
{m && } {/* Kopfzeile: Typ + Kategorie. Die element-spezifischen Attribut-Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) liegen jetzt im Attribute-Panel; ObjectInfo trägt nur noch Bezugspunkt + Masse. */}
{t(`objinfo.kind.${sel.kind}`)} {sel.categoryCode}
{/* Bezugspunkt-Würfel (3×3, immer quadratisch) links, X/Y/Z als Spalte rechts daneben (Nutzer-Wunsch). */}
{t("objinfo.refpoint")}
{FRACTIONS.map((fy) => FRACTIONS.map((fx) => { const active = anchor.fx === fx && anchor.fy === fy; return ( ); }), )}
{/* X / Y des gewählten Bezugspunkts (editierbar) + Z (read-only) + ein Dreh-Feld, als Spalte. */}
host.onMoveSelectionBy(nx - px, 0)} /> 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. */}
{t("objinfo.z")}
{/* Drehen: Delta-Winkel (Grad) um den gewählten Bezugspunkt — kein persistenter Zustand, das Feld zeigt nach dem Commit wieder „0". */} host.onRotateSelectionAround(px, py, deg)} />
{/* Maße (editierbar) — Commit auf blur/Enter skaliert um den Anker. */}
{t("objinfo.dimensions")}
commitSize(w, height)} /> 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" && ( host.onSetDrawingLineLength(v)} /> )} {drawingShape === "circle" && ( host.onSetDrawingCircleRadius(v)} /> )} {/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert die exportierten *Section-Komponenten). */}
); } // ── Raum-Attribut-Abschnitt ────────────────────────────────────────────────── // Editierbarer Name, SIA-416-Kategorie (5 Blatt-Kategorien), abgeleitete Fläche // + Umfang (read-only), Referenzgeschoss, sowie ein Button, der den Rich-Text- // Stempel-Editor öffnet. Alle Werte/Setter über den Host. export function RoomSection({ room, roomId, host, }: { room: RoomInfo; roomId: string; host: ReturnType; }) { return ( <>
{t("objinfo.room.section")}
{/* Name (editierbar). */}
{t("objinfo.room.name")} host.onSetRoomName(e.target.value)} title={t("objinfo.room.name")} />
{/* SIA-416-Kategorie (5 Blatt-Kategorien). */}
{t("objinfo.room.sia")} host.onSetRoomSia(v as SiaCategory)} options={SIA_CATEGORIES.map((c) => ({ value: c, label: siaLabelFull(c), }))} title={t("objinfo.room.sia")} width={170} />
{/* Fläche + Umfang (read-only, abgeleitet). */}
{t("objinfo.room.area")} {room.area.toFixed(2)} m²
{t("objinfo.room.perimeter")} {formatM(room.perimeter)}
{/* Referenzgeschoss (read-only). */}
{t("objinfo.room.refFloor")} {room.floorName}
{/* Stempel bearbeiten (öffnet den Rich-Text-Editor). */} ); } // ── 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; }) { return ( <>
{t("objinfo.extrudedSolid.section")}
{/* Höhe (editierbar, > 0) — löst eine Re-Extrusion (truck-WASM) aus. */}
{t("objinfo.extrudedSolid.height")} { const v = Number(e.target.value); if (Number.isFinite(v) && v > 0) host.onSetExtrudedSolidHeight(v); }} title={t("objinfo.extrudedSolid.height")} />
{/* Verjüngung (editierbar, 0..1) — löst eine Re-Extrusion (truck-WASM) aus. */}
{t("objinfo.extrudedSolid.taper")} { const v = Number(e.target.value); if (Number.isFinite(v)) host.onSetExtrudedSolidTaper(v); }} title={t("objinfo.extrudedSolid.taper")} />
{/* Grundfläche (read-only, abgeleitet). */}
{t("objinfo.extrudedSolid.area")} {solid.area.toFixed(2)} m²
{/* Geschoss (read-only). */}
{t("objinfo.extrudedSolid.floor")} {solid.floorName}
); } // ── 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; }) { return ( <>
{t("objinfo.column.section")}
{/* Profil-Dropdown (Rechteck/Kreis). */}
{t("objinfo.column.profile")} 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} />
{/* Editierbare Masse je Profil. */} {column.profile.kind === "round" ? ( host.onSetColumnRadius(v)} /> ) : ( <> host.onSetColumnWidth(v)} /> host.onSetColumnDepth(v)} /> )} host.onSetColumnHeight(v)} /> {/* Drehung (Grad). */}
{t("objinfo.column.rotation")} { const v = Number(e.target.value); if (Number.isFinite(v)) host.onSetColumnRotation(v); }} title={t("objinfo.column.rotation")} />
{/* Geschoss (read-only). */}
{t("objinfo.column.floor")} {column.floorName}
); } // ── Treppen-Attribut-Abschnitt ─────────────────────────────────────────────── // Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete // Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only. /** Rechteck-Umriss (CCW) aus unterer/linker Ecke + Breite/Tiefe (für Dach-Resize). */ function rectOutline(x0: number, y0: number, w: number, d: number) { return [ { x: x0, y: y0 }, { x: x0 + w, y: y0 }, { x: x0 + w, y: y0 + d }, { x: x0, y: y0 + d }, ]; } /** Dach-Abschnitt: Form, Neigung(en), Überstand, Firstrichtung, Dicke. */ export function RoofSection({ roof, host, }: { roof: RoofInfo; host: ReturnType; }) { const numField = ( labelKey: TranslationKey, value: number, step: number, apply: (v: number) => void, ) => (
{t(labelKey)} { const v = Number(e.target.value); if (Number.isFinite(v)) apply(v); }} title={t(labelKey)} />
); return ( <>
{t("objinfo.roof.section")}
{/* Dachform. */}
{t("objinfo.roof.shape")} host.onSetRoofPatch({ shape: v as RoofShape })} options={[ { value: "flach", label: t("objinfo.roof.shape.flach") }, { value: "pult", label: t("objinfo.roof.shape.pult") }, { value: "sattel", label: t("objinfo.roof.shape.sattel") }, { value: "walm", label: t("objinfo.roof.shape.walm") }, { value: "mansarde", label: t("objinfo.roof.shape.mansarde") }, { value: "zelt", label: t("objinfo.roof.shape.zelt") }, ]} title={t("objinfo.roof.shape")} width={130} />
{/* Firstrichtung (X/Y) — nur bei Formen mit First relevant. */} {roof.shape !== "flach" && roof.shape !== "zelt" && (
{t("objinfo.roof.ridgeAxis")} host.onSetRoofPatch({ ridgeAxis: v as "x" | "y" })} options={[ { value: "x", label: t("objinfo.roof.ridgeAxis.x") }, { value: "y", label: t("objinfo.roof.ridgeAxis.y") }, ]} title={t("objinfo.roof.ridgeAxis")} width={130} />
)} {/* Neigung(en) — Flachdach hat keine. */} {roof.shape !== "flach" && numField("objinfo.roof.pitch", roof.pitchDeg, 1, (v) => host.onSetRoofPatch({ pitchDeg: Math.max(0, Math.min(85, v)) }), )} {roof.shape === "mansarde" && numField("objinfo.roof.pitchUpper", roof.pitchUpperDeg ?? roof.pitchDeg / 2, 1, (v) => host.onSetRoofPatch({ pitchUpperDeg: Math.max(0, Math.min(85, v)) }), )} {/* Mansarde-Untertyp (Giebel/Walm/Zelt) + Knicklage. */} {roof.shape === "mansarde" && (
{t("objinfo.roof.mansardType")} host.onSetRoofPatch({ mansardType: v as "giebel" | "walm" | "zelt" }) } options={[ { value: "giebel", label: t("objinfo.roof.mansard.giebel") }, { value: "walm", label: t("objinfo.roof.mansard.walm") }, { value: "zelt", label: t("objinfo.roof.mansard.zelt") }, ]} title={t("objinfo.roof.mansardType")} width={130} />
)} {roof.shape === "mansarde" && numField("objinfo.roof.mansardKnee", (roof.mansardKneeRatio ?? 0.4) * 100, 1, (v) => host.onSetRoofPatch({ mansardKneeRatio: Math.max(5, Math.min(49, v)) / 100 }), )} {/* Grundriss-Masse (Breite/Tiefe): das Umriss-Rechteck neu bilden, die untere/linke Ecke (minX/minY) bleibt fix. */} {numField("objinfo.roof.width", roof.width, 0.1, (v) => { const w = Math.max(0.2, v); host.onSetRoofPatch({ outline: rectOutline(roof.minX, roof.minY, w, roof.depth), }); })} {numField("objinfo.roof.depth", roof.depth, 0.1, (v) => { const d = Math.max(0.2, v); host.onSetRoofPatch({ outline: rectOutline(roof.minX, roof.minY, roof.width, d), }); })} {/* Überstand + Dicke (Meter). */} {numField("objinfo.roof.overhang", roof.overhang, 0.05, (v) => host.onSetRoofPatch({ overhang: Math.max(0, v) }), )} {numField("objinfo.roof.thickness", roof.thickness, 0.02, (v) => host.onSetRoofPatch({ thickness: Math.max(0.01, v) }), )} {/* Traufhöhe (absolutes Z) — z. B. für einen Kniestock übers Geschoss anheben. */} {numField("objinfo.roof.baseElevation", roof.baseElevation, 0.1, (v) => host.onSetRoofPatch({ baseElevation: v }), )} {/* Abgeleitete Werte (read-only). */}
{t("objinfo.roof.ridgeHeight")} {formatM(roof.ridgeHeight)}
{t("objinfo.roof.area")} {roof.footprintArea.toFixed(2)} m²
); } export function StairSection({ stair, host, }: { stair: StairInfo; host: ReturnType; }) { return ( <>
{t("objinfo.stair.section")}
{/* Treppentyp (Bibliothek) — treibt u. a. die 3D-Tragart (massiv/offen). */}
{t("objinfo.stair.type")} 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} />
{/* Grundform-Dropdown. */}
{t("objinfo.stair.shape")} host.onSetStairShape(v as StairShape)} options={[ { value: "straight", label: t("objinfo.stair.shape.straight") }, { value: "L", label: t("objinfo.stair.shape.L") }, { value: "spiral", label: t("objinfo.stair.shape.spiral") }, ]} title={t("objinfo.stair.shape")} width={130} />
{/* Referenzpunkt der Laufbreite (links/mitte/rechts). */}
{t("objinfo.stair.referenz")} host.onSetStairReferenz(v as "links" | "mitte" | "rechts")} options={[ { value: "links", label: t("objinfo.stair.referenz.links") }, { value: "mitte", label: t("objinfo.stair.referenz.mitte") }, { value: "rechts", label: t("objinfo.stair.referenz.rechts") }, ]} title={t("objinfo.stair.referenz")} width={130} />
{/* Laufrichtung (aufwärts/abwärts). */}
{t("objinfo.stair.direction")}
{/* Editierbare Maße. */} host.onSetStairWidth(v)} /> host.onSetStairSteps(v)} /> host.onSetStairRise(v)} /> {/* Abgeleitete Werte (read-only). */}
{t("objinfo.stair.riser")} {formatM(stair.riserHeight)}
{t("objinfo.stair.tread")} {formatM(stair.treadDepth)}
{/* Referenzgeschoss (read-only). */}
{t("objinfo.stair.refFloor")} {stair.floorName}
{/* UK/OK (read-only). */}
{t("objinfo.stair.bottom")} {formatM(stair.zBottom)}
{t("objinfo.stair.top")} {formatM(stair.zTop)}
); } // ── Öffnungs-Attribut-Abschnitt ────────────────────────────────────────────── // Art (Fenster/Tür), Wirts-Wand, Position/Breite/Höhe/Brüstung, bei Türen // zusätzlich Anschlag/Aufschlag/Richtung/Winkel; UK/OK read-only. export function OpeningSection({ opening, host, }: { opening: OpeningInfo; host: ReturnType; }) { const isDoor = opening.kind === "door"; return ( <>
{t("objinfo.opening.section")}
{/* Tür-/Fenstertyp (Bibliothek) — je nach Art aus doorTypes bzw. windowTypes. Der ⚙-Knopf öffnet den reichen Fenster-/Tür-Einstellungsdialog. */}
{t("objinfo.opening.type")} 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={106} />
{/* Art (Fenster/Tür) — read-only: die Gattung wird beim Platzieren mit dem jeweiligen Werkzeug festgelegt und NICHT nachträglich umgeschaltet (ein Fenster in eine Tür zu verwandeln ergibt keinen sinnvollen Bauteil). */}
{t("objinfo.opening.kind")} {t(isDoor ? "objinfo.opening.kind.door" : "objinfo.opening.kind.window")}
{/* Wirts-Wand (read-only Anzeige des Geschosses/Wand-Bezugs). */}
{t("objinfo.opening.hostWall")} {opening.hostWallName}
{/* Editierbare Maße. */} host.onSetOpeningPosition(v)} /> host.onSetOpeningWidth(v)} /> host.onSetOpeningHeight(v)} /> {!isDoor && ( <> host.onSetOpeningSill(v)} /> {/* Fenster-spezifisch: Flügelanzahl (1–4). */} host.onSetOpeningWingCount(Math.max(1, Math.min(4, Math.round(v))))} /> )} {/* Tür-spezifisch: Typ / Sturzlinien / Anschlag / Aufschlagseite / Richtung / Winkel. */} {isDoor && ( <> {/* Tür-Typ (normal / Wandöffnung). */}
{t("objinfo.opening.doorType")} host.onSetOpeningDoorType(v as "normal" | "wandoeffnung")} options={[ { value: "normal", label: t("objinfo.opening.doorType.normal") }, { value: "wandoeffnung", label: t("objinfo.opening.doorType.wandoeffnung") }, ]} title={t("objinfo.opening.doorType")} width={130} />
{/* Sturzlinien-Darstellung (keine/innen/aussen/beide). */}
{t("objinfo.opening.lintelLines")} host.onSetOpeningLintelLines(v as "keine" | "innen" | "aussen" | "beide")} options={[ { value: "keine", label: t("objinfo.opening.lintelLines.keine") }, { value: "innen", label: t("objinfo.opening.lintelLines.innen") }, { value: "aussen", label: t("objinfo.opening.lintelLines.aussen") }, { value: "beide", label: t("objinfo.opening.lintelLines.beide") }, ]} title={t("objinfo.opening.lintelLines")} width={130} />
{t("objinfo.opening.hinge")} host.onSetOpeningHinge(v as "start" | "end")} options={[ { value: "start", label: t("objinfo.opening.hinge.start") }, { value: "end", label: t("objinfo.opening.hinge.end") }, ]} title={t("objinfo.opening.hinge")} width={130} />
{t("objinfo.opening.swing")} host.onSetOpeningSwing(v as "left" | "right")} options={[ { value: "left", label: t("objinfo.opening.swing.left") }, { value: "right", label: t("objinfo.opening.swing.right") }, ]} title={t("objinfo.opening.swing")} width={130} />
{t("objinfo.opening.dir")} host.onSetOpeningDir(v as "in" | "out")} options={[ { value: "in", label: t("objinfo.opening.dir.in") }, { value: "out", label: t("objinfo.opening.dir.out") }, ]} title={t("objinfo.opening.dir")} width={130} />
host.onSetOpeningSwingAngle(v)} /> )}
{/* UK/OK (read-only, aus der Wand-UK + Brüstung/Höhe abgeleitet). */}
{t("objinfo.opening.bottom")} {formatM(opening.zBottom)}
{t("objinfo.opening.top")} {formatM(opening.zTop)}
); } // ── Decken-Attribut-Abschnitt ──────────────────────────────────────────────── // Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss + // OK-Bindung, Fläche. Alle Werte/Setter über den Host. export function CeilingSection({ ceiling, host, }: { ceiling: CeilingInfo; host: ReturnType; }) { const isSingle = ceiling.singleLayer; const multiPresets = ceiling.ceilingTypes.filter((ct) => ct.layerCount > 1); function chooseSingle() { if (isSingle) return; host.onSetCeilingThickness(ceiling.thickness); } function chooseMulti() { if (!isSingle) return; const first = multiPresets[0]; if (first) host.onSetCeilingType(first.id); } return ( <>
{t("objinfo.ceiling.section")}
{/* Aufbau-Typ-Segment. */}
{t("objinfo.ceiling.buildup")}
{/* Einschichtig → Dicke-Zahlfeld. Mehrschichtig → Preset-Dropdown. */} {isSingle ? ( host.onSetCeilingThickness(v)} /> ) : (
{t("objinfo.ceiling.preset")} host.onSetCeilingType(id)} options={ceiling.ceilingTypes.map((ct) => ({ value: ct.id, label: ct.label, title: ct.name, }))} title={t("objinfo.ceiling.preset")} width={150} />
)} {/* Referenzgeschoss (read-only Anzeige). */}
{t("objinfo.ceiling.refFloor")} {ceiling.floorName}
{/* Fläche + Volumen (read-only). */}
{t("objinfo.ceiling.area")} {ceiling.area.toFixed(2)} m²
{t("objinfo.volume")} {ceiling.volume.toFixed(2)} m³
{/* OK der Decke: an Geschoss gebunden ODER eigene Höhe. */} host.onSetCeilingTop(a)} /> {/* UK der Decke: an Geschoss gebunden ODER eigene Höhe; ohne Override bleibt es beim heutigen Verhalten UK = OK − Dicke. */} host.onSetCeilingBottom?.(a)} />
{t("objinfo.ceiling.height")} {formatM(ceiling.zTop - ceiling.zBottom)}
); } // ── Wand-Attribut-Abschnitt ────────────────────────────────────────────────── // Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss + // Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host. export function WallSection({ wall, host, }: { wall: WallInfo; host: ReturnType; }) { // Aufbau-Typ-Segment: einschichtig vs. mehrschichtig. Aus dem Modell // abgeleitet (singleLayer); der Umschalter auf „mehrschichtig" wählt einen // mehrschichtigen Wandtyp-Preset (erster mit >1 Schicht), auf „einschichtig" // setzt er die Dicke des aktuellen Aufbaus als einschichtige Wand. const isSingle = wall.singleLayer; const multiPresets = wall.wallTypes.filter((wt) => wt.layerCount > 1); function chooseSingle() { if (isSingle) return; // Auf einschichtig wechseln: aktuelle Gesamtdicke als Einzelschicht setzen. host.onSetWallThickness(wall.thickness); } function chooseMulti() { if (!isSingle) return; const first = multiPresets[0]; if (first) host.onSetWallType(first.id); } return ( <>
{t("objinfo.wall.section")}
{/* Referenzlinie (wo die Achse über die Dicke liegt) — außen/mitte/innen ODER bei mehrschichtigen Wänden eine SCHICHTTRENNLINIE (Fuge). Ein Fugenwert wird als "off:" kodiert und übersteuert die benannte Linie. */}
{t("objinfo.wall.refLine")} { if (v.startsWith("off:")) host.onSetWallReferenceOffset(Number(v.slice(4))); else host.onSetWallReferenceLine(v as WallReferenceLine); }} options={[ { value: "left", label: t("objinfo.wall.refLine.left") }, { value: "center", label: t("objinfo.wall.refLine.center") }, { value: "right", label: t("objinfo.wall.refLine.right") }, ...wall.layerBoundaries.map((b) => ({ value: `off:${b.offset.toFixed(4)}`, label: `${t("objinfo.wall.refLine.joint")} ${b.label}`, })), ]} title={t("objinfo.wall.refLine")} width={120} />
{/* Terminierung am Deckenanschluss (Zuschnitt, NICHT Priorität): ob die Wand an der Decke endet oder oberhalb wieder auftaucht. Default "both". */}
{t("objinfo.wall.sliceTermination")} 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} />
{/* Aufbau-Typ-Segment. */}
{t("objinfo.wall.buildup")}
{/* Einschichtig → Dicke-Zahlfeld. Mehrschichtig → Preset-Dropdown. */} {isSingle ? ( host.onSetWallThickness(v)} /> ) : (
{t("objinfo.wall.preset")} host.onSetWallType(id)} options={wall.wallTypes.map((wt) => ({ value: wt.id, label: wt.label, title: wt.name, }))} title={t("objinfo.wall.preset")} width={150} />
)} {/* Referenzgeschoss (read-only Anzeige). */}
{t("objinfo.wall.refFloor")} {wall.floorName}
{/* Länge + Volumen (read-only). Volumen ist NETTO — Fenster/Türen abgezogen; der Titel zeigt zusätzlich das Bruttovolumen. */}
{t("objinfo.wall.length")} {formatM(wall.length)}
{t("objinfo.volume")} 0 ? `${t("objinfo.wall.volumeGross")}: ${wall.grossVolume.toFixed(2)} m³ − ${wall.openingVolume.toFixed(2)} m³` : undefined } > {wall.netVolume.toFixed(2)} m³
{/* Verknüpfung zum oberen Geschoss: OK an nächstes Geschoss binden. */}
{/* OK & UK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". OK (oben) steht oben, UK (unten) darunter — räumlich passend (Nutzer). */} host.onSetWallTop(a)} /> host.onSetWallBottom(a)} /> {/* Höhe = OK − UK (abgeleitet, read-only). */}
{t("objinfo.wall.height")} {formatM(wall.zTop - wall.zBottom)}
); } // ── UK/OK-Zeile: Modus-Umschalter (Geschoss/eigene Höhe) + Feld ────────────── // „Geschoss gebunden" → Geschoss-Dropdown; „eigene Höhe" → absolutes Z-Feld. // `null`-Anchor (undefined im Modell) = Geschoss-Default; zeigt „eigene Höhe" // mit dem aufgelösten Default-Z als Ausgangswert NICHT — Default ist // „an Geschoss gebunden" (Geschoss-Default-Verhalten). function VerticalAnchorRow({ label, anchor, defaultZ, floors, defaultFloorId, onChange, }: { label: string; anchor?: VerticalAnchor; defaultZ: number; floors: { id: string; name: string }[]; defaultFloorId: string; onChange: (a: VerticalAnchor | null) => void; }) { const mode: "floor" | "custom" = anchor?.mode === "custom" ? "custom" : "floor"; function setMode(next: "floor" | "custom") { if (next === mode) return; if (next === "custom") { onChange({ mode: "custom", z: defaultZ }); } else { // Zurück auf Geschoss-Bindung: bei vorhandenem expliziten Geschoss dieses, // sonst Default-Geschoss → entspricht dem Geschoss-Default-Verhalten. onChange({ mode: "floor", floorId: defaultFloorId }); } } return (
{label}
{/* 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" ? (
{t("objinfo.anchor.refFloor")} onChange({ mode: "floor", floorId: id })} options={floors.map((f) => ({ value: f.id, label: f.name }))} title={t("objinfo.anchor.refFloor")} width={150} />
) : ( onChange({ mode: "custom", z })} /> )}
); } // ── Editierbares Maßfeld ───────────────────────────────────────────────────── // Kontrolliert über den vom Host gelieferten Wert, hält aber während des // Tippens einen lokalen Entwurf. Commit auf blur/Enter; Escape verwirft. // Nach erfolgreichem Resize liefert der Host eine neue bbox → neuer `value` → // das Feld zeigt automatisch die aktualisierte Größe. `min` erlaubt negative // Werte (z. B. absolute Z-Höhen unter 0). function DimensionField({ label, value, onCommit, min = 0, }: { label: string; value: number; onCommit: (v: number) => void; min?: number; }) { // Entwurf als String, damit Zwischenzustände (leer, „1.") tippbar sind. // `null` = kein aktiver Entwurf → zeige den Host-Wert. const [draft, setDraft] = useState(null); const shown = draft ?? value.toFixed(3); function commit() { if (draft === null) return; const v = Number(draft); setDraft(null); if (isFinite(v) && v >= min) onCommit(v); } return ( ); } // ── 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(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 ( ); } // ── Mess-Werkzeug-Abschnitt ─────────────────────────────────────────────────── // Zeigt die Live-Messwerte (letztes Segment, aufsummierte Länge, umschlossene // Fläche, Richtung). Fläche nur, sobald der Zug ≥ 3 Ecken hat. function MeasurementSection({ m }: { m: MeasurementReadout }) { return (
{t("objinfo.measure.title")}
{t("objinfo.measure.segment")} {m.segment.toFixed(3)} m {t("objinfo.measure.total")} {m.total.toFixed(3)} m {t("objinfo.measure.angle")} {m.angle.toFixed(1)}° {m.area != null && ( <> {t("objinfo.measure.area")} {m.area.toFixed(2)} m² )}
{t("objinfo.measure.hint")}
); }