Dächer: Platzier-Werkzeug + 2D-Plan + 3D-Flächen + Demo-Dach
Dach-Feature end-to-end nutzbar: - Befehl „Dach" (Alias dach/rf, BIM-Ribbon, Satteldach-Icon): Grundriss als Rechteck aufziehen; die Dachform (Sattel/Walm/Pult/Mansarde/Zelt/Flach) wählt man als Inline-Option der Befehlszeile vor/während des Aufziehens. First entlang der längeren Seite (Standard). - 2D-Grundriss (generatePlan): Traufe/First/Grat (Walm/Zelt) + gestrichelte Knicklinien (Mansarde), Farbe aus roof.color/Kategorie „31 Dächer". - 3D (toWalls3d emitRoofs): Dachflächen + Giebel als terrakottafarbene Meshes (Fan-Triangulierung, Mansarde-Fünfeck sauber). - Seed: Demo-Satteldach RF1 über dem Baukörper (OG, 5×4, Überstand 0.4). - i18n de/en für Befehl + Formen. +10 Tests (2D 7, 3D 3). tsc + vitest 640 grün. Auswahl/Attribut-Editieren placierter Dächer folgt separat.
This commit is contained in:
@@ -0,0 +1,173 @@
|
|||||||
|
// Roof — Dach als Engine-Befehl: der Grundriss wird als RECHTECK aufgezogen
|
||||||
|
// (zwei Klicks: erste Ecke → Gegenecke), committet ein `Roof`-Bauteil. Die
|
||||||
|
// Dachform/Neigung wählt man nach dem Platzieren im Attribut-Panel; Default ist
|
||||||
|
// ein Satteldach mit First entlang der LÄNGEREN Rechteckseite.
|
||||||
|
//
|
||||||
|
// Schritte:
|
||||||
|
// 1) „Erste Dachecke:" → Punkt
|
||||||
|
// 2) „Gegenecke:" → Live-Rechteck-Vorschau; Klick committet das Dach.
|
||||||
|
//
|
||||||
|
// Herkunft der Dach-Felder (CommandContext):
|
||||||
|
// • floorId ← ctx.level.id (aktives Geschoss)
|
||||||
|
// • categoryCode← "35" (Ebene „Dächer"; Fallback ctx.defaultCategoryCode)
|
||||||
|
|
||||||
|
import type { Roof, RoofShape } from "../../model/types";
|
||||||
|
import { uniqueId } from "../../tools/types";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
CmdOption,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
/** Default-Ebene (Kategorie) für Dächer („31 Dächer"). */
|
||||||
|
const ROOF_CATEGORY = "31";
|
||||||
|
|
||||||
|
/** Wählbare Dachformen (Inline-Optionen der Befehlszeile). */
|
||||||
|
const SHAPE_OPTIONS: CmdOption[] = [
|
||||||
|
{ id: "sattel", labelKey: "cmd.roof.shape.sattel" },
|
||||||
|
{ id: "walm", labelKey: "cmd.roof.shape.walm" },
|
||||||
|
{ id: "pult", labelKey: "cmd.roof.shape.pult" },
|
||||||
|
{ id: "mansarde", labelKey: "cmd.roof.shape.mansarde" },
|
||||||
|
{ id: "zelt", labelKey: "cmd.roof.shape.zelt" },
|
||||||
|
{ id: "flach", labelKey: "cmd.roof.shape.flach" },
|
||||||
|
];
|
||||||
|
const SHAPE_IDS = new Set(SHAPE_OPTIONS.map((o) => o.id));
|
||||||
|
|
||||||
|
/** Existiert die Dach-Kategorie („35")? Sonst Fallback auf die aktive Kategorie. */
|
||||||
|
function roofCategory(ctx: CommandContext): string {
|
||||||
|
const flat: string[] = [];
|
||||||
|
const walk = (list: { code: string; children?: unknown[] }[]) => {
|
||||||
|
for (const c of list) {
|
||||||
|
flat.push(c.code);
|
||||||
|
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
|
||||||
|
return flat.includes(ROOF_CATEGORY) ? ROOF_CATEGORY : ctx.defaultCategoryCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vier Eckpunkte eines Rechtecks aus zwei Gegenecken (CCW). */
|
||||||
|
function rectCorners(a: Vec2, b: Vec2): Vec2[] {
|
||||||
|
const x0 = Math.min(a.x, b.x);
|
||||||
|
const x1 = Math.max(a.x, b.x);
|
||||||
|
const y0 = Math.min(a.y, b.y);
|
||||||
|
const y1 = Math.max(a.y, b.y);
|
||||||
|
return [
|
||||||
|
{ x: x0, y: y0 },
|
||||||
|
{ x: x1, y: y0 },
|
||||||
|
{ x: x1, y: y1 },
|
||||||
|
{ x: x0, y: y1 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoofIdle extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
shape: RoofShape;
|
||||||
|
}
|
||||||
|
interface RoofRect extends CommandState {
|
||||||
|
phase: "corner";
|
||||||
|
a: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
shape: RoofShape;
|
||||||
|
}
|
||||||
|
type RoofState = RoofIdle | RoofRect;
|
||||||
|
|
||||||
|
/** Vorschau: aufgezogenes Rechteck + Masse im HUD. */
|
||||||
|
function roofDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
|
const pts = cursor ? rectCorners(a, cursor) : [a];
|
||||||
|
const preview: DraftShape[] = [{ kind: "poly", pts, closed: !!cursor }];
|
||||||
|
const draft: ToolDraft = { preview, vertices: [a] };
|
||||||
|
if (cursor) {
|
||||||
|
draft.hud = {
|
||||||
|
at: cursor,
|
||||||
|
text: `${Math.abs(cursor.x - a.x).toFixed(2)} × ${Math.abs(cursor.y - a.y).toFixed(2)} m`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hängt ein Dach ans Projekt (immutabel). Entartete Rechtecke werden verworfen. */
|
||||||
|
function appendRoof(p: Project, a: Vec2, b: Vec2, shape: RoofShape, ctx: CommandContext): Project {
|
||||||
|
const w = Math.abs(b.x - a.x);
|
||||||
|
const d = Math.abs(b.y - a.y);
|
||||||
|
if (w < 0.1 || d < 0.1) return p;
|
||||||
|
const roof: Roof = {
|
||||||
|
id: uniqueId("RF"),
|
||||||
|
type: "roof",
|
||||||
|
floorId: ctx.level.id,
|
||||||
|
categoryCode: roofCategory(ctx),
|
||||||
|
outline: rectCorners(a, b),
|
||||||
|
shape,
|
||||||
|
// Flach/Pult brauchen keine „Firsthöhe"; Sattel/Walm/Mansarde/Zelt ~30°.
|
||||||
|
pitchDeg: shape === "flach" ? 0 : 30,
|
||||||
|
overhang: 0.4,
|
||||||
|
// First entlang der längeren Seite (Standard).
|
||||||
|
ridgeAxis: w >= d ? "x" : "y",
|
||||||
|
thickness: 0.2,
|
||||||
|
};
|
||||||
|
const roofs = p.roofs ? [...p.roofs, roof] : [roof];
|
||||||
|
return { ...p, roofs };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (shape: RoofShape = "sattel"): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", shape, lastPoint: null } as RoofIdle,
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const roofCommand: Command = {
|
||||||
|
name: "roof",
|
||||||
|
labelKey: "cmd.roof.label",
|
||||||
|
floorOnly: true,
|
||||||
|
prompt: (s) => ((s as RoofState).phase === "corner" ? "cmd.roof.corner" : "cmd.roof.start"),
|
||||||
|
accepts: () => ["point", "option"],
|
||||||
|
// Dachform als Inline-Optionen — vor/während des Aufziehens wählbar.
|
||||||
|
options: () => SHAPE_OPTIONS,
|
||||||
|
init: (): RoofIdle => ({ phase: "start", shape: "sattel", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as RoofState;
|
||||||
|
// Dachform-Option: aktualisiert die Form, Phase/Rechteck bleiben.
|
||||||
|
if (input.kind === "option" && SHAPE_IDS.has(input.id)) {
|
||||||
|
const ns = { ...s, shape: input.id as RoofShape } as RoofState;
|
||||||
|
const draft = ns.phase === "corner" ? roofDraft(ns.a, ns.cursor) : null;
|
||||||
|
return [ns, { draft }];
|
||||||
|
}
|
||||||
|
if (input.kind !== "point") {
|
||||||
|
return [s, { draft: s.phase === "corner" ? roofDraft(s.a, s.cursor) : null }];
|
||||||
|
}
|
||||||
|
if (s.phase !== "corner") {
|
||||||
|
const ns: RoofRect = {
|
||||||
|
phase: "corner",
|
||||||
|
a: input.point,
|
||||||
|
cursor: input.point,
|
||||||
|
shape: s.shape,
|
||||||
|
lastPoint: input.point,
|
||||||
|
};
|
||||||
|
return [ns, { draft: roofDraft(input.point, input.point) }];
|
||||||
|
}
|
||||||
|
// Zweite Ecke → committen.
|
||||||
|
const a = s.a;
|
||||||
|
const b = input.point;
|
||||||
|
const shape = s.shape;
|
||||||
|
return [
|
||||||
|
{ phase: "start", shape, lastPoint: null } as RoofIdle,
|
||||||
|
{ draft: null, done: true, commit: (p) => appendRoof(p, a, b, shape, ctx) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as RoofState;
|
||||||
|
if (s.phase !== "corner") return [s, { draft: null }];
|
||||||
|
const ns: RoofRect = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: roofDraft(s.a, point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import { ceilingCommand } from "./cmds/ceiling";
|
|||||||
import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening";
|
import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening";
|
||||||
import { stairCommand } from "./cmds/stair";
|
import { stairCommand } from "./cmds/stair";
|
||||||
import { columnCommand } from "./cmds/column";
|
import { columnCommand } from "./cmds/column";
|
||||||
|
import { roofCommand } from "./cmds/roof";
|
||||||
import { roomCommand } from "./cmds/room";
|
import { roomCommand } from "./cmds/room";
|
||||||
import { rectCommand } from "./cmds/rect";
|
import { rectCommand } from "./cmds/rect";
|
||||||
import { circleCommand } from "./cmds/circle";
|
import { circleCommand } from "./cmds/circle";
|
||||||
@@ -37,6 +38,7 @@ export const COMMANDS: Record<string, Command> = {
|
|||||||
tuer: tuerCommand,
|
tuer: tuerCommand,
|
||||||
stair: stairCommand,
|
stair: stairCommand,
|
||||||
column: columnCommand,
|
column: columnCommand,
|
||||||
|
roof: roofCommand,
|
||||||
room: roomCommand,
|
room: roomCommand,
|
||||||
line: lineCommand,
|
line: lineCommand,
|
||||||
polyline: polylineCommand,
|
polyline: polylineCommand,
|
||||||
@@ -75,6 +77,8 @@ export const ALIASES: Record<string, string> = {
|
|||||||
tp: "stair",
|
tp: "stair",
|
||||||
stuetze: "column",
|
stuetze: "column",
|
||||||
stütze: "column",
|
stütze: "column",
|
||||||
|
dach: "roof",
|
||||||
|
rf: "roof",
|
||||||
raum: "room",
|
raum: "room",
|
||||||
rm: "room",
|
rm: "room",
|
||||||
l: "line",
|
l: "line",
|
||||||
|
|||||||
@@ -856,6 +856,15 @@ export const de = {
|
|||||||
"cmd.ceiling.label": "Decke",
|
"cmd.ceiling.label": "Decke",
|
||||||
"cmd.ceiling.start": "Erster Umrisspunkt der Decke:",
|
"cmd.ceiling.start": "Erster Umrisspunkt der Decke:",
|
||||||
"cmd.ceiling.next": "Nächster Punkt ( Schliessen ):",
|
"cmd.ceiling.next": "Nächster Punkt ( Schliessen ):",
|
||||||
|
"cmd.roof.label": "Dach",
|
||||||
|
"cmd.roof.start": "Erste Dachecke:",
|
||||||
|
"cmd.roof.corner": "Gegenecke:",
|
||||||
|
"cmd.roof.shape.sattel": "Satteldach",
|
||||||
|
"cmd.roof.shape.walm": "Walmdach",
|
||||||
|
"cmd.roof.shape.pult": "Pultdach",
|
||||||
|
"cmd.roof.shape.mansarde": "Mansarddach",
|
||||||
|
"cmd.roof.shape.zelt": "Zeltdach",
|
||||||
|
"cmd.roof.shape.flach": "Flachdach",
|
||||||
"cmd.opening.label": "Öffnung",
|
"cmd.opening.label": "Öffnung",
|
||||||
"cmd.opening.windowLabel": "Fenster",
|
"cmd.opening.windowLabel": "Fenster",
|
||||||
"cmd.opening.doorLabel": "Tür",
|
"cmd.opening.doorLabel": "Tür",
|
||||||
|
|||||||
@@ -846,6 +846,15 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.ceiling.label": "Ceiling",
|
"cmd.ceiling.label": "Ceiling",
|
||||||
"cmd.ceiling.start": "First outline point of ceiling:",
|
"cmd.ceiling.start": "First outline point of ceiling:",
|
||||||
"cmd.ceiling.next": "Next point ( Close ):",
|
"cmd.ceiling.next": "Next point ( Close ):",
|
||||||
|
"cmd.roof.label": "Roof",
|
||||||
|
"cmd.roof.start": "First roof corner:",
|
||||||
|
"cmd.roof.corner": "Opposite corner:",
|
||||||
|
"cmd.roof.shape.sattel": "Gable roof",
|
||||||
|
"cmd.roof.shape.walm": "Hip roof",
|
||||||
|
"cmd.roof.shape.pult": "Mono-pitch roof",
|
||||||
|
"cmd.roof.shape.mansarde": "Mansard roof",
|
||||||
|
"cmd.roof.shape.zelt": "Pyramid roof",
|
||||||
|
"cmd.roof.shape.flach": "Flat roof",
|
||||||
"cmd.opening.label": "Opening",
|
"cmd.opening.label": "Opening",
|
||||||
"cmd.opening.windowLabel": "Window",
|
"cmd.opening.windowLabel": "Window",
|
||||||
"cmd.opening.doorLabel": "Door",
|
"cmd.opening.doorLabel": "Door",
|
||||||
|
|||||||
@@ -451,6 +451,27 @@ export const sampleProject: Project = {
|
|||||||
ceilingTypeId: "dg-massiv",
|
ceilingTypeId: "dg-massiv",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// Dach — Satteldach über dem Baukörper (5×4), First entlang X (längere Seite),
|
||||||
|
// 0.4 m Traufüberstand. Sitzt auf der OG-Oberkante (roofBaseElevation-Default).
|
||||||
|
roofs: [
|
||||||
|
{
|
||||||
|
id: "RF1",
|
||||||
|
type: "roof",
|
||||||
|
floorId: "og",
|
||||||
|
categoryCode: "31",
|
||||||
|
outline: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 5, y: 0 },
|
||||||
|
{ x: 5, y: 4 },
|
||||||
|
{ x: 0, y: 4 },
|
||||||
|
],
|
||||||
|
shape: "sattel",
|
||||||
|
pitchDeg: 30,
|
||||||
|
overhang: 0.4,
|
||||||
|
ridgeAxis: "x",
|
||||||
|
thickness: 0.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
// Treppen — eine gerade Beispieltreppe im EG entlang der Ostwand, steigt ins OG
|
// Treppen — eine gerade Beispieltreppe im EG entlang der Ostwand, steigt ins OG
|
||||||
// (totalRise = Geschosshöhe). Kategorie „40 Treppen".
|
// (totalRise = Geschosshöhe). Kategorie „40 Treppen".
|
||||||
stairs: [
|
stairs: [
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Unit-Tests für die Dach-Darstellung im Grundriss (generatePlan → addRoof):
|
||||||
|
* `project.roofs` wird nach Geschoss + sichtbarer Kategorie gefiltert (analog
|
||||||
|
* Decken) und pro Dach in Grundriss-Linien übersetzt (Traufe/First/Grat/Knick,
|
||||||
|
* aus `roofGeometry`). Reine Linien-Darstellung ohne Poché — die Form (flach/
|
||||||
|
* sattel/walm/mansarde) entscheidet, welche der vier Linienarten auftauchen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { generatePlan } from "./generatePlan";
|
||||||
|
import type { Project, Roof } from "../model/types";
|
||||||
|
|
||||||
|
/** Minimalprojekt ohne Wände: nur das Geschoss + die Dach-Kategorie „35". */
|
||||||
|
function baseProject(roofs?: Roof[]): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [
|
||||||
|
{
|
||||||
|
id: "eg",
|
||||||
|
name: "EG",
|
||||||
|
kind: "floor",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
floorHeight: 2.6,
|
||||||
|
cutHeight: 1.0,
|
||||||
|
baseElevation: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layers: [{ code: "35", name: "Dächer", color: "#8a4b32", lw: 0.25, visible: true, locked: false }],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
...(roofs !== undefined ? { roofs } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rectOutline: Roof["outline"] = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 6, y: 0 },
|
||||||
|
{ x: 6, y: 4 },
|
||||||
|
{ x: 0, y: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const roof = (id: string, shape: Roof["shape"], extra: Partial<Roof> = {}): Roof => ({
|
||||||
|
id,
|
||||||
|
type: "roof",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "35",
|
||||||
|
outline: rectOutline,
|
||||||
|
shape,
|
||||||
|
pitchDeg: 30,
|
||||||
|
overhang: 0.5,
|
||||||
|
ridgeAxis: "x",
|
||||||
|
thickness: 0.3,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = new Set(["35"]);
|
||||||
|
|
||||||
|
/** Filtert die Grundriss-Primitive nach `kind:"line"` + gegebener Klasse. */
|
||||||
|
function linesOf(primitives: ReturnType<typeof generatePlan>["primitives"], cls: string) {
|
||||||
|
return primitives.filter(
|
||||||
|
(p): p is Extract<typeof p, { kind: "line" }> => p.kind === "line" && p.cls === cls,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generatePlan — Dächer (Dachaufsicht)", () => {
|
||||||
|
it("Satteldach: Traufe + First, aber kein Grat/Knick", () => {
|
||||||
|
const plan = generatePlan(baseProject([roof("R1", "sattel")]), "eg", visible);
|
||||||
|
expect(linesOf(plan.primitives, "roof-eaves").length).toBeGreaterThan(0);
|
||||||
|
expect(linesOf(plan.primitives, "roof-ridge").length).toBeGreaterThan(0);
|
||||||
|
expect(linesOf(plan.primitives, "roof-hip").length).toBe(0);
|
||||||
|
expect(linesOf(plan.primitives, "roof-break").length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Walmdach: Traufe + First + Grat", () => {
|
||||||
|
const plan = generatePlan(baseProject([roof("R2", "walm")]), "eg", visible);
|
||||||
|
expect(linesOf(plan.primitives, "roof-eaves").length).toBeGreaterThan(0);
|
||||||
|
expect(linesOf(plan.primitives, "roof-ridge").length).toBeGreaterThan(0);
|
||||||
|
expect(linesOf(plan.primitives, "roof-hip").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Mansarddach: Knicklinien gestrichelt", () => {
|
||||||
|
const plan = generatePlan(baseProject([roof("R3", "mansarde")]), "eg", visible);
|
||||||
|
const breaks = linesOf(plan.primitives, "roof-break");
|
||||||
|
expect(breaks.length).toBeGreaterThan(0);
|
||||||
|
for (const b of breaks) {
|
||||||
|
expect(b.dash).toBeTruthy();
|
||||||
|
expect(b.dash?.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Flachdach: nur Traufe, weder First noch Grat noch Knick", () => {
|
||||||
|
const plan = generatePlan(baseProject([roof("R4", "flach")]), "eg", visible);
|
||||||
|
expect(linesOf(plan.primitives, "roof-eaves").length).toBeGreaterThan(0);
|
||||||
|
expect(linesOf(plan.primitives, "roof-ridge").length).toBe(0);
|
||||||
|
expect(linesOf(plan.primitives, "roof-hip").length).toBe(0);
|
||||||
|
expect(linesOf(plan.primitives, "roof-break").length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("First-Linie ist kräftiger als die Traufe (First > Traufe > Knick)", () => {
|
||||||
|
const plan = generatePlan(baseProject([roof("R5", "mansarde")]), "eg", visible);
|
||||||
|
const eaves = linesOf(plan.primitives, "roof-eaves")[0];
|
||||||
|
const ridge = linesOf(plan.primitives, "roof-ridge")[0];
|
||||||
|
const brk = linesOf(plan.primitives, "roof-break")[0];
|
||||||
|
expect(ridge.weightMm).toBeGreaterThan(eaves.weightMm);
|
||||||
|
expect(eaves.weightMm).toBeGreaterThan(brk.weightMm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("löst die Strichfarbe über roof.color, sonst die Kategorie-Farbe auf", () => {
|
||||||
|
const withColor = generatePlan(
|
||||||
|
baseProject([roof("R6", "sattel", { color: "#ff0000" })]),
|
||||||
|
"eg",
|
||||||
|
visible,
|
||||||
|
);
|
||||||
|
expect(linesOf(withColor.primitives, "roof-eaves")[0].color).toBe("#ff0000");
|
||||||
|
|
||||||
|
const withoutColor = generatePlan(baseProject([roof("R7", "sattel")]), "eg", visible);
|
||||||
|
expect(linesOf(withoutColor.primitives, "roof-eaves")[0].color).toBe("#8a4b32");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Rückwärtskompatibilität: ohne Dächer (bzw. Dach auf anderem Geschoss) bleiben die Primitive unverändert", () => {
|
||||||
|
const planNoField = generatePlan(baseProject(undefined), "eg", visible);
|
||||||
|
const planEmpty = generatePlan(baseProject([]), "eg", visible);
|
||||||
|
const planOtherFloor = generatePlan(
|
||||||
|
baseProject([roof("R8", "sattel", { floorId: "og" })]),
|
||||||
|
"eg",
|
||||||
|
visible,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(planEmpty.primitives).toEqual(planNoField.primitives);
|
||||||
|
expect(planOtherFloor.primitives).toEqual(planNoField.primitives);
|
||||||
|
expect(planNoField.primitives.some((p) => p.kind === "line" && p.cls.startsWith("roof-"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
Opening,
|
Opening,
|
||||||
OverrideActions,
|
OverrideActions,
|
||||||
Project,
|
Project,
|
||||||
|
Roof,
|
||||||
Room,
|
Room,
|
||||||
Stair,
|
Stair,
|
||||||
Vec2,
|
Vec2,
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
wallTypeThickness,
|
wallTypeThickness,
|
||||||
} from "../model/types";
|
} from "../model/types";
|
||||||
import { columnFootprint } from "../geometry/column";
|
import { columnFootprint } from "../geometry/column";
|
||||||
|
import { roofGeometry } from "../geometry/roof";
|
||||||
import { effectiveOverrides, hasOverrides } from "../overrides/engine";
|
import { effectiveOverrides, hasOverrides } from "../overrides/engine";
|
||||||
import { evaluateRoom, siaLabel } from "../geometry/roomArea";
|
import { evaluateRoom, siaLabel } from "../geometry/roomArea";
|
||||||
import type { Marks, RichTextDoc } from "../text/richText";
|
import type { Marks, RichTextDoc } from "../text/richText";
|
||||||
@@ -114,6 +116,15 @@ const SYMBOL_HAIRLINE_MM = 0.02;
|
|||||||
* Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach
|
* Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach
|
||||||
* BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */
|
* BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */
|
||||||
const OVERHEAD_DASH: number[] = [0.4, 0.25];
|
const OVERHEAD_DASH: number[] = [0.4, 0.25];
|
||||||
|
/** Strichstärken der Dach-Grundrisslinien (mm Papier): Traufe = mittlere
|
||||||
|
* Haarlinie, First etwas kräftiger als die Traufe, Grat dazwischen, Knick dünn
|
||||||
|
* + gestrichelt (vgl. addRoofs). */
|
||||||
|
const ROOF_EAVES_MM = 0.13;
|
||||||
|
const ROOF_RIDGE_MM = 0.25;
|
||||||
|
const ROOF_HIP_MM = 0.18;
|
||||||
|
const ROOF_BREAK_MM = 0.09;
|
||||||
|
/** Strichmuster (mm Papier) der Dach-Knicklinie (Mansarde). */
|
||||||
|
const ROOF_BREAK_DASH: number[] = [0.12, 0.08];
|
||||||
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
|
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
|
||||||
const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = {
|
const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = {
|
||||||
grob: 1.6, // dickere Sammellinie
|
grob: 1.6, // dickere Sammellinie
|
||||||
@@ -735,6 +746,20 @@ export function generatePlan(
|
|||||||
addCeilingPoche(primitives, project, ceiling, greyed, detail, category, wallFootprints);
|
addCeilingPoche(primitives, project, ceiling, greyed, detail, category, wallFootprints);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dächer dieses Geschosses (Dachaufsicht): reine Grundriss-Linien (Traufe/
|
||||||
|
// First/Grat/Knick), kein Poché. Über der Wand-Poché gezeichnet.
|
||||||
|
const roofs = (project.roofs ?? []).filter(
|
||||||
|
(r) =>
|
||||||
|
r.floorId === floorId &&
|
||||||
|
visibleCodes.has(r.categoryCode) &&
|
||||||
|
categoryDisplay(r.categoryCode).render,
|
||||||
|
);
|
||||||
|
for (const roof of roofs) {
|
||||||
|
const greyed = categoryDisplay(roof.categoryCode).greyed;
|
||||||
|
const category = catByCode.get(roof.categoryCode);
|
||||||
|
addRoof(primitives, roof, greyed, category);
|
||||||
|
}
|
||||||
|
|
||||||
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
|
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
|
||||||
// (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände
|
// (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände
|
||||||
// lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob".
|
// lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob".
|
||||||
@@ -867,7 +892,7 @@ export function generatePlan(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
primitives,
|
primitives,
|
||||||
bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms),
|
bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms, roofs),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2414,6 +2439,58 @@ function addCeilingPoche(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dach im Grundriss (Dachaufsicht): reine Linien-Darstellung ohne Poché — die
|
||||||
|
* 2D-Geometrie aus `roofGeometry` (First/Grat/Knick hängen NICHT von der
|
||||||
|
* Traufhöhe ab, daher `eavesZ=0`):
|
||||||
|
* • Traufe (`eaves`): geschlossener Umriss-Ring als mittlere Haarlinie.
|
||||||
|
* • First (`ridges`): kräftige Volllinie, etwas stärker als die Traufe.
|
||||||
|
* • Grat (`hips`, Walm/Zelt): Volllinie zwischen Traufe- und First-Stärke.
|
||||||
|
* • Knick (`breaks`, Mansarde): dünne gestrichelte Linie.
|
||||||
|
* Farbe: `roof.color` übersteuert sonst die Kategorie-Farbe (analog Stützen-
|
||||||
|
* Poché), Default die neutrale Poché-Tinte.
|
||||||
|
*/
|
||||||
|
function addRoof(
|
||||||
|
out: Primitive[],
|
||||||
|
roof: Roof,
|
||||||
|
greyed: boolean,
|
||||||
|
category: LayerCategory | undefined,
|
||||||
|
): void {
|
||||||
|
const stroke = roof.color ?? category?.color ?? POCHE_STROKE;
|
||||||
|
const geo = roofGeometry(roof, 0);
|
||||||
|
|
||||||
|
const n = geo.eaves.length;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
out.push({
|
||||||
|
kind: "line",
|
||||||
|
a: geo.eaves[i],
|
||||||
|
b: geo.eaves[(i + 1) % n],
|
||||||
|
cls: "roof-eaves",
|
||||||
|
weightMm: ROOF_EAVES_MM,
|
||||||
|
color: stroke,
|
||||||
|
greyed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [a, b] of geo.ridges) {
|
||||||
|
out.push({ kind: "line", a, b, cls: "roof-ridge", weightMm: ROOF_RIDGE_MM, color: stroke, greyed });
|
||||||
|
}
|
||||||
|
for (const [a, b] of geo.hips) {
|
||||||
|
out.push({ kind: "line", a, b, cls: "roof-hip", weightMm: ROOF_HIP_MM, color: stroke, greyed });
|
||||||
|
}
|
||||||
|
for (const [a, b] of geo.breaks) {
|
||||||
|
out.push({
|
||||||
|
kind: "line",
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
cls: "roof-break",
|
||||||
|
weightMm: ROOF_BREAK_MM,
|
||||||
|
dash: ROOF_BREAK_DASH,
|
||||||
|
color: stroke,
|
||||||
|
greyed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deckkraft (0..255) der Raum-Füllung → 2-stelliges Hex-Suffix. Transluzente
|
* Deckkraft (0..255) der Raum-Füllung → 2-stelliges Hex-Suffix. Transluzente
|
||||||
* Farbfüllung entfällt — Plandarstellung schwarz/grau; Farbe bleibt späteren
|
* Farbfüllung entfällt — Plandarstellung schwarz/grau; Farbe bleibt späteren
|
||||||
@@ -2671,6 +2748,7 @@ function computeBounds(
|
|||||||
ceilings: Ceiling[] = [],
|
ceilings: Ceiling[] = [],
|
||||||
stairs: Stair[] = [],
|
stairs: Stair[] = [],
|
||||||
rooms: Room[] = [],
|
rooms: Room[] = [],
|
||||||
|
roofs: Roof[] = [],
|
||||||
) {
|
) {
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
const acc = (p: Vec2) => {
|
const acc = (p: Vec2) => {
|
||||||
@@ -2683,6 +2761,9 @@ function computeBounds(
|
|||||||
}
|
}
|
||||||
for (const c of ceilings) for (const p of c.outline) acc(p);
|
for (const c of ceilings) for (const p of c.outline) acc(p);
|
||||||
for (const r of rooms) for (const p of r.boundary) acc(p);
|
for (const r of rooms) for (const p of r.boundary) acc(p);
|
||||||
|
// Traufe (inkl. Überstand) statt reinem Umriss, damit ein weit auskragendes
|
||||||
|
// Dach das „Einpassen" korrekt mit einrahmt.
|
||||||
|
for (const roof of roofs) for (const p of roofGeometry(roof, 0).eaves) acc(p);
|
||||||
for (const s of stairs) {
|
for (const s of stairs) {
|
||||||
const { zBottom, zTop } = stairVerticalExtent(project, s);
|
const { zBottom, zTop } = stairVerticalExtent(project, s);
|
||||||
const geo = stairGeometry(s, zTop - zBottom);
|
const geo = stairGeometry(s, zTop - zBottom);
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import type { Project } from "../model/types";
|
import type { Project, Roof } from "../model/types";
|
||||||
import { sampleProject } from "../model/sampleProject";
|
import { sampleProject } from "../model/sampleProject";
|
||||||
import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d";
|
import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d";
|
||||||
import { resolveHatch } from "./generatePlan";
|
import { resolveHatch } from "./generatePlan";
|
||||||
|
import { roofGeometry, roofBaseElevation } from "../geometry/roof";
|
||||||
|
|
||||||
const RGB: [number, number, number] = [1, 0.5, 0.1];
|
const RGB: [number, number, number] = [1, 0.5, 0.1];
|
||||||
const FLOATS_PER_VERTEX = 6; // [px,py,pz, r,g,b]
|
const FLOATS_PER_VERTEX = 6; // [px,py,pz, r,g,b]
|
||||||
@@ -1047,3 +1048,78 @@ describe("emitStairMeshes (Betontreppe: glatte Laufplatte als Mesh)", () => {
|
|||||||
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
|
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
|
||||||
|
// Dieselbe Terrakotta-Farbe wie ROOF_RGB in toWalls3d.ts — dient hier nur der
|
||||||
|
// Isolation der Dach-Meshes von Kontext-/Öffnungs-/Treppen-Meshes im Test.
|
||||||
|
const ROOF_RGB: [number, number, number] = [0.72, 0.45, 0.36];
|
||||||
|
const isRoofMesh = (m: { color: readonly number[] }) =>
|
||||||
|
m.color[0] === ROOF_RGB[0] && m.color[1] === ROOF_RGB[1] && m.color[2] === ROOF_RGB[2];
|
||||||
|
|
||||||
|
const baseRoof: Roof = {
|
||||||
|
id: "R1",
|
||||||
|
type: "roof",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "35",
|
||||||
|
outline: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 6, y: 0 },
|
||||||
|
{ x: 6, y: 4 },
|
||||||
|
{ x: 0, y: 4 },
|
||||||
|
],
|
||||||
|
shape: "sattel",
|
||||||
|
pitchDeg: 30,
|
||||||
|
overhang: 0,
|
||||||
|
ridgeAxis: "x",
|
||||||
|
baseElevation: 3, // fix, unabhängig von Geschoss-Stapelung
|
||||||
|
thickness: 0.3,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("Satteldach: Flächen + Giebel als EIN Roof-Mesh, First-Höhe passt zur Traufhöhe + ridgeHeight", () => {
|
||||||
|
const project: Project = { ...sampleProject, roofs: [baseRoof] };
|
||||||
|
const { meshes } = projectToModel3d(project);
|
||||||
|
const roofMeshes = meshes.filter(isRoofMesh);
|
||||||
|
// Zwei Dachflächen (je ein Quad -> 2 Dreiecke) + zwei Giebel (je ein Dreieck)
|
||||||
|
// landen in EINEM kombinierten Mesh (Flächen und Giebel bilden zusammen den
|
||||||
|
// geschlossenen Dachkörper).
|
||||||
|
expect(roofMeshes.length).toBe(1);
|
||||||
|
const m = roofMeshes[0];
|
||||||
|
expect(m.positions.every((v) => Number.isFinite(v))).toBe(true);
|
||||||
|
expect(m.indices.length % 3).toBe(0);
|
||||||
|
expect(m.indices.length).toBeGreaterThan(0);
|
||||||
|
const vcount = m.positions.length / 3;
|
||||||
|
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
|
||||||
|
// Firsthöhe: eavesZ (baseElevation) + ridgeHeight aus roofGeometry.
|
||||||
|
const g = roofGeometry(baseRoof, roofBaseElevation(project, baseRoof));
|
||||||
|
const expectedRidgeZ = roofBaseElevation(project, baseRoof) + g.ridgeHeight;
|
||||||
|
const heights = m.positions.filter((_, i) => i % 3 === 2);
|
||||||
|
expect(Math.max(...heights)).toBeCloseTo(expectedRidgeZ, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Walmdach: vier Dachflächen (2 Quader + 2 Dreiecke) korrekt trianguliert", () => {
|
||||||
|
const walmRoof: Roof = { ...baseRoof, id: "R2", shape: "walm" };
|
||||||
|
const project: Project = { ...sampleProject, roofs: [walmRoof] };
|
||||||
|
const { meshes } = projectToModel3d(project);
|
||||||
|
const roofMeshes = meshes.filter(isRoofMesh);
|
||||||
|
expect(roofMeshes.length).toBe(1);
|
||||||
|
const m = roofMeshes[0];
|
||||||
|
expect(m.positions.every((v) => Number.isFinite(v))).toBe(true);
|
||||||
|
// Walm hat KEINE Giebel (gables: []); 2 Quader (je 2 Dreiecke) + 2 Dreiecke
|
||||||
|
// (je 1 Dreieck) = 6 Dreiecke = 18 Indizes.
|
||||||
|
expect(m.indices.length).toBe(18);
|
||||||
|
const vcount = m.positions.length / 3;
|
||||||
|
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Projekt ohne Dächer emittiert keine Roof-Meshes (Rückwärtskompatibilität)", () => {
|
||||||
|
// Explizit leeres roofs-Feld -> keine Roof-Meshes.
|
||||||
|
const project: Project = { ...sampleProject, roofs: [] };
|
||||||
|
expect(projectToModel3d(project).meshes.filter(isRoofMesh).length).toBe(0);
|
||||||
|
// Fehlt das Feld ganz (undefined), ebenfalls keine.
|
||||||
|
const noField: Project = { ...sampleProject };
|
||||||
|
delete (noField as { roofs?: unknown }).roofs;
|
||||||
|
expect(projectToModel3d(noField).meshes.filter(isRoofMesh).length).toBe(0);
|
||||||
|
// sampleProject enthält jetzt ein Demo-Satteldach (RF1) -> genau ein Roof-Mesh.
|
||||||
|
expect(projectToModel3d(sampleProject).meshes.filter(isRoofMesh).length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+51
-3
@@ -51,6 +51,8 @@ import { pointInOutline } from "../geometry/ceiling";
|
|||||||
import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening";
|
import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening";
|
||||||
import type { DetailLevel } from "../ui/TopBar";
|
import type { DetailLevel } from "../ui/TopBar";
|
||||||
import { stairGeometry, stairBBox } from "../geometry/stair";
|
import { stairGeometry, stairBBox } from "../geometry/stair";
|
||||||
|
import { roofGeometry, roofBaseElevation } from "../geometry/roof";
|
||||||
|
import type { Vec3 as RoofVec3 } from "../geometry/roof";
|
||||||
import { extrudePolygon, extrudeCircle } from "../engine/truckSolid";
|
import { extrudePolygon, extrudeCircle } from "../engine/truckSolid";
|
||||||
import { computeJoins } from "../model/joins";
|
import { computeJoins } from "../model/joins";
|
||||||
import type { WallCuts } from "../model/joins";
|
import type { WallCuts } from "../model/joins";
|
||||||
@@ -393,6 +395,8 @@ const GLASS_HALF_THICK = 0.01;
|
|||||||
const FRAME_RGB: RRgb = [0.85, 0.85, 0.83];
|
const FRAME_RGB: RRgb = [0.85, 0.85, 0.83];
|
||||||
/** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */
|
/** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */
|
||||||
const DEFAULT_FRAME_WIDTH = 0.06;
|
const DEFAULT_FRAME_WIDTH = 0.06;
|
||||||
|
/** Warmer Terrakotta-Grauton für Dachflächen/-giebel (s. {@link emitRoofs}) — hebt das Dach klar von Wand/Decke/Kontext ab. */
|
||||||
|
const ROOF_RGB: RRgb = [0.72, 0.45, 0.36];
|
||||||
/** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */
|
/** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */
|
||||||
const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255];
|
const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255];
|
||||||
/** Halbe Linienbreite der 2D-Boden-Spuren im 3D (Meter) — liest sich als dünne Linie. */
|
/** Halbe Linienbreite der 2D-Boden-Spuren im 3D (Meter) — liest sich als dünne Linie. */
|
||||||
@@ -1633,6 +1637,49 @@ function emitColumns(project: Project): RMesh[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trianguliert ein KONVEXES 3D-Polygon per Dreiecks-Fächer ab Vertex 0 und hängt
|
||||||
|
* die Dreiecke an `positions`/`indices` an. `roofGeometry` (Vertrag in
|
||||||
|
* `geometry/roof.ts`) liefert für `planes`/`gables` ausschliesslich konvexe
|
||||||
|
* Polygone (Dreieck/Quader bei den meisten Formen, ein Fünfeck-Giebel bei
|
||||||
|
* Mansarde) — ein Fächer trianguliert diese korrekt, ohne Ear-Clipping.
|
||||||
|
*/
|
||||||
|
function pushFanTriangles(positions: number[], indices: number[], pts: RoofVec3[]): void {
|
||||||
|
if (pts.length < 3) return;
|
||||||
|
const base = positions.length / 3;
|
||||||
|
for (const p of pts) positions.push(p[0], p[1], p[2]);
|
||||||
|
for (let i = 1; i < pts.length - 1; i++) indices.push(base, base + i, base + i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emittiert Dächer (`project.roofs`) als geschlossene Dreiecks-Meshes: sowohl die
|
||||||
|
* geneigten Dachflächen (`RoofGeometry.planes`) als auch die Giebelflächen
|
||||||
|
* (`RoofGeometry.gables`) — erst zusammen bilden sie einen von aussen
|
||||||
|
* geschlossenen Körper (die Giebel schliessen die offenen Enden bei Sattel/
|
||||||
|
* Mansarde, die First-/Grat-/Knicklinien sind reine Grundriss-Hilfslinien und
|
||||||
|
* werden hier nicht gebraucht). Traufhöhe über {@link roofBaseElevation}
|
||||||
|
* (Override oder Geschoss-Oberkante), Flächen über {@link roofGeometry}. Farbe:
|
||||||
|
* `roof.color` (falls gültiges Hex), sonst {@link ROOF_RGB}. `positions` in
|
||||||
|
* MODELL-Metern (x, y, z=Höhe) wie {@link emitOpeningGlass}/{@link emitMeshes};
|
||||||
|
* render3d rendert Kontext-Meshes doppelseitig, die Winding-Reihenfolge ist also
|
||||||
|
* unerheblich.
|
||||||
|
*/
|
||||||
|
function emitRoofs(project: Project): RMesh[] {
|
||||||
|
const out: RMesh[] = [];
|
||||||
|
for (const roof of project.roofs ?? []) {
|
||||||
|
const eavesZ = roofBaseElevation(project, roof);
|
||||||
|
const g = roofGeometry(roof, eavesZ);
|
||||||
|
const color = roof.color ? hexToRgb(roof.color, ROOF_RGB) : ROOF_RGB;
|
||||||
|
const positions: number[] = [];
|
||||||
|
const indices: number[] = [];
|
||||||
|
for (const plane of g.planes) pushFanTriangles(positions, indices, plane.pts);
|
||||||
|
for (const gable of g.gables) pushFanTriangles(positions, indices, gable);
|
||||||
|
if (indices.length === 0) continue;
|
||||||
|
out.push({ positions, indices, kind: "extrusion", color });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emittiert die rohen Kontext-Meshes eines Projekts: Gelände-TINs (`terrainMesh`)
|
* Emittiert die rohen Kontext-Meshes eines Projekts: Gelände-TINs (`terrainMesh`)
|
||||||
* und importierte Volumen (`importedMesh`) aus `project.context`. `contourSet`
|
* und importierte Volumen (`importedMesh`) aus `project.context`. `contourSet`
|
||||||
@@ -2110,14 +2157,15 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R
|
|||||||
// Kontext-Meshes (Terrain/Import) + Fenster-/Tür-Glasscheiben (eigenes Mesh
|
// Kontext-Meshes (Terrain/Import) + Fenster-/Tür-Glasscheiben (eigenes Mesh
|
||||||
// je Scheibe) + Rahmen-/Sprossen-Riegel typisierter Öffnungen (Zarge/
|
// je Scheibe) + Rahmen-/Sprossen-Riegel typisierter Öffnungen (Zarge/
|
||||||
// Blendrahmen/Kämpfer/Mittelpfosten, s. emitOpeningFrames) + glatte Beton-
|
// Blendrahmen/Kämpfer/Mittelpfosten, s. emitOpeningFrames) + glatte Beton-
|
||||||
// Laufplatten (gerade Betontreppen als Mesh) — pickGeometry/Highlight nutzen
|
// Laufplatten (gerade Betontreppen als Mesh) + Dachflächen/-giebel
|
||||||
// `meshes` bewusst NICHT (man wählt Wand/Öffnung/Treppe; Treppen-Pick läuft
|
// (s. emitRoofs) — pickGeometry/Highlight nutzen `meshes` bewusst NICHT (man
|
||||||
// über die pickGeometry-Boxen).
|
// wählt Wand/Öffnung/Treppe; Treppen-Pick läuft über die pickGeometry-Boxen).
|
||||||
meshes: [
|
meshes: [
|
||||||
...emitMeshes(project),
|
...emitMeshes(project),
|
||||||
...emitOpeningGlass(project),
|
...emitOpeningGlass(project),
|
||||||
...emitOpeningFrames(project, detail),
|
...emitOpeningFrames(project, detail),
|
||||||
...emitStairMeshes(project),
|
...emitStairMeshes(project),
|
||||||
|
...emitRoofs(project),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ export function CommandIcon({ name }: { name: string }) {
|
|||||||
<line x1="8" y1="2.5" x2="8" y2="13.5" strokeDasharray="1.6 1.6" />
|
<line x1="8" y1="2.5" x2="8" y2="13.5" strokeDasharray="1.6 1.6" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case "roof":
|
||||||
|
// Satteldach: Giebeldreieck + Traufe.
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<polyline points="2,9 8,3 14,9" />
|
||||||
|
<line x1="2" y1="12" x2="14" y2="12" />
|
||||||
|
<line x1="8" y1="3" x2="8" y2="9" strokeDasharray="1.5 1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export const RIBBON_TABS: RibbonTab[] = [
|
|||||||
t("stair"),
|
t("stair"),
|
||||||
c("column"),
|
c("column"),
|
||||||
t("ceiling"),
|
t("ceiling"),
|
||||||
|
c("roof"),
|
||||||
t("room"),
|
t("room"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user