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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user