Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)

Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+189
View File
@@ -0,0 +1,189 @@
// Unit-Tests der Override-Regel-Engine (reine Auswertung, src/overrides/engine.ts):
// Operatoren, Reihenfolge (oberste Regel gewinnt pro Feld), additive
// Teilaktionen, deaktivierte Regeln, layer_name gegen Name UND Code.
import { describe, it, expect } from "vitest";
import type { OverrideRule } from "../model/types";
import {
EMPTY_OVERRIDES,
effectiveOverrides,
hasOverrides,
matchesCondition,
} from "./engine";
/** Kurzform: Regel mit Defaults. */
function rule(partial: Partial<OverrideRule> & Pick<OverrideRule, "condition" | "actions">): OverrideRule {
return {
id: partial.id ?? "r",
name: partial.name ?? "Regel",
enabled: partial.enabled ?? true,
condition: partial.condition,
actions: partial.actions,
};
}
const WALL_SUBJECT = { layerName: "Wände", layerCode: "20", objectName: "AW 24" };
describe("overrides/engine — matchesCondition", () => {
it("equals: case-insensitiv, trimmt den Regelwert", () => {
expect(
matchesCondition(
{ type: "layer_name", operator: "equals", value: " wände " },
WALL_SUBJECT,
),
).toBe(true);
expect(
matchesCondition(
{ type: "layer_name", operator: "equals", value: "Decken" },
WALL_SUBJECT,
),
).toBe(false);
});
it("layer_name matcht Namen ODER Code der Ebene", () => {
expect(
matchesCondition(
{ type: "layer_name", operator: "equals", value: "20" },
WALL_SUBJECT,
),
).toBe(true);
});
it("contains und starts_with auf dem Objektnamen", () => {
expect(
matchesCondition(
{ type: "object_name", operator: "contains", value: "24" },
WALL_SUBJECT,
),
).toBe(true);
expect(
matchesCondition(
{ type: "object_name", operator: "starts_with", value: "aw" },
WALL_SUBJECT,
),
).toBe(true);
expect(
matchesCondition(
{ type: "object_name", operator: "starts_with", value: "24" },
WALL_SUBJECT,
),
).toBe(false);
});
it("not_equals trifft nur, wenn WEDER Name noch Code gleich sind", () => {
expect(
matchesCondition(
{ type: "layer_name", operator: "not_equals", value: "20" },
WALL_SUBJECT,
),
).toBe(false);
expect(
matchesCondition(
{ type: "layer_name", operator: "not_equals", value: "Wände" },
WALL_SUBJECT,
),
).toBe(false);
expect(
matchesCondition(
{ type: "layer_name", operator: "not_equals", value: "Decken" },
WALL_SUBJECT,
),
).toBe(true);
});
it("fehlende Subjekt-Eigenschaft: positive Operatoren treffen nicht, not_equals schon", () => {
expect(
matchesCondition(
{ type: "object_name", operator: "contains", value: "x" },
{ layerName: "Wände", layerCode: "20" },
),
).toBe(false);
expect(
matchesCondition(
{ type: "object_name", operator: "not_equals", value: "x" },
{ layerName: "Wände", layerCode: "20" },
),
).toBe(true);
});
});
describe("overrides/engine — effectiveOverrides", () => {
it("ohne Regeln (oder undefined) kommt EMPTY_OVERRIDES zurück", () => {
expect(effectiveOverrides(undefined, WALL_SUBJECT)).toBe(EMPTY_OVERRIDES);
expect(effectiveOverrides([], WALL_SUBJECT)).toBe(EMPTY_OVERRIDES);
expect(hasOverrides(EMPTY_OVERRIDES)).toBe(false);
});
it("oberste Regel gewinnt pro Aktions-Feld", () => {
const rules = [
rule({
id: "top",
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#ff0000" },
}),
rule({
id: "below",
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#00ff00" },
}),
];
expect(effectiveOverrides(rules, WALL_SUBJECT).color).toBe("#ff0000");
});
it("additive Teilaktionen: untere Regeln füllen offene Felder", () => {
const rules = [
rule({
id: "color-only",
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#ff0000" },
}),
rule({
id: "weight-only",
condition: { type: "object_name", operator: "starts_with", value: "AW" },
actions: { lineweight: 0.7, color: "#0000ff" },
}),
];
const eff = effectiveOverrides(rules, WALL_SUBJECT);
// Farbe aus der obersten Regel, Strichstärke additiv aus der zweiten.
expect(eff).toEqual({ color: "#ff0000", lineweight: 0.7 });
});
it("deaktivierte Regeln werden übersprungen", () => {
const rules = [
rule({
id: "off",
enabled: false,
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#ff0000" },
}),
rule({
id: "on",
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#00ff00" },
}),
];
expect(effectiveOverrides(rules, WALL_SUBJECT).color).toBe("#00ff00");
});
it("nicht matchende Regeln tragen nichts bei", () => {
const rules = [
rule({
id: "other-layer",
condition: { type: "layer_name", operator: "equals", value: "30" },
actions: { color: "#ff0000" },
}),
];
expect(effectiveOverrides(rules, WALL_SUBJECT)).toBe(EMPTY_OVERRIDES);
});
it("linetypeId wird wie die anderen Felder aufgelöst", () => {
const rules = [
rule({
id: "lt",
condition: { type: "layer_name", operator: "contains", value: "wän" },
actions: { linetypeId: "dashed" },
}),
];
expect(effectiveOverrides(rules, WALL_SUBJECT).linetypeId).toBe("dashed");
});
});
+131
View File
@@ -0,0 +1,131 @@
// Grafische Overrides — reine Auswertungs-Engine (Regel-Engine, DOSSIER A1).
//
// Wertet die Regelliste `Project.overrideRules` für ein Element aus und
// liefert die effektiven Aktionen (Farbe/Strichstärke/Linienstil). Die Regeln
// werden ADDITIV angewendet: pro Aktions-Feld gewinnt die OBERSTE (erste)
// aktive Regel, deren Bedingung matcht; spätere Regeln füllen nur noch die
// verbleibenden Felder. Deaktivierte Regeln werden übersprungen.
//
// Reines Rendering-Overlay: die Engine kennt weder Project noch Renderer —
// der Aufrufer (plan/generatePlan.ts) baut das Subjekt aus Ebene + Element
// und legt das Ergebnis als oberste Schicht ÜBER die reguläre
// By-Layer/By-Object-Attribut-Auflösung. Elementdaten bleiben unverändert.
import type {
OverrideActions,
OverrideCondition,
OverrideRule,
} from "../model/types";
/**
* Das Subjekt einer Auswertung — die vergleichbaren Eigenschaften EINES
* Elements. `layer_name`-Bedingungen matchen gegen Namen ODER Code der Ebene
* (LayerCategory), `object_name`-Bedingungen gegen den Element-/Typ-Namen.
*/
export interface OverrideSubject {
/** Name der Ebene (LayerCategory.name) des Elements. */
layerName?: string;
/** Code der Ebene (LayerCategory.code / Element.categoryCode). */
layerCode?: string;
/** Element-/Typ-Name (z. B. Wandtyp-Name, Raum-Name, Öffnungs-Label). */
objectName?: string;
}
/** Leere Aktionen (kein Override) — geteilte Konstante für den Schnellpfad. */
export const EMPTY_OVERRIDES: OverrideActions = Object.freeze({});
/** Case-insensitiver Feld-Vergleich eines Operators. */
function matchValue(
operator: OverrideCondition["operator"],
candidate: string,
value: string,
): boolean {
switch (operator) {
case "equals":
return candidate === value;
case "contains":
return candidate.includes(value);
case "starts_with":
return candidate.startsWith(value);
case "not_equals":
return candidate !== value;
}
}
/**
* Prüft, ob eine Bedingung auf ein Subjekt zutrifft (case-insensitiv,
* Wert getrimmt). `layer_name` vergleicht gegen Ebenen-NAME und -CODE:
* positive Operatoren (equals/contains/starts_with) treffen, wenn EINER der
* beiden passt; `not_equals` trifft nur, wenn KEINER der beiden gleich ist
* (sonst würde der Code fast jede Namens-Ungleichheit erfüllen).
*/
export function matchesCondition(
condition: OverrideCondition,
subject: OverrideSubject,
): boolean {
const value = condition.value.trim().toLowerCase();
const candidates: string[] = [];
if (condition.type === "layer_name") {
if (subject.layerName != null) candidates.push(subject.layerName.trim().toLowerCase());
if (subject.layerCode != null) candidates.push(subject.layerCode.trim().toLowerCase());
} else {
if (subject.objectName != null) candidates.push(subject.objectName.trim().toLowerCase());
}
if (candidates.length === 0) {
// Subjekt trägt die Eigenschaft nicht: positive Operatoren können nicht
// treffen; „ungleich" ist trivial erfüllt.
return condition.operator === "not_equals";
}
if (condition.operator === "not_equals") {
return candidates.every((c) => c !== value);
}
return candidates.some((c) => matchValue(condition.operator, c, value));
}
/**
* Kollabiert die Regelliste für ein Subjekt zu den effektiven Aktionen.
* Additiv, oberste Regel gewinnt PRO FELD: die erste aktive, matchende Regel,
* die ein Feld setzt, bestimmt es; weitere Regeln füllen nur offene Felder.
* Ohne Treffer (oder ohne Regeln) kommt {@link EMPTY_OVERRIDES} zurück.
*/
export function effectiveOverrides(
rules: readonly OverrideRule[] | undefined,
subject: OverrideSubject,
): OverrideActions {
if (!rules || rules.length === 0) return EMPTY_OVERRIDES;
let out: OverrideActions | null = null;
for (const rule of rules) {
if (!rule.enabled) continue;
if (!matchesCondition(rule.condition, subject)) continue;
const a = rule.actions;
if (a.color === undefined && a.lineweight === undefined && a.linetypeId === undefined) {
continue;
}
if (!out) out = {};
if (out.color === undefined && a.color !== undefined) out.color = a.color;
if (out.lineweight === undefined && a.lineweight !== undefined) {
out.lineweight = a.lineweight;
}
if (out.linetypeId === undefined && a.linetypeId !== undefined) {
out.linetypeId = a.linetypeId;
}
// Alle Felder belegt → keine weitere Regel kann noch etwas beitragen.
if (
out.color !== undefined &&
out.lineweight !== undefined &&
out.linetypeId !== undefined
) {
break;
}
}
return out ?? EMPTY_OVERRIDES;
}
/** True, wenn mindestens eine Aktion gesetzt ist (Schnelltest für Aufrufer). */
export function hasOverrides(actions: OverrideActions): boolean {
return (
actions.color !== undefined ||
actions.lineweight !== undefined ||
actions.linetypeId !== undefined
);
}