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:
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
defaultRoomStamp,
|
||||
roomStampToDoc,
|
||||
roomStampExtraLines,
|
||||
roomDisplayName,
|
||||
} from "./roomStamp";
|
||||
import type { Room } from "./types";
|
||||
|
||||
function makeRoom(over: Partial<Room> = {}): Room {
|
||||
return {
|
||||
id: "r1",
|
||||
type: "room",
|
||||
floorId: "f1",
|
||||
categoryCode: "45",
|
||||
siaCategory: "HNF",
|
||||
name: "Wohnen",
|
||||
boundary: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 4, y: 0 },
|
||||
{ x: 4, y: 3 },
|
||||
{ x: 0, y: 3 },
|
||||
],
|
||||
color: "#123456",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("defaultRoomStamp", () => {
|
||||
it("leitet Name/Flags aus dem Raum ab", () => {
|
||||
const stamp = defaultRoomStamp(makeRoom());
|
||||
expect(stamp.name).toBe("Wohnen");
|
||||
expect(stamp.showFloorArea).toBe(true);
|
||||
expect(stamp.showUsage).toBe(true);
|
||||
expect(stamp.number).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("roomStampToDoc", () => {
|
||||
it("eine Zeile ohne nameLine2", () => {
|
||||
const doc = roomStampToDoc({ name: "Bad", showFloorArea: true, showUsage: true });
|
||||
expect(doc.paragraphs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("zwei Zeilen mit nameLine2", () => {
|
||||
const doc = roomStampToDoc({
|
||||
name: "Bad",
|
||||
nameLine2: "WC",
|
||||
showFloorArea: true,
|
||||
showUsage: true,
|
||||
});
|
||||
expect(doc.paragraphs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("Nummer als Präfix-Run in Zeile 1", () => {
|
||||
const doc = roomStampToDoc({
|
||||
number: "1.01",
|
||||
name: "Bad",
|
||||
showFloorArea: true,
|
||||
showUsage: true,
|
||||
});
|
||||
expect(doc.paragraphs[0].runs).toHaveLength(2);
|
||||
expect(doc.paragraphs[0].runs[0].text).toBe("1.01 ");
|
||||
expect(doc.paragraphs[0].runs[1].text).toBe("Bad");
|
||||
});
|
||||
});
|
||||
|
||||
describe("roomStampExtraLines", () => {
|
||||
it("beachtet Flags und Präfix", () => {
|
||||
const lines = roomStampExtraLines(
|
||||
{ name: "Bad", showFloorArea: true, showUsage: true, floorAreaPrefix: "BF " },
|
||||
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
|
||||
);
|
||||
expect(lines[0]).toBe("BF 12.35 m²");
|
||||
expect(lines[1]).toBe("HNF · Hauptnutzfläche");
|
||||
});
|
||||
|
||||
it("blendet Zeilen aus, wenn Flags falsch", () => {
|
||||
const lines = roomStampExtraLines(
|
||||
{ name: "Bad", showFloorArea: false, showUsage: false },
|
||||
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
|
||||
);
|
||||
expect(lines).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("roomDisplayName", () => {
|
||||
it("faellt auf room.name zurück ohne Stempel", () => {
|
||||
expect(roomDisplayName(makeRoom())).toBe("Wohnen");
|
||||
});
|
||||
|
||||
it("zieht name + nameLine2 zusammen", () => {
|
||||
const room = makeRoom({
|
||||
stamp: { name: "Bad", nameLine2: "WC", showFloorArea: true, showUsage: true },
|
||||
});
|
||||
expect(roomDisplayName(room)).toBe("Bad WC");
|
||||
});
|
||||
|
||||
it("nutzt nur name ohne nameLine2", () => {
|
||||
const room = makeRoom({
|
||||
stamp: { name: "Küche", showFloorArea: true, showUsage: true },
|
||||
});
|
||||
expect(roomDisplayName(room)).toBe("Küche");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// Reines Modul für den strukturierten Raum-Stempel (Feldmodell). App-frei und
|
||||
// unit-getestet: baut aus einem RoomStamp das gerenderte Rich-Text-Dokument
|
||||
// (roomStampToDoc) und die Live-Zeilen (roomStampExtraLines), und liefert den
|
||||
// Anzeigenamen für Listen (roomDisplayName). Keine React-/Store-Abhängigkeit.
|
||||
//
|
||||
// Folge-Arbeit: Fensterfläche (braucht Öffnung-in-Raum-Geometrie).
|
||||
|
||||
import type { Room, RoomStamp } from "./types";
|
||||
import type { SiaCategory } from "../geometry/roomArea";
|
||||
import type { RichTextDoc, Paragraph, TextRun } from "../text/richText";
|
||||
import { makeRun } from "../text/richText";
|
||||
|
||||
/** Grösse (pt) des Raumnamens (Zeile 1) relativ zur Stempel-Basis. */
|
||||
const NAME_PT = 12;
|
||||
/** Grösse (pt) der kleinen Raumnummer als Präfix. */
|
||||
const NUMBER_PT = 9;
|
||||
/** Grösse (pt) der zweiten Namenszeile. */
|
||||
const LINE2_PT = 9;
|
||||
|
||||
/**
|
||||
* Standard-Stempel aus einem Raum ableiten: Name = Raum-Name, Bodenfläche und
|
||||
* Nutzung angezeigt. Nummer/Zeile 2 bleiben leer (frei ergänzbar).
|
||||
*/
|
||||
export function defaultRoomStamp(room: Room): RoomStamp {
|
||||
return {
|
||||
name: room.name,
|
||||
showFloorArea: true,
|
||||
showUsage: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das Rich-Text-Dokument des Stempels:
|
||||
* • Zeile 1: optionale Raumnummer (kleiner Präfix) + Raumname (grösser, fett).
|
||||
* • Zeile 2 (optional): nameLine2 (kursiv, kleiner).
|
||||
* Die Live-Zeilen (Fläche/Nutzung) werden separat als extraLines gesetzt.
|
||||
*/
|
||||
export function roomStampToDoc(stamp: RoomStamp): RichTextDoc {
|
||||
const line1: TextRun[] = [];
|
||||
const number = (stamp.number ?? "").trim();
|
||||
if (number) {
|
||||
line1.push(makeRun(`${number} `, { sizePt: NUMBER_PT }));
|
||||
}
|
||||
line1.push(makeRun(stamp.name, { bold: true, sizePt: NAME_PT }));
|
||||
|
||||
const paragraphs: Paragraph[] = [{ runs: line1 }];
|
||||
const line2 = (stamp.nameLine2 ?? "").trim();
|
||||
if (line2) {
|
||||
paragraphs.push({ runs: [makeRun(line2, { italic: true, sizePt: LINE2_PT })] });
|
||||
}
|
||||
return { version: 1, paragraphs };
|
||||
}
|
||||
|
||||
/** Kontext für die Live-Zeilen (nicht gespeichert, bei jedem Rendern frisch). */
|
||||
export interface RoomStampContext {
|
||||
netArea: number;
|
||||
siaCategory: SiaCategory;
|
||||
siaLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Live-Zeilen unter dem Stempel: Bodenfläche (mit Präfix) wenn showFloorArea,
|
||||
* Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage.
|
||||
*/
|
||||
export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): string[] {
|
||||
const lines: string[] = [];
|
||||
if (stamp.showFloorArea) {
|
||||
const prefix = stamp.floorAreaPrefix ?? "";
|
||||
lines.push(`${prefix}${ctx.netArea.toFixed(2)} m²`);
|
||||
}
|
||||
if (stamp.showUsage) {
|
||||
lines.push(`${ctx.siaCategory} · ${ctx.siaLabel}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzeigename für Listen: strukturierter Name (name + optional nameLine2 zu
|
||||
* einem Namen zusammengezogen), sonst der Alt-Raumname.
|
||||
*/
|
||||
export function roomDisplayName(room: Room): string {
|
||||
const stamp = room.stamp;
|
||||
if (!stamp) return room.name;
|
||||
const line2 = (stamp.nameLine2 ?? "").trim();
|
||||
return line2 ? `${stamp.name} ${line2}` : stamp.name;
|
||||
}
|
||||
@@ -133,9 +133,10 @@ export const sampleProject: Project = {
|
||||
},
|
||||
{ code: "30", name: "Decken", color: "#605850", lw: 0.35, visible: true, locked: false },
|
||||
{ code: "31", name: "Dächer", color: "#7a4a3a", lw: 0.35, visible: true, locked: false },
|
||||
{ code: "40", name: "Treppen", color: "#4a6a58", lw: 0.35, visible: true, locked: false },
|
||||
{ code: "35", name: "Träger", color: "#a87858", lw: 0.5, visible: true, locked: false },
|
||||
{ code: "50", name: "Text", color: "#d0d0d0", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "60", name: "Plangrafik", color: "#c0a040", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "60", name: "Räume", color: "#5a7a9a", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "90", name: "Referenzen", color: "#585860", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "99", name: "Konstruktion", color: "#404048", lw: 0.13, visible: true, locked: false },
|
||||
],
|
||||
@@ -173,6 +174,73 @@ export const sampleProject: Project = {
|
||||
hinge: "start",
|
||||
},
|
||||
],
|
||||
// Öffnungen (Fenster/Türen) — ein Beispiel-Fenster in der EG-Südwand (W1,
|
||||
// frei von der Tür) und in der EG-Nordwand (W3). Kategorie „21" (Türen/Fenster).
|
||||
openings: [
|
||||
{
|
||||
id: "O1",
|
||||
type: "opening",
|
||||
hostWallId: "W1",
|
||||
categoryCode: "21",
|
||||
kind: "window",
|
||||
position: 3.6, // hinter der Tür (2.0–2.9), noch innerhalb der 5-m-Wand
|
||||
width: 1.0,
|
||||
height: 1.2,
|
||||
sillHeight: 0.9,
|
||||
},
|
||||
{
|
||||
id: "O2",
|
||||
type: "opening",
|
||||
hostWallId: "W3",
|
||||
categoryCode: "21",
|
||||
kind: "window",
|
||||
position: 1.8,
|
||||
width: 1.4,
|
||||
height: 1.3,
|
||||
sillHeight: 0.9,
|
||||
},
|
||||
],
|
||||
// Decken (Slabs) — anfangs leer; werden mit dem Decken-Werkzeug gefüllt.
|
||||
ceilings: [],
|
||||
// Treppen — eine gerade Beispieltreppe im EG entlang der Ostwand, steigt ins OG
|
||||
// (totalRise = Geschosshöhe). Kategorie „40 Treppen".
|
||||
stairs: [
|
||||
{
|
||||
id: "ST1",
|
||||
type: "stair",
|
||||
floorId: "eg",
|
||||
categoryCode: "40",
|
||||
shape: "straight",
|
||||
start: { x: 3.7, y: 0.4 },
|
||||
dir: { x: 0, y: 1 },
|
||||
runLength: 3.2,
|
||||
width: 1.0,
|
||||
totalRise: 2.6,
|
||||
stepCount: 15,
|
||||
up: true,
|
||||
},
|
||||
],
|
||||
// Räume (SIA-416-Flächen) — ein Beispielraum im EG (lichte Innenkontur des
|
||||
// 5×4-m-Umrisses, Aussenwände 0.2 m → Innenmaß 4.8×3.8 m ≈ 18.24 m²).
|
||||
// Kategorie „45 Räume", SIA-Blatt HNF (Hauptnutzfläche).
|
||||
rooms: [
|
||||
{
|
||||
id: "R1",
|
||||
type: "room",
|
||||
floorId: "eg",
|
||||
categoryCode: "60",
|
||||
siaCategory: "HNF",
|
||||
name: "Wohnen",
|
||||
boundary: [
|
||||
{ x: 0.1, y: 0.1 },
|
||||
{ x: 4.9, y: 0.1 },
|
||||
{ x: 4.9, y: 3.9 },
|
||||
{ x: 0.1, y: 3.9 },
|
||||
],
|
||||
color: "#5a7a9a",
|
||||
stampAnchor: { x: 2.5, y: 2.0 },
|
||||
},
|
||||
],
|
||||
// Freie 2D-Zeichengeometrie — anfangs leer; wird mit den Zeichenwerkzeugen
|
||||
// gefüllt (docs/design/drawing-tools.md).
|
||||
drawings2d: [],
|
||||
|
||||
@@ -635,6 +635,35 @@ export interface Room {
|
||||
* Text. Die LIVE-Flächenzeile wird beim Rendern separat darunter gesetzt.
|
||||
*/
|
||||
stampDoc?: RichTextDoc;
|
||||
/**
|
||||
* Strukturierter Raum-Stempel (Feldmodell). Ist er gesetzt, hat er Vorrang vor
|
||||
* `stampDoc`/`name`: der Stempel-Text wird aus den Feldern gebaut, die Live-
|
||||
* Zeilen (Bodenfläche/Nutzung) aus den Flags. Fehlt er, gilt der Alt-Pfad.
|
||||
*/
|
||||
stamp?: RoomStamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feldmodell des Raum-Stempels. Ersetzt den freien Text durch benannte Felder;
|
||||
* daraus baut roomStamp.ts sowohl das gerenderte Rich-Text-Dokument als auch die
|
||||
* Live-Zeilen. Minimal gehalten (MVP).
|
||||
*
|
||||
* Folge-Arbeit: Fensterfläche (braucht Öffnung-in-Raum-Geometrie) — hier bewusst
|
||||
* NICHT enthalten.
|
||||
*/
|
||||
export interface RoomStamp {
|
||||
/** Raumnummer (kleiner Präfix in Zeile 1). */
|
||||
number?: string;
|
||||
/** Raumname (Zeile 1). */
|
||||
name: string;
|
||||
/** Raumname Zeile 2 (in Listen mit `name` zu einem Namen zusammengezogen). */
|
||||
nameLine2?: string;
|
||||
/** Bodenfläche anzeigen (Live-Zeile). */
|
||||
showFloorArea: boolean;
|
||||
/** Präfix vor der Bodenfläche, z. B. "BF " (Default leer). */
|
||||
floorAreaPrefix?: string;
|
||||
/** Nutzung (HNF/… · Bezeichnung) anzeigen (Live-Zeile). */
|
||||
showUsage: boolean;
|
||||
}
|
||||
|
||||
/** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */
|
||||
|
||||
+57
-3
@@ -3,10 +3,10 @@
|
||||
// und die vertikale Ausdehnung (UK..OK). Alle Defaults entsprechen exakt dem
|
||||
// bisherigen Verhalten, sodass bestehende Wände unverändert rendern.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Project, VerticalAnchor, Wall } from "./types";
|
||||
import { getFloor } from "./types";
|
||||
import type { Ceiling, Project, Stair, VerticalAnchor, Wall } from "./types";
|
||||
import { ceilingThickness, getFloor } from "./types";
|
||||
|
||||
/**
|
||||
* Versatz der Schichtstapelung entlang der linken Normalen `+n` (leftNormal),
|
||||
@@ -70,6 +70,60 @@ export function wallVerticalExtent(
|
||||
return { zBottom, zTop };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertikale Ausdehnung einer Decke (absolute Z-Werte in Metern).
|
||||
* • zTop: aus `ceiling.top`, sonst Oberkante des Geschosses
|
||||
* (baseElevation + floorHeight) — also bündig mit dem Wandkopf.
|
||||
* • zBottom: zTop − Deckendicke (die Decke wächst nach UNTEN).
|
||||
* Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Oberkante.
|
||||
*/
|
||||
export function ceilingVerticalExtent(
|
||||
project: Project,
|
||||
ceiling: Ceiling,
|
||||
): { zBottom: number; zTop: number } {
|
||||
let floorTop = 0;
|
||||
try {
|
||||
const floor = getFloor(project, ceiling.floorId);
|
||||
floorTop = (floor.baseElevation ?? 0) + (floor.floorHeight ?? 0);
|
||||
} catch {
|
||||
floorTop = 0;
|
||||
}
|
||||
const zTop = resolveAnchor(project, ceiling.top) ?? floorTop;
|
||||
const zBottom = zTop - ceilingThickness(project, ceiling);
|
||||
return { zBottom, zTop };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertikale Ausdehnung einer Treppe (absolute Z-Werte in Metern).
|
||||
* • zBottom: OKFF (baseElevation) des zugehörigen Geschosses.
|
||||
* • zTop: zBottom + Gesamt-Steighöhe. Fehlt `stair.totalRise`, gilt die
|
||||
* Geschosshöhe (floorHeight) des Geschosses — so steigt die Treppe genau ins
|
||||
* nächste Geschoss (OKFF → OKFF).
|
||||
* Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Unterkante.
|
||||
*/
|
||||
export function stairVerticalExtent(
|
||||
project: Project,
|
||||
stair: Stair,
|
||||
): { zBottom: number; zTop: number } {
|
||||
let base = 0;
|
||||
let floorHeight = 0;
|
||||
try {
|
||||
const floor = getFloor(project, stair.floorId);
|
||||
base = floor.baseElevation ?? 0;
|
||||
floorHeight = floor.floorHeight ?? 0;
|
||||
} catch {
|
||||
base = 0;
|
||||
floorHeight = 0;
|
||||
}
|
||||
const rise =
|
||||
stair.totalRise != null && stair.totalRise > 0
|
||||
? stair.totalRise
|
||||
: floorHeight > 0
|
||||
? floorHeight
|
||||
: 2.6;
|
||||
return { zBottom: base, zTop: base + rise };
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Geschoss DIREKT ÜBER dem Wand-Geschoss (in Dokumentreihenfolge der
|
||||
* "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt.
|
||||
|
||||
Reference in New Issue
Block a user