From 9c911e6d4349f9a9808b25a160dbb8833f318189 Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 5 Jul 2026 21:22:51 +0200 Subject: [PATCH] =?UTF-8?q?Schnitt/Ansicht=20Phase=201:=20Wand=20unter=20S?= =?UTF-8?q?chnittebene=20als=20Ansichts-Umriss=20(viewOnly,=20keine=20Poch?= =?UTF-8?q?=C3=A9),=20mit=20Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PENDENZEN.md | 3 +- src/plan/generatePlan.ts | 31 +++++++++- src/plan/generatePlan.viewwall.test.ts | 82 ++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 src/plan/generatePlan.viewwall.test.ts diff --git a/PENDENZEN.md b/PENDENZEN.md index cc08ebe..55280c0 100644 --- a/PENDENZEN.md +++ b/PENDENZEN.md @@ -53,7 +53,7 @@ - [ ] **DWG/DXF-Import via `acadrust` (weiterbauen).** ✅ Spike `763a558`: `acadrust` 0.4 (MPL-2.0, pure Rust) **baut zu wasm32** (Crate `src-tauri/dwgimport`, 839 KB), parst DXF aus Byte-Buffer (`DxfReader::from_reader`+`Cursor`), headless getestet. **Offen:** (1) Entity→DOSSIER-Modell-Mapping (LINE/ARC/… → Wand/Öffnung — die eigentliche Domainarbeit, Wochen), (2) Datei-Upload-Glue im Browser (``→Uint8Array→`parse_dxf_summary_json`, trivial), (3) DWG-binär (`DwgReader::from_reader` analog, aber R13–R2018-Korrektheit unverifiziert), (4) WASM-Größe (nalgebra Haupttreiber). **Klarstellung:** der TS-DXF/DWG-Import (`parseDxf`/`parseDwg`/`dxfToDrawings`) + Upload-UI (`App.tsx`, `ImportDialog.tsx`) existieren längst und funktionieren — der acadrust-Weg wäre eine Rust-Neuimplementierung des Lesens (nur DWG-**Schreiben** ist eine echte Lücke). ✅ **2887794 (2026-07-05): Kurven-Abdeckungslücke geschlossen** — `parseDxf` deckt jetzt ARC/CIRCLE/ELLIPSE (tesselliert zu Konturen, Winkel Radiant, voller Umlauf geschlossen) zusätzlich zu LINE/LWPOLYLINE/POLYLINE/MESH ab; 5 Tests, volle Suite 307 grün. ✅ **c481373 (2026-07-05): SPLINE + INSERT ergänzt** — `parseDxf` wertet SPLINE als echte B-Spline (De Boor, Grad/Knoten; Fallback fitPoints/Kontrollpolygon) aus und expandiert INSERT-Block-Referenzen (2D-Transform Scale/Rotation/Basispunkt + MINSERT-Array + verschachtelte Blöcke, Tiefe ≤8) zu transformierten Konturen; Kontur-Dispatch in gemeinsamen `collectContours` refaktoriert; +7 Tests, volle Suite 314 grün. **Bekannte Grenzen:** rationale SPLINE-Gewichte ignoriert (dxf-parser liefert sie nicht); Block-interne MESH/3DFACE-Entities werden im 2D-Import nicht expandiert. ✅ **c29f27e (2026-07-05): HATCH ergänzt** — dxf-parser hat KEINEN HATCH-Handler (verwarf HATCH stumm); Lösung via `registerEntityHandler` + eigenem `HatchHandler` (sammelt rohe Gruppencodes) + testbarer `hatchContours`-Auswertung: Randpfade (Polyline-Pfade + Linien-/Bogen-Kanten, Bögen über vorhandene Tessellierung) → geschlossene Konturen mit `Contour.filled`; `contoursToDrawings` macht daraus gefüllte `polyline`-Drawing2D (fillColor-Default, restylebar). +6 Tests, Suite 320 grün. ✅ **05bc5aa (2026-07-05): HATCH-Ellipse/Spline-Kanten** ergänzt (Kantentyp 3/4 tesselliert; B-Spline-Sampling in `sampleBSpline` extrahiert). ✅ **4b93ac9 (2026-07-05): TEXT/MTEXT** — `parseDxf` liefert `DxfImportResult.texts` (`ImportedText`: Position/Höhe-in-Metern/Winkel-Radiant; MTEXT-Formatcodes grob gesäubert); `textsToDrawings` → `{shape:"text"}`-Drawing2D; **Darstellung neu**: `addDrawing2D` emittiert ein schlankes `kind:"drawingText"`-Primitiv, PlanView rendert es rein per SVG (modellverankert, Rotation; GPU-Guard so, dass es in ALLEN Renderer-Modi im SVG bleibt); `toRenderScene` überspringt es; ImportDialog zählt/importiert Texte. +5 Tests, Suite 327 grün. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Insel-Loops = eigene Ringe (keine echten Löcher). **Bekannte Grenzen TEXT:** importierte Texte (pointerEvents:none) noch nicht per Canvas-Klick selektierbar; MTEXT-Feinformatierung flachgeklopft; Block-interne TEXT/MTEXT nicht expandiert. **Text im Tauri visuell abgenommen (Nutzer 2026-07-05)** — auch gedreht korrekt. ✅ **4ac99d3: CIRCLE/ARC als echte glatte Formen** — `Contour.curve` trägt die wahre Kreis-/Bogen-Geometrie (pts bleiben für Kontext/3D); `contoursToDrawings` baut `{shape:"circle"|"arc"}`; neue Primitive `drawingCircle` (SVG ``) + `drawingArc` (SVG-Bogenpfad), `toRenderScene` tesselliert sie für den nativen Pfad; +4 Tests, Suite 331. Ellipse bleibt tesselliert (kein Ellipsen-Primitiv). **Weiter offen:** Entity→Wand-Semantik (die dicke Domainarbeit); DWG-Schreiben (einzige echte Export-Lücke). - [ ] **STRATEGIE — „von BIM-Tool zu echtem CAD".** Direkt am Quellcode studierte Referenzen (OpenCADStudio/truck/acadrust) + Web-Import-Landkarte → konkrete, priorisierte Ansätze in **[RESEARCH_CAD_APPROACHES.md](RESEARCH_CAD_APPROACHES.md)**. Kern: (1) generisches Entity-Modell + Trait-Dispatch, (2) modeless Command-System (`StepInput`-Funnel + Kommandozeile), (3) DWG/DXF-Round-Trip via `acadrust` (MPL-2.0, pure Rust, WASM-tauglich), (4) volle Object-Snap-Schicht, (5) 3D-B-Rep später selektiv via `truck` (Apache-2.0, Geometrie-Crates WASM-fähig, Booleans/Fillets noch instabil). Der `kernel2d`-Rust/WASM-Kurs ist damit bestätigt. **Mit Nutzer priorisieren, welcher Ansatz zuerst.** -- [ ] **Schnitt- vs. Ansichts-Darstellung nach Schnitthöhe (BIM-Standard).** Kern-Idee (Nutzer): jedes Bauteil bekommt getrennt eine **Schnittlinie** (kräftig, z. B. 0.25–0.35 mm, + Schnitt-Poché) und eine **Ansichtslinie** (Haarlinie); WELCHE gilt, entscheidet die z-Ausdehnung des Bauteils vs. die **Schnitthöhe** des Grundrisses (Default ~1 m): +- [ ] **Schnitt- vs. Ansichts-Darstellung nach Schnitthöhe (BIM-Standard).** ✅ **Phase 1 (Wand unter Schnittebene) erledigt (2026-07-05):** `generatePlan.addWallPoche` hat einen `viewOnly`-Zweig — erreicht eine Wand die Grundriss-Schnitthöhe nicht (`wall.height < floor.cutHeight`, z. B. 0.3-m-Brüstung bei 1 m), wird sie nur als Ansichts-Umriss (Haarlinie, `fill:"none"`, keine Schraffur) gezeichnet statt als Schnitt-Poché; normale Wände (≥ Schnitthöhe) unberührt. Segment-/Gehrungs-/Öffnungs-Logik geteilt. +2 Tests (`generatePlan.viewwall.test.ts`, Suite 336). **Offen (Phase 2/3):** Bauteil ÜBER der Ebene → gestrichelte Überkopf-Linie (Decken/Slabs/Unterzüge); per-Component View-/Cut-Weight + View-Schraffur; cut/Aufsicht/Untersicht-Slots; Decken/30-cm-Wände-Feinheiten. Kern-Idee (Nutzer): jedes Bauteil bekommt getrennt eine **Schnittlinie** (kräftig, z. B. 0.25–0.35 mm, + Schnitt-Poché) und eine **Ansichtslinie** (Haarlinie); WELCHE gilt, entscheidet die z-Ausdehnung des Bauteils vs. die **Schnitthöhe** des Grundrisses (Default ~1 m): - Bauteil wird von der Schnittebene GESCHNITTEN → Schnittlinie + Schnitt-Poché (heutiges Wandverhalten). - Bauteil liegt ganz UNTER der Ebene (z. B. 30-cm-Wand bei 1 m Schnitthöhe, Brüstung, Podest) → nur „von oben gesehen" = **Ansichtslinie/Haarlinie, KEINE Schnitt-Poché**. ← genau der vom Nutzer genannte Fall. - Bauteil liegt ganz ÜBER der Ebene (Decke/Slab, Unterzug) → Ansicht, üblicherweise **gestrichelte** Überkopf-Haarlinie. @@ -107,6 +107,7 @@ _Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._ +- [x] 2026-07-05 **Schnitt/Ansicht Phase 1: Wand unter Schnittebene = Ansichtslinie** — `addWallPoche(viewOnly)` in `generatePlan.ts`: `wall.height < floor.cutHeight` (Brüstung/Podestrand) → Umriss-Haarlinie (`fill:"none"`, NO_HATCH) statt Schnitt-Poché; normale Wände unberührt (Segment-/Gehrungs-/Öffnungslogik geteilt). +2 Tests (`generatePlan.viewwall.test.ts`), Suite 336. Erster Baustein des BIM-Standard-Items „Schnitt vs. Ansicht nach Schnitthöhe". - [x] 2026-07-05 **Ribbon Phase 4: modulare Custom-Bar** (Tab „Eigene") — datengetrieben auf der bestehenden Registry: neuer `custom`-Tab, `CustomBar` in `RibbonBar.tsx` rendert die vom Nutzer gewählten Items + einen „+ Hinzufügen"-Picker (Dropdown mit Häkchen, listet ALLE Werkzeuge/Befehle aus `ALL_RIBBON_ITEMS`, Klick = an/abwählen). `itemKey`/`itemFromKey` für stabile Persistenz; State in App (`customItems`) via localStorage (`dossier.ribbon.customItems`). Gleiche `RibbonButton`-Aktivierung wie normale Tabs (kein Sonderweg). tsc + Suite 334 grün. - [x] 2026-07-05 **Ribbon Phase 3 (Teil): Werkzeug-Sidebar raus** — `DEFAULT_LEFT_GROUPS` nur noch `attributes` (volle linke Höhe), `LAYOUT_VERSION` 7→8 (gespeicherte Layouts fallen auf neuen Default zurück → sichtbar). Wandtyp-/Deckentyp-Picker von `ToolsPanel` ins `AttributesPanel` verschoben (Nutzer-Entscheid „ins Attribute-Panel") — erscheint bei aktivem Wand-/Decken-Werkzeug (Sektion „Neues Bauteil", auch ohne Auswahl), Host-State unverändert. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar), `Dropdown`-Import dort entfernt. tsc + Suite 334 grün. **Offen:** Objektinfo unter Attribute mergen. - [x] 2026-07-05 **Fix: gezeichnete/importierte Kreise+Bögen im WebGL unsichtbar** — der WebGL-Compiler (`glPlan/glPlanCompile.ts`, Default-Renderer) dispatchte `polygon`/`line`/`arc`, aber NICHT `drawingCircle`/`drawingArc` → seit `4ac99d3` (Kreise als echtes `drawingCircle`-Primitiv statt 64-Eck-Polygon) fielen sie im GL still raus (SVG-/WASM-Pfad hatten sie, GL nicht). Zweig ergänzt: bildschirm-adaptive Tessellierung wie beim `arc`-Primitiv (Kreis geschlossen + optionale Vollton-Füllung, Bogen a0..a1 offen). tsc + Suite 334 grün. **Nutzer-Report** („zeichne Kreis, bleibt nicht sichtbar"). diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 8d6836b..7470bdb 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -677,7 +677,14 @@ export function generatePlan( const openings = openingsOfWall(project, wall.id); const gaps = wallGaps(wall, doors, openings); const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null }; - addWallPoche(primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm, wallCategory); + // Schnitt vs. Ansicht nach Schnitthöhe (BIM-Standard): erreicht die Wand die + // Grundriss-Schnittebene nicht (Höhe < Schnitthöhe des Geschosses, z. B. eine + // Brüstung/ein Podestrand), wird sie nur als Ansichts-Umriss gezeichnet — + // keine Schnitt-Poché. Normale Wände (Höhe ≥ Schnitthöhe) sind unberührt. + const wallViewOnly = wall.height < (floor?.cutHeight ?? 1.0); + addWallPoche( + primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm, wallCategory, wallViewOnly, + ); // Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste // umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt. if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed); @@ -1258,6 +1265,10 @@ function addWallPoche( detail: DetailLevel, wallLwMm: number, category: LayerCategory | undefined, + // Wand liegt GANZ unter der Grundriss-Schnittebene (z. B. 30-cm-Brüstung bei + // 1 m Schnitthöhe) → „von oben gesehen": nur Ansichts-Umriss (Haarlinie), KEINE + // Schnitt-Poché/Schraffur/Schichten. Der reguläre Schnittfall bleibt `false`. + viewOnly: boolean, ): void { const wt = getWallType(project, wall); const total = wallTypeThickness(wt); @@ -1326,6 +1337,24 @@ function addWallPoche( const startCut = s <= 1e-6 ? cuts.startCut : null; const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null; + if (viewOnly) { + // Ansichts-Wand (unter der Schnittebene): ein Umriss über die volle Dicke + // als Haarlinie, ohne Füllung/Schraffur. Segment-/Gehrungs-/Öffnungs-Logik + // bleibt dieselbe wie im Schnittfall (nur eben ohne Poché). + out.push({ + kind: "polygon", + pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut), + fill: "none", + stroke, + strokeWidthMm: layerLineMm, + hatch: NO_HATCH, + greyed, + noStrokeEdges: joinEdges(startCut, endCut), + wallId, + }); + continue; + } + if (detail === "grob") { // Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss. out.push({ diff --git a/src/plan/generatePlan.viewwall.test.ts b/src/plan/generatePlan.viewwall.test.ts new file mode 100644 index 0000000..2e00174 --- /dev/null +++ b/src/plan/generatePlan.viewwall.test.ts @@ -0,0 +1,82 @@ +/** + * Unit-Tests für „Schnitt vs. Ansicht nach Schnitthöhe" (generatePlan): + * Eine Wand, die die Grundriss-Schnittebene NICHT erreicht (Höhe < `cutHeight`, + * z. B. eine Brüstung), wird nur als Ansichts-Umriss gezeichnet — Poché-Polygone + * OHNE Füllung (`fill:"none"`), keine Schraffur. Eine normale Wand (Höhe ≥ + * Schnitthöhe) behält die geschnittene Poché (gefüllte Bänder). + */ + +import { describe, it, expect } from "vitest"; +import { generatePlan } from "./generatePlan"; +import type { Project, Wall } from "../model/types"; + +/** Minimalprojekt: eine normale Wand (2.6 m) + eine niedrige Wand (0.3 m). */ +function project(): Project { + const wall = (id: string, start: Wall["start"], end: Wall["end"], height: number): Wall => ({ + id, + type: "wall", + floorId: "eg", + categoryCode: "20", + start, + end, + wallTypeId: "aw", + height, + }); + return { + id: "t", + name: "T", + lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }], + hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }], + components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }], + wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.2 }] }], + drawingLevels: [ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 }, + ], + layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }], + walls: [ + wall("HI", { x: 0, y: 0 }, { x: 4, y: 0 }, 2.6), // erreicht Schnitt → Poché + wall("LOW", { x: 0, y: 2 }, { x: 4, y: 2 }, 0.3), // Brüstung → nur Ansicht + ], + doors: [], + openings: [], + ceilings: [], + stairs: [], + rooms: [], + drawings2d: [], + context: [], + }; +} + +const visible = new Set(["20"]); + +describe("generatePlan — Schnitt vs. Ansicht nach Schnitthöhe", () => { + it("zeichnet die niedrige Wand nur als Ansichts-Umriss (fill none), die normale mit Poché", () => { + const plan = generatePlan(project(), "eg", visible, undefined, "mittel"); + const wallPolys = (id: string) => + plan.primitives.filter( + (p): p is Extract => + p.kind === "polygon" && p.wallId === id, + ); + + const hi = wallPolys("HI"); + const low = wallPolys("LOW"); + expect(hi.length).toBeGreaterThan(0); + expect(low.length).toBeGreaterThan(0); + + // Normale Wand: mindestens ein gefülltes Poché-Band (Bauteil-Farbe). + expect(hi.some((p) => p.fill !== "none")).toBe(true); + // Niedrige Wand: ALLE Polygone ohne Füllung (reiner Ansichts-Umriss). + expect(low.every((p) => p.fill === "none")).toBe(true); + }); + + it("behandelt die niedrige Wand als geschnitten, wenn die Schnitthöhe darunter liegt", () => { + const p = project(); + p.drawingLevels[0].cutHeight = 0.2; // Schnittebene unter 0.3 m → auch LOW geschnitten + const plan = generatePlan(p, "eg", visible, undefined, "mittel"); + const low = plan.primitives.filter( + (pr): pr is Extract => + pr.kind === "polygon" && pr.wallId === "LOW", + ); + expect(low.some((pr) => pr.fill !== "none")).toBe(true); + }); +});