2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand

Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
2026-07-02 00:12:39 +02:00
parent 7b3b597abc
commit 8fd8987b70
184 changed files with 29421 additions and 669 deletions
+449 -2
View File
@@ -17,10 +17,22 @@
import { useState } from "react";
import { t } from "../i18n";
import { formatM } from "../model/types";
import type { VerticalAnchor, WallReferenceLine } from "../model/types";
import type {
SiaCategory,
StairShape,
VerticalAnchor,
WallReferenceLine,
} from "../model/types";
import { SIA_CATEGORIES, siaLabelFull } from "../geometry/roomArea";
import { Dropdown } from "../ui/Dropdown";
import { usePanelHost } from "./host";
import type { WallInfo } from "../state/selectionInfo";
import type {
CeilingInfo,
OpeningInfo,
RoomInfo,
StairInfo,
WallInfo,
} from "../state/selectionInfo";
// ── Bezugspunkt-Raster ───────────────────────────────────────────────────────
// Neun Anker als 3×3-Raster. Zeile 0 = oben (fy=0), Spalte 0 = links (fx=0).
@@ -71,6 +83,10 @@ export function ObjectInfoPanel() {
}
const wall = sel.wall;
const ceiling = sel.ceiling;
const opening = sel.opening;
const stair = sel.stair;
const room = sel.room;
return (
<div className="objinfo-panel">
@@ -161,10 +177,441 @@ export function ObjectInfoPanel() {
{/* ── Wand-Abschnitt (nur bei genau einer Wand) ────────────────────── */}
{wall && <WallSection wall={wall} host={host} />}
{/* ── Decken-Abschnitt (nur bei genau einer Decke) ─────────────────── */}
{ceiling && <CeilingSection ceiling={ceiling} host={host} />}
{/* ── Öffnungs-Abschnitt (nur bei genau einer Öffnung) ─────────────── */}
{opening && <OpeningSection opening={opening} host={host} />}
{/* ── Treppen-Abschnitt (nur bei genau einer Treppe) ───────────────── */}
{stair && <StairSection stair={stair} host={host} />}
{/* ── Raum-Abschnitt (nur bei genau einem Raum) ────────────────────── */}
{room && <RoomSection room={room} roomId={sel.id} host={host} />}
</div>
);
}
// ── 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.
function RoomSection({
room,
roomId,
host,
}: {
room: RoomInfo;
roomId: string;
host: ReturnType<typeof usePanelHost>;
}) {
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.room.section")}</div>
{/* Name (editierbar). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.room.name")}</span>
<input
type="text"
className="objinfo-text"
value={room.name}
onChange={(e) => host.onSetRoomName(e.target.value)}
title={t("objinfo.room.name")}
/>
</div>
{/* SIA-416-Kategorie (5 Blatt-Kategorien). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.room.sia")}</span>
<Dropdown
value={room.siaCategory}
onChange={(v) => host.onSetRoomSia(v as SiaCategory)}
options={SIA_CATEGORIES.map((c) => ({
value: c,
label: siaLabelFull(c),
}))}
title={t("objinfo.room.sia")}
width={170}
/>
</div>
{/* Fläche + Umfang (read-only, abgeleitet). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.room.area")}</span>
<span className="objinfo-fval">{room.area.toFixed(2)} m²</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.room.perimeter")}</span>
<span className="objinfo-fval">{formatM(room.perimeter)}</span>
</div>
{/* Referenzgeschoss (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.room.refFloor")}</span>
<span className="objinfo-fval">{room.floorName}</span>
</div>
{/* Stempel bearbeiten (öffnet den Rich-Text-Editor). */}
<button
type="button"
className="objinfo-btn"
onClick={() => host.onEditRoomStamp(roomId)}
>
{t("objinfo.room.editStamp")}
</button>
</>
);
}
// ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
function StairSection({
stair,
host,
}: {
stair: StairInfo;
host: ReturnType<typeof usePanelHost>;
}) {
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.stair.section")}</div>
{/* Grundform-Dropdown. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.shape")}</span>
<Dropdown
value={stair.shape}
onChange={(v) => 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}
/>
</div>
{/* Laufrichtung (aufwärts/abwärts). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.direction")}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + (stair.up ? " active" : "")}
onClick={() => host.onSetStairUp(true)}
>
{t("objinfo.stair.direction.up")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (!stair.up ? " active" : "")}
onClick={() => host.onSetStairUp(false)}
>
{t("objinfo.stair.direction.down")}
</button>
</div>
</div>
{/* Editierbare Maße. */}
<DimensionField
label={t("objinfo.stair.width")}
value={stair.width}
onCommit={(v) => host.onSetStairWidth(v)}
/>
<DimensionField
label={t("objinfo.stair.steps")}
value={stair.stepCount}
onCommit={(v) => host.onSetStairSteps(v)}
/>
<DimensionField
label={t("objinfo.stair.rise")}
value={stair.totalRise}
onCommit={(v) => host.onSetStairRise(v)}
/>
{/* Abgeleitete Werte (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.riser")}</span>
<span className="objinfo-fval">{formatM(stair.riserHeight)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.tread")}</span>
<span className="objinfo-fval">{formatM(stair.treadDepth)}</span>
</div>
{/* Referenzgeschoss (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.refFloor")}</span>
<span className="objinfo-fval">{stair.floorName}</span>
</div>
<div className="objinfo-sep" />
{/* UK/OK (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.bottom")}</span>
<span className="objinfo-fval">{formatM(stair.zBottom)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.top")}</span>
<span className="objinfo-fval">{formatM(stair.zTop)}</span>
</div>
</>
);
}
// ── Ö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.
function OpeningSection({
opening,
host,
}: {
opening: OpeningInfo;
host: ReturnType<typeof usePanelHost>;
}) {
const isDoor = opening.kind === "door";
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.opening.section")}</div>
{/* Art-Segment Fenster/Tür. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.kind")}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + (!isDoor ? " active" : "")}
onClick={() => host.onSetOpeningKind("window")}
>
{t("objinfo.opening.kind.window")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (isDoor ? " active" : "")}
onClick={() => host.onSetOpeningKind("door")}
>
{t("objinfo.opening.kind.door")}
</button>
</div>
</div>
{/* Wirts-Wand (read-only Anzeige des Geschosses/Wand-Bezugs). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.hostWall")}</span>
<span className="objinfo-fval">{opening.hostWallName}</span>
</div>
{/* Editierbare Maße. */}
<DimensionField
label={t("objinfo.opening.position")}
value={opening.position}
onCommit={(v) => host.onSetOpeningPosition(v)}
/>
<DimensionField
label={t("objinfo.opening.width")}
value={opening.width}
onCommit={(v) => host.onSetOpeningWidth(v)}
/>
<DimensionField
label={t("objinfo.opening.height")}
value={opening.height}
onCommit={(v) => host.onSetOpeningHeight(v)}
/>
{!isDoor && (
<DimensionField
label={t("objinfo.opening.sill")}
value={opening.sillHeight}
onCommit={(v) => host.onSetOpeningSill(v)}
/>
)}
{/* Tür-spezifisch: Anschlag / Aufschlagseite / Richtung / Winkel. */}
{isDoor && (
<>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.hinge")}</span>
<Dropdown
value={opening.hinge ?? "start"}
onChange={(v) => 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}
/>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.swing")}</span>
<Dropdown
value={opening.swing ?? "left"}
onChange={(v) => 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}
/>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.dir")}</span>
<Dropdown
value={opening.openingDir ?? "in"}
onChange={(v) => 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}
/>
</div>
<DimensionField
label={t("objinfo.opening.swingAngle")}
value={opening.swingAngle ?? 90}
min={1}
onCommit={(v) => host.onSetOpeningSwingAngle(v)}
/>
</>
)}
<div className="objinfo-sep" />
{/* UK/OK (read-only, aus der Wand-UK + Brüstung/Höhe abgeleitet). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.bottom")}</span>
<span className="objinfo-fval">{formatM(opening.zBottom)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.top")}</span>
<span className="objinfo-fval">{formatM(opening.zTop)}</span>
</div>
</>
);
}
// ── Decken-Attribut-Abschnitt ────────────────────────────────────────────────
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
// OK-Bindung, Fläche. Alle Werte/Setter über den Host.
function CeilingSection({
ceiling,
host,
}: {
ceiling: CeilingInfo;
host: ReturnType<typeof usePanelHost>;
}) {
const isSingle = ceiling.singleLayer;
const multiPresets = ceiling.wallTypes.filter((wt) => wt.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 (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.ceiling.section")}</div>
{/* Aufbau-Typ-Segment. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.buildup")}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + (isSingle ? " active" : "")}
onClick={chooseSingle}
>
{t("objinfo.wall.single")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (!isSingle ? " active" : "")}
onClick={chooseMulti}
disabled={isSingle && multiPresets.length === 0}
>
{t("objinfo.wall.multi")}
</button>
</div>
</div>
{/* Einschichtig → Dicke-Zahlfeld. Mehrschichtig → Preset-Dropdown. */}
{isSingle ? (
<DimensionField
label={t("objinfo.ceiling.thickness")}
value={ceiling.thickness}
onCommit={(v) => host.onSetCeilingThickness(v)}
/>
) : (
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.preset")}</span>
<Dropdown
value={ceiling.wallTypeId}
onChange={(id) => host.onSetCeilingType(id)}
options={ceiling.wallTypes.map((wt) => ({
value: wt.id,
label: t("objinfo.wall.presetLabel", {
name: wt.name,
thickness: wt.thickness.toFixed(2),
}),
}))}
title={t("objinfo.ceiling.preset")}
width={150}
/>
</div>
)}
{/* Referenzgeschoss (read-only Anzeige). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.refFloor")}</span>
<span className="objinfo-fval">{ceiling.floorName}</span>
</div>
{/* Fläche (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-sep" />
{/* OK der Decke: an Geschoss gebunden ODER eigene Höhe. */}
<VerticalAnchorRow
label={t("objinfo.ceiling.top")}
anchor={ceiling.top}
defaultZ={ceiling.zTop}
floors={ceiling.floors}
defaultFloorId={ceiling.floorId}
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>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.height")}</span>
<span className="objinfo-fval">{formatM(ceiling.zTop - ceiling.zBottom)}</span>
</div>
</>
);
}
// ── Wand-Attribut-Abschnitt ──────────────────────────────────────────────────
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host.
+123
View File
@@ -0,0 +1,123 @@
// Inhalts-Panel „Flächenbilanz" (SIA 416) — listet alle Räume gruppiert nach
// ihrer SIA-416-Blatt-Kategorie mit Zwischen-/Gesamtsummen und bietet einen
// CSV-Export. Fläche/Bilanz werden LIVE aus den Raum-Umrissen abgeleitet
// (geometry/roomArea: evaluateRoom/balance/roomsToCsv) — nie gespeichert.
//
// Daten kommen über usePanelHost (project.rooms). Bezeichner englisch, UI-Text
// deutsch via t() (CONVENTIONS.md).
import { t } from "../i18n";
import { usePanelHost } from "./host";
import {
balance,
evaluateRoom,
roomsToCsv,
siaLabelFull,
} from "../geometry/roomArea";
import type { Room } from "../model/types";
import { roomDisplayName } from "../model/roomStamp";
/** Löst je Raum die anrechenbare (Netto-)Fläche + Kategorie auf. */
function roomAreas(rooms: Room[]): { category: Room["siaCategory"]; area: number }[] {
return rooms.map((r) => ({
category: r.siaCategory,
area: evaluateRoom(r.boundary, r.siaCategory).netArea,
}));
}
/** Löst den Anzeigenamen eines Geschosses (oder die ID) auf. */
function floorNameOf(
levels: { id: string; name: string }[],
floorId: string,
): string {
return levels.find((z) => z.id === floorId)?.name ?? floorId;
}
/** Stösst den Download einer CSV-Datei an (reiner Client, Blob-URL). */
function downloadCsv(fileName: string, content: string): void {
// BOM voranstellen, damit Excel UTF-8 (Umlaute/„m²") korrekt liest.
const blob = new Blob(["" + content], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function RoomBalancePanel() {
const host = usePanelHost();
const rooms = host.project.rooms ?? [];
const levels = host.project.drawingLevels;
if (rooms.length === 0) {
return (
<div className="balance-panel">
<div className="balance-empty">{t("balance.empty")}</div>
</div>
);
}
const bal = balance(roomAreas(rooms));
const onExport = () => {
const csv = roomsToCsv(
rooms.map((r) => ({
number: r.stamp?.number ?? r.id,
name: roomDisplayName(r),
level: floorNameOf(levels, r.floorId),
category: r.siaCategory,
area: evaluateRoom(r.boundary, r.siaCategory).netArea,
})),
);
downloadCsv("flaechenbilanz.csv", csv);
};
return (
<div className="balance-panel">
<div className="balance-head">
<span className="balance-title">{t("balance.title")}</span>
<button type="button" className="balance-export" onClick={onExport}>
{t("balance.export")}
</button>
</div>
{/* Raumliste (je Raum: Name · Geschoss · SIA · Fläche). */}
<div className="balance-section-label">{t("balance.rooms")}</div>
<div className="balance-table">
{rooms.map((r) => {
const res = evaluateRoom(r.boundary, r.siaCategory);
return (
<div key={r.id} className="balance-row">
<span className="balance-name">{roomDisplayName(r)}</span>
<span className="balance-cat">{r.siaCategory}</span>
<span className="balance-area">{res.netArea.toFixed(2)} m²</span>
</div>
);
})}
</div>
{/* SIA-416-Bilanz (Blatt-Kategorien + Zwischensummen NF/NGF + Gesamt GF). */}
<div className="balance-section-label">{t("balance.summary")}</div>
<div className="balance-table">
{bal.rows.map((row) => (
<div
key={row.key}
className={"balance-row" + (row.aggregate ? " aggregate" : "")}
title={siaLabelFull(row.key)}
>
<span className="balance-name">
{row.key} · {row.label}
</span>
<span className="balance-count">{row.count}</span>
<span className="balance-area">{row.area.toFixed(2)} m²</span>
</div>
))}
</div>
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
// Schwebender Dialog zum Bearbeiten des strukturierten Raum-Stempels (Feldmodell):
// Raumnummer, Raumname (+ Zeile 2), Bodenfläche (mit Präfix) und Nutzung. Ersetzt
// für neue Räume den freien Text-Editor. Kontrolliert (value/onApply/onCancel);
// arbeitet auf einem lokalen Entwurf, erst „Übernehmen" schreibt zurück.
// Bezeichner englisch, UI-Text über t().
//
// Folge-Arbeit: Zeilen-Layout-Editor (welches Feld auf welche Zeile),
// Per-Feld-Typografie, Fensterfläche.
import { useState } from "react";
import { t } from "../i18n";
import type { RoomStamp } from "../model/types";
export interface RoomStampEditorProps {
/** Ausgangsstempel (wird beim Öffnen kopiert; Editor arbeitet lokal). */
value: RoomStamp;
/** „Übernehmen" — den bearbeiteten Stempel zurückschreiben. */
onApply: (stamp: RoomStamp) => void;
/** „Abbrechen"/Schliessen — ohne Übernahme. */
onCancel: () => void;
}
export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorProps) {
const [draft, setDraft] = useState<RoomStamp>(value);
const patch = (p: Partial<RoomStamp>) => setDraft((d) => ({ ...d, ...p }));
return (
<div
className="texteditor-overlay"
role="dialog"
aria-modal="true"
aria-label={t("roomStamp.title")}
onMouseDown={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
>
<div className="texteditor-dialog roomstamp-dialog">
<div className="texteditor-head">
<span className="texteditor-title">{t("roomStamp.title")}</span>
<button
type="button"
className="texteditor-x"
onClick={onCancel}
title={t("text.cancel")}
aria-label={t("text.cancel")}
>
<span className="material-symbols-outlined" aria-hidden="true">
close
</span>
</button>
</div>
<div className="texteditor-body roomstamp-body">
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.number")}</span>
<input
type="text"
className="roomstamp-input"
value={draft.number ?? ""}
onChange={(e) => patch({ number: e.target.value })}
autoFocus
/>
</label>
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.name")}</span>
<input
type="text"
className="roomstamp-input"
value={draft.name}
onChange={(e) => patch({ name: e.target.value })}
/>
</label>
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.nameLine2")}</span>
<input
type="text"
className="roomstamp-input"
value={draft.nameLine2 ?? ""}
onChange={(e) => patch({ nameLine2: e.target.value })}
/>
</label>
<label className="roomstamp-check">
<input
type="checkbox"
checked={draft.showFloorArea}
onChange={(e) => patch({ showFloorArea: e.target.checked })}
/>
<span>{t("roomStamp.showFloorArea")}</span>
</label>
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.floorAreaPrefix")}</span>
<input
type="text"
className="roomstamp-input"
value={draft.floorAreaPrefix ?? ""}
disabled={!draft.showFloorArea}
onChange={(e) => patch({ floorAreaPrefix: e.target.value })}
/>
</label>
<label className="roomstamp-check">
<input
type="checkbox"
checked={draft.showUsage}
onChange={(e) => patch({ showUsage: e.target.checked })}
/>
<span>{t("roomStamp.showUsage")}</span>
</label>
</div>
<div className="texteditor-foot">
<button type="button" className="texteditor-btn" onClick={onCancel}>
{t("text.cancel")}
</button>
<button
type="button"
className="texteditor-btn texteditor-btn-primary"
onClick={() => onApply(draft)}
>
{t("text.apply")}
</button>
</div>
</div>
</div>
);
}
export default RoomStampEditor;
+18
View File
@@ -16,6 +16,7 @@ import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
import { usePanelHost } from "./host";
import type { ContextObject } from "../model/types";
import { ContextImportDialog } from "../ui/ContextImportDialog";
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
function typeBadgeKey(obj: ContextObject): TranslationKey {
@@ -34,6 +35,9 @@ export function SitePanel() {
const objects = host.contextObjects;
const contourSets = objects.filter((o) => o.type === "contourSet");
// Standort-Import-Dialog (geo.admin/OSM) offen?
const [ctxImportOpen, setCtxImportOpen] = useState(false);
// Gewählter Kontur-Satz für „Gelände erzeugen" (default: erster vorhandener).
const [selectedSet, setSelectedSet] = useState<string>("");
const effectiveSet =
@@ -62,8 +66,22 @@ export function SitePanel() {
>
{t("site.import")}
</button>
<button
type="button"
className="site-btn"
onClick={() => setCtxImportOpen(true)}
>
{t("site.importLocation")}
</button>
</div>
{ctxImportOpen && (
<ContextImportDialog
onImport={(objs) => host.onAddContextObjects(objs)}
onClose={() => setCtxImportOpen(false)}
/>
)}
{contourSets.length > 0 && (
<div className="site-terrain-row">
{contourSets.length > 1 ? (
+65
View File
@@ -45,6 +45,50 @@ function ToolIcon({ id }: { id: ToolId }) {
<line x1="2" y1="10.5" x2="14" y2="10.5" />
</svg>
);
case "ceiling":
// Deckenplatte: gefülltes Rechteck mit Diagonal-Schraffur (Slab-Symbol).
return (
<svg {...common}>
<rect x="2.5" y="4" width="11" height="8" />
<line x1="4" y1="12" x2="8" y2="4" />
<line x1="8" y1="12" x2="12" y2="4" />
</svg>
);
case "window":
// Fenster: Rahmen-Rechteck mit Mittelsprosse (Verglasung).
return (
<svg {...common}>
<rect x="2.5" y="4" width="11" height="8" />
<line x1="8" y1="4" x2="8" y2="12" />
</svg>
);
case "door":
// Tür: Pfosten + Türblatt mit Schwenkbogen.
return (
<svg {...common}>
<line x1="3" y1="13" x2="3" y2="4" />
<line x1="3" y1="4" x2="11" y2="4" />
<path d="M3 13 A9 9 0 0 0 11 4" />
</svg>
);
case "stair":
// Treppensymbol: gestufte Linie (Tritte) + Lauflinie mit Pfeil.
return (
<svg {...common}>
<polyline points="2,14 2,11 6,11 6,8 10,8 10,5 14,5 14,2" />
<line x1="4" y1="12.5" x2="12" y2="3.5" />
<polyline points="10.5,3 12,3.5 11.5,5" />
</svg>
);
case "room":
// Raum: umschlossene Fläche (Rechteck) mit Flächen-Marke (kleines Kreuz).
return (
<svg {...common}>
<rect x="2.5" y="3.5" width="11" height="9" />
<line x1="6" y1="8" x2="10" y2="8" />
<line x1="8" y1="6" x2="8" y2="10" />
</svg>
);
case "line":
// Einzelne Diagonale.
return (
@@ -126,6 +170,27 @@ export function ToolsPanel() {
{host.activeTool === "wall" && <div className="tools-sep" />}
{/* Deckentyp-Auswahl — nur sichtbar, wenn das Decken-Werkzeug aktiv ist.
Nutzt denselben Aufbau-Typ (WallType) wie die Wand. */}
{host.activeTool === "ceiling" && (
<label className="tools-field">
<span>{t("tool.ceilingType")}</span>
<select
value={host.activeWallTypeId}
disabled={!host.toolsEnabled}
onChange={(e) => host.onActiveWallTypeId(e.target.value)}
>
{host.project.wallTypes.map((wt) => (
<option key={wt.id} value={wt.id}>
{wt.name}
</option>
))}
</select>
</label>
)}
{host.activeTool === "ceiling" && <div className="tools-sep" />}
{/* Fang-Abschnitt. Master-Checkbox steuert die übrigen Fang-Optionen. */}
<label className="tools-snap-row">
<input
+10
View File
@@ -21,6 +21,7 @@ import { ToolsPanel } from "./ToolsPanel";
import { AttributesPanel } from "./AttributesPanel";
import { ObjectInfoPanel } from "./ObjectInfoPanel";
import { SitePanel } from "./SitePanel";
import { RoomBalancePanel } from "./RoomBalancePanel";
/** IDs der eingebauten Dock-Panels (auch für defaultLayout/Filter nutzbar). */
export const BUILTIN_PANEL_IDS = {
@@ -30,6 +31,7 @@ export const BUILTIN_PANEL_IDS = {
attributes: "attributes",
objectInfo: "object-info",
site: "site",
roomBalance: "room-balance",
} as const;
// Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil).
@@ -79,3 +81,11 @@ registerPanel({
hasDisplayMode: false,
render: () => <SitePanel />,
});
// Flächenbilanz (SIA 416) — Raumliste + Kategorie-Summen + CSV-Export.
registerPanel({
id: BUILTIN_PANEL_IDS.roomBalance,
title: "nav.roomBalance",
hasDisplayMode: false,
render: () => <RoomBalancePanel />,
});
+50
View File
@@ -22,6 +22,8 @@ import type {
LayerCategory,
LineStyle,
Project,
SiaCategory,
StairShape,
VerticalAnchor,
WallReferenceLine,
} from "../model/types";
@@ -156,6 +158,54 @@ export interface PanelHostValue {
/** Setzt/entfernt die OK-Bindung (`null` = UK + height). */
onSetWallTop: (anchor: VerticalAnchor | null) => void;
// ── Decken-Attribute (Object-Info-Panel; wirken NUR auf die selektierte Decke) ─
/** Weist der Decke einen Aufbau-Typ-Preset zu (wie WallType). */
onSetCeilingType: (wallTypeId: string) => void;
/** Setzt die Gesamtdicke der Decke (Meter, thickness-Übersteuerung). */
onSetCeilingThickness: (thickness: number) => void;
/** Setzt/entfernt die OK-Bindung der Decke (`null` = Geschoss-Oberkante). */
onSetCeilingTop: (anchor: VerticalAnchor | null) => void;
// ── Öffnungs-Attribute (Object-Info-Panel; nur die selektierte Öffnung) ─
/** Wechselt die Art (Fenster/Tür); setzt bei Tür die Tür-Defaults. */
onSetOpeningKind: (kind: "window" | "door") => void;
/** Setzt die Öffnungsbreite (Meter). */
onSetOpeningWidth: (width: number) => void;
/** Setzt die Öffnungshöhe (Meter). */
onSetOpeningHeight: (height: number) => void;
/** Setzt die Brüstungshöhe (Meter; nur Fenster sinnvoll). */
onSetOpeningSill: (sillHeight: number) => void;
/** Setzt die Position entlang der Wandachse (Meter ab Wandanfang). */
onSetOpeningPosition: (position: number) => void;
/** Setzt den Türöffnungswinkel (Grad). */
onSetOpeningSwingAngle: (swingAngle: number) => void;
/** Setzt den Anschlag-Pfosten der Tür. */
onSetOpeningHinge: (hinge: "start" | "end") => void;
/** Setzt die Aufschlagseite der Tür. */
onSetOpeningSwing: (swing: "left" | "right") => void;
/** Setzt die Aufschlagrichtung der Tür (innen/außen). */
onSetOpeningDir: (dir: "in" | "out") => void;
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
/** Setzt die Grundform (gerade/L/Wendel). */
onSetStairShape: (shape: StairShape) => void;
/** Setzt die Laufbreite (Meter). */
onSetStairWidth: (width: number) => void;
/** Setzt die Stufenanzahl (Setzstufen, ≥ 2). */
onSetStairSteps: (stepCount: number) => void;
/** Setzt die Gesamt-Steighöhe (Meter, OKFF → OKFF). */
onSetStairRise: (totalRise: number) => void;
/** Setzt die Laufrichtung (aufwärts/abwärts). */
onSetStairUp: (up: boolean) => void;
// ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ────────
/** Setzt den Raum-Namen. */
onSetRoomName: (name: string) => void;
/** Setzt die SIA-416-Blatt-Kategorie des Raums. */
onSetRoomSia: (category: SiaCategory) => void;
/** Öffnet den Rich-Text-Editor des Raum-Stempels (per ID). */
onEditRoomStamp: (roomId: string) => void;
// ── Site-/Kontext-Schicht (SitePanel) ───────────────────────────────────
/** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */
contextObjects: ContextObject[];
+1 -1
View File
@@ -60,7 +60,7 @@ const DEFAULT_LEFT_GROUPS: DockGroup[] = [
{ tabs: ["attributes"], activeTab: "attributes", weight: 1.1 },
];
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
{ tabs: ["object-info"], activeTab: "object-info", weight: 1 },
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
{
tabs: ["drawing-levels", "layers", "site"],
activeTab: "drawing-levels",