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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />,
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user