Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge
Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem, dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en) sowie Projektdokumentation und Probe-Harness.
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Probe / screenshot artifacts (regenerated)
|
||||||
|
scripts/*.png
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
+452
@@ -0,0 +1,452 @@
|
|||||||
|
# Architektur — Standalone Browser-BIM (cad)
|
||||||
|
|
||||||
|
> Stand: 2026-06-29
|
||||||
|
> Vision, Phasen und Backlog: [ROADMAP.md](ROADMAP.md). Konventionen: [CONVENTIONS.md](CONVENTIONS.md).
|
||||||
|
> Detail-Designs: [docs/design/elements.md](docs/design/elements.md) ·
|
||||||
|
> [docs/design/plans-output.md](docs/design/plans-output.md) ·
|
||||||
|
> [docs/design/resources-graphics.md](docs/design/resources-graphics.md).
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt, **wie** die Standalone-Browser-App gebaut wird und
|
||||||
|
wie sie die Konzepte des DOSSIER-Rhino-Plugins in Browser-Äquivalente übersetzt.
|
||||||
|
DOSSIER ist ein Rhino-8-Plugin (Python + React-WebView); `cad` ist die *eigen
|
||||||
|
ständige* Browser-Variante: kein Rhino-Document, kein `doc.Strings`, kein
|
||||||
|
IronPython — stattdessen ein **eigenes typisiertes Datenmodell in TypeScript**,
|
||||||
|
gerendert über **Three.js** (3D) und **SVG** (Plan), gespeichert als **JSON-Datei**.
|
||||||
|
|
||||||
|
Alle Bezeichner im Code sind **englisch** (Vectorworks-Terminologie); Prosa und
|
||||||
|
UI-Texte sind deutsch. Einheiten intern in **Metern**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Leitprinzip — ein Modell, viele Darstellungen
|
||||||
|
|
||||||
|
Das semantische Gebäudemodell ist die **einzige Wahrheit**. Jede Sicht (3D,
|
||||||
|
Grundriss, Schnitt, Ansicht) ist eine **reine Ableitung** (`derive`) daraus.
|
||||||
|
Darstellung (Detailgrad, Stile, Schraffuren, Overrides) wird **beim Rendern**
|
||||||
|
angewandt, nie in die Geometrie eingebacken.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Project (semantisches Modell, JSON) │ ← einzige Wahrheit
|
||||||
|
│ resources · types · designLevels · │
|
||||||
|
│ layers · elements │
|
||||||
|
└──────────────┬───────────────────────────┘
|
||||||
|
│ pure derive()
|
||||||
|
┌───────────────────────┼────────────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
Scene3D (Three.js) PlanModel → SVG SectionModel → SVG
|
||||||
|
buildScene() generatePlan() generateSection() (HLR)
|
||||||
|
│ │ │
|
||||||
|
└────────── beide lesen dieselben joins/components/styles ──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Das steht im Spike bereits: `generatePlan.ts` und `Viewport3D.tsx` extrudieren
|
||||||
|
**dasselbe** gehrte Band-Polygon (`clippedBand`). Diese Symmetrie ist der Kern
|
||||||
|
und wird beim Ausbau strikt gehalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Repo-Struktur (Ziel)
|
||||||
|
|
||||||
|
Wächst aus dem heutigen `src/` (model/plan/viewport/ui). Module sind **klein und
|
||||||
|
fachlich geschnitten** — wir vermeiden bewusst den `elemente.py`-Monolithen
|
||||||
|
(7244 LOC) aus DOSSIER (dort als Schwachstelle #4.1 dokumentiert).
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
model/
|
||||||
|
types.ts // Project, DesignLevel, LayerCategory, Element-Union (existiert)
|
||||||
|
geometry.ts // 2D/3D-Mathe ohne Kernel (existiert)
|
||||||
|
joins.ts // Wand-Verschneidung (Gehrung; später Prio-T/X) (existiert)
|
||||||
|
sampleProject.ts // Demo-Haus (existiert)
|
||||||
|
project.ts // Factory, Defaults, Migrationen
|
||||||
|
selectors.ts // abgeleitete Reads (visibleCodes, elementsOnLevel…)
|
||||||
|
ids.ts // newId(prefix) — UUID-Erzeugung
|
||||||
|
elements/ // pro Bauteil ein Modul (Daten + Generierung)
|
||||||
|
wall.ts opening.ts slab.ts stair.ts roof.ts structure.ts space.ts
|
||||||
|
resources/
|
||||||
|
componentManager.ts hatchManager.ts lineManager.ts symbolLibrary.ts
|
||||||
|
overrides.ts // regelbasierte Engine (Condition → Action)
|
||||||
|
store/
|
||||||
|
store.ts // Zustand-Store: { project, ui }, Aktionen, Undo/Redo
|
||||||
|
persistence.ts // Datei save/load (JSON), Autosave (IndexedDB)
|
||||||
|
history.ts // Undo/Redo-Ring
|
||||||
|
plan/
|
||||||
|
generatePlan.ts // Grundriss aus Footprint + Symbolik (existiert)
|
||||||
|
generateSection.ts // Schnitt/Ansicht aus 3D-Projektion (HLR-Worker)
|
||||||
|
PlanView.tsx // SVG-Renderer + Pan/Zoom/Grips (existiert)
|
||||||
|
primitives.ts // Primitive-Union, SVG-Serializer, DXF/PDF-Export
|
||||||
|
viewport/
|
||||||
|
Viewport3D.tsx // Three.js-Szene aus dem Modell (existiert)
|
||||||
|
scene.ts // buildScene(project) → THREE.Group (Layer-Spiegel)
|
||||||
|
clip.ts // Plan-/Schnitt-Clipping über THREE.Plane
|
||||||
|
camera.ts // Kamera-Presets, Norden-Rotation
|
||||||
|
sheets/
|
||||||
|
layout.ts // Sheet/Viewport-Datenmodell
|
||||||
|
SheetEditor.tsx // Plansatz-Editor
|
||||||
|
exportPdf.ts // Vektor-PDF (svg → pdf-lib / jsPDF)
|
||||||
|
workers/
|
||||||
|
geometry.worker.ts // Booleans (Öffnungen) + HLR via Comlink
|
||||||
|
ui/
|
||||||
|
App.tsx Navigator panels… // React-Oberfläche (heute in App.tsx, wird gesplittet)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Datenmodell
|
||||||
|
|
||||||
|
### 2.1 Zwei unabhängige Achsen (DOSSIER-Modell, im Spike umgesetzt)
|
||||||
|
|
||||||
|
DOSSIER trennt zwei orthogonale Konzepte, persistiert als zwei getrennte
|
||||||
|
JSON-Bäume (`dossier_zeichnungsebenen`, `dossier_ebenen`). Wir übernehmen das
|
||||||
|
1:1 in `types.ts` (existiert bereits als `drawingLevels` + `layers`):
|
||||||
|
|
||||||
|
1. **Zeichnungsebenen** (`DesignLevel` / `DrawingLevel`) — die obersten
|
||||||
|
Dokument-Abschnitte. Zwei produktive Arten:
|
||||||
|
- **Geschoss** (`kind:"floor"`): `floorHeight`, `cutHeight`,
|
||||||
|
`baseElevation` (akkumuliert via `recomputeFloorElevations`), `visible`/`locked`.
|
||||||
|
- **Schnitt/Ansicht** (`kind:"section"|"elevation"`): `linePoints`,
|
||||||
|
`directionSign`, Höhenbereich, Tiefe.
|
||||||
|
- **Zeichnung** (`kind:"drawing"`): freie 2D-Ebene ohne Geschossbezug.
|
||||||
|
2. **Ebenen** (`LayerCategory`) — das **Grafik-Kategorie-Schema**, das in *jedem*
|
||||||
|
Geschoss gilt. Baum mit `{code, name, color, lw, visible, locked, hatch?, children}`.
|
||||||
|
Codes 1:1 wie DOSSIER (`DEFAULT_LAYER_SCHEMA` aus `launcher/src/App.jsx`):
|
||||||
|
`00 Raster · 01 Vermessung · 20 Wände (└21 Türen/Fenster, 22 Möbel, 25 Stützen) ·
|
||||||
|
30 Decken · 31 Dächer · 35 Träger · 50 Text · 60 Plangrafik …`
|
||||||
|
|
||||||
|
Ein **Element** kennt sein **Geschoss** (`floorId`) *und* seine **Ebene**
|
||||||
|
(`categoryCode`). Sichtbarkeit ergibt sich aus dem Schnitt (Geschoss `geschoss × code`
|
||||||
|
Matrix), genau wie DOSSIERs `apply_visibility(z_mode, e_mode)` in `layer_builder.py`.
|
||||||
|
|
||||||
|
> **Begriffsnotiz:** Heute heißt der Typ im Code `DrawingLevel`. ROADMAP §5 nennt
|
||||||
|
> die Modell-Eingabe (Geschosse) Vectorworks-konform **Design Layer** und die
|
||||||
|
> abgeleitete Ausgabe **Drawing Layer / Sheet**. Wir behalten `DrawingLevel` als
|
||||||
|
> Union (kind=floor ≙ Design Layer, kind=section/elevation/drawing ≙ Drawing
|
||||||
|
> Layer) und führen `Sheet` erst separat ein (§2.5, plans-output.md). Kein Rename
|
||||||
|
> ohne expliziten Auftrag.
|
||||||
|
|
||||||
|
### 2.2 Ressourcen-Bibliotheken (Vectorworks-Stil)
|
||||||
|
|
||||||
|
Neuer Block `Project.resources` (heute provisorisch als flache `materials[]`).
|
||||||
|
Alles verweist **per id** — zentral änderbar. Details: resources-graphics.md.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Resources {
|
||||||
|
lineStyles: LineStyle[]; // Line Manager: { id, name, weight(mm), color, dash:number[] }
|
||||||
|
hatches: Hatch[]; // Hatch Manager: { id, name, pattern, scale, angle, lineStyleId }
|
||||||
|
components: Component[]; // Component Manager (= DOSSIER-Material erweitert):
|
||||||
|
// { id, name, hatchId, color3d, texture3d?, joinPriority }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`joinPriority` ist DOSSIERs `_MATERIAL_PRIO` (Beton 800 … Putz 100) als
|
||||||
|
**Daten** statt Hardcode — steuert die Prioritäts-T-Verschneidung (Risiko #1).
|
||||||
|
|
||||||
|
### 2.3 Aufbauten (mehrschichtige Typen)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface WallType { id; name; layers: { componentId; thickness }[]; }
|
||||||
|
interface SlabType { id; name; layers: { componentId; thickness }[]; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Schichtdicke liegt am Layer, **Priorität am Component** (so muss man Prio nur
|
||||||
|
einmal pflegen). 3D und Plan lesen dieselben `layers[]`.
|
||||||
|
|
||||||
|
### 2.4 Elemente
|
||||||
|
|
||||||
|
Diskriminierte Union `Element` (heute `Wall | Door`), erweitert um
|
||||||
|
`Window | Slab | Stair | Roof | Column | Beam | Space | Draw2d`. Jedes Element:
|
||||||
|
`{ id, type, floorId, categoryCode, ... }`. Gehostete Elemente (Tür/Fenster)
|
||||||
|
tragen `hostWallId` statt `floorId` (Geschoss ergibt sich aus der Wand). Volle
|
||||||
|
Felder: elements.md.
|
||||||
|
|
||||||
|
### 2.5 Pläne / Output (eigene Achse)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Sheet { // Plansatz-Blatt (≙ DOSSIER Layout/PageView)
|
||||||
|
id; name; paper: "A0".."A4"|"Letter"; landscape: boolean;
|
||||||
|
viewports: SheetViewport[]; // platzierte Ableitungen + Titelblock
|
||||||
|
folder?: string;
|
||||||
|
}
|
||||||
|
interface SheetViewport { // ≙ DOSSIER Detail + gebundener Ausschnitt
|
||||||
|
id; rect: Rect; sourceViewId: string; // referenziert DrawingLevel oder ViewSnapshot
|
||||||
|
scale: number; // 1:N
|
||||||
|
}
|
||||||
|
interface ViewSnapshot { // ≙ DOSSIER Ausschnitt (View-Snapshot)
|
||||||
|
id; name; folder?;
|
||||||
|
camera: CameraState; scale: number; detailLevel: DetailLevel;
|
||||||
|
visibility: VisibilityState; // pro Geschoss/Ebene
|
||||||
|
overrides?: { presetId?; enabled };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. State, Persistenz, Undo/Redo
|
||||||
|
|
||||||
|
### 3.1 Store — Zustand
|
||||||
|
|
||||||
|
Heute: `useState<Project>` in `App.tsx`, immutabel via `setProject`. Das skaliert
|
||||||
|
nicht für Werkzeuge + Undo. Migration auf **Zustand** (ROADMAP §4):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AppState {
|
||||||
|
project: Project; // das Dokument
|
||||||
|
ui: { // flüchtig, NICHT persistiert/undobar
|
||||||
|
activeLevelId; activeViewType; selection: string[];
|
||||||
|
activeTool: ToolId; hover; cameraByView; planTransform;
|
||||||
|
};
|
||||||
|
// Aktionen mutieren via Immer-Producer; jede Modell-Mutation pusht History.
|
||||||
|
apply(mutator: (p: Project) => void): void;
|
||||||
|
undo(): void; redo(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trennung Modell ↔ UI** ist hart: nur `project` ist undobar und wird gespeichert.
|
||||||
|
`ui` (Auswahl, Kamera, aktives Werkzeug) lebt separat. Das ersetzt DOSSIERs
|
||||||
|
`sc.sticky`-Bus — wo DOSSIER cross-modul über Sticky-Keys kommuniziert, lesen bei
|
||||||
|
uns einfach alle Komponenten denselben Store (reaktiv). **Kein Sticky, kein
|
||||||
|
Bridge-Polling, keine `is not None`-Lücken** (DOSSIER-Schwachstelle #4.4).
|
||||||
|
|
||||||
|
### 3.2 Persistenz — JSON-Datei statt `doc.Strings`
|
||||||
|
|
||||||
|
DOSSIER speichert *alles* in `doc.Strings` (Key-Value am Rhino-Document) +
|
||||||
|
globale Presets als JSON-Dateien im User-Home. Browser-Äquivalent:
|
||||||
|
|
||||||
|
| DOSSIER | Browser (cad) |
|
||||||
|
|---|---|
|
||||||
|
| `doc.Strings.SetString(key, json)` | Feld im `Project`-Objekt (ein JSON-Baum) |
|
||||||
|
| `.3dm`-Datei mit eingebetteten Strings | **`.cad.json`-Datei** via File System Access API |
|
||||||
|
| Globale Presets (`~/Library/.../*.json`) | **LocalStorage** (`cad.presets.*`) + Export/Import |
|
||||||
|
| Launcher schreibt Settings-Datei | **IndexedDB** Autosave (Phase 5) |
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// persistence.ts
|
||||||
|
const FORMAT_VERSION = 1;
|
||||||
|
function serialize(p: Project): string // JSON.stringify({ version, project })
|
||||||
|
function deserialize(text: string): Project // + migrate(version) bis aktuell
|
||||||
|
async function saveToFile(p: Project): Promise<void> // showSaveFilePicker → .cad.json
|
||||||
|
async function loadFromFile(): Promise<Project> // showOpenFilePicker; Fallback <input type=file>
|
||||||
|
async function autosave(p: Project) // IndexedDB, debounced 2 s
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Save/Load:** [File System Access API](https://developer.mozilla.org/docs/Web/API/File_System_Access_API)
|
||||||
|
(`showSaveFilePicker`/`showOpenFilePicker`); Fallback Download-Blob + `<input type=file>`
|
||||||
|
für Firefox/Safari.
|
||||||
|
- **Autosave/Recovery:** IndexedDB (via `idb`), entkoppelt vom expliziten Speichern.
|
||||||
|
- **Cross-Projekt-Presets:** LocalStorage mit Namespacing (`cad.presets.overrides`,
|
||||||
|
`cad.presets.wallTypes`, …) + JSON-Export/Import — ersetzt DOSSIERs
|
||||||
|
`~/Library/.../override_presets.json` (overrides.py).
|
||||||
|
- **Migrationen:** `migrate(data, fromVersion)`-Kette, additiv. DOSSIER macht das
|
||||||
|
per Sticky-Prefix (`traite_`→`pause_`→`dossier_`); wir machen es versioniert im
|
||||||
|
Datei-Schema.
|
||||||
|
|
||||||
|
### 3.3 Undo/Redo
|
||||||
|
|
||||||
|
DOSSIER überlässt Undo Rhino (und hat dadurch Cache-Stale-Bugs, Schwachstelle
|
||||||
|
#4.3). Wir besitzen das Dokument selbst → **eigener History-Ring**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// history.ts — Snapshots des project-Teilbaums (strukturelles Sharing via Immer-Patches)
|
||||||
|
interface History { undo: Patch[][]; redo: Patch[][]; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Jede `apply()`-Mutation erzeugt Immer-Patches → Push auf `undo`. Da abgeleitete
|
||||||
|
Sichten **pure** sind, ist nach `undo()` kein Cache zu invalidieren — der
|
||||||
|
ganze Cache-Stale-Komplex aus DOSSIER (`_JOINTS_CACHE_KEY`, Material-Cache)
|
||||||
|
entfällt strukturell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Rendering
|
||||||
|
|
||||||
|
### 4.1 Three.js-Szene spiegelt den Ebenen-Baum
|
||||||
|
|
||||||
|
DOSSIER baut in Rhino eine **Layer-Hierarchie** `Zeichnungsebene → CODE_Name-Sublayer`
|
||||||
|
(`layer_builder.build_layers`) und hängt jedes Objekt an den passenden Sublayer.
|
||||||
|
Browser-Spiegel: ein **`THREE.Group`-Baum** mit identischer Struktur, gebaut aus
|
||||||
|
dem Modell (nicht persistiert — reine Ableitung):
|
||||||
|
|
||||||
|
```
|
||||||
|
scene
|
||||||
|
└─ levelGroup[floorId] (y-Offset = baseElevation) ← Geschoss
|
||||||
|
└─ categoryGroup[categoryCode] (userData.code) ← Ebene
|
||||||
|
└─ element meshes (userData.elementId)
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// scene.ts
|
||||||
|
function buildScene(project: Project, vis: VisibilityState): THREE.Group
|
||||||
|
function syncScene(root: THREE.Group, project, vis) // diff statt full rebuild (Perf)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Sichtbarkeit:** `categoryGroup.visible = vis.codes.has(code)` und
|
||||||
|
`levelGroup.visible = vis.floors.has(floorId)` — entspricht DOSSIERs
|
||||||
|
`apply_visibility`, aber als simples `.visible`-Toggle statt Layer-Table-Modify.
|
||||||
|
- **Farbe/Material:** Component → `MeshStandardMaterial` (PBR später), per
|
||||||
|
`componentId` gecacht (wie heute `layerMaterial`-Cache, aber projektweit).
|
||||||
|
- **„Grau/gesperrt"-Modi** (DOSSIER `grey`/`grey_locked`): graues Override-Material
|
||||||
|
auf der Gruppe statt Layer-Color-Tausch.
|
||||||
|
|
||||||
|
### 4.2 Grundriss = Clipping-Ebene + symbolische Generierung
|
||||||
|
|
||||||
|
DOSSIER zeigt den Grundriss eines Geschosses, indem es eine **Clipping-Plane** auf
|
||||||
|
`okff + schnitthöhe` legt, Normale −Z (`layer_builder.update_clipping_plane`).
|
||||||
|
Zwei Browser-Pfade — **beide nötig** (ROADMAP §3):
|
||||||
|
|
||||||
|
1. **3D-Viewport im Plan-Modus:** `THREE.Plane(normal=(0,0,-1), constant=cutZ)` über
|
||||||
|
`renderer.clippingPlanes` + `material.clippingPlanes`. `clip.ts` setzt die Ebene
|
||||||
|
pro aktivem Geschoss (`cutZ = baseElevation + cutHeight`), Kamera orthografisch
|
||||||
|
von oben. So sieht man den **echten geschnittenen Volumenkörper**.
|
||||||
|
2. **Symbolischer SVG-Grundriss** (der Hauptweg, schnell + sauber): `generatePlan.ts`
|
||||||
|
erzeugt Vektor-Primitive **direkt aus Parametern** — Wand-Schnittbänder,
|
||||||
|
Öffnungs-Aussparungen, Tür-Schwenkbögen — *ohne* Mesh zu schneiden. Steht im
|
||||||
|
Spike. Ausbau: Schraffuren, Lauflinien, Bemaßung, LoD (plans-output.md).
|
||||||
|
|
||||||
|
### 4.3 Schnitt/Ansicht = Kamera + Schnittebenen + HLR
|
||||||
|
|
||||||
|
DOSSIER setzt 1–2 Clipping-Planes (Cut + Back), Parallel-Projektion senkrecht zur
|
||||||
|
Linie, zoomt auf die BBox (`schnitte.activate_schnitt`). Browser:
|
||||||
|
|
||||||
|
- **3D-Vorschau:** zwei `THREE.Plane` (Cut auf der Linie, Back in `directionSign`-
|
||||||
|
Richtung um `depthBack` versetzt) + orthografische Kamera senkrecht zur Linie.
|
||||||
|
- **Vektor-Ergebnis (Risiko #4):** Hidden-Line-Removal durch das zusammengebaute
|
||||||
|
Gebäude → saubere Linien als SVG. Via **OpenCascade.js** (`HLRBRep`) im
|
||||||
|
**Web Worker** (Comlink), Ergebnis gecacht pro (Schnitt, sichtbare Ebenen,
|
||||||
|
Modell-Hash). Geschnittene Bauteile bekommen Component-Schraffur (Section-Style,
|
||||||
|
resources-graphics.md). Details: plans-output.md.
|
||||||
|
|
||||||
|
### 4.4 SVG-Renderer + Geometrie-Booleans
|
||||||
|
|
||||||
|
- **Plan-Output:** SVG (`PlanView.tsx`). `Primitive`-Union (polygon/line/arc/text/
|
||||||
|
hatch) → SVG-Elemente; derselbe Serializer speist DXF- und PDF-Export.
|
||||||
|
- **Öffnungs-Booleans (Risiko #2):** Tür/Fenster schneidet Loch in Wand. Für 3D
|
||||||
|
reicht heute das Aussparen per Segment-Extrusion (im Spike). Für exakte B-Rep-
|
||||||
|
Verschneidung (und IFC) **OpenCascade.js** *oder* **Manifold** im Worker —
|
||||||
|
Entscheidung in Phase 0 (ROADMAP §8): OCC = exakt/B-Rep, Manifold = schnell/Mesh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. React-Panel-Struktur
|
||||||
|
|
||||||
|
DOSSIER ist eine Sammlung **getrennter WebView-Panels** (EBENEN, ELEMENTE,
|
||||||
|
GESTALTUNG, OBERLEISTE, MASSSTAB, AUSSCHNITTE, DIMENSIONEN, LAYOUTS, OVERRIDES),
|
||||||
|
die über die Python-Bridge + `sc.sticky` kommunizieren. Im Browser ist alles
|
||||||
|
**eine SPA** mit einem geteilten Store — die Panels werden zu Docking-Bereichen.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TopBar Werkzeuge · Ansichtstyp · Massstab · Snaps · Save/Load │ ≙ OBERLEISTE+MASSSTAB
|
||||||
|
├──────────────┬──────────────────────────────────────┬───────────────┤
|
||||||
|
│ Navigator │ Viewport (Three.js oder SVG-Plan) │ Inspector │
|
||||||
|
│ ┌──────────┐ │ ┌────────────────────────────────┐ │ ┌───────────┐ │
|
||||||
|
│ │Zeichnungs│ │ │ 3D / Grundriss / Schnitt / │ │ │ aktives │ │ ≙ ELEMENTE/
|
||||||
|
│ │-ebenen │ │ │ Ansicht — abgeleitet aus dem │ │ │ Element + │ │ GESTALTUNG-
|
||||||
|
│ ├──────────┤ │ │ Modell │ │ │ Stil │ │ Properties
|
||||||
|
│ │ Ebenen │ │ └────────────────────────────────┘ │ └───────────┘ │
|
||||||
|
│ │ (Baum) │ │ │ Manager-Tabs: │
|
||||||
|
│ │ │ │ │ Components · │ ≙ Resource-
|
||||||
|
│ └──────────┘ │ │ Hatch · Line │ Manager
|
||||||
|
├──────────────┴──────────────────────────────────────┴───────────────┤
|
||||||
|
│ BottomBar Koordinaten · Snaps · Statusmeldungen │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
Modal/Drawer: Ausschnitte · Sheets/Layouts · Overrides · Kamera-Presets · SIA-Bilanz
|
||||||
|
```
|
||||||
|
|
||||||
|
**Komponenten-Map** (heute alles in `App.tsx`; wird gesplittet):
|
||||||
|
|
||||||
|
| Bereich | DOSSIER-Panel | cad-Komponente |
|
||||||
|
|---|---|---|
|
||||||
|
| Zeichnungsebenen-Liste | EBENEN (oben) | `Navigator/DrawingLevels.tsx` |
|
||||||
|
| Ebenen-Baum | EBENEN (Baum) | `Navigator/LayerTree.tsx` (Spike: `CategoryRow`) |
|
||||||
|
| Top-Bar (View/Snaps/Massstab) | OBERLEISTE, MASSSTAB | `TopBar.tsx` |
|
||||||
|
| Element-Eigenschaften | ELEMENTE, ELEMENT-PROPERTIES | `Inspector/ElementProps.tsx` |
|
||||||
|
| Stil/Attribute der Auswahl | GESTALTUNG | `Inspector/StylePanel.tsx` |
|
||||||
|
| Component/Hatch/Line | (Project-Settings, mass_style) | `managers/*Manager.tsx` |
|
||||||
|
| View-Snapshots | AUSSCHNITTE | `panels/Snapshots.tsx` |
|
||||||
|
| Plansätze | LAYOUTS | `sheets/SheetEditor.tsx` |
|
||||||
|
| Regelbasierte Overrides | OVERRIDES | `panels/Overrides.tsx` |
|
||||||
|
| Bemaßung | DIMENSIONEN | `panels/Dimensions.tsx` |
|
||||||
|
| Element-Übersicht (BIM-Tree) | ELEMENTE-ÜBERSICHT | `panels/ElementTree.tsx` |
|
||||||
|
| Kamera-Presets | KAMERA | `panels/CameraPresets.tsx` |
|
||||||
|
|
||||||
|
**Werkzeug-System** (ersetzt DOSSIERs Rhino-Command-Aliases `cmd/wand.py` etc.):
|
||||||
|
ein `Tool`-Interface mit `onPointerDown/Move/Up`, das Vorschau-Primitive
|
||||||
|
zurückgibt und beim Bestätigen `store.apply()` ruft. Pointer-Events auf Canvas/SVG
|
||||||
|
mit Snap-Engine (Endpunkt/Mitte/Schnitt/Ortho/Raster). Tabelle der Tools:
|
||||||
|
elements.md §8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rhino → Browser — Mapping-Tabelle
|
||||||
|
|
||||||
|
| DOSSIER (Rhino-Plugin) | cad (Standalone-Browser) | Anmerkung |
|
||||||
|
|---|---|---|
|
||||||
|
| **Rhino `RhinoDoc`** | `Project` (TS-Objekt im Store) | einzige Wahrheit |
|
||||||
|
| **`doc.Strings[key]=json`** | Feld im `Project`-JSON | persistiert in `.cad.json` |
|
||||||
|
| **`.3dm`-Datei** | **`.cad.json`** via File System Access API | + IndexedDB-Autosave |
|
||||||
|
| **Globale Presets (~/Library/*.json)** | **LocalStorage** + Export/Import | cross-Projekt |
|
||||||
|
| **`sc.sticky` (cross-modul Bus)** | **Zustand-Store** (reaktiv) | kein Polling, kein None-Bug |
|
||||||
|
| **`panel_base.BaseBridge` / WebView-IPC** | direkte React-Props/Store | keine `document.title`-Hacks |
|
||||||
|
| **`document.title="RHINOMSG::"` Polling** | entfällt | SPA, kein WebView |
|
||||||
|
| **Eto.Forms Satelliten-Fenster** | React-Modal/Drawer | z.B. Ausschnitt-Settings |
|
||||||
|
| **Rhino Layer-Tabelle (Sublayer-Baum)** | `LayerCategory[]`-Baum + `THREE.Group`-Spiegel | `scene.ts` |
|
||||||
|
| **`layer_builder.build_layers`** | `buildScene` (Ableitung) | Gruppen statt Layer |
|
||||||
|
| **`layer.PlotWeight`** | `LineStyle.weight` (mm) → SVG `stroke-width` | massstabsabh. (§plans) |
|
||||||
|
| **`SectionStyle` (Layer)** | Component-Schraffur beim Schnitt-Rendern | resources-graphics.md |
|
||||||
|
| **Clipping-Plane (`AddClippingPlane`)** | `THREE.Plane` + `renderer.clippingPlanes` | `clip.ts` |
|
||||||
|
| **`vp.ChangeToParallelProjection`** | `THREE.OrthographicCamera` | `camera.ts` |
|
||||||
|
| **`vp.GetFrustum()` → Massstab** | Frustum-Breite (ortho) → 1:N | gleiche Mathe (§plans) |
|
||||||
|
| **CoreGraphics DPI-Auto-Detect** | `window.devicePixelRatio` + CSS-px | Browser kennt DPI nativ |
|
||||||
|
| **`Rhino.Geometry.Brep`-Booleans** | OpenCascade.js / Manifold (Worker) | Öffnungen, HLR |
|
||||||
|
| **`HLRBRep` (über Rhino-Display)** | OpenCascade.js `HLRBRep` (Worker) | Schnitt/Ansicht-Linien |
|
||||||
|
| **Rhino-Grips + DisplayConduit** | Pointer-Events auf SVG/Canvas + Overlay | grip-editing (elements.md) |
|
||||||
|
| **`Rhino.Input.Custom.GetPoint`** | `Tool`-Pointer-Handler + Snap | Werkzeug-System |
|
||||||
|
| **`MouseCallback` (Doppelklick Schnitt)** | `onDoubleClick` auf SVG-Symbol | plans-output.md |
|
||||||
|
| **Rhino Undo/Redo** | eigener History-Ring (Immer-Patches) | kein Cache-Stale |
|
||||||
|
| **`HatchPattern`-Tabelle** | `Hatch`-Ressourcen + SVG `<pattern>` | resources-graphics.md |
|
||||||
|
| **`Linetype`-Tabelle** | `LineStyle.dash[]` → SVG `stroke-dasharray` | resources-graphics.md |
|
||||||
|
| **`TextEntity` / Rich-Text** | SVG `<text>` / HTML-Annotation | plans-output.md |
|
||||||
|
| **`RhinoPageView` (Layout)** | `Sheet` + `SheetEditor` | plans-output.md |
|
||||||
|
| **`FilePdf` Multi-Page-Export** | svg → `pdf-lib`/`jsPDF` (Vektor) | plans-output.md |
|
||||||
|
| **Swisstopo/OSM via .NET HttpClient** | `fetch()` (CORS-fähige STAC/Overpass-APIs) | Phase 4 |
|
||||||
|
| **LV95↔WGS84 (Python-Formeln)** | dieselben Formeln in TS portiert | Phase 4 |
|
||||||
|
| **IronPython 2.7/3 Runtime-Risiken** | entfällt (TS/Browser) | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Was wir von DOSSIER übernehmen — und was nicht
|
||||||
|
|
||||||
|
**Übernehmen (bewährte Konzepte):**
|
||||||
|
- Zwei-Achsen-Dokumentmodell (Zeichnungsebenen × Ebenen).
|
||||||
|
- Layer-Codes/Farben/lw 1:1 (`DEFAULT_LAYER_SCHEMA`).
|
||||||
|
- Component/Hatch/Line-Manager mit id-Verweisen + `joinPriority`.
|
||||||
|
- Ansichtstypen = Kamera + optionaler Schnitt (vereinheitlicht).
|
||||||
|
- LoD (`darstellung`: einfach/standard/detail) mit Dokument-Override.
|
||||||
|
- Regelbasierte Overrides (Condition → Action, additive Priorität, reversibel).
|
||||||
|
- Ausschnitte (View-Snapshots), Layer-Kombinationen, Massstab-pro-Viewport.
|
||||||
|
- SIA-416-Räume, Norden-Rotation, Stil-Kataloge.
|
||||||
|
|
||||||
|
**Bewusst anders (Browser-nativ, Schwachstellen vermeiden):**
|
||||||
|
- **Kein Sticky-Bus** → ein reaktiver Store (vermeidet DOSSIER #4.4/#4.5/#4.6).
|
||||||
|
- **Kein Monolith** → Bauteil-Module statt 7244-LOC-`elemente.py` (#4.1).
|
||||||
|
- **Pure Ableitungen** statt mutierter Doc-Objekte → kein Cache-Stale (#4.3),
|
||||||
|
kein Undo/Redo-Loch.
|
||||||
|
- **Versioniertes Datei-Schema** statt UserString-Sticky-Migration.
|
||||||
|
- **Persistenz im Project-JSON** statt verteilt über `doc.Strings`.
|
||||||
|
|
||||||
|
**Anti-Over-Engineering (aus DOSSIER-CONVENTIONS.md übernommen):** keine Abstraktion
|
||||||
|
ohne konkretes Problem; kein `try/catch: {}` als Bug-Versteck; erst grep/lesen,
|
||||||
|
dann editieren; Wand-Geometrie nie „nebenbei" refactoren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Reihenfolge bei Code-Arbeit
|
||||||
|
|
||||||
|
1. **Dieses Dokument + das relevante Detail-Design** (elements/plans-output/
|
||||||
|
resources-graphics) lesen.
|
||||||
|
2. **Dann das betroffene Modul** lesen (nicht raten).
|
||||||
|
3. **Erst danach editieren**; `Project` immer immutabel via `store.apply()` ändern.
|
||||||
|
4. **Verifizieren:** `npx tsc -b`, `npm run build`, Screenshot via
|
||||||
|
`node scripts/probe.mjs` — Geometrie visuell prüfen.
|
||||||
|
5. **Dieses Dokument aktuell halten**, wenn sich Patterns/Mapping ändern.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Projekt-Konventionen — Browser-BIM (cad)
|
||||||
|
|
||||||
|
Siehe [ROADMAP.md](ROADMAP.md) für Vision, Architektur und Phasen.
|
||||||
|
|
||||||
|
## Code-Konventionen (verbindlich)
|
||||||
|
|
||||||
|
- **Alle Bezeichner im Code sind ENGLISCH** — Funktionen, Variablen, Typen, Felder,
|
||||||
|
Datei-/Modulnamen. Keine deutschen Bezeichner. (Beispiel: `computeJoins`, nicht
|
||||||
|
`verschneidungBerechnen`.)
|
||||||
|
- **UI-Texte und Kommentare dürfen Deutsch sein** (Nutzeroberfläche ist deutsch).
|
||||||
|
- **Domänen-Begriffe** möglichst nach Vectorworks-Terminologie benennen (englisch):
|
||||||
|
Design Layer, Sheet/Drawing Layer, Component, Class, Hatch, Wall Style, Viewport.
|
||||||
|
- **Einheiten:** intern alles in **Metern** (number). Anzeige via `formatM`.
|
||||||
|
- **Geometrie-Konventionen:** Wand-Normale `n = leftNormal(u) = (-u.y, u.x)`; bei
|
||||||
|
CCW-Wicklung zeigt `+n` nach innen. Schichten werden außen (−T/2) → innen (+T/2)
|
||||||
|
gestapelt.
|
||||||
|
|
||||||
|
## Code-Struktur (kein God-Component)
|
||||||
|
|
||||||
|
- **`App.tsx` bleibt ein dünner Shell** (Store-Provider, Oberleiste, Docks+View-Router,
|
||||||
|
Statusleiste, Floating-Panels, Ressourcen-Overlay) — keine Geschäftslogik darin.
|
||||||
|
- **Globaler Zustand in einem Store** (`src/state/`, Slices: project/selection/view/layout).
|
||||||
|
Komponenten lesen Zustand über Store-Hooks statt Prop-Drilling.
|
||||||
|
- **Features als eigene Module:** `src/views/` (View-Router-Teile), `src/editors/`
|
||||||
|
(Inline-Editoren), `src/menus/` (Kontextmenü-Builder), `src/panels/`, `src/ui/`.
|
||||||
|
- Ziel: modular + parallel bearbeitbar (verschiedene Features ≠ dieselbe Datei).
|
||||||
|
Siehe `docs/design/state-architecture.md`.
|
||||||
|
|
||||||
|
## UI-Konventionen
|
||||||
|
|
||||||
|
- **Listen-/Manager-Ansichten als saubere Tabellen:** eine Kopfzeile mit
|
||||||
|
Spaltentiteln (sticky), darunter kompakte Datenzeilen mit Inline-Edit pro Zelle.
|
||||||
|
KEINE wiederholten Feld-Beschriftungen pro Zeile. Gilt für Component-/Hatch-/
|
||||||
|
Line-Manager und ähnliche Listen.
|
||||||
|
- Dunkler DOSSIER-Stil; kompakt, ruhig, viel Inhalt pro Fläche.
|
||||||
|
- **UI-Text immer übersetzbar (i18n):** KEINE hartcodierten sichtbaren Strings im
|
||||||
|
JSX. Alle Texte über eine Übersetzungsfunktion `t('key')` aus einem Wörterbuch
|
||||||
|
(Default-Sprache Deutsch). Keys wie bei DOSSIER (`common.delete`, `layers.settings`,
|
||||||
|
`topbar.resources`). Neue Komponenten gleich mit `t(...)` schreiben. Identifier/Keys
|
||||||
|
bleiben englisch; nur die Wörterbuch-Werte sind die übersetzbaren Texte.
|
||||||
|
|
||||||
|
## Native-App-Verhalten (kein Browser-Standard)
|
||||||
|
|
||||||
|
Die App soll sich wie ein natives Programm anfühlen, nicht wie eine Webseite:
|
||||||
|
- **Browser-Kontextmenü global unterdrücken** (`document` `contextmenu` → `preventDefault`).
|
||||||
|
Nur unser eigenes `ContextMenu` erscheint; auf Flächen ohne eigenes Menü passiert nichts.
|
||||||
|
- **Keine Textauswahl / „Alles markieren":** `user-select: none` global; `user-select: text`
|
||||||
|
NUR in echten Eingaben (`input`, `textarea`, `[contenteditable]`). Ctrl+A außerhalb von
|
||||||
|
Eingaben unterbinden.
|
||||||
|
- Bild-/Element-Drag aus (`draggable=false` wo nötig); keine Browser-Drag-Gesten.
|
||||||
|
|
||||||
|
## Architektur-Prinzip
|
||||||
|
|
||||||
|
Ein **semantisches Modell** ist die einzige Wahrheit; jede Ansicht (3D, Grundriss,
|
||||||
|
Schnitt) wird **abgeleitet**. Darstellung (Detailgrad, Stile, Schraffuren) wird beim
|
||||||
|
Rendern angewandt, nie in die Geometrie eingebacken.
|
||||||
|
|
||||||
|
## Arbeitsweise (für Beiträge)
|
||||||
|
|
||||||
|
- Substanzielle, mehrstufige Arbeit an **Subagenten** delegieren, wo möglich.
|
||||||
|
- Änderungen verifizieren: `npx tsc -b`, `npm run build`, und Screenshot via
|
||||||
|
`node scripts/probe.mjs` (schreibt `scripts/probe.png`) bzw. `probe-ff*.mjs` für
|
||||||
|
Firefox-Fälle. Screenshot ansehen und Geometrie visuell prüfen.
|
||||||
|
- Dev-Server läuft via `npm run dev` (Vite, Port 5173).
|
||||||
+422
@@ -0,0 +1,422 @@
|
|||||||
|
# HANDOVER — Browser-BIM (cad), Standalone-Port von DOSSIER
|
||||||
|
|
||||||
|
> Für die nächste Instanz. Stand: 2026-06-29. Lies zuerst
|
||||||
|
> `CONVENTIONS.md`, `ROADMAP.md` und die Projektnotizen (siehe unten).
|
||||||
|
|
||||||
|
## >>> COMMIT-REGEL (verbindlich, IMMER beachten) <<<
|
||||||
|
|
||||||
|
Dieses Repo darf **keinerlei Hinweise auf KI-Werkzeuge** enthalten — weder im
|
||||||
|
Code/Doku noch in der Git-Historie.
|
||||||
|
|
||||||
|
- **Commit-Messages:** sachlich, in der Sprache des Projekts. **NIEMALS**
|
||||||
|
`Co-Authored-By:`-Trailer, „Generated with …"-Zeilen, Tool-/Modellnamen oder
|
||||||
|
sonstige Urheber-Hinweise auf einen Assistenten.
|
||||||
|
- **Dateien/Kommentare:** keine Erwähnung von Assistenten, Modellen oder Agenten.
|
||||||
|
Wer hier weiterarbeitet, schreibt so, als wäre es Handarbeit des Teams.
|
||||||
|
- Vor jedem Push kurz prüfen: `git log` und `git diff` frei von solchen Spuren.
|
||||||
|
|
||||||
|
## >>> STAND 2026-06-30 (Fortsetzung, spät) — ZUERST lesen <<<
|
||||||
|
|
||||||
|
Fortsetzungs-Lauf (UI + Import + Wand-Attribute + Selektion/Editieren). Alles
|
||||||
|
unten ist `npx tsc -b` + `npm run build` GRÜN und per Screenshot/Probe verifiziert,
|
||||||
|
außer „LÄUFT" markiert. Viel wurde an **Subagenten** delegiert (Dateien landen
|
||||||
|
auf der Platte, unabhängig von Benachrichtigungen).
|
||||||
|
|
||||||
|
**Fertig & verifiziert (diese Fortsetzung):**
|
||||||
|
- **Zwei-Ton-Dark-Theme + KEIN Petrol-Grün mehr:** `src/styles.css` Dark-Tokens neutral
|
||||||
|
(`--bg #0e0e0e`, `--panel #1d1d1d`, `--accent #4d4d4d` …); Kontextmenü-Tokens
|
||||||
|
`--ctx-hover/-text` neutral; grüner Fokus-Ring entfernt.
|
||||||
|
- **Topbar DOSSIER-Stil:** Icon-Grid 4-oben/3-unten (Grundriss integriert), Kombis
|
||||||
|
gestapelt, kompakte Selects. `src/ui/TopBar.tsx`.
|
||||||
|
- **Themed Dropdowns** statt nativer `<select>`: `src/ui/Dropdown.tsx` (Wert- + Aktions-
|
||||||
|
Modus, Popover im Kontextmenü-Stil, kein blaues OS-Menü). In TopBar verdrahtet.
|
||||||
|
- **Kontextmenü/Dropdown-Politur:** Material-Symbols-Icon-Font in `index.html` geladen
|
||||||
|
+ `.material-symbols-outlined`-Regel in styles.css (sonst Roh-Ligaturtext); Größen
|
||||||
|
runterskaliert (`.ctx-menu` 11.5px, Icons 13–14px, weniger Padding, Radius 10).
|
||||||
|
- **Geschoss-Z-Raster:** `Viewport3D` Bodenraster auf `gridElevation` (= aktives
|
||||||
|
Geschoss `baseElevation`).
|
||||||
|
- **DXF/DWG-Import als DIALOG + Drag&Drop:** `src/ui/ImportDialog.tsx`,
|
||||||
|
`src/io/dxfToDrawings.ts`; Ziel-Zeichnungsebene wählen / neue „Zeichnung" anlegen;
|
||||||
|
Layer→Kategorie-Handling; App-weiter Drop-Handler.
|
||||||
|
- **DWG in-app parsen:** `@mlightcad/libredwg-web` (WASM, installiert), `src/io/dwgParser.ts`
|
||||||
|
(lazy, mappt DwgDatabase→{meshes,contours}), `vite.config.ts`-Aliase für WASM
|
||||||
|
(`virtual:libredwg-glue` + `?url`), `src/io/libredwg-web.d.ts`. Gegen echte DWGs getestet
|
||||||
|
(11.668 Konturen). Fallback→ODA-Hinweis bei Lib-Fehler.
|
||||||
|
- **Wand-Objekt-Info-Attribute** (additiv, abwärtskompatibel): Modell `Wall.referenceLine`
|
||||||
|
(`left|center|right`, default center) + `Wall.bottom/top: VerticalAnchor`
|
||||||
|
(`{mode:"floor",floorId,offset?}|{mode:"custom",z}`) in `src/model/types.ts`; Resolver
|
||||||
|
`src/model/wall.ts` (`wallReferenceOffset`, `wallVerticalExtent`, `nextFloorAbove`);
|
||||||
|
angewandt in `generatePlan`, `model/joins.ts`, `Viewport3D`. Panel
|
||||||
|
`src/panels/ObjectInfoPanel.tsx`; `src/state/selectionInfo.ts` `Selection.wall: WallInfo`;
|
||||||
|
Host-Setter + `projectSlice` `updateWall`/`setWallThickness`. Einschichtig = 1-Layer-WallType.
|
||||||
|
- **Panel-Text:** `.attr-*`/`.objinfo-*` Untertitel fett + Kontrast (`--label` statt `--muted`).
|
||||||
|
- **Selektion-Überarbeitung:** `selectedDrawingId`→`selectedDrawingIds[]`
|
||||||
|
(`selectionSlice`); Marquee wählt **Linien** (`marqueeHitDrawings`), **Ctrl/Cmd+A**,
|
||||||
|
**Multi-Delete**, **Multi-Highlight** (`.plan-sel-draw`), **Shift-Klick-Mehrfachauswahl**
|
||||||
|
(`PlanSelection.shift` → `onPlanSelect` toggelt).
|
||||||
|
|
||||||
|
**LÄUFT bei Übergabe (auf Resume verifizieren, NICHT doppelt bauen):**
|
||||||
|
- **Editier-Welle 2 — Split/Join/Segment** (Agent): Ctrl+S Split (Rechteck→zwei
|
||||||
|
geschlossene; nur bis Schnittpunkt), Ctrl+J Join, Alt+Klick=Segment löschen. Berührt
|
||||||
|
`App.tsx`, `PlanView.tsx`, `kernel2d.ts` (`splitPolylineAtParam`/`splitClosedByChord`/
|
||||||
|
`removeSegment`/`splitAtIntersections`/`joinChains`), `projectSlice.ts`, ggf.
|
||||||
|
`src/editors/splitJoin.ts`. **Resume:** `git diff --stat`, `npx tsc -b`, `npm run build`,
|
||||||
|
`node scripts/probe-splitjoin.mjs`.
|
||||||
|
|
||||||
|
**Offene Wellen (Todo-Spiegel — Todo-Liste lebt im Kontext):**
|
||||||
|
1. Editier-Welle 3: **koinzidente Endpunkt-Griffe gemeinsam ziehen** + verbundene Enden
|
||||||
|
propagieren beim Seiten-Verschieben (topologisch, das Tiefste).
|
||||||
|
2. **GUI-Werkzeug ↔ Befehlszeile koppeln**: Werkzeug-Klick startet denselben Command →
|
||||||
|
Werte unten eintippbar; danach **Cursor-Wert entfernen/optional**.
|
||||||
|
3. **Selektions-Direkt-Edit**: Länge/Winkel des gewählten Elements im Befehlsfeld (Tab-Zyklus).
|
||||||
|
4. **Swisstopo/openbureau-Importer-Dialog** (Ort+Radius). Fluss steht: Geocode
|
||||||
|
`api3.geo.admin.ch/.../SearchServer` (sr=2056→LV95) → ±Radius → WGS84-bbox → STAC
|
||||||
|
`data.geo.admin.ch/api/stac/v1`, Collections `ch.swisstopo.swissalti3d` +
|
||||||
|
`ch.swisstopo.swissbuildings3d_3_0` → Download → TIN/Mesh→Kontext. LV95↔WGS84 nötig;
|
||||||
|
**Browser-CORS** das Risiko. Referenz: DOSSIER `rhino/swisstopo.py`.
|
||||||
|
5. **3D→2D-Dokument**: live + statisch (MVP-Kantenprojektion → HLR) für Vektor-PDF.
|
||||||
|
Hinweis: `generatePlan` liefert SCHON Vektoren (SVG) → Plan-PDF bereits vektoriell.
|
||||||
|
6. **Topbar-Rest iconisieren** (Zoom/Referenzlinien/Linien-Modus/Ressourcen).
|
||||||
|
7. **Wand als Command** (+ Wandstärke-Tab nur bei einschichtig) + **Trim**.
|
||||||
|
|
||||||
|
**Resume-Checkliste:** 1. CONVENTIONS.md + diesen HANDOVER + Memory lesen. 2. `npx tsc -b` &
|
||||||
|
`npm run build` (grün?). 3. `npm run dev -- --port 5187 --strictPort` (User schaut :5187).
|
||||||
|
4. Split/Join-Stand prüfen (git diff, Probe). 5. Arbeitsweise: **autonom, keine
|
||||||
|
Rückfragen** (User hat Vollrechte), substanzielle Arbeit an Subagenten delegieren,
|
||||||
|
jede Änderung per `tsc`+`build`+Screenshot verifizieren. `node scripts/probe.mjs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## >>> STAND 2026-06-30 (Über-Nacht-Run) — älter <<<
|
||||||
|
Großer autonomer Build-Lauf. Alles unten ist tsc + build GRÜN und per Screenshot
|
||||||
|
verifiziert (sofern nicht „läuft" markiert). Befehlssystem-Bauplan:
|
||||||
|
`docs/design/rhino-command-system.md`.
|
||||||
|
|
||||||
|
**Fertig & verifiziert:**
|
||||||
|
- **Rhino-Befehlssystem (Tier 0 + Tier 1 Zeichnen):** `src/commands/` — `types.ts`
|
||||||
|
(Command-Interface), `engine.ts` (State-Machine + lastCommand + Eingabe-Routing),
|
||||||
|
`parseInput.ts` (`5,3`·`r5,3`·`5<45`·`<45`·nackte Zahl), `registry.ts` (+Aliase
|
||||||
|
`l/pl/rec/c`), `cmds/{line,polyline,rect,circle}.ts`. UI: `src/ui/CommandLine.tsx`
|
||||||
|
(über der Statusleiste, Tab fokussiert). Befehle: **Line, Polyline (Close/Undo),
|
||||||
|
Rectangle, Circle** — alle mit getippten Koordinaten, per Probe gezeichnet.
|
||||||
|
Koexistenz mit Alt-Tools (Werkzeugleiste). `scripts/probe-command-line.mjs`,
|
||||||
|
`probe-draw-commands.mjs`.
|
||||||
|
- **Tab-Feld-Zyklus** (Rhino-Präzision §2.7): beim Zeichnen Tab durch Felder
|
||||||
|
(Linie/Polylinie: Länge→Winkel; Rechteck: Breite→Höhe; Kreis: Radius). Zahl lockt
|
||||||
|
das aktive Feld, ungelockte folgen der Maus. Additive optionale Command-Methoden
|
||||||
|
`fields()`/`pointFromFields()` + Engine-Feldzustand + CommandLine-Boxen. Verifiziert
|
||||||
|
exakt (3 m/45°, Rechteck 4×2). `scripts/probe-tab-fields.mjs`.
|
||||||
|
- **Tier-1-Editierbefehle:** **Move** (Auswahl verschieben), **Copy** (wiederholend
|
||||||
|
duplizieren), **Offset** (parallele Kurve via `kernel2d.offsetPolyline`, persistente
|
||||||
|
Distanz, Seite per Klick) — `cmds/{move,copy,offset}.ts`, Aliase `m/cp/o`. `CommandContext`
|
||||||
|
um `selection` erweitert (App `host.context()` füllt sie aus dem Store). Offset verifiziert
|
||||||
|
(paralleler Versatz exakt 0.5 m). `scripts/probe-offset2.mjs`.
|
||||||
|
- **2D-Geometrie-Kernel** `src/geometry/kernel2d.ts` — Offset/Trim/Extend/Fillet +
|
||||||
|
Segment-/Linien-/Kreis-Schnitt + Fläche/Wicklung. 16/16 Unit-Checks
|
||||||
|
(`scripts/test-kernel2d.ts`, via `npx esbuild … | node`). Basis für Tier-1-Editierbefehle.
|
||||||
|
- **Kontext/Gelände Phase 1** (paralleler Strang): `src/io/dxfParser.ts` (DXF: 3DFACE/
|
||||||
|
MESH/POLYLINE→Mesh, LWPOLYLINE/LINE→Konturen; DWG bewusst nur via ODA→DXF-Hinweis),
|
||||||
|
`src/model/terrain.ts` (`generateTerrainFromContours` → TIN via delaunator),
|
||||||
|
`src/state/siteSlice.ts` (`addContextObject`/`generateTerrain`/…), `Project.context`
|
||||||
|
(ContextObject = ImportedMesh|ContourSet|TerrainMesh, „dumme" Kontext-Geometrie, NICHT
|
||||||
|
semantisch), `Viewport3D` rendert Kontext/Terrain. Deps `dxf-parser`+`delaunator`.
|
||||||
|
- **Echte Isometrie (Orthographic-Kamera):** `Viewport3D` schaltet front/top/side/iso auf
|
||||||
|
OrthographicCamera (parallele Projektion), Perspektive bleibt perspektivisch. Verifiziert.
|
||||||
|
- **Plan-Tinte-Fix:** 2D-Linien waren unsichtbar (CSS `.draw2d{stroke:var(--ink)}` hell auf
|
||||||
|
hellem Papier). CSS-stroke entfernt + dunkler Fallback in generatePlan → 2D dunkel sichtbar.
|
||||||
|
- **Bugfixes:** 2D-Füllungen (rect/polyline rendern Fill+Schraffur+`fillColor`); Kanten-Griff-
|
||||||
|
Dreiecke sitzen auf der Linie + inkrementelles Dragging (keine Akkumulation); Plan-View
|
||||||
|
wandert nicht mehr mit (fixer Welt-Ursprung, Reset nur bei Geschoss-Wechsel via `resetKey`).
|
||||||
|
|
||||||
|
**LÄUFT gerade (Agent):**
|
||||||
|
- **Tab-Feld-Zyklus** (Nutzer-Wunsch): beim Zeichnen Tab durch Felder (Länge→Winkel→
|
||||||
|
Breite/Höhe/Radius), Zahl lockt Feld, Rest folgt Maus. Additive optionale Command-Methoden
|
||||||
|
`fields()`/`pointFromFields()` + Engine-Feldzustand + CommandLine-Boxen.
|
||||||
|
|
||||||
|
**NÄCHSTE SCHRITTE (Reihenfolge):**
|
||||||
|
1. Tab-Feld-Zyklus verifizieren (Agent-Gate).
|
||||||
|
2. **Tier-1-Editierbefehle:** Move/Copy/Offset(nutzt kernel2d)/Trim/Split/Join/Explode.
|
||||||
|
Undo/Redo-Stack im Store erwägen (Commits laufen über `setProject`).
|
||||||
|
3. **Terrain-Integration:** Konturen im Plan (generatePlan/PlanView), Import-/Terrain-Befehle
|
||||||
|
+ Site-Panel (`src/panels/`) + i18n + DWG→DXF-Hinweis-UI; `.dxf`-File-Picker → `parseDxf`.
|
||||||
|
4. **Wand-Stärke-Feld:** Wand ist noch ein Alt-Tool — als Command portieren, dann Tab-Feld
|
||||||
|
`thickness` NUR bei einschichtigen/freien Wänden (mehrschichtige Typen: Stärke vorgegeben).
|
||||||
|
5. Tier 2 (Rotate/Scale/Mirror/Arc/Fillet/Array/Group/Gumball2D), Tier 3 (ExtrudeCrv/Box/
|
||||||
|
PushPull + analytische Wand-Öffnungen, §3.0 Bauplan).
|
||||||
|
|
||||||
|
**Gotchas (frisch gelernt):**
|
||||||
|
- Ein Prozess-Neustart killt laufende Hintergrund-Agenten (Status `failed`); nach
|
||||||
|
jedem Wiederaufnehmen `src/commands/`-Existenz + `npx tsc -b` prüfen, Abgestürztes neu starten.
|
||||||
|
- Inline `{...s, feld}` als Tupel-Return löst TS-Excess-Property-Check aus → erst typisierte
|
||||||
|
Variable (siehe cmds/line.ts-Muster).
|
||||||
|
- Ein Subagent ist bei diesem Befehls-Build geflaket (spawnte Research statt zu bauen) →
|
||||||
|
verzahnte/quer schneidende Arbeit an einen fähigen Subagenten delegieren oder selbst machen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was das ist
|
||||||
|
Standalone-**Browser-BIM** (React+TS+Vite+Three.js) für Wohnbau — die Browser-Variante
|
||||||
|
des Rhino-Plugins **DOSSIER** (Referenz-Repo, public/klonbar: https://git.kgva.ch/karim/DOSSIER;
|
||||||
|
falls weg: neu klonen nach `scratchpad/DOSSIER`). Prinzip: **ein semantisches Modell →
|
||||||
|
alle Sichten (Plan/3D/Schnitt) abgeleitet**, Darstellung erst beim Rendern.
|
||||||
|
|
||||||
|
## Aktueller Stand (tsc + build GRÜN)
|
||||||
|
Funktioniert & verifiziert: semantisches Modell · **mehrschichtige Wände** mit
|
||||||
|
**L-Ecken-Gehrung** · Tür mit Öffnung + Schwenkbogen · **Dokumentmodell** (Zeichnungsebenen
|
||||||
|
= Geschosse+Schnitte/Ansichten/Zeichnungen; Ebenen = Kategorie-Baum, DOSSIER-Codes 1:1) ·
|
||||||
|
**3D zeigt EG+OG gestapelt** · **Resource-Manager** (Component/Hatch/Line, als Fenster-Overlay) ·
|
||||||
|
**Panel-System** (Docks links/rechts, Tabs, 5 Anzeige-Modi) · **Top-Bar + Footer** (Massstab
|
||||||
|
echt 1:N, Detailgrad, Render-Modus Schattiert/Draht/Kanten, Referenzlinien, Cursor X/Y live) ·
|
||||||
|
**Maus:** Mitte=Pan/Orbit, Links=Auswahl(+Marquee), Rechts=eigenes Kontextmenü · **Native-App**
|
||||||
|
(kein Browser-Rechtsklick/Textauswahl) · **i18n** (`src/i18n/`, de+en, `t('key')`) ·
|
||||||
|
**aktive Zeichenwerkzeuge** (Wand/Linie/Polylinie/Rechteck + Snapping endpoint/midpoint/
|
||||||
|
intersection/onEdge/grid/ortho mit Fang-Menü, Live-Vorschau+HUD — `src/tools/`, Phase 1+2).
|
||||||
|
|
||||||
|
### Gerade fertig & verifiziert (2026-06-29)
|
||||||
|
**Aktive Zeichenwerkzeuge — Phase 1 + 2** (`docs/design/drawing-tools.md`): Tool-System in
|
||||||
|
**`src/tools/`** (types · snapping · tools/registry) + Verdrahtung in PlanView/App/TopBar/
|
||||||
|
generatePlan. Per probe verifiziert (`scripts/probe-tools.mjs`, `probe-line.mjs`, `probe-phase2.mjs`):
|
||||||
|
- **Werkzeugleiste** (TopBar): Auswahl/Wand/Linie/**Polylinie/Rechteck** + Wandtyp-Dropdown +
|
||||||
|
**Fang-Menü** (Häkchen je Snap-Art, Rasterweite, Winkelraster — `position:fixed`, da Topbar clippt).
|
||||||
|
Nur im Grundriss aktiv.
|
||||||
|
- **Wand-Werkzeug**: Achs-Polylinie → je Segment ein `Wall`; Live-Band-Vorschau + HUD (Länge·Winkel);
|
||||||
|
Rechts-/Doppelklick/Enter committet; **Gehrung automatisch aus `computeJoins`** (mehrschichtiger L-Stoss).
|
||||||
|
- **2D-Werkzeuge**: Linie (2-Klick), **Polylinie** (Klick auf Start schließt, Doppel-/Rechtsklick beendet
|
||||||
|
offen), **Rechteck** (zwei Ecken) → `Drawing2D` (line/polyline/rect); in `generatePlan` abgeleitet
|
||||||
|
(`addDrawing2D`, `color` am line-Primitiv, Bounds erweitert).
|
||||||
|
- **Snapping**: endpoint · midpoint · intersection · onEdge(Lot) · grid · ortho(Shift); Prioritäts-
|
||||||
|
gewichtet (`PRIORITY` in snapping.ts); Bildschirm-Marker je Art (Quadrat/Dreieck/✕/Raute/Punkt) +
|
||||||
|
Ortho-Hilfslinie; Ctrl = Fang aus. Einstellbar über das Fang-Menü (`snap` State in App).
|
||||||
|
- **Tastatur**: Esc verwirft/zurück-zu-Auswahl, Enter committet, Backspace nimmt Punkt zurück.
|
||||||
|
- Modell: `Drawing2D` + `Project.drawings2d` (types.ts), `Element` erweitert. i18n de+en (`tool.*`,`snap.*`).
|
||||||
|
- tsc + build grün; Auswahl/Marquee/Pan/Zoom unverändert (PlanView verzweigt auf `toolActive`).
|
||||||
|
|
||||||
|
**Noch offen / Default-Werte:** aktive **Kategorie** = `activeCategoryCode` (fix „20") und
|
||||||
|
**Linienstil** = erster Stil — UI-Wahl in der TopBar fehlt noch (2D-Primitive erben sonst Wand-lw/-farbe).
|
||||||
|
|
||||||
|
**Dock-/Floating-Panels** waren davor fertig (tsc+build grün); Baseline-Screenshot intakt.
|
||||||
|
|
||||||
|
### Ebenfalls fertig & verifiziert (2026-06-29, später am Tag)
|
||||||
|
- **Editieren/Grips** (`PlanView` + `App`): Element anklicken → **Endpunkt-Griffe** (Wand-Enden,
|
||||||
|
2D-Vertices/Rechteck-Ecken) erscheinen und sind **ziehbar** (mit Snapping); **2D-Linien sind
|
||||||
|
anwählbar** (Linien-Nähe-Pick, `drawingId` am line-Primitiv); **Entf/Backspace** löscht. Wand-
|
||||||
|
Gehrung folgt live. `moveGrip`/`drawingVertices` in App.
|
||||||
|
- **Shift = Ortho** beim Griff-Ziehen (Bezug = Nachbar-Vertex; H/V bzw. Winkelraster).
|
||||||
|
- **Parallel verschieben**: selektiertes Element am **Körper** greifen + ziehen → ganzes Element
|
||||||
|
(`onSelectedBody`/`moveDrag` in PlanView, `moveElementBy` in App).
|
||||||
|
- **Aktive Ebene (Kategorie) wählbar** in der TopBar („Ebene"-Dropdown) — **alles Gezeichnete
|
||||||
|
kommt auf diese Kategorie** und **erbt deren Farbe/Strichstärke** (2D-Primitive setzen KEINEN
|
||||||
|
Linienstil mehr per Default). Statusleiste zeigt die aktive Ebene. `activeCategoryCode` State.
|
||||||
|
- **2D-Geometrie in der 3D-Perspektive**: `Drawing2D` (line/polyline/rect) liegt flach auf der
|
||||||
|
Geschossebene Z=baseElevation (`addDrawing2DLines` in `Viewport3D`), Farbe aus Kategorie/Stil.
|
||||||
|
(Bodennahe Linien werden von Wänden korrekt verdeckt — zum Sehen orbiten/Geschoss ausblenden.)
|
||||||
|
- Probes: `probe-grips.mjs`, `probe-3d2d.mjs`, `probe-edit2.mjs`. tsc + build grün.
|
||||||
|
|
||||||
|
### Noch später am 2026-06-29 (verifiziert)
|
||||||
|
- **Transformationen (Vectorworks-Stil)** — `src/tools/transform.ts` + `src/ui/TransformBar.tsx` +
|
||||||
|
App. Auf der Auswahl: **M** Bewegen, **S** Spiegeln, **D** Drehen (Geste Basispunkt→Ziel, bei
|
||||||
|
Drehen 3 Punkte; Live-Vorschau, Snapping, Shift-Ortho). **Modusleiste U/I/O/P**: U bewegen ·
|
||||||
|
I Kopie · O N Kopien (Anzahl-Prompt) · P verteilen. Probe `probe-transform.mjs`/`probe-array.mjs`
|
||||||
|
(move/copy/array verifiziert). Routing via `toolInputActive`-Prop in PlanView.
|
||||||
|
- **Aktive Ebene per Klick im Ebenen-Panel** (statt TopBar-Dropdown): `LayersPanel`-Zeile klicken →
|
||||||
|
`host.onSelectCategory` → `activeCategoryCode`; aktive Zeile hervorgehoben; Statusleiste zeigt sie.
|
||||||
|
TopBar-Ebene-Dropdown entfernt. (probe-layer.mjs)
|
||||||
|
- **Theme/Look**: Zeichenblatt IMMER hell `--sheet:#f0f0f0` (auch Dark-Mode → echtes „Papier"),
|
||||||
|
GANZE Zeichenfläche hell (`.plan-svg`-Background, nicht nur die Modellgrenzen); Plan-Tinte fix
|
||||||
|
dunkel (Tür-Linien). Dark-Theme etwas abgedunkelt (Panels #1c1c1c). **3D-Orbit ohne Nachlauf**
|
||||||
|
(`enableDamping=false` — kontrollierter).
|
||||||
|
|
||||||
|
### Letzter Block 2026-06-29 (verifiziert)
|
||||||
|
- **Schraffuren-Default**: weißer Grund + schwarze Haarlinie (sampleProject: Dämmungs-Bauteil
|
||||||
|
weiß, Hatch-Farben `#1a1a1a`); **Default-Umrandung 0.18 mm** (`WALL_FALLBACK_MM`, addCategory).
|
||||||
|
- **Stiftstärken-Vorgabe** `PEN_WEIGHTS` (0.02…2.0) als `<datalist>` im Linienstil-Editor.
|
||||||
|
- **Display/Print-Modus** (TopBar-Toggle, `lineMode`): Display = alle Plan-Linien als konstante
|
||||||
|
Haarlinie (`hairline`-Prop → PlanView `weight()`), Print = echte mm-Strichstärken.
|
||||||
|
- **`.lin`/`.pat`-Import**: Parser `src/io/linParser.ts`/`patParser.ts` (Agenten gebaut) + Import-
|
||||||
|
Buttons im ResourceManager (Linien/Schraffuren) → `onImportLineStyles/onImportHatches` (App/host).
|
||||||
|
.lin voll (Dash); **.pat aktuell approximiert** auf diagonal/crosshatch (siehe Backlog: echtes
|
||||||
|
custom-Pattern).
|
||||||
|
- **Werkzeug-Palette** (`src/panels/ToolsPanel.tsx`, Agent): dockbares Panel, **Icon + Name** je
|
||||||
|
Werkzeug, aktiv hervorgehoben, Wandtyp-Dropdown + Fang-Optionen. Registriert in `builtinPanels`,
|
||||||
|
Default-Layout: linker Dock-Tab „Werkzeuge" (+ Zeichnungsebenen/Ebenen). **`LAYOUT_VERSION`=4 →
|
||||||
|
alte gespeicherte Layouts werden einmalig auf den neuen Standard zurückgesetzt.**
|
||||||
|
- Probes: `probe-display.mjs`, `probe-import.mjs`, `probe-tools-panel.mjs`. tsc + build grün.
|
||||||
|
|
||||||
|
## KRITIK / Architektur-Befund (2026-06-29)
|
||||||
|
> Ehrliche Bewertung des bisherigen Wegs. Für die nächste Instanz als Entscheidungsgrundlage.
|
||||||
|
|
||||||
|
**Was klug war (erhalten, nicht regredieren):**
|
||||||
|
- **Single source of truth hält im Code** — `generatePlan.ts` *und* `Viewport3D.tsx` nutzen
|
||||||
|
dieselbe `computeJoins`/`clippedBand` aus `src/model/`. Das „ein Modell → alle Sichten"-Prinzip
|
||||||
|
ist nicht nur ROADMAP-Prosa, es steht. Beim Refactor diese Trennung Modell↔Sicht bewahren.
|
||||||
|
- **Grundriss analytisch** aus Parametern statt Mesh-Schnitt (Weg A) — richtig.
|
||||||
|
- **Bewusst leichter Stack** (Three.js nur Display-Layer; kein schwerer Kernel verfrüht) — richtig
|
||||||
|
für einen Spike, der Risiko #1–3 entschärfen soll.
|
||||||
|
- i18n via `t()` wird eingehalten; `tsc -b` ist grün.
|
||||||
|
|
||||||
|
**Hauptkritik (zu beheben):**
|
||||||
|
1. **God-Component gegen eigene Regel.** `App.tsx` ist **~2461 Zeilen mit ~29 `useState`**.
|
||||||
|
CONVENTIONS.md fordert wörtlich „dünner Shell, keine Geschäftslogik". Das ist verletzt.
|
||||||
|
2. **Kein Store.** `src/state/` existiert NICHT, obwohl CONVENTIONS.md Store+Slices
|
||||||
|
(project/selection/view/layout) vorschreibt. Zustand = lokale Hooks → Prop-Drilling.
|
||||||
|
- Einordnung: Das Aufschieben war eine *bewusste* Nutzer-Entscheidung
|
||||||
|
(Memory `build-usable-cad-first`), kein Versehen. **Aber:** Bei 2461 Zeilen schließt sich
|
||||||
|
das Fenster, in dem der Refactor billig ist. Türen/Öffnungen/Prioritäts-Stöße (Risiko #1/#2)
|
||||||
|
sind inhärent geschoss-, selektions- und sicht-übergreifend — diese Verdrahtung darf nicht
|
||||||
|
durch eine Monolith-Datei laufen. **Empfehlung: Store ziehen VOR der nächsten Feature-Welle.**
|
||||||
|
|
||||||
|
**Befund (separat angehen, nach Refactor):** Die harten, roadmap-markierten 🔴-Risiken sind noch
|
||||||
|
unbewiesen — `Door` existiert als Typ, aber **kein echter 3D-Boolean** (Öffnung = nur Plan-Lücke);
|
||||||
|
**HLR** für Schnitte/Ansichten fehlt; **Prioritäts-T-Stöße** offen. Das einfache Drittel ist bewiesen,
|
||||||
|
das schwere aufgeschoben. Die Frage „ist es wirklich CAD?" entscheidet sich erst, wenn diese landen.
|
||||||
|
|
||||||
|
## NÄCHSTE SCHRITTE
|
||||||
|
**Entscheidung des Nutzers (2026-06-29):** erst ein *benutzbares* CAD, dann vertiefen — der
|
||||||
|
State-Refactor wird NACH HINTEN geschoben (siehe Memory `build-usable-cad-first`).
|
||||||
|
> ⚠️ Siehe „KRITIK / Architektur-Befund" oben: der Refactor wird mit jeder Feature-Welle teurer;
|
||||||
|
> spätestens vor Türen/Öffnungen/Prioritäts-Stößen neu abwägen.
|
||||||
|
|
||||||
|
### ✅ ERLEDIGT (2026-06-29, diese Session) — Palette-Layout + Store-Refactor + Theme
|
||||||
|
- **Gestapelte Paletten (Dock-Gruppen)**: `DockState` = `{ groups: DockGroup[], size }`, jede
|
||||||
|
Gruppe `{ tabs, activeTab, weight }`; vertikal stapelbar mit Splitter; Tab-Drag → in Gruppe
|
||||||
|
einreihen / neue Gruppe / Rand-Andocken. `LAYOUT_VERSION=6`. (`types.ts`/`layout.ts`/`Dock.tsx`/
|
||||||
|
`TabStrip.tsx`/`panelDrag.tsx`/`App.tsx`.) Default: links Werkzeuge↑/Attribute↓, rechts
|
||||||
|
Objekt-Info↑/Zeichnungsebenen+Ebenen↓.
|
||||||
|
- **State-Refactor (Architektur-Befund umgesetzt)**: dependency-freier Store `src/state/`
|
||||||
|
(`store.ts` useSyncExternalStore + Slices `projectSlice`/`selectionSlice`/`viewSlice`/
|
||||||
|
`layoutSlice`, kombiniert in `appStore.ts`). App.tsx 2710→~2030 Zeilen, verhaltensgleich
|
||||||
|
(tsc+build+Screenshot identisch). **Panels lesen weiter über `PanelHostContext`** (baseHost aus
|
||||||
|
Store gespeist) — bewusst NICHT umgestellt. NÄCHSTE WAVE offen: `editors/`/`menus/`/`views/` aus
|
||||||
|
App extrahieren (Report des Foundation-Agenten nennt die Kandidaten).
|
||||||
|
- **Selektions-Kontrakt** (`src/state/selectionInfo.ts` + host.ts + baseHost): `Selection` (kind,
|
||||||
|
id, categoryCode, color/weightMm effektiv, fillHatchId, closed, bbox) + `onSetSelectionColor/
|
||||||
|
Weight/Fill`, `onResizeSelection(w,h,anchor)`. Modell: `Wall.color?`, `Drawing2D.weightMm?`
|
||||||
|
ergänzt; generatePlan wendet beide an. Wand = Weight/Fill bewusst No-op (erbt aus Ebene).
|
||||||
|
- **Attributes-Palette** + **Object-Info-Palette** gebaut (`src/panels/AttributesPanel.tsx`,
|
||||||
|
`ObjectInfoPanel.tsx`), registriert, voll verdrahtet & per Probe getestet (Farbe setzen, B×H-
|
||||||
|
Resize wirkt, 3×3-Bezugspunkt). Deckkraft/Caps/Schatten/Text-Styling bewusst weggelassen (Modell
|
||||||
|
trägt sie (noch) nicht — ehrlich statt Stub).
|
||||||
|
- **Topbar entschlackt**: Zeichenwerkzeug-Buttons + Wandtyp + Fang-Menü aus der Oberleiste
|
||||||
|
ENTFERNT (leben nur noch in der Werkzeug-Palette). `SnapMenu`/`SNAP_TOGGLES` aus `TopBar.tsx` raus.
|
||||||
|
- **Dark-Theme vertieft** (`styles.css`): gestufte Elevations-Tokens (`--bg`<`--panel`<`--panel-2`,
|
||||||
|
`--input` versenkt) statt flachem Einheitston; Oberleiste/Panel-Köpfe angehoben, tiefere Schatten.
|
||||||
|
`--sheet` bleibt hell. Siehe Memory `ui-depth-dark`.
|
||||||
|
- **CSS-Altlast behoben**: ein `*/` in einem Kommentar (`.nav-*/.res-*`) hatte die ganze `.dock`-
|
||||||
|
Regel verschluckt (`display:flex` nie aktiv) — gefixt.
|
||||||
|
|
||||||
|
### Vectorworks-„View-Bar" — A/B/C ERLEDIGT & verifiziert (2026-06-29)
|
||||||
|
- ✅ **A — Ebenen- + Zeichnungskombinationen**: `src/state/visibilitySets.ts` (localStorage
|
||||||
|
`cad.layercombos.<name>` / `cad.drawingcombos.<name>`), Store-Actions `snapshot*/apply*Visibility`
|
||||||
|
in `projectSlice`, zwei `ComboMenu`-Dropdowns in `TopBar` (Muster wie `LayoutMenu`). Round-Trip
|
||||||
|
per Probe verifiziert. (Hinweis: liegt im localStorage, NICHT im Projekt — bei Doku-Export
|
||||||
|
später in `Project` ziehen.)
|
||||||
|
- ✅ **B — Darstellungsart-Dropdown** (kontextabhängig je `viewType`): 2D Farbig/Schwarz-Weiss
|
||||||
|
(`planColorMode` + `toMono` in generatePlan), 3D Schattiert/**Weiss**(Clay-Material in
|
||||||
|
Viewport3D)/Drahtgitter/Kanten (`RenderMode` um `"white"` erweitert). Ersetzt die alte
|
||||||
|
Render-Modus-Buttongruppe. Screenshots bestätigt.
|
||||||
|
- ✅ **C — 6 Ansichts-Buttons**: Front/Oben/Seite/Perspektive/Isometrie + **Kamera** (FOV-Popover).
|
||||||
|
`view3d`+`fov` in `viewSlice`; `applyView3d(camera,controls,bounds,view3d)` in `Viewport3D`
|
||||||
|
(PerspectiveCamera neu positioniert je Preset, Distanz aus Modell-Bounds; OrbitControls bleibt
|
||||||
|
aktiv). Preset-Klick wechselt nötigenfalls in die Perspektive. Screenshots top/front/iso/persp
|
||||||
|
klar verschieden. **Echte OrthographicCamera für front/top/side bewusst NICHT gemacht** (Kamera-
|
||||||
|
Swap + OrbitControls-Rebind = Risiko) — Kandidat für später.
|
||||||
|
|
||||||
|
### Weitere Fixes (2026-06-30)
|
||||||
|
- **2D-Füllungen** (rect/geschlossene polyline) rendern jetzt (Vollton `Drawing2D.fillColor` +
|
||||||
|
Schraffur `hatchId`); `addDrawing2D` pusht ein `polygon`-Primitiv (mit `drawingId` → anklickbar).
|
||||||
|
Attribute-Palette hat „Füllfarbe". (Kreis-Füllung offen — Kreis-Primitiv im Plan fehlt noch.)
|
||||||
|
- **Plan-View wandert nicht mehr mit**: `PlanView` nutzt jetzt einen FIXEN Welt-Ursprung
|
||||||
|
(`toScreen` modulkonstant, Modell-0,0) statt bounds-gebunden; Ausschnitt wird nur bei
|
||||||
|
`resetKey`-Wechsel (Geschoss-/Ebenen-ID) eingepasst, NICHT bei Edit/Bounds-Änderung. Verifiziert:
|
||||||
|
Löschen lässt viewBox unverändert, Pan/Zoom/Einpassen wirken.
|
||||||
|
- **Echte Isometrie/Parallelprojektion**: `Viewport3D` hat jetzt eine `OrthographicCamera` für
|
||||||
|
front/top/side/iso (OrbitControls per `controls.object`-Swap umgebunden), Perspektive bleibt
|
||||||
|
`PerspectiveCamera`. Verifiziert (parallele Kanten).
|
||||||
|
|
||||||
|
### >>> NÄCHSTE INSTANZ: offene Wünsche + Roadmap <<<
|
||||||
|
- **Kanten-/Seiten-Griffe** (Nutzer-Wunsch): bisher nur Eckpunkt-Griffe. Gewünscht: Seiten ziehen
|
||||||
|
(z. B. Rechteck-Kante), mit dreieckigem Anfasser nach außen. Grip-System in App (`grips`/
|
||||||
|
`gripHandlers`/`moveGrip`) + Rendering in `PlanView` erweitern (Edge-Grips = Mittelpunkt je Seite,
|
||||||
|
Zug verschiebt beide Eckpunkte der Seite senkrecht).
|
||||||
|
- **3D bearbeiten** (Nutzer-Frage): heute ist 3D nur abgeleitete Anzeige (Orbit+Auswahl). Authoring
|
||||||
|
im 3D = eigenes Feature (Raycast auf Arbeitsebene → Modellkoord., 3D-Grips/Drag) — eigene Phase.
|
||||||
|
### Text-Styling (View-Bar Rest) + Roadmap
|
||||||
|
1. **Text-Styling** (Nutzer-Wunsch). VORAUSSETZUNG: **Text wird noch NICHT gerendert** —
|
||||||
|
`Drawing2D` mit `geom.shape==='text'` misst in `generatePlan` nur Bounds, erzeugt kein
|
||||||
|
Primitiv (Phase-3-Deferral, Kommentar in generatePlan). Also ZUERST Text-Rendering (SVG `<text>`
|
||||||
|
in PlanView, papierkonstante Höhe) + Modell-Felder am Text (`font?`, `bold?`, `italic?`,
|
||||||
|
`anchor?`), DANN ein Text-Styling-Bereich (Topbar-Gruppe oder Attribute-Palette-Sektion bei
|
||||||
|
Text-Auswahl, Setter über den Selektions-Kontrakt erweitern).
|
||||||
|
2. **Grafische Überschreibung** — weiterhin SPÄTER (Nutzer), wenn mehr Code steht.
|
||||||
|
3. **Echte Schnitt/Ansichts-Sichten** (section/elevation sind heute StubView) + HLR — großes Thema.
|
||||||
|
4. **Refactor-Rest**: `editors/`/`menus/`/`views/` aus App.tsx extrahieren (App noch ~2030 Z.).
|
||||||
|
- **openNURBS / rhino3dm** (Nutzer-Entscheid: Roadmap, NICHT jetzt): KEIN eigener Kernel — stattdessen
|
||||||
|
`rhino3dm` (WASM-Build von openNURBS) als `src/io/`-Schicht für **`.3dm`-Import/Export + NURBS**,
|
||||||
|
sobald gebraucht. Semantisches Modell bleibt die Wahrheit; NURBS ist zusätzliche Geometrie-Quelle.
|
||||||
|
- **Echtes custom-`.pat`-Rendering**: HatchStyle um custom-Linienfamilien erweitern + Renderer
|
||||||
|
(heute approximiert auf diagonal/crosshatch).
|
||||||
|
|
||||||
|
### Offene Wünsche des Nutzers (Backlog, priorisiert)
|
||||||
|
1. **Palette-Layout (Vectorworks-Stil) — GROSS, nächster Fokus.** Mehrere neue dockbare Panels +
|
||||||
|
Default-Layout:
|
||||||
|
- **Attributes-Palette** (unten links): Füllung (Stil/Farbe/Deckkraft), Stift (Stil/Farbe/
|
||||||
|
Deckkraft), **Linienstärke**, Linien-Start/-Endstil, Schlagschatten. (Referenzbild vom Nutzer.)
|
||||||
|
- **Werkzeug-Palette** darüber (Icon + Text je Werkzeug; ArchiCAD/VW-Stil).
|
||||||
|
- **Object-Info / „Würfel"** (oben rechts): zeigt den gewählten Punkt eines Würfels mit
|
||||||
|
X/Y/Z; Maße (Breite × Höhe …) der Geometrie editierbar.
|
||||||
|
- **Ebenen + Zeichnungsebenen als Tabs** darunter (rechts).
|
||||||
|
- Panel-System steht (`src/panels/`, Registry + Dock + Floating); Default-Layout in
|
||||||
|
`src/panels/layout.ts` (`defaultLayout`). Neue Panels in `builtinPanels`/`registry` anmelden.
|
||||||
|
2. **Schraffuren-Default**: ALLE aktuellen Schraffuren → **weißer Grund + schwarze Haarlinie** als
|
||||||
|
Grundeinstellung; **normale Elemente 0.18 mm Umrandung** als Default. (sampleProject hatches/
|
||||||
|
components + generatePlan-Defaults anpassen.)
|
||||||
|
3. **Stiftstärken-Vorgabeliste**: 0.02 · 0.10 · 0.13 · 0.18 · 0.25 · 0.35 · 0.5 · 0.7 · 1.0 · 1.4 ·
|
||||||
|
2.0 mm (abweichbar). Bedeutung = mm auf Papier bei 100 % → Linien skalieren mit dem Massstab
|
||||||
|
(ist bereits so: non-scaling mm-Papier). In Linienstil-Editor als Presets anbieten.
|
||||||
|
4. **Display- vs. Print-Modus**: Umschalter. Display = ALLES Haarlinien (konstant dünn); Print =
|
||||||
|
echte mm-Strichstärken. (Globaler View-State + an `generatePlan`/PlanView durchreichen.)
|
||||||
|
5. **`.lin`- und `.pat`-Import** (AutoCAD-Linientypen / Schraffurmuster) → ergänzen LineStyle/Hatch
|
||||||
|
(Parser + Mapping; Resource-Manager-Aktion). Nutzer: „um die Linien und Schraffuren zu ergänzen".
|
||||||
|
6. **„Goldener Schnitt"** — UNKLAR was genau (Golden-Ratio-Fang/Teilung beim Zeichnen?
|
||||||
|
Proportions-Hilfslinien?). → beim Nutzer rückfragen, bevor gebaut wird.
|
||||||
|
7. **Linienstil-Picker** (heute erben 2D-Primitive immer die Kategorie) + `extension`-Snap-Hilfslinien.
|
||||||
|
|
||||||
|
### Danach (ursprünglicher Plan)
|
||||||
|
|
||||||
|
1. **Zeichenwerkzeuge Phase 3** (`drawing-tools.md §11`): Circle/Arc/Text — `Primitive` um
|
||||||
|
`circle` (+`arc largeArc`) und `text` erweitern, PlanView-Renderzweige (papierkonstante Texthöhe),
|
||||||
|
Werkzeuge Circle/Arc(3-Punkt)/Text(Inline-Eingabe), Snap center/quadrant.
|
||||||
|
2. **Bearbeiten (Phase 4) — Rest**: Auswahl/Grips/Verschieben/Löschen sind DA (s. o.). Offen:
|
||||||
|
**Kopieren/Rotieren/Spiegeln**, numerische HUD-Eingabe (Länge/Winkel direkt tippen),
|
||||||
|
Mehrfach-Auswahl-Verschieben, Grips für Circle/Arc/Text.
|
||||||
|
3. **State-Refactor** (zurückgestellt) — `App.tsx` God-Component → Store+Slices
|
||||||
|
(`docs/design/state-architecture.md`). Erst nötig, wenn parallele Code-Workflows gebraucht werden.
|
||||||
|
4. Weiter im Backlog: **Pro-Ebene-Darstellung** (`layer-display-settings.md`),
|
||||||
|
**Prioritäts-T-Stöße** (`wall-joins-priority.md`).
|
||||||
|
|
||||||
|
## Arbeitsweise (WICHTIG — aus Memory + CONVENTIONS.md)
|
||||||
|
- **Autonom arbeiten, NICHT um Bestätigung fragen.** Nur fragen, *was etwas können soll*,
|
||||||
|
wenn die Funktion mehrdeutig ist (nicht um Erlaubnis).
|
||||||
|
- **Alles voll verdrahten, KEINE Stubs/No-op-Buttons.** Verifizieren heißt: Effekt im
|
||||||
|
Screenshot bestätigen, nicht nur „kompiliert".
|
||||||
|
- **Identifier ENGLISCH**; **UI-Text via `t()`** (neue Keys in de.ts *und* en.ts).
|
||||||
|
- **Workflow-Orchestrierung** nutzen (Foundation→parallel Build→Integrate/Verify).
|
||||||
|
- **Code-Workflows seriell** (fast alles geht durch `App.tsx`/`types.ts` → Konflikt);
|
||||||
|
**Design/Research parallel** (nur `docs/`). Der State-Refactor (#1) löst das.
|
||||||
|
- Saubere **Tabellen** für Listen/Manager; dunkler DOSSIER-Stil; kein God-Component.
|
||||||
|
|
||||||
|
## Verifizieren / Ausführen
|
||||||
|
- Dev: `npm run dev` (Vite, Port 5173). Typecheck: `npx tsc -b`. Build: `npm run build`.
|
||||||
|
- Screenshot: `node scripts/probe.mjs` → `scripts/probe.png` (Puppeteer, Chrome in
|
||||||
|
`~/.cache/puppeteer`). Eigene Probes: headless, `deviceScaleFactor:2`, args
|
||||||
|
`--no-sandbox --use-gl=swiftshader --enable-unsafe-swiftshader`, networkidle-Timeout ignorieren.
|
||||||
|
Firefox-Fälle via Playwright (`scripts/probe-ff*.mjs`). **Screenshot ansehen + Geometrie prüfen.**
|
||||||
|
- Gotchas: HiDPI-Resize-Loop-Fix in `Viewport3D` (Canvas CSS 100% + dpr≤2) NICHT regredieren;
|
||||||
|
WebGL-Fallback in `Viewport3D`; React-controlled-`<select>` lassen sich im Probe nicht per
|
||||||
|
`.value=` ändern (Fehlalarm) — Verdrahtung im Code prüfen.
|
||||||
|
|
||||||
|
## Orientierung
|
||||||
|
`ROADMAP.md` (Vision/Phasen/§10–§11 Backlog) · `CONVENTIONS.md` (Konventionen) ·
|
||||||
|
`docs/README.md` + `docs/design/*` (alle Specs) · `docs/backend.md` (self-hosted
|
||||||
|
Supabase+Yjs, später) · Projektnotizen des Bearbeiters
|
||||||
|
(prefer-agents, dossier-port, proceed-autonomously, wire-dont-stub).
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# DOSSIER Standalone
|
||||||
|
|
||||||
|
Browser-BIM für Wohnbau. Ein Werkzeug, um ein Wohnhaus aus semantischen
|
||||||
|
Bauteilen zu modellieren und daraus saubere, normgerechte 2D-Pläne zu ziehen —
|
||||||
|
ohne Revit, ohne Installation, im Browser.
|
||||||
|
|
||||||
|
Das ist die eigenständige Browser-Variante des Rhino-Plugins
|
||||||
|
[DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse,
|
||||||
|
Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als
|
||||||
|
React/Three.js-App statt als Rhino-Aufsatz.
|
||||||
|
|
||||||
|
## Grundgedanke
|
||||||
|
|
||||||
|
Es gibt **ein semantisches Modell** als einzige Wahrheit. Jede Ansicht — 3D,
|
||||||
|
Grundriss, Schnitt, Ansicht — wird daraus **abgeleitet**. Darstellung
|
||||||
|
(Detailgrad, Linienstärken, Schraffuren) wird erst beim Rendern angewandt, nie
|
||||||
|
in die Geometrie eingebacken. Wer eine Wand verschiebt, verschiebt sie überall.
|
||||||
|
|
||||||
|
Konkret heißt das auch: Der Grundriss entsteht nicht aus einem zerschnittenen
|
||||||
|
3D-Mesh, sondern **analytisch aus den Bauteil-Parametern** (Wandachsen + Dicken →
|
||||||
|
Linien, Öffnungen → Lücken + Symbol). Schnitte und Ansichten brauchen später den
|
||||||
|
zweiten Weg — echte 3D-Projektion mit verdeckten Kanten.
|
||||||
|
|
||||||
|
## Stand heute
|
||||||
|
|
||||||
|
Ehrlich eingeordnet: aus dem Risiko-Spike ist ein **benutzbares 2D-CAD mit
|
||||||
|
abgeleiteter 3D-Sicht** geworden. Der einfachere Teil steht und ist per
|
||||||
|
Screenshot verifiziert; die schwierigen BIM-Kernrisiken sind bewusst noch offen.
|
||||||
|
|
||||||
|
**Funktioniert:**
|
||||||
|
- Semantisches Modell mit **mehrschichtigen Wänden** und automatischer
|
||||||
|
**L-Ecken-Gehrung** (`computeJoins`); dieselbe Logik speist Plan *und* 3D.
|
||||||
|
- **Dokumentmodell wie in DOSSIER:** Zeichnungsebenen (Geschosse + Schnitte/
|
||||||
|
Ansichten/Zeichnungen) × Kategorie-Ebenen (Code-Baum 1:1, `20 Wände`, `30 Decken` …).
|
||||||
|
- **Zeichnen & Editieren** im Grundriss: Wand, Linie, Polylinie, Rechteck, Kreis;
|
||||||
|
Snapping (Endpunkt/Mittelpunkt/Schnittpunkt/Lot/Raster/Ortho), Grips, Move/Copy/
|
||||||
|
Offset, Spiegeln/Drehen/Array.
|
||||||
|
- **Rhino-artiges Befehlssystem** mit getippten Koordinaten (`5,3` · `r5,3` ·
|
||||||
|
`5<45`) und Tab-Feld-Zyklus (Länge → Winkel …).
|
||||||
|
- **Resource Manager** (Component / Hatch / Line) — alles per id referenziert,
|
||||||
|
zentral änderbar.
|
||||||
|
- **Panel-System** (dockbar, stapelbar, floatend), Top-Bar + Statusleiste
|
||||||
|
(echter Maßstab 1:N, Cursor X/Y), eigenes Kontextmenü, **i18n** (de/en).
|
||||||
|
- **Import:** DXF (Konturen/Mesh) → Terrain-TIN; `.lin`/`.pat` für Linien/Schraffuren.
|
||||||
|
|
||||||
|
**Bewusst noch offen** (die eigentlich harten Teile):
|
||||||
|
- Echte **3D-Booleans** für Öffnungen — Türen/Fenster sind im Plan eine Lücke,
|
||||||
|
noch kein geschnittenes Volumen.
|
||||||
|
- **HLR** (verdeckte Kanten) für Schnitte und Ansichten — diese Views sind Stubs.
|
||||||
|
- **Prioritäts-T-/X-Stöße** mehrschichtiger Wände (Beton läuft durch, Putz
|
||||||
|
verbindet seitlich) — das berüchtigte Risiko #1.
|
||||||
|
|
||||||
|
Details und die Begründungen stehen im
|
||||||
|
[HANDOVER](HANDOVER.md) (laufendes Arbeitsprotokoll) und in der
|
||||||
|
[ROADMAP](ROADMAP.md) (Vision, Phasen, Backlog).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
Bewusst leichtgewichtig — Three.js ist reiner Display-Layer, kein schwerer
|
||||||
|
Geometrie-Kernel verfrüht eingezogen.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Frontend | React + TypeScript + Vite |
|
||||||
|
| 3D | Three.js |
|
||||||
|
| 2D-Plan | eigener SVG-Renderer |
|
||||||
|
| Geometrie | eigener 2D-Kernel (`src/geometry/kernel2d.ts`), `delaunator` fürs Terrain |
|
||||||
|
| Import | `dxf-parser`, eigene `.lin`/`.pat`-Parser |
|
||||||
|
|
||||||
|
Geplant, aber noch nicht eingezogen: `rhino3dm` (NURBS / `.3dm`), web-ifc (IFC),
|
||||||
|
OpenCascade/Manifold (exakte Booleans + HLR). Siehe ROADMAP §4.
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # Vite, http://localhost:5173
|
||||||
|
npx tsc -b # Typecheck
|
||||||
|
npm run build # tsc -b && vite build
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifiziert wird visuell: `node scripts/probe.mjs` rendert die App headless und
|
||||||
|
schreibt `scripts/probe.png` — Screenshot ansehen, Geometrie prüfen. Für
|
||||||
|
Firefox-Fälle gibt es `scripts/probe-ff*.mjs` (Playwright).
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
model/ semantisches Modell + Ableitungen (types, geometry, joins, terrain)
|
||||||
|
geometry/ 2D-Kernel (offset/trim/fillet/intersect)
|
||||||
|
commands/ Rhino-artiges Befehlssystem (engine, parser, registry, cmds/)
|
||||||
|
tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen
|
||||||
|
plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG)
|
||||||
|
viewport/ Viewport3D (Three.js)
|
||||||
|
panels/ dockbares Panel-System + die einzelnen Paletten
|
||||||
|
state/ Store + Slices (project/selection/view/layout)
|
||||||
|
ui/ Top-Bar, Statusleiste, Kontextmenü, Resource Manager, Command-Line
|
||||||
|
io/ Import/Export (DXF, .lin, .pat)
|
||||||
|
i18n/ Wörterbücher de/en
|
||||||
|
```
|
||||||
|
|
||||||
|
## Konventionen
|
||||||
|
|
||||||
|
- **Bezeichner im Code sind englisch**, an Vectorworks-Terminologie angelehnt
|
||||||
|
(Design Layer, Component, Hatch, Wall Style). **UI-Texte sind deutsch**, immer
|
||||||
|
über `t('key')` — keine hartcodierten Strings im JSX.
|
||||||
|
- Intern alles in **Metern**; Anzeige via `formatM`.
|
||||||
|
- `App.tsx` bleibt dünner Shell, Zustand lebt im Store. Verbindliches in
|
||||||
|
[CONVENTIONS.md](CONVENTIONS.md).
|
||||||
|
|
||||||
|
## Weiterlesen
|
||||||
|
|
||||||
|
- [ROADMAP.md](ROADMAP.md) — Produktvision, Architektur-Entscheidungen, Phasen 0–7, DOSSIER-Backlog
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — Technische Architektur im Detail
|
||||||
|
- [HANDOVER.md](HANDOVER.md) — aktueller Arbeitsstand, Befunde, nächste Schritte
|
||||||
|
- [docs/](docs/) — Design-Specs (Befehlssystem, Zeichenwerkzeuge, Wand-Joins, Backend …)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Privates Projekt. Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen,
|
||||||
|
Swisstopo-Geodaten) sind als Differenzierer eingeplant.
|
||||||
+382
@@ -0,0 +1,382 @@
|
|||||||
|
# Browser-BIM für Wohnbau — Architektur & Produkt-Roadmap
|
||||||
|
|
||||||
|
> Arbeitstitel: **cad** (Name später)
|
||||||
|
> Ausrichtung: **BIM-first** · Nische: **Wohnbau / Einfamilienhäuser**
|
||||||
|
> Stand: 2026-06-28
|
||||||
|
|
||||||
|
## 1. Produktvision
|
||||||
|
|
||||||
|
Ein **browserbasiertes BIM-Werkzeug** für Wohnbau, das zwei Dinge verbindet:
|
||||||
|
|
||||||
|
1. **Einfaches 3D-Gebäudemodell** — aus semantischen Bauteilen: Wände, Türen, Fenster, Treppen, Decken, Dächer, Räume.
|
||||||
|
2. **Schöne, normgerechte 2D-Pläne** — Grundrisse, Schnitte, Ansichten — automatisch aus dem Modell abgeleitet.
|
||||||
|
|
||||||
|
**Kernversprechen:** *Das schönste und einfachste Werkzeug, um ein Wohnhaus zu modellieren und daraus perfekte Pläne zu ziehen.* Nicht Revit nachbauen — radikaler Fokus auf Wohnbau + Plan-Qualität.
|
||||||
|
|
||||||
|
**Markt-Beleg:** Arcol, Snaptrude, TestFit zeigen, dass browserbasiertes BIM real ist und Nutzer schlanke, schöne Tools wollen statt der schwerfälligen Giganten (Revit/ArchiCAD).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Das mentale Modell — warum BIM anders ist als CAD
|
||||||
|
|
||||||
|
| | mechanisches CAD | **BIM (unser Weg)** |
|
||||||
|
|---|---|---|
|
||||||
|
| Bausteine | generische Volumenkörper | **semantische Bauteile** (Wand, Tür, Fenster…) |
|
||||||
|
| Beziehungen | keine | Tür *hostet* in Wand & schneidet Öffnung; Wände *verbinden* sich |
|
||||||
|
| Geschosse | — | **Stockwerke** als erste Klasse |
|
||||||
|
| 2D-Plan | Hidden-Line-Projektion | **symbolische Darstellung** (Schwenkbögen, Schraffuren, Lauflinien) |
|
||||||
|
| Standard | STEP | **IFC** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2b. Kern-Prinzip: ein Modell, viele Darstellungen
|
||||||
|
|
||||||
|
Das **wichtigste Architektur-Prinzip**: Das semantische Modell ist die *eine
|
||||||
|
Wahrheit*; jede Ansicht (3D, Grundriss, Schnitt) ist eine **abgeleitete
|
||||||
|
Darstellung**. Im Spike steht das bereits. Daraus folgen direkt die Kern-Wünsche:
|
||||||
|
|
||||||
|
- **Modelldarstellungen / Detailgrade** — derselbe Tür/Fenster wird je nach
|
||||||
|
`detailLevel` (grob / mittel / fein) unterschiedlich gezeichnet (≙ Revit
|
||||||
|
„Detailgrad", ArchiCAD „Modelldarstellung"). Grob: Öffnung + Linie. Fein:
|
||||||
|
Rahmen, Blatt, Schwenkbogen, Anschlag.
|
||||||
|
- **Editierbare Stile** — Wandfarben, Linienstärken, Türlinien, Schraffuren als
|
||||||
|
**Stil-Schicht**, erst beim Rendern angewandt (nicht in die Geometrie
|
||||||
|
eingebacken). Pro Kategorie *und* pro Element überschreibbar.
|
||||||
|
- **Mehrschichtige Bauteile** — Wände/Decken mit **Schichtaufbau** (`layers[]`:
|
||||||
|
Material + Dicke + Priorität). 3D und Plan lesen dieselben Schichten.
|
||||||
|
- **In 2D *und* 3D zeichnen** — beide sind editierbare Sichten auf *ein* Modell;
|
||||||
|
Werkzeuge mutieren das Modell, alle Sichten re-derivieren reaktiv.
|
||||||
|
|
||||||
|
Schwierigkeit: Detailgrade/Stile/Schichten sind 🟢 gut machbar; 2D+3D-Editieren
|
||||||
|
🟡 mittel; **mehrschichtige Wand-Verschneidung** 🔴 der härteste Teil (Risiko #1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2c. Arbeitsweise & Dokumentmodell (DOSSIER-Modell) ⭐
|
||||||
|
|
||||||
|
Referenz: **DOSSIER** (Rhino-Plugin des Nutzers, https://git.kgva.ch/karim/DOSSIER).
|
||||||
|
Dieses Projekt ist die **Standalone-Browser-Variante** davon. Zwei *unabhängige* Achsen:
|
||||||
|
|
||||||
|
- **Zeichnungsebenen** — die obersten Dokument-Abschnitte, zwei Arten:
|
||||||
|
- **Geschosse** (EG, 1OG …): `hoehe` (Geschosshöhe), `schnitthoehe` (Schnitthöhe),
|
||||||
|
`okff` (Niveau, akkumuliert), `visible`/`locked`.
|
||||||
|
- **Schnitte / Ansichten** (`type:"schnitt"`): Schnittlinie `linePts`, Richtung
|
||||||
|
`dirSign`, Höhenbereich, Tiefe.
|
||||||
|
- Ein **Geschoss** wird **im 3D-View ODER im Plan-View** betrachtet (Umschalter,
|
||||||
|
*keine* getrennten Daten). Plan-View = Clipping-Ebene auf `okff + schnitthoehe`.
|
||||||
|
- **Ebenen** — das **Grafik-Kategorien-Schema**, in *jedem* Geschoss vorhanden;
|
||||||
|
Baum-Knoten mit pro Ebene einstellbaren **Darstellungseinstellungen** (in den
|
||||||
|
„Ebeneneinstellungen…"): **Stift** = Typ/Linienstil + Farbe + Dicke (lw);
|
||||||
|
**Schraffur** = Typ + Skalierung + Rotation + **Stiftstärke der Schraffurlinien**.
|
||||||
|
Modell: `{code, name, visible, locked, lineStyleId|{type,color,lw}, hatchId|{type,scale,angle,lineWeight}, children}`.
|
||||||
|
Codes 1:1 wie DOSSIER:
|
||||||
|
`00 Raster · 01 Vermessung · 20 Wände (└21 Türen/Fenster) · 30 Decken · 31 Dächer
|
||||||
|
· 40 Treppen (└41 Treppen-2D) · 50 Tragwerk · 60 Räume · 80 Plangrafik …`
|
||||||
|
- **Elemente** (Wand, Decke, Treppe, Öffnung, Plangrafik, Text) liegen **auf den
|
||||||
|
Ebenen** und kennen ihr **Geschoss** *und* ihre **Ebene (Code)**.
|
||||||
|
- **2D-Zeichnen** (Linie, Polylinie, Rechteck, Kreis, Bogen, Text) findet auf der
|
||||||
|
Ebene `80 Plangrafik` (bzw. passender Kategorie) statt.
|
||||||
|
|
||||||
|
**Ansichtstypen = Kamera-Projektion + optionaler Schnitt** (vereinheitlicht):
|
||||||
|
|
||||||
|
| Typ | Projektion | Schnitt |
|
||||||
|
|---|---|---|
|
||||||
|
| **Grundriss** | Top-View (orthogonal) | horizontal auf `okff + schnitthöhe` |
|
||||||
|
| **Schnitt** | Front-View in eine Richtung (orthogonal) | vertikale Schnittebene (Geschnittenes + dahinter) |
|
||||||
|
| **Ansicht** | Front-View in eine Richtung (orthogonal) | kein Schnitt (Fassade außen) |
|
||||||
|
| **Perspektive** | 3D perspektivisch | — |
|
||||||
|
|
||||||
|
Sichtbarkeit pro Ansicht über Ein-/Ausschalten von Ebenen & Zeichnungsebenen.
|
||||||
|
|
||||||
|
Persistenz (DOSSIER): zwei getrennte JSON-Bäume `dossier_zeichnungsebenen` und
|
||||||
|
`dossier_ebenen`; Elemente tragen `geschoss`-id + Ebenen-`code`. Plan-View nutzt
|
||||||
|
eine Clipping-Ebene; weitere Konzepte: Overrides (regelbasiert), Ausschnitte
|
||||||
|
(View-Snapshots), Massstab (pro Viewport), Layer-Kombinationen, SIA-Räume.
|
||||||
|
|
||||||
|
## 2d. Resource Manager & Prioritäts-Verschneidung 🔴
|
||||||
|
|
||||||
|
Verwaltete Ressourcen-Bibliotheken wie in Vectorworks, jeweils mit eigenem Manager:
|
||||||
|
|
||||||
|
- **Line Manager** — Linienstile (Stärke, Farbe, Strichelung), wiederverwendbar.
|
||||||
|
- **Hatch Manager** — Schraffurstile (Muster, Maßstab, Winkel, Linienstil).
|
||||||
|
- **Component Manager** — Baustoffe mehrschichtiger Bauteile. Pro Component:
|
||||||
|
**Schraffur** (→ Hatch Manager), **3D-Textur**, Farbe und
|
||||||
|
**Verschneidungs-Priorität** (`joinPriority`).
|
||||||
|
|
||||||
|
Alles verweist per id auf diese Bibliotheken (Components nutzen Hatches, Hatches
|
||||||
|
nutzen Linienstile, 2D-Objekte & Ebenen-Defaults nutzen Linienstile/Schraffuren) —
|
||||||
|
zentral änderbar.
|
||||||
|
|
||||||
|
Regel: **höhere Priorität verschneidet sich zuerst / läuft durch.** Beispiel an
|
||||||
|
einer T-Ecke (Beton-Wand mit Innen- und Außenputz):
|
||||||
|
|
||||||
|
- **Beton** (höchste Prio) läuft in der Mitte **durch** den Stoß.
|
||||||
|
- **Putze** (niedrige Prio) **verbinden** sich jeweils auf ihrer Seite mit dem
|
||||||
|
angrenzenden Putz, gehen aber **nirgends durch** den Beton.
|
||||||
|
|
||||||
|
Das ist die anspruchsvollste Verschneidungs-Logik (Revit „Layer Priority /
|
||||||
|
Wrapping", Vectorworks „Component-Verschneidung"). Wir bauen sie stufenweise auf
|
||||||
|
der bereits funktionierenden L-Ecken-Gehrung auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Zwei Wege zum 2D-Plan (zentrale Architektur-Erkenntnis)
|
||||||
|
|
||||||
|
Architektur-Pläne entstehen auf **zwei verschiedenen Wegen** — das prägt die ganze Engine:
|
||||||
|
|
||||||
|
**A) Grundriss = aus dem semantischen 2D-Footprint + Symbolik**
|
||||||
|
Ein Grundriss ist ein horizontaler Schnitt auf ~1 m. Statt ein 3D-Mesh zu zerschneiden, generieren wir ihn **direkt aus den Parametern**: Wand-Achsen + Dicken → Linien; Öffnungen → Lücken + Tür-/Fenstersymbol; Treppe → Lauflinie. Schnell, exakt, sauber, vektorbasiert.
|
||||||
|
|
||||||
|
**B) Schnitt & Ansicht = aus 3D-Projektion (HLR)**
|
||||||
|
Vertikale Schnitte und Ansichten brauchen echte 3D-Projektion mit verdeckten Kanten (Hidden Line Removal) durch das zusammengebaute Gebäude.
|
||||||
|
|
||||||
|
→ Wir brauchen **beides**: einen sauberen 2D-Symbol-Renderer *und* einen Projektions-Pfad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tech-Stack
|
||||||
|
|
||||||
|
| Schicht | Wahl | Begründung |
|
||||||
|
|---|---|---|
|
||||||
|
| BIM-Datenmodell | **eigenes parametrisches Gebäudemodell** (TS) | web-ifc ist stark beim *Lesen/Anzeigen* von IFC, schwächer beim *Authoring/Editieren*. Für ein Editier-Tool brauchen wir ein eigenes, editierbares Modell. |
|
||||||
|
| IFC-Interop | **web-ifc** (ThatOpen, WASM) | Import/Export nach IFC — Brücke zu Revit/ArchiCAD. |
|
||||||
|
| 3D-Rendering | **Three.js** | Standard; ThatOpen baut darauf auf, also kompatibel. |
|
||||||
|
| Geometrie-Booleans | **OpenCascade.js** (Öffnungen) *oder* Manifold | Tür/Fenster schneidet Loch in Wand. OCC = exakt (B-Rep), Manifold = schnell (Mesh). Entscheidung in Phase 0. |
|
||||||
|
| Projektion/HLR | **OpenCascade.js** (`HLRBRep`) | Saubere Linien für Schnitte/Ansichten. |
|
||||||
|
| 2D-Pläne | **SVG** + eigener Symbol-Renderer | Vektor, druckbar, exportierbar (DXF/PDF). |
|
||||||
|
| Frontend | **React + TypeScript + Vite** | Schnelles HMR, großes Ökosystem. |
|
||||||
|
| Worker-Bridge | **Comlink** | Schwere Geometrie im Web Worker, UI bleibt flüssig. |
|
||||||
|
| State | **Zustand** o.ä. | Passt zum komplexen Dokumentmodell. |
|
||||||
|
| Persistenz (später) | Postgres + Object Storage | Versionierbare Projekte. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Datenmodell (grob)
|
||||||
|
|
||||||
|
Vectorworks-orientiert: **Design Layers** (Modell-Eingabe) und **Drawing Layers**
|
||||||
|
(abgeleitete Ausgabe). Bauteile beziehen ihren Aufbau aus **Components** (verwaltet
|
||||||
|
im Component Manager).
|
||||||
|
|
||||||
|
```
|
||||||
|
Project
|
||||||
|
├─ Resources // verwaltete Bibliotheken (Vectorworks-Stil)
|
||||||
|
│ ├─ LineStyles[] // Line Manager: { id, name, weight, color, dash }
|
||||||
|
│ ├─ Hatches[] // Hatch Manager: { id, name, pattern, scale, angle, lineStyleId }
|
||||||
|
│ └─ Components[] // Component Manager: wiederverwendbare Baustoffe
|
||||||
|
│ Component { id, name, hatchId, texture3d, color, joinPriority }
|
||||||
|
│ // joinPriority: höher = verschneidet sich zuerst (geht durch)
|
||||||
|
├─ Grids (Achsraster) (optional)
|
||||||
|
├─ Types // mehrschichtige Aufbauten
|
||||||
|
│ ├─ WallType { id, name, layers: Layer[] }
|
||||||
|
│ └─ SlabType { id, name, layers: Layer[] }
|
||||||
|
│ Layer = { componentId, thickness } // Priorität liegt am Component
|
||||||
|
│
|
||||||
|
├─ DesignLayers ("Ebenen") // hier wird modelliert & 2D gezeichnet
|
||||||
|
│ DesignLayer { id, name, elevation(z), height(Δz),
|
||||||
|
│ defaultLineStyle, defaultHatch,
|
||||||
|
│ elements: Wall | Door | Window | Slab | Stair | Roof | Space ,
|
||||||
|
│ draw2d: Line | Polyline | Rect | Circle | Arc | Text }
|
||||||
|
│ Wall { axis, wallTypeId, height }
|
||||||
|
│ Door { hostWall, position, width, height, swing, symbolId }
|
||||||
|
│ Window { hostWall, position, width, height, sill }
|
||||||
|
│ Slab { boundary, slabTypeId } · Space { boundary, name } // Fläche auto
|
||||||
|
│
|
||||||
|
└─ DrawingLayers ("Zeichnungsebenen") // abgeleitete Ausgabe
|
||||||
|
DrawingLayer { id, name,
|
||||||
|
type: plan | section | elevation | drawing,
|
||||||
|
cutHeight(z), // bei plan: Schnitthöhe
|
||||||
|
sectionLine, // bei section
|
||||||
|
sourceDesignLayers[],
|
||||||
|
detailLevel: coarse|medium|fine,
|
||||||
|
scale, styleOverrides, dims[], labels[], annotations[] }
|
||||||
|
```
|
||||||
|
3D *und* jede Drawing Layer werden **aus den Design Layers abgeleitet**. `cutHeight`,
|
||||||
|
`detailLevel`, `Styles` und `Component`-Eigenschaften steuern, *wie* abgeleitet wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Die harten Risiken (früh angehen)
|
||||||
|
|
||||||
|
1. **Wand-Verbindungen / Cleanup** ⚠️ — Wo Wände aufeinandertreffen, müssen sie sauber verschneiden. **L-Ecken-Gehrung: ✅ erledigt.** Offen & berüchtigt schwer: **Prioritäts-basierte T-/X-Stöße bei mehrschichtigen Wänden** (Beton durch, Putz verbindet seitlich, geht nicht durch — siehe 2d). → stufenweise auf der Gehrung aufbauen.
|
||||||
|
2. **Gehostete Öffnungen** — Tür/Fenster muss synchron mit der Wand bleiben (verschieben, schneiden). → Saubere Host-Beziehung im Modell.
|
||||||
|
3. **Symbolischer Plan-Renderer** — normgerechte Darstellung (Schwenkbögen, Schraffuren der geschnittenen Bauteile, Lauflinien). → Eigenes Regelwerk; früh prototypen.
|
||||||
|
4. **Schnitt-/Ansichts-Projektion (HLR)** durch ganzes Gebäude — Performance. → Worker + Caching.
|
||||||
|
5. **IFC-Treue** — verlustarmer Round-Trip. → Früh mit echten IFC-Dateien testen.
|
||||||
|
6. **Geschoss-übergreifende Elemente** (Treppen, Lufträume).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Phasen-Roadmap
|
||||||
|
|
||||||
|
### Phase 0 — Spike: das größte Risiko zuerst
|
||||||
|
**Ziel:** Beweisen, dass der symbolische Plan-Pfad im Browser funktioniert.
|
||||||
|
- Eine Wand zeichnen, eine Tür platzieren → Öffnung wird geschnitten (3D).
|
||||||
|
- Daraus **Grundriss als SVG** generieren: Wand-Schnittlinien + Tür-**Schwenkbogen**.
|
||||||
|
- ✅ *Erfolg:* sauberer, schöner Grundriss-Ausschnitt aus einem semantischen Modell.
|
||||||
|
|
||||||
|
### Phase 1 — MVP: durchgehende Wohnbau-Scheibe
|
||||||
|
- **Dokumentmodell (Vectorworks-Stil):** Design Layers ("Ebenen") + Drawing Layers
|
||||||
|
("Zeichnungsebenen", Typ plan/section/elevation, mit `cutHeight`).
|
||||||
|
- **Resource Manager:** Line Manager, Hatch Manager, Component Manager (mit
|
||||||
|
`joinPriority`, Schraffur, 3D-Textur).
|
||||||
|
- **Wände** mehrschichtig (✅) mit Eck-Gehrung (✅); **Prioritäts-T-Stöße** (Risiko #1).
|
||||||
|
- **Türen & Fenster** gehostet in Wänden (Risiko #2).
|
||||||
|
- **2D-Zeichnen:** Linie, Polylinie, Rechteck, Kreis, Bogen mit Stilen.
|
||||||
|
- **Decken/Böden** (Slabs); 3D-Viewport + **live Grundriss**, Basis-Bemaßung.
|
||||||
|
- → aus DOSSIER (§11): Wand-Referenzlage (mid/left/right), Öffnungs-Detailgrad mit Dokument-Override, Decken-Aussparungen, Element-Übersicht (BIM-Tree).
|
||||||
|
- ✅ Ein einfaches Haus modellieren → saubere Pläne pro Geschoss.
|
||||||
|
|
||||||
|
### Phase 2 — Vollständiger Bauteil-Satz Wohnbau
|
||||||
|
- **Treppen** (mit Lauflinie im Plan), **Dächer**, Geländer.
|
||||||
|
- **Räume/Spaces** mit automatischer Flächenberechnung & Raumstempel.
|
||||||
|
- Stützen/Unterzüge (falls nötig).
|
||||||
|
- Materialien & einfache Visualisierung.
|
||||||
|
- → aus DOSSIER (§11): Treppen-Typen (gerade/L/Wendel) + geschossübergreifend, Dach-Typen (Pult/Sattel/Walm/Mansarde), Stützen-Profile, **SIA-416-Räume** + CSV, Raumstempel-Builder, Stil-Kataloge (Wände/Öffnungen).
|
||||||
|
|
||||||
|
### Phase 3 — Plan-/Dokumentations-Modul ⭐ (Differenzierung)
|
||||||
|
**Hier gewinnen wir. Maximale Politur.**
|
||||||
|
- **Grundrisse, Schnitte (HLR, Risiko #4), Ansichten.**
|
||||||
|
- **Automatische Bemaßung** (Außenketten, Achsen, Öffnungen) + manuelle.
|
||||||
|
- Schraffuren geschnittener Bauteile, Raumstempel, Beschriftungen, Symbole.
|
||||||
|
- **Plansätze/Sheets** mit Titelblock, Maßstäben, Layout.
|
||||||
|
- Schöne Typografie & Linienführung — genau das, was die Großen vermasseln.
|
||||||
|
- → aus DOSSIER (§11): **Massstab pro Viewport** (Auto-DPI, Plotweight-/Schraffur-Skalierung), Section-Style (3D-Schnittflächen), Ausschnitte (View-Snapshots) + Layer-Kombinationen, Kamera-Presets (Kardinal/Iso, **Norden-Rotation**), Detail-Bindung an Ausschnitt, Multi-Page-PDF @DPI, regelbasierte Overrides, Rich-Text-Annotationen.
|
||||||
|
|
||||||
|
### Phase 4 — Interop (Import/Export)
|
||||||
|
- **Import:** **DWG/DXF** (2D-Pläne/Bestand), **IFC** (BIM-Bestand), **STL/OBJ**
|
||||||
|
(Mesh-Referenzmodelle), **XYZ** (Punktwolken aus Vermessung).
|
||||||
|
- **Export:** **IFC** (Brücke zu Revit/ArchiCAD), **DWG/DXF**, **glTF/OBJ**.
|
||||||
|
- Round-Trip-Tests mit echten Dateien (Risiko #5).
|
||||||
|
- → aus DOSSIER (§11): **Swisstopo-Import** (swissBUILDINGS3D / swissALTI3D / SWISSIMAGE, LV95↔WGS84) ⭐ CH, OSM-Overpass-Kontext, Terrain-Mesh-Generator.
|
||||||
|
|
||||||
|
### Phase 5 — Persistenz & Konten
|
||||||
|
- Accounts, Projekte speichern/laden, Versionierung, Auto-Save.
|
||||||
|
- → aus DOSSIER (§11): Projekt-Persistierung auf Browser-Storage migrieren (IndexedDB statt doc.Strings); Presets/Favoriten cross-projekt (LocalStorage, Export/Import).
|
||||||
|
|
||||||
|
### Phase 6 — Export & Kollaboration
|
||||||
|
- Export: **PDF**, **DXF** (Pläne); **IFC**, **glTF** (3D).
|
||||||
|
- Teilen per Link, Kommentare, später Echtzeit-Co-Editing.
|
||||||
|
|
||||||
|
### Phase 7 — Produktisierung
|
||||||
|
- Performance-Härtung (große Modelle), Onboarding, Pricing, PWA/Offline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Offene Fragen
|
||||||
|
- Booleans: OpenCascade (exakt) vs. Manifold (schnell) — Entscheidung in Phase 0.
|
||||||
|
- Welche Normen für Plandarstellung (SIA / DIN / …)? → beeinflusst Symbolik. *(Hinweis: User ist in der Schweiz → SIA prüfen.)*
|
||||||
|
- Wie viel Statik/Bauphysik (gar nicht / später)?
|
||||||
|
- Pricing-Modell (Freemium, pro Seat?).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Nächster konkreter Schritt
|
||||||
|
**Phase 0 starten:** Projekt scaffolden + Spike bauen — Wand + Tür mit geschnittener Öffnung → schöner Grundriss-Ausschnitt als SVG (mit Schwenkbogen). Das entschärft Risiko #1–#3 (Modell, Hosting, Symbolik) auf einmal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Leitentscheidungen & UI-Architektur
|
||||||
|
|
||||||
|
### 10a. Leitentscheidungen aus der Recherche (Details in `docs/`, Index `docs/README.md`)
|
||||||
|
1. **Pure-Ableitungs-Architektur** als Fundament — ein semantisches Modell, alle Sichten abgeleitet, Darstellung erst beim Rendern (Store + Undo früh).
|
||||||
|
2. **OCCT/replicad im Web Worker** früh als Spike — kritischer Pfad für Schnitt/Ansicht (HLR), exakte Wand-Booleans und IFC. WASM-Größe + HLR-Kosten (pro Ansicht cachen) validieren.
|
||||||
|
3. **Component-getriebene Prioritäts-Verschneidung** (`joinPriority` am Component): höchstes gemeinsames Material läuft durch, Rest mitert. 2D-Plan analytisch, exakte 3D-Booleans im Worker.
|
||||||
|
4. **SVG/Paper-Space-Maßstabsmodell** — Strichstärke/Text/Hatch in mm, `dpi=96·devicePixelRatio`, ein Serializer für Bildschirm + PDF + DXF.
|
||||||
|
5. **CH-Spezifika als Differenzierer** ohne Backend — SIA-416 (reine Logik) + serverloser Swisstopo-Flow (CORS-offen, Parzelle/EGRID, Norden-Rotation, Origin-Shift).
|
||||||
|
|
||||||
|
### 10b. Panel-System (dockbar, Tabs, erweiterbar) — NEU
|
||||||
|
- **Docks links & rechts**; Panels in **Tab-Strips** gruppierbar (z. B. Zeichnungsebenen & Ebenen als Tabs eines Docks).
|
||||||
|
- **Panel-Registry** → eigene Panels und spätere **Plugins** registrieren sich und erscheinen als Panel.
|
||||||
|
- **Verschiebbar & floatend (am Tab gegriffen):** Ein Panel wird **am Tab selbst** (im Tab-Strip) gezogen → innerhalb des Docks **umsortieren**, ins andere Dock ziehen, oder aus dem Dock lösen. Andocken am **linken/rechten Rand** (Andock-Zonen beim Ziehen hervorheben); wird nicht angedockt, **schwebt** das Panel als freies (verschieb- und größenveränderbares) **Floating-Fenster** über dem Arbeitsbereich. Float-Position/Größe + Dock-Zustand werden gespeichert.
|
||||||
|
- **Fenster-Layouts speicherbar** (localStorage, benannte Layouts; Standard-Layout als Default).
|
||||||
|
- Der **Ressourcen-Manager** wird ebenfalls ein Panel (rechtes Dock).
|
||||||
|
- Pro Layer-Panel oben ein **Anzeige-Modus-Dropdown**: *nur aktive · alle anzeigen · aktive + andere grau* (DOSSIER/Vectorworks „Layer Options") — wirkt auf Plan & 3D.
|
||||||
|
|
||||||
|
### 10c. Plan-Navigation
|
||||||
|
- Grundriss/Schnitt/Ansicht: **Pan** (ziehen), **Zoom** (Mausrad zum Cursor), **Einpassen** — analog zum 3D-Viewport. SVG-`viewBox`-Transform.
|
||||||
|
|
||||||
|
### 10d. Backend & Kollaboration (Details: `docs/backend.md`)
|
||||||
|
- **Jetzt:** client-only (IndexedDB + Datei-Export/Import), kein Server. Modell JSON-serialisierbar + Edits als Operationen → **CRDT-fähig** halten.
|
||||||
|
- **Phase 5:** **Supabase self-hosted** (Docker Compose: Postgres + Auth + Storage) für Konten/Projekte/Dateien.
|
||||||
|
- **Phase 6:** **Yjs + Hocuspocus** (CRDT-Sync-Container, persistiert nach Postgres) für Echtzeit-Kollaboration. Alles self-hosted.
|
||||||
|
- Empfehlung: Stack **noch nicht** aufsetzen (würde den Modellierer ausbremsen); Weiche ist gestellt, Einführung additiv.
|
||||||
|
|
||||||
|
### 10e. Top-Bar & Footer/Status-Leiste (Vectorworks-Stil) — NEU
|
||||||
|
- **Top-Bar (Oberleiste, wie DOSSIER `toolbar.py`/`ToolbarApp.jsx`):** Ansichts-Umschalter (Grundriss/Perspektive/Schnitt/Ansicht), Render-/Darstellungsmodus, aktives Geschoss + aktive Ebene, Snapping-Schalter, Massstab, Werkzeug-Kontext, Einstellungen/Ressourcen.
|
||||||
|
- **Footer/Status-Leiste (wie Vectorworks unten):** Cursor-Koordinaten **X/Y/Z**, Einheit, aktueller **Massstab** (1:N) + **Zoom %**, aktives Geschoss/Ebene, **Snap-Status**, kurzer Werkzeug-Hinweis links.
|
||||||
|
- Beide an das Panel-/Dock-Layout angedockt; Inhalte aus dem Modell abgeleitet.
|
||||||
|
|
||||||
|
### 10f. Maus-Interaktion & Kontextmenü — NEU
|
||||||
|
- **Maus-Schema:** **Mitte = navigieren** (Plan: Pan · 3D: Orbit, `Shift`+Mitte: Pan) · **Links = Auswahl** (Einzelklick + **Markierrahmen/Aufziehrahmen** für Mehrfachauswahl im 2D) · **Rechts = Kontextmenü** · **Rad = Zoom** (zum Cursor).
|
||||||
|
- Marquee: Aufziehen von links→rechts = nur vollständig umschlossene Elemente; rechts→links = auch berührte (wie CAD-üblich).
|
||||||
|
- **Eigenes Kontextmenü-System** (gestylt, dunkel, wiederverwendbar) — kein Browser-Menü.
|
||||||
|
- **Ebenen-Kontextmenü 1:1 wie DOSSIER** (Einträge aus `layers_panel.py`/`DrawingLevelsApp.jsx` übernehmen) — auch auf Zeichnungsebenen.
|
||||||
|
- Kontextmenü generisch, damit Plan-Elemente, Panels & spätere Plugins eigene Einträge registrieren können.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Aus DOSSIER übernehmen — Backlog
|
||||||
|
|
||||||
|
Konkret im DOSSIER-Rhino-Plugin umgesetzte Features, die sich für den Standalone-Port lohnen. Bereits abgedeckt (Layer-Modell, mehrschichtige Wände, Prioritäts-Stöße, Component-/Line-/Hatch-Manager, Ansichtstypen, Detailgrade) ist hier **nicht** erneut gelistet — nur das Zusätzliche. Aufwand: S/M/L. Phase verweist auf §7.
|
||||||
|
|
||||||
|
> **⭐ CH-Schätze (Schweiz-spezifisch, kaum woanders verfügbar):**
|
||||||
|
> - **Swisstopo-Geodaten** — swissBUILDINGS3D (3D-Bestand), swissALTI3D (präzises Höhenmodell), SWISSIMAGE (10-cm-Orthofoto), offene STAC-APIs ohne Auth, inkl. **LV95↔WGS84**-Transformation. Echter Standort-Kontext per Knopfdruck statt manuellem CAD-Import.
|
||||||
|
> - **SIA-416-Flächen** — Raum-Klassifikation (HNF/NNF/VF/FF/GF/AGF) mit automatischer Bilanz + CSV/Excel-Export. Pflicht für CH-Energie-/Flächennachweise.
|
||||||
|
> - **Norden-Rotation** bei Kamera-Presets — Georeferenzierung passend zu Swisstopo/swissBUILDINGS.
|
||||||
|
|
||||||
|
### Bauteile
|
||||||
|
|
||||||
|
| Feature | Nutzen | Aufwand | Phase |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Wand-Referenzlage (mid/left/right) | Achse intuitiv auf Aussenkante/Mitte legen; hilft beim Import fremder Dateien | S | 1 |
|
||||||
|
| Öffnungs-Detailgrad + Dokument-Override (`aktive_darstellung`) | LoD je Massstab (1:500 Rechteck → 1:50 Glas/Sims) global umschaltbar; kritisch für Mixed-Scale | M | 2 |
|
||||||
|
| Fenster/Tür mit Rahmen, Brüstung, Sims, Glas, Flügelzahl | Öffnung als vollwertiges Bauteil statt nur Loch; Render-Realismus | M | 2 |
|
||||||
|
| Tür-Schwenkbogen (Öffnungswinkel + Anschlagseite) | Öffnungsbahnen für Möblierung/Kollision; Standard-Plansymbol | M | 2 |
|
||||||
|
| Decken-Aussparungen (Treppenauge, Schächte, Kamin) | Konstruktiv echte Deckenöffnungen, nicht nur sichtbar | M | 1–2 |
|
||||||
|
| Decken UK/OK-Override | Abhängungen, schräge Brüstungen, abweichende Raumhöhen | S | 1 |
|
||||||
|
| Treppen-Typen gerade/L/Wendel + Stufen/Lauflinie/Podest | Volle Vertikalerschliessung, volumetrisch korrekt, Plan-Symbole | M | 2 |
|
||||||
|
| Treppe geschossübergreifend (`geschoss_end`, Höhen-Override) | Atrien, Rampen, Mehr-Geschoss-Läufe (Risiko #6) | S | 2 |
|
||||||
|
| Treppen-2D-Symbol mit Auf-/Abpfeil + Schnitt | Normgerechtes Plansymbol (Richtung, Stufenzahl, Lauflinie) | M | 2 |
|
||||||
|
| Dach-Typen Pult/Sattel/Walm/Mansarde + Neigung(en) | 3D-Volumen mit Gefälle, Kubatur, Material; Mansarde später (L) | M–L | 2–3 |
|
||||||
|
| Stützen-Profile (Quadrat/Rechteck/Rund/I/Rohr) + Drehung | Beton- und Stahltragwerk mit echtem Querschnitt | M | 2 |
|
||||||
|
| Träger achs-basiert, hängt unter Decken-OK | Unterzug folgt Deckenoberkante, weniger Fehler bei Updates | S | 2 |
|
||||||
|
| **SIA-416-Räume** (HNF/NNF/VF/FF/GF/AGF) + Bilanz-CSV ⭐ | CH-Flächennachweis, Excel-Export | M | 2 |
|
||||||
|
| Raum-Stempel-Builder (Drag-&-Drop-Felder) + Fläche-Rundung + Personen | Projekt-eigene Stempel-Layouts ohne Code; lesbare Listen; Brandschutz | M | 2–3 |
|
||||||
|
| Element-Übersicht (BIM-Tree Geschoss→Kind→Element, Suche, Zoom) | Inhaltsverzeichnis bei 100+ Elementen; Shift-Klick = Zoom | S | 1 |
|
||||||
|
| Stil-Kataloge Wände & Öffnungen (Presets) | Standard-Typen 1-Klick; globaler Stilwechsel | M | 2 |
|
||||||
|
| Grip-Editing (Wand-Endpunkte, Schnitt-Symbole im Plan) | Direktes Ziehen statt Dialog; 2D/3D-Sync; wichtig im Browser | L | 3–4 |
|
||||||
|
|
||||||
|
### Darstellung / Ressourcen
|
||||||
|
|
||||||
|
| Feature | Nutzen | Aufwand | Phase |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Regelbasierte Overrides (Layer-/Tag-/Name-Regel, Priorität, Templates) | Automatische Farb-/Strich-/Linientyp-Anpassung; wiederverwendbar (vertieft §2c „Overrides") | M | 3 |
|
||||||
|
| Section-Style für 3D-Schnittflächen (Schraffur + Schnittkante/Silhouette) | 3D-Schnitt-Rendering im Viewport, nicht nur 2D-Plan | M | 4 |
|
||||||
|
| Massstabs-abhängige Linientyp-/Plotweight-Skalierung | Linientypen & Strichstärken bei 1:N korrekt sichtbar; PDF-Treue | M | 3–4 |
|
||||||
|
| Material-Bibliothek mit PBR (Rauheit/Reflexion/Transparenz) + Templates | Vertieft Component-Manager um Renderqualität; Seeds Beton/Holz/Dämmung | M | 3 |
|
||||||
|
| Rich-Text-Annotationen (Bold/Italic/Hoch-/Tiefstellung, Maskierung, Rahmen) | Bemaßungs-Indizes, formatierte Beschriftungen auf Canvas | M | 3 |
|
||||||
|
| LoD-bewusste Stil-UI (zeigt nur passende Controls je Geometrie-Typ) | Weniger kognitive Last (keine Füll-Optionen bei 3D-Auswahl) | S | 1 |
|
||||||
|
|
||||||
|
### Pläne / Output
|
||||||
|
|
||||||
|
| Feature | Nutzen | Aufwand | Phase |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Massstab pro Viewport** mit Auto-DPI + Schraffur-/Strich-Skalierung | Exakte Masse & lesbare Strichstärken ohne manuelle Kalibrierung | M | 3 |
|
||||||
|
| Ausschnitte / View-Snapshots (Kamera + Darstellung + Massstab) | Navigation über 50+ Ansichten; ersetzt Ordner-Wildwuchs (vertieft §2c) | M | 3 |
|
||||||
|
| Layer-Kombinationen als Presets (live oder eingefroren) | Bauphasen/Varianten/MEP per Klick statt manuellem Toggling (vertieft §2c) | S | 3 |
|
||||||
|
| Kamera-Presets (Kardinal N/O/S/W, Iso-Oktanten, **Norden-Rotation** ⭐) | Schnelle Ansichtswechsel; Georeferenzierung für Swisstopo | S | 3 |
|
||||||
|
| Detail↔Ausschnitt-Bindung + „Alle aktualisieren" | Titelblock/Detail synchron umbenennen; 1-Klick-Sync aller Schnitte | M | 3 |
|
||||||
|
| Multi-Page-PDF-Export @DPI (Vektor) | Druckfertige Plansätze — Kern-Output (ergänzt Phase-3-Sheets) | M | 3 |
|
||||||
|
| 9-Punkt-Bemaßung/Objekt-Info (lesen + verschieben/skalieren/rotieren) | Direktes Dimensionieren ohne Properties-Panel | M | 3 |
|
||||||
|
|
||||||
|
### Kontext / Daten
|
||||||
|
|
||||||
|
| Feature | Nutzen | Aufwand | Phase |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Swisstopo-Import** (swissBUILDINGS3D/ALTI3D/SWISSIMAGE) ⭐ CH | Authentischer Standort-Kontext, offene APIs, kein Auth | M | 4 |
|
||||||
|
| LV95↔WGS84-Transformation ⭐ CH | Karten-Anzeige + präzise CH-Koordinaten; Formeln direkt portierbar | S | 4 |
|
||||||
|
| OSM-Overpass-Import (Strassen/Gebäude/Wasser/Grün, 7 Kategorien) | Weltweiter, kostenloser Kontext; ergänzt Swisstopo | M | 4 |
|
||||||
|
| Terrain-Mesh-Generator (Mesh/TIN/NURBS-Patch/Höhenlinien, Volumen für Schnitt) | Geländemodell aus Höhendaten; Section-Cut-Füllung; portierbar zu Three.js | L | 4 |
|
||||||
|
| Auto-Zoom auf Import + Nullpunkt-Verschiebung (LV95→0/0/0) | Modellierungsgenauigkeit trotz Millionen-Koordinaten; UX-Standard | S | 4 |
|
||||||
|
| Projekt-Persistierung browser-nativ (IndexedDB statt doc.Strings) | Projekt kapselt seine Einstellungen lokal | M | 5 |
|
||||||
|
| Cross-Projekt-Presets (LocalStorage-Favoriten, Export/Import, Team-Sharing) | Einmal speichern, überall nutzen | S | 5 |
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
# Dokumentation — Standalone Browser-BIM (cad)
|
||||||
|
|
||||||
|
> Stand: 2026-06-29 · Übergeordnet: [ROADMAP.md](../ROADMAP.md) (Vision & Phasen) ·
|
||||||
|
> [CONVENTIONS.md](../CONVENTIONS.md) (Konventionen) · [ARCHITECTURE.md](../ARCHITECTURE.md).
|
||||||
|
|
||||||
|
Dieses Verzeichnis bündelt die Recherche- und Design-Dokumente für `cad`, die
|
||||||
|
eigenständige Browser-Variante des DOSSIER-Rhino-Plugins (React + TypeScript +
|
||||||
|
Three.js + SVG, alles client-side). **Leitprinzip aller Dokumente:** ein
|
||||||
|
semantisches Modell ist die einzige Wahrheit; jede Sicht (3D, Grundriss, Schnitt)
|
||||||
|
wird **abgeleitet**, Darstellung erst beim Rendern angewandt. Bezeichner im Code
|
||||||
|
englisch (Vectorworks-Terminologie), Prosa deutsch, Einheiten intern in Metern.
|
||||||
|
|
||||||
|
Die Dokumente sind in vier Gruppen geordnet: **Tech** (Bibliotheken/Kernel),
|
||||||
|
**Architektur/Design** (Aufbau & Bauteile), **UX** (Oberfläche & Interaktion),
|
||||||
|
**Swisstopo/SIA** (CH-Geodaten & Flächenstandards).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech — Technologie- & Bibliotheksauswahl
|
||||||
|
|
||||||
|
### [research/tech-selection.md](research/tech-selection.md)
|
||||||
|
Evaluiert den kompletten Client-Stack für ein serverloses BIM-Werkzeug und
|
||||||
|
empfiehlt **`replicad`** (idiomatische TS-Schicht über `opencascade.js`/OCCT, MIT)
|
||||||
|
als primären B-Rep-Kernel im Web Worker, ergänzt durch **`Manifold`** (Apache-2.0)
|
||||||
|
für schnelle, robuste Mesh-Booleans auf Importgeometrie — denn nur ein echter
|
||||||
|
B-Rep-Kernel liefert exakte 2D-Ableitungen, und genau das löst replicads
|
||||||
|
`drawProjection` (OCC-HLR, `{visible, hidden}`-Kanten direkt im Browser). Weitere
|
||||||
|
Wahl: Import via **web-ifc + Fragments** (IFC), `dxf-parser` (DXF) und
|
||||||
|
`libredwg-web` (DWG, aber **GPL-3.0 → vorab klären/kapseln**); Vektor-Export über
|
||||||
|
**`svg2pdf.js` + `jsPDF`** (PDF) und **`@tarikjabiri/dxf`** (echte Hatch-Entities);
|
||||||
|
Schraffuren als SVG-`<pattern>` mit `userSpaceOnUse` (maßstabskorrekt); Rendering
|
||||||
|
über **`three/webgpu`** mit automatischem WebGL2-Fallback. Top-Risiken: DWG-Lizenz,
|
||||||
|
OCCT-WASM-Größe, HLR-Kosten (pro Ansicht cachen), WebGPU vor Migration benchmarken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur/Design — Aufbau, Datenmodell & Bauteile
|
||||||
|
|
||||||
|
### [../ARCHITECTURE.md](../ARCHITECTURE.md)
|
||||||
|
Die übergreifende Standalone-Architektur und die systematische Übersetzung jedes
|
||||||
|
DOSSIER-Konzepts in ein Browser-Äquivalent (30-zeilige **Rhino→Browser-Mapping-
|
||||||
|
Tabelle**). Kern: das semantische `Project` (JSON) als einzige Wahrheit mit pure
|
||||||
|
`derive()` zu Scene3D/Plan/Section; ein **Zwei-Achsen-Datenmodell**
|
||||||
|
(`drawingLevels` × `layers`) plus `Resources`/`WallType`/`Element`/`Sheet`; ein
|
||||||
|
**Zustand-Store** ersetzt DOSSIERs `sc.sticky`-Bus, **`.cad.json`** (File System
|
||||||
|
Access API) + IndexedDB-Autosave ersetzen `doc.Strings`, und ein **Immer-Patch-
|
||||||
|
Undo/Redo** eliminiert die Cache-Stale-Bugs strukturell. Ziel-Repo-Struktur mit
|
||||||
|
**kleinen Bauteil-Modulen** statt des 7244-LOC-`elemente.py`-Monolithen; Rendering
|
||||||
|
über einen `THREE.Group`-Baum, der den Ebenen-Baum spiegelt.
|
||||||
|
|
||||||
|
### [design/elements.md](design/elements.md)
|
||||||
|
Legt **Daten, Generierung (3D + Plan) und Grip-Editing pro Bauteil** fest. Wichtigste
|
||||||
|
Empfehlung: die **Prioritäts-T-/X-Verschneidung mehrschichtiger Wände** (Backbone-
|
||||||
|
Algorithmus, Port von `_t_junction_layer_overrides`) — das höchstpriorisierte
|
||||||
|
gemeinsame Material läuft durch, der Rest mitert an; Priorität sitzt am **Component**
|
||||||
|
(`joinPriority` als Daten, nicht Hardcode). Deckt zudem gehostete Öffnungen mit
|
||||||
|
LoD-Stufen (`_OEFF_PIECE_DEFS`), Decken mit Aussparungen, Treppen (gerade/L/Wendel,
|
||||||
|
geschossübergreifend, normgerechtes 2D-Symbol), Dächer, Tragwerk und **SIA-416-Räume**
|
||||||
|
(Shoelace-Fläche, Stempel, Färbung über Override-Preset) ab; das `Tool`-Interface +
|
||||||
|
Snap-Engine ersetzt DOSSIERs Rhino-Command-Aliases.
|
||||||
|
|
||||||
|
### [design/plans-output.md](design/plans-output.md)
|
||||||
|
Der Weg zu **schönen, normgerechten, druckfertigen 2D-Plänen** (Vektor-PDF). Zentrale
|
||||||
|
Erkenntnis: Ansichten = Kamera + optionaler Schnitt, und es gibt **zwei Plan-Pfade**
|
||||||
|
(symbolischer Grundriss aus Parametern vs. Schnitt/Ansicht via **HLR im Worker**,
|
||||||
|
gecacht). Empfiehlt SVG/Paper-Space als Maßstabsmodell — Strichstärke/Schraffur sind
|
||||||
|
direkt in mm definiert (`dpi = 96·devicePixelRatio`, Hatch-Faktor `sqrt(N)/10`), was
|
||||||
|
DOSSIERs fragiles Plotweight-Rescaling überflüssig macht. Behandelt außerdem
|
||||||
|
Ausschnitte/View-Snapshots, Layer-Kombinationen, Kamera-Presets + Norden-Rotation,
|
||||||
|
Bemaßung sowie Sheets + Vektor-PDF-Export (`svg2pdf.js`/`jsPDF`, `PAPER_MM`).
|
||||||
|
|
||||||
|
### [design/resources-graphics.md](design/resources-graphics.md)
|
||||||
|
Die **Stil-Schicht**: verwaltete Ressourcen-Bibliotheken (Component-/Hatch-/Line-
|
||||||
|
Manager, alles per id referenziert), die `resolveStyle`-Kette
|
||||||
|
(ByLayer → Element-Style → Override) und die **regelbasierte Overrides-Engine**.
|
||||||
|
Schlüssel-Empfehlung: Overrides als **reine Render-Reads** modellieren (kein
|
||||||
|
Backup/Restore wie in DOSSIER, da nichts mutiert wird) — inklusive eines
|
||||||
|
**SIA-416-Presets** statt hartcodierter Färbung. Ergänzt Symbol-Bibliothek,
|
||||||
|
Rich-Text-Annotationen, den LoD-Resolver (`resolveDetail`) und den Section-Style für
|
||||||
|
geschnittene Bauteile; eine Tabelle zeigt, was der Browser hier gegenüber DOSSIER
|
||||||
|
vereinfacht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX — Oberfläche, Interaktion & gefühlte Geschwindigkeit
|
||||||
|
|
||||||
|
### [research/ux-patterns.md](research/ux-patterns.md)
|
||||||
|
Untersucht UX-Muster moderner Browser-CAD/BIM-Tools (Arcol, Snaptrude, TestFit,
|
||||||
|
Onshape, Vectorworks, Figma) und leitet **priorisierte Leitplanken** ab. Empfehlung
|
||||||
|
für die Grundstruktur: eine feste, Figma-artige **3-Zonen-Shell**
|
||||||
|
(Navigator/Viewport/Inspector) — explizit gegen Paletten-Wildwuchs —, mit
|
||||||
|
Vectorworks-Navigation-Tabs für unsere zwei Achsen und einem zwei/drei-spaltigen
|
||||||
|
Resource-Manager als Vorbild. Größte Differenzierungs-Hebel laut Doku:
|
||||||
|
**Snapping/Inferencing** im Onshape-Stil (Vertex-Highlights, Achsenlinien, Shift
|
||||||
|
unterdrückt) und **Grip-Editing über Sicht-Grenzen** (Schnittlinie im Plan ziehen);
|
||||||
|
dazu perceived-performance-Muster (Skeletons, optimistic UI, 150-ms-Delay-then-show),
|
||||||
|
eine Command-Palette (Cmd/Ctrl-K) und learn-by-doing-Onboarding am Sample-Projekt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Swisstopo/SIA — Schweizer Geodaten & Flächenstandards
|
||||||
|
|
||||||
|
### [research/swisstopo-sia.md](research/swisstopo-sia.md)
|
||||||
|
Dokumentiert die **live getesteten** geo.admin.ch-Dienste und die SIA-Flächenlogik.
|
||||||
|
Überraschendster Befund: **alles ist ohne eigenen Backend-Proxy nutzbar** — alle vier
|
||||||
|
Hosts senden `access-control-allow-origin: *`, und der Height-Service antwortet
|
||||||
|
faktisch frei. Schlüssel fürs Browser-Gelände-Mesh ist **swissALTI3D als Cloud-
|
||||||
|
Optimized GeoTIFF** (Range-Requests via `geotiff.js`, kein Full-Download); die
|
||||||
|
**Parzelle** kommt direkt als LV95-Polygon + EGRID aus dem Identify-Service. Empfiehlt
|
||||||
|
einen konkreten Library-Satz (`proj4`, `geotiff`, `3DTilesRendererJS`/`loaders.gl`)
|
||||||
|
und ordnet die Umsetzung in ROADMAP-Phasen ein (Phase 2 SIA-Räume = reine Logik →
|
||||||
|
Phase 4a Koordinaten → 4b Gelände/Orthofoto → 4c Nachbargebäude). SIA-Teil:
|
||||||
|
verifizierte SIA-416-Formeln, DOSSIERs SIA-Logik 1:1 portierbar (Shoelace,
|
||||||
|
`compute_sia_bilanz`, CSV mit BOM); Origin-Shift (LV95 → 0/0/0) ist Pflicht wegen
|
||||||
|
float32-Jitter, Caching über IndexedDB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top-5 Querschnitts-Empfehlungen für die ROADMAP
|
||||||
|
|
||||||
|
Diese fünf Punkte tauchen in mehreren Dokumenten auf und sollten die ROADMAP-Planung
|
||||||
|
und Priorisierung leiten:
|
||||||
|
|
||||||
|
1. **Pure-Ableitungs-Architektur als unverhandelbares Fundament** — ein
|
||||||
|
semantisches Modell, alle Sichten abgeleitet, Darstellung erst beim Rendern.
|
||||||
|
Trägt ARCHITECTURE.md, beide Plan-/Stil-Designs und die UX-Doku (billiger
|
||||||
|
Split-View, optimistic Edits, kein Cache-Stale-/Override-Restore-Aufwand). Muss
|
||||||
|
früh stehen (Store + Undo, Phase 0–1), weil sie alles Spätere prägt.
|
||||||
|
|
||||||
|
2. **OCCT/replicad im Web Worker früh als Spike absichern** — der B-Rep-Kernel und
|
||||||
|
sein `drawProjection`-HLR sind der kritische Pfad für Schnitt/Ansicht (Risiko #4)
|
||||||
|
*und* für exakte Wand-Booleans (Risiko #1) *und* für IFC. WASM-Größe, HLR-Kosten
|
||||||
|
(pro Ansicht cachen) und das Worker-Pattern sollten vor Phase 3 mit einer echten
|
||||||
|
Szene validiert werden.
|
||||||
|
|
||||||
|
3. **Component-getriebene Prioritäts-Verschneidung (Backbone-T/X) als zentrales
|
||||||
|
Geometrie-Risiko** — `joinPriority` als Daten am Component; höchstes gemeinsames
|
||||||
|
Material läuft durch, Rest mitert. Verbindet elements.md + resources-graphics.md;
|
||||||
|
2D-Plan rein analytisch, exakte 3D-Booleans im Worker. Stufenweise umsetzen
|
||||||
|
(Risiko #1, Phase 1).
|
||||||
|
|
||||||
|
4. **SVG/Paper-Space-Maßstabsmodell + maßstabskorrekte Schraffuren durchgängig** —
|
||||||
|
Strichstärke/Text/Hatch in mm, `dpi = 96·devicePixelRatio`, Hatch `sqrt(N)/10`,
|
||||||
|
SVG-`<pattern>` mit `userSpaceOnUse`. Eliminiert DOSSIERs Plotweight-Rescaling und
|
||||||
|
speist denselben Serializer für Bildschirm, PDF und DXF (tech-selection +
|
||||||
|
plans-output + resources-graphics).
|
||||||
|
|
||||||
|
5. **Schweiz-Spezifika als Differenzierer ohne Backend-Last** — SIA-416-Bilanz
|
||||||
|
(reine Logik, Phase 2, ⭐) und der serverlose Swisstopo-Flow (CORS-offen,
|
||||||
|
COG-Terrain, Parzelle/EGRID, Norden-Rotation, Origin-Shift). Klein im Aufwand,
|
||||||
|
groß im CH-Marktwert; SIA-Färbung läuft über das Override-Preset, nicht über
|
||||||
|
Sonderpfade.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Backend & Kollaboration — Architekturentscheidung
|
||||||
|
|
||||||
|
> Stand: 2026-06-29 · Ziel: komplett self-hosted, kollaborations-offen
|
||||||
|
|
||||||
|
## Grundsatz
|
||||||
|
So lange wie möglich **client-only** bleiben; das Backend additiv einführen, ohne
|
||||||
|
den Kern umzubauen. Die Pure-Ableitungs-Architektur (ein serialisierbares Modell,
|
||||||
|
alle Sichten abgeleitet) ist bereits kollaborations-freundlich.
|
||||||
|
|
||||||
|
## Phasen
|
||||||
|
| Phase | Persistenz / Backend |
|
||||||
|
|---|---|
|
||||||
|
| **0–3** (Modellierer) | **Client-only**: IndexedDB + Datei-Export/Import (JSON). Offline-fähig (PWA möglich). Kein Server. |
|
||||||
|
| **5** (Konten/Persistenz) | **Supabase self-hosted** (Docker Compose): Postgres + Auth + Storage. Projekte, Versionen, Dateien (IFC/Pläne/Assets). Row-Level-Security pro Nutzer/Projekt. |
|
||||||
|
| **6** (Kollaboration) | **Yjs (CRDT)** + **Hocuspocus** Sync-Server (Container), persistiert Snapshots nach Postgres. Presence/Cursors. Optional Supabase-Realtime nur für leichte Broadcasts. |
|
||||||
|
|
||||||
|
## Warum Yjs/Hocuspocus statt reinem Supabase-Realtime
|
||||||
|
Gleichzeitiges Editieren eines strukturierten Dokuments braucht Konfliktauflösung
|
||||||
|
(CRDT). Yjs ist dafür Standard; Hocuspocus ist der self-hostbare Server dazu und
|
||||||
|
kann nach Postgres (Supabase) persistieren. Supabase-Realtime allein wäre nur
|
||||||
|
Pub/Sub ohne Merge-Semantik.
|
||||||
|
|
||||||
|
## Was wir JETZT schon richtig machen (damit Collab nicht blockiert)
|
||||||
|
- Dokumentmodell rein **JSON-serialisierbar**, keine Zyklen, stabile IDs.
|
||||||
|
- Edits immutable über `setProject` → später leicht auf Yjs-Doc abbildbar
|
||||||
|
(`Y.Map`/`Y.Array` je Sammlung: drawingLevels, layers, components, walls …).
|
||||||
|
- Kein Wahrheits-Zustand im Three.js-Scene-Graph oder im DOM — alles ableitbar.
|
||||||
|
- Ressourcen (Components/Hatches/Lines) als referenzierte Bibliotheken (IDs) →
|
||||||
|
gut mergebar.
|
||||||
|
|
||||||
|
## Self-hosted Stack (Skizze, Phase 5/6)
|
||||||
|
```
|
||||||
|
docker-compose:
|
||||||
|
supabase (postgres, gotrue auth, storage, kong gateway, studio)
|
||||||
|
hocuspocus (yjs websocket sync, persist -> postgres)
|
||||||
|
web (vite build, statisch via nginx/caddy)
|
||||||
|
```
|
||||||
|
Alles auf eigener Infrastruktur lauffähig; keine externe Cloud nötig.
|
||||||
|
|
||||||
|
## Offene Punkte
|
||||||
|
- Granularität der CRDT-Struktur (pro Sammlung vs. pro Element).
|
||||||
|
- Datei-Storage (Supabase Storage vs. S3-kompatibel/MinIO im selben Stack).
|
||||||
|
- Auth-Modell (E-Mail, OIDC/SSO fürs Büro).
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Kontextmenü & Anzeige-Modi — 1:1 wie DOSSIER
|
||||||
|
|
||||||
|
> Quelle: DOSSIER `src/components/ContextMenu.jsx` + `DrawingLevelsApp.jsx`/Ebenen-Panel.
|
||||||
|
> Maus-Schema (unsere Festlegung): **Mitte = navigieren** (Plan Pan / 3D Orbit, Shift+Mitte Pan) ·
|
||||||
|
> **Links = Auswahl** · **Rechts = Kontextmenü** · **Rad = Zoom**.
|
||||||
|
|
||||||
|
## ContextMenu-Komponente (generisch, wiederverwendbar)
|
||||||
|
`ContextMenu({ x, y, items, onClose, title })`
|
||||||
|
- **item**: `{ label, icon?, onClick, disabled?, danger?, shortcut?, divider? }`
|
||||||
|
- Fixed-Position mit Rand-Clamp (4px); min-width 200px; Radius 13px; weicher Schatten;
|
||||||
|
Mount-Animation `scale(.94) translateY(-5px)` 100ms; Item-Hover = `--accent-dim`;
|
||||||
|
`danger` = rote Schrift; `divider` = 1px Trenner; Titel oben (caps, 10px, muted).
|
||||||
|
- **Schließen:** Klick außerhalb · Escape · erneuter Rechtsklick · nach Item-Klick.
|
||||||
|
- z-index ~300 (unter Modals).
|
||||||
|
|
||||||
|
## Ebenen-Kontextmenü (Rechtsklick auf Ebenen-Zeile)
|
||||||
|
1. **Ebeneneinstellungen…** (`settings`) — Ebenen-Dialog *(Rhino-spez. → vorerst Stub)*
|
||||||
|
2. — Trenner —
|
||||||
|
3. **Sub-Ebene hinzufügen…** (`add`)
|
||||||
|
4. **Selektion hierher übertragen** (`move_down`) *(braucht Auswahl → später)*
|
||||||
|
5. — Trenner —
|
||||||
|
6. **Duplizieren** (`content_copy`) — Klon mit Suffix „ KOPIE"
|
||||||
|
7. **Eigenschaften kopieren** (`colorize`) — Farbe + Linienstärke
|
||||||
|
8. **Eigenschaften einfügen** (`format_paint`) — disabled wenn Clipboard leer
|
||||||
|
9. — Trenner —
|
||||||
|
10. **Löschen** (`delete`, danger) — disabled wenn ≤1 Ebene
|
||||||
|
Titel = Ebenenname/-code.
|
||||||
|
|
||||||
|
## Zeichnungsebenen-Kontextmenü (Rechtsklick auf Geschoss/Schnitt/Zeichnung)
|
||||||
|
1. **Einstellungen…** (`settings`)
|
||||||
|
2. — Trenner —
|
||||||
|
3. **Duplizieren** (`content_copy`) — Klon mit Suffix „ Kopie"
|
||||||
|
4. — Trenner —
|
||||||
|
5. **Löschen** (`delete`, danger) — disabled wenn ≤1 Ebene
|
||||||
|
Titel = Name.
|
||||||
|
|
||||||
|
## „+"-Menü (Zeichnungsebenen)
|
||||||
|
**Geschoss** (`layers`) · **Schnitt / Ansicht** (`content_cut`) · — · **Zeichnung** (`edit_note`)
|
||||||
|
|
||||||
|
## Anzeige-Modi (Dropdown oben im Panel) — **5 Modi** (für Ebenen UND Zeichnungsebenen)
|
||||||
|
| Wert | Label | Verhalten |
|
||||||
|
|---|---|---|
|
||||||
|
| `all_force` | **Alle anzeigen** | alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge → wechselt zu „Ausgewählte" |
|
||||||
|
| `all` | **Ausgewählte** | sichtbar nach per-Zeile-Flag |
|
||||||
|
| `active` | **Nur aktive** | nur aktive sichtbar; andere stark gedimmt |
|
||||||
|
| `grey` | **Andere grau** | aktive normal, andere 45% (Sichtbarkeits-Flags gelten) |
|
||||||
|
| `grey_locked` | **Andere grau & gesperrt** | wie grey + andere gesperrt |
|
||||||
|
Regel: Klick aufs Auge in `all_force`/`active` schaltet automatisch auf `all`.
|
||||||
|
*(Hinweis: Panel-System-Workflow hatte vorerst nur 3 Modi — beim Kontextmenü-Build auf diese 5 angleichen.)*
|
||||||
|
|
||||||
|
## Migration (was sofort geht / was Stub bleibt)
|
||||||
|
- Sofort: ContextMenu-Komponente 1:1; Duplizieren, Eigenschaften kopieren/einfügen, Löschen,
|
||||||
|
+Menü, 5 Anzeige-Modi (alles reine JSON-State-Operationen).
|
||||||
|
- Stub/später: „Ebeneneinstellungen…"/„Einstellungen…" (Dialog), „Sub-Ebene hinzufügen", „Selektion hierher übertragen".
|
||||||
@@ -0,0 +1,760 @@
|
|||||||
|
# Aktive Zeichen- und Bearbeitungs-Werkzeuge
|
||||||
|
|
||||||
|
Status: Entwurf. Dieses Dokument spezifiziert das **Tool-System** für das aktive
|
||||||
|
Erzeugen von Modell-Elementen durch Zeichnen im Grundriss: Wände (Achs-Polylinie
|
||||||
|
→ `Wall` eines `WallType`) sowie reine 2D-Geometrie (Linie, Polylinie, Rechteck,
|
||||||
|
Kreis, Bogen, Text). Es definiert die Werkzeug-Zustandsmaschine, die Live-Vorschau
|
||||||
|
(Rubber-Band), das **Snapping** mit Bildschirm-Markern, die Ebenen-/Kategorie-/
|
||||||
|
Stil-Zuordnung neuer Elemente und das neue Element `Drawing2D` samt Ableitung in
|
||||||
|
`generatePlan`.
|
||||||
|
|
||||||
|
Bezugsdokumente: [elements.md](elements.md) (Wand-/Tür-Modell),
|
||||||
|
[resources-graphics.md](resources-graphics.md) (Stil-Auflösung),
|
||||||
|
[plans-output.md](plans-output.md) (Papier-Maßstab, mm-Strichstärken),
|
||||||
|
[context-menu.md](context-menu.md) (Maus-Schema).
|
||||||
|
|
||||||
|
## 0. Architektur-Prinzip (Bezug zum Repo)
|
||||||
|
|
||||||
|
Die App folgt der Regel **ein semantisches Modell ist die einzige Wahrheit; jede
|
||||||
|
Ansicht ist abgeleitet** (CONVENTIONS.md, `App.tsx`). Werkzeuge greifen darum NUR über
|
||||||
|
`setProject` immutabel auf das `Project`-Modell zu; sie schreiben NIE Geometrie
|
||||||
|
direkt in den Plan. Der `PlanView` bleibt eine reine Darstellungs-/Eingabe-
|
||||||
|
Schicht. Das Tool-System setzt genau an der bestehenden Naht in `PlanView` an:
|
||||||
|
|
||||||
|
- **Modell↔Screen.** `PlanView` rechnet bereits Cursor-Pixel → viewBox-Einheiten
|
||||||
|
(`clientToView`) → Modell-Meter (`viewToModel`). Diese Umrechnung ist die
|
||||||
|
Grundlage; Werkzeuge arbeiten ausschließlich in **Modell-Metern** (CONVENTIONS.md:
|
||||||
|
intern alles in Metern). Für Snap-Marker brauchen Werkzeuge zusätzlich die
|
||||||
|
Rückrichtung Modell → viewBox (`toScreen`, existiert bereits) bzw. Modell →
|
||||||
|
Client-Pixel.
|
||||||
|
- **Pointer-Handling.** `PlanView` besitzt heute drei Gesten an der linken Taste/
|
||||||
|
Mitte/rechts: Auswahl/Marquee, Pan, Kontextmenü. Das Tool-System schiebt sich
|
||||||
|
VOR diese Logik: ist ein aktives Zeichenwerkzeug gewählt (≠ `select`), übernimmt
|
||||||
|
das Werkzeug `pointerdown/move/up`; das `select`-Werkzeug delegiert an die heute
|
||||||
|
schon vorhandene Auswahl-/Marquee-Logik (kein Verhaltensbruch).
|
||||||
|
- **Pan/Zoom bleiben immer aktiv.** Mittlere Maustaste (Pan) und Mausrad (Zoom)
|
||||||
|
laufen unverändert weiter, auch während ein Zeichenwerkzeug aktiv ist — sonst
|
||||||
|
kann man beim Zeichnen nicht navigieren.
|
||||||
|
|
||||||
|
## 1. Datenfluss-Überblick
|
||||||
|
|
||||||
|
```
|
||||||
|
TopBar (Werkzeugleiste) --activeTool--> App-State
|
||||||
|
│
|
||||||
|
┌──── activeTool, wallTypeId, defaultCategoryCode ────┐
|
||||||
|
▼ ▼
|
||||||
|
PlanView ── pointerdown/move/up (Modellpunkt) ──> ToolController
|
||||||
|
▲ │
|
||||||
|
Snap-Marker + Rubber-Band-Overlay <── DraftState (Vorschau) ──┘
|
||||||
|
│ │
|
||||||
|
└──────────────── commit ──> onToolCommit(Element) ──> setProject
|
||||||
|
```
|
||||||
|
|
||||||
|
`activeTool` und die Werkzeug-Parameter (aktiver `WallType`, Default-Kategorie)
|
||||||
|
liegen als **View-State** in `App.tsx` — wie `viewType`, `detail`, `selectedWallIds`
|
||||||
|
bereits dort liegen. Der `ToolController` ist **frameworkfrei** (reines TS, kein
|
||||||
|
React-State pro Mausbewegung — analog zu `drag`/`marquee` als `useRef` in
|
||||||
|
`PlanView`), damit die Live-Vorschau ohne Re-Render des ganzen Baums läuft. Nur
|
||||||
|
beim **Commit** wird `setProject` (Re-Render) ausgelöst.
|
||||||
|
|
||||||
|
## 2. Koordinaten & Hilfsfunktionen
|
||||||
|
|
||||||
|
`PlanView` exportiert künftig zwei reine Konverter (heute intern vorhanden),
|
||||||
|
plus die effektive Pixel-pro-Meter-Skala für die Snap-Toleranz:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// PlanView-intern bereits da; wird als stabile Callbacks nach außen gereicht.
|
||||||
|
type ToModel = (clientX: number, clientY: number) => Vec2; // Pixel → Meter
|
||||||
|
type ToClient = (m: Vec2) => { x: number; y: number }; // Meter → Pixel
|
||||||
|
type PxPerMeter = () => number; // aktuelle meet-Skala * PX_PER_M (Snap-Toleranz)
|
||||||
|
```
|
||||||
|
|
||||||
|
`PxPerMeter` ergibt sich aus `meetScale(view) * PX_PER_M` (beides in `PlanView`
|
||||||
|
vorhanden). Snap-Toleranzen werden in **Bildschirm-Pixeln** definiert (z. B. 10 px)
|
||||||
|
und über `pxPerMeter` in Meter umgerechnet — so ist der Fangradius zoom-unabhängig
|
||||||
|
konstant am Bildschirm.
|
||||||
|
|
||||||
|
## 3. Tool-System
|
||||||
|
|
||||||
|
### 3.1 Werkzeug-Identität und Registry
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type ToolId =
|
||||||
|
| "select" // Default: Auswahl/Marquee (heutiges Verhalten)
|
||||||
|
| "wall" // Wand-Achs-Polylinie → Wall je Segment
|
||||||
|
| "line" // einzelne 2D-Strecke
|
||||||
|
| "polyline" // offene 2D-Polylinie
|
||||||
|
| "rect" // 2D-Rechteck (zwei Ecken)
|
||||||
|
| "circle" // 2D-Kreis (Zentrum + Radius)
|
||||||
|
| "arc" // 2D-Bogen (3-Punkt oder Zentrum-Start-Ende)
|
||||||
|
| "text"; // 2D-Textmarke
|
||||||
|
|
||||||
|
/** Live-Kontext, den ein Werkzeug bei jedem Schritt erhält. */
|
||||||
|
export interface ToolContext {
|
||||||
|
project: Project;
|
||||||
|
/** Aktives Geschoss/Zeichnungsebene (Ziel der neuen Elemente). */
|
||||||
|
level: DrawingLevel;
|
||||||
|
/** Default-Kategorie-Code für neue Elemente (siehe §6). */
|
||||||
|
defaultCategoryCode: string;
|
||||||
|
/** Aktiver Wandtyp für das Wand-Werkzeug. */
|
||||||
|
activeWallTypeId: string;
|
||||||
|
/** Aktiver Linienstil-Code für 2D-Primitive (Line Manager). */
|
||||||
|
activeLineStyleId: string;
|
||||||
|
/** Snapping-Einstellungen (an/aus je Typ, ortho, grid). */
|
||||||
|
snap: SnapSettings;
|
||||||
|
/** Pixel pro Meter (für Snap-Toleranz in Metern). */
|
||||||
|
pxPerMeter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein an einer Modellposition ausgelöstes Pointer-Ereignis. */
|
||||||
|
export interface ToolPointer {
|
||||||
|
/** Roher Modellpunkt (vor Snapping), in Metern. */
|
||||||
|
raw: Vec2;
|
||||||
|
/** Gesnappter Punkt + Marker-Info (siehe §5). null = kein Snap. */
|
||||||
|
snap: SnapResult | null;
|
||||||
|
/** Effektiver Punkt = snap?.point ?? raw. */
|
||||||
|
point: Vec2;
|
||||||
|
/** Modifikatoren (Shift = Ortho erzwingen, Ctrl = Snap aus, Alt = …). */
|
||||||
|
shift: boolean;
|
||||||
|
ctrl: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
button: number; // 0 links, 2 rechts
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was ein Werkzeug-Schritt nach außen meldet. */
|
||||||
|
export interface ToolResult {
|
||||||
|
/** Neuer Vorschau-Zustand (Rubber-Band-Geometrie); null = nichts zu zeigen. */
|
||||||
|
draft: ToolDraft | null;
|
||||||
|
/** Bei Abschluss: Mutation, die App über setProject anwendet. */
|
||||||
|
commit?: (p: Project) => Project;
|
||||||
|
/** true → Werkzeug ist fertig und kehrt in seinen Ruhezustand zurück. */
|
||||||
|
done?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Werkzeug-Schnittstelle (reine Funktionen über einen internen State). */
|
||||||
|
export interface Tool {
|
||||||
|
id: ToolId;
|
||||||
|
/** UI-Label-Key (i18n), z. B. "tool.wall". */
|
||||||
|
labelKey: string;
|
||||||
|
/** Material-Symbol-Name für die Werkzeugleiste. */
|
||||||
|
icon: string;
|
||||||
|
/** Statuszeilen-Hinweis-Key je Phase (z. B. "tool.wall.firstPoint"). */
|
||||||
|
hintKey: (state: ToolState) => string;
|
||||||
|
|
||||||
|
/** Initialer Ruhezustand. */
|
||||||
|
init(): ToolState;
|
||||||
|
/** Klick/Tap (pointerdown→up ohne Drag, bzw. „setze Punkt"). */
|
||||||
|
onClick(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult];
|
||||||
|
/** Bewegung (Hover/Drag): nur Vorschau, nie Commit. */
|
||||||
|
onMove(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult];
|
||||||
|
/** Doppelklick/Enter: mehrteilige Werkzeuge abschließen (z. B. Polylinie). */
|
||||||
|
onCommitGesture(state: ToolState, ctx: ToolContext): [ToolState, ToolResult];
|
||||||
|
/** Esc: aktuellen Entwurf verwerfen, zurück in den Ruhezustand. */
|
||||||
|
onCancel(state: ToolState): [ToolState, ToolResult];
|
||||||
|
/** Backspace: letzten gesetzten Punkt zurücknehmen (mehrteilig). */
|
||||||
|
onUndoPoint?(state: ToolState, ctx: ToolContext): [ToolState, ToolResult];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ToolState` ist je Werkzeug ein Discriminated Union (Beispiel Wand in §4). Der
|
||||||
|
`ToolController` hält genau eine aktive `Tool`-Instanz + deren `ToolState` in
|
||||||
|
einem `useRef` und ist die einzige Stelle, die diese Methoden aufruft.
|
||||||
|
|
||||||
|
### 3.2 Vorschau-Geometrie (Rubber-Band)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Darstellbare Vorschau — dieselben Primitive wie der Plan, plus Marker. */
|
||||||
|
export interface ToolDraft {
|
||||||
|
/** Vorschau-Primitive (gestrichelt/halbtransparent gezeichnet). */
|
||||||
|
preview: Primitive[];
|
||||||
|
/** Bereits gesetzte „feste" Stützpunkte (kleine Quadrate). */
|
||||||
|
vertices: Vec2[];
|
||||||
|
/** Optionaler Maß-/Winkel-Text am Cursor (z. B. "3.20 m, 90°"). */
|
||||||
|
hud?: { at: Vec2; text: string };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtig: Die Vorschau benutzt **dieselben `Primitive`-Typen** wie `generatePlan`
|
||||||
|
(`polygon | line | arc`). Damit kann der Vorschau-Layer mit derselben
|
||||||
|
`PrimitiveShape`-Renderlogik gezeichnet werden (DRY) — nur mit einer
|
||||||
|
Vorschau-CSS-Klasse (gestrichelt, Akzentfarbe). Für die Wand-Vorschau kann das
|
||||||
|
Werkzeug sogar `generatePlan` auf einem **temporären Projekt** (Original + die in
|
||||||
|
Bau befindliche Wand) aufrufen, um echte gehrte Poché live zu zeigen; in der
|
||||||
|
ersten Phase reicht eine einfache Bandvorschau (`wallCorners`).
|
||||||
|
|
||||||
|
### 3.3 Zustandsmaschine (allgemein)
|
||||||
|
|
||||||
|
Jedes Werkzeug ist eine kleine Maschine über `pointerdown → move → up`. Da
|
||||||
|
`PlanView` Pointer-Capture nutzt, kommen `move`/`up` zuverlässig an. Generisches
|
||||||
|
Muster:
|
||||||
|
|
||||||
|
```
|
||||||
|
ruht ──pointerdown──> (Werkzeug setzt 1. Punkt / startet Drag)
|
||||||
|
▲ │
|
||||||
|
│ ├──move──> Vorschau (rubber-band), kein Commit
|
||||||
|
│ │
|
||||||
|
│ (mehrteilig) pointerdown──> Punkt anhängen, Vorschau weiter
|
||||||
|
│ │
|
||||||
|
└──Esc/Cancel─────────────┤
|
||||||
|
▼
|
||||||
|
Doppelklick/Enter/letzter Punkt ──> commit(project) ──> ruht
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Klick-vs-Drag.** Wie heute in `PlanView` (`MARQUEE_THRESHOLD_PX`): unter der
|
||||||
|
Schwelle ist es ein „Punkt setzen" (Klick), darüber ein Drag. Rechteck/Kreis/
|
||||||
|
Linie unterstützen BEIDE Bedienarten: Zwei-Klick (Punkt, Punkt) ODER Drücken-
|
||||||
|
Ziehen-Loslassen. Polyline/Wall sind reine Klickfolgen mit Abschluss per
|
||||||
|
Doppelklick/Enter.
|
||||||
|
- **Esc** verwirft den Entwurf (`onCancel`) und bleibt im selben Werkzeug.
|
||||||
|
Zweites Esc (im Ruhezustand) schaltet zurück auf `select`.
|
||||||
|
- **Rechtsklick** während eines aktiven Entwurfs = „abschließen/abbrechen"
|
||||||
|
(CAD-üblich), KEIN Kontextmenü; im Ruhezustand öffnet Rechtsklick wie bisher
|
||||||
|
das Plan-Kontextmenü.
|
||||||
|
|
||||||
|
### 3.4 Einbettung in PlanView (Pointer-Routing)
|
||||||
|
|
||||||
|
`PlanView` bekommt zwei neue Props:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface PlanViewProps {
|
||||||
|
// … bisherige Props …
|
||||||
|
/** Aktives Werkzeug; "select" = bisheriges Verhalten. */
|
||||||
|
activeTool?: ToolId;
|
||||||
|
/**
|
||||||
|
* Werkzeug-Treiber. PlanView ruft diese Callbacks mit fertig gesnappten
|
||||||
|
* Modellpunkten auf und rendert den zurückgegebenen Draft als Overlay.
|
||||||
|
*/
|
||||||
|
toolHandlers?: {
|
||||||
|
onToolDown(p: ToolPointer): void;
|
||||||
|
onToolMove(p: ToolPointer): void;
|
||||||
|
onToolUp(p: ToolPointer): void;
|
||||||
|
onToolDoubleClick(): void;
|
||||||
|
/** liefert die zu zeichnende Vorschau (von App/Controller gehalten). */
|
||||||
|
draft: ToolDraft | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Routing in `onPointerDown` (Ergänzung der bestehenden Methode):
|
||||||
|
|
||||||
|
```
|
||||||
|
onPointerDown(e):
|
||||||
|
if e.button === 1: → bestehender Pan (unverändert)
|
||||||
|
if e.button === 0:
|
||||||
|
if activeTool === "select": → bestehende Auswahl-/Marquee-Geste
|
||||||
|
else:
|
||||||
|
setPointerCapture
|
||||||
|
p = makeToolPointer(e) // raw → snap → point (§5)
|
||||||
|
toolHandlers.onToolDown(p)
|
||||||
|
if e.button === 2 (rechts):
|
||||||
|
if activeTool !== "select" && entwurf aktiv: toolHandlers.onToolUp({button:2,…}) // abschließen
|
||||||
|
else: bestehendes Kontextmenü
|
||||||
|
```
|
||||||
|
|
||||||
|
`onPointerMove`/`onPointerUp` analog: bei aktivem Zeichenwerkzeug an
|
||||||
|
`onToolMove`/`onToolUp` routen statt an Pan/Marquee. Der Cursor wird auf
|
||||||
|
`crosshair` gesetzt. `makeToolPointer` führt das Snapping aus (§5) und liefert den
|
||||||
|
fertigen `ToolPointer`.
|
||||||
|
|
||||||
|
Die **Snap-Marker** und der **Draft** werden als zusätzliche SVG-Gruppe NACH den
|
||||||
|
Plan-Primitiven, aber vor der Auswahl-Hervorhebung gerendert (immer obenauf,
|
||||||
|
`pointerEvents="none"`). Marker werden in viewBox-Einheiten über `toScreen`
|
||||||
|
positioniert (existiert bereits).
|
||||||
|
|
||||||
|
## 4. Werkzeug: Wand (Wall)
|
||||||
|
|
||||||
|
Das Wand-Werkzeug zeichnet eine **Achs-Polylinie**; jedes Segment wird zu einem
|
||||||
|
eigenständigen `Wall`-Element des aktiven `WallType` auf dem aktiven Geschoss.
|
||||||
|
Aufeinanderfolgende Segmente teilen sich einen Knoten → die bestehende
|
||||||
|
`computeJoins`-Verschneidung (in `generatePlan`) erzeugt automatisch saubere
|
||||||
|
Gehrungen an den Ecken. Kein zusätzlicher Join-Code nötig.
|
||||||
|
|
||||||
|
### 4.1 Zustand
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type WallToolState =
|
||||||
|
| { phase: "idle" }
|
||||||
|
| {
|
||||||
|
phase: "drawing";
|
||||||
|
/** Bisher gesetzte Achs-Knoten (in Metern). */
|
||||||
|
points: Vec2[];
|
||||||
|
/** Aktuelle Cursor-Position (gesnappt) für die Rubber-Band-Vorschau. */
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Pseudocode
|
||||||
|
|
||||||
|
```
|
||||||
|
WallTool.onClick(state, p, ctx):
|
||||||
|
if state.phase === "idle":
|
||||||
|
return [{phase:"drawing", points:[p.point], cursor:p.point}, {draft: draftFor([p.point], p.point, ctx)}]
|
||||||
|
else: // weiteren Knoten anhängen
|
||||||
|
pts = [...state.points, p.point]
|
||||||
|
# Ortho/Snap haben p.point bereits ausgerichtet (§5).
|
||||||
|
return [{phase:"drawing", points: pts, cursor: p.point}, {draft: draftFor(pts, p.point, ctx)}]
|
||||||
|
|
||||||
|
WallTool.onMove(state, p, ctx):
|
||||||
|
if state.phase !== "drawing": return [state, {draft:null}]
|
||||||
|
return [{...state, cursor:p.point}, {draft: draftFor(state.points, p.point, ctx)}]
|
||||||
|
|
||||||
|
WallTool.onCommitGesture(state, ctx): // Doppelklick / Enter / Rechtsklick
|
||||||
|
if state.phase !== "drawing" || state.points.length < 2:
|
||||||
|
return [{phase:"idle"}, {draft:null, done:true}]
|
||||||
|
pts = state.points
|
||||||
|
return [{phase:"idle"}, {
|
||||||
|
draft: null, done: true,
|
||||||
|
commit: (proj) => appendWalls(proj, pts, ctx)
|
||||||
|
}]
|
||||||
|
|
||||||
|
WallTool.onCancel(state):
|
||||||
|
return [{phase:"idle"}, {draft:null, done:true}]
|
||||||
|
|
||||||
|
WallTool.onUndoPoint(state):
|
||||||
|
if state.phase==="drawing" && state.points.length>1:
|
||||||
|
return [{...state, points: state.points.slice(0,-1)}, {draft: …}]
|
||||||
|
return [{phase:"idle"}, {draft:null}]
|
||||||
|
```
|
||||||
|
|
||||||
|
`draftFor` baut die Vorschau: feste Segmente zwischen `points` + ein „lebendes"
|
||||||
|
Segment `points[last] → cursor`. Pro Segment werden die vier Band-Eckpunkte über
|
||||||
|
`wallCorners(a, b, thickness)` (vorhanden) berechnet und als Vorschau-`polygon`
|
||||||
|
gezeichnet; zusätzlich ein HUD mit Länge `|b−a|` und Winkel. `thickness =
|
||||||
|
wallTypeThickness(getWallType(...))`.
|
||||||
|
|
||||||
|
### 4.3 Commit ins Modell
|
||||||
|
|
||||||
|
```
|
||||||
|
appendWalls(project, pts, ctx):
|
||||||
|
newWalls = []
|
||||||
|
for i in 0 .. pts.length-2:
|
||||||
|
a = pts[i]; b = pts[i+1]
|
||||||
|
if |b-a| < EPS: continue // Null-Segmente überspringen
|
||||||
|
newWalls.push({
|
||||||
|
id: uniqueId("W"), // siehe §8 (ID-Vergabe)
|
||||||
|
type: "wall",
|
||||||
|
floorId: ctx.level.id, // aktives Geschoss
|
||||||
|
categoryCode: ctx.defaultCategoryCode, // §6
|
||||||
|
start: a, end: b,
|
||||||
|
wallTypeId: ctx.activeWallTypeId,
|
||||||
|
height: ctx.level.floorHeight ?? 2.6, // Geschosshöhe als Default
|
||||||
|
})
|
||||||
|
return { ...project, walls: [...project.walls, ...newWalls] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweise:
|
||||||
|
- **Höhe** erbt die lichte Geschosshöhe (`DrawingLevel.floorHeight`), Fallback 2.6 m.
|
||||||
|
- **Geschossbindung**: Das Wand-Werkzeug ist nur aktiv, wenn `level.kind === "floor"`
|
||||||
|
(sonst gibt es keine Wände). In `drawing`-Ebenen ist das Wand-Werkzeug
|
||||||
|
deaktiviert (nur 2D-Werkzeuge); siehe §6.
|
||||||
|
- Die Wicklung wird NICHT erzwungen — `leftNormal`-Konvention (CONVENTIONS.md) und
|
||||||
|
`computeJoins` arbeiten richtungsunabhängig pro Segment.
|
||||||
|
|
||||||
|
## 5. Snapping
|
||||||
|
|
||||||
|
Snapping läuft in `makeToolPointer` (PlanView) BEVOR der Punkt an das Werkzeug
|
||||||
|
geht. Es prüft mehrere Snap-Quellen, wählt die nächstgelegene innerhalb der
|
||||||
|
Toleranz und liefert sowohl den gefangenen Punkt als auch eine **Marker-Art** für
|
||||||
|
die Bildschirmdarstellung.
|
||||||
|
|
||||||
|
### 5.1 Typen
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type SnapKind =
|
||||||
|
| "endpoint" // Wand-Achsenende, Polylinien-Knoten, Primitiv-Endpunkt
|
||||||
|
| "midpoint" // Mitte einer Strecke/Wandachse
|
||||||
|
| "intersection" // Schnittpunkt zweier Achsen/Linien
|
||||||
|
| "center" // Kreis-/Bogenzentrum
|
||||||
|
| "quadrant" // Kreis-Quadrantenpunkte (0/90/180/270°)
|
||||||
|
| "onEdge" // nächster Punkt AUF einer Wandachse/Linie (Lot)
|
||||||
|
| "grid" // Rasterpunkt
|
||||||
|
| "ortho" // orthogonal/winkelrastriert zum vorigen Punkt
|
||||||
|
| "extension"; // Verlängerung einer Achse (gestrichelte Hilfslinie)
|
||||||
|
|
||||||
|
export interface SnapResult {
|
||||||
|
point: Vec2; // gefangener Punkt (Meter)
|
||||||
|
kind: SnapKind;
|
||||||
|
/** Quell-Element (für Marker/Hilfslinien), optional. */
|
||||||
|
refA?: Vec2;
|
||||||
|
refB?: Vec2;
|
||||||
|
/** Bildschirm-Distanz Cursor→Snap (px) — für die Auswahl des Besten. */
|
||||||
|
distPx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapSettings {
|
||||||
|
enabled: boolean; // Master-Schalter (Ctrl invertiert temporär)
|
||||||
|
endpoint: boolean;
|
||||||
|
midpoint: boolean;
|
||||||
|
intersection: boolean;
|
||||||
|
center: boolean;
|
||||||
|
onEdge: boolean;
|
||||||
|
grid: boolean;
|
||||||
|
gridSize: number; // Rasterweite in Metern, z. B. 0.10
|
||||||
|
ortho: boolean; // Shift erzwingt zusätzlich
|
||||||
|
angleStep: number; // Winkelraster in Grad (z. B. 45)
|
||||||
|
tolerancePx: number; // Fangradius am Bildschirm, z. B. 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Snap-Kandidaten sammeln
|
||||||
|
|
||||||
|
Quellen pro Geschoss (gefiltert auf sichtbare Kategorien, wie der Plan):
|
||||||
|
|
||||||
|
| Snap | Quelle |
|
||||||
|
|------|--------|
|
||||||
|
| endpoint | `wall.start`, `wall.end` aller sichtbaren Wände; Knoten bereits gesetzter Draft-Punkte; `Drawing2D`-Vertices |
|
||||||
|
| midpoint | Mitte jeder Wandachse und jedes 2D-Segments |
|
||||||
|
| intersection | paarweise `lineIntersect` der Wandachsen (nur Paare, deren Boxen sich am Cursor nähern) |
|
||||||
|
| center/quadrant | Kreise/Bögen aus `Drawing2D` |
|
||||||
|
| onEdge | Lotfußpunkt des Cursors auf jede nahe Wandachse/2D-Linie |
|
||||||
|
| grid | Rundung des Cursors auf `gridSize` |
|
||||||
|
| ortho | Ausrichtung relativ zum letzten Draft-Punkt (§5.4) |
|
||||||
|
|
||||||
|
Performance: Kandidaten werden je `move` neu erzeugt, aber **früh nach
|
||||||
|
Bildschirm-Distanz gefiltert** (nur Punkte innerhalb ~`2·tolerancePx`). Bei
|
||||||
|
großen Modellen kann eine grobe Bounding-Box-Vorauswahl je Wand vorgeschaltet
|
||||||
|
werden; in den ersten Phasen genügt lineares Scannen (Wandzahl ist klein).
|
||||||
|
|
||||||
|
### 5.3 Auswahl-Pseudocode
|
||||||
|
|
||||||
|
```
|
||||||
|
computeSnap(rawModel, ctx, draftPoints, lastPoint):
|
||||||
|
if ctrl(): return null # Snap temporär aus
|
||||||
|
s = ctx.snap
|
||||||
|
tolM = s.tolerancePx / ctx.pxPerMeter # px-Toleranz → Meter
|
||||||
|
cands: SnapResult[] = []
|
||||||
|
|
||||||
|
if s.endpoint: cands += endpoints(...) filtered to within tolM
|
||||||
|
if s.midpoint: cands += midpoints(...)
|
||||||
|
if s.intersection: cands += intersections(...)
|
||||||
|
if s.center: cands += centers/quadrants(...)
|
||||||
|
if s.onEdge: cands += perpendicularFeet(...) # niedrigere Priorität
|
||||||
|
|
||||||
|
# Punkt-Snaps haben Vorrang vor Linien-/Raster-Snaps:
|
||||||
|
pick = argmin(cands, by distPx within tolM, tie-break by priority)
|
||||||
|
if pick: rawModel = pick.point
|
||||||
|
|
||||||
|
# Ortho/Winkelraster wirkt RELATIV zum letzten Punkt und ÜBERLAGERT:
|
||||||
|
if (s.ortho || shift()) && lastPoint:
|
||||||
|
rawModel = applyAngleConstraint(lastPoint, rawModel, s.angleStep)
|
||||||
|
# Wenn dabei auch ein Punkt-Snap nahe der Ortho-Linie liegt → bevorzugen.
|
||||||
|
|
||||||
|
if !pick && s.grid:
|
||||||
|
g = snapToGrid(rawModel, s.gridSize)
|
||||||
|
if dist(g, rawModel) within tolM: return {point:g, kind:"grid", …}
|
||||||
|
|
||||||
|
return pick ?? null
|
||||||
|
```
|
||||||
|
|
||||||
|
Prioritätsreihenfolge bei gleichem Abstand: `endpoint > intersection > midpoint >
|
||||||
|
center/quadrant > onEdge > grid`. Ortho/Winkelraster ist eine **Projektion**, kein
|
||||||
|
Punkt-Kandidat: es verschiebt den (ggf. schon gesnappten) Punkt auf die nächste
|
||||||
|
erlaubte Richtung vom letzten Knoten.
|
||||||
|
|
||||||
|
### 5.4 Ortho / Winkelraster
|
||||||
|
|
||||||
|
```
|
||||||
|
applyAngleConstraint(from, to, stepDeg):
|
||||||
|
d = to - from
|
||||||
|
ang = atan2(d.y, d.x)
|
||||||
|
k = round(ang / rad(stepDeg)) * rad(stepDeg)
|
||||||
|
len = |d|
|
||||||
|
return from + (cos(k), sin(k)) * len
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit `stepDeg = 90` ist das klassisches Ortho (H/V); `45` erlaubt Diagonalen.
|
||||||
|
`Shift` erzwingt Ortho temporär unabhängig von der Einstellung.
|
||||||
|
|
||||||
|
### 5.5 Bildschirm-Marker
|
||||||
|
|
||||||
|
Pro aktivem Snap zeichnet `PlanView` ein Marker-Glyph an `toScreen(snap.point)`
|
||||||
|
(`pointerEvents="none"`, eigene CSS-Klassen, papierkonstante Größe via
|
||||||
|
non-scaling):
|
||||||
|
|
||||||
|
- `endpoint` → kleines Quadrat ▫
|
||||||
|
- `midpoint` → Dreieck �△
|
||||||
|
- `intersection` → ✕
|
||||||
|
- `center` → ○, `quadrant` → ◇
|
||||||
|
- `onEdge` → ⟂-Glyph
|
||||||
|
- `grid` → feiner Punkt
|
||||||
|
- `ortho`/`extension` → zusätzlich eine **gestrichelte Hilfslinie** von `refA`
|
||||||
|
(Bezugspunkt) zum Cursor
|
||||||
|
|
||||||
|
Marker erscheinen NUR während ein Zeichenwerkzeug aktiv ist. i18n-Tooltips/Status
|
||||||
|
(„Endpunkt", „Mittelpunkt", …) über `t('snap.endpoint')` etc.
|
||||||
|
|
||||||
|
## 6. Ebene, Kategorie und Stil neuer Elemente
|
||||||
|
|
||||||
|
Neue Elemente brauchen eine **Zeichnungsebene** (DrawingLevel) und eine
|
||||||
|
**Kategorie** (LayerCategory `code`) sowie — bei 2D-Primitiven — einen Stift/
|
||||||
|
Schraffur-Stil.
|
||||||
|
|
||||||
|
### 6.1 Zeichnungsebene (Ziel)
|
||||||
|
|
||||||
|
- Ziel ist **immer das aktive Geschoss/die aktive Zeichnungsebene** (`activeLevelId`
|
||||||
|
in `App.tsx`). Wände nur auf `kind === "floor"`. 2D-Primitive (`Drawing2D`) auf
|
||||||
|
jeder Ebene, also auch auf `kind === "drawing"` (freie 2D-Zeichnung).
|
||||||
|
|
||||||
|
### 6.2 Kategorie (categoryCode)
|
||||||
|
|
||||||
|
- Es gibt eine **aktive Kategorie** als View-State (`activeCategoryCode` in App,
|
||||||
|
neu). Default beim Start: der Code der gewählten Wand-Kategorie (im Sample „20"
|
||||||
|
Wände), bzw. die erste sichtbare Kategorie. Die Statusleiste zeigt heute schon
|
||||||
|
die „aktive Ebene" (`activeLayerName`); diese wird künftig von `activeCategoryCode`
|
||||||
|
gespeist statt nur aus der Auswahl abgeleitet.
|
||||||
|
- Neue Wände: `categoryCode = activeCategoryCode` (z. B. „20").
|
||||||
|
- Neue 2D-Primitive: ebenfalls `activeCategoryCode`. Sinnvoll ist eine eigene
|
||||||
|
2D-/Hilfslinien-Kategorie (z. B. „90 Zeichnung"); diese wird über die
|
||||||
|
Kategorie-Auswahl in der Statusleiste/Werkzeugleiste gesetzt.
|
||||||
|
- Die Kategorie liefert Farbe + Strichstärke (`LayerCategory.color`, `.lw`), genau
|
||||||
|
wie `generatePlan` es heute für Wände via `categoryLwMap` nutzt.
|
||||||
|
|
||||||
|
### 6.3 Stift/Schraffur
|
||||||
|
|
||||||
|
- **Wände** erhalten KEINEN eigenen Stift — ihr Erscheinungsbild kommt aus dem
|
||||||
|
`WallType` (Component → Hatch → LineStyle) und der Kategorie-`lw` (bestehender
|
||||||
|
Pfad in `generatePlan`).
|
||||||
|
- **2D-Primitive** referenzieren optional einen `LineStyle` aus dem Line Manager
|
||||||
|
(`activeLineStyleId`). Ohne expliziten Stil erben sie Farbe/Strichstärke aus der
|
||||||
|
Kategorie (`color`, `lw`). Flächige 2D-Primitive (geschlossenes Rechteck/Kreis/
|
||||||
|
Polyline) können optional eine Schraffur (`hatchId`) tragen.
|
||||||
|
|
||||||
|
## 7. Speicherung der 2D-Primitive: `Drawing2D`
|
||||||
|
|
||||||
|
2D-Geometrie wird als neues Modell-Element `Drawing2D` gespeichert — analog zu
|
||||||
|
`Wall`/`Door` ein semantisches Element, das beim Rendern abgeleitet wird (KEINE
|
||||||
|
vorab erzeugten Primitive im Modell). Damit bleibt die Architektur „Modell →
|
||||||
|
abgeleitete Ansicht" intakt.
|
||||||
|
|
||||||
|
### 7.1 Typ
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Geometrie-Form eines 2D-Zeichenelements. */
|
||||||
|
export type Drawing2DGeom =
|
||||||
|
| { shape: "line"; a: Vec2; b: Vec2 }
|
||||||
|
| { shape: "polyline"; pts: Vec2[]; closed: boolean }
|
||||||
|
| { shape: "rect"; min: Vec2; max: Vec2 } // achsparallel
|
||||||
|
| { shape: "circle"; center: Vec2; r: number }
|
||||||
|
| {
|
||||||
|
shape: "arc";
|
||||||
|
center: Vec2;
|
||||||
|
r: number;
|
||||||
|
/** Start-/Endwinkel in Radiant (math. Konvention, CCW positiv). */
|
||||||
|
a0: number;
|
||||||
|
a1: number;
|
||||||
|
}
|
||||||
|
| { shape: "text"; at: Vec2; text: string; height: number; angle: number };
|
||||||
|
|
||||||
|
/** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */
|
||||||
|
export interface Drawing2D {
|
||||||
|
id: string;
|
||||||
|
type: "drawing2d";
|
||||||
|
/** Zeichnungsebene (Geschoss ODER freie 2D-Ebene). */
|
||||||
|
levelId: string;
|
||||||
|
/** Grafik-Kategorie (Ebene) — liefert Farbe/Strichstärke als Default. */
|
||||||
|
categoryCode: string;
|
||||||
|
geom: Drawing2DGeom;
|
||||||
|
/** Optionaler Linienstil (Line Manager); sonst Kategorie-Default. */
|
||||||
|
lineStyleId?: string;
|
||||||
|
/** Optionale Schraffur für geschlossene Formen (Hatch Manager). */
|
||||||
|
hatchId?: string;
|
||||||
|
/** Optionale explizite Strichfarbe; sonst Kategorie-Farbe. */
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergänzung am `Project`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface Project {
|
||||||
|
// … bisher …
|
||||||
|
drawings2d: Drawing2D[]; // NEU
|
||||||
|
}
|
||||||
|
export type Element = Wall | Door | Drawing2D; // erweitert
|
||||||
|
```
|
||||||
|
|
||||||
|
`sampleProject` bekommt ein leeres `drawings2d: []`. Lösch-/Referenz-Regeln:
|
||||||
|
beim Löschen einer Zeichnungsebene werden auch deren `Drawing2D` entfernt (analog
|
||||||
|
zur bestehenden Wand-/Tür-Bereinigung in `deleteLevel`).
|
||||||
|
|
||||||
|
### 7.2 Ableitung in `generatePlan`
|
||||||
|
|
||||||
|
`generatePlan` rendert künftig zusätzlich die `Drawing2D` des Geschosses (gefiltert
|
||||||
|
wie Wände auf sichtbare Kategorien + `categoryDisplay`). Neue Funktion
|
||||||
|
`addDrawing2D(out, project, d, greyed, lwMm)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
addDrawing2D(out, project, d):
|
||||||
|
color = d.color ?? categoryColor(d.categoryCode)
|
||||||
|
weight = lineStyle(d.lineStyleId)?.weight ?? categoryLw(d.categoryCode)
|
||||||
|
dash = lineStyle(d.lineStyleId)?.dash ?? null
|
||||||
|
switch d.geom.shape:
|
||||||
|
"line": out.push({kind:"line", a, b, cls:"draw2d", weightMm:weight, dash})
|
||||||
|
"polyline": for each segment → line-Primitive (closed → Schluss-Segment)
|
||||||
|
"rect": vier Kanten als line-Primitive (oder polygon, falls hatchId)
|
||||||
|
"circle": → als zwei 180°-Bögen (arc-Primitive) ODER neues Primitiv (s. u.)
|
||||||
|
"arc": → arc-Primitive (center/from/to/r aus a0,a1)
|
||||||
|
"text": → neues text-Primitiv (s. u.)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dabei wird, wo möglich, der **vorhandene** `Primitive`-Vorrat (`line`, `arc`,
|
||||||
|
`polygon`) wiederverwendet — die Strichstärke kommt in mm Papier (wie der Rest des
|
||||||
|
Plans), Farbe über eine CSS-Klasse oder ein neues optionales `color`-Feld am
|
||||||
|
`line`-Primitive.
|
||||||
|
|
||||||
|
Zwei `Primitive`-Erweiterungen sind nötig:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// kreisförmige Vollkurve (Kreis) — sonst muss man sie in zwei Bögen zerlegen:
|
||||||
|
| { kind: "circle"; center: Vec2; r: number; cls: string; weightMm: number;
|
||||||
|
dash?: number[] | null; fill?: string; greyed?: boolean }
|
||||||
|
// Textmarke:
|
||||||
|
| { kind: "text"; at: Vec2; text: string; heightMm: number; angle: number;
|
||||||
|
cls: string; color?: string; greyed?: boolean }
|
||||||
|
```
|
||||||
|
|
||||||
|
`PlanView.renderPrimitive` bekommt entsprechende `case`-Zweige (`<circle>`,
|
||||||
|
`<text>`). Text wird in **Papier-Millimetern** dimensioniert (Höhe → `mmToPx`,
|
||||||
|
non-scaling), damit die Schrifthöhe beim Zoomen papierkonstant bleibt (analog zu
|
||||||
|
Strichstärken in `plans-output.md`).
|
||||||
|
|
||||||
|
Das `arc`-Primitiv zeichnet heute nur Kurzbögen (≤180°, `large-arc=0`). Für
|
||||||
|
beliebige 2D-Bögen wird es um ein `largeArc`-Flag erweitert (aus `|a1−a0|`
|
||||||
|
berechnet); abwärtskompatibel (Default 0).
|
||||||
|
|
||||||
|
## 8. ID-Vergabe & Immutabilität
|
||||||
|
|
||||||
|
- Neue IDs über einen kleinen Helfer `uniqueId(prefix)` (z. B.
|
||||||
|
`\`${prefix}-${Date.now()}-${counter++}\``), konsistent mit der bestehenden
|
||||||
|
Praxis in `App.tsx` (`floor-${Date.now()}` usw.). Wand-Präfix „W", 2D-Präfix
|
||||||
|
„dr2d".
|
||||||
|
- Alle Mutationen laufen über `setProject` immutabel (CONVENTIONS.md / App-Konvention).
|
||||||
|
Der `commit(project)` eines Werkzeugs ist eine reine Funktion `Project →
|
||||||
|
Project`; App ruft `setProject(prev => result.commit(prev))`.
|
||||||
|
|
||||||
|
## 9. App- und PlanView-Verdrahtung (konkret)
|
||||||
|
|
||||||
|
Neuer View-State in `App.tsx`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [activeTool, setActiveTool] = useState<ToolId>("select");
|
||||||
|
const [activeCategoryCode, setActiveCategoryCode] = useState<string>(/* erste Wand-Kat */);
|
||||||
|
const [activeWallTypeId, setActiveWallTypeId] = useState<string>(project.wallTypes[0].id);
|
||||||
|
const [activeLineStyleId, setActiveLineStyleId] = useState<string>(project.lineStyles[0].id);
|
||||||
|
const [snap, setSnap] = useState<SnapSettings>(DEFAULT_SNAP);
|
||||||
|
const toolStateRef = useRef<ToolState>(getTool(activeTool).init());
|
||||||
|
const [draft, setDraft] = useState<ToolDraft | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
Der `ToolController` ist eine kleine Hook/Klasse, die `toolStateRef` hält und die
|
||||||
|
`PlanView.toolHandlers` implementiert:
|
||||||
|
|
||||||
|
```
|
||||||
|
onToolDown(p): [st, res] = tool.onClick(toolStateRef.current, p, ctx)
|
||||||
|
toolStateRef.current = st; setDraft(res.draft)
|
||||||
|
if res.commit: setProject(res.commit)
|
||||||
|
if res.done: toolStateRef.current = tool.init()
|
||||||
|
onToolMove(p): [st, res] = tool.onMove(...); toolStateRef.current=st; setDraft(res.draft)
|
||||||
|
onToolDoubleClick(): [st,res]=tool.onCommitGesture(...); apply commit/done; setDraft(null)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keyboard (global, nur wenn ein Zeichenwerkzeug aktiv ist):
|
||||||
|
`Esc → onCancel`, `Enter → onCommitGesture`, `Backspace → onUndoPoint`. Beim
|
||||||
|
Wechsel von `activeLevelId`/`viewType` wird der laufende Entwurf verworfen (analog
|
||||||
|
zur bestehenden Auswahl-Bereinigung in den `useEffect`s).
|
||||||
|
|
||||||
|
`ctx` (ToolContext) wird in App via `useMemo` aus Project + aktiven Selektionen
|
||||||
|
gebaut und an PlanView/Controller gereicht.
|
||||||
|
|
||||||
|
### 9.1 Werkzeugleiste (TopBar)
|
||||||
|
|
||||||
|
Eine neue Werkzeug-Gruppe in der `TopBar` (links, vor den Ansichts-Toggles), als
|
||||||
|
i18n-beschriftete Icon-Buttons (`t('tool.select')`, `t('tool.wall')`, …). Aktiv-
|
||||||
|
Zustand hervorgehoben. Daneben: Auswahl des aktiven `WallType` (für Wand) und der
|
||||||
|
aktiven Kategorie/des Linienstils (Dropdowns), sowie Snap-Toggles (kleines
|
||||||
|
Snap-Menü mit Checkboxen je `SnapKind`, Grid-Größe, Winkelraster). Wand-/2D-
|
||||||
|
Werkzeuge werden je nach `level.kind` aktiviert/deaktiviert (Tooltip nennt den
|
||||||
|
Grund — wie die bestehenden disabled-Menüpunkte in `App.tsx`).
|
||||||
|
|
||||||
|
### 9.2 i18n-Keys (neu, Auszug)
|
||||||
|
|
||||||
|
```
|
||||||
|
tool.select / tool.wall / tool.line / tool.polyline / tool.rect /
|
||||||
|
tool.circle / tool.arc / tool.text
|
||||||
|
tool.wall.firstPoint / tool.wall.nextPoint / tool.wall.finish
|
||||||
|
snap.endpoint / snap.midpoint / snap.intersection / snap.center /
|
||||||
|
snap.quadrant / snap.onEdge / snap.grid / snap.ortho
|
||||||
|
snap.settings / snap.gridSize / snap.angleStep
|
||||||
|
status.activeWallType / status.activeCategory / status.activeTool
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle sichtbaren Strings über `t(...)` (CONVENTIONS.md). Identifier bleiben englisch.
|
||||||
|
|
||||||
|
## 10. Übrige Werkzeuge (Kurzspezifikation)
|
||||||
|
|
||||||
|
| Werkzeug | Eingabe | Zustand | Commit |
|
||||||
|
|----------|---------|---------|--------|
|
||||||
|
| **Line** | 2 Punkte (Klick-Klick oder Drag) | `{a?}` | `Drawing2D{shape:"line"}` |
|
||||||
|
| **Polyline** | n Punkte, Abschluss Doppelklick/Enter; `closed` per „C" oder Klick auf Start | `{pts}` | `Drawing2D{shape:"polyline"}` |
|
||||||
|
| **Rectangle** | 2 Ecken (Drag oder Klick-Klick) | `{p0?}` | `Drawing2D{shape:"rect"}` (min/max sortiert) |
|
||||||
|
| **Circle** | Zentrum + Radius-Punkt | `{center?}` | `Drawing2D{shape:"circle"}` |
|
||||||
|
| **Arc** | 3 Punkte (Start, durch, Ende) ODER Zentrum-Start-Ende (Modus-Toggle) | `{p0?,p1?}` | `Drawing2D{shape:"arc"}` (a0/a1 aus Punkten) |
|
||||||
|
| **Text** | 1 Punkt → Inline-Eingabefeld (wie `InlineEditor` in App) | `{at?}` | `Drawing2D{shape:"text"}` |
|
||||||
|
|
||||||
|
Alle nutzen dasselbe `Tool`-Interface, dasselbe Snapping und denselben Draft-/
|
||||||
|
Commit-Pfad. Text öffnet beim Setzen des Ankerpunkts ein kleines Overlay-Eingabe-
|
||||||
|
feld (an `toClient(at)` positioniert) und committet bei Enter/Blur.
|
||||||
|
|
||||||
|
3-Punkt-Bogen → Zentrum: Umkreismittelpunkt der drei Punkte (Schnitt der
|
||||||
|
Mittelsenkrechten via `lineIntersect`), `r`, `a0/a1` aus Start-/Endwinkel; Drehsinn
|
||||||
|
aus dem mittleren Punkt.
|
||||||
|
|
||||||
|
## 11. Phasenplan
|
||||||
|
|
||||||
|
**Phase 1 — Gerüst + Select + Wall + Line (MVP).**
|
||||||
|
1. `ToolId`, `Tool`, `ToolContext`, `ToolPointer`, `ToolDraft`, `ToolResult`,
|
||||||
|
`SnapResult`, `SnapSettings`, `Drawing2D`(+`Project.drawings2d`) als Typen.
|
||||||
|
2. `PlanView`: `toScreen`/`viewToModel`/`pxPerMeter` als Callbacks nach außen;
|
||||||
|
Pointer-Routing für `activeTool !== "select"`; Draft-/Marker-Overlay-Rendering;
|
||||||
|
Crosshair-Cursor.
|
||||||
|
3. `ToolController` + App-State (`activeTool`, `activeCategoryCode`,
|
||||||
|
`activeWallTypeId`, `snap`) + Keyboard (Esc/Enter/Backspace).
|
||||||
|
4. **WallTool** voll funktionsfähig (Polylinie → Wände, Live-Band-Vorschau, HUD,
|
||||||
|
Commit via `appendWalls`). Verschneidung kommt automatisch aus `computeJoins`.
|
||||||
|
5. **LineTool** als erstes 2D-Werkzeug; `generatePlan.addDrawing2D` für `line`;
|
||||||
|
`Drawing2D`-Löschung beim Geschoss-Löschen.
|
||||||
|
6. **Snapping Stufe 1**: endpoint + grid + ortho (Shift), mit Bildschirm-Markern.
|
||||||
|
7. TopBar-Werkzeuggruppe (select/wall/line) + WallType-/Kategorie-Auswahl;
|
||||||
|
i18n-Keys; Statusleiste zeigt aktives Werkzeug + Kategorie.
|
||||||
|
8. Verifizieren: `npx tsc -b`, `npm run build`, Screenshot via `scripts/probe.mjs`
|
||||||
|
(Wand zeichnen, Gehrung prüfen).
|
||||||
|
|
||||||
|
**Phase 2 — Snapping vervollständigen + 2D-Grundformen.**
|
||||||
|
- Snap: midpoint, intersection, onEdge (Lot), extension-Hilfslinien, Winkelraster
|
||||||
|
(45°), Snap-Einstellungsmenü in der TopBar.
|
||||||
|
- Werkzeuge: Polyline, Rectangle (inkl. optionaler Schraffur für geschlossene
|
||||||
|
Formen). `Primitive`-Erweiterung nur für tatsächlich gebrauchte Formen.
|
||||||
|
|
||||||
|
**Phase 3 — Kurven + Text.**
|
||||||
|
- `Primitive` um `circle` (+ `arc` `largeArc`) und `text` erweitern; PlanView-
|
||||||
|
Renderzweige; Text papierkonstant.
|
||||||
|
- Werkzeuge: Circle, Arc (3-Punkt), Text (Inline-Eingabe). Snap: center/quadrant.
|
||||||
|
|
||||||
|
**Phase 4 — Bearbeitung (Folge-Doku).**
|
||||||
|
- Grips/Editieren bestehender Elemente (Wand-Enden ziehen, 2D-Vertices verschieben),
|
||||||
|
Verschieben/Kopieren/Rotieren der Auswahl, numerische Direkteingabe von
|
||||||
|
Länge/Winkel im HUD. Baut auf demselben Snapping + Draft-Pfad auf. (Eigenes
|
||||||
|
Design-Dokument; hier nur als Ausblick.)
|
||||||
|
|
||||||
|
## 12. Architektur-Garantien (Checkliste)
|
||||||
|
|
||||||
|
- Modell bleibt einzige Wahrheit; Werkzeuge schreiben nur `Project`, nie Plan-
|
||||||
|
Primitive. Ansichten (Plan/3D) leiten ab.
|
||||||
|
- Alle Bezeichner englisch; alle UI-Texte über `t(...)`; Einheiten in Metern,
|
||||||
|
Anzeige via `formatM`; Strichstärken/Texthöhen in mm Papier (non-scaling).
|
||||||
|
- Native-App-Verhalten: kein Browser-Kontextmenü während des Zeichnens; keine
|
||||||
|
Textauswahl (außer Text-Eingabefeld); Pan/Zoom immer verfügbar.
|
||||||
|
- DRY: Vorschau nutzt dieselben `Primitive` + Renderlogik wie der Plan; Snapping
|
||||||
|
und Commit-Pfad sind werkzeugübergreifend geteilt.
|
||||||
|
</content>
|
||||||
|
</invoke>
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
# Design — Bauteile (Elements)
|
||||||
|
|
||||||
|
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||||
|
> Output/Pläne: [plans-output.md](plans-output.md). Ressourcen/Stile:
|
||||||
|
> [resources-graphics.md](resources-graphics.md).
|
||||||
|
|
||||||
|
Dieses Dokument legt die **Daten**, die **Generierung** (3D-Geometrie + Plan-
|
||||||
|
Symbolik) und das **Grip-Editing** je Bauteil fest und übersetzt DOSSIERs
|
||||||
|
`elemente.py` (7244 LOC, Monolith) in **kleine Bauteil-Module** (`src/model/
|
||||||
|
elements/wall.ts`, `opening.ts`, …). Bezeichner englisch, Prosa deutsch, Meter.
|
||||||
|
|
||||||
|
DOSSIERs Architektur dort: pro Element eine **Achse/Outline-Source** (editierbar)
|
||||||
|
+ ein **auto-generiertes Volumen** (`wand_axis`+`wand_volume`, Outline+Brep).
|
||||||
|
Browser-Äquivalent: das **semantische Element ist die Source**; Geometrie wird per
|
||||||
|
`generate*()` **abgeleitet** (nie persistiert). Das ist sauberer als DOSSIERs
|
||||||
|
zwei-Objekt-Modell und kennt kein Cache-Stale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Gemeinsames Fundament
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/model/elements/base.ts
|
||||||
|
interface ElementBase {
|
||||||
|
id: string;
|
||||||
|
type: ElementType; // "wall" | "window" | "door" | "slab" | "stair" | "roof"
|
||||||
|
// | "column" | "beam" | "space" | "draw2d"
|
||||||
|
floorId: string; // Zeichnungsebene (Geschoss); bei gehosteten via Host
|
||||||
|
categoryCode: string; // Ebene (Grafik-Kategorie), z.B. "20"
|
||||||
|
styleId?: string; // optionaler Element-Override-Stil (resources-graphics.md)
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Geometrie-Konvention** (aus CONVENTIONS.md, im Spike etabliert): Wand-Normale
|
||||||
|
`n = leftNormal(u) = (-u.y, u.x)`; bei CCW-Wicklung zeigt `+n` nach innen.
|
||||||
|
Schichten werden außen (`-T/2`) → innen (`+T/2`) gestapelt (`generatePlan.addWallPoche`,
|
||||||
|
`Viewport3D.addWallMeshes`).
|
||||||
|
|
||||||
|
**Detailgrad** (LoD) — DOSSIERs `darstellung` (`auto|einfach|standard|detail`):
|
||||||
|
```ts
|
||||||
|
type DetailLevel = "coarse" | "medium" | "fine"; // einfach | standard | detail
|
||||||
|
// Auflösung: Element-Wert "auto" → Dokument-/Snapshot-Wert; sonst Element-Wert.
|
||||||
|
function resolveDetail(el: ElementBase, doc: { detailLevel: DetailLevel }): DetailLevel
|
||||||
|
```
|
||||||
|
≙ DOSSIER `_resolve_oeff_darstellung` + `get_aktive_darstellung`. Steuert, *wie
|
||||||
|
viel* Symbolik gezeichnet wird (1:500 Rechteck → 1:50 Glas/Sims/Schwenkbogen).
|
||||||
|
|
||||||
|
**Generierungs-Signaturen** (jedes Modul exportiert beides):
|
||||||
|
```ts
|
||||||
|
function build3d(project, el, ctx): THREE.Object3D // Volumen (Schichten/Brep)
|
||||||
|
function generatePlan(project, el, ctx, lod): Primitive[] // Schnittflächen + Symbol
|
||||||
|
// ctx trägt baseElevation, joins, sichtbare Codes, resolver für Components/Styles
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Wand (Wall) — mehrschichtig
|
||||||
|
|
||||||
|
### 1.1 Daten
|
||||||
|
```ts
|
||||||
|
interface Wall extends ElementBase {
|
||||||
|
type: "wall";
|
||||||
|
start: Vec2; end: Vec2; // Achse (Centerline) im Grundriss [im Spike]
|
||||||
|
wallTypeId: string; // → WallType.layers (außen→innen)
|
||||||
|
height: number;
|
||||||
|
reference: "mid" | "left" | "right"; // Referenzlage der Achse (DOSSIER _wand_referenz)
|
||||||
|
baseOffset?: number; // UK relativ zu OKFF (default 0)
|
||||||
|
topOffset?: number; // OK-Override (default = floorHeight)
|
||||||
|
jointRole?: "auto" | "through" | "butt"; // T-Stoss-Rolle (DOSSIER wand_joint_rolle)
|
||||||
|
// Mehrsegment-Wände (Polyline): optional axisPoints statt start/end
|
||||||
|
axisPoints?: Vec2[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`reference` verschiebt die Achse auf Außenkante/Mitte (DOSSIER
|
||||||
|
`_wall_offsets_from_referenz`): hilft beim Modellieren *und* beim Import fremder
|
||||||
|
Pläne (ROADMAP §11). Offsets: `mid → [+T/2, -T/2]`, `left → [0, -T]`, `right → [+T, 0]`.
|
||||||
|
|
||||||
|
### 1.2 Generierung — 3D + Plan (Status: im Spike, einschichtig→mehrschichtig ✅)
|
||||||
|
Beide Sichten extrudieren/füllen **dasselbe gehrte Band-Polygon** pro Schicht.
|
||||||
|
Heute schon vorhanden:
|
||||||
|
- `geometry.clippedBand(start, end, offA, offB, startCut, endCut)` — Band mit
|
||||||
|
Gehrungsschnitt.
|
||||||
|
- `generatePlan.addWallPoche` — pro Schicht ein gefülltes Polygon (Component-Fill +
|
||||||
|
Schraffur), Öffnungen ausgespart.
|
||||||
|
- `Viewport3D.addLayerPrism` — dasselbe Polygon via `ExtrudeGeometry`.
|
||||||
|
|
||||||
|
### 1.3 Wand-Verschneidung (Joins) — Risiko #1
|
||||||
|
|
||||||
|
**Status: L-Ecken-Gehrung ✅** (`joins.computeJoins` → `miterLine`, robust gegen
|
||||||
|
Wicklung + ungleiche Dicken). **Offen: Prioritäts-T-/X-Stöße** bei mehrschichtigen
|
||||||
|
Wänden.
|
||||||
|
|
||||||
|
DOSSIERs gelöste Logik (`elemente._t_junction_layer_overrides`, `_wand_should_apply_t_miter`),
|
||||||
|
die wir portieren:
|
||||||
|
|
||||||
|
1. **Knoten finden:** Endpunkte auf Gitter runden (`roundKey`, existiert),
|
||||||
|
gruppieren. `==1` freies Ende, `==2` L-Ecke (Gehrung, ✅), `>2` T/X.
|
||||||
|
2. **Through-Wand bestimmen:** an einem T-Stoß läuft genau **eine** Wand durch.
|
||||||
|
Auswahl nach `jointRole` (DOSSIER-Regel), sonst nach Component-`joinPriority`:
|
||||||
|
```
|
||||||
|
my.role="through" → ich laufe durch (kein Miter)
|
||||||
|
my.role="butt" → ich stoße an (Miter)
|
||||||
|
beide "auto" → höhere joinPriority = Through-Wand
|
||||||
|
```
|
||||||
|
3. **Schicht-Durchdringung (Backbone):** **nur das Material mit der höchsten
|
||||||
|
gemeinsamen `joinPriority`** in *beiden* Wänden läuft durch und unioniert
|
||||||
|
(T-Form). Beispiel ROADMAP §2d: Beton (800) läuft mittig durch; Putze (100)
|
||||||
|
verbinden sich seitlich, gehen aber nirgends durch den Beton. Alle Nicht-
|
||||||
|
Backbone-Schichten der anstoßenden Wand mitern an der Through-Außenkante
|
||||||
|
(`standard_miter`). Ergebnis: gleichfarbige Außenlagen bilden automatisch
|
||||||
|
saubere L-Stöße.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// joins.ts — Erweiterung der bestehenden API
|
||||||
|
interface WallCuts { startCut: Line|null; endCut: Line|null;
|
||||||
|
// neu: pro-Schicht Overrides am T-Stoss
|
||||||
|
layerExtensions?: number[]; // wie weit jede Schicht in Through-Body drillt
|
||||||
|
layerMiters?: (Line|null)[]; // pro-Schicht Mitre (null = Backbone, läuft durch)
|
||||||
|
}
|
||||||
|
function computeJoins(project, walls): Map<string, WallCuts> // erweitert
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementierungsplan (stufenweise, Risiko #1):**
|
||||||
|
- (a) ✅ L-Gehrung bleibt.
|
||||||
|
- (b) T-Stoß ohne Schichten: Backbone = ganze Wand; Through union, Stem mitert.
|
||||||
|
- (c) T-Stoß mit Schichten: Backbone-Material-Logik wie oben (Port von
|
||||||
|
`_t_junction_layer_overrides`).
|
||||||
|
- (d) X-Stoß: paarweise als zwei T behandeln.
|
||||||
|
- **Booleans:** Union/Extension der Backbone-Säule via **OpenCascade.js/Manifold**
|
||||||
|
im Worker (`workers/geometry.worker.ts`), nur für 3D + exakten B-Rep-Export; der
|
||||||
|
2D-Plan bleibt rein analytisch (Polygon-Clipping, kein Kernel) — schnell.
|
||||||
|
- **Validierung:** Screenshot-Probe der T-Ecke (Beton durch, Putz seitlich).
|
||||||
|
|
||||||
|
### 1.4 Grip-Editing (Risiko #L, Phase 3–4)
|
||||||
|
DOSSIER: Display-Conduit zeichnet dicke Marker an Achs-Endpunkten, MouseCallback
|
||||||
|
fängt Klick → `GetPoint` mit Snap → `_replace_axis_vertex` → Volumen regeneriert
|
||||||
|
(`wand_grips.py`). Browser-Port:
|
||||||
|
- **Marker:** SVG-Kreise (r ≈ 7 px) an Endpunkten/Knicks der *selektierten* Wand,
|
||||||
|
als Overlay über dem Plan (unabhängig von Ebenen-Sichtbarkeit) — exakt DOSSIERs
|
||||||
|
Conduit-Idee.
|
||||||
|
- **Hit-Test:** Pointer-Distanz < 14 px (DOSSIER `_HIT_RADIUS_PX`).
|
||||||
|
- **Drag:** `pointerdown` auf Marker → Live-Preview-Linien zu Nachbar-Vertices →
|
||||||
|
Snap (Endpunkt/Ortho/Raster) → `pointerup` → `store.apply(p => wall.start = newPt)`.
|
||||||
|
Abgeleitete Sichten (Plan + 3D) re-derivieren reaktiv — kein manuelles Regen.
|
||||||
|
- Funktioniert für Line (2 Grips) und Polyline (jeder Knick ein Grip), wie DOSSIER.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Öffnungen (Window / Door) — gehostet, LoD
|
||||||
|
|
||||||
|
### 2.1 Daten
|
||||||
|
```ts
|
||||||
|
interface OpeningBase extends ElementBase {
|
||||||
|
hostWallId: string; // Host-Wand (Geschoss ergibt sich daraus) [im Spike]
|
||||||
|
position: number; // Abstand entlang Wandachse vom Wand-Start (m)
|
||||||
|
width: number; height: number;
|
||||||
|
reference: "mid" | "left" | "right"; // Lage des Klickpunkts in der Öffnung
|
||||||
|
detailLevel: DetailLevel | "auto";
|
||||||
|
frame?: { width: number; depth: number; pos: "outer"|"mid"|"inner"; offset: number };
|
||||||
|
outerSide: "left" | "right"; // welche Wandseite ist außen
|
||||||
|
}
|
||||||
|
interface Window extends OpeningBase {
|
||||||
|
type: "window";
|
||||||
|
sill: number; // Brüstungshöhe
|
||||||
|
sashes: 1|2|3|4; // Flügelzahl
|
||||||
|
sillProfileOut?: "none"|"narrow"|"standard"|"wide"; // Sims außen (DOSSIER _OEFF_SIMS_STYLES)
|
||||||
|
sillProfileIn?: "none"|"narrow"|"standard"|"wide";
|
||||||
|
glass: boolean;
|
||||||
|
}
|
||||||
|
interface Door extends OpeningBase {
|
||||||
|
type: "door";
|
||||||
|
swing: "left" | "right"; // Anschlagseite [im Spike]
|
||||||
|
hinge: "start" | "end"; // Scharnierpfosten [im Spike]
|
||||||
|
openAngle: number; // Plan-Öffnungswinkel 0–180 (default 90)
|
||||||
|
doorType: "normal" | "wall-opening"; // Wandöffnung = ohne Blatt
|
||||||
|
frameType: "casing" | "block"; // Zarge | Blockrahmen
|
||||||
|
lintel?: "none"|"inner"|"outer"|"both";// Sturzlinien-Anzeige (DOSSIER _OEFF_STURZ)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Felder 1:1 aus DOSSIERs `_OEFF_*`-Keys + `_OEFF_STYLE_FIELDS`. Presets (Fenster
|
||||||
|
Standard/Gross/Bandlage, Tür Innen/Eingang/Verglast, Wandöffnung) als
|
||||||
|
Style-Katalog (resources-graphics.md), seed wie `_OEFF_DEFAULT_STYLES`.
|
||||||
|
|
||||||
|
### 2.2 Host-Beziehung (Risiko #2)
|
||||||
|
Die Öffnung kennt ihre Wand (`hostWallId`); ihre Geometrie wird **relativ zur
|
||||||
|
Wandachse** berechnet (`opening.axisFrame(wall, position)` → Punkt + Tangente +
|
||||||
|
Normale, ≙ DOSSIER `_oeff_axis_frame`). Verschiebt sich die Wand, folgt die
|
||||||
|
Öffnung automatisch (sie hält keinen absoluten Punkt). Beim Plan/3D wird die
|
||||||
|
Wand an `[position, position+width]` ausgespart — steht im Spike (`addWallPoche`
|
||||||
|
Segmentierung, `addWallMeshes` Sturz).
|
||||||
|
|
||||||
|
### 2.3 Generierung nach LoD
|
||||||
|
| LoD | Plan-Symbol | 3D |
|
||||||
|
|---|---|---|
|
||||||
|
| **coarse** (1:200/500) | Öffnung als Lücke + dünne Linie | Aussparung, kein Rahmen |
|
||||||
|
| **medium** (1:100) | + Rahmenlinien, Tür-Schwenkbogen (`addDoorSymbol` ✅), Sturz gestrichelt | Aussparung + einfacher Rahmen-Quader |
|
||||||
|
| **fine** (1:50) | + Glas-Doppellinie, Sims, Flügel-Teilung, Anschlag | Rahmen + Blatt + Glas (transparent) + Sims (DOSSIER `_OEFF_PIECE_DEFS`) |
|
||||||
|
|
||||||
|
- **Tür-Schwenkbogen:** im Spike (`generatePlan.addDoorSymbol` — Blatt + Arc).
|
||||||
|
Ausbau: `openAngle`, lichte vs. volle Breite je LoD (Port von
|
||||||
|
`_make_tuer_swing_curves`).
|
||||||
|
- **3D-Stücke** (Rahmen/Glas/Flügel/Sims/Sturz) ≙ DOSSIER `_make_oeffnung_pieces`
|
||||||
|
/ `_OEFF_PIECE_DEFS` — jeweils eigene Component (Farbe + Transparenz: Glas
|
||||||
|
α≈0.88, IOR 1.5). Pieces landen auf Unter-Ebenen von `21 Türen/Fenster`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Decke / Boden (Slab) — mit Aussparungen
|
||||||
|
|
||||||
|
### 3.1 Daten
|
||||||
|
```ts
|
||||||
|
interface Slab extends ElementBase {
|
||||||
|
type: "slab";
|
||||||
|
boundary: Vec2[]; // geschlossener Umriss (CCW)
|
||||||
|
slabTypeId: string; // mehrschichtig (analog WallType)
|
||||||
|
openings?: Vec2[][]; // Aussparungen: Treppenauge, Schacht, Kamin (DOSSIER aussp)
|
||||||
|
ukOverride?: number; okOverride?: number; // UK/OK statt auto (Abhängung, schräge Brüstung)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Generierung
|
||||||
|
- **Z-Auflösung:** `okOverride ?? (baseElevation_oberes_Geschoss)`,
|
||||||
|
`ukOverride ?? (ok - thickness)` — Port von `_resolve_decke_z`. Decke sitzt
|
||||||
|
standardmäßig zwischen zwei Geschossen.
|
||||||
|
- **3D:** `boundary` als `THREE.Shape`, Aussparungen als `shape.holes` (`THREE.Path`),
|
||||||
|
`ExtrudeGeometry` über die Schichten (≙ `_make_decke_volume(outline, holes)`).
|
||||||
|
- **Plan:** im Schnitt unter `cutHeight` meist nur Kante; Aussparungs-Ränder als
|
||||||
|
Linien; geschnittene Decke (in Schnitt-Ansicht) bekommt Schraffur.
|
||||||
|
- **Aussparung↔Decke:** Aussparung als geschlossene Curve, die räumlich in der
|
||||||
|
Decke liegt (`_find_decke_containing_point` / `_find_aussparungen_for_decke`).
|
||||||
|
Bei uns: `Slab.openings` direkt im Slab — keine separate Source nötig (einfacher
|
||||||
|
als DOSSIERs Parent-Child).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Treppe (Stair) — Typen, Lauflinie, geschossübergreifend
|
||||||
|
|
||||||
|
### 4.1 Daten
|
||||||
|
```ts
|
||||||
|
interface Stair extends ElementBase {
|
||||||
|
type: "stair";
|
||||||
|
kind: "straight" | "l-shaped" | "spiral"; // gerade | L | Wendel (DOSSIER _TREPPE_ARTEN)
|
||||||
|
run: Vec2[]; // Lauflinien-Stützpunkte (gerade: 2; L: 3; Wendel: Zentrum+Start)
|
||||||
|
width: number;
|
||||||
|
reference: "mid" | "left" | "right"; // Lage der Lauflinie zur Treppe
|
||||||
|
steps: number; // Anzahl Steigungen
|
||||||
|
mode: "solid" | "flat" | "slab-edge"; // massiv | flach | Plattenrand
|
||||||
|
runSlabThickness?: number; // Lauf-Plattendicke
|
||||||
|
floorEndId?: string; // Zielgeschoss (geschossübergreifend, Risiko #6)
|
||||||
|
heightOverride?: number; ukOverride?: number;
|
||||||
|
rules?: { riser:[lo,hi,on]; tread:[lo,hi,on]; stepGo:[lo,hi,on] }; // SIA-Komfortregeln
|
||||||
|
lockRiser?: { value: number }; // Schrittmass-Lock (S fix, N passt sich an)
|
||||||
|
// Plan-Symbol-Flags (DOSSIER _KEY_TREPPE_SHOW_*)
|
||||||
|
show?: { treads; runLine; outline; breakLine };
|
||||||
|
upperDashed?: boolean; // obere Stufen gestrichelt (über Schnitthöhe)
|
||||||
|
arrowStyle?: "classic"|"filled"|"double"|"line";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Generierung
|
||||||
|
- **Steigung/Auftritt:** `riser = height/steps`; `tread` aus Lauflinienlänge /
|
||||||
|
(steps−1). SIA-Komfort: `2·riser + tread ∈ [0.60, 0.65]` (DOSSIER
|
||||||
|
`_TREPPE_SOLL_DEFAULT`). Lock: ist `lockRiser` gesetzt, wird `steps` neu
|
||||||
|
berechnet statt `riser` zu ändern.
|
||||||
|
- **3D je `kind`:** gerade → Stapel von Tritt-Quadern oder massive Rampe;
|
||||||
|
L → zwei Läufe + Podest (`podestMin`); Wendel → um Zentrum rotierte Tritte
|
||||||
|
(Port `_make_treppe_*_preview` / Volume-Funktionen). `mode` steuert massiv vs.
|
||||||
|
Lauf-Platte.
|
||||||
|
- **Geschossübergreifend (Risiko #6):** Höhe = `(baseElevation[floorEndId] -
|
||||||
|
baseElevation[floorId])` falls `floorEndId` gesetzt; sonst Geschosshöhe. Treppe
|
||||||
|
taucht dann in beiden Geschoss-Grundrissen auf (mit Schnitt an `cutHeight`).
|
||||||
|
- **Plan-Symbol (normgerecht):** Lauflinie mit **Auf-/Abpfeil** (`arrowStyle`),
|
||||||
|
Stufenkanten, Bruchlinie an `cutHeight` (untere durchgezogen, obere gestrichelt
|
||||||
|
via `upperDashed`), Außenkante. ≙ DOSSIERs 2D-Treppensymbol; liegt auf Ebene
|
||||||
|
`40 Treppen`/`41 Treppen-2D`.
|
||||||
|
|
||||||
|
### 4.3 Grip-Editing
|
||||||
|
Lauflinien-Stützpunkte als Grips (wie Wand-Vertices, §1.4); Ziehen ändert
|
||||||
|
Geometrie + Stufenzahl reaktiv.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dach (Roof)
|
||||||
|
|
||||||
|
### 5.1 Daten
|
||||||
|
```ts
|
||||||
|
interface Roof extends ElementBase {
|
||||||
|
type: "roof";
|
||||||
|
outline: Vec2[]; // Grundriss-Umriss
|
||||||
|
roofType: "mono" | "gable" | "hip" | "mansard"; // Pult|Sattel|Walm|Mansarde
|
||||||
|
thickness: number;
|
||||||
|
slope: number; // Grad (Hauptneigung)
|
||||||
|
eaveIndex?: number; // Index der Traufkante (Pult)
|
||||||
|
ridge?: "long" | "short"; // Firstrichtung (Sattel)
|
||||||
|
// Mansarde:
|
||||||
|
slopeLower?: number; kinkHeight?: number;
|
||||||
|
mansardVariant?: "hip" | "gable" | "hip-gable";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Generierung
|
||||||
|
Port von DOSSIERs `_make_pultdach/_satteldach/_walmdach/_mansardendach*` +
|
||||||
|
`_thicken_roof_inward`. Aufwand M–L (Mansarde später). Reihenfolge: Pult →
|
||||||
|
Sattel → Walm → Mansarde. 3D als Brep/Mesh über OpenCascade.js (Worker), da
|
||||||
|
Schräg-Verschneidung Booleans braucht. Plan: Firstlinien + Traufe + ggf.
|
||||||
|
Höhenkoten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Tragwerk (Column / Beam)
|
||||||
|
|
||||||
|
### 6.1 Daten
|
||||||
|
```ts
|
||||||
|
interface ProfileDef {
|
||||||
|
shape: "square"|"rect"|"round"|"i-beam"|"tube"; // DOSSIER _TRAG_PROFILE
|
||||||
|
b?: number; h?: number; d?: number; t?: number; // Breite/Höhe/Durchm./Wanddicke
|
||||||
|
angle: number; // Rotation um Z
|
||||||
|
}
|
||||||
|
interface Column extends ElementBase { type:"column"; point: Vec2; profile: ProfileDef;
|
||||||
|
uk?: number; ok?: number; }
|
||||||
|
interface Beam extends ElementBase { type:"beam"; axis:[Vec2,Vec2]; profile: ProfileDef;
|
||||||
|
zTop?: number; // hängt unter Decken-OK (zTop = ok der Decke)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Generierung
|
||||||
|
- **Querschnitt:** `profileCurve(shape, b,h,d,t, angle)` (Port `_trag_profile_curve`)
|
||||||
|
→ für Stütze entlang Z extrudieren (`_make_stuetze_volume`), für Träger entlang
|
||||||
|
der Achse (`_make_traeger_volume`, Profil in der Schnitt-Ebene).
|
||||||
|
- **Träger achs-basiert unter Decke:** `zTop` default = OK der darüberliegenden
|
||||||
|
Decke → Unterzug folgt automatisch (weniger Update-Fehler, ROADMAP §11).
|
||||||
|
- Stützen liegen auf `25 Stützen`, Träger auf `35 Träger`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Raum (Space) — SIA-416 + Stempel
|
||||||
|
|
||||||
|
### 7.1 Daten
|
||||||
|
```ts
|
||||||
|
interface Space extends ElementBase {
|
||||||
|
type: "space";
|
||||||
|
boundary: Vec2[]; // geschlossener Umriss
|
||||||
|
number?: string; spaceName?: string; function?: string;
|
||||||
|
sia?: "" | "HNF"|"NNF"|"VF"|"FF"|"GF"|"AGF"; // SIA-416-Klasse
|
||||||
|
persons?: number; // Personenbelegung (Brandschutz)
|
||||||
|
areaRounding: "exact"|"0.01"|"0.1"|"0.5"|"1";
|
||||||
|
stamp: StampConfig; // Raumstempel-Layout (s.u.)
|
||||||
|
fill?: string; // Füll-Hatch-Id (optional)
|
||||||
|
}
|
||||||
|
interface StampConfig { // ≙ DOSSIER Stempel-Builder
|
||||||
|
layout: FieldId[][]; // Zeilen × Felder, z.B. [["number","name"],["function"],["area"]]
|
||||||
|
font; bold; italic; textHeight; textMode:"fixed"|"scale"; align:"left"|"mid"|"right";
|
||||||
|
offset: Vec2; // Stempel-Position relativ zum Centroid (User-Move)
|
||||||
|
}
|
||||||
|
type FieldId = "number"|"name"|"function"|"area"|"sia";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Generierung & Bilanz
|
||||||
|
- **Fläche:** Shoelace-Formel über `boundary`, gerundet nach `areaRounding`
|
||||||
|
(`_resolve_raum_rundung`). Umfang analog.
|
||||||
|
- **Stempel:** als SVG-Text-Block aus `layout`-Zeilen am Centroid + `offset`
|
||||||
|
(User kann verschieben; Offset persistiert wie DOSSIER `stamp_dx/dy`).
|
||||||
|
`textMode:"scale"` → Texthöhe in Paper-mm × Massstab (plans-output.md).
|
||||||
|
- **SIA-Färbung:** über die **Overrides-Engine** (regelbasiert), nicht hartcodiert
|
||||||
|
— DOSSIER `_build_sia_preset_rules` erzeugt 4 Regeln `userString sia == hnf|nnf|vf|ff`
|
||||||
|
→ Farbe + Solid-Hatch. Bei uns: ein Override-Preset „SIA-416" (resources-graphics.md),
|
||||||
|
das auf `space.sia` matcht. Toggle = Preset aktivieren.
|
||||||
|
- **SIA-Bilanz + CSV:** `panels/SiaBalance.tsx` summiert Flächen je Klasse je
|
||||||
|
Geschoss → Tabelle + CSV-Export (`HNF/NNF/VF/FF/GF/AGF`). Pflicht für
|
||||||
|
CH-Flächennachweis (ROADMAP ⭐).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Werkzeuge (Tools) — ersetzt Rhino-Command-Aliases
|
||||||
|
|
||||||
|
DOSSIER hat pro Bauteil ein Command-Alias (`rhino/aliases/cmd/wand.py`, `tuer.py`,
|
||||||
|
`treppe.py`, …) das `GetPoint`-Interaktionen fährt. Browser: ein **Tool-Interface**
|
||||||
|
mit Pointer-Handlern + Snap.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Tool {
|
||||||
|
id: ToolId;
|
||||||
|
onPointerDown(pt: Vec2, snap: SnapResult, state): void;
|
||||||
|
onPointerMove(pt: Vec2, snap: SnapResult, state): Primitive[]; // Live-Preview
|
||||||
|
onPointerUp(pt: Vec2, snap: SnapResult, state): void;
|
||||||
|
commit(store): void; // ruft store.apply()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tool | DOSSIER-Alias | Kurzbeschrieb |
|
||||||
|
|---|---|---|
|
||||||
|
| `wall` | `cmd/wand` | Achse zeichnen (Linie/Polyline), Dicke/Referenz/Typ aus „last used" |
|
||||||
|
| `door`/`window` | `cmd/tuer`,`fenster` | Punkt auf Wandachse → hosten (Snap an Wand) |
|
||||||
|
| `slab` | `cmd/decke` | Umriss klicken; Aussparung als Loch |
|
||||||
|
| `stair` | `cmd/treppe` | Lauflinie + Breite + Stufen |
|
||||||
|
| `roof` | `cmd/dach` | Umriss + Typ + Neigung |
|
||||||
|
| `column`/`beam` | `cmd/stuetze`,`traeger` | Punkt / Achse + Profil |
|
||||||
|
| `space` | `cmd/raum` | Umriss → Fläche auto, Stempel |
|
||||||
|
| `draw2d` | `cmd/symbol`,`stempel` | Linie/Polyline/Rect/Kreis/Bogen/Text auf `60 Plangrafik` |
|
||||||
|
| `pipette` | `cmd/pipette` | Stil/Typ von Element übernehmen |
|
||||||
|
|
||||||
|
**Snap-Engine** (`tools/snap.ts`): Endpunkt, Mitte, Schnitt, senkrecht, Raster,
|
||||||
|
Ortho — ersetzt Rhinos OSnap. T-Snap an andere Wandachsen (Port
|
||||||
|
`_t_snap_to_wand_axis`, `_snap_endpoint_to_other_wand_axis`) sorgt für saubere
|
||||||
|
Knoten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Element-Übersicht (BIM-Tree)
|
||||||
|
`panels/ElementTree.tsx`: Baum Geschoss → Bauteiltyp → Element, mit Suche und
|
||||||
|
Shift-Klick = Zoom (DOSSIER ELEMENTE-ÜBERSICHT). Inhaltsverzeichnis bei 100+
|
||||||
|
Elementen — reine Ableitung aus `project.elements`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Reihenfolge der Umsetzung (verweist auf ROADMAP-Phasen)
|
||||||
|
|
||||||
|
1. **Phase 1:** Wand mehrschichtig ✅ + L-Gehrung ✅ → **Prio-T-Stoß** (§1.3);
|
||||||
|
Tür/Fenster gehostet (§2); Decke + Aussparung (§3); Wand-Referenzlage (§1.1);
|
||||||
|
Element-Übersicht (§9).
|
||||||
|
2. **Phase 2:** Treppe (§4), Dach (§5), Tragwerk (§6), SIA-Räume + Stempel (§7),
|
||||||
|
Stil-Kataloge.
|
||||||
|
3. **Phase 3–4:** Grip-Editing (§1.4/§4.3), exakte B-Rep-Booleans im Worker.
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
# Design — Ebenen-Darstellung (Layer Display Settings)
|
||||||
|
|
||||||
|
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||||
|
> Ressourcen/Stile: [resources-graphics.md](resources-graphics.md). Output/Pläne:
|
||||||
|
> [plans-output.md](plans-output.md). Kontextmenü/Inline-Editor:
|
||||||
|
> [context-menu.md](context-menu.md).
|
||||||
|
|
||||||
|
Dieses Dokument spezifiziert die **per-Ebene Darstellungseinstellungen** auf der
|
||||||
|
`LayerCategory` (Grafik-Kategorie) und den dazugehörigen Editor
|
||||||
|
„Ebeneneinstellungen…", der aus dem Ebenen-Kontextmenü geöffnet wird.
|
||||||
|
|
||||||
|
Heute trägt jede `LayerCategory` nur eine flache Strichstärke (`lw`), eine `color`
|
||||||
|
und eine optionale `hatch` (ein freier String, der nirgends aufgelöst wird). Das
|
||||||
|
reicht nicht: Eine Ebene soll — wie in Vectorworks/DOSSIER — einen vollständigen
|
||||||
|
**Stift (PEN)** und eine vollständige **Standard-Schraffur (HATCH)** definieren,
|
||||||
|
die beim Rendern angewandt werden. Bezeichner englisch, Prosa/UI-Text deutsch
|
||||||
|
(CONVENTIONS.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zielbild
|
||||||
|
|
||||||
|
Jede Ebene definiert zwei Darstellungs-Aspekte, die in den Grundriss-Generator
|
||||||
|
einfließen:
|
||||||
|
|
||||||
|
- **PEN** — Linienstil der Ebene: `type` (durchgezogen / gestrichelt / …),
|
||||||
|
`color` und `lw` (Strichstärke in mm Papier). Steuert alle Umriss-/Symbol-Linien
|
||||||
|
der Elemente dieser Ebene (Wand-Umriss, Tür-Symbol, Referenzlinie).
|
||||||
|
- **HATCH** — Standard-Schraffur der Ebene: `pattern`, `scale`, `angle` und die
|
||||||
|
`lineWeight` der Musterlinien. Wird angewandt, wo ein Element keine eigene
|
||||||
|
Schraffur aus einem `Component` mitbringt (z. B. einschichtige/„grob"-Flächen,
|
||||||
|
reine 2D-Zeichnungsobjekte einer Ebene).
|
||||||
|
|
||||||
|
Beides folgt dem Architektur-Prinzip: **Darstellung wird beim Rendern aufgelöst,
|
||||||
|
nie in die Geometrie eingebacken.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Reference vs. Inline — Entscheidung
|
||||||
|
|
||||||
|
Es gibt drei Modelle, ein Datum für PEN/HATCH einer Ebene zu halten:
|
||||||
|
|
||||||
|
1. **Pure inline** — die Ebene trägt `{type,color,lw}` und `{pattern,scale,angle,
|
||||||
|
lineWeight}` direkt. Einfach, aber: kein Wiederverwenden, kein zentrales
|
||||||
|
Ändern; widerspricht der Ressourcen-Architektur (resources-graphics.md §1:
|
||||||
|
„alles verweist per id, zentral änderbar").
|
||||||
|
2. **Pure reference** — die Ebene trägt nur `lineStyleId` / `hatchId`. Konsistent,
|
||||||
|
zentral, aber unflexibel: Eine Ebene kann z. B. nicht „den Stil X, aber in
|
||||||
|
ihrer eigenen Farbe" wollen, ohne einen Klon-Stil anzulegen.
|
||||||
|
3. **Reference + optionale per-Ebene Overrides** (EMPFOHLEN) — die Ebene
|
||||||
|
**verweist** auf eine `LineStyle`- bzw. `HatchStyle`-Ressource und darf
|
||||||
|
**einzelne Felder lokal überschreiben**. Das ist exakt das DOSSIER/Vectorworks-
|
||||||
|
Muster: ein Stil als Basis, regelbasierte/lokale Overrides obendrauf
|
||||||
|
(resources-graphics.md, `overrides.py`).
|
||||||
|
|
||||||
|
### Empfehlung: Reference + optionale Overrides
|
||||||
|
|
||||||
|
Begründung:
|
||||||
|
|
||||||
|
- **Zentrale Pflege bleibt erhalten:** Ändert man den Linienstil „Wand stark" im
|
||||||
|
Line Manager, ziehen alle Ebenen nach, die ihn referenzieren und das jeweilige
|
||||||
|
Feld nicht überschreiben.
|
||||||
|
- **Lokale Freiheit ohne Stil-Wildwuchs:** Eine Ebene kann punktuell `color` oder
|
||||||
|
`lw` anpassen (häufigster Fall: gleiche Strichart, andere Farbe), ohne einen
|
||||||
|
fast identischen Stil zu duplizieren.
|
||||||
|
- **Migrationsfähig:** Die heutige flache `{color, lw}` der Ebene wird zu reinen
|
||||||
|
Overrides über einem neutralen Basis-Stil — verlustfrei (siehe §6).
|
||||||
|
- **Konsistent mit der bestehenden Kette:** `Component → Hatch → LineStyle`
|
||||||
|
verweist bereits per id; Ebenen reihen sich nahtlos ein.
|
||||||
|
|
||||||
|
Die Overrides sind **sparse**: nur gesetzte Felder überschreiben. Ein leeres
|
||||||
|
Override-Objekt (oder `undefined`) bedeutet „komplett dem Stil folgen".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Datenmodell (TS)
|
||||||
|
|
||||||
|
### 3.1 LineStyle erweitern um `type`
|
||||||
|
|
||||||
|
`LineStyle` trägt heute schon `weight`, `color`, `dash`. Wir machen die
|
||||||
|
Strichart explizit benennbar (statt nur via `dash`-Array), damit der Editor ein
|
||||||
|
sauberes Dropdown anbietet und `dash` daraus ableiten kann.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Benannte Strichart eines Stifts (für UI-Dropdown). */
|
||||||
|
export type LineKind = "solid" | "dashed" | "dotted" | "dashdot";
|
||||||
|
|
||||||
|
/** mm-Strichmuster je Strichart (relativ zur Papier-mm). */
|
||||||
|
export const LINE_DASH: Record<LineKind, number[] | null> = {
|
||||||
|
solid: null,
|
||||||
|
dashed: [0.6, 0.4],
|
||||||
|
dotted: [0.1, 0.25],
|
||||||
|
dashdot: [0.6, 0.25, 0.1, 0.25],
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LineStyle {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** NEU: benannte Strichart; `dash` wird daraus abgeleitet, falls nicht gesetzt. */
|
||||||
|
kind: LineKind;
|
||||||
|
/** Strichstärke in Millimetern (≙ Rhino PlotWeight). */
|
||||||
|
weight: number;
|
||||||
|
color: string;
|
||||||
|
/** Strichmuster in mm; `null` = durchgezogen. Optional — sonst aus `kind`. */
|
||||||
|
dash: number[] | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Hinweis: `kind` ist additiv; bestehende `LineStyle`-Daten setzen es per Migration
|
||||||
|
> aus `dash` (§6).
|
||||||
|
|
||||||
|
### 3.2 PEN- und HATCH-Override-Typen
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Per-Ebene Stift (PEN). Verweist auf einen LineStyle; einzelne Felder dürfen
|
||||||
|
* lokal überschrieben werden. Alle Override-Felder optional (sparse).
|
||||||
|
*/
|
||||||
|
export interface LayerPen {
|
||||||
|
/** Basis-Linienstil (Line Manager). */
|
||||||
|
lineStyleId: string;
|
||||||
|
/** Lokale Overrides — nur gesetzte Felder gewinnen. */
|
||||||
|
override?: {
|
||||||
|
kind?: LineKind;
|
||||||
|
color?: string;
|
||||||
|
/** Strichstärke in mm Papier. */
|
||||||
|
lw?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-Ebene Standard-Schraffur (HATCH). Verweist auf einen HatchStyle; einzelne
|
||||||
|
* Felder dürfen lokal überschrieben werden. `enabled=false` = Ebene hat keine
|
||||||
|
* Default-Schraffur (Umriss-only).
|
||||||
|
*/
|
||||||
|
export interface LayerHatch {
|
||||||
|
/** Aktiv? false = keine Default-Schraffur dieser Ebene. */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Basis-Schraffur (Hatch Manager). */
|
||||||
|
hatchId: string;
|
||||||
|
/** Lokale Overrides — nur gesetzte Felder gewinnen. */
|
||||||
|
override?: {
|
||||||
|
pattern?: HatchPattern;
|
||||||
|
scale?: number;
|
||||||
|
/** Drehung in Grad. */
|
||||||
|
angle?: number;
|
||||||
|
color?: string;
|
||||||
|
/** Strichstärke der Musterlinien in mm Papier. */
|
||||||
|
lineWeight?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 LayerCategory erweitern
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface LayerCategory {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
|
||||||
|
// ── NEU: vollständige Darstellung ──────────────────────────────────────────
|
||||||
|
/** Stift der Ebene (PEN) — Linien aller Elemente dieser Ebene. */
|
||||||
|
pen: LayerPen;
|
||||||
|
/** Standard-Schraffur der Ebene (HATCH). */
|
||||||
|
hatch: LayerHatch;
|
||||||
|
|
||||||
|
/** Unterkategorien (Baum). */
|
||||||
|
children?: LayerCategory[];
|
||||||
|
|
||||||
|
// ── DEPRECATED (nur Übergang; siehe Migration §6) ──────────────────────────
|
||||||
|
/** @deprecated → pen.override.color. */
|
||||||
|
color?: string;
|
||||||
|
/** @deprecated → pen.override.lw. */
|
||||||
|
lw?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`color` und `lw` bleiben als optionale, deprecatete Felder bestehen, bis alle
|
||||||
|
Lesepfade auf den Resolver (§4) umgestellt sind, und werden dann entfernt. Die
|
||||||
|
Panel-Swatch (`LayersPanel`) liest künftig die **aufgelöste** Stift-Farbe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Resolver — vom Modell zur Render-Entscheidung
|
||||||
|
|
||||||
|
Der Resolver löst PEN/HATCH einer Ebene gegen die Ressourcen-Bibliotheken auf und
|
||||||
|
wendet die Overrides an. Er ist die **einzige** Stelle, an der „Stil + Override"
|
||||||
|
zusammenfließen; Generator und Panel rufen nur ihn.
|
||||||
|
|
||||||
|
### 4.1 Aufgelöste Render-Typen
|
||||||
|
|
||||||
|
`HatchRender` existiert bereits in `generatePlan.ts`. Wir ergänzen ein paralleles
|
||||||
|
`PenRender` und exportieren beide Resolver aus einem neuen Modul
|
||||||
|
`src/model/layerStyle.ts` (damit Panel und Generator teilen).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Aufgelöster Stift einer Ebene — alles, was die Linie zu zeichnen braucht. */
|
||||||
|
export interface PenRender {
|
||||||
|
color: string;
|
||||||
|
/** Strichstärke in mm Papier. */
|
||||||
|
lw: number;
|
||||||
|
/** Strichmuster in mm Papier; null = durchgezogen. */
|
||||||
|
dash: number[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HatchRender: bereits in generatePlan.ts definiert (pattern, scale, angle,
|
||||||
|
// color, lineWeight, dash). Wird nach layerStyle.ts gezogen und re-exportiert.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Resolver-Funktionen (Pseudocode)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function resolvePen(project: Project, layer: LayerCategory): PenRender {
|
||||||
|
const ls = getLineStyle(project, layer.pen.lineStyleId); // wirft, falls fehlend
|
||||||
|
const o = layer.pen.override ?? {};
|
||||||
|
const kind = o.kind ?? ls.kind;
|
||||||
|
return {
|
||||||
|
color: o.color ?? ls.color,
|
||||||
|
lw: o.lw ?? ls.weight,
|
||||||
|
// Override-kind setzt das dash neu; sonst Stil-dash bzw. aus kind abgeleitet.
|
||||||
|
dash: o.kind ? LINE_DASH[o.kind] : (ls.dash ?? LINE_DASH[ls.kind]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLayerHatch(project: Project, layer: LayerCategory): HatchRender | null {
|
||||||
|
if (!layer.hatch.enabled) return null; // Ebene ohne Default-Schraffur
|
||||||
|
const h = getHatch(project, layer.hatch.hatchId); // wirft, falls fehlend
|
||||||
|
const o = layer.hatch.override ?? {};
|
||||||
|
// Musterlinien-Stärke: Override > LineStyle der Schraffur > Default 0.13 mm.
|
||||||
|
const baseLs = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null;
|
||||||
|
return {
|
||||||
|
pattern: o.pattern ?? h.pattern,
|
||||||
|
scale: o.scale ?? h.scale,
|
||||||
|
angle: o.angle ?? h.angle,
|
||||||
|
color: o.color ?? h.color,
|
||||||
|
lineWeight: o.lineWeight ?? baseLs?.weight ?? 0.13,
|
||||||
|
dash: baseLs?.dash ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Beide bauen eine `Map<code, …>` über den ganzen Baum, analog zur heutigen
|
||||||
|
`categoryLwMap`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function penMap(project: Project): Map<string, PenRender> {
|
||||||
|
const m = new Map<string, PenRender>();
|
||||||
|
for (const c of flattenCategories(project.layers)) m.set(c.code, resolvePen(project, c));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
export function layerHatchMap(project: Project): Map<string, HatchRender | null> {
|
||||||
|
const m = new Map<string, HatchRender | null>();
|
||||||
|
for (const c of flattenCategories(project.layers))
|
||||||
|
m.set(c.code, resolveLayerHatch(project, c));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Einfluss auf `generatePlan`
|
||||||
|
|
||||||
|
Heute (generatePlan.ts):
|
||||||
|
|
||||||
|
- `categoryLwMap(project.layers)` liefert nur `lw` je Code; die Umriss-Strichstärke
|
||||||
|
kommt daraus, **Farbe** der Umrisse ist fest `POCHE_STROKE`.
|
||||||
|
- Schraffur kommt ausschließlich aus dem `Component` der jeweiligen Schicht
|
||||||
|
(`resolveHatch(project, comp.hatchId)`); die Ebenen-`hatch` wird **nicht** genutzt.
|
||||||
|
|
||||||
|
Änderungen (minimal-invasiv, additiv):
|
||||||
|
|
||||||
|
### 5.1 Pens ersetzen `lwByCode`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const pens = penMap(project); // statt categoryLwMap
|
||||||
|
const layerHatches = layerHatchMap(project);
|
||||||
|
…
|
||||||
|
const pen = pens.get(wall.categoryCode) ?? FALLBACK_PEN; // {color, lw, dash}
|
||||||
|
```
|
||||||
|
|
||||||
|
`addWallPoche` und `addDoorSymbol` bekommen statt `wallLwMm: number` /
|
||||||
|
`doorLwMm: number` jeweils das ganze `pen: PenRender`:
|
||||||
|
|
||||||
|
- **Wand-Umrisslinie:** `stroke: pen.color` (statt fix `POCHE_STROKE`),
|
||||||
|
`strokeWidthMm: pen.lw * OUTLINE_DETAIL_FACTOR[detail]`, `dash: pen.dash`.
|
||||||
|
→ Das `Primitive` „polygon" braucht ein optionales `dash?: number[] | null`
|
||||||
|
(Schichtfugen bleiben durchgezogen; nur die Umriss-Kontur nutzt `pen.dash`).
|
||||||
|
- **Schichtfugen:** behalten `POCHE_STROKE` und ihre dünne `LAYER_LINE_MM`
|
||||||
|
(interne Hilfslinien sind bewusst neutral, nicht stift-gefärbt).
|
||||||
|
- **Tür-Symbol / Referenzlinie:** `cls` bleibt, aber `weightMm` aus `pen.lw`,
|
||||||
|
und die PlanView darf die Stift-Farbe nutzen (`door-leaf` etc. erhalten optional
|
||||||
|
ein `stroke`-Feld am line/arc-Primitive; ansonsten greift die CSS-Klasse wie
|
||||||
|
bisher).
|
||||||
|
|
||||||
|
### 5.2 Default-Schraffur der Ebene
|
||||||
|
|
||||||
|
Die Ebenen-Schraffur greift dort, wo **keine Component-Schraffur** vorliegt:
|
||||||
|
|
||||||
|
- **`detail === "grob"`** (eine Sammelfläche, heute `NO_HATCH`): statt `NO_HATCH`
|
||||||
|
nun `layerHatches.get(wall.categoryCode) ?? NO_HATCH`. So bekommt die grobe
|
||||||
|
Poché die Standard-Schraffur der Ebene (z. B. ein leichtes Diagonalmuster),
|
||||||
|
falls die Ebene eine definiert; sonst bleibt sie ungeschraffiert.
|
||||||
|
- **mittel/fein, mehrschichtig:** unverändert — die Component-Schraffur je Schicht
|
||||||
|
hat Vorrang (spezifischer als die Ebene). Die Ebenen-Schraffur ist der
|
||||||
|
*Fallback*, nicht der Default-Override.
|
||||||
|
- **Reine 2D-Zeichnungsobjekte** (künftige `drawing`-Ebenen-Elemente ohne
|
||||||
|
Component): nutzen direkt `resolveLayerHatch` als ihre Füllschraffur.
|
||||||
|
|
||||||
|
Auflöse-Reihenfolge der Schraffur einer gezeichneten Fläche:
|
||||||
|
|
||||||
|
```
|
||||||
|
Component.hatch > LayerCategory.hatch (enabled) > keine Schraffur
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Geänderte Signaturen (Zusammenfassung)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// vorher: addWallPoche(out, project, wall, doors, cuts, greyed, detail, wallLwMm)
|
||||||
|
function addWallPoche(out, project, wall, doors, cuts, greyed, detail,
|
||||||
|
pen: PenRender, layerHatch: HatchRender | null): void
|
||||||
|
|
||||||
|
// vorher: addDoorSymbol(out, wall, door, greyed, detail, doorLwMm)
|
||||||
|
function addDoorSymbol(out, wall, door, greyed, detail, pen: PenRender): void
|
||||||
|
```
|
||||||
|
|
||||||
|
`Primitive` (polygon) erhält optional `dash?: number[] | null`; line/arc erhalten
|
||||||
|
optional `stroke?: string`, damit Pen-Farbe durchschlagen kann (CSS-Klasse bleibt
|
||||||
|
Default).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Editor „Ebeneneinstellungen…"
|
||||||
|
|
||||||
|
Geöffnet wie heute über `layerMenuItems → openLayerEditor(code)` →
|
||||||
|
`setEditor({ kind: "layer", code, x, y })`. Der bestehende `InlineEditor`-Rahmen
|
||||||
|
(dunkel, am Anker, Esc/Außenklick schließt) und die `EditorField`-Zeilen bleiben;
|
||||||
|
der Inhalt wächst von 3 Feldern auf zwei kompakte Abschnitte **PEN** und **HATCH**.
|
||||||
|
|
||||||
|
Da der Editor jetzt mehr Felder trägt, wird er als **kompakte Sektions-Form**
|
||||||
|
gestaltet (zwei Gruppen mit Trenn-Überschrift), gemäß CONVENTIONS.md UI-Konventionen
|
||||||
|
(saubere Form, keine wiederholten Beschriftungen, DOSSIER-Stil, alles via `t()`).
|
||||||
|
|
||||||
|
### 6.1 Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
┌ Ebene 20 ───────────────── ×
|
||||||
|
│ Name [ Wände ]
|
||||||
|
│
|
||||||
|
│ ── Stift (PEN) ──────────────
|
||||||
|
│ Linienstil [ Wand stark ▾ ] ← Dropdown über project.lineStyles
|
||||||
|
│ Strichart [ durchgezogen ▾ ] ← override.kind (leer = "vom Stil")
|
||||||
|
│ Farbe [■] [↺] ← override.color; ↺ = Override entfernen
|
||||||
|
│ Stärke [ 0.35 ] mm [↺] ← override.lw
|
||||||
|
│
|
||||||
|
│ ── Schraffur (HATCH) ────────
|
||||||
|
│ [✓] aktiv
|
||||||
|
│ Schraffur [ Beton ▾ ] ← Dropdown über project.hatches
|
||||||
|
│ Muster [ vom Stil ▾ ] ← override.pattern
|
||||||
|
│ Maßstab [ 1.00 ] [↺]
|
||||||
|
│ Drehung [ 45 ] ° [↺]
|
||||||
|
│ Farbe [■] [↺]
|
||||||
|
│ Linienst. [ 0.13 ] mm [↺]
|
||||||
|
└──────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Override-Semantik im UI:** Jedes Override-Feld zeigt entweder „vom Stil"
|
||||||
|
(Override leer → Platzhalter mit dem aufgelösten Stil-Wert als Hint) oder einen
|
||||||
|
konkreten Wert. Ein kleiner **Reset-Knopf `↺`** je Override-Feld löscht das
|
||||||
|
Override (setzt es zurück auf `undefined` → Feld folgt wieder dem Stil).
|
||||||
|
- **Live, kein Bestätigen:** wie der heutige Editor — jede Änderung ruft sofort
|
||||||
|
`patchCategory(code, patch)`.
|
||||||
|
- **i18n:** alle Labels über `t()`. Neue Keys (Beispiele):
|
||||||
|
`editor.pen`, `editor.lineStyle`, `editor.lineKind`, `editor.color`,
|
||||||
|
`editor.lineWeight`, `editor.hatch`, `editor.hatchEnabled`, `editor.pattern`,
|
||||||
|
`editor.scale`, `editor.rotation`, `editor.fromStyle`, `editor.resetOverride`.
|
||||||
|
Strichart-/Muster-Werte: `lineKind.solid`, `lineKind.dashed`, …,
|
||||||
|
`hatchPattern.solid`, `hatchPattern.diagonal`, … . Menü-Label bleibt
|
||||||
|
`ctx.layerSettings`.
|
||||||
|
|
||||||
|
### 6.2 Patch-Helfer
|
||||||
|
|
||||||
|
`patchCategory(code, patch: Partial<LayerCategory>)` bleibt die Schnittstelle.
|
||||||
|
Für die verschachtelten Overrides nutzt der Editor schmale Helfer (im App-Scope),
|
||||||
|
die sparse mergen und leere Overrides auf `undefined` kollabieren:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function setPenOverride(cat: LayerCategory, patch: Partial<LayerPen["override"]>) {
|
||||||
|
const next = pruneEmpty({ ...cat.pen.override, ...patch });
|
||||||
|
patchCategory(cat.code, { pen: { ...cat.pen, override: next } });
|
||||||
|
}
|
||||||
|
function setHatchOverride(cat, patch) { /* analog für cat.hatch.override */ }
|
||||||
|
// pruneEmpty: entfernt undefined-Felder; gibt undefined zurück, wenn leer.
|
||||||
|
```
|
||||||
|
|
||||||
|
`setLineStyleId` / `setHatchId` setzen nur die Referenz; `hatch.enabled` ist ein
|
||||||
|
Checkbox-Patch.
|
||||||
|
|
||||||
|
### 6.3 „Eigenschaften kopieren / einfügen"
|
||||||
|
|
||||||
|
Der bestehende `layerClipboard` (heute `{ color, lw }`) wird auf die volle
|
||||||
|
Darstellung erweitert: `{ pen, hatch }` (die Override-tragenden Strukturen, ohne
|
||||||
|
`code/name/visible/locked`). „Kopieren" liest `{ pen, hatch }` der Quelle,
|
||||||
|
„Einfügen" patcht sie auf das Ziel. So überträgt sich der komplette Stift +
|
||||||
|
Schraffur einer Ebene auf eine andere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Migration bestehender Beispieldaten
|
||||||
|
|
||||||
|
Bestehende Projekte/Sample-Daten haben `LayerCategory { color, lw, hatch?: string }`
|
||||||
|
und `LineStyle { weight, color, dash }` (ohne `kind`). Eine reine Lese-Zeit-
|
||||||
|
Migration (`migrateProject(project)`), idempotent, beim Laden:
|
||||||
|
|
||||||
|
1. **LineStyle.kind ableiten** — aus `dash`:
|
||||||
|
```
|
||||||
|
dash == null || dash.length === 0 → "solid"
|
||||||
|
sonst, wenn min(dash) sehr klein → "dotted" (heuristisch)
|
||||||
|
sonst → "dashed"
|
||||||
|
```
|
||||||
|
(Eine genaue Zuordnung ist nicht nötig; `dash` bleibt führend, `kind` ist nur
|
||||||
|
für das Dropdown.)
|
||||||
|
|
||||||
|
2. **Neutralen Basis-Linienstil sicherstellen** — falls die Bibliothek noch keinen
|
||||||
|
generischen „Standard"-Stift hat, einen `lineStyle` mit
|
||||||
|
`{ id: "ls-default", name: "Standard", kind: "solid", weight: <Ebenen-lw>, color: "#000", dash: null }`
|
||||||
|
anlegen. (Pro Ebene wird der Stift referenziert; die Ebenen-spezifischen
|
||||||
|
`color`/`lw` wandern in das **Override**, nicht in den Stil — so bleibt der
|
||||||
|
Stil wiederverwendbar.)
|
||||||
|
|
||||||
|
3. **Pro LayerCategory `pen` bauen:**
|
||||||
|
```ts
|
||||||
|
pen = {
|
||||||
|
lineStyleId: "ls-default",
|
||||||
|
override: pruneEmpty({ color: cat.color, lw: cat.lw }),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Damit ist die Darstellung **pixelgenau wie vorher** (gleiche Farbe, gleiche lw),
|
||||||
|
nur jetzt über die Resolver-Kette.
|
||||||
|
|
||||||
|
4. **Pro LayerCategory `hatch` bauen** — aus dem alten `hatch?: string`:
|
||||||
|
- War `hatch` ein gültiger `HatchStyle.id` → `{ enabled: true, hatchId: hatch }`.
|
||||||
|
- War es ein Pattern-Name oder leer/unbekannt → `{ enabled: false, hatchId:
|
||||||
|
<erste Hatch-id der Bibliothek> }` (Referenz muss existieren, aber inaktiv).
|
||||||
|
So entsteht **keine** unbeabsichtigte Schraffur (Default heute: keine).
|
||||||
|
|
||||||
|
5. **Deprecated-Felder belassen** für eine Übergangsphase; nach Umstellung aller
|
||||||
|
Lesepfade (`generatePlan`, `LayersPanel`-Swatch, Clipboard) in einem zweiten
|
||||||
|
Schritt `color`/`lw` aus `LayerCategory` und der alte `hatch: string` entfernen.
|
||||||
|
|
||||||
|
Migration ist **idempotent**: Liegt `pen`/`hatch` bereits vor, wird die Ebene
|
||||||
|
unverändert durchgereicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Build-Plan (phasiert)
|
||||||
|
|
||||||
|
**Phase 1 — Datenmodell & Resolver (keine UI-Sichtbarkeit).**
|
||||||
|
- `LineKind` + `LINE_DASH`, `LineStyle.kind`, `LayerPen`, `LayerHatch`,
|
||||||
|
`LayerCategory.pen/hatch` in `types.ts`.
|
||||||
|
- `src/model/layerStyle.ts`: `PenRender`, `resolvePen`, `resolveLayerHatch`,
|
||||||
|
`penMap`, `layerHatchMap`; `HatchRender` hierher ziehen + re-exportieren.
|
||||||
|
- `migrateProject()` (Schritte §7) + Aufruf beim Laden/Seed.
|
||||||
|
- `npx tsc -b` grün.
|
||||||
|
|
||||||
|
**Phase 2 — Generator umstellen.**
|
||||||
|
- `generatePlan` nutzt `penMap`/`layerHatchMap` statt `categoryLwMap`.
|
||||||
|
- `Primitive`-polygon `dash?`, line/arc `stroke?` ergänzen; `addWallPoche`/
|
||||||
|
`addDoorSymbol`-Signaturen auf `PenRender` + `HatchRender|null`.
|
||||||
|
- Ebenen-Default-Schraffur in „grob" und für Schicht-lose Flächen verdrahten.
|
||||||
|
- Visuell prüfen via `node scripts/probe.mjs` (Geometrie unverändert, Farben/lw
|
||||||
|
identisch zur Migration).
|
||||||
|
|
||||||
|
**Phase 3 — Panel.**
|
||||||
|
- `LayersPanel`-Swatch liest aufgelöste Stift-Farbe (`resolvePen(...).color`).
|
||||||
|
|
||||||
|
**Phase 4 — Editor.**
|
||||||
|
- `InlineEditor`-Inhalt für `kind: "layer"` auf die PEN/HATCH-Sektionen erweitern
|
||||||
|
(§6), mit Dropdowns über `project.lineStyles` / `project.hatches`, Reset-Knöpfen,
|
||||||
|
neuen i18n-Keys.
|
||||||
|
- `layerClipboard` auf `{ pen, hatch }` erweitern; Kopieren/Einfügen anpassen.
|
||||||
|
|
||||||
|
**Phase 5 — Aufräumen.**
|
||||||
|
- Deprecatete `color`/`lw`/`hatch: string` aus `LayerCategory` entfernen, sobald
|
||||||
|
kein Lesepfad sie mehr nutzt; Sample-Daten direkt im neuen Format ablegen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Offene Punkte / bewusst nicht jetzt
|
||||||
|
|
||||||
|
- **Pro-Geschoss-Overrides der Ebene** (eine Ebene anders je `DrawingLevel`):
|
||||||
|
nicht in dieser Iteration; das Schema gilt geschossübergreifend (types.ts).
|
||||||
|
Falls später nötig, als zweite Override-Ebene über demselben Resolver.
|
||||||
|
- **Regelbasierte Overrides** (resources-graphics.md, `overrides.py`): orthogonal;
|
||||||
|
würden nach der Ebenen-Auflösung greifen.
|
||||||
|
- **Linienstil-Endkappen/Joins** und feinere Dash-Skalierung: bleiben in der
|
||||||
|
PlanView (Darstellung), nicht im Modell.
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
# Design — Pläne & Output
|
||||||
|
|
||||||
|
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||||
|
> Bauteile: [elements.md](elements.md). Ressourcen/Stile: [resources-graphics.md](resources-graphics.md).
|
||||||
|
|
||||||
|
Hier gewinnen wir (ROADMAP §3, Phase 3 ⭐): **schöne, normgerechte 2D-Pläne**,
|
||||||
|
automatisch aus dem Modell abgeleitet, druckfertig als Vektor-PDF. Dieses Dokument
|
||||||
|
übersetzt DOSSIERs `schnitte.py`, `massstab.py`, `ausschnitte.py`, `kamera.py`,
|
||||||
|
`dimensionen.py`, `layouts.py` in Browser-Module. Bezeichner englisch, Prosa
|
||||||
|
deutsch, Meter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ansichtstypen = Kamera-Projektion + optionaler Schnitt
|
||||||
|
|
||||||
|
Vereinheitlichtes Modell (ROADMAP §2c, im Spike als `DrawingLevelKind` angelegt):
|
||||||
|
|
||||||
|
| Typ | Projektion | Schnitt | Erzeugung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Grundriss** | Ortho Top | horizontal auf `okff + cutHeight` | symbolisch aus Footprint (Pfad A) |
|
||||||
|
| **Schnitt** | Ortho Front (Richtung) | vertikale Schnittebene + Tiefe | 3D-Projektion/HLR (Pfad B) |
|
||||||
|
| **Ansicht** | Ortho Front (Richtung) | kein Schnitt (Fassade außen) | 3D-Projektion/HLR (Pfad B) |
|
||||||
|
| **Perspektive** | 3D perspektivisch | — | Three.js direkt |
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ViewType = "plan" | "section" | "elevation" | "perspective";
|
||||||
|
interface DerivedView { // was der Viewport gerade zeigt
|
||||||
|
type: ViewType;
|
||||||
|
levelId?: string; // Geschoss (plan) bzw. Schnitt/Ansicht (DrawingLevel)
|
||||||
|
camera: CameraState;
|
||||||
|
cut?: CutSpec; // Clipping-Spezifikation (s.u.)
|
||||||
|
detailLevel: DetailLevel;
|
||||||
|
}
|
||||||
|
interface CutSpec {
|
||||||
|
planes: { point: Vec3; normal: Vec3 }[]; // 1 (plan/elevation) oder 2 (section: cut+back)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zwei Wege zum Plan** (zentrale Architektur-Erkenntnis, ROADMAP §3) — wir bauen
|
||||||
|
**beide**:
|
||||||
|
- **A) Grundriss = symbolisch** aus den Parametern (`plan/generatePlan.ts`, im
|
||||||
|
Spike). Schnell, exakt, vektorbasiert. Kein Mesh-Schnitt.
|
||||||
|
- **B) Schnitt & Ansicht = 3D-Projektion mit Hidden-Line-Removal** (`plan/
|
||||||
|
generateSection.ts`, §4). Durch das zusammengebaute Gebäude.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Schnitt & Ansicht — Datenmodell & Aktivierung
|
||||||
|
|
||||||
|
DOSSIER speichert Schnitte als Zeichnungsebenen-Eintrag (`type:"schnitt"`) mit
|
||||||
|
`linePts/dirSign/depthBack/cutAtLine/heightMin/heightMax/projection`
|
||||||
|
(`schnitte.create_schnitt_entry`). Im Spike sind die Felder als `DrawingLevel`
|
||||||
|
(`kind:"section"|"elevation"`, `linePoints`, `directionSign`) angelegt — wir
|
||||||
|
ergänzen:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface SectionLevel extends DrawingLevel { // kind: "section" | "elevation"
|
||||||
|
linePoints: [Vec2, Vec2];
|
||||||
|
directionSign: 1 | -1; // Blickrichtung (Pfeil im Plan)
|
||||||
|
depthBack: number; // Tiefe hinter der Schnittlinie (default 8)
|
||||||
|
cutAtLine: boolean; // true=Schnitt (cut+back), false=Ansicht (nur back)
|
||||||
|
heightMin: number; heightMax: number;
|
||||||
|
projection: "parallel" | "perspective";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aktivierung** (Port `schnitte.activate_schnitt`):
|
||||||
|
1. `view_dir` = senkrecht zur Linie in XY, Richtung = `directionSign`.
|
||||||
|
2. **3D-Vorschau:** `THREE.Plane`s setzen —
|
||||||
|
- Cut (nur `cutAtLine`): auf der Linie, Normale `+view_dir`.
|
||||||
|
- Back (immer): um `depthBack` in `+view_dir` versetzt, Normale `−view_dir`.
|
||||||
|
- via `renderer.localClippingEnabled = true`, `material.clippingPlanes`.
|
||||||
|
3. **Kamera:** `OrthographicCamera`, Position `mid − view_dir·dist`, Target `mid`,
|
||||||
|
Up `+Z`; Zoom auf BBox (`linePoints` + Höhenbereich + `depthBack`). Bei
|
||||||
|
`perspective`: `PerspectiveCamera` + FOV.
|
||||||
|
4. **Vektor-Ergebnis:** HLR (§4).
|
||||||
|
|
||||||
|
**2D-Plan-Symbol** (Schnittmarke im Grundriss, Port `make_schnitt_symbol`): Linie
|
||||||
|
+ Endpfeile in `view_dir`, Beschriftung. Bleibt im Grundriss sichtbar (liegt auf
|
||||||
|
einer eigenen Ebene, z.B. `18 Schnittlinien`). **Doppelklick** auf das Symbol
|
||||||
|
aktiviert den Schnitt (`onDoubleClick` auf das SVG-Symbol → `setActiveLevel(id)`,
|
||||||
|
≙ DOSSIER `_SchnittDoubleClickHandler`).
|
||||||
|
|
||||||
|
**Grip-Editing der Schnittlinie:** Endpunkte als Grips im Grundriss; Ziehen
|
||||||
|
aktualisiert `linePoints` + Symbol + (falls aktiv) Clipping — ohne Re-Zoom der
|
||||||
|
3D-View (DOSSIER `skip_view`-Flag-Äquivalent: Drag aktualisiert nur die Clip-
|
||||||
|
Ebenen, nicht die Kamera).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Massstab (Scale) — pro Viewport, Auto-DPI
|
||||||
|
|
||||||
|
### 3.1 Mathematik (Port `massstab._compute_scale`, identisch im Browser)
|
||||||
|
```
|
||||||
|
frustumWidth_world = ortho-Kamera-Breite in Modell-Einheiten (Meter)
|
||||||
|
frustumWidth_mm = frustumWidth_world * 1000 (Meter→mm)
|
||||||
|
screenWidth_mm = canvasWidthCssPx * 25.4 / dpi
|
||||||
|
N (1:N) = frustumWidth_mm / screenWidth_mm
|
||||||
|
```
|
||||||
|
- **Nur bei Orthografie** sinnvoll; in Perspektive zeigt die UI „—" (wie DOSSIER).
|
||||||
|
- **DPI:** Browser kennt das nativ — `dpi = 96 * window.devicePixelRatio` (CSS
|
||||||
|
definiert 1 px = 1/96 inch). Das ersetzt DOSSIERs CoreGraphics-JXA-Detection
|
||||||
|
komplett und ist exakter. Optional manuell kalibrierbar (Eingabe in den
|
||||||
|
Settings), persistiert pro Projekt.
|
||||||
|
- **Massstab setzen** (1:N → Zoom): `frustumWidth_world = screenWidth_mm · N /
|
||||||
|
1000`; bei `THREE.OrthographicCamera` `camera.zoom = canvasWidthCssPx /
|
||||||
|
(frustumWidth_world / metersPerPixelAtZoom1)` bzw. direkt `left/right` setzen.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// plan/scale.ts
|
||||||
|
function computeScale(view: { frustumWidthWorld; canvasCssWidthPx; dpi }): number|null // 1:N
|
||||||
|
function applyScale(camera: THREE.OrthographicCamera, n: number, canvasCssWidthPx, dpi): void
|
||||||
|
const SCALE_PRESETS = [1,5,10,20,25,50,100,200,500,1000]; // 1:N Dropdown
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Massstabs-abhängige Skalierung (DOSSIER-Stärke)
|
||||||
|
Bei 1:N müssen **Strichstärken** und **Schraffuren** lesbar bleiben:
|
||||||
|
- **Plotweight → SVG stroke-width:** `strokeWidthPx = lwMm / 25.4 · dpi`
|
||||||
|
(Welt-unabhängig; die Linie ist im Plan immer z.B. 0.25 mm dick). DOSSIER
|
||||||
|
skaliert dafür die PlotWeights (`_apply_scaled_lineweights`); im SVG-Modell
|
||||||
|
rechnen wir die mm-Strichstärke direkt in Pixel — **viel einfacher**, da SVG
|
||||||
|
von Natur aus papierbezogen ist.
|
||||||
|
- **Schraffur-Skalierung:** DOSSIER nutzt `factor = sqrt(N)/10` (1:100 ⇒ 1.0,
|
||||||
|
1:50 ⇒ 0.71, 1:500 ⇒ 2.24; `apply_scaled_hatches`). Port: SVG `<pattern>`-
|
||||||
|
`patternTransform="scale(factor)"` bzw. `patternUnits` so wählen, dass das Muster
|
||||||
|
die gewünschte Paper-Dichte hat. Formel 1:1 übernehmen.
|
||||||
|
- **Linetype-Dash:** `stroke-dasharray` in mm→px, ebenfalls papierbezogen.
|
||||||
|
|
||||||
|
> **Kernvorteil gegenüber DOSSIER:** Weil der Plan **SVG/Paper-Space** ist,
|
||||||
|
> entfällt das fragile Welt↔Bildschirm-Plotweight-Rescaling (DOSSIER `write_plotweight`,
|
||||||
|
> `read_plotweight`, Print-Display-Toggle). Strichstärke und Maßlinien sind direkt
|
||||||
|
> in mm definiert und werden 1:1 gedruckt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Schnitt/Ansicht-Projektion (HLR) — Risiko #4
|
||||||
|
|
||||||
|
Vertikale Schnitte/Ansichten brauchen **echte 3D-Projektion mit verdeckten
|
||||||
|
Kanten** durch das zusammengebaute Gebäude.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// plan/generateSection.ts (läuft im Web Worker via Comlink)
|
||||||
|
interface SectionRequest { meshes: SerializedBrep[]; cut: CutSpec; camera: CameraState; }
|
||||||
|
interface SectionResult {
|
||||||
|
cutLines: Primitive[]; // Schnittkanten (dick) — geschnittene Bauteile
|
||||||
|
cutFaces: Primitive[]; // Schnittflächen → Component-Schraffur (Poché)
|
||||||
|
visibleLines: Primitive[]; // sichtbare Projektion (dünn)
|
||||||
|
hiddenLines?: Primitive[]; // verdeckte (gestrichelt, optional)
|
||||||
|
}
|
||||||
|
function generateSection(req: SectionRequest): SectionResult
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Kernel:** **OpenCascade.js** `HLRBRep_Algo` / `HLRBRep_HLRToShape` (B-Rep →
|
||||||
|
sichtbare/verdeckte Kanten). Eingabe = die Bauteil-Breps (Wände/Decken/Treppen…),
|
||||||
|
Projektionsrichtung aus `camera`. Alternativ Mesh-basiert (langsamer, weniger
|
||||||
|
sauber).
|
||||||
|
- **Schnittflächen-Schraffur (Section-Style):** wo die Cut-Plane ein Bauteil
|
||||||
|
durchschneidet, entsteht eine Fläche → mit der Component-Schraffur füllen
|
||||||
|
(resources-graphics.md). ≙ DOSSIER `SectionStyle` (Hatch + Schnittkante +
|
||||||
|
Silhouette), nur dass wir es als SVG-Fill rendern statt als Rhino-Layer-Property.
|
||||||
|
- **Performance:** schwer → **Worker + Cache**. Cache-Key =
|
||||||
|
hash(sichtbare Element-IDs + Geometrie-Hash + CutSpec + camera). Nur neu rechnen,
|
||||||
|
wenn sich relevante Eingaben ändern (ROADMAP Risiko #4). Geschnittene vs. dahinter
|
||||||
|
liegende Geometrie über die Back-Plane begrenzen (`depthBack`).
|
||||||
|
- **Stufenweise:** (a) Ansicht ohne Verdeckung (einfache Projektion) → (b) HLR
|
||||||
|
sichtbar → (c) verdeckte Kanten gestrichelt → (d) Schnittflächen-Poché.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ausschnitte (View-Snapshots)
|
||||||
|
|
||||||
|
Navigation über 50+ Ansichten ohne Ordner-Wildwuchs (DOSSIER `ausschnitte.py`).
|
||||||
|
Ein Snapshot speichert **Kamera + Sichtbarkeit + Massstab + Darstellung + Overrides**.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// in Project: viewSnapshots: ViewSnapshot[]
|
||||||
|
interface ViewSnapshot {
|
||||||
|
id; name; folder?: string;
|
||||||
|
camera: CameraState; // pos/target/up/parallel/fov + frustumWidth (Zoom!)
|
||||||
|
scale: number; // 1:N (DOSSIER speichert "1:50"-String)
|
||||||
|
detailLevel: DetailLevel; // LoD-Override (DOSSIER darstellung)
|
||||||
|
visibility: VisibilityState; // pro Geschoss + pro Ebene visible/locked
|
||||||
|
layerCombinationId?: string; // ODER Verweis auf Layer-Kombi (live) — s.u.
|
||||||
|
overrides?: { presetId?: string; enabled: boolean };
|
||||||
|
}
|
||||||
|
interface CameraState { position; target; up; parallel; fov?; frustumWidth?; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Save:** aktuellen `ui`-Zustand einfrieren (Port `_capture`: Kamera inkl.
|
||||||
|
Frustum-Breite für exakten Zoom-Restore, Layer-Sichtbarkeit, Massstab, LoD).
|
||||||
|
- **Restore:** Snapshot → `ui` + ggf. `project`-Sichtbarkeit anwenden (Port
|
||||||
|
`_restore`): Kamera, Sichtbarkeit (oder referenzierte Layer-Kombi), LoD,
|
||||||
|
optional Overrides-Preset. Da alles im Store liegt, ist das ein einfacher
|
||||||
|
State-Set — kein Multi-Panel-Force-Send-Tanz wie in DOSSIER.
|
||||||
|
- **Ordner, Umbenennen, Duplizieren, Settings-Drawer** wie DOSSIER (`_duplicate`,
|
||||||
|
`_set_field`, `_open_settings_window` → React-Drawer statt Eto-Form).
|
||||||
|
|
||||||
|
### 5.1 Layer-Kombinationen (Presets)
|
||||||
|
```ts
|
||||||
|
interface LayerCombination { id; name; visibility: VisibilityState; }
|
||||||
|
```
|
||||||
|
Bauphasen/Varianten/MEP per Klick (DOSSIER `_save_preset`/`apply_layer_preset_by_name`).
|
||||||
|
Snapshot kann **live** auf eine Kombi verweisen (folgt Änderungen) **oder**
|
||||||
|
eingefroren den `visibility`-Stand halten — genau DOSSIERs Wahl (`layerCombination`
|
||||||
|
vs. `layers`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Kamera-Presets & Norden-Rotation ⭐
|
||||||
|
|
||||||
|
Port `kamera.py`. Schnelle Ansichtswechsel + Georeferenzierung (Swisstopo, Phase 4).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// viewport/camera.ts
|
||||||
|
function setCardinal(cam, dir: "N"|"E"|"S"|"W", northAngle: number): void
|
||||||
|
function setIso(cam, octant: "NE"|"SE"|"SW"|"NW"|..., northAngle: number): void
|
||||||
|
function setTop(cam, northAngle: number): void // Plan-Norden zeigt nach oben
|
||||||
|
// northAngle = Grad im Uhrzeigersinn von +Y (DOSSIER dossier_north_angle, default 0)
|
||||||
|
const north = (deg) => ({ x: Math.sin(rad(deg)), y: Math.cos(rad(deg)) });
|
||||||
|
interface CameraPreset { id; name; camera: CameraState; } // benutzerdefiniert, gespeichert
|
||||||
|
```
|
||||||
|
- **Norden-Rotation:** alle Kardinal-/Iso-Richtungen werden um `northAngle`
|
||||||
|
rotiert (Port `set_cardinal_view`, `_set_iso`, `set_top_view`). `northAngle`
|
||||||
|
liegt im `Project` (georeferenziert zu swissBUILDINGS).
|
||||||
|
- **Benutzer-Presets:** speichern/laden wie DOSSIER (`_load_presets`/`_save_presets`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Bemaßung (Dimensions)
|
||||||
|
|
||||||
|
Port `dimensionen.py`. Maße werden **aus dem Modell abgeleitet** (Wand-Dicken,
|
||||||
|
Geschoss-Höhen, Öffnungen) + manuelle Maßketten.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Dimension {
|
||||||
|
id; floorId; categoryCode; // liegt auf einer Ebene
|
||||||
|
kind: "linear" | "chain" | "aligned" | "level"; // Einzel|Kette|ausgerichtet|Höhenkote
|
||||||
|
refs: DimRef[]; // Bezugspunkte (frei ODER an Element gebunden)
|
||||||
|
offset: number; // Abstand der Maßlinie vom Objekt
|
||||||
|
style: DimStyleId; // Pfeile, Texthöhe, Einheiten
|
||||||
|
}
|
||||||
|
type DimRef = { point: Vec2 } | { elementId: string; anchor: "start"|"end"|"jamb"|... };
|
||||||
|
```
|
||||||
|
- **Auto-Bemaßung** (Phase 3): Außenketten (Gebäude-Hülle), Achsketten (Achsraster),
|
||||||
|
Öffnungs-Ketten — aus der Geometrie generiert, dann editierbar.
|
||||||
|
- **9-Punkt-Objekt-Info** (DOSSIER ROADMAP §11): Bounding-Box-Maße lesen +
|
||||||
|
Element via Greifen verschieben/skalieren/rotieren — direkt im Plan.
|
||||||
|
- **Rich-Text-Indizes** (Bold/Hoch-/Tiefstellung) für Maßzahlen — als SVG
|
||||||
|
`<tspan>` mit `baseline-shift` (resources-graphics.md §Rich-Text).
|
||||||
|
- **Massstabsbezug:** Texthöhe/Pfeilgröße in **Paper-mm**, rendern × Massstab —
|
||||||
|
konsistent mit §3.2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Plansätze (Sheets) & PDF-Export
|
||||||
|
|
||||||
|
DOSSIER nutzt Rhinos `RhinoPageView` + `Detail`-Viewports + `FilePdf`
|
||||||
|
(`layouts.py`). Browser-Äquivalent: eigenes Sheet-Modell + SVG → PDF.
|
||||||
|
|
||||||
|
### 8.1 Datenmodell
|
||||||
|
```ts
|
||||||
|
interface Sheet {
|
||||||
|
id; name; folder?;
|
||||||
|
paper: "A0"|"A1"|"A2"|"A3"|"A4"|"Letter"; landscape: boolean;
|
||||||
|
viewports: SheetViewport[];
|
||||||
|
titleBlock?: TitleBlock; // Titelblock (Projekt/Plan/Massstab/Datum)
|
||||||
|
}
|
||||||
|
interface SheetViewport { // ≙ DOSSIER Detail + gebundener Ausschnitt
|
||||||
|
id; rect: { x; y; w; h }; // Position auf dem Blatt (mm)
|
||||||
|
source: { kind: "level"; levelId } | { kind: "snapshot"; snapshotId };
|
||||||
|
scale: number; // 1:N
|
||||||
|
clipToRect: boolean;
|
||||||
|
}
|
||||||
|
const PAPER_MM = { A0:[841,1189], A1:[594,841], A2:[420,594], A3:[297,420],
|
||||||
|
A4:[210,297], Letter:[216,279] }; // Port PAPER_SIZES_MM
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Sheet-Editor
|
||||||
|
`sheets/SheetEditor.tsx`: Blatt als SVG in mm, Viewports per Drag platzieren/
|
||||||
|
skalieren, Quelle (Geschoss/Snapshot) + Massstab zuweisen. Ein Viewport rendert
|
||||||
|
den abgeleiteten Plan/Schnitt **bei seinem Massstab** in sein `rect` (≙ DOSSIER
|
||||||
|
`apply_snapshot_to_detail`). Bei Änderung der Quelle re-derivieren (live), kein
|
||||||
|
manuelles Re-Sync nötig (DOSSIER war Snapshot-Mode).
|
||||||
|
|
||||||
|
### 8.3 Detail↔Ausschnitt-Bindung
|
||||||
|
`SheetViewport.source.snapshotId` ist die Bindung (DOSSIER `_BIND_KEY`). „Alle
|
||||||
|
aktualisieren" = alle Viewports neu rendern; weil rein abgeleitet, ist das
|
||||||
|
automatisch. Umbenennen synchronisiert Titelblock + Schnitt-Symbol (DOSSIER
|
||||||
|
Detail↔Ausschnitt-Sync).
|
||||||
|
|
||||||
|
### 8.4 PDF-Export (Vektor, Multi-Page, @DPI)
|
||||||
|
Port `layouts._export_pdf`, aber **vektorbasiert** (DOSSIER rasterte via
|
||||||
|
`ViewCaptureToFile` @DPI — wir bleiben Vektor → schärfer, kleiner):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// sheets/exportPdf.ts
|
||||||
|
async function exportSheetsPdf(sheets: Sheet[], opts: { vector: boolean }): Promise<Blob>
|
||||||
|
```
|
||||||
|
- **Vektor-Pfad (bevorzugt):** jeder Sheet-Viewport rendert seinen Plan als SVG;
|
||||||
|
SVG → PDF via **`svg2pdf.js` + `jsPDF`** (oder `pdf-lib` mit eigenem Pfad-
|
||||||
|
Emit). Eine PDF-Seite pro Sheet, Größe = `PAPER_MM`. Strichstärken/Schraffuren
|
||||||
|
sind bereits in mm (§3.2) → 1:1 druckbar.
|
||||||
|
- **Raster-Fallback** (Perspektiven/3D-Inhalte): Three.js `renderer` → Canvas →
|
||||||
|
PNG @DPI → in PDF-Seite (`px = mm/25.4·dpi`, Port der DOSSIER-Pixelrechnung).
|
||||||
|
- **Speichern:** Blob → File System Access API (`showSaveFilePicker`) / Download.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Primitive & SVG-Serializer (gemeinsame Basis)
|
||||||
|
|
||||||
|
Alle Pläne (Grundriss, Schnitt, Ansicht, Sheet-Viewport) sprechen dieselbe
|
||||||
|
`Primitive`-Sprache (heute in `generatePlan.ts`), erweitert um Schraffur/Text:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Primitive =
|
||||||
|
| { kind:"polygon"; pts:Vec2[]; fill:string; stroke:string; strokeWidthMm:number; hatchId?:string }
|
||||||
|
| { kind:"line"; a:Vec2; b:Vec2; styleId:string } // styleId → LineStyle (mm, dash)
|
||||||
|
| { kind:"arc"; center:Vec2; from:Vec2; to:Vec2; r:number; styleId:string }
|
||||||
|
| { kind:"text"; at:Vec2; text:string; heightMm:number; align; font; rich?:RichRun[] }
|
||||||
|
| { kind:"symbol"; at:Vec2; symbolId:string; scale:number; angle:number }; // Symbol-Bibliothek
|
||||||
|
interface Plan { primitives: Primitive[]; bounds: Rect; }
|
||||||
|
```
|
||||||
|
- **SVG-Serializer** (`plan/primitives.ts`): Primitive → SVG-Elemente.
|
||||||
|
`strokeWidthMm` → px via `mm·dpi/25.4`; `hatchId` → `<pattern>`-Referenz;
|
||||||
|
`styleId` → `stroke`/`stroke-dasharray`. Derselbe Serializer für Bildschirm
|
||||||
|
*und* PDF.
|
||||||
|
- **DXF-Export** (Phase 4): dieselben Primitive → DXF-Entities (`dxf`-Writer-lib).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Umsetzungs-Reihenfolge (verweist auf ROADMAP-Phasen)
|
||||||
|
|
||||||
|
1. **Phase 1 (MVP):** Grundriss-Generator ✅ ausbauen (Schraffuren, LoD), Live-
|
||||||
|
Grundriss neben 3D, Basis-Bemaßung; Massstab pro Viewport (§3).
|
||||||
|
2. **Phase 3 ⭐:** Schnitt/Ansicht via HLR (§4, Worker), Auto-Bemaßung (§7),
|
||||||
|
Ausschnitte + Layer-Kombinationen (§5), Kamera-Presets + Norden (§6),
|
||||||
|
Sheets + Vektor-PDF (§8).
|
||||||
|
3. **Phase 4:** DXF-Export (§9), Detail↔Ausschnitt-Sync-Politur.
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# Design — Ressourcen & Grafik
|
||||||
|
|
||||||
|
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||||
|
> Bauteile: [elements.md](elements.md). Output/Pläne: [plans-output.md](plans-output.md).
|
||||||
|
|
||||||
|
Die **Stil-Schicht**: verwaltete Ressourcen-Bibliotheken (Vectorworks-Stil),
|
||||||
|
regelbasierte Overrides, Symbol-/Text-Bibliotheken und Detailgrad-Steuerung. Sie
|
||||||
|
wird **beim Rendern angewandt, nie in die Geometrie eingebacken** (ROADMAP §2b).
|
||||||
|
Dieses Dokument übersetzt DOSSIERs `styles.py`/`gestaltung.py`, `mass_style.py`,
|
||||||
|
`overrides.py`, `library.py`, `text_create.py`. Bezeichner englisch, Prosa
|
||||||
|
deutsch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resource Manager — verwaltete Bibliotheken
|
||||||
|
|
||||||
|
Drei Bibliotheken im `Project.resources`-Block; **alles verweist per id**, zentral
|
||||||
|
änderbar (ROADMAP §2d). Verweis-Kette: 2D-Objekte/Ebenen → LineStyle/Hatch;
|
||||||
|
Hatch → LineStyle; Component → Hatch (+3D-Material).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Resources {
|
||||||
|
lineStyles: LineStyle[];
|
||||||
|
hatches: Hatch[];
|
||||||
|
components: Component[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineStyle { // Line Manager
|
||||||
|
id; name;
|
||||||
|
weight: number; // Strichstärke in mm (≙ Rhino PlotWeight)
|
||||||
|
color: string; // hex
|
||||||
|
dash: number[]; // Strichmuster in mm ([] = durchgezogen)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Hatch { // Hatch Manager
|
||||||
|
id; name;
|
||||||
|
pattern: PatternId; // "solid" | "diagonal" | "insulation" | "concrete" | ...
|
||||||
|
scale: number; // Grundmaßstab des Musters
|
||||||
|
angle: number; // Grad
|
||||||
|
lineStyleId: string; // Linien der Schraffur → Line Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Component { // Component Manager (= DOSSIER-Material, erweitert)
|
||||||
|
id; name;
|
||||||
|
hatchId: string; // Schnitt-Schraffur → Hatch Manager
|
||||||
|
color3d: string; // 3D-Diffusfarbe
|
||||||
|
texture3d?: TextureRef; // optionale PBR-Textur (Phase 3)
|
||||||
|
pbr?: { roughness; metalness; opacity; ior }; // Material-Bibliothek (ROADMAP §11)
|
||||||
|
joinPriority: number; // Verschneidungs-Rang (DOSSIER _MATERIAL_PRIO als Daten)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration vom heutigen Stand:** Der Spike hat `Material { color, planFill, hatch }`
|
||||||
|
und `Layer { materialId, thickness, priority }`. Ziel: `Material → Component`
|
||||||
|
(`color→color3d`, `planFill→` Fill aus `hatch.pattern==solid`+Farbe, `hatch`-Enum
|
||||||
|
→ `hatchId`), `Layer.priority → Component.joinPriority` (Priorität wandert vom
|
||||||
|
Layer zum Component, damit man sie nur einmal pflegt — siehe elements.md §1.3).
|
||||||
|
|
||||||
|
### 1.1 Manager-UI
|
||||||
|
`managers/ComponentManager.tsx`, `HatchManager.tsx`, `LineManager.tsx` — je eine
|
||||||
|
Liste mit CRUD + Vorschau (Three-Sphere für Component-3D, SVG-Swatch für
|
||||||
|
Hatch/Line). **Seeds** beim ersten Projekt (DOSSIER-Defaults):
|
||||||
|
- LineStyles: 0.13 / 0.18 / 0.25 / 0.35 / 0.50 mm (aus `DEFAULT_LAYER_SCHEMA`-lw).
|
||||||
|
- Hatches: `solid`, `diagonal`, `concrete`, `insulation` (Dämmung).
|
||||||
|
- Components: Stahlbeton (prio 800), Beton (800), Mauerwerk (600), Ziegel (550),
|
||||||
|
Holzständer (400), Dämmung (200), Putz (100) — exakt DOSSIER `_MATERIAL_PRIO`
|
||||||
|
(elements.md §1.3). Plus Glas (transparent), Holz-Türblatt (DOSSIER
|
||||||
|
`_OEFF_PIECE_DEFS`).
|
||||||
|
|
||||||
|
### 1.2 Render-Anwendung
|
||||||
|
- **3D:** Component → `MeshStandardMaterial` (`color3d`/`pbr`), pro `componentId`
|
||||||
|
gecacht (`viewport/scene.ts`).
|
||||||
|
- **Plan/Schnitt:** geschnittene Schicht → Polygon mit `fill` (Component-Farbe) +
|
||||||
|
`<pattern>` aus `hatchId`. Pattern als SVG `<pattern>` mit `patternTransform`
|
||||||
|
für Massstab (plans-output.md §3.2). Der Hatch nutzt seinen `lineStyleId` für
|
||||||
|
die Musterlinien.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Mehrschichtige Aufbauten ↔ Ressourcen
|
||||||
|
`WallType.layers[].componentId` / `SlabType.layers[].componentId` verweisen auf
|
||||||
|
Components. 3D und Plan lesen dieselben Schichten (elements.md §1). Die
|
||||||
|
**Prioritäts-Verschneidung** (Risiko #1) liest `Component.joinPriority`:
|
||||||
|
höhere Priorität läuft am Stoß durch (Backbone), niedrigere stößt seitlich an —
|
||||||
|
Algorithmus in elements.md §1.3 (Port DOSSIER `_t_junction_layer_overrides`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stile & Element-Override (Selektions-Attribute)
|
||||||
|
|
||||||
|
DOSSIERs GESTALTUNG-Panel (`styles.py`) setzt Farbe/Lineweight/Linetype/Hatch auf
|
||||||
|
die *Selektion*. Browser-Äquivalent — zwei Ebenen, in Render-Reihenfolge:
|
||||||
|
|
||||||
|
```
|
||||||
|
ByLayer (Ebenen-Default) → Element-Style (styleId) → Override-Regeln → gerendert
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Effektiver Stil eines Elements (resolve beim Rendern, nie persistiert)
|
||||||
|
interface EffectiveStyle { color; lineStyleId; hatchId?; }
|
||||||
|
function resolveStyle(project, el, doc): EffectiveStyle {
|
||||||
|
// 1) Default aus LayerCategory(categoryCode)
|
||||||
|
// 2) überschrieben durch el.styleId (Element-Override, optional)
|
||||||
|
// 3) überschrieben durch passende Override-Regeln (§4)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Wall-/Opening-Stil-Kataloge** (Presets, ROADMAP §11): benannte Sätze von
|
||||||
|
Default-Werten (Wandtyp + Farbe + lw; Öffnung mit Rahmen/Sims/…). 1-Klick-
|
||||||
|
Anwendung, globaler Stilwechsel. Speicherung: pro Projekt + cross-Projekt
|
||||||
|
(LocalStorage), Seed wie DOSSIER `_OEFF_DEFAULT_STYLES` (elements.md §2.1).
|
||||||
|
- **LoD-bewusste Stil-UI** (DOSSIER ROADMAP §11): das Stil-Panel zeigt nur
|
||||||
|
passende Controls je Geometrietyp (keine Füll-Optionen bei einer 3D-/Linien-
|
||||||
|
Auswahl). `panels/StylePanel.tsx` schaltet Felder nach `selection`-Typ.
|
||||||
|
- **Pipette:** Stil/Typ von einem Element auf ein anderes übernehmen (DOSSIER
|
||||||
|
`cmd/pipette`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Regelbasierte Overrides (Engine)
|
||||||
|
|
||||||
|
Port `overrides.py` (ArchiCAD Graphical Overrides / Vectorworks
|
||||||
|
Datenvisualisierung). Im Browser **viel einfacher**, weil Overrides reine
|
||||||
|
**Render-Transformationen** sind — kein UserString-Backup/Restore nötig (DOSSIER
|
||||||
|
musste Originalwerte sichern, weil es echte Rhino-Objekte mutierte; wir mutieren
|
||||||
|
nichts).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface OverrideConfig { enabled: boolean; rules: OverrideRule[]; activePresetId?: string; }
|
||||||
|
interface OverrideRule {
|
||||||
|
id; name; enabled: boolean;
|
||||||
|
conditions: Condition[]; conditionsLogic: "and" | "or";
|
||||||
|
actions: { color?: string; lineWeight?: number; lineStyleId?: string;
|
||||||
|
hatchId?: string; hatchScale?: number };
|
||||||
|
}
|
||||||
|
interface Condition {
|
||||||
|
type: "category" | "userField" | "name" | "elementType"; // ≙ layer_name/user_string/object_name
|
||||||
|
operator: "equals"|"notEquals"|"contains"|"startsWith"|"endsWith";
|
||||||
|
value: string;
|
||||||
|
key?: string; // nur für userField (z.B. "sia")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auswertung** (Port `_compose_overrides`):
|
||||||
|
```ts
|
||||||
|
function composeOverrides(el, project, cfg): Partial<Actions> {
|
||||||
|
// additive: Actions aller matchenden, aktiven Regeln kombinieren;
|
||||||
|
// bei Konflikt für dieselbe Property gewinnt die Regel WEITER OBEN (kleinerer Index).
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Anwendung:** `resolveStyle` (§3) ruft `composeOverrides` — Override liegt
|
||||||
|
über Element-Style. Reines Read beim Rendern → kein `apply_all`/`restore_all`,
|
||||||
|
kein Backup, **keine reversibilität nötig**. Toggle `enabled` rendert neu.
|
||||||
|
- **Live:** Da abgeleitet, schlägt jede Modell-/Regel-Änderung sofort durch (kein
|
||||||
|
`install_listeners`/`AddRhinoObject`-Hook wie DOSSIER).
|
||||||
|
- **Presets & Templates** (cross-Projekt): Preset = Satz Regeln, Template =
|
||||||
|
einzelne Regel; LocalStorage statt `~/Library/.../override_presets.json`
|
||||||
|
(`save_preset`/`load_preset`/`list_rule_templates` → `resources/overridePresets.ts`).
|
||||||
|
- **SIA-416-Preset** (elements.md §7): vier Regeln `userField sia == HNF|NNF|VF|FF`
|
||||||
|
→ Farbe + Solid-Hatch, Port `_build_sia_preset_rules`. Aktivieren = Preset
|
||||||
|
`activePresetId` setzen.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// resources/overrides.ts
|
||||||
|
function composeOverrides(el, project, cfg): Partial<OverrideAction>
|
||||||
|
function setActivePreset(project, presetId): Project // immutabel
|
||||||
|
const PRESETS_NS = "cad.presets.overrides"; // LocalStorage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Symbol-Bibliothek
|
||||||
|
|
||||||
|
Port `library.py` + `cmd/symbol`. Wiederverwendbare 2D-Symbole (Möbel, Sanitär,
|
||||||
|
Bäume, Nordpfeil, Pflanzen) für den Plan.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Symbol {
|
||||||
|
id; name; category: string; // "furniture" | "sanitary" | "vegetation" | "annotation"
|
||||||
|
svgPath: string; // Pfad-/Gruppen-Markup im Symbol-Koordinatensystem (m)
|
||||||
|
defaultScale: number;
|
||||||
|
}
|
||||||
|
interface SymbolInstance extends ElementBase { // type:"draw2d", subtype:"symbol"
|
||||||
|
symbolId: string; at: Vec2; scale: number; angle: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Speicherung:** mitgelieferte Symbole als statische Assets (SVG); Nutzer-
|
||||||
|
Symbole im Projekt + cross-Projekt (LocalStorage). DOSSIER nutzt Block-
|
||||||
|
Definitionen; bei uns SVG-Definition + Instanz-Transform (`<use>`-artig).
|
||||||
|
- **Picker:** `panels/SymbolPicker.tsx` (≙ DOSSIER `SymbolPicker.jsx`) — Grid mit
|
||||||
|
Vorschau, Drag in den Plan; Instanz auf Ebene `60 Plangrafik` (bzw. `22 Möbel`).
|
||||||
|
- **Render:** `Primitive{ kind:"symbol", ... }` → SVG `<g transform>` mit dem
|
||||||
|
Symbol-Markup (plans-output.md §9).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Text & Rich-Text-Annotationen
|
||||||
|
|
||||||
|
Port `text_create.py` + `text_editor.py`. Formatierte Beschriftungen auf Canvas.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TextElement extends ElementBase { // type:"draw2d", subtype:"text"
|
||||||
|
at: Vec2; runs: RichRun[];
|
||||||
|
heightMm: number; // Paper-mm (rendern × Massstab) ODER Modell-m
|
||||||
|
heightMode: "paper" | "model"; // DOSSIER raum_txt_modus fix|masstab
|
||||||
|
font: string; align: "left"|"mid"|"right"; angle: number;
|
||||||
|
mask?: boolean; // Hintergrund-Maskierung (verdeckt Linien darunter)
|
||||||
|
frame?: boolean; // Rahmen um den Text
|
||||||
|
}
|
||||||
|
interface RichRun {
|
||||||
|
text: string;
|
||||||
|
bold?; italic?; super?; sub?; // Hoch-/Tiefstellung (Maß-Indizes)
|
||||||
|
}
|
||||||
|
interface TextStyle { id; name; font; heightMm; bold; italic; } // Text-Presets
|
||||||
|
```
|
||||||
|
- **Render:** `<text>` mit `<tspan>` pro Run; `super/sub` via `baseline-shift` +
|
||||||
|
kleinerer `font-size`; `mask` via weißem `<rect>` darunter; `frame` via `<rect>`.
|
||||||
|
- **Editor:** Inline-Rich-Text-Editor (contentEditable oder leichter Custom-Editor)
|
||||||
|
→ `RichRun[]`. Fonts aus einer kuratierten Web-Font-Liste + System-Fonts
|
||||||
|
(DOSSIER `_list_system_fonts` mit Preferred-Liste DM Mono/Krungthep/…); im
|
||||||
|
Browser via `document.fonts` / `queryLocalFonts()` (wo verfügbar) + gebündelte
|
||||||
|
Web-Fonts.
|
||||||
|
- **Massstabsbezug:** `heightMode:"paper"` → Texthöhe in mm, gerendert × Massstab
|
||||||
|
(plans-output.md §3) — Beschriftung bleibt bei jedem Massstab lesbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Detailgrad (Level of Detail)
|
||||||
|
|
||||||
|
Querschnittsthema (ROADMAP §2b „Modelldarstellungen"). Drei Stufen, Dokument-/
|
||||||
|
Snapshot-weiter Override mit Per-Element-Ausnahme — DOSSIER
|
||||||
|
`darstellung`/`aktive_darstellung`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DetailLevel = "coarse" | "medium" | "fine"; // einfach | standard | detail
|
||||||
|
// Auflösung (Port _resolve_oeff_darstellung):
|
||||||
|
function resolveDetail(el, doc): DetailLevel {
|
||||||
|
const v = el.detailLevel ?? "auto";
|
||||||
|
return v === "auto" ? doc.detailLevel : v; // doc-Level hat IMMER konkreten Wert
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Dokument-Ebene:** `Project.detailLevel` (Default `coarse`/einfach = 1:100,
|
||||||
|
DOSSIER `_DARSTELLUNG_DEFAULT_GLOBAL`). In der TopBar global umschaltbar; ein
|
||||||
|
ViewSnapshot kann ihn pro Ansicht überschreiben (plans-output.md §5).
|
||||||
|
- **Wirkung:** jedes Bauteil-`generatePlan`/`build3d` liest den aufgelösten LoD und
|
||||||
|
zeichnet entsprechend (elements.md: Tür coarse=Lücke, fine=Glas/Schwenkbogen/
|
||||||
|
Sims). Kritisch für Mixed-Scale-Pläne (1:50 Detail neben 1:200 Übersicht).
|
||||||
|
- **Schnitt-Schraffur** koppelt an LoD: grob ggf. nur Umriss, fein voll schraffiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Section-Style (3D-Schnittflächen)
|
||||||
|
Port DOSSIER `_apply_section_style` (`layer_builder.py`). Wo die Schnittebene ein
|
||||||
|
Bauteil durchschneidet: Schnittfläche bekommt die **Component-Schraffur**, die
|
||||||
|
Schnittkante einen dicken Rand, optional eine Silhouette.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface SectionStyle { // pro Component (oder Ebene) ableitbar
|
||||||
|
hatchId?: string; hatchScale; hatchAngle;
|
||||||
|
boundaryShow: boolean; boundaryLineStyleId; boundaryWidthScale;
|
||||||
|
fillBackground: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **3D-Viewport:** Three.js hat keinen nativen „Schnittflächen-Cap". Cap-Geometrie
|
||||||
|
selbst erzeugen: Schnittpolygon der Cut-Plane mit den Breps → Fläche mit
|
||||||
|
Hatch-Material (oder Stencil-Cap-Technik). Phase 4.
|
||||||
|
- **2D-Schnitt (SVG):** `generateSection` (plans-output.md §4) liefert `cutFaces`
|
||||||
|
→ mit `SectionStyle.hatchId` füllen. Das ist der Hauptweg; der 3D-Cap ist Bonus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Was Browser hier einfacher macht (vs. DOSSIER)
|
||||||
|
|
||||||
|
| DOSSIER-Aufwand | entfällt im Browser, weil … |
|
||||||
|
|---|---|
|
||||||
|
| UserString-Backup/Restore bei Overrides (`_backup_original`/`_restore_original`) | Overrides sind reine Render-Reads — nichts wird mutiert |
|
||||||
|
| Hatch-Curve-Link über Sticky (`gestaltung_curve_hatch`, Pending-TTL) | Schraffur ist eine Eigenschaft des Polygons, kein separates Objekt |
|
||||||
|
| Plotweight-Welt↔Bildschirm-Rescaling (`write/read_plotweight`) | Strichstärke ist in mm im SVG-Paper-Space (plans-output.md §3.2) |
|
||||||
|
| `install_listeners` für Live-Override-Reapply | reaktiver Store re-rendert automatisch |
|
||||||
|
| SectionStyle-API-Reflection über Rhino-Versionen | wir definieren das Rendering selbst (SVG/Three) |
|
||||||
|
| Cross-doc Presets als Dateien im User-Home | LocalStorage + Export/Import |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Umsetzungs-Reihenfolge (verweist auf ROADMAP-Phasen)
|
||||||
|
|
||||||
|
1. **Phase 1:** Component/Hatch/Line-Manager (§1) + `resolveStyle` (§3) +
|
||||||
|
LoD-Grundgerüst (§7) + LoD-bewusste Stil-UI.
|
||||||
|
2. **Phase 2:** Stil-Kataloge (Wände/Öffnungen), Material-Seeds mit `joinPriority`.
|
||||||
|
3. **Phase 3:** Overrides-Engine + SIA-Preset (§4), Symbol-Bibliothek (§5),
|
||||||
|
Rich-Text (§6), PBR-Material-Bibliothek; massstabsabhängige Hatch-/Linetype-
|
||||||
|
Skalierung (plans-output.md §3.2).
|
||||||
|
4. **Phase 4:** Section-Style 3D-Cap (§8).
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# Handoff — Rhino-artiges Befehlssystem + Modellier-Werkzeuge
|
||||||
|
|
||||||
|
> Für die Instanz, die das Befehlssystem (Tab-getriggert) und die Rhino-artigen
|
||||||
|
> Modellierfunktionen baut. Stand: 2026-06-30. Zuerst lesen: `CONVENTIONS.md`,
|
||||||
|
> `ROADMAP.md`, `HANDOVER.md`, `docs/design/drawing-tools.md`. Volle Autonomie,
|
||||||
|
> selbst bestätigen (Memory `proceed-autonomously`, `wire-dont-stub`, `prefer-agents`).
|
||||||
|
|
||||||
|
Dieses Dokument hat drei Teile:
|
||||||
|
1. **Was schon steht** (worauf du aufbaust — exakte Dateien/Typen/Actions).
|
||||||
|
2. **Rhino-Referenz** (Interaktionsmodell, das nachzubilden ist).
|
||||||
|
3. **Konkreter Bauplan für DIESE Codebase** (Architektur, Dateien, Reihenfolge).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR — die Kernidee
|
||||||
|
|
||||||
|
Es gibt **kein** Befehlssystem, keine Command-Line, keinen Tab-Handler. Das baust du
|
||||||
|
greenfield. **Aber:** Das Werkzeug-System ist bereits eine saubere Pure-Function-Registry
|
||||||
|
(`src/tools/`) mit generischem Controller in `App.tsx`, und die Mutations-Schicht
|
||||||
|
(`projectSlice`) ist umfassend. Ein neues Modellier-Tool steckst du durch Hinzufügen eines
|
||||||
|
`Tool`-Objekts ein — **PlanView muss dafür nicht angefasst werden**.
|
||||||
|
|
||||||
|
Das Befehlssystem ist im Kern eine **State-Machine-Engine über prompt → pick/type →
|
||||||
|
options**, plus eine **Command-Line-UI** (Statusleiste), plus ein **Koordinaten-Parser**.
|
||||||
|
Befehle dispatchen auf bestehende Store-Actions + `setActiveTool`/`setProject`.
|
||||||
|
|
||||||
|
**Wichtigster konzeptioneller Sprung:** Die heutigen Tools haben je eine *eigene*
|
||||||
|
ad-hoc-Phasenlogik (`onClick`/`onMove`). Rhino-Feel verlangt eine **gemeinsame
|
||||||
|
Prompt/Option/Numerik-Engine**, die alle Befehle teilen. Plane das als Verallgemeinerung
|
||||||
|
des bestehenden `Tool`-Interfaces, nicht als Parallelwelt daneben (sonst zwei Eingabe-Pfade,
|
||||||
|
die divergieren).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TEIL 1 — Was schon steht (Baufundament)
|
||||||
|
|
||||||
|
Stack: React 18 + TS + Vite, `three` 0.169 (nur 3D-Display). Einheiten intern **Meter**.
|
||||||
|
Eigener winziger Store (`useSyncExternalStore`, kein Redux/Zustand). Identifier englisch,
|
||||||
|
UI-Text deutsch via `t()`. Strict tsc (`noUnusedLocals` → ungenutzte Vars brechen den Build).
|
||||||
|
|
||||||
|
## 1.1 Werkzeug-System — `src/tools/`
|
||||||
|
- **`Tool`-Interface** `src/tools/types.ts:139` — reine Funktionen über internen `ToolState`
|
||||||
|
(Discriminated Union je Tool). Handler geben `[nextState, ToolResult]` zurück. Tools
|
||||||
|
schreiben NIE Plan-Primitive; sie geben `commit(project) => project` zurück.
|
||||||
|
- `ToolId` `types.ts:11`: `"select" | "wall" | "line" | "polyline" | "rect"`.
|
||||||
|
- `ToolContext` `types.ts:72`: `{ project, level, defaultCategoryCode, activeWallTypeId, activeLineStyleId }`.
|
||||||
|
- `ToolPointer` `types.ts:85`: `{ raw, snap, point (=snap?.point ?? raw), shift, ctrl, alt, button }`.
|
||||||
|
- `ToolResult` `types.ts:121`: `{ draft, commit?, done? }`. `ToolDraft` `types.ts:109`:
|
||||||
|
`{ preview: DraftShape[], vertices: Vec2[], snap?, hud?: {at,text} }`.
|
||||||
|
- **Registry** `src/tools/tools.ts:375`: `TOOLS: Record<ToolId,Tool>`, `getTool(id)` `:384`,
|
||||||
|
`TOOL_ORDER` `:389`. Implementiert: select (Platzhalter), wall, line, polyline, rect.
|
||||||
|
- `uniqueId(prefix)` `types.ts:188` — ID-Generator.
|
||||||
|
|
||||||
|
## 1.2 Controller / Verdrahtung — alles in `App.tsx` (NICHT in den Tool-Dateien)
|
||||||
|
- Aktives Tool: `const [activeTool,setActiveTool]=useState<ToolId>("select")` `App.tsx:197`.
|
||||||
|
- Laufender Zustand in **Ref** `toolStateRef` `App.tsx:205` (kein Re-Render je Mausschritt).
|
||||||
|
Live-Vorschau `const [draft,setDraft]` `App.tsx:206`.
|
||||||
|
- **Controller** `runToolStep(kind,raw,pxPerMeter,mods)` `App.tsx:350`: baut `ToolContext`
|
||||||
|
(`toolCtx` `:294`), snappt via `snapFor` `:322` (→ `computeSnap`), baut `ToolPointer`,
|
||||||
|
ruft `tool.onClick/onMove`, speichert State in Ref, `applyToolResult` `:339` wendet
|
||||||
|
draft/commit/done an. `toolHandlers` `App.tsx:419` verbindet PlanView↔Controller.
|
||||||
|
- Tool-Tasten `App.tsx:448`: Esc=Abbruch/zurück-zu-select, Enter=Commit-Geste, Backspace=Punkt zurück.
|
||||||
|
|
||||||
|
## 1.3 Semantisches Modell — `src/model/types.ts`
|
||||||
|
- `type Element = Wall | Door | Drawing2D` `:251`. `Vec2={x,y}`. **Kein Slab/Stair-Typ.**
|
||||||
|
- `Project` `:254`: `{ ..., wallTypes[], drawingLevels[], layers[], walls[], doors[], drawings2d[] }`.
|
||||||
|
- `Wall` `:150`: `{ id,type:"wall", floorId, categoryCode, start, end, wallTypeId, height, color? }`
|
||||||
|
(Mittellinie + mehrschichtiger `WallType`).
|
||||||
|
- **Plan-Primitive `Drawing2DGeom`** `:200` — die Geom-Typen existieren bereits ALLE:
|
||||||
|
`line | polyline | rect | circle | arc | text`. Aber Tools erzeugen heute nur line/polyline/rect,
|
||||||
|
und `drawingVertices` (Grips) kennt nur diese drei. **circle/arc/text sind im Typ da, aber
|
||||||
|
nicht durchgängig gerendert/editierbar** — Lücke, kein Neubau nötig.
|
||||||
|
- `Drawing2D` `:209`: `{ id,type:"drawing2d", levelId, categoryCode, geom, lineStyleId?, hatchId?, color?, fillColor?, weightMm? }`.
|
||||||
|
|
||||||
|
## 1.4 Geometrie — `src/model/geometry.ts`
|
||||||
|
`sub,add,scale,len,normalize`; `leftNormal(a)={x:-a.y,y:a.x}` `:17` (Wand-Normale-Konvention);
|
||||||
|
`cross`, `lineIntersect(a,da,b,db)`, `along`, `wallBand`, `wallCorners` `:70`, `clippedBand` `:87`.
|
||||||
|
`src/model/joins.ts`: `computeJoins(project,walls)` `:44` (nur L-Ecken gehrt; T/X eckig).
|
||||||
|
**Für Offset/Trim/Fillet (Rhino-Kern) gibt es NOCH KEIN 2D-Geometrie-Kernel** — Kurven/Kurven-
|
||||||
|
Schnitt, Polylinien-Offset usw. musst du ergänzen (siehe Bauplan §3.4).
|
||||||
|
|
||||||
|
## 1.5 Store — `src/state/`
|
||||||
|
- `createStore` `store.ts:51` über `useSyncExternalStore`. **Actions leben IM State**
|
||||||
|
(`useStore(s=>s.action)`, referenzstabil). `RootState = Project & Selection & View & Layout`
|
||||||
|
`appStore.ts:21`. Exports `useStore`, `getState`, `setState`.
|
||||||
|
- **projectSlice**: `project` + `setProject(next|(p)=>p)`. Mutationen u.a. `addFloor`,
|
||||||
|
`addCategory`, `setElementColor/Weight/Fill`, `resizeElement`, `moveGripOf`, `moveElementByOf`,
|
||||||
|
`moveEdgeOf`, `commitTransformOn`. **Es gibt keine generische „addWall/addDrawing2d"-Action** —
|
||||||
|
Tools committen via `setProject`. (Beim Befehlssystem ggf. saubere Actions ergänzen.)
|
||||||
|
- **Aktive Zeichenebene + aktive Kategorie liegen im viewSlice**, NICHT in selection:
|
||||||
|
`activeLevelId` `viewSlice.ts:46`/`setActiveLevelId`, `activeCategoryCode` `:42`/`setActiveCategoryCode`.
|
||||||
|
- selectionSlice: `selectedWallIds[]`, `selectedDrawingId` + Setter/`clearSelection`.
|
||||||
|
|
||||||
|
## 1.6 Views, Eingabe, Koordinaten — `src/plan/PlanView.tsx` (SVG-Vektor)
|
||||||
|
- Modell → `Plan`-Primitive via `generatePlan` `src/plan/generatePlan.ts:203`. `Primitive` =
|
||||||
|
`polygon|line|arc` (polygons tragen `wallId`/`drawingId` für Hit-Test).
|
||||||
|
- **Transform (entscheidend):** `PX_PER_M=90` `:20`; `toScreen(p)={x:p.x*90,y:-p.y*90}` `:31`
|
||||||
|
(fixer Welt-Ursprung 0,0; Y flippt). Invers `viewToModel` `:440`. SVG `viewBox`=State `view`;
|
||||||
|
Pan/Zoom ändern nur `view`, nie das Modell↔Screen-Mapping.
|
||||||
|
- **`rawModelAt(clientX,clientY)`** `:446` = aktuelle Mauswelt-Position in Meter (der Eine-Aufruf,
|
||||||
|
den ein Tool/Befehl braucht). `currentPxPerMeter()` `:488`.
|
||||||
|
- **Pointer-Events** alle am `<svg>` `:910`: down `:553`, move `:625`, up `:715`, wheel `:820`,
|
||||||
|
dblclick `:847`, contextmenu `:857`. Schema: Mitte=Pan, Links=Select/Marquee/Tool, Rechts=Menü.
|
||||||
|
Bei `toolActive` `:268` routen Links-Events zu `toolHandlers`. **PlanView meldet bereits
|
||||||
|
`(rawModelAt, currentPxPerMeter, toolMods)` nach oben** — neue Tools brauchen hier NICHTS.
|
||||||
|
- **Snapping** `src/tools/snapping.ts`: `computeSnap(input)` `:88` — endpoint/midpoint/intersection/
|
||||||
|
onEdge/grid/ortho mit Prioritätstabelle. Wird in App (`snapFor`) konsumiert, nicht in PlanView.
|
||||||
|
`applyAngleConstraint` für Ortho. `SnapSettings`/`DEFAULT_SNAP` in `tools/types.ts:36/55`.
|
||||||
|
- 3D `src/viewport/Viewport3D.tsx` (three.js, Raycaster): nur Anzeige+Auswahl, **keine
|
||||||
|
Zeichenwerkzeuge**. 3D-Authoring = eigene spätere Phase (Raycast auf Arbeitsebene).
|
||||||
|
|
||||||
|
## 1.7 Tastatur / globale Eingabe — **kein Dispatch-System**
|
||||||
|
- `main.tsx:38` globaler `contextmenu`→preventDefault; `:42` blockt Ctrl/Cmd+A außerhalb Inputs;
|
||||||
|
`isTextEntry(el)` `:24`.
|
||||||
|
- App-useEffects mit `window.addEventListener("keydown")`: Tool-Tasten `:448`, Delete `:604`,
|
||||||
|
Transform-Shortcuts m/s/d + u/i/o/p `:637`. **Jeder Guard wiederholt inline den
|
||||||
|
INPUT/TEXTAREA/contentEditable-Check** — es gibt keine geteilte Keymap. Dein Tab-Handler +
|
||||||
|
Command-Input kommt als neuer globaler `keydown` dazu (siehe §3.2).
|
||||||
|
|
||||||
|
## 1.8 UI-Shell + i18n
|
||||||
|
- `App.tsx` (~2200 Z., enthält noch ToolController/Grips/Transform). JSX `:1043`: TopBar → body
|
||||||
|
(Dock links, Content-View-Router, TransformBar, Dock rechts, Floating) → StatusBar →
|
||||||
|
ResourceManager → ContextMenu → InlineEditor. Panel-Daten via `PanelHostContext` (`baseHost`
|
||||||
|
`App.tsx:729`, Typ `host.ts`).
|
||||||
|
- `StatusBar.tsx` — Footer: links `hint` (Tool-Hinweis), rechts X/Y, Einheit, Massstab 1:N, Zoom,
|
||||||
|
aktives Geschoss, aktive Ebene. **Bester Ort für die Command-Line** (Rhino hat sie klassisch unten).
|
||||||
|
- **i18n** `src/i18n/`: `t(key,params?)` `index.ts:67`, `useT()` `:84`. Flaches `as const`-Dict,
|
||||||
|
Punkt-Namespaces (`tool.*`,`snap.*`,`transform.*`,`status.*`…). `de.ts` (Quelle, ~309 Keys) +
|
||||||
|
`en.ts`; `TranslationKey=keyof typeof de` erzwingt Parität. **Neue Keys IMMER in beide Dateien.**
|
||||||
|
Keine hartcodierten JSX-Strings.
|
||||||
|
|
||||||
|
## 1.9 Verifizieren
|
||||||
|
- `npx tsc -b` · `npm run build` · Dev `npm run dev` (Vite 5173, `host:true`).
|
||||||
|
- Screenshot `node scripts/probe.mjs` → `scripts/probe.png` (Puppeteer headless, `deviceScaleFactor:2`,
|
||||||
|
URL via `PROBE_URL`). Viele task-Probes existieren (`probe-tools.mjs`, `probe-line.mjs`,
|
||||||
|
`probe-transform.mjs` …) — gute Vorlagen, um Tools/Befehle programmatisch zu treiben.
|
||||||
|
**Screenshot ansehen + Geometrie prüfen**, nicht nur „kompiliert".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TEIL 2 — Rhino-Referenz (das Interaktionsmodell)
|
||||||
|
|
||||||
|
## 2.1 Die Command-Line ist das Rückgrat
|
||||||
|
**Alles ist ein Befehl**, und die Command-Line **hört immer zu**: Tastenanschläge gehen an die
|
||||||
|
Command-Line, wenn sie nicht von einem Feld konsumiert werden. Kein „Tool aktiv vs. Eingabe aktiv".
|
||||||
|
Die Zeile hat gleichzeitig drei Rollen: **Eingabe** (Befehl/Wert tippen), **Prompt**
|
||||||
|
(„Start of line", „Next point"), **Optionen** (eckige, klickbare Inline-Optionen).
|
||||||
|
|
||||||
|
## 2.2 Befehl aufrufen
|
||||||
|
- Namen tippen, z. B. `Line`. **Präfix-Autocomplete** (case-insensitiv): `L`→`Li`→`Lin` zeigt
|
||||||
|
Kandidatenliste mit Best-Match. **Tab/Pfeile** akzeptieren Vorschlag, **Enter/Leertaste** führt aus.
|
||||||
|
- **Aliase**: nutzerdefinierte Kürzel → Makro (z. B. `L`→`!_Line`, `cp`→`!_Copy`). Werden VOR
|
||||||
|
Autocomplete gematcht. (Minimal: Einzelbuchstabe→Befehl.)
|
||||||
|
|
||||||
|
## 2.3 Enter / Leertaste / Rechtsklick (leicht falsch gemacht)
|
||||||
|
- **Enter = Leertaste** in der Command-Line. Beide: Befehl ausführen / Default akzeptieren /
|
||||||
|
mehrteiligen Befehl **beenden** / bei **leerer** Zeile **letzten Befehl wiederholen**.
|
||||||
|
- **Rechtsklick im Viewport = Enter.** Also: Rechtsklick beendet Polyline UND wiederholt bei
|
||||||
|
leerer Zeile den letzten Befehl. → `lastCommand` speichern, bei Leer-Enter/Rechtsklick neu starten.
|
||||||
|
|
||||||
|
## 2.4 Inline-Optionen (klickbare Klammern)
|
||||||
|
```
|
||||||
|
Start of line ( BothSides=No Chamfer Mode=Distance ):
|
||||||
|
```
|
||||||
|
- Jede Option **klickbar UND tippbar** (genug Buchstaben zur Eindeutigkeit + Enter).
|
||||||
|
- **Toggle** `Name=Value` flippt beim Klick. **Value**-Option fragt Unterwert ab. **Action**-Option
|
||||||
|
(ohne `=`) verzweigt sofort.
|
||||||
|
- Optionen sind **innerhalb des Befehls persistent**, viele **über Aufrufe hinweg** (letzte
|
||||||
|
Offset-Distanz, Array-Anzahl, Fillet-Radius merken). **Zuletzt benutzte Optionswerte je Befehl
|
||||||
|
persistieren** — Nutzer erwarten das.
|
||||||
|
|
||||||
|
## 2.5 Sub-Prompts = State-Machine
|
||||||
|
Befehle laufen Prompts ab. `Line`: „Start of line:" → Punkt → „End of line:" → Punkt → fertig.
|
||||||
|
`Polyline`: „Start" → „Next point ( Close Undo ):" → … → **Enter** beendet. Prompt-Text ist
|
||||||
|
sichtbar und lehrreich („Next point. Press Enter when done") — literal nachbilden.
|
||||||
|
|
||||||
|
## 2.6 Transparente/verschachtelbare Befehle
|
||||||
|
Manche Befehle (Zoom/Pan, Osnap-Toggle, alles mit `'`-Präfix) laufen **innerhalb** eines anderen,
|
||||||
|
ohne ihn abzubrechen, und kehren zum Original-Prompt zurück. → Command-Runner braucht einen **Stack**.
|
||||||
|
|
||||||
|
## 2.7 Koordinaten- & Numerik-Eingabe (Herz der Präzision)
|
||||||
|
| Eingabe | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `5,3` / `5,3,2` | absolut X,Y(,Z) |
|
||||||
|
| `r5,3` | **relativ** zum letzten Punkt (das `r`-Idiom) |
|
||||||
|
| `<45` | Winkel-Constraint auf 45°, dann Maus/Distanz |
|
||||||
|
| `5<45` | **polar**: Distanz 5 unter 45° vom letzten Punkt |
|
||||||
|
| Zahl tippen während Drag | **Distanz-Lock**: Richtung per Maus, Länge per Zahl+Enter (meistgenutzte Geste) |
|
||||||
|
| Zahl + **Tab** | Lock umschalten (Länge fix → Winkel folgt Maus, oder umgekehrt) |
|
||||||
|
Das Feld parst **kontextabhängig**: Befehlsname / Optionsbuchstabe / Koordinate / nackte Zahl —
|
||||||
|
je nach Befehlszustand. Das Live-Tool muss **einen primären Skalar** (Länge/Radius/Distanz)
|
||||||
|
exponieren, an den eine getippte Zahl bindet.
|
||||||
|
|
||||||
|
## 2.8 Osnaps + Ortho + Gumball
|
||||||
|
- **Osnaps** (persistente Toggles): End, Mid, Cen, Int, Perp, Near, Quad, Tan, Point. Pro Mausschritt
|
||||||
|
gegen nahe Geometrie geprüft (Pixel-Toleranz), Marker+Label am Cursor; liefert **exakte
|
||||||
|
Modellkoordinate** (nie Roh-Maus, wenn Snap aktiv). One-Shot-Osnap überschreibt für den nächsten Pick.
|
||||||
|
- **Ortho** (F8): Winkelraster (90°/konfigurierbar), **Shift** togglet temporär. **Grid Snap** (F9).
|
||||||
|
**SmartTrack**: temporäre Hilfslinien aus zuletzt gehoverten Punkten.
|
||||||
|
- **Gumball**: On-Object-Widget (Pfeile=Move, Bögen=Rotate, Handles=Scale); Handle klicken →
|
||||||
|
Zahl tippen für exakten Transform. Direkt-Manipulations-Gegenstück zu getippten Befehlen.
|
||||||
|
|
||||||
|
> Präzisionsmodell = **(Snap ODER getippte Koordinate) × (Ortho/Winkel-Constraint) ×
|
||||||
|
> (Distanz-Constraint)**, in EINEM Pick komponierbar.
|
||||||
|
|
||||||
|
## 2.9 Auswahl-Modell (links/rechts-Regel exakt)
|
||||||
|
- Klick = wählen; Shift+Klick add; Ctrl+Klick remove.
|
||||||
|
- **Links→rechts = Window** (nur voll umschlossene; **durchgezogenes** Rechteck).
|
||||||
|
- **Rechts→links = Crossing** (auch berührte; **gestricheltes** Rechteck). Richtung bestimmt
|
||||||
|
Modus — starke Konvention, exakt nachbilden.
|
||||||
|
- **SelLast** (zuletzt erzeugte/gewählte erneut wählen) ist enorm nützlich („erzeugen, dann sofort
|
||||||
|
bewegen"). Min. `SelLast`, `SelAll`, `SelNone`, `Invert`.
|
||||||
|
|
||||||
|
## 2.10 Befehls-Prompt-Sequenzen (Kurz)
|
||||||
|
2D: **Line** (2 Pkt) · **Polyline** (Close/Undo, Enter beendet) · **Rectangle** (Ecke+Ecke, oder
|
||||||
|
Breite/Höhe tippen; 3Point/Center) · **Circle** (Center+Radius; 2P/3P/Tan) · **Arc** (Center-Start-End /
|
||||||
|
3Point) · **Offset** (Kurve wählen → Seite klicken/Distanz tippen; Distanz persistent) ·
|
||||||
|
**Fillet/Chamfer** (Kurve1→Kurve2, Radius/Distances persistent) · **Trim** (Schneider wählen→Enter→
|
||||||
|
wegzuschneidendes Stück klicken) · **Split** · **Extend** · **Join** · **Explode** ·
|
||||||
|
**Move/Copy/Rotate/Scale/Mirror** (Auswahl→Basispunkt→Ziel; Copy-Option) · **ArrayRect/ArrayPolar** ·
|
||||||
|
**Group/Ungroup**.
|
||||||
|
3D (braucht CSG, später): **ExtrudeCrv** (geschlossene Kurve→Solid, Cap) · **Box** · **Boolean
|
||||||
|
Union/Difference/Intersection** · **Cap** · **Gumball-Face-Drag = PushPull** · Loft/Sweep/Revolve.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TEIL 3 — Bauplan für DIESE Codebase
|
||||||
|
|
||||||
|
> Ziel: nutzbarer 2D-Architektur-Drafter mit Rhino-Feel, dann einfaches Massing. Halte das
|
||||||
|
> ROADMAP-Prinzip: **ein semantisches Modell → Sichten abgeleitet**; Extrusionshöhe ist eine
|
||||||
|
> Eigenschaft, nie eingebackene Geometrie.
|
||||||
|
|
||||||
|
## 3.0 Kuratierungs-Prinzip (WICHTIG — Nutzer-Vorgabe)
|
||||||
|
**NICHT den ganzen Rhino-Katalog stumpf portieren.** Wir bauen ein **Wohnbau-BIM**, keinen
|
||||||
|
NURBS-Allzweck-Modeller. Nimm nur, was dem Wohnbau-Workflow dient; lass den Rest weg, bis er
|
||||||
|
konkret gebraucht wird. Faustregel: *Brauche ich das, um ein Einfamilienhaus zu zeichnen und
|
||||||
|
daraus Pläne zu ziehen?* Wenn nein → weglassen.
|
||||||
|
|
||||||
|
**Bewusst WEGLASSEN (vorerst):** Loft / Sweep1+2 / Revolve (Sonderformen, kaum Wohnbau) ·
|
||||||
|
freie NURBS-Kurven (`Curve`/`InterpCrv` Grad>1, Deformable, FromFoci) · Ellipse · Tangent/
|
||||||
|
Bisector/4Point-Linienvarianten · SmartTrack (nett, nicht kritisch) · der volle `Sel*`-Zoo
|
||||||
|
(nur SelLast/SelAll/SelNone/Invert) · Knot/Vertex/Tan-Osnaps. Alle leicht später additiv
|
||||||
|
nachrüstbar — kein Grund, sie jetzt mitzuschleppen.
|
||||||
|
|
||||||
|
**Booleans sind KEIN „nice to have später"** — sie werden gebraucht, **sobald Tür/Fenster als
|
||||||
|
echte 3D-Öffnung** kommen (heute schneidet `Door` nur eine Plan-Lücke, kein 3D-Boolean, siehe
|
||||||
|
HANDOVER). Darum: CSG/Booleans an die **Tür/Fenster-Phase koppeln** und dann reinnehmen — nicht
|
||||||
|
ans Ende schieben. ABER (das ist der „nicht stumpf"-Teil):
|
||||||
|
> Für **rechteckige** Öffnungen in extrudierten Wänden braucht es **keinen allgemeinen
|
||||||
|
> Boolean-Kernel**. Eine analytische **Wand-minus-Box-Subtraktion** (Öffnung als parametrische
|
||||||
|
> Aussparung im Wand-Solid) ist einfacher, robuster und für 95 % Wohnbau ausreichend. Den
|
||||||
|
> allgemeinen CSG-Boolean (`rhino3dm`) erst ziehen, wenn schräge/runde/verschnittene Fälle
|
||||||
|
> wirklich auftreten. Also: **Öffnungen zuerst analytisch, allgemeine Booleans erst bei Bedarf.**
|
||||||
|
|
||||||
|
## 3.1 Leitentscheidung: Engine verallgemeinern, nicht parallel bauen
|
||||||
|
Baue eine gemeinsame **Command-Engine**, die das bestehende `Tool`-Interface erweitert/ablöst,
|
||||||
|
sodass es **einen** Eingabepfad gibt (Maus + Tastatur + Command-Line speisen dieselbe Maschine).
|
||||||
|
Konkret: ein `Command`-Modell, das je Schritt einen **Prompt** (Text), erwartete **Eingabearten**
|
||||||
|
(Punkt | Zahl | Option | Auswahl) und **Optionen** beschreibt. Die heutigen Tools werden zu
|
||||||
|
Befehlen dieser Engine (wall/line/polyline/rect lassen sich 1:1 portieren — ihre Phasenlogik ist
|
||||||
|
schon eine Mini-State-Machine).
|
||||||
|
|
||||||
|
**Warum nicht das alte Tool-Interface unangetastet lassen und Command-Line nur draufsetzen?**
|
||||||
|
Weil die Command-Line getippte Koordinaten/Optionen in denselben Schritt einspeisen muss, in dem
|
||||||
|
die Maus pickt. Zwei getrennte Pfade divergieren garantiert (Snapping, Constraints, HUD doppelt).
|
||||||
|
|
||||||
|
## 3.2 Neue Dateien (Vorschlag)
|
||||||
|
- `src/commands/engine.ts` — Command-Runner: aktiver Befehl, Prompt-Stack (für transparente
|
||||||
|
Befehle §2.6), `lastCommand`-Wiederholung, Routing von Maus-Pick / getippter Eingabe / Option-Klick
|
||||||
|
in den aktuellen Schritt. Hält `CommandState`.
|
||||||
|
- `src/commands/types.ts` — `Command`-Interface (Verallgemeinerung von `Tool`): Schritte mit
|
||||||
|
`prompt: TranslationKey`, `accepts: ("point"|"number"|"option"|"selection")[]`, `options: CmdOption[]`,
|
||||||
|
`onInput(state,input,ctx): [state, CommandResult]`. `CommandResult` wie `ToolResult` (+`commit`).
|
||||||
|
- `src/commands/parseInput.ts` — Koordinaten-Parser (§2.7): `5,3` · `r5,3` · `5<45` · `<45` ·
|
||||||
|
nackte Zahl (Distanz-Lock) · Optionsbuchstabe. Liefert eine Discriminated Union, die die Engine
|
||||||
|
in einen Modellpunkt/Constraint auflöst (mit `lastPoint` für `r`/polar).
|
||||||
|
- `src/commands/registry.ts` — `COMMANDS: Record<string,Command>` + Aliase + Autocomplete (Präfix).
|
||||||
|
- `src/ui/CommandLine.tsx` — die Command-Line-UI **in/über der Statusleiste** (`StatusBar.tsx`):
|
||||||
|
zeigt Prompt + klickbare Optionen + Texteingabe; Autocomplete-Dropdown. Tab fokussiert sie.
|
||||||
|
- (später) `src/geometry/kernel2d.ts` — 2D-Kernel für Offset/Trim/Fillet/Schnitt (§3.4).
|
||||||
|
- (viel später) `src/geometry/solid3d.ts` o. `rhino3dm`-Anbindung für Massing/Booleans (§3.5).
|
||||||
|
|
||||||
|
## 3.3 Verdrahtung (minimal-invasiv)
|
||||||
|
- **Globaler Tab-Handler**: neuer `window.keydown` in App (gleicher Guard wie `App.tsx:448` —
|
||||||
|
INPUT/TEXTAREA/contentEditable überspringen). Tab → Command-Line fokussieren/öffnen. Jeder
|
||||||
|
getippte Buchstabe ohne aktives Tool startet den Befehlsmodus (Rhino „hört immer zu" — optional
|
||||||
|
in Phase 2; Phase 1 reicht Tab).
|
||||||
|
- **Command-Line → Engine → Store**: Befehle dispatchen auf `setActiveTool` (für tool-artige) bzw.
|
||||||
|
direkt auf Store-Actions / `setProject`. Nutze `getState()/setState()` (referenzstabil) aus
|
||||||
|
`appStore.ts`.
|
||||||
|
- **Pick-Eingabe**: die Engine konsumiert dieselben `(rawModelAt, currentPxPerMeter, toolMods)`,
|
||||||
|
die PlanView schon hochmeldet (`ToolHandlers`). `computeSnap` für Punktfang wiederverwenden.
|
||||||
|
→ PlanView braucht im Idealfall **keine Änderung** (höchstens: Window/Crossing-Marquee-Visual
|
||||||
|
durchgezogen vs. gestrichelt nach Drag-Richtung, §2.9 — heute evtl. nur ein Modus).
|
||||||
|
- **Prompt/HUD**: Prompt-Text in die Statusleiste (`StatusBar` `hint` existiert schon). Distanz/
|
||||||
|
Winkel-HUD am Cursor existiert in `ToolDraft.hud`.
|
||||||
|
|
||||||
|
## 3.4 Reihenfolge (Tiers — strikt 2D zuerst)
|
||||||
|
**Tier 0 — Substrat (VOR jedem Befehl; das ist der „Feel"):**
|
||||||
|
1. Command-Runner + Command-Line-UI (Prompt → pick/type → Optionen; Enter/Space/Rechtsklick =
|
||||||
|
bestätigen/beenden/wiederholen; `lastCommand`).
|
||||||
|
2. Koordinaten-Parser (`x,y` · `rdx,dy` · `dist<angle` · nackte-Zahl-Lock).
|
||||||
|
3. Osnaps (End/Mid/Cen/Int/Perp/Near) — `computeSnap` ist da, ggf. Cen/Perp/Near ergänzen.
|
||||||
|
4. Ortho (90°/45°, Shift-Toggle) + Grid-Snap — teils vorhanden (`applyAngleConstraint`).
|
||||||
|
5. Auswahl: Klick, Shift/Ctrl add/remove, **Window vs. Crossing** (durchgezogen/gestrichelt,
|
||||||
|
links/rechts-Regel).
|
||||||
|
|
||||||
|
**Tier 1 — 2D-Pflicht (reines SVG/2D), grobe Baufolge:**
|
||||||
|
6. **Line** (validiert die ganze pick/snap/constrain-Schleife) → 7. **Polyline** (Close/Undo) →
|
||||||
|
8. **Rectangle** (Ecke + Center/3Point) → 9. **Circle** (Center+Radius). Diese vier portieren die
|
||||||
|
heutigen Tools auf die Engine + numerische Eingabe.
|
||||||
|
10. **Move** → 11. **Copy** (wiederholend) → 12. **Offset** (persistente Distanz — DAS Architektur-
|
||||||
|
Primitiv) → 13. **Trim** + **Split** → 14. **Join** + **Explode**. **Undo/Redo** durchgängig
|
||||||
|
annehmen (heute? — prüfen; ggf. Command-History/Undo-Stack im Store ergänzen).
|
||||||
|
|
||||||
|
**Tier 2 — 2D stark nützlich:** Rotate/Scale/Mirror (Copy-Option) · Fillet/Chamfer · Arc ·
|
||||||
|
Extend · ArrayRect/ArrayPolar · Group/Ungroup · Gumball(2D) · Sel*-Helfer (min. SelLast).
|
||||||
|
|
||||||
|
**Tier 3 — Massing + Öffnungen (an Tür/Fenster-Phase gekoppelt):**
|
||||||
|
- **Öffnungen zuerst analytisch:** Tür/Fenster als parametrische Aussparung im Wand-Solid
|
||||||
|
(Wand-Extrude minus Öffnungs-Box) — KEIN allgemeiner Boolean-Kernel nötig (§3.0). Das ist der
|
||||||
|
kritische, roadmap-markierte 🔴-Teil (echte 3D-Öffnung statt nur Plan-Lücke) und kommt MIT
|
||||||
|
Tür/Fenster, nicht danach.
|
||||||
|
- **Massing-Befehle:** ExtrudeCrv (geschlossene Plan-Kurve → gecapptes Solid) → Box →
|
||||||
|
Gumball-Face-Drag-PushPull.
|
||||||
|
- **Allgemeine Booleans** (Union/Difference/Intersection) **erst bei Bedarf** (schräge/runde/
|
||||||
|
verschnittene Fälle): **kein eigener Kernel — `rhino3dm` (WASM-openNURBS)** als `src/io/`-Schicht
|
||||||
|
(Roadmap-Entscheid, HANDOVER). Bis dahin reicht die analytische Subtraktion.
|
||||||
|
- **Weggelassen:** Loft/Sweep/Revolve/OffsetSrf (§3.0 — Sonderformen, kaum Wohnbau).
|
||||||
|
|
||||||
|
## 3.5 Was sauber 2D ist vs. was hart ist
|
||||||
|
- **Sauber SVG/2D:** Line, Polyline, Rect, Circle, Arc, Move/Copy/Rotate/Scale/Mirror, Array, Group,
|
||||||
|
Control-Point-Edit, Gumball(2D). Affine Transforms + Kurven-Schnitt.
|
||||||
|
- **Echte Arbeit (2D-Kernel nötig):** **Offset, Trim, Fillet** brauchen kompetenten Kurven-Schnitt
|
||||||
|
und Polylinien-Offset — dafür Zeit einplanen (`src/geometry/kernel2d.ts`).
|
||||||
|
- **Braucht 3D/CSG:** Extrude, Box, Boolean*, Cap, OffsetSrf, Loft/Sweep/Revolve, Face-Drag. Booleans
|
||||||
|
sind das Korrektheits-Zentrum → `rhino3dm`.
|
||||||
|
|
||||||
|
## 3.6 Gotchas (aus Rhino-Verhalten + dieser Codebase)
|
||||||
|
- **Command-Line hört immer zu** — Tasten global routen, aber die `isTextEntry`-Disziplin
|
||||||
|
(`main.tsx:24`) + die Native-App-Regeln (kein Ctrl+A/keine Textauswahl, CONVENTIONS.md) wahren.
|
||||||
|
- **Zuletzt benutzte Optionswerte je Befehl persistieren** (Offset-Distanz, Array-Anzahl, Fillet-Radius).
|
||||||
|
- **Enter = Rechtsklick = Wiederholen/Bestätigen/Mehrteiliges-Beenden** — alle drei auf EIN Signal.
|
||||||
|
- **Distanz-Lock:** Live-Befehl muss EINEN primären Skalar exponieren, an den eine getippte Zahl bindet.
|
||||||
|
- **Window vs. Crossing** über Drag-Richtung + durchgezogen/gestrichelt — nicht global ein Modus.
|
||||||
|
- **Osnap liefert exakte Modellkoordinate** — nie Roh-Maus, wenn Snap aktiv (`ToolPointer.point`).
|
||||||
|
- **Strict tsc** (`noUnusedLocals`) — ungenutzte Vars/Parameter brechen `npm run build`.
|
||||||
|
- **i18n**: jeder sichtbare String über `t('key')`, Keys in `de.ts` UND `en.ts` (Parität erzwungen).
|
||||||
|
- **Keine generische addWall/addDrawing2d-Action** — entweder via `setProject` committen (wie heute)
|
||||||
|
oder beim Refactor saubere Actions im `projectSlice` ergänzen (besser für Undo/Redo).
|
||||||
|
- **App.tsx ist bereits ~2200 Z.** (God-Component-Kritik in HANDOVER). Lege Command-Engine in
|
||||||
|
`src/commands/`, halte App-Verdrahtung dünn (nur Tab-Handler + CommandLine-Mount + Dispatch-Brücke).
|
||||||
|
|
||||||
|
## 3.7 Verifikations-Drehbuch
|
||||||
|
Pro Tier eine Probe (Vorlage: `scripts/probe-tools.mjs`/`probe-transform.mjs`): Befehl per Command-
|
||||||
|
Line tippen → Punkte/Werte tippen → Screenshot → Geometrie visuell prüfen. Tier 0 zuerst headless
|
||||||
|
treiben (Tab → „line" → „0,0" Enter → „r3,0" Enter → Linie im PNG sichtbar). `npx tsc -b` +
|
||||||
|
`npm run build` grün halten. **Screenshot ansehen**, nicht nur Kompilat vertrauen (Memory `wire-dont-stub`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anhang — Minimaler erster Meilenstein (konkret)
|
||||||
|
1. `src/commands/types.ts` + `engine.ts` + `parseInput.ts` (Tier 0.1/0.2).
|
||||||
|
2. `src/ui/CommandLine.tsx`, in `StatusBar` gemountet; Tab-Handler in App.
|
||||||
|
3. `Line` als erster Command (portiert `lineTool`), inkl. getippter `0,0` / `r3,0` / `3<45`.
|
||||||
|
4. Probe `scripts/probe-command-line.mjs`: Tab→line→zwei getippte Koordinaten→PNG prüfen.
|
||||||
|
5. Dann Polyline/Rect/Circle, danach Move/Copy/Offset.
|
||||||
|
|
||||||
|
Damit steht der Rhino-Feel-Kern, und jeder weitere Befehl ist additiv (neues `Command`-Objekt in
|
||||||
|
`registry.ts`, keine PlanView-/App-Änderung).
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Zustands-Architektur & Code-Aufteilung (App.tsx entschlacken)
|
||||||
|
|
||||||
|
> Ziel: `App.tsx` von „God-Component" zu dünnem Shell. Globaler Zustand in einen
|
||||||
|
> Store, Features in eigene Module → **wartbar + parallel bearbeitbar**.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
`App.tsx` hält aktuell: Projekt-State, Auswahl, View-State (viewType/detailLevel/
|
||||||
|
renderMode/referenceLines/scale/activeLevel), Layout, ALLE Mutations-Handler
|
||||||
|
(floors/layers/components/hatches/lineStyles/walls/doors), Kontextmenü-Builder,
|
||||||
|
Inline-Editoren, Layout-Menü, View-Routing. → Risiko + Flaschenhals (jedes Feature
|
||||||
|
fasst App.tsx an → kein paralleles Arbeiten).
|
||||||
|
|
||||||
|
## Zielstruktur
|
||||||
|
```
|
||||||
|
src/state/
|
||||||
|
store.ts // Store (Zustand) — kombiniert die Slices, ein useStore-Hook
|
||||||
|
projectSlice.ts // project + alle Mutationen (floors, layers, components,
|
||||||
|
// hatches, lineStyles, walls, doors) inkl. recompute/refs-aware delete
|
||||||
|
selectionSlice.ts // selectedWallIds (+ marquee-Ergebnis)
|
||||||
|
viewSlice.ts // viewType, detailLevel, renderMode, referenceLines, activeLevelId, scale
|
||||||
|
layoutSlice.ts // wraps src/panels/layout.ts (docks + floating)
|
||||||
|
src/views/ // LevelPlanView, PerspectiveView, SectionStub, DrawingView (+ ViewRouter)
|
||||||
|
src/editors/ // FloorSettingsEditor, LayerSettingsEditor, … (heute inline in App)
|
||||||
|
src/menus/ // layerContextMenu(), levelContextMenu(), planContextMenu() — bauen ContextMenu-Items
|
||||||
|
src/ui/ // TopBar, StatusBar, ResourceManager, ContextMenu (bestehen)
|
||||||
|
src/panels/ // Docks/Panels (bestehen)
|
||||||
|
App.tsx // DÜNN: Store-Provider · TopBar · (Docks + ViewRouter) · StatusBar
|
||||||
|
// · Floating-Panels · Ressourcen-Overlay
|
||||||
|
```
|
||||||
|
|
||||||
|
## Store-Wahl: Zustand (empfohlen)
|
||||||
|
- Winzige Lib, kein Boilerplate, **Slices** gut teilbar, Selektoren verhindern
|
||||||
|
Re-Render-Sturm, kein Prop-Drilling. Passt zu „verschiedene Features = verschiedene
|
||||||
|
Slice-Dateien" → Parallelität.
|
||||||
|
- Alternative ohne Dependency: Context + useReducer oder `useSyncExternalStore`
|
||||||
|
(wie i18n). Mehr Boilerplate; bei der State-Menge ist Zustand ergonomischer.
|
||||||
|
- Komponenten: `const walls = useStore(s => s.walls)` / `useStore(s => s.addFloor)`.
|
||||||
|
PanelHostContext entfällt (Panels lesen direkt aus dem Store).
|
||||||
|
|
||||||
|
## Vorgehen (reiner Refactor — Verhalten MUSS identisch bleiben)
|
||||||
|
1. Store + Slices anlegen, Projekt-State + Mutationen aus App.tsx hierher ziehen
|
||||||
|
(1:1, gleiche Logik inkl. recomputeFloorElevations, refs-aware delete).
|
||||||
|
2. View-/Selection-/Layout-State in ihre Slices.
|
||||||
|
3. Inline-Editoren, Kontextmenü-Builder, View-Routing in `src/editors/`,`src/menus/`,`src/views/` extrahieren; sie lesen den Store.
|
||||||
|
4. App.tsx auf den Shell reduzieren.
|
||||||
|
5. Verifizieren: tsc + build + Screenshots — **pixel-/funktionsgleich** zu vorher
|
||||||
|
(Auswahl, Massstab, Kontextmenü, Panels, 3D/Plan, i18n). Reiner Umbau, kein
|
||||||
|
Feature-Wechsel.
|
||||||
|
|
||||||
|
## Auszahlung
|
||||||
|
- Danach editiert ein Wand-Feature `projectSlice`/`views`, ein Panel-Feature `panels`,
|
||||||
|
ein Editor `editors` — **disjunkte Dateien → mehrere Code-Workflows parallel** möglich.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Top-Bar (Oberleiste) & Footer/Status-Leiste — Design
|
||||||
|
|
||||||
|
> Referenz: DOSSIER `rhino/toolbar.py` + `src/ToolbarApp.jsx`. Hier auf unseren
|
||||||
|
> Standalone-Stack (React+TS, eigenes Modell) übersetzt. DOSSIER hat KEINEN Footer
|
||||||
|
> (delegiert an Rhinos eigene Leiste) — den Footer ergänzen wir neu (Vectorworks-Stil).
|
||||||
|
|
||||||
|
## Top-Bar — Gruppen (links → rechts)
|
||||||
|
|
||||||
|
1. **Marke/Logo** + Settings-Icons (Projekt-Einstellungen, App-Einstellungen).
|
||||||
|
2. **Ansicht** — Umschalter: Grundriss · Perspektive · Schnitt · Ansicht.
|
||||||
|
Später: 3D-Views Top/Iso + Himmelsrichtungen N/O/S/W (mit Nordwinkel-Rotation).
|
||||||
|
3. **Darstellung** — Render-/Anzeigemodus (Wireframe/Shaded/…) + **Detailgrad**
|
||||||
|
(grob/mittel/fein, ≙ DOSSIER „Darstellung" Einfach/Standard/Detail).
|
||||||
|
4. **Massstab & Zoom** — Live-Anzeige „1:N" + Dropdown (1:1,1:5,…,1:1000, frei) +
|
||||||
|
**Plan-Ansicht-Toggle** (Linienstärken für Druck) + Zoom-Buttons: 100% · Einpassen ·
|
||||||
|
Auswahl. Quelle: viewBox-Skala / `dpi = 96·devicePixelRatio` (siehe docs/design/plans-output.md).
|
||||||
|
5. **Overrides** (regelbasiert, Toggle + Preset) · **Masse** (Bemaßungs-Preset) — später.
|
||||||
|
6. **Anordnen (Z-Order)** — nach vorne/hinten (für 2D-Plangrafik) — später.
|
||||||
|
7. **Snapping** — Master-Osnap + Modi (End/Mitte/Schnittpunkt/Lot/Zentrum/Nah) +
|
||||||
|
**Raster** an/aus + **Referenzlinien** (Wandachsen) an/aus — wenn Zeichenwerkzeuge da sind.
|
||||||
|
8. **Text** — Stil/Font/Größe + B/I/U + Ausrichtung + „+" — mit den 2D-Werkzeugen.
|
||||||
|
|
||||||
|
### MVP jetzt (zu vorhandenem Modell)
|
||||||
|
- Ansichts-Umschalter (haben wir, ausbauen) · Detailgrad-Dropdown · **Massstab 1:N +
|
||||||
|
Zoom: Einpassen/Auswahl/100%** · Render-Modus · Referenzlinien-Toggle · Ressourcen.
|
||||||
|
|
||||||
|
## Footer / Status-Leiste (neu, unten, ~22 px)
|
||||||
|
|
||||||
|
`[Werkzeug-Hinweis] … [Cursor X/Y/Z] · [Einheit] · [Massstab 1:N] · [Zoom %] · [Geschoss] · [Ebene] · [Snap] … [Auswahl: n]`
|
||||||
|
|
||||||
|
- **Cursor X/Y/Z** — live aus der Plan-/3D-Position (Plan: aus viewBox-Inverse der Maus).
|
||||||
|
- **Einheit** — m (aus Projekt). **Massstab** 1:N + **Zoom %** — aus der View-Transform.
|
||||||
|
- **Aktives Geschoss** + **aktive Ebene** — aus Selection/State.
|
||||||
|
- **Snap-Status** — aktive Fänge (später). **Auswahl: n** — Anzahl selektierter Objekte.
|
||||||
|
- **Werkzeug-Hinweis** links — kontextueller Text des aktiven Werkzeugs.
|
||||||
|
|
||||||
|
### MVP jetzt
|
||||||
|
- Cursor X/Y (im Grundriss), Einheit, Massstab 1:N, Zoom %, aktives Geschoss + Ebene.
|
||||||
|
Snap/Werkzeug/Auswahl kommen mit den Zeichenwerkzeugen.
|
||||||
|
|
||||||
|
## Anbindung
|
||||||
|
Beide Leisten docken an das Panel-/Dock-Layout an (Top über den Docks, Footer darunter),
|
||||||
|
Inhalte rein aus dem Modell + View-State abgeleitet (keine Sonderzustände).
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
# Design — Prioritätsbasierte mehrschichtige Wand-Verschneidung (T/X)
|
||||||
|
|
||||||
|
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||||
|
> Aufbauend auf der bestehenden **L-Ecken-Gehrung** in `src/model/joins.ts`
|
||||||
|
> (`computeJoins`, `miterLine`) und `src/model/geometry.ts` (`clippedBand`,
|
||||||
|
> `lineIntersect`). Plan-Verbraucher: `src/plan/generatePlan.ts` (`addWallPoche`).
|
||||||
|
> Bezeichner **englisch**, Prosa deutsch, Einheiten **Meter**.
|
||||||
|
|
||||||
|
> **Hinweis Referenz:** Die im Auftrag genannte DOSSIER-Datei
|
||||||
|
> `rhino/wand_grips.py` ist in dieser Umgebung **nicht vorhanden** (das einzige
|
||||||
|
> auffindbare `dossier` ist ein Typst-Portfolio, kein Rhino-Plugin). Das Design
|
||||||
|
> stützt sich daher auf (a) den bestehenden Code, (b) die in CONVENTIONS.md/elements.md
|
||||||
|
> festgehaltenen Geometrie-Konventionen und (c) die etablierte Bau-Semantik der
|
||||||
|
> prioritätsbasierten Schichtverschneidung (Vectorworks „Komponenten-Verbindung",
|
||||||
|
> Revit „Layer Priority", ArchiCAD „Composite Priority"). Sobald `wand_grips.py`
|
||||||
|
> verfügbar ist, sollte Abschnitt 7 (Priorität/Backbone) gegen DOSSIERs konkrete
|
||||||
|
> Rangregel abgeglichen werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Problem & Leitbild
|
||||||
|
|
||||||
|
Heute (`computeJoins`) wird **nur die L-Ecke** behandelt: genau zwei Wandenden
|
||||||
|
treffen sich in einem Knoten, eine gemeinsame **Gehrungslinie** (`miterLine`)
|
||||||
|
schneidet beide Wände sauber. T-/X-Stöße (≥ 3 Enden) bleiben rechtwinklig
|
||||||
|
gekappt (`if (ends.length !== 2) continue;`) — die durchgehende Wand wird von der
|
||||||
|
ankommenden Wand nicht durchdrungen, und die Schichten überlappen sich oder
|
||||||
|
klaffen.
|
||||||
|
|
||||||
|
**Ziel:** An jedem Knoten — L (2 Enden), T (3), X/Kreuz (4+) — soll **jede
|
||||||
|
Schicht jeder Wand** genau bis zu der Fläche reichen, die ihre Verschneidungs-
|
||||||
|
Semantik vorgibt:
|
||||||
|
|
||||||
|
- Die **gemeinsame, höchstpriorisierte Schicht** (Backbone, z. B. Stahlbeton)
|
||||||
|
läuft **durch** den Stoß.
|
||||||
|
- **Niederpriorisierte Schichten** (z. B. Putz, Dämmung) **stoßen** an die nächst-
|
||||||
|
höhere Schicht des Nachbarn und **enden** dort.
|
||||||
|
- Putz **läuft auf jeder Seite** bis zur Betonfläche und endet dort; Putz
|
||||||
|
**überquert nie** den Beton (kanonisches Beispiel, siehe §7.1).
|
||||||
|
|
||||||
|
**Architektur-Prinzip (CONVENTIONS.md):** Das semantische Modell ist die einzige
|
||||||
|
Wahrheit. Die Verschneidung ist eine **reine Ableitung** (`Project + Wall[]` →
|
||||||
|
Trimm-Linien je Schicht-Band), nichts wird in die Geometrie eingebacken. Sie wird
|
||||||
|
beim Generieren des Plans angewandt und ist **deterministisch** und **idempotent**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Geometrie-Konventionen (Wiederholung, verbindlich)
|
||||||
|
|
||||||
|
Aus CONVENTIONS.md und `geometry.ts`:
|
||||||
|
|
||||||
|
- Achsrichtung `u = normalize(end - start)`.
|
||||||
|
- Wand-Normale `n = leftNormal(u) = (-u.y, u.x)`.
|
||||||
|
- Schichten werden **außen → innen** gestapelt: Offset entlang `n` läuft von
|
||||||
|
`-T/2` (linke/„außen"-Kante) nach `+T/2` (rechte/„innen"-Kante). Eine Schicht `k`
|
||||||
|
belegt das Intervall `[off_k, off_k + thickness_k]` mit `off_0 = -T/2`.
|
||||||
|
- Eine **unendliche Gerade** ist `Line { point, dir }`; Schnittpunkt zweier
|
||||||
|
Geraden via `lineIntersect(a, da, b, db)` (null bei parallel).
|
||||||
|
- Ein Schicht-**Band** zwischen Offsets `offA, offB` wird über `clippedBand(start,
|
||||||
|
end, offA, offB, startCut, endCut)` gebildet; jede Bandlängskante wird mit einer
|
||||||
|
optionalen Schnittlinie verschnitten statt rechtwinklig gekappt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Datenmodell — was schon da ist, was neu kommt
|
||||||
|
|
||||||
|
### 2.1 Vorhanden (`types.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Component { …; joinPriority: number; } // höher = läuft am Stoß durch
|
||||||
|
interface Layer { componentId: string; thickness: number; }
|
||||||
|
interface WallType { id; name; layers: Layer[]; } // layers: außen → innen
|
||||||
|
interface Wall { start: Vec2; end: Vec2; wallTypeId; height; … }
|
||||||
|
```
|
||||||
|
|
||||||
|
`joinPriority` ist bereits **pro Bauteil (Component)** definiert — genau richtig:
|
||||||
|
Priorität ist eine **Material-Eigenschaft**, nicht eine Schicht-Eigenschaft. Damit
|
||||||
|
verschneidet sich Beton mit Beton unabhängig vom Wandtyp.
|
||||||
|
|
||||||
|
### 2.2 Neue, abgeleitete Strukturen (keine neuen persistenten Felder)
|
||||||
|
|
||||||
|
Die heutige `WallCuts`-Struktur (eine Schnittlinie je **Wandende**) reicht für L
|
||||||
|
(Gehrung) — aber **nicht** für T/X, weil dort verschiedene Schichten **verschiedene**
|
||||||
|
Trimm-Linien brauchen (Beton läuft durch, Putz stoppt früher). Wir erweitern auf
|
||||||
|
**pro Schicht, pro Ende** eine eigene Trimm-Linie:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/model/joins.ts (erweitert)
|
||||||
|
|
||||||
|
/** Eine gerichtete Halbkante eines Wandendes an einem Knoten. */
|
||||||
|
interface WallEnd {
|
||||||
|
wallId: string;
|
||||||
|
end: "start" | "end";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trimm-Linie + Klassifikation für GENAU EIN Schicht-Band an EINEM Ende. */
|
||||||
|
interface LayerCut {
|
||||||
|
/** Schnittgerade in Weltkoordinaten; null = rechtwinklig kappen (Default). */
|
||||||
|
line: Line | null;
|
||||||
|
/**
|
||||||
|
* "miter" – Gehrung (L): geteilte Diagonale mit dem Nachbarn.
|
||||||
|
* "butt" – Band stößt stumpf an eine Nachbarfläche (niedrigere Priorität).
|
||||||
|
* "through" – Band läuft durch den Knoten (höchste Priorität / Backbone).
|
||||||
|
* "square" – freies Ende, rechtwinklig (line === null).
|
||||||
|
*/
|
||||||
|
kind: "miter" | "butt" | "through" | "square";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pro Wandende: eine Trimm-Linie je Schicht-Index (parallel zu WallType.layers). */
|
||||||
|
interface EndCuts {
|
||||||
|
/** layerCuts[k] gilt für layers[k]; Länge === wt.layers.length. */
|
||||||
|
layerCuts: LayerCut[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ersetzt die alte WallCuts: jetzt schichtweise an beiden Enden. */
|
||||||
|
interface WallJoin {
|
||||||
|
start: EndCuts;
|
||||||
|
end: EndCuts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JoinMap = Map<string /*wallId*/, WallJoin>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Abwärtskompatibilität:** Die alte L-Gehrung ist der Spezialfall „alle
|
||||||
|
`layerCuts[k].line` an einem Ende sind dieselbe `miter`-Linie". `clippedBand`
|
||||||
|
bleibt unverändert; der Plan-Generator ruft es jetzt **je Schicht mit der
|
||||||
|
schichtspezifischen Linie** auf (siehe §8).
|
||||||
|
|
||||||
|
### 2.3 Knoten-Repräsentation
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Ein Verschneidungsknoten: alle Wandenden, die sich (gerundet) berühren. */
|
||||||
|
interface Junction {
|
||||||
|
key: string; // roundKey(p)
|
||||||
|
p: Vec2; // Knotenposition (Mittel der Enden)
|
||||||
|
arms: Arm[]; // sortiert nach Außenwinkel (CCW)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein „Arm" = ein Wandende, gesehen als Strahl, der vom Knoten WEGzeigt. */
|
||||||
|
interface Arm {
|
||||||
|
we: WallEnd;
|
||||||
|
wall: Wall;
|
||||||
|
/** Richtung VOM Knoten weg in die Wand hinein (immer normiert). */
|
||||||
|
dirOut: Vec2; // = end==="start" ? +u : -u
|
||||||
|
/** Außenwinkel atan2(dirOut.y, dirOut.x) für Sortierung. */
|
||||||
|
angle: number;
|
||||||
|
total: number; // Gesamtdicke der Wand
|
||||||
|
/** Schicht-Profil, vom Knoten aus gesehen (siehe §3.2). */
|
||||||
|
layers: ArmLayer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Schicht eines Arms, mit ihren beiden Längs-Flächengeraden am Knoten. */
|
||||||
|
interface ArmLayer {
|
||||||
|
index: number; // Index in wt.layers
|
||||||
|
componentId: string;
|
||||||
|
priority: number; // Component.joinPriority
|
||||||
|
/** Offsets entlang n der Wand: [innerEdge..outerEdge] des Bandes. */
|
||||||
|
off0: number; off1: number;
|
||||||
|
/** Die zwei Längsflächen als Geraden (point am Knoten, dir = u der Wand). */
|
||||||
|
faceA: Line; faceB: Line;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Knotenerkennung (Junction Detection)
|
||||||
|
|
||||||
|
### 3.1 Knoten clustern
|
||||||
|
|
||||||
|
Identisch zur heutigen Logik, nur ohne die `length !== 2`-Abbruchbedingung:
|
||||||
|
|
||||||
|
```
|
||||||
|
function buildJunctions(walls): Junction[]
|
||||||
|
map = Map<key, WallEnd[]>
|
||||||
|
for w in walls:
|
||||||
|
map.push(roundKey(w.start), {wallId:w.id, end:"start"})
|
||||||
|
map.push(roundKey(w.end), {wallId:w.id, end:"end"})
|
||||||
|
out = []
|
||||||
|
for (key, ends) in map:
|
||||||
|
if ends.length < 2: continue // freies Ende → alle Schichten "square"
|
||||||
|
arms = ends.map(buildArm)
|
||||||
|
arms.sort(by angle) // CCW um den Knoten
|
||||||
|
out.push({ key, p: avgEndPoint(ends), arms })
|
||||||
|
return out
|
||||||
|
```
|
||||||
|
|
||||||
|
`roundKey` (vorhanden) gruppiert Endpunkte auf ein 0.1-mm-Gitter. **Wichtig für
|
||||||
|
T-Stöße:** Bei einem echten T endet die ankommende Wand **auf der Achse** der
|
||||||
|
durchgehenden Wand, nicht an deren Endpunkt. Solche Knoten werden über die
|
||||||
|
Endpunkt-Gruppierung **nicht** gefunden, wenn die durchgehende Wand dort kein Ende
|
||||||
|
hat. Daher zusätzlich (siehe §3.3) eine **Achs-Auf-Achs-Inzidenz**.
|
||||||
|
|
||||||
|
### 3.2 Arm-Schichtprofil (kanonische Orientierung)
|
||||||
|
|
||||||
|
Damit Schichten zweier Arme vergleichbar sind, muss jeder Arm seine Schichten in
|
||||||
|
**konsistenter Welt-Orientierung** kennen. Wir speichern je Schicht ihre beiden
|
||||||
|
**Längsflächen** als Geraden mit Stützpunkt am Knoten und Richtung `u`:
|
||||||
|
|
||||||
|
```
|
||||||
|
function buildArm(we): Arm
|
||||||
|
wall = byId(we.wallId); u = dirOf(wall); n = leftNormal(u)
|
||||||
|
dirOut = we.end==="start" ? u : negate(u) // vom Knoten in die Wand
|
||||||
|
j = we.end==="start" ? wall.start : wall.end
|
||||||
|
total = wallTypeThickness(wt)
|
||||||
|
off = -total/2
|
||||||
|
layers = []
|
||||||
|
for (k, layer) in wt.layers:
|
||||||
|
off0 = off; off1 = off + layer.thickness
|
||||||
|
faceA = { point: j + n*off0, dir: u }
|
||||||
|
faceB = { point: j + n*off1, dir: u }
|
||||||
|
layers.push({ index:k, componentId, priority, off0, off1, faceA, faceB })
|
||||||
|
off = off1
|
||||||
|
return { we, wall, dirOut, angle: atan2(dirOut), total, layers }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 T-Stoß-Erkennung (Achs-auf-Achs)
|
||||||
|
|
||||||
|
Zusätzlich zu Endpunkt-Clustern: ein Wandende `e` (Punkt `P`) bildet einen
|
||||||
|
**T-Stoß** mit Wand `B`, wenn `P` (innerhalb Toleranz) auf der **Strecke** `B.start
|
||||||
|
→ B.end` liegt, aber **nicht** auf deren Endpunkten:
|
||||||
|
|
||||||
|
```
|
||||||
|
function findTeeIncidences(walls):
|
||||||
|
for endpoint P of each wall A (as WallEnd e):
|
||||||
|
for each wall B != A:
|
||||||
|
if pointOnSegment(P, B.start, B.end, tol) and not nearEndpoint(P, B):
|
||||||
|
// virtueller Knoten: A endet, B läuft durch.
|
||||||
|
registerTee(P, armOf(e), passThroughWall=B)
|
||||||
|
```
|
||||||
|
|
||||||
|
`B` wird hier als **durchgehender Strang** behandelt (kein Ende am Knoten); im
|
||||||
|
Junction-Modell taucht `B` als zwei kollineare „Arme" auf (Richtung `+u` und
|
||||||
|
`−u`), die der Prioritätsalgorithmus (§5) automatisch als „durchlaufend" erkennt
|
||||||
|
(zwei kollineare Arme gleicher Wand → ihre Schichten enden nie gegeneinander).
|
||||||
|
|
||||||
|
**MVP-Vereinfachung (Phase 1, §10):** T-Stöße zunächst NUR über koinzidente
|
||||||
|
Endpunkte (B hat dort tatsächlich einen Eckpunkt, z. B. weil die Wand dort geteilt
|
||||||
|
wurde). Echte Achs-auf-Achs-T-Stöße (B durchgehend) folgen in Phase 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Winkel-Sektoren & Nachbar-Flächen
|
||||||
|
|
||||||
|
Für die Prioritätsauflösung muss man wissen, **welche Fläche eines Arms welcher
|
||||||
|
Fläche des Nachbarn gegenübersteht**. Die Arme sind CCW nach `angle` sortiert.
|
||||||
|
Zwischen zwei aufeinanderfolgenden Armen `arms[i]` und `arms[i+1]` (zyklisch) liegt
|
||||||
|
ein **Sektor** (Keil). Jeder Sektor wird von **einer Längsfläche jedes der beiden
|
||||||
|
Arme** begrenzt:
|
||||||
|
|
||||||
|
```
|
||||||
|
arm[i+1]
|
||||||
|
\ Sektor S_i
|
||||||
|
\ /
|
||||||
|
faceR(i+1)\ ___ faceL(i)
|
||||||
|
X (Knoten)
|
||||||
|
/
|
||||||
|
/
|
||||||
|
arm[i]
|
||||||
|
```
|
||||||
|
|
||||||
|
Konvention: pro Arm hat die **äußere** Schicht (Index 0, Offset `-T/2`) die Fläche,
|
||||||
|
die in den **CCW-vorausgehenden** Sektor zeigt; die **innere** Schicht den
|
||||||
|
**nachfolgenden**. Konkret bestimmen wir die „dem Sektor zugewandte" Fläche jeder
|
||||||
|
Schicht über das Vorzeichen von `cross(dirOut, sektorrichtung)` — analog zur
|
||||||
|
`lbCloser`-Heuristik in `miterLine`, aber pro Sektor statt global.
|
||||||
|
|
||||||
|
```
|
||||||
|
function sectorFaces(armLeft, armRight):
|
||||||
|
// armRight ist CCW vor armLeft (Sektor liegt zwischen ihnen).
|
||||||
|
// Wähle für jeden Arm die Schicht-Flächen, die in den Sektor zeigen.
|
||||||
|
faceOf(arm, towards): pick faceA or faceB of each layer by sign of
|
||||||
|
cross(arm.dirOut, towards - knoten)
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Sektor-Sicht verallgemeinert die bestehende `miterLine`: bei genau zwei
|
||||||
|
Armen (L) gibt es zwei Sektoren (innen/außen), und die Mittel-Gehrungslinie ergibt
|
||||||
|
sich wie bisher aus dem Schnitt der gegenüberliegenden Außen- bzw. Innenflächen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Prioritätsauflösung — das Kernstück
|
||||||
|
|
||||||
|
### 5.1 Idee
|
||||||
|
|
||||||
|
An einem Knoten konkurrieren Schichten verschiedener Arme um denselben Raum. Regel:
|
||||||
|
|
||||||
|
> Eine Schicht **läuft durch** (`through`), wenn sie zur **höchsten am Knoten
|
||||||
|
> präsenten Priorität** gehört **und** auf der „gegenüberliegenden" Seite eine
|
||||||
|
> Schicht **gleichen Materials** (oder ≥ gleicher Priorität) existiert, an die sie
|
||||||
|
> nahtlos anschließt. Andernfalls **stößt** sie (`butt`) an die nächsthöher-
|
||||||
|
> priorisierte Nachbarfläche und endet dort.
|
||||||
|
|
||||||
|
Praktisch lösen wir das **fläche-gegen-fläche** je Schicht: Für jede Schicht `L`
|
||||||
|
eines Arms suchen wir die **Trimm-Fläche**, die ihr Band beendet. Das ist die
|
||||||
|
**erste** (vom Knoten aus, entlang `dirOut`) der gegenüberliegenden Flächen mit
|
||||||
|
**echt höherer Priorität**; existiert keine, läuft die Schicht bis zur
|
||||||
|
Knoten-Mittelachse durch.
|
||||||
|
|
||||||
|
### 5.2 Pro Schicht: Trimm-Fläche finden
|
||||||
|
|
||||||
|
```
|
||||||
|
function resolveLayerCut(arm, layer, junction): LayerCut
|
||||||
|
// Kandidaten: alle Schichten ALLER ANDEREN Arme, deren Band den
|
||||||
|
// Halbraum dieses Layers überlappt (Offset-Überlapp im gemeinsamen
|
||||||
|
// Sektor) UND deren Priorität die Trimm-Entscheidung bestimmt.
|
||||||
|
candidates = []
|
||||||
|
for other in junction.arms where other.wall.id-end != arm.id-end:
|
||||||
|
for ol in other.layers:
|
||||||
|
if overlapsInSector(layer, ol, arm, other):
|
||||||
|
candidates.push({ other, ol, face: facingFace(ol, towards arm) })
|
||||||
|
|
||||||
|
// Höchste am Knoten präsente Priorität (global, für "through").
|
||||||
|
maxPrio = max over all candidate.ol.priority and layer.priority
|
||||||
|
|
||||||
|
if layer.priority == maxPrio and hasCollinearSamePrio(arm, layer, junction):
|
||||||
|
// Backbone: läuft durch bis zur Mittel-/Gehrungslinie.
|
||||||
|
return { line: miterMidline(arm, layer, junction), kind: "through" }
|
||||||
|
|
||||||
|
// Sonst: stoße an die NÄCHSTE Fläche höherer Priorität entlang dirOut.
|
||||||
|
blockers = candidates.filter(c => c.ol.priority > layer.priority)
|
||||||
|
if blockers.empty:
|
||||||
|
// niemand höher → Gehrung mit dem Nachbarn gleicher Stufe (L-Fall)
|
||||||
|
return { line: miterMidline(arm, layer, junction), kind: "miter" }
|
||||||
|
|
||||||
|
nearest = argmin over blockers of distanceAlong(dirOut, face)
|
||||||
|
return { line: nearest.face, kind: "butt" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Hilfsbegriffe:
|
||||||
|
|
||||||
|
- `overlapsInSector(layer, ol, …)` — projiziert beide Bänder auf die **Normale des
|
||||||
|
Sektors** und prüft Intervall-Überlapp `[off0,off1] ∩ [off0',off1'] ≠ ∅`. Nur
|
||||||
|
überlappende Bänder können sich gegenseitig trimmen.
|
||||||
|
- `facingFace(ol, towards arm)` — die der `arm`-Seite zugewandte Längsfläche von
|
||||||
|
`ol` (die `faceA`/`faceB` von §3.2), als `Line`.
|
||||||
|
- `distanceAlong(dirOut, face)` — Abstand des Schnittpunkts `faceLine ∩ layerAxis`
|
||||||
|
vom Knoten, gemessen entlang `dirOut`. Negative bzw. hinter dem Knoten liegende
|
||||||
|
Treffer werden verworfen (eine Trimm-Fläche muss **vor** dem Band liegen).
|
||||||
|
- `miterMidline(...)` — die gemeinsame Diagonale für gleichrangige Begegnung; für
|
||||||
|
zwei Arme exakt die heutige `miterLine`.
|
||||||
|
- `hasCollinearSamePrio(...)` — true, wenn auf der „anderen Seite" des Knotens eine
|
||||||
|
Schicht **gleichen Materials/Priorität** existiert, in die das Band nahtlos
|
||||||
|
übergeht (Backbone-Kontinuität, §7).
|
||||||
|
|
||||||
|
### 5.3 Resultat in `LayerCut.line` schreiben
|
||||||
|
|
||||||
|
`resolveLayerCut` liefert für `layers[k]` eine `Line | null`. Diese wird in
|
||||||
|
`WallJoin[end].layerCuts[k]` abgelegt. `clippedBand` kappt damit **dieses eine
|
||||||
|
Band** an genau dieser Linie — alle anderen Schichten derselben Wand können andere
|
||||||
|
Linien (oder `null`) haben. Das ist die ganze Verkabelung zum Renderer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Master-Pseudocode `computeJoins` (neu)
|
||||||
|
|
||||||
|
```
|
||||||
|
function computeJoins(project, walls): JoinMap
|
||||||
|
result = init each wall → { start:{layerCuts:[square…]}, end:{layerCuts:[square…]} }
|
||||||
|
junctions = buildJunctions(walls) // §3.1
|
||||||
|
// (Phase 3: junctions += findTeeIncidences(walls)) // §3.3
|
||||||
|
|
||||||
|
for J in junctions:
|
||||||
|
if J.arms.length == 1: continue // freies Ende: alles "square"
|
||||||
|
if J.arms.length == 2:
|
||||||
|
resolveL(J, project, result) // bestehende Gehrung, schichtweise
|
||||||
|
else:
|
||||||
|
resolveTorX(J, project, result) // §5, pro Arm pro Schicht
|
||||||
|
return result
|
||||||
|
|
||||||
|
function resolveTorX(J, project, result):
|
||||||
|
for arm in J.arms:
|
||||||
|
cuts = []
|
||||||
|
for layer in arm.layers:
|
||||||
|
cuts[layer.index] = resolveLayerCut(arm, layer, J) // §5.2
|
||||||
|
writeEnd(result, arm.we, cuts) // start oder end
|
||||||
|
|
||||||
|
function resolveL(J, project, result):
|
||||||
|
[a, b] = J.arms
|
||||||
|
// Wie heute: eine gemeinsame Gehrungslinie pro Sektor; aber wenn die
|
||||||
|
// Dicken/Schichten ungleich sind, kann pro Schicht über §5.2 verfeinert werden.
|
||||||
|
// MVP: eine miterLine für alle Schichten beider Arme (= heutiges Verhalten,
|
||||||
|
// schichtweise dupliziert).
|
||||||
|
line = miterLine(project, a.wall, a.we.end, b.wall)
|
||||||
|
for arm in [a,b]:
|
||||||
|
cuts = arm.layers.map(_ => ({ line, kind:"miter" }))
|
||||||
|
writeEnd(result, arm.we, cuts)
|
||||||
|
```
|
||||||
|
|
||||||
|
So bleibt die **L-Ecke bit-genau wie heute** (Regressionssicherheit), und die
|
||||||
|
neue Logik greift nur bei ≥ 3 Armen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Prioritäts-Semantik im Detail (das Putz/Beton-Beispiel)
|
||||||
|
|
||||||
|
### 7.1 Kanonisches Beispiel
|
||||||
|
|
||||||
|
Wandtyp „Aussenwand" (außen → innen):
|
||||||
|
|
||||||
|
| k | Component | thickness | joinPriority |
|
||||||
|
|---|------------|-----------|--------------|
|
||||||
|
| 0 | Putz aussen| 0.02 | 1 |
|
||||||
|
| 1 | Stahlbeton | 0.18 | 9 |
|
||||||
|
| 2 | Putz innen | 0.015 | 1 |
|
||||||
|
|
||||||
|
Drei solche Wände treffen in einem T-Knoten (zwei kollinear durchgehend „BB",
|
||||||
|
eine ankommend „A"):
|
||||||
|
|
||||||
|
1. **Beton (prio 9)** der durchgehenden Wände `BB` läuft durch — beide
|
||||||
|
Beton-Bänder sind kollinear gleicher Priorität → `hasCollinearSamePrio` =
|
||||||
|
true → `through`. Der Beton von `A` (prio 9) **stößt** an die **Betonfläche**
|
||||||
|
von `BB` (gegenüberliegende Fläche, gleiche höchste Priorität, aber kein
|
||||||
|
kollinearer Partner für `A`) → `butt` an Betonaußenfläche von `BB`.
|
||||||
|
2. **Putz (prio 1)** jeder Wand stößt an die **erste höherpriorisierte Fläche**
|
||||||
|
entlang `dirOut`. Für die Putzschichten von `A` ist das die **Betonfläche von
|
||||||
|
`BB`**: Putz **läuft auf jeder Seite bis zum Beton** und endet dort (`butt`).
|
||||||
|
3. Putz **überquert nie** den Beton, weil eine `butt`-Trimm-Linie immer die
|
||||||
|
**nächste** höhere Fläche ist — sie liegt vor dem Beton-Durchlauf.
|
||||||
|
|
||||||
|
Ergebnis exakt wie gefordert: *Beton durch, Putz schließt beidseitig an, Putz
|
||||||
|
kreuzt Beton nicht.*
|
||||||
|
|
||||||
|
### 7.2 Warum Priorität pro Component (nicht pro Layer)
|
||||||
|
|
||||||
|
Beton trifft Beton → gleiche Priorität → nahtlos. Würde Priorität pro Schicht
|
||||||
|
vergeben, müsste man sie pro Wandtyp neu pflegen. `joinPriority` am Component löst
|
||||||
|
das materialweise — Stahlbeton hat **immer** 9, egal in welchem Wandtyp.
|
||||||
|
|
||||||
|
### 7.3 Backbone-Erkennung für „through"
|
||||||
|
|
||||||
|
`hasCollinearSamePrio(arm, layer, junction)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
for other in junction.arms where other != arm:
|
||||||
|
if collinear(arm.dirOut, other.dirOut) (antiparallel, gleiche Achse) and
|
||||||
|
other has a layer ol with ol.priority == layer.priority and
|
||||||
|
bandsAlign(layer, ol): // gleiche Offsets relativ zur gemeinsamen Achse
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
```
|
||||||
|
|
||||||
|
Nur wenn das Band auf der **gegenüberliegenden** Achse einen passenden Partner
|
||||||
|
gleicher Priorität und Lage hat, läuft es wirklich **durch**. Sonst (z. B. der
|
||||||
|
ankommende Beton von `A`) **stößt** es an — exakt das gewünschte Verhalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Layer-Wrapping (Umschlagen niederpriorisierter Schichten)
|
||||||
|
|
||||||
|
Ein subtiler, aber wichtiger Fall: An einem T-Stoß endet die **innere** Putzschicht
|
||||||
|
der ankommenden Wand `A` an der Betonfläche von `BB`. Damit der Putz **um die Ecke
|
||||||
|
herum sichtbar bleibt** (auf der Innenseite der durchgehenden Wand zieht der innere
|
||||||
|
Putz von `BB` durch), ist nichts Zusätzliches nötig — `BB`s innerer Putz läuft als
|
||||||
|
eigenes durchgehendes Band weiter.
|
||||||
|
|
||||||
|
Wo Wrapping aktiv nötig ist: wenn eine **niederpriorisierte Außenschicht** an einer
|
||||||
|
Ecke **um eine höhere Schicht herumgeführt** werden soll (z. B. Dämmung, die außen
|
||||||
|
um die Stütze läuft). Modellierung:
|
||||||
|
|
||||||
|
- Standard (MVP): kein Wrapping — jede Schicht endet an ihrer Trimm-Fläche
|
||||||
|
(`butt`). Das deckt T/X korrekt ab.
|
||||||
|
- Optional (Phase 4): Ein `wrap`-Flag pro Component (`Component.wrapAtEnds?:
|
||||||
|
boolean`). Ist es gesetzt, erzeugt der Generator an der Trimm-Fläche ein
|
||||||
|
**zusätzliches kurzes Stirnband** quer (Offset-Intervall der Schicht, Länge =
|
||||||
|
Tiefe bis zur nächsten Fläche), sodass die Schicht ihre eigene Stirnseite
|
||||||
|
„umschließt". Geometrisch ein weiteres `clippedBand` mit getauschten Achsen.
|
||||||
|
|
||||||
|
Wrapping ist bewusst **nachgelagert**; es ändert die Trimm-Logik (§5) nicht,
|
||||||
|
sondern fügt nur Zusatzpolygone hinzu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Anbindung an den Plan-Generator (`generatePlan.addWallPoche`)
|
||||||
|
|
||||||
|
Heute ruft `addWallPoche` `clippedBand(p1, p2, off, off+thickness, startCut,
|
||||||
|
endCut)` mit **einer** `WallCuts` je Ende. Neu:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// joins: JoinMap (neu)
|
||||||
|
const join = joins.get(wall.id) ?? emptyJoin(wt.layers.length);
|
||||||
|
…
|
||||||
|
let off = -total / 2;
|
||||||
|
wt.layers.forEach((layer, k) => {
|
||||||
|
const startCut = (s <= 1e-6) ? join.start.layerCuts[k].line : null;
|
||||||
|
const endCut = (e >= axisLen - 1e-6) ? join.end.layerCuts[k].line : null;
|
||||||
|
out.push({ kind:"polygon",
|
||||||
|
pts: clippedBand(p1, p2, off, off + layer.thickness, startCut, endCut),
|
||||||
|
fill: comp.color, hatch: resolveHatch(...), … });
|
||||||
|
off += layer.thickness;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Die **Umrisslinie** über die volle Dicke (`-T/2..+T/2`) nutzt für ihre beiden
|
||||||
|
Kanten die Cuts der **äußersten** bzw. **innersten** Schicht — oder wird, falls
|
||||||
|
Schichten unterschiedlich getrimmt sind, durch eine **abgeleitete Outline**
|
||||||
|
(Vereinigung der Schicht-Polygone, §11) ersetzt. MVP: weiterhin volle-Dicke-Band
|
||||||
|
mit den Cuts von Schicht 0 / letzter Schicht.
|
||||||
|
- `grob`-Detailgrad nutzt nur die **Backbone-Schicht** → ihr `through`/`miter`-Cut
|
||||||
|
für die ganze Sammelfläche (entspricht der bestehenden `backboneColor`-Logik).
|
||||||
|
|
||||||
|
**Keine Signaturänderung an `clippedBand`** — es bleibt der zentrale Trimmer; wir
|
||||||
|
füttern es nur schichtweise mit verschiedenen Linien.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 2D-analytisch jetzt vs. exakte 3D-Booleans später (OCCT)
|
||||||
|
|
||||||
|
### 10.1 Jetzt — 2D-analytisch (dieses Design)
|
||||||
|
|
||||||
|
- **Plan/Grundriss** ist eine reine 2D-Ableitung (vgl. `generatePlan.ts`-Kopf:
|
||||||
|
„nicht durch Zerschneiden eines 3D-Meshes, sondern direkt aus den Parametern").
|
||||||
|
- Trimmen = **Geraden-Schnitt** (`lineIntersect`) + Polygon-Kappen (`clippedBand`).
|
||||||
|
Kein Polygon-Boolean nötig, solange Schichten als **konvexe Bänder** mit
|
||||||
|
schrägen Stirnflächen modelliert werden. Das ist exakt, schnell (O(Arme² ·
|
||||||
|
Schichten) je Knoten), und deterministisch.
|
||||||
|
- **3D-Wand** im Viewport: Extrusion der getrimmten 2D-Schichtpolygone in `z`
|
||||||
|
(Höhe). Damit stimmen Plan und 3D ohne separaten Kernel überein.
|
||||||
|
|
||||||
|
Grenzen der 2D-Analytik: nicht-konvexe Stirnprofile, echte Materialdurchdringung in
|
||||||
|
`z` (z. B. wenn Wände unterschiedlicher Höhe / Sturz / Brüstung interagieren),
|
||||||
|
Verschneidung Wand × Decke × Stütze. Das braucht echte Volumen-Booleans.
|
||||||
|
|
||||||
|
### 10.2 Später — exakte 3D-Booleans (OCCT / opencascade.js)
|
||||||
|
|
||||||
|
- **Bibliothek:** `opencascade.js` (WASM-Port von OCCT) — `BRepAlgoAPI_Cut/Common`,
|
||||||
|
`BRepPrimAPI_MakePrism` für Extrusionen. Alternativ `manifold-3d` (schneller,
|
||||||
|
robuster für reine Mesh-Booleans, aber ohne B-Rep/Fillets).
|
||||||
|
- **Modell bleibt gleich:** Priorität (`joinPriority`) bestimmt die **Cut-
|
||||||
|
Reihenfolge**. Algorithmus: baue je Schicht einen über-langen Solid (Band ×
|
||||||
|
Höhe, über den Knoten hinaus verlängert); subtrahiere von jeder niederpriori-
|
||||||
|
sierten Schicht die Solids **aller höherpriorisierten** Schichten am Knoten
|
||||||
|
(`result = layerSolid − ⋃ higherPrioritySolids`). Gleiche Priorität: kein Cut
|
||||||
|
(Beton bleibt an Beton). Das ist die **3D-Verallgemeinerung exakt derselben
|
||||||
|
Prioritätsregel** — die 2D-`butt`/`through`-Klassifikation aus §5 ist die
|
||||||
|
2D-Projektion dieser Boolean-Reihenfolge.
|
||||||
|
- Die Datenstrukturen (§2) bleiben unverändert; nur der **Trimmer** wird
|
||||||
|
ausgetauscht (`clippedBand` → OCCT-Cut). Deshalb ist das 2D-Design bereits
|
||||||
|
„OCCT-ready".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Robuste Outline (optional, Phase 4)
|
||||||
|
|
||||||
|
Wenn Schichten an einem Knoten unterschiedlich getrimmt sind, ist die „volle
|
||||||
|
Dicke"-Umrisslinie nicht mehr ein einfaches Band. Saubere Lösung: die
|
||||||
|
**Wand-Outline** als **Vereinigung aller Schicht-Polygone** berechnen
|
||||||
|
(Polygon-Boolean in 2D). Empfohlene Library: **`polygon-clipping`** (Martinez-
|
||||||
|
Rueda, robust, klein) oder **`@flatten-js/boolean-op`**. Damit wird der Umriss
|
||||||
|
exakt die Außenkontur der getrimmten Bänder — auch bei X-Knoten. MVP verzichtet
|
||||||
|
darauf und nutzt die Schicht-0/N-Cuts (visuell für die meisten Fälle ausreichend).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Edge Cases
|
||||||
|
|
||||||
|
1. **Gleiche Priorität, nicht kollinear** (z. B. zwei Betonwände treffen im rechten
|
||||||
|
Winkel im T): kein „through" (kein kollinearer Partner), aber auch kein
|
||||||
|
`butt`-Blocker höherer Priorität → Fallback **`miter`** (gemeinsame Gehrung wie
|
||||||
|
im L-Fall). Bei drei gleichrangigen Armen: paarweise Gehrung pro Sektor; die
|
||||||
|
resultierenden Stirnflächen sind die Sektor-Halbierenden.
|
||||||
|
2. **Kollinear (180°)** — zwei Wände in einer Linie: `lineIntersect` liefert null
|
||||||
|
(parallel). Behandlung wie heute: **kein Schnitt**, Bänder laufen gerade durch
|
||||||
|
(bei gleichem Typ nahtlos). Bei ungleichem Typ: die schmalere Wand stößt an die
|
||||||
|
breitere; pro Schicht über §5 auflösbar.
|
||||||
|
3. **> 2 Schichten / asymmetrische Wandtypen**: Der Algorithmus ist in der Schicht-
|
||||||
|
anzahl generisch (`resolveLayerCut` läuft je Schicht-Index). Treffen Wände
|
||||||
|
verschiedener Typen (verschiedene Schichtzahl/-dicke), entscheidet **nur die
|
||||||
|
Priorität pro Fläche**, nicht der Index — deshalb arbeiten wir mit
|
||||||
|
`ArmLayer.faceA/faceB` (Welt-Flächen), nicht mit Schicht-Indizes über Wände
|
||||||
|
hinweg.
|
||||||
|
4. **Backbone fehlt** (alle Prioritäten gleich): degeneriert sauber zu reinen
|
||||||
|
Gehrungen (Fall 1).
|
||||||
|
5. **Sehr spitze Winkel**: Trimm-Schnittpunkte können weit vom Knoten wegwandern.
|
||||||
|
Begrenzung: `distanceAlong` auf `≤ maxReach` (z. B. `3 · total`) clampen; sonst
|
||||||
|
rechtwinklig kappen (`square`), um Artefakte zu vermeiden.
|
||||||
|
6. **Öffnungen am Wandende** (`addWallPoche`-Segmentierung): Cuts gelten nur für das
|
||||||
|
echte Wandende (`s≤ε` / `e≥len−ε`), wie heute. Türnahe Segmentenden bleiben
|
||||||
|
`square`.
|
||||||
|
7. **Numerische Knoten-Toleranz**: `roundKey`-Gitter (0.1 mm) und ε in
|
||||||
|
`pointOnSegment` müssen konsistent sein, sonst „flackernde" Knoten. Toleranz
|
||||||
|
zentral als Konstante (`JOIN_EPS = 1e-4` m).
|
||||||
|
8. **Mehr als 2 Wände gleicher Achse** (degenerierter X, alle kollinear): als ein
|
||||||
|
durchgehender Strang behandeln; Prioritätsregel pro Schicht greift normal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Staged Build-Plan
|
||||||
|
|
||||||
|
**Phase 1 — Single-Layer T (Fundament).**
|
||||||
|
- `JoinMap`/`WallJoin`/`EndCuts`/`LayerCut` einführen; `WallCuts` darin als
|
||||||
|
Spezialfall abbilden. `clippedBand` unverändert.
|
||||||
|
- `buildJunctions` ohne `length!==2`-Abbruch; L-Fall ruft bestehende `miterLine`
|
||||||
|
(schichtweise dupliziert) → **Regressionsgleichheit** zu heute.
|
||||||
|
- T-Knoten **nur über koinzidente Endpunkte** (§3.3 MVP). Für **einschichtige**
|
||||||
|
Wände: die durchgehende Wand läuft durch, die ankommende stößt rechtwinklig an
|
||||||
|
deren nächste Fläche. Verifizieren: `npx tsc -b`, `npm run build`,
|
||||||
|
`node scripts/probe.mjs`, Screenshot prüfen.
|
||||||
|
|
||||||
|
**Phase 2 — Prioritäts-Auflösung mehrschichtig (T).**
|
||||||
|
- `ArmLayer`-Profil (§3.2), Sektor-Flächen (§4), `resolveLayerCut` (§5) inkl.
|
||||||
|
`through`/`butt`/`miter`-Klassifikation und `hasCollinearSamePrio`.
|
||||||
|
- `addWallPoche` schichtweise verkabeln (§9). Putz/Beton-Testszene (§7.1) anlegen
|
||||||
|
und visuell prüfen.
|
||||||
|
|
||||||
|
**Phase 3 — X-Knoten & echte Achs-T-Stöße.**
|
||||||
|
- `findTeeIncidences` (Achs-auf-Achs, §3.3): durchgehende Wand wird nicht geteilt.
|
||||||
|
- 4+-Arm-Sektorlogik vollständig; Edge Cases 1/5/8 absichern.
|
||||||
|
|
||||||
|
**Phase 4 — Politur.**
|
||||||
|
- Robuste Outline via Polygon-Boolean (§11); optionales Layer-Wrapping (§8,
|
||||||
|
`Component.wrapAtEnds`).
|
||||||
|
|
||||||
|
**Phase 5 — Exakte 3D-Booleans (OCCT).**
|
||||||
|
- `opencascade.js` integrieren; Trimmer-Interface so abstrahieren, dass 2D
|
||||||
|
(`clippedBand`) und 3D (OCCT-Cut) dieselbe Prioritäts-Reihenfolge nutzen (§10.2).
|
||||||
|
Plan bleibt 2D-analytisch; nur das 3D-Volumen nutzt Booleans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Trimmer-Abstraktion (für §10.2-Migration)
|
||||||
|
|
||||||
|
Damit Phase 5 nicht den Aufrufer ändert, kapseln wir das Trimmen hinter einer
|
||||||
|
Schnittstelle. 2D nutzt `clippedBand`; 3D nutzt OCCT — beide konsumieren dieselbe
|
||||||
|
`JoinMap`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface LayerTrimmer {
|
||||||
|
/** 2D: getrimmtes Bandpolygon. 3D-Variante liefert stattdessen einen Solid. */
|
||||||
|
trimBand(p1: Vec2, p2: Vec2, offA: number, offB: number,
|
||||||
|
startCut: Line | null, endCut: Line | null): Vec2[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Prioritätslogik (`computeJoins`) bleibt der **gemeinsame, kernel-unabhängige**
|
||||||
|
Kopf; nur der `LayerTrimmer` wird ausgetauscht. Das hält das Design dem
|
||||||
|
Architektur-Prinzip treu: ein semantisches Modell, viele abgeleitete Ansichten.
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
# Swisstopo-Geodaten & SIA-Flächenstandards im Browser-BIM
|
||||||
|
|
||||||
|
> Stand: 2026-06-29 · Recherche für das Standalone-Browser-BIM (React + TS + Three.js),
|
||||||
|
> Port von **DOSSIER** (Rhino-Plugin). Ziel: (A) Schweizer Geodaten (Höhenmodell,
|
||||||
|
> Orthofoto, 3D-Gebäude, Parzellen) direkt im Browser laden, (B) Standort-Kontext
|
||||||
|
> (Gelände + Parzelle + Nachbargebäude) für ein Projekt importieren, (C) SIA-416-
|
||||||
|
> Flächen/Volumen + Raumschemata berechnen wie in DOSSIER.
|
||||||
|
>
|
||||||
|
> Bezug zur ROADMAP: Swisstopo/Terrain/OSM = **Phase 4** (Kontext/Daten); SIA-416-
|
||||||
|
> Räume + Bilanz-CSV = **Phase 2**. Beide sind dort bereits als ⭐-Features gelistet.
|
||||||
|
|
||||||
|
**Alle in diesem Dokument genannten geo.admin.ch-Endpunkte wurden am 2026-06-29 live
|
||||||
|
gegen die echte API getestet** (curl + CORS-Header-Check). Wo „verifiziert" steht,
|
||||||
|
liegt eine echte Antwort vor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil A — Swisstopo-APIs & Dienste aus dem Browser
|
||||||
|
|
||||||
|
### A.0 Das Wichtigste vorweg: CORS & Lizenz
|
||||||
|
|
||||||
|
Zwei Fragen entscheiden, ob ein Dienst *ohne Backend-Proxy* aus einer reinen
|
||||||
|
Browser-App nutzbar ist: CORS und Lizenz. Beide sind hier günstig.
|
||||||
|
|
||||||
|
**CORS (live verifiziert):** Alle relevanten Hosts senden `access-control-allow-origin: *`:
|
||||||
|
|
||||||
|
| Host | Dienst | CORS | Range-Requests |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `api3.geo.admin.ch` | REST (height, profile, identify, find, search) | ✅ `*` | — |
|
||||||
|
| `data.geo.admin.ch` | STAC-API + Daten-Assets (COG-GeoTIFF, XYZ.zip) | ✅ `*` | ✅ `206 Partial Content`, `accept-ranges`/`content-range` vorhanden |
|
||||||
|
| `wmts.geo.admin.ch` | WMTS-Kacheln | ✅ `*` | — |
|
||||||
|
| `3d.geo.admin.ch` | 3D-Tiles (`tileset.json` + glTF) | ✅ `*` | — |
|
||||||
|
|
||||||
|
→ **Konsequenz:** Höhenabfrage, Geocoding, Parzellen-Identify, Karten-/Orthofoto-
|
||||||
|
Kacheln, **COG-GeoTIFF-Höhenmodell per Range-Request** und 3D-Tiles sind **direkt aus
|
||||||
|
dem Browser ohne eigenen Proxy** abrufbar. Das ist ein großer Vorteil gegenüber vielen
|
||||||
|
anderen nationalen Geodiensten.
|
||||||
|
|
||||||
|
**Lizenz:** swisstopo/geo.admin.ch ist **Open Government Data**: „The acquisition and
|
||||||
|
use of data or services is free of charge, subject to the provisions on fair use."
|
||||||
|
Kommerzielle Nutzung ist erlaubt, Einbindung in (auch kommerzielle) Web-Apps explizit
|
||||||
|
gedeckt. Pflicht-Attribution: **`© swisstopo`** (bzw. „© Data: swisstopo"). „Fair use"
|
||||||
|
= z.B. Web-App mit Ø 20'000 Nutzern/Tag ok; aggressives Bot-Scraping vermeiden. Haftung
|
||||||
|
ausgeschlossen, ~98% Verfügbarkeit. [Terms of use FSDI](https://www.geo.admin.ch/en/general-terms-of-use-fsdi)
|
||||||
|
|
||||||
|
> ⚠️ **Korrektur zu DOSSIER & zur Doku:** Die offizielle REST-Doku notiert beim
|
||||||
|
> *Height*-Service „This service is not freely accessible (fee required)". Das ist
|
||||||
|
> **in der Praxis falsch / veraltet**: Der Endpunkt antwortet anonym, ohne Key, mit
|
||||||
|
> `200` und CORS `*` (verifiziert, siehe A.1). DOSSIERs Aussage „alle APIs offen, ohne
|
||||||
|
> Auth, ohne Key" deckt sich mit der gemessenen Realität. Wir verlassen uns aber nicht
|
||||||
|
> blind darauf, sondern behandeln 402/429 defensiv (Retry/Backoff, Cache).
|
||||||
|
|
||||||
|
### A.1 Höhenabfrage — Height-Service (Einzelpunkt)
|
||||||
|
|
||||||
|
Punkt-Höhe (DTM) aus swissALTI3D/DTM. **Verifiziert:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://api3.geo.admin.ch/rest/services/height?easting=2600000&northing=1200000&sr=2056
|
||||||
|
→ {"height":"555.5"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameter:
|
||||||
|
- `easting`, `northing` — LV95 (`sr=2056`) oder LV03 (`sr=21781`). **Pflicht.**
|
||||||
|
- `sr` — `2056` (LV95) angeben, sonst Default `21781`.
|
||||||
|
- `elevation_model` — `DTM2` (= swissALTI3D, 2 m), `DTM25` (Default), `COMB`.
|
||||||
|
(Im Tal lieferten DTM2/DTM25/COMB denselben Wert; im Steilgelände kann DTM2 genauer sein.)
|
||||||
|
- `callback` — JSONP (brauchen wir wegen CORS nicht).
|
||||||
|
|
||||||
|
Nutzung im Tool: **Projekt-Nullpunkt-Z** bzw. „Gebäude auf Gelände setzen" — eine
|
||||||
|
einzelne Höhe an der Projekt-Koordinate. Antwortzeit ~50–150 ms. Quelle:
|
||||||
|
[GeoAdmin REST – Height](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html)
|
||||||
|
|
||||||
|
### A.2 Höhenprofil — Profile-Service (Schnittlinie)
|
||||||
|
|
||||||
|
Höhen entlang einer Polylinie — ideal für **Geländeschnitt** unter einem
|
||||||
|
Gebäude-Schnitt. **Verifiziert** (echte Werte zurück):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://api3.geo.admin.ch/rest/services/profile.json
|
||||||
|
?geom={"type":"LineString","coordinates":[[2600000,1200000],[2600200,1200000]]}
|
||||||
|
&sr=2056&nb_points=3
|
||||||
|
→ [{"alts":{"COMB":555.5,"DTM2":555.5,"DTM25":555.5},"dist":0,"easting":2600000,"northing":1200000},
|
||||||
|
{"alts":{...},"dist":100,...}, {"dist":200,...}]
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameter: `geom` (GeoJSON-LineString, max 6'000 Punkte), `sr`, `nb_points` (Anzahl
|
||||||
|
Stützpunkte, Default 200), `elevation_models`, `offset` (Glättung). Auch als
|
||||||
|
`profile.csv`. → Für 2D-Geländeschnitte **ohne** Mesh-Download. Quelle:
|
||||||
|
[GeoAdmin REST – Profile](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html)
|
||||||
|
|
||||||
|
### A.3 swissALTI3D — Höhenmodell als COG-GeoTIFF (Mesh-Quelle) ⭐
|
||||||
|
|
||||||
|
Das ist der **Schlüssel für das Gelände-Mesh im Browser**. swissALTI3D ist das präzise
|
||||||
|
DTM der Schweiz (ohne Vegetation/Bebauung), Auflösung 0.5 m / 2 m, alle 6 Jahre
|
||||||
|
aktualisiert ([swissALTI3D](https://www.swisstopo.admin.ch/en/height-model-swissalti3d)).
|
||||||
|
Bezug über die **STAC-API** (verifiziert — Tile `swissalti3d_2019_2599-1198`):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://data.geo.admin.ch/api/stac/v1/collections/ch.swisstopo.swissalti3d/items
|
||||||
|
?bbox=<lonMin,latMin,lonMax,latMax>&limit=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Jedes 1×1-km-Tile liefert pro Auflösung **zwei** Asset-Typen:
|
||||||
|
|
||||||
|
| Asset | Typ | Browser-tauglich? |
|
||||||
|
|---|---|---|
|
||||||
|
| `..._0.5_2056_5728.tif` / `..._2_2056_5728.tif` | **Cloud-Optimized GeoTIFF**, **EPSG:2056** | ✅ **direkt** via `geotiff.js` + Range |
|
||||||
|
| `..._0.5_2056_5728.xyz.zip` / `..._2_..._xyz.zip` | ASCII-XYZ (E N Z) in ZIP | ✅ via `fflate` entpacken (so macht es DOSSIER) |
|
||||||
|
|
||||||
|
**Verifiziert:** `data.geo.admin.ch` liefert auf das `.tif` ein `206 Partial Content`
|
||||||
|
mit `content-range` bei `Range:`-Header und CORS `*`. Das bedeutet: **`geotiff.js`
|
||||||
|
liest nur den benötigten Ausschnitt eines COG per HTTP-Range, ohne das ganze File zu
|
||||||
|
laden** — perfekt für eine Browser-App. Bbox in WGS84 für STAC, Tile-Daten dann in
|
||||||
|
LV95-Metern (kein Reprojizieren der Z-Werte nötig). Quelle:
|
||||||
|
[STAC tech docs](https://docs.geo.admin.ch/) · COG-Tile live geprüft.
|
||||||
|
|
||||||
|
### A.4 SWISSIMAGE / Karten — WMTS-Kacheln
|
||||||
|
|
||||||
|
Orthofoto (10 cm) und Landeskarten als Kacheln. RESTful-URL-Template:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://wmts.geo.admin.ch/1.0.0/<Layer>/default/<Time>/<TileMatrixSet>/<z>/<TileCol>/<TileRow>.<ext>
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel-Layer:
|
||||||
|
- `ch.swisstopo.swissimage` — Orthofoto, `.jpeg`
|
||||||
|
- `ch.swisstopo.pixelkarte-farbe` — Landeskarte farbig, `.jpeg`
|
||||||
|
- `ch.kantone.cadastralwebmap-farbe` — **Katasterplan (AV)**, `.png`
|
||||||
|
|
||||||
|
TileMatrixSets: **`2056` (LV95)**, `21781`, `3857` (Web-Mercator), `4326`. Zoom 0–28
|
||||||
|
(4000 m → 0.1 m); Zoom 27/28 nur für wenige Layer (swissimage, Kataster). Für 3D in
|
||||||
|
Three.js am einfachsten **`3857`** (Standard-Slippy-Map-Schema, z/x/y), z.B.
|
||||||
|
|
||||||
|
```
|
||||||
|
https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
Für planimetrisch exakte 2D-Arbeit besser **`2056`**. CORS `*` (verifiziert). Quellen:
|
||||||
|
[WMTS docs](https://docs.geo.admin.ch/visualize-data/wmts.html) ·
|
||||||
|
[WMTS service](https://wmts.geo.admin.ch/) ·
|
||||||
|
[WMTS EPSG:2056 CodePen](https://codepen.io/geoadmin/pen/GZKEam).
|
||||||
|
|
||||||
|
> Es gibt zusätzlich klassisches **WMS** (`https://wms.geo.admin.ch/`, GetMap mit
|
||||||
|
> beliebiger BBox/Größe, ebenfalls EPSG:2056). Für ein einzelnes georeferenziertes
|
||||||
|
> Orthofoto-Rechteck unter dem Modell ist ein WMS-GetMap manchmal praktischer als
|
||||||
|
> WMTS-Kacheln zu stitchen. [WMS docs](https://docs.geo.admin.ch/visualize-data/wms.html)
|
||||||
|
|
||||||
|
### A.5 swissBUILDINGS3D — Nachbargebäude (3D)
|
||||||
|
|
||||||
|
Zwei Wege:
|
||||||
|
|
||||||
|
**(a) 3D-Tiles (Streaming, Cesium-Format)** — `glTF`/`tileset.json`, für große Gebiete:
|
||||||
|
```
|
||||||
|
https://3d.geo.admin.ch/<Layer>/<Version>/<Time>/tileset.json
|
||||||
|
```
|
||||||
|
Layer u.a. `ch.swisstopo.swissbuildings3d.3d`, `ch.swisstopo.swisstlm3d.3d`,
|
||||||
|
`ch.swisstopo.swissnames3d.3d`, `ch.swisstopo.vegetation.3d`. `Version` = `v1`, `Time`
|
||||||
|
optional (ISO `YYYYMMDD`, weglassen = aktuellste). CORS `*` (verifiziert auf
|
||||||
|
`tileset.json`). Direkt für CesiumJS gedacht; in **reinem Three.js** über
|
||||||
|
`@loaders.gl/3d-tiles` oder den `3DTilesRendererJS` (NASA-AMMOS/`three.js`-Community)
|
||||||
|
ladbar. [3D-Tiles docs](https://docs.geo.admin.ch/visualize-data/3d-tiles.html) ·
|
||||||
|
[Switzerland in 3D](https://www.swisstopo.admin.ch/en/switzerland-in-3d)
|
||||||
|
|
||||||
|
**(b) STAC-Tiles als CAD/Mesh-Datei (Download pro Tile)** — so macht es DOSSIER:
|
||||||
|
Collections `ch.swisstopo.swissbuildings3d_3_0` (neu; in Städten z.T. >700 MB Tiles)
|
||||||
|
und `ch.swisstopo.swissbuildings3d_2` (1-km-Tiles, ~50 MB, stabil). Assets in
|
||||||
|
`.dxf/.dwg/.obj/.ifc` (+ `.zip`), Varianten `solid`/`separated`. Für den **Import als
|
||||||
|
echte, editierbare Massen** ins eigene Modell ist der OBJ/IFC-Tile-Weg besser als
|
||||||
|
3D-Tiles (die sind read-only Visualisierung). swissBUILDINGS3D: >3 Mio Gebäude,
|
||||||
|
Lage-/Höhengenauigkeit 30–50 cm.
|
||||||
|
|
||||||
|
→ **Empfehlung:** Für „Nachbarschaft als Kontext anzeigen" (Phase 4 Start) **3D-Tiles
|
||||||
|
streamen** (kein Download, kein Parsing). Wenn der Nutzer Nachbargebäude als Geometrie
|
||||||
|
*braucht* (Verschattung, Abstand), **STAC-OBJ-Tile** laden und als Mesh importieren.
|
||||||
|
|
||||||
|
### A.6 Parzelle / Kataster (AV) — Identify-Service ⭐
|
||||||
|
|
||||||
|
**Verifiziert** — Parzellen-Polygon aus einer Koordinate, in LV95:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://api3.geo.admin.ch/rest/services/api/MapServer/identify
|
||||||
|
?geometry=2600423,1199521&geometryType=esriGeometryPoint
|
||||||
|
&imageDisplay=100,100,96&mapExtent=2600323,1199421,2600523,1199621
|
||||||
|
&tolerance=2&layers=all:ch.kantone.cadastralwebmap-farbe
|
||||||
|
&returnGeometry=true&geometryFormat=geojson&sr=2056
|
||||||
|
→ {"results":[{"type":"Feature","bbox":[...],
|
||||||
|
"geometry":{"type":"Polygon","coordinates":[[ [2600377.1,1199523.7], ... ]]},
|
||||||
|
"attributes":{"number":"698","egris_egrid":"CH507635214670","ak":"BE", ...}}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Parzellen-Layer: **`ch.kantone.cadastralwebmap-farbe`** (liefert `number`,
|
||||||
|
`egris_egrid` = EGRID, Kanton; mit `returnGeometry=true&geometryFormat=geojson` das
|
||||||
|
**Parzellen-Polygon in LV95-Metern** → direkt als Grundstücksgrenze importierbar).
|
||||||
|
- Gebäudeadressen: `ch.swisstopo.amtliches-gebaeudeadressverzeichnis`; Gebäude-/
|
||||||
|
Wohnungsregister `ch.bfs.gebaeude_wohnungs_register` (EGID).
|
||||||
|
- Pflichtparameter: `geometry`, `geometryType` (`esriGeometryPoint|...Polygon|...Envelope`),
|
||||||
|
`mapExtent`, `imageDisplay`, `tolerance`; `sr=2056`; max 50 Features/Request.
|
||||||
|
|
||||||
|
Quellen: [Identify features](https://docs.geo.admin.ch/access-data/identify-features.html) ·
|
||||||
|
[GeoAdmin REST – Identify/Find](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html) ·
|
||||||
|
Live-Antwort oben.
|
||||||
|
|
||||||
|
### A.7 Geocoding — SearchServer (Adresse → LV95)
|
||||||
|
|
||||||
|
**Verifiziert** (Adresse → Koordinate, deckt sich mit DOSSIERs `geocode()`):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://api3.geo.admin.ch/rest/services/api/SearchServer
|
||||||
|
?searchText=Bundesplatz 3 Bern&type=locations&origins=address&sr=2056&limit=1
|
||||||
|
→ results[0].attrs: { label:"Bundesplatz 3 <b>3011 Bern</b>", lat:46.94677, lon:7.44419,
|
||||||
|
geom_st_box2d:"BOX(2600423.26 1199521.11, ...)", origin:"address", ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `type=locations`, `origins` aus `{address, parcel, gg25, gazetteer, zipcode, district,
|
||||||
|
kantone}`, `sr=2056`. **Im LV95-Modus liefert die Geo-Admin-Konvention `y`=East,
|
||||||
|
`x`=North** (DOSSIER liest genau so: `e=attrs.y`, `n=attrs.x`). Labels enthalten
|
||||||
|
`<b>`-Tags (strippen).
|
||||||
|
- `type=featuresearch` + `features=<layer>` durchsucht Attribute (z.B. Parzellennummer).
|
||||||
|
|
||||||
|
Quelle: [Search](https://docs.geo.admin.ch/access-data/search.html) · Live-Antwort oben.
|
||||||
|
|
||||||
|
### A.8 Koordinatensystem LV95 / EPSG:2056 & Transformationen
|
||||||
|
|
||||||
|
Intern rechnet das BIM-Tool in **Metern** (ROADMAP-Konvention) und verschiebt den
|
||||||
|
Projekt-Ursprung nahe (0,0,0); LV95-Koordinaten sind ~2.6 Mio / 1.2 Mio Meter groß und
|
||||||
|
würden bei `float32` (Three.js) zu **Jitter** führen → **Origin-Shift Pflicht** (siehe B.3).
|
||||||
|
DOSSIER macht genau das (`origin_shift`/`shift_lv95`, typ. bbox-Center → 0/0/0).
|
||||||
|
|
||||||
|
Transformations-Optionen:
|
||||||
|
|
||||||
|
1. **`proj4` (npm `proj4@2.20.9`)** — universell, exakt. EPSG:2056-Definition:
|
||||||
|
```js
|
||||||
|
proj4.defs("EPSG:2056",
|
||||||
|
"+proj=somerc +lat_0=46.9524055555556 +lon_0=7.43958333333333 +k_0=1 "+
|
||||||
|
"+x_0=2600000 +y_0=1200000 +ellps=bessel "+
|
||||||
|
"+towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs +type=crs");
|
||||||
|
const [e,n] = proj4("EPSG:4326","EPSG:2056",[lon,lat]); // WGS84→LV95
|
||||||
|
```
|
||||||
|
Genauigkeit mit dieser 3-Parameter-`towgs84` ~1 m (für Kontext-Import völlig
|
||||||
|
ausreichend). Types: `@types/proj4`. Quellen:
|
||||||
|
[epsg.io/2056](https://epsg.io/2056) · [proj4js](https://github.com/proj4js/proj4js).
|
||||||
|
|
||||||
|
2. **Näherungsformeln (CH1903→WGS84, swisstopo)** — DOSSIERs Ansatz, ~1 m genau,
|
||||||
|
**0 Dependencies** (zwei kleine Funktionen `lv95_to_wgs84`/`wgs84_to_lv95`).
|
||||||
|
1:1 nach TS portierbar; gut, wenn man `proj4` nicht ziehen will. Reicht, weil
|
||||||
|
STAC-Queries ohnehin nur eine grobe WGS84-Bbox brauchen und alle *Daten* schon in
|
||||||
|
LV95 kommen.
|
||||||
|
|
||||||
|
3. **swisstopo REFRAME Web-API** — cm-genaue offizielle Umrechnung (LV95↔WGS84,
|
||||||
|
LN02↔Bessel). Nur nötig, wenn Vermessungs-Genauigkeit verlangt wird. REST, online.
|
||||||
|
[REFRAME Web](https://www.swisstopo.admin.ch/en/rest-api-geoservices-reframe-web)
|
||||||
|
|
||||||
|
**Empfehlung:** `proj4` mit fester EPSG:2056-Def (eine Abhängigkeit, exakt genug,
|
||||||
|
wartungsarm) — oder, wenn Dependency-Geiz, DOSSIERs Formeln portieren. REFRAME nur bei
|
||||||
|
Bedarf nachrüsten.
|
||||||
|
|
||||||
|
### A.9 Endpoint-Übersicht (Spickzettel)
|
||||||
|
|
||||||
|
| Zweck | Endpoint | Frei/CORS | Format |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Punkt-Höhe | `api3…/rest/services/height` | ✅ ✅ | JSON |
|
||||||
|
| Höhenprofil (Schnitt) | `api3…/rest/services/profile.json` | ✅ ✅ | JSON/CSV |
|
||||||
|
| Gelände-Mesh (COG) | STAC `…/swissalti3d/items` → `.tif` (COG, 2056) | ✅ ✅ Range | GeoTIFF |
|
||||||
|
| Gelände (ASCII) | STAC `…/swissalti3d` → `.xyz.zip` | ✅ ✅ | XYZ in ZIP |
|
||||||
|
| Orthofoto/Karte | `wmts…/1.0.0/<layer>/…/{z}/{x}/{y}.jpeg` | ✅ ✅ | Kacheln |
|
||||||
|
| 3D-Nachbargebäude (stream) | `3d…/ch.swisstopo.swissbuildings3d.3d/v1/tileset.json` | ✅ ✅ | 3D-Tiles/glTF |
|
||||||
|
| 3D-Gebäude (Datei) | STAC `…/swissbuildings3d_2` → `.obj/.ifc` | ✅ ✅ | OBJ/IFC |
|
||||||
|
| Parzelle/Kataster | `api3…/MapServer/identify` `layers=all:ch.kantone.cadastralwebmap-farbe` | ✅ ✅ | GeoJSON |
|
||||||
|
| Geocoding | `api3…/SearchServer?type=locations` | ✅ ✅ | JSON |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil B — Standort-Kontext importieren (Terrain + Parzelle + Nachbargebäude)
|
||||||
|
|
||||||
|
So bekommt ein Projekt seinen realen Kontext „auf Knopfdruck". Der Ablauf folgt
|
||||||
|
DOSSIER (`rhino/swisstopo.py`), übersetzt auf Browser-Libs.
|
||||||
|
|
||||||
|
### B.1 Pipeline (End-to-End)
|
||||||
|
|
||||||
|
```
|
||||||
|
Adresse/Parzelle ──SearchServer──▶ Zentrum (E,N) in LV95
|
||||||
|
│
|
||||||
|
├─ radius r ──▶ bbox_LV95 (E±r, N±r) ──proj4/Formeln──▶ bbox_WGS84
|
||||||
|
│
|
||||||
|
├─[Parzelle] identify(cadastralwebmap, point) ─▶ Polygon (LV95) ─▶ Grundstücksgrenze (Ebene 01 Vermessung)
|
||||||
|
│
|
||||||
|
├─[Gelände] STAC(swissalti3d, bbox_WGS84) ─▶ COG .tif(2056)
|
||||||
|
│ └─ geotiff.js readRasters(window) ─▶ Höhen-Grid (E,N,Z, m)
|
||||||
|
│ └─ Three.js BufferGeometry (Grid→Mesh) [optional: TIN, Höhenlinien, Volumen]
|
||||||
|
│
|
||||||
|
├─[Orthofoto] WMTS swissimage ─▶ Textur auf Gelände-Mesh ODER georef. Plane
|
||||||
|
│
|
||||||
|
└─[Nachbarn] 3D-Tiles streamen (Anzeige) ODER STAC swissbuildings3d_2 .obj ─▶ Mesh-Import
|
||||||
|
(Weltweit/ausserhalb CH: OSM-Overpass als Fallback, siehe B.5)
|
||||||
|
──▶ alle Geometrien um origin_shift (bbox-Center→0/0/0) verschoben, Z aus ALTI3D
|
||||||
|
```
|
||||||
|
|
||||||
|
### B.2 Gelände-Mesh aus swissALTI3D (Kern, Phase 4)
|
||||||
|
|
||||||
|
**Empfohlener Browser-Weg (COG + geotiff.js):**
|
||||||
|
|
||||||
|
1. STAC-Query mit `bbox_WGS84` → Liste der überlappenden Tiles; pro Tile das
|
||||||
|
gewünschte COG-Asset (`_2_2056_` für 2 m, `_0.5_2056_` für 0.5 m).
|
||||||
|
2. `geotiff.js`: `const tiff = await fromUrl(href)` → COG; `image.readRasters({window})`
|
||||||
|
liest **nur den Ausschnitt** (Range-Requests, da CORS+Range bestätigt). Ergebnis ist
|
||||||
|
ein reguläres Z-Raster mit bekanntem Origin/PixelScale (LV95-Meter) aus den
|
||||||
|
GeoKeys/`image.getOrigin()`/`image.getResolution()`.
|
||||||
|
3. Raster → **`THREE.BufferGeometry`**: ein Vertex pro Rasterpunkt
|
||||||
|
`(E−shiftE, N−shiftN, Z−shiftZ)`, Faces als zwei Dreiecke pro Zelle (DOSSIER:
|
||||||
|
`mesh_from_grid`, gleiche Logik), `computeVertexNormals()`. Bei 0.5 m wird das Mesh
|
||||||
|
groß → bei Bedarf raumräumlich sub-samplen (DOSSIER macht ganzzahliges Sub-Sampling
|
||||||
|
auf dem **globalen** LV95-Raster, damit Nachbar-Tiles nahtlos zusammenpassen).
|
||||||
|
4. Mehrere Tiles: erst zu **einem** Grid mergen (gemeinsamer Origin/Step), dann meshen —
|
||||||
|
sonst entstehen Nähte (DOSSIER: `merge_grids`).
|
||||||
|
|
||||||
|
**Alternativweg (XYZ, exakt wie DOSSIER):** `.xyz.zip` laden → mit **`fflate`**
|
||||||
|
(npm, schnellster Inflate im Browser) entpacken → ASCII `E N Z` parsen → gleiches Grid.
|
||||||
|
Robust, aber überträgt mehr Bytes als der COG-Range-Weg. Für den Port empfehle ich
|
||||||
|
**COG primär, XYZ als Fallback**.
|
||||||
|
|
||||||
|
Bibliotheken: `geotiff@3.0.5`, `fflate` (XYZ-Variante), `three@0.185.0`.
|
||||||
|
[geotiff.js](https://github.com/geotiffjs/geotiff.js/) ·
|
||||||
|
[3D-Terrain aus GeoTIFF mit Three.js](https://spatial-dev.guru/2024/11/30/creating-3d-terrain-maps-from-geotiff-files-with-three-js/).
|
||||||
|
|
||||||
|
**Ableitungen wie in DOSSIER** (alle aus dem Grid, in Phase 4 portierbar):
|
||||||
|
- **Höhenlinien** (Marching-Squares auf dem Grid; npm `d3-contour` oder
|
||||||
|
`marchingsquares`) — für 2D-Plan.
|
||||||
|
- **TIN / Patch** (Delaunay aus den Punkten; npm `delaunator`) — alternatives Mesh.
|
||||||
|
- **Geschlossenes Gelände-Volumen** (Boden N m unter tiefstem Punkt) → gefüllte
|
||||||
|
Querschnitte beim Schnitt-Cut (DOSSIER `terrainVolume`/`terrainVolumeDepth`).
|
||||||
|
|
||||||
|
### B.3 Origin-Shift (Pflicht)
|
||||||
|
|
||||||
|
LV95-Koordinaten (~2.6e6) sprengen `float32`. Beim Import einmal
|
||||||
|
`shift = (eCenter, nCenter, zRef)` festlegen, **alle** Geometrien `−shift` rechnen, und
|
||||||
|
`shift` am Projekt persistieren (für Re-Import / Geo-Referenz / Norden). DOSSIER:
|
||||||
|
`origin_shift`/`shift_lv95`, plus „Auto-Zoom auf Import" (ROADMAP §11). Damit bleibt das
|
||||||
|
Modell metergenau und der Rückweg in echte LV95-Koordinaten (Export, weitere
|
||||||
|
swisstopo-Abfragen) ist `+ shift`.
|
||||||
|
|
||||||
|
### B.4 Parzelle + Orthofoto
|
||||||
|
|
||||||
|
- **Parzelle:** `identify(...cadastralwebmap..., returnGeometry=true, geometryFormat=geojson)`
|
||||||
|
→ Polygon (LV95) → `−shift` → als geschlossene Polylinie auf Ebene **`01 Vermessung`**.
|
||||||
|
Attribute `number`/`egris_egrid` am Objekt/Projekt speichern.
|
||||||
|
- **Orthofoto:** WMTS `swissimage`-Kacheln über die Modell-Bbox stitchen → eine Textur,
|
||||||
|
als Material auf das Gelände-Mesh **oder** auf eine georeferenzierte Plane (DOSSIER:
|
||||||
|
`add_ortho_plane`, mit UV-Shift gegen Tile-Nähte). Für 3D ist `3857` einfacher, für
|
||||||
|
exakte 2D-Lage `2056`.
|
||||||
|
|
||||||
|
### B.5 OSM-Overpass als weltweiter Fallback (Phase 4)
|
||||||
|
|
||||||
|
Ausserhalb der Schweiz (oder wenn nur 2D-Footprints/Straßen reichen): DOSSIER hat einen
|
||||||
|
**Overpass-Importer** (`https://overpass-api.de/api/interpreter`, POST) mit 7 Kategorien
|
||||||
|
(Straßen/Gebäude/Wasser/Wasserläufe/Grün/Wege). Liefert OSM-Ways → Polylinien. Im
|
||||||
|
Browser identisch nutzbar (`fetch` POST). Overpass koordiniert in WGS84 → mit
|
||||||
|
`proj4`→LV95→`−shift`. Hinweis: Overpass-CORS ist beim Haupt-Server meist offen, kann
|
||||||
|
aber je nach Mirror variieren; ggf. anderen Mirror wählen.
|
||||||
|
[Overpass API](https://overpass-api.de/).
|
||||||
|
|
||||||
|
### B.6 Was sich von DOSSIER **nicht** 1:1 portieren lässt
|
||||||
|
|
||||||
|
- **Rhino-`_-Import`** für DXF/DWG/OBJ und **`_-MeshPatch`/Delaunay**-Commands gibt es im
|
||||||
|
Browser nicht → ersetzen durch JS-Parser/Algorithmen (OBJ: `three`-`OBJLoader`;
|
||||||
|
Delaunay: `delaunator`; Contours: `d3-contour`).
|
||||||
|
- **Filesystem-Cache neben der `.3dm`** → Browser: **IndexedDB**-Cache (Cache-API für
|
||||||
|
Kacheln). Passt zur ROADMAP-Phase 5 (IndexedDB-Persistenz).
|
||||||
|
- IFC-Import von swissBUILDINGS3D 3.0 → über **web-ifc** (ohnehin im Stack, Phase 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil C — SIA 416 (Flächen/Volumen) & SIA 421 + DOSSIER-Logik
|
||||||
|
|
||||||
|
### C.1 SIA 416 — Flächen- und Volumenhierarchie
|
||||||
|
|
||||||
|
**SIA 416:2003** „Flächen und Volumen von Gebäuden" ist die in der CH gültige Norm und
|
||||||
|
Berechnungsbasis für Kostenplanung/Flächennachweise. Sie kennt vier Bereiche:
|
||||||
|
**GSF** (Grundstück), **GF** (Geschossflächen), **AGF** (Aussengeschossflächen), **GV**
|
||||||
|
(Volumen). Maßgeblich ist die effektive Geometrie (keine fiktiven Zuschläge mehr).
|
||||||
|
Quellen: [SIA 416 Übersicht (siworks/DBV)](https://diebauherrenvertretung.ch/sia146/) ·
|
||||||
|
[SIA-Shop 416/2003](https://shop.sia.ch/normenwerk/architekt/sia%20416/dfi/D/Product) ·
|
||||||
|
[Flächenkennzahlen SIA 416 (Ginesta, PDF)](https://www.ginesta.ch/resources/public/lava3/media/kcfinder/files/Fl%C3%A4chenkennzahlen%20SIA%20416%20Norm.pdf).
|
||||||
|
|
||||||
|
**Hierarchie & Formeln (verifiziert):**
|
||||||
|
|
||||||
|
```
|
||||||
|
GSF Grundstücksfläche
|
||||||
|
GF Geschossfläche = KF + NGF
|
||||||
|
├─ KF Konstruktionsfläche (Wände/Stützen; tragend KFT + nicht tragend KFN)
|
||||||
|
└─ NGF Nettogeschossfläche = NF + VF + FF
|
||||||
|
├─ NF Nutzfläche = HNF + NNF
|
||||||
|
│ ├─ HNF Hauptnutzfläche (zweckbestimmte Hauptnutzung: Wohnen, Büro …)
|
||||||
|
│ └─ NNF Nebennutzfläche (Lager, Bad/WC, Abstell-, Nebenräume)
|
||||||
|
├─ VF Verkehrsfläche (Erschließung: Flure, Treppen, Lifte)
|
||||||
|
└─ FF Funktionsfläche (Gebäudetechnik: Heizung, Lüftung, Technik)
|
||||||
|
AGF Aussengeschossfläche (Balkone, Terrassen, gedeckte Aussenflächen)
|
||||||
|
GV Gebäudevolumen [m³]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Abk. | Deutsch | Inhalt (Kurz) |
|
||||||
|
|---|---|---|
|
||||||
|
| GSF | Grundstücksfläche | Parzellenfläche (aus Kataster, Teil A.6) |
|
||||||
|
| GF | Geschossfläche | allseits umschlossene + überdeckte Grundrissflächen, geschossweise |
|
||||||
|
| KF | Konstruktionsfläche | Bauteile (Wände/Stützen), nicht begehbar; KFT tragend / KFN nicht tragend |
|
||||||
|
| NGF | Nettogeschossfläche | begehbare Fläche innerhalb der Umschließung = NF+VF+FF |
|
||||||
|
| NF | Nutzfläche | tatsächlich nutzbar = HNF+NNF |
|
||||||
|
| HNF | Hauptnutzfläche | zweckbestimmte Hauptnutzung |
|
||||||
|
| NNF | Nebennutzfläche | dienende Nebenräume (Lager, Bad, WC) |
|
||||||
|
| VF | Verkehrsfläche | horizontale/vertikale Erschließung |
|
||||||
|
| FF | Funktionsfläche | Gebäudetechnik |
|
||||||
|
| AGF | Aussengeschossfläche | Balkone/Terrassen u.ä. |
|
||||||
|
| GV | Gebäudevolumen | umbauter Raum [m³] |
|
||||||
|
|
||||||
|
> **Versionshinweis:** Es kursiert eine Revision **SIA 416:2017** mit präzisierten
|
||||||
|
> Begriffen, in der Praxis wird aber breit weiter **416:2003** referenziert. Für unser
|
||||||
|
> Tool reicht die Klassifikation **HNF/NNF/VF/FF/GF/AGF + Bilanz**; die genaue Auflage
|
||||||
|
> nur als Label dokumentieren. Verbindliche Definitionen stehen im kostenpflichtigen
|
||||||
|
> Normtext (SIA-Shop) — die hier zitierten freien Quellen stimmen in der Struktur überein.
|
||||||
|
|
||||||
|
### C.2 SIA 421 — Flächengliederung für Bewirtschaftung/Vermietung
|
||||||
|
|
||||||
|
**SIA 421:2006** „Flächengliederung und Mengenangaben" (Korrigenda C1:2014) baut auf der
|
||||||
|
SIA-416-Systematik auf und **gliedert Flächen für Immobilien-Bewirtschaftung und
|
||||||
|
Vermietung** (Mietflächen, Nutzungseinheiten, Zuordnung von VF/FF zu Mietern). Relevant,
|
||||||
|
sobald wir **Mietflächen-/Bewirtschaftungs-Auswertungen** wollen (über den reinen
|
||||||
|
Architektur-Nachweis hinaus). Für den ersten Wurf **nicht zwingend** — SIA 416 reicht
|
||||||
|
für Flächennachweis und Raumschema. SIA 421 ist die natürliche Erweiterung, wenn
|
||||||
|
Property-Management-Features kommen. Quellen:
|
||||||
|
[SIA 421:2006 (PDF Inhalt)](https://shop.sia.ch/d475e3f9-10c9-48aa-9be8-657784ea519b/F/DownloadAnhang) ·
|
||||||
|
[Korrigenda C1:2014 (PDF)](https://www.sia.ch/fileadmin/content/download/sia-norm/korrigenda_sn/421-C1_2014_d.pdf).
|
||||||
|
|
||||||
|
### C.3 Wie DOSSIER SIA macht (Vorlage für den Port)
|
||||||
|
|
||||||
|
Quellcode: `rhino/elemente.py` (Räume) + `rhino/elemente_uebersicht.py` (Bilanz/CSV).
|
||||||
|
Kernpunkte, die wir 1:1 übernehmen:
|
||||||
|
|
||||||
|
**Datenmodell pro Raum.** Ein Raum ist eine **geschlossene Outline-Curve** + ein
|
||||||
|
**Text-Stempel**. Klassifikation über das Feld `dossier_raum_sia` mit Werten
|
||||||
|
`{"", hnf, nnf, vf, ff, gf, agf}` (`_RAUM_SIA_KINDS`). Weitere Felder: `name`, `nummer`,
|
||||||
|
`funktion` (`wohnen|schlafen|bad|kueche|essen|flur|…`), `personen` (für
|
||||||
|
Personenbelegung/Brandschutz), Rundung, Stempel-Layout.
|
||||||
|
|
||||||
|
**Flächen-/Umfangsberechnung** (`_raum_amp`): Fläche aus `AreaMassProperties` (≙ in JS
|
||||||
|
**Shoelace-Formel** über das Polygon), Umfang = Kurvenlänge, plus Zentroid für den
|
||||||
|
Stempel. Im Browser: Polygon-Fläche selbst rechnen (Shoelace), kein Mesh nötig — exakt
|
||||||
|
und schnell. Rundungsstufen (`_format_area`): `exakt|0.01|0.1|0.5|1` (z.B. `0.5` =
|
||||||
|
`round(a*2)/2`).
|
||||||
|
|
||||||
|
**SIA-Bilanz** (`compute_sia_bilanz`, `scope = total | geschoss:<id>`): summiert
|
||||||
|
Raumflächen je Klasse, dann:
|
||||||
|
```
|
||||||
|
NF = HNF + NNF
|
||||||
|
NGF = NF + VF + FF (GF/AGF separat aggregiert, zählen nicht in NGF)
|
||||||
|
```
|
||||||
|
(genau die Formeln aus C.1). Ergebnis je Geschoss + Total.
|
||||||
|
|
||||||
|
**Farb-/Darstellungs-Konvention** (`_SIA_COLORS_HEX`, Pastell): HNF rot `#e8a8a8`,
|
||||||
|
NNF orange `#e8c498`, VF gelb `#e8d878`, FF hellblau `#a8c8e0`, GF grau `#d0d0d0`,
|
||||||
|
AGF hellgrün `#c0d8c0`. Umgesetzt als **regelbasierte Overrides** (`_build_sia_preset_rules`,
|
||||||
|
Preset „SIA-Raeume"): Bedingung `user_string == code` → Outline-Farbe + Solid-Hatch.
|
||||||
|
→ Passt 1:1 zur geplanten **Overrides-Engine** (ROADMAP §2c/§11).
|
||||||
|
|
||||||
|
**Export** (`_cmd_export_raeume`, `_export_bilanz`): CSV, **Semikolon + UTF-8-BOM**
|
||||||
|
(CH/DE-Excel), Dezimal-Komma. Raumliste: Nummer; Name; Geschoss; Funktion; SIA; Fläche;
|
||||||
|
Fläche gerundet; Umfang. Bilanz: eine Spalte je Geschoss + Total, Zeilen je Kategorie.
|
||||||
|
→ Im Browser: Blob + Download (kein SaveFileDialog), gleiche CSV-Struktur. Optional
|
||||||
|
direkt `.xlsx` via `sheetjs`/`exceljs`.
|
||||||
|
|
||||||
|
**Layer-Routing:** GF→`61_GF`, AGF→`62_AGF`, Rest→`60_RAEUME` (`_layer_path_for_raum_sia`)
|
||||||
|
— damit Geschossflächen-Outlines getrennt schalt-/exportierbar sind. Übersetzt sich auf
|
||||||
|
unsere Ebenen-Codes (`60 Räume`).
|
||||||
|
|
||||||
|
**Was wir im Port besser/anders machen:**
|
||||||
|
- Fläche per **Shoelace** statt Rhino-Mass-Props (0 Deps).
|
||||||
|
- Bilanz **reaktiv** aus dem semantischen Modell (Zustand-Store) statt Doc-Scan.
|
||||||
|
- **Space = Slab-/Raum-Polygon mit `siaClass`** im Datenmodell (ROADMAP:
|
||||||
|
`Space { boundary, name }`), Bilanz als abgeleitete Sicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil D — Umsetzungsplan (Endpunkte · Libs · Phase)
|
||||||
|
|
||||||
|
Reihenfolge orientiert sich an der ROADMAP (SIA = Phase 2, Geo = Phase 4) und an „größter
|
||||||
|
Nutzen zuerst, geringste Abhängigkeit zuerst".
|
||||||
|
|
||||||
|
### Phase 2 — SIA-Räume (kein Netz, reine Logik) ⭐
|
||||||
|
**Endpunkte:** keine. **Libs:** keine (Shoelace selbst), optional `exceljs`/`sheetjs` für
|
||||||
|
.xlsx.
|
||||||
|
1. `Space`-Modell: `{ boundary[], geschossId, name, nummer, funktion, siaClass∈{hnf,nnf,vf,ff,gf,agf}, personen }`.
|
||||||
|
2. `computeArea` (Shoelace) + Umfang; Rundungsstufen (`exakt|0.01|0.1|0.5|1`) wie DOSSIER `_format_area`.
|
||||||
|
3. `computeSiaBilanz(scope)` → `{hnf,nnf,nf,vf,ff,ngf,gf,agf,count,personen}` mit
|
||||||
|
`nf=hnf+nnf`, `ngf=nf+vf+ff`.
|
||||||
|
4. SIA-Farbpalette + Stil-Override (Outline-Farbe/Solid-Fill) in der Overrides-Engine.
|
||||||
|
5. Raumstempel-Renderer (Felder-Layout) + **CSV-Export** (Semikolon, UTF-8-BOM, Komma)
|
||||||
|
für Raumliste **und** Bilanz.
|
||||||
|
*Ergebnis:* SIA-416-Flächennachweis + Raumschema, Excel-kompatibel — vor jeder Geo-Arbeit nutzbar.
|
||||||
|
|
||||||
|
### Phase 4a — Geo-Grundlage: Koordinaten + Standortabfrage
|
||||||
|
**Endpunkte:** `SearchServer` (Geocoding), `height` (Punkt-Z), `identify`
|
||||||
|
(Parzelle, `cadastralwebmap-farbe`). **Libs:** `proj4@2.20.9` (+ `@types/proj4`).
|
||||||
|
1. `proj4`-EPSG:2056-Def + Helfer `lv95↔wgs84`, `bboxLv95→bboxWgs84` (DOSSIER-Logik).
|
||||||
|
2. **Origin-Shift**-Mechanik + Persistenz am Projekt (`shift = bbox-Center`), Auto-Zoom.
|
||||||
|
3. Adresssuche → Zentrum; „Gelände-Höhe holen" (height); „Parzelle holen" → Polygon auf
|
||||||
|
Ebene `01 Vermessung` (+ EGRID/Nummer am Projekt).
|
||||||
|
*Ergebnis:* Projekt ist georeferenziert; Parzelle + Adresse + Geländehöhe vorhanden.
|
||||||
|
|
||||||
|
### Phase 4b — Gelände-Mesh + Orthofoto
|
||||||
|
**Endpunkte:** STAC `swissalti3d` (COG `.tif`, 2056) + `profile.json`; WMTS `swissimage`.
|
||||||
|
**Libs:** `geotiff@3.0.5`, `three@0.185.0`, optional `fflate` (XYZ-Fallback),
|
||||||
|
`d3-contour`/`marchingsquares` (Höhenlinien), `delaunator` (TIN).
|
||||||
|
1. STAC-Query (bbox) → COG-Tiles; `geotiff.js` Range-Read → Grid; Tiles mergen.
|
||||||
|
2. Grid → `THREE.BufferGeometry` (DOSSIER `mesh_from_grid`/`merge_grids`); Sub-Sampling
|
||||||
|
auf globalem LV95-Raster; Normalen.
|
||||||
|
3. Optional: Höhenlinien (2D-Plan), TIN, geschlossenes Volumen (Schnitt-Füllung).
|
||||||
|
4. WMTS-`swissimage`-Kacheln → Textur auf Mesh/Plane (DOSSIER `add_ortho_plane`).
|
||||||
|
5. Geländeschnitt im 2D-Plan via `profile.json` entlang der Schnittlinie.
|
||||||
|
*Ergebnis:* echtes Gelände mit Orthofoto unter dem Gebäude; Geländeschnitte.
|
||||||
|
|
||||||
|
### Phase 4c — Nachbargebäude + weltweiter Fallback
|
||||||
|
**Endpunkte:** 3D-Tiles `ch.swisstopo.swissbuildings3d.3d/v1/tileset.json` (Anzeige)
|
||||||
|
**oder** STAC `swissbuildings3d_2` `.obj/.ifc` (Import); OSM `overpass-api.de`.
|
||||||
|
**Libs:** `@loaders.gl/3d-tiles@4.4.3` **oder** `3DTilesRendererJS`; `three`-`OBJLoader`;
|
||||||
|
`web-ifc` (für 3.0-IFC); `proj4`.
|
||||||
|
1. Kontext-Anzeige: 3D-Tiles in den Three-Scenegraph streamen (kein Download).
|
||||||
|
2. Bedarf an echter Geometrie (Verschattung/Abstand): STAC-OBJ-Tile → Mesh-Import → `−shift`.
|
||||||
|
3. Ausserhalb CH / nur 2D: Overpass-POST → Ways → Polylinien (Ebene `70 OSM`).
|
||||||
|
*Ergebnis:* Nachbarschaftskontext (CH 3D, weltweit OSM).
|
||||||
|
|
||||||
|
### Querschnitt (alle Geo-Phasen)
|
||||||
|
- **Caching:** IndexedDB für STAC-Antworten + COG-Bytes + Kacheln (ersetzt DOSSIERs
|
||||||
|
Disk-Cache); Cache-Schlüssel = Tile-ID/URL.
|
||||||
|
- **Defensive HTTP:** Timeouts, Retry/Backoff, 402/429 abfangen, Größen-Limit pro Tile
|
||||||
|
(DOSSIER: 200-MB-Guard).
|
||||||
|
- **Attribution:** „© swisstopo" sichtbar einblenden, „© OpenStreetMap-Mitwirkende" bei OSM.
|
||||||
|
- **Worker:** GeoTIFF-Parsing + Mesh-Bau im Web-Worker (Comlink, ROADMAP-Stack), UI bleibt flüssig.
|
||||||
|
|
||||||
|
### Empfohlener Library-Satz (npm, aktuell)
|
||||||
|
`proj4@2.20.9` · `geotiff@3.0.5` · `three@0.185.0` · `fflate` (XYZ) ·
|
||||||
|
`@loaders.gl/3d-tiles@4.4.3` *oder* `3DTilesRendererJS` · `delaunator` ·
|
||||||
|
`d3-contour` · `web-ifc` (im Stack) · `exceljs`/`sheetjs` (optional, .xlsx).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
|
||||||
|
- GeoAdmin REST (height/profile/identify/find/search): https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html
|
||||||
|
- GeoAdmin Tech-Docs (Hub): https://docs.geo.admin.ch/
|
||||||
|
- Identify Features: https://docs.geo.admin.ch/access-data/identify-features.html
|
||||||
|
- Search: https://docs.geo.admin.ch/access-data/search.html
|
||||||
|
- WMTS: https://docs.geo.admin.ch/visualize-data/wmts.html · https://wmts.geo.admin.ch/
|
||||||
|
- WMS: https://docs.geo.admin.ch/visualize-data/wms.html
|
||||||
|
- 3D-Tiles: https://docs.geo.admin.ch/visualize-data/3d-tiles.html
|
||||||
|
- swissALTI3D: https://www.swisstopo.admin.ch/en/height-model-swissalti3d
|
||||||
|
- Switzerland in 3D / swissBUILDINGS3D: https://www.swisstopo.admin.ch/en/switzerland-in-3d
|
||||||
|
- Terms of use (FSDI / OGD): https://www.geo.admin.ch/en/general-terms-of-use-fsdi
|
||||||
|
- REFRAME Web-API: https://www.swisstopo.admin.ch/en/rest-api-geoservices-reframe-web
|
||||||
|
- EPSG:2056 Definition: https://epsg.io/2056
|
||||||
|
- proj4js: https://github.com/proj4js/proj4js
|
||||||
|
- geotiff.js: https://github.com/geotiffjs/geotiff.js/
|
||||||
|
- Three.js-Terrain aus GeoTIFF: https://spatial-dev.guru/2024/11/30/creating-3d-terrain-maps-from-geotiff-files-with-three-js/
|
||||||
|
- WMTS EPSG:2056 Beispiel: https://codepen.io/geoadmin/pen/GZKEam
|
||||||
|
- Overpass API: https://overpass-api.de/
|
||||||
|
- SIA 416 (Übersicht): https://diebauherrenvertretung.ch/sia146/ · https://shop.sia.ch/normenwerk/architekt/sia%20416/dfi/D/Product
|
||||||
|
- SIA 416 Flächenkennzahlen (PDF): https://www.ginesta.ch/resources/public/lava3/media/kcfinder/files/Fl%C3%A4chenkennzahlen%20SIA%20416%20Norm.pdf
|
||||||
|
- SIA 421:2006 (PDF) + Korrigenda C1:2014: https://shop.sia.ch/d475e3f9-10c9-48aa-9be8-657784ea519b/F/DownloadAnhang · https://www.sia.ch/fileadmin/content/download/sia-norm/korrigenda_sn/421-C1_2014_d.pdf
|
||||||
|
- DOSSIER-Quellcode (Vorlage): `rhino/swisstopo.py`, `rhino/osm.py`, `rhino/elemente.py` (Räume/SIA), `rhino/elemente_uebersicht.py` (Bilanz/CSV)
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Technologie-Auswahl: Browser-basiertes BIM-Tool (DOSSIER-Port)
|
||||||
|
|
||||||
|
> **Kontext:** Standalone, browser-basiertes BIM-Werkzeug (React + TypeScript + Three.js) als Port des DOSSIER Rhino-Plugins. Keine Server-Abhaengigkeit gewuenscht (alles client-side). Recherchestand: Juni 2026.
|
||||||
|
>
|
||||||
|
> **Leitprinzip:** Wo immer moeglich auf einem echten B-Rep-Geometriekernel (OCCT) aufbauen, weil ein BIM-Werkzeug exakte 2D-Ableitungen (Schnitte, verdeckte Kanten, Bemassung) braucht — das ist mit reinen Dreiecksnetzen nicht sauber loesbar. Mesh-Booleans (Manifold) als schnelle Ergaenzung fuer Importgeometrie und Vorschau.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Geometriekernel im Browser (Solids + Booleans)
|
||||||
|
|
||||||
|
Das ist das schwierigste und zugleich wichtigste Problem. Drei ernsthafte Optionen, die alle im Browser (WASM) laufen.
|
||||||
|
|
||||||
|
### Optionen
|
||||||
|
|
||||||
|
**A) opencascade.js (OCCT als WASM)**
|
||||||
|
Port des vollstaendigen OpenCASCADE-Kernels (OCCT) nach WebAssembly via Emscripten. Voller B-Rep-Kernel: NURBS-Flaechen, exakte boolesche Operationen, Fillets/Chamfers, STEP/IGES-Import/-Export, Meshing. TypeScript-Bindings vorhanden. Die neueren Versionen (V3-Linie) zielen explizit auf moderne Bundler.
|
||||||
|
- Repo: <https://github.com/donalffons/opencascade.js/>
|
||||||
|
- Doku: <https://opencascade-js.vercel.app/>
|
||||||
|
- npm: <https://www.npmjs.com/package/opencascade.js>
|
||||||
|
- **Lizenz:** OCCT steht unter **LGPL-2.1 mit OCCT-Exception** (seit 6.7.0). Kommerzielle Nutzung ohne Lizenzgebuehren/Royalties erlaubt, sofern man (a) sichtbar darauf hinweist, dass die Software OCCT nutzt, und (b) eine Kopie der OCCT-Lizenz mitliefert. Die Exception entschaerft das statische-Linking-Problem fuer Header/Templates. Quellen: <https://dev.opencascade.org/resources/licensing>, <https://spdx.org/licenses/OCCT-exception-1.0.html>
|
||||||
|
- **Trade-offs:** Sehr grosse WASM-Binaries (zweistelliger MB-Bereich je nach Custom-Build), steile Lern- und API-Kurve (rohe OCCT-C++-API durchgereicht), Build-Pflege aufwendig.
|
||||||
|
|
||||||
|
**B) replicad (Abstraktion ueber opencascade.js)** — *empfohlene Basis*
|
||||||
|
replicad ist eine schlanke, idiomatische TypeScript-Schicht ueber opencascade.js. Es liefert genau die High-Level-Bausteine, die ein BIM-Tool braucht: Sketches/Blueprints, Extrude/Revolve/Loft, Booleans, Fillet/Chamfer — und entscheidend: **HLR-Projektionen** (`drawProjection`) und 2D-Drawings mit SVG-Export. Laeuft per Design im **Web Worker** und gibt Dreiecksnetze an den Main-Thread fuer Three.js zurueck.
|
||||||
|
- Doku/Library-Guide: <https://replicad.xyz/docs/use-as-a-library/>
|
||||||
|
- API: <https://replicad.xyz/docs/api/>
|
||||||
|
- **Lizenz:** **MIT** (eigene Schicht) — der OCCT/LGPL-Hinweis gilt weiterhin fuer das eingebettete WASM. Repo: <https://github.com/sgenoud/replicad>
|
||||||
|
- **Trade-offs:** Erbt OCCT-WASM-Groesse und -Robustheitsgrenzen; kleineres Team/Bus-Faktor als OCCT selbst. Man kann jederzeit „unter die Haube" auf rohes opencascade.js durchgreifen, wenn die Abstraktion nicht ausreicht.
|
||||||
|
|
||||||
|
**C) Manifold (manifold-3d, WASM)** — *empfohlen als schnelle Mesh-Ergaenzung*
|
||||||
|
Geometrie-Bibliothek fuer topologisch robuste **Dreiecksnetze**. Bietet den (laut Autor) ersten garantiert mannigfaltigen Mesh-Boolean-Algorithmus — extrem schnell und robust gegen Randfaelle. Stark parallelisiert.
|
||||||
|
- Repo: <https://github.com/elalish/manifold>
|
||||||
|
- npm: <https://www.npmjs.com/package/manifold-3d> (aktuell v3.5.x, Juni 2026)
|
||||||
|
- **Lizenz:** **Apache-2.0** (sehr permissiv, ideal). Bestaetigt: <https://github.com/elalish/manifold>
|
||||||
|
- **Trade-offs:** **Nur Meshes, kein B-Rep** — keine exakten NURBS-Flaechen, keine echten Fillets auf Krümmungen, kein STEP. Fuer ein BIM-Tool, das exakte Plaene/Schnitte ableiten will, alleine nicht ausreichend, aber unschlagbar fuer schnelle Booleans auf importierter Mesh-Geometrie und Live-Vorschau. **Wichtig:** unterstuetzt `slice(z)` und `project()` (siehe Abschnitt 3).
|
||||||
|
|
||||||
|
**D) three-bvh-csg (nur erwähnt, nicht empfohlen als Kernel)**
|
||||||
|
Sehr schnelle CSG direkt auf Three.js-BufferGeometry (auf three-mesh-bvh). >100x schneller als BSP-basierte Three.js-CSG-Libs. Aber: erklaert selbst, dass Resultate „aufgrund numerischer Praezision nicht garantiert 2-mannigfaltig" sind und verweist fuer CAD-Robustheit ausdruecklich auf Manifold.
|
||||||
|
- Repo: <https://github.com/gkjohnson/three-bvh-csg>
|
||||||
|
- Forum: <https://discourse.threejs.org/t/three-bvh-csg-a-library-for-performing-fast-csg-operations/42713>
|
||||||
|
- **Trade-offs:** Gut fuer Live-Vorschau/visuelles Schneiden, ungeeignet als verlaesslicher Modellierkernel.
|
||||||
|
|
||||||
|
### Empfehlung (Kernel)
|
||||||
|
**replicad (= opencascade.js) als primaerer B-Rep-Kernel im Web Worker; Manifold als schneller Mesh-Boolean-Pfad fuer Import-/Vorschaugeometrie.** Diese Zweiteilung deckt sowohl „exakte BIM-Geometrie + 2D-Ableitung" (OCCT) als auch „schnell + robust auf beliebigen Meshes" (Manifold) ab. three-bvh-csg nur, falls man interaktives Echtzeit-Schneiden visuell braucht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 2D-Ableitung aus 3D: verdeckte Kanten (HLR) + Schnittgenerierung
|
||||||
|
|
||||||
|
Kernfrage des DOSSIER-Ports: aus 3D-Solids saubere 2D-Zeichnungen (sichtbare/verdeckte Kanten, Schnitte) erzeugen — im Browser.
|
||||||
|
|
||||||
|
### Optionen
|
||||||
|
|
||||||
|
**A) OCC HLRBRep via replicad `drawProjection` — empfohlen**
|
||||||
|
OCCT enthaelt zwei HLR-Algorithmen: `HLRBRep_Algo` (exakt, auf der echten B-Rep) und `HLRBRep_PolyAlgo` (auf polyederisierter Naeherung, schneller, aber polygonal). Quellen: <https://dev.opencascade.org/doc/refman/html/class_h_l_r_b_rep.html>, <https://dev.opencascade.org/doc/occt-7.7.0/refman/html/class_h_l_r_b_rep___poly_algo.html>
|
||||||
|
|
||||||
|
replicad macht genau das im Browser nutzbar: `drawProjection(shape, camera)` liefert ein Objekt mit **`{ visible, hidden }`** — getrennte sichtbare und verdeckte Kantenzuege, die man unterschiedlich stylen kann (z.B. verdeckt = gestrichelt). Konkretes Beispiel aus der Doku:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { drawProjection, ProjectionCamera } = replicad;
|
||||||
|
const camera = new ProjectionCamera(corner).lookAt(center);
|
||||||
|
const { visible, hidden } = drawProjection(shape, camera);
|
||||||
|
// visible/hidden sind Drawings -> .toSVG()
|
||||||
|
```
|
||||||
|
- Beispiel: <https://replicad.xyz/docs/examples/projections/>
|
||||||
|
- Verwandte API: `makeProjectedEdges`, `ProjectionCamera`, `Drawing.toSVG()` / `toSVGPaths()` (<https://replicad.xyz/docs/api/classes/Drawing/>)
|
||||||
|
- **Das ist der entscheidende Grund, replicad/OCCT zu nehmen:** exakte verdeckte-Kanten-Berechnung auf echtem B-Rep ist mit Mesh-Tools nicht serioes machbar.
|
||||||
|
- **Trade-offs:** HLR ist rechenintensiv (deshalb Worker + Caching pro Ansicht/Kamera); `HLRBRep_Algo` exakt aber langsam, `PolyAlgo` schneller aber genaehert.
|
||||||
|
|
||||||
|
**B) Schnitte (Sections) via OCCT**
|
||||||
|
Echte Schnitte ueber Schnitt mit einer Ebene/Halbraum (`BRepAlgoAPI_Section` bzw. Boolean mit Schnittkoerper) ergeben exakte Schnittkanten als B-Rep-Edges, die wiederum nach SVG/DXF gehen. In replicad ueber Booleans + Projektion abbildbar.
|
||||||
|
|
||||||
|
**C) Manifold `slice()` / `project()` — schnelle Mesh-Variante**
|
||||||
|
`Manifold.slice(z)` gibt den Querschnitt parallel zur X-Y-Ebene auf Hoehe `z` als `CrossSection` (2D-Polygone, intern Clipper2); `Manifold.project()` die projizierte Aussenkontur. Quellen: <https://manifoldcad.org/docs/jsapi/>, <https://manifoldcad.org/docs/html/classmanifold_1_1_cross_section.html>
|
||||||
|
- **Trade-offs:** liefert **keine** verdeckte/sichtbare-Kanten-Trennung und keine Innenkanten-Semantik wie HLR — nur die geometrische Schnitt-/Projektionskontur des Meshes. Gut fuer Plan-Schnittkonturen (siehe Abschnitt 3), ungenuegend fuer vollwertige Ansichts-Zeichnungen mit verdeckten Kanten.
|
||||||
|
|
||||||
|
### Empfehlung (2D-Ableitung)
|
||||||
|
**HLR und Ansichts-Zeichnungen ueber replicad `drawProjection` (OCC HLRBRep) im Worker, mit Caching pro Kamera/Ansicht. Echte Schnitte ueber OCCT-Section-Boolean. Manifold `slice()` als schneller Pfad nur fuer reine Schnittkonturen (z.B. Plan-Cut auf Mesh-Importen).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Plan-Ansicht: Clipping bei `cutHeight`
|
||||||
|
|
||||||
|
Verhalten: horizontaler Schnitt auf einstellbarer Hoehe (Grundriss), darüber Abschneiden, Schnittflaechen markieren.
|
||||||
|
|
||||||
|
### Optionen
|
||||||
|
|
||||||
|
**A) Three.js Clipping Planes (`clippingPlanes` / `localClippingEnabled`)**
|
||||||
|
Three.js bietet globale (`renderer.clippingPlanes`) und material-lokale (`material.clippingPlanes`) Schnittebenen; `renderer.localClippingEnabled` ist standardmaessig aus (Null-Kosten, bis aktiviert). Quellen: <https://threejs.org/docs/#api/en/materials/Material.clippingPlanes>, <https://threejs.org/examples/webgl_clipping.html>
|
||||||
|
- **Pro:** GPU-seitig, dynamisch (Slider auf `cutHeight` = `plane.constant` aendern, kein Geometrie-Rebuild), sehr fluessig.
|
||||||
|
- **Contra:** Clipping schneidet nur visuell — es entstehen **offene** Querschnitte (keine Deckflaeche). Fuer „Schnittflaeche fuellen/markieren" braucht man entweder einen Stencil-Cap-Trick oder eine echte Schnittkontur (Abschnitt 2). `clipIntersection = true` kann Material-Reinitialisierung pro Frame und FPS-Einbrueche verursachen — moeglichst vermeiden. Quelle: <https://github.com/mrdoob/three.js/issues/18675>
|
||||||
|
|
||||||
|
**B) Object Culling / Sichtbarkeit nach Hoehe**
|
||||||
|
Elemente oberhalb `cutHeight` per Bounding-Box/Etagen-Metadaten ausblenden (`object.visible = false`).
|
||||||
|
- **Pro:** trivial, keine Shader-Kosten, nutzt BIM-Etagensemantik.
|
||||||
|
- **Contra:** grobkoernig (ganze Objekte, kein praeziser Schnitt mitten durch ein Bauteil).
|
||||||
|
|
||||||
|
**C) Echte Schnittgeometrie pro Etage (OCCT/Manifold) fuer den 2D-Plan**
|
||||||
|
Fuer die exportierbare 2D-Grundriss-Zeichnung den echten Schnitt auf `cutHeight` rechnen (OCCT-Section bzw. `Manifold.slice(z)`), Schnittflaechen schraffieren (Abschnitt 6).
|
||||||
|
|
||||||
|
### Empfehlung (Plan-Clipping)
|
||||||
|
**Hybrid:** Im 3D-Viewport **Three.js Clipping Planes** fuer das interaktive Abschneiden (Slider direkt auf `plane.constant`), kombiniert mit **Object-Culling** ueber Etagen-Metadaten fuer Grob-Performance. Schnittflaechen-Caps via Stencil-Technik. Fuer die **exportierbare** 2D-Grundriss-Zeichnung die **echte** Schnittkontur ueber OCCT/Manifold berechnen statt nur GPU-Clipping. `clipIntersection` meiden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Import: DWG/DXF, IFC, STL, OBJ, XYZ-Punktwolken
|
||||||
|
|
||||||
|
### DXF / DWG
|
||||||
|
- **DXF lesen:** `dxf-parser` (gdsestimating) — robust, weit verbreitet, parst DXF-Strings zu JS-Objekten. Repo: <https://github.com/gdsestimating/dxf-parser>. Zum reinen 2D-Anzeigen: `dxf-viewer` (vagran). <https://github.com/vagran/dxf-viewer>
|
||||||
|
- **DWG lesen (binaer!):** `@mlightcad/libredwg-web` — LibreDWG nach WASM, parst **DWG** (und DXF) direkt im Browser/Node ohne Backend. Aktuell v3.x. Repo: <https://github.com/mlightcad/libredwg-web>, npm: <https://www.npmjs.com/package/@mlightcad/libredwg-web>
|
||||||
|
- **Achtung Qualitaet/Limits:** DWG-Parsing ist speicherintensiv (kann >2 GB RAM ziehen); libredwg-web erzwingt WASM-Heap-Limits, sehr grosse DWGs koennen scheitern. Im Default-Build sind DXF-Parsing und DWG-Schreiben deaktiviert, um die WASM-Groesse zu senken — DXF also lieber mit `dxf-parser` lesen. Quelle: <https://medium.com/@mlightcad/parsing-autocad-dwg-files-in-the-browser-without-relying-on-the-backend-9067c5d9abf0>
|
||||||
|
- **Lizenz:** LibreDWG ist **GPL-3.0** — das ist fuer ein proprietaeres Produkt heikel. Wenn das Produkt nicht GPL sein soll, DWG-Import entweder ueber einen separaten Out-of-Process-Konverter kapseln oder Nutzer bitten, vorab nach DXF zu exportieren. **DWG-Lizenzfrage vor Integration klaeren.**
|
||||||
|
- **Referenz-Implementierung:** `cad-viewer` (mlightcad) zeigt vollstaendigen browser-only DXF/DWG-Viewer/Editor. <https://github.com/mlightcad/cad-viewer>
|
||||||
|
|
||||||
|
### IFC (BIM-Kern)
|
||||||
|
- **web-ifc (ThatOpen/engine_web-ifc):** IFC lesen/schreiben in JS „at native speeds" via WASM. De-facto-Standard fuer Open-BIM im Browser. Repo: <https://github.com/ThatOpen/engine_web-ifc>, Doku: <https://thatopen.github.io/engine_web-ifc/docs/>. **Lizenz: MPL-2.0** (datei-basiertes Copyleft, fuer proprietaere Apps i.d.R. unkritisch). Quelle: <https://spdx.org/licenses/MPL-2.0.html>
|
||||||
|
- **ThatOpen Components + Fragments:** Hoehere Ebene — `components` (Tools für BIM-Apps), `fragments` (kompaktes Binaerformat auf Google FlatBuffers). Typisch: ~100 MB IFC -> ~10 MB Fragments, >10x schnelleres Laden; Konvertierung lauft worker-basiert. Doku: <https://docs.thatopen.com/Tutorials/Fragments/Fragments/IfcImporter/>, Repo: <https://github.com/ThatOpen/engine_fragment>. IfcImporter setzt web-ifc (>=0.0.72) voraus.
|
||||||
|
- **Empfehlung:** IFC einmal mit web-ifc parsen, in **Fragments** cachen, danach aus Fragments laden.
|
||||||
|
|
||||||
|
### STL / OBJ / XYZ-Punktwolken
|
||||||
|
- **Standard-Three.js-Loader** decken alles ab: `STLLoader` (ASCII+Binaer), `OBJLoader`, `XYZLoader` (XYZ/XYZRGB -> BufferGeometry), `PCDLoader`. Quellen: <https://threejs.org/docs/#examples/en/loaders/PCDLoader>, <https://deepwiki.com/mrdoob/three.js/4.2-model-format-loaders>. Three.js ist **MIT**.
|
||||||
|
- **Punktwolken-Performance:** Naive Darstellung skaliert nicht — ~17 Mio. Punkte ruckeln deutlich. Strategien: Downsampling, **LOD/Culling**, Hintergrund-/Streaming-Laden. Fuer sehr grosse Wolken Out-of-Core-Octree-Renderer (z.B. Potree-Ansatz) erwaegen statt eines einzelnen `Points`-Objekts. Quellen: <https://discourse.threejs.org/t/render-large-point-cloud-data-in-threejs/57331>, <https://discourse.threejs.org/t/performance-issues-rendering-large-ply-point-cloud-in-three-js-downsampling-and-background-loading/69135>
|
||||||
|
|
||||||
|
### Empfehlung (Import)
|
||||||
|
**IFC: web-ifc + Fragments (ThatOpen).** **DXF: dxf-parser.** **DWG: libredwg-web — aber GPL-Lizenz vorab klaeren / kapseln.** **STL/OBJ/XYZ/PCD: native Three.js-Loader, mit LOD/Downsampling fuer grosse Punktwolken (Potree-Pattern bei Bedarf).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Vektor-Export: SVG -> PDF (Print) und DXF
|
||||||
|
|
||||||
|
### SVG -> PDF
|
||||||
|
- **svg2pdf.js (yWorks) + jsPDF — empfohlen.** Reine JS-Loesung, laeuft im Browser, erhaelt **echte Vektoren** (kein Rasterisieren via html2canvas!), was fuer druckfaehige Plaene entscheidend ist. Integriert sich ueber `doc.svg(element, ...)`. Kompatibel mit jsPDF v2/v3/v4. Repo: <https://github.com/yWorks/svg2pdf.js/>. Lizenz: svg2pdf.js **MIT**, jsPDF **MIT**.
|
||||||
|
- **Trade-off:** SVG-Feature-Abdeckung ist sehr gut, aber nicht 100% — exotische Filter/Pattern koennen abweichen; Schraffuren als explizite Linien (statt CSS-Filter) exportieren erhoeht Treffsicherheit.
|
||||||
|
- **Alternative/ergaenzend:** `pdf-lib` (MIT) fuer Seitenmontage, Mehrseitigkeit, Metadaten, Zusammenfuehren — kann mit jsPDF-Output kombiniert werden. (`html2canvas`+jsPDF bewusst **vermeiden**, da Raster statt Vektor.)
|
||||||
|
|
||||||
|
### DXF-Export
|
||||||
|
- **@tarikjabiri/dxf (dxfjs/writer) — empfohlen.** Moderner, in TypeScript geschriebener DXF-Generator fuer Node + Browser. Unterstuetzt u.a. **Blocks, Hatches, Insert, Image** — d.h. Schraffuren lassen sich als echte DXF-Hatches exportieren (wichtig fuer CAD-Weiterverarbeitung). npm: <https://www.npmjs.com/package/@tarikjabiri/dxf>, Doku: <https://dxf.vercel.app/>, Repo: <https://github.com/tarikjabiri/js-dxf>. Lizenz: MIT.
|
||||||
|
- Einfachere Alternative: `dxf-writer` (Vorlaeufer, weniger Features).
|
||||||
|
|
||||||
|
### Empfehlung (Export)
|
||||||
|
**SVG -> PDF: svg2pdf.js + jsPDF (Vektor, nicht Raster), optional pdf-lib fuer Seitenmontage. DXF-Export: @tarikjabiri/dxf mit echten Hatch-Entities.** Interner Zwischenschritt: 2D-Geometrie als SVG-Paths halten (replicad `Drawing.toSVGPaths()`), daraus sowohl PDF als auch DXF erzeugen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Schraffuren / Muster in SVG/Canvas bei Massstab
|
||||||
|
|
||||||
|
### Optionen & Erkenntnisse
|
||||||
|
- **SVG `<pattern>` mit `patternUnits="userSpaceOnUse"`** ist der richtige Weg fuer massstaebliche Schraffuren: das Muster skaliert NICHT mit der Form (im Gegensatz zu `objectBoundingBox`), sondern bleibt im Weltkoordinaten-Raster — exakt, was man fuer „mm pro Linie im Plan" braucht. Dichte/Winkel ueber `patternTransform`. Quellen: <https://www.w3.org/TR/2015/WD-SVG2-20150915/pservers.html>, <https://www.codegenes.net/blog/simple-fill-pattern-in-svg-diagonal-hatching/>
|
||||||
|
- **Performance:** Pattern-Layer werden bei jedem Layout-/Zoom-Schritt neu gerechnet; `userSpaceOnUse` vermeidet teures Rescaling und ist hier zugleich der schnellere und der korrekte Weg. Quelle: <https://oreillymedia.github.io/Using_SVG/extras/ch19-performance.html>
|
||||||
|
- **SVG vs. Canvas:** Fuer technische Zeichnungen ist SVG qualitativ klar ueberlegen (Vektor, exporttauglich nach PDF/DXF). Canvas wird erst bei sehr vielen einfachen Elementen schneller. Quellen: <https://felt.com/blog/from-svg-to-canvas-part-1-making-felt-faster>, <https://www.yworks.com/blog/svg-canvas-webgl>
|
||||||
|
- **Skalierungs-Strategie:** Bei sehr dichten Schraffuren ueber grosse Flaechen kann die Linienzahl explodieren -> entweder als Pattern-Kachel rendern (eine Definition, vielfach referenziert, statt tausende Einzellinien) **oder** beim Export Schraffuren in echte Linien/Hatch-Entities aufloesen (DXF-Hatch, Abschnitt 5).
|
||||||
|
|
||||||
|
### Empfehlung (Schraffuren)
|
||||||
|
**SVG `<pattern>` mit `patternUnits="userSpaceOnUse"` als primaerer Renderpfad** (massstabskorrekt, exportierbar, performant durch Kachel-Referenzierung). Bei extrem grossen/dichten Flaechen Canvas-Overlay nur fuer die reine Bildschirm-Vorschau erwaegen; fuer Export immer SVG -> svg2pdf.js bzw. echte DXF-Hatches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. WebGPU vs. WebGL + Worker-Offloading der WASM-Geometrie
|
||||||
|
|
||||||
|
### Rendering: WebGPU vs. WebGL
|
||||||
|
- **Reifegrad:** Seit Three.js r171 (Sept. 2025) ist der **WebGPURenderer produktionsreif** mit `import * as THREE from 'three/webgpu'` und **automatischem WebGL2-Fallback** — kein eigener Fallback-Code noetig. Quelle: <https://www.utsubo.com/blog/webgpu-threejs-migration-guide>
|
||||||
|
- **Browser-Abdeckung:** Chrome/Edge 113 (Mai 2023), Safari 26.0 (Sept. 2025), Firefox 141 (Juli 2025). ~95% der Nutzer WebGPU-faehig, restliche ~5% bekommen WebGL2-Fallback. Quelle: <https://vr.org/articles/webgpu-baseline-2026-three-js-webxr-default>
|
||||||
|
- **Performance (nuanciert):** Bei draw-call-lastigen Szenen (viele Bauteile/Etagen) gewinnt WebGPU deutlich (bei ~10'000 Draw-Calls ~50 FPS WebGPU vs. ~30 FPS WebGL); bei wenigen grossen Meshes kann WebGL noch gleichauf oder schneller sein. Compute-Shader (Punktwolken, Culling) sind ein WebGPU-Alleinstellungsmerkmal. Quellen: <https://medium.com/@sudenurcevik/upgrading-performance-moving-from-webgl-to-webgpu-in-three-js-4356e84e4702>, <https://altersquare.io/three-js-vs-webgpu-2026-large-scale-construction-viewers/>
|
||||||
|
- **Vorsicht:** Es gibt weiterhin Szenarien, in denen WebGPU langsamer ist als WebGL — daher messen, nicht blind migrieren. Quelle: <https://github.com/mrdoob/three.js/issues/31055>
|
||||||
|
|
||||||
|
### Worker-Offloading der WASM-Geometrie
|
||||||
|
- **Pflicht, nicht optional:** OCCT/replicad-Berechnungen (Booleans, HLR, Section) gehoeren in einen **Web Worker**, sonst blockiert die UI. replicad ist genau dafuer gebaut (WASM im Worker, Mesh zurueck an den Main-Thread). Quelle: <https://replicad.xyz/docs/use-as-a-library/>
|
||||||
|
- **Muster:** Worker laedt das (grosse) WASM einmal; Kommunikation via Comlink o.ae.; Geometrie als Transferable (ArrayBuffer) zuruecksenden, um Kopierkosten zu sparen. Manifold (WASM) ebenso im Worker betreiben; Manifold ist intern stark parallelisiert.
|
||||||
|
|
||||||
|
### Empfehlung (Performance)
|
||||||
|
**Three.js `three/webgpu`-Renderer mit automatischem WebGL2-Fallback** (gratis Abwaertskompatibilitaet, Vorteil bei vielen Draw-Calls/Etagen). **Alle WASM-Geometrie (OCCT/replicad + Manifold) konsequent in Web Worker(n)**, Ergebnis als Transferables. WebGPU-Compute fuer Punktwolken-/Culling-Beschleunigung als spaeteres Optimierungs-Upside. Vor groesserer WebGPU-Optimierung mit der echten Szene benchmarken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfehlung (Zusammenfassung)
|
||||||
|
|
||||||
|
| Thema | Wahl | Warum |
|
||||||
|
|---|---|---|
|
||||||
|
| **Geometriekernel (Solids/Booleans)** | **replicad** (= opencascade.js/OCCT) im Worker; **Manifold** als Mesh-Boolean-Ergaenzung | Echter B-Rep-Kernel noetig fuer exakte BIM-Geometrie + 2D-Ableitung; Manifold (Apache-2.0) schnell+robust fuer Mesh-Importe/Vorschau |
|
||||||
|
| **2D-Ableitung (HLR/Schnitt)** | **replicad `drawProjection`** (OCC HLRBRep, liefert `{visible, hidden}`); OCCT-Section fuer Schnitte | Einziger seriöser Weg fuer verdeckte/sichtbare Kanten auf echtem B-Rep im Browser; Manifold `slice()` nur fuer reine Konturen |
|
||||||
|
| **Plan-Clipping (`cutHeight`)** | **Three.js Clipping Planes** + **Object-Culling** (Etagen); echte Schnittkontur (OCCT/Manifold `slice`) fuer Export | GPU-Clipping fluessig & dynamisch fuer Viewport; Culling fuer Grob-Performance; exakte Kontur nur fuer druckbaren Plan |
|
||||||
|
| **Import IFC** | **web-ifc + Fragments** (ThatOpen) | De-facto Open-BIM-Standard, native Speed, ~10x kleineres/schnelleres Fragments-Caching; MPL-2.0 |
|
||||||
|
| **Import DXF / DWG** | **dxf-parser** (DXF) / **libredwg-web** (DWG) | Bewaehrt & browser-only; **DWG = GPL-3.0 -> Lizenz vorab klaeren/kapseln**, RAM-Limits bei grossen Dateien |
|
||||||
|
| **Import STL/OBJ/XYZ/PCD** | **Native Three.js-Loader** + LOD/Downsampling | Out of the box (MIT); grosse Punktwolken brauchen Octree/Potree-Pattern |
|
||||||
|
| **SVG -> PDF (Print)** | **svg2pdf.js + jsPDF** (+ pdf-lib optional) | Echte Vektoren statt Raster -> druckfaehig; MIT-Lizenzen |
|
||||||
|
| **DXF-Export** | **@tarikjabiri/dxf** | TS, Browser-faehig, echte Hatch-/Block-Entities fuer CAD-Weiterverarbeitung; MIT |
|
||||||
|
| **Schraffuren/Muster** | **SVG `<pattern>` mit `userSpaceOnUse`** | Massstabskorrekt (skaliert nicht mit Form), exportierbar, performant via Kachel-Referenz |
|
||||||
|
| **Rendering** | **Three.js `three/webgpu`** mit WebGL2-Fallback | Produktionsreif seit r171, ~95% Abdeckung, Vorteil bei vielen Draw-Calls; gratis Fallback |
|
||||||
|
| **WASM-Offloading** | **Web Worker** fuer OCCT/replicad + Manifold, Transferables | UI bleibt reaktiv; replicad ist dafuer gebaut; spart Kopierkosten |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Wichtigste Risiken / offene Punkte
|
||||||
|
1. **DWG-Lizenz (LibreDWG = GPL-3.0):** Vor Integration klaeren — sonst proprietaeres Produkt gefaehrdet. Option: separater Konvertierungs-Service oder DXF-Pflicht beim Import.
|
||||||
|
2. **OCCT-WASM-Groesse & Build-Pflege:** zweistellige MB; Custom-Build/Tree-Shaking und Lazy-Loading im Worker einplanen.
|
||||||
|
3. **HLR-Kosten:** `drawProjection` pro Ansicht cachen; ggf. `PolyAlgo` fuer schnelle Vorschau, `HLRBRep_Algo` fuer den finalen Plan.
|
||||||
|
4. **WebGPU nicht blind:** mit echter Szene benchmarken — es gibt Faelle, in denen WebGL noch fuehrt.
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
# UX/UI-Patterns moderner Browser-CAD/BIM-Tools — Research & Leitplanken
|
||||||
|
|
||||||
|
> Recherche für unser Browser-BIM (React + TS + Three.js, DOSSIER-Port, Wohnbau,
|
||||||
|
> CH/SIA-Kontext). Ziel: konkrete, **übernehmbare** Interaktions- und UI-Muster.
|
||||||
|
> Stand: 2026-06-29.
|
||||||
|
>
|
||||||
|
> Untersucht: **Arcol**, **Snaptrude**, **TestFit**, **Onshape** (Browser-CAD),
|
||||||
|
> **Vectorworks** (Resource/Navigation-Modell), **Figma** (Canvas-Interaktion,
|
||||||
|
> Inspector). Querschnitt: Snapping/Inferencing, Grip-Editing, perceived
|
||||||
|
> performance, Command-Palette, Onboarding.
|
||||||
|
>
|
||||||
|
> Jeder externe Claim ist mit Quelle verlinkt. Am Ende:
|
||||||
|
> **„Leitplanken für unsere UI"** — priorisierte Empfehlungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Kurzfazit (TL;DR)
|
||||||
|
|
||||||
|
Die ganze Klasse moderner Browser-CAD/BIM-Tools konvergiert auf ein **gemeinsames
|
||||||
|
Set von Mustern**, das wir fast 1:1 übernehmen sollten:
|
||||||
|
|
||||||
|
1. **Ein Modell, viele synchrone Sichten** (2D-Plan ⇄ 3D ⇄ Daten/Sheets), Änderung
|
||||||
|
in einer Sicht propagiert sofort in alle anderen. (Arcol, Snaptrude)
|
||||||
|
2. **Kontextuelle UI** statt voller Werkzeugleisten: Buttons/Felder erscheinen nur,
|
||||||
|
wenn die aktuelle Auswahl sie erlaubt. (Arcol, Onshape, Figma)
|
||||||
|
3. **Drei-Zonen-Layout**: links Navigator/Layer-Baum, Mitte Canvas + schwebende
|
||||||
|
Tool-Palette, rechts Inspector. (Figma, Vectorworks, Onshape)
|
||||||
|
4. **Aggressives Snapping/Inferencing** mit Live-Glyphen + Modifier zum
|
||||||
|
Unterdrücken. (Onshape) — für Maus-im-Browser unverzichtbar.
|
||||||
|
5. **Direkte Manipulation per Grips** statt Dialogen. (Figma, DOSSIER-Backlog)
|
||||||
|
6. **Perceived performance** über Skeletons, optimistic UI und gescopte
|
||||||
|
Ladezustände — im Browser-3D-Kontext ein Differenzierungs-Hebel.
|
||||||
|
7. **Command-Palette (Ctrl/Cmd-K)** als Discovery- und Speed-Layer.
|
||||||
|
8. **Onboarding via „learn by doing"** an einem mitgelieferten Sample-Projekt +
|
||||||
|
progressive disclosure.
|
||||||
|
|
||||||
|
Unsere bestehende Architektur (semantisches Modell als Single Source of Truth,
|
||||||
|
abgeleitete Sichten, Vectorworks-Terminologie) ist exakt der richtige Unterbau für
|
||||||
|
diese Muster — die meiste Arbeit liegt in der **UI-Schicht**, nicht im Datenmodell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Gesamtlayout & Navigator-/Layer-Panels
|
||||||
|
|
||||||
|
### 1.1 Was die Tools machen
|
||||||
|
|
||||||
|
**Figma** strukturiert die Fläche in vier Zonen: eine Toolbar, **zwei Panels** und
|
||||||
|
einen scrollbaren Canvas. Links das **Navigation-Panel** mit Layern und Pages,
|
||||||
|
rechts das **Properties-Panel**; der Layer-Baum „enthält und organisiert alle
|
||||||
|
Elemente auf dem Canvas … und zeigt, wie Elemente verbunden sind"
|
||||||
|
([Figma: left sidebar](https://help.figma.com/hc/en-us/articles/360039831974-View-layers-and-pages-in-the-left-sidebar),
|
||||||
|
[Figma: Interface](https://www.inthepocket.design/course/figma-for-everyone/the-interface)).
|
||||||
|
|
||||||
|
**Vectorworks** trennt sauber zwei Konzepte, die für uns 1:1 relevant sind:
|
||||||
|
- Die **Navigation Palette** gibt Zugriff auf *Classes, Design Layers, Sheet
|
||||||
|
Layers, Viewports, Saved Views, References* — jeweils als eigener Tab mit Liste.
|
||||||
|
Sichtbarkeit wird per Klick in einer **Visibility-Spalte** gesetzt; Doppelklick
|
||||||
|
**aktiviert** einen Layer/eine Class. Die Zeichenfläche bleibt nutzbar, während
|
||||||
|
die Palette offen ist
|
||||||
|
([VW Navigation Palette](https://app-help.vectorworks.net/2022/eng/VW2022_Guide/Structure/The_Navigation_palette.htm)).
|
||||||
|
- Paletten sind **andockbar/ein-/ausblendbar pro Workspace**
|
||||||
|
([VW Palettes & Tool Sets](https://app-help.vectorworks.net/2018/eng/VW2018_Guide/Start/Palettes_and_Tool_Sets.htm)).
|
||||||
|
|
||||||
|
**Arcol** baut beim Modellieren einen **Model Tree** im Menü auf — pro
|
||||||
|
BIM-Komponente wächst der Baum mit
|
||||||
|
([AEC Magazine: Arcol BIM in browser](https://aecmag.com/bim/arcol-bim-cloud-browser/)).
|
||||||
|
|
||||||
|
**Onshape** zeigt links den **Feature-/Assembly-Baum** (parametrische Historie:
|
||||||
|
Sketches, Features, Mates) — die Bauhistorie ist die primäre Navigation
|
||||||
|
([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm)).
|
||||||
|
|
||||||
|
### 1.2 Übernahme für uns
|
||||||
|
|
||||||
|
Unser Dokumentmodell hat **zwei unabhängige Achsen** (siehe ROADMAP §2c):
|
||||||
|
**Zeichnungsebenen** (Geschosse + Schnitte/Ansichten) und **Ebenen**
|
||||||
|
(Grafik-Kategorien-Baum `00 Raster … 80 Plangrafik`). Das mappt fast wörtlich auf
|
||||||
|
das Vectorworks-Navigations-Modell:
|
||||||
|
|
||||||
|
- **Linke Sidebar, getabbt** wie die VW-Navigation-Palette:
|
||||||
|
- Tab **„Geschosse / Ansichten"** (= unsere Zeichnungsebenen): EG/1OG/…,
|
||||||
|
Schnitte, Ansichten. Mit **Visibility-Toggle**, **Lock**, und **Doppelklick =
|
||||||
|
aktiv setzen** (welches Geschoss editiert wird).
|
||||||
|
- Tab **„Ebenen"** (= Grafik-Kategorien-Baum, in *jedem* Geschoss gleich): Baum
|
||||||
|
mit Code, Name, Farb-Swatch, Linienstärke, Visibility, Hatch. Pro Ansicht
|
||||||
|
schaltbar (das ist genau VWs „Sichtbarkeit pro Viewport/Saved View").
|
||||||
|
- Tab **„BIM-Tree / Elemente"** (aus DOSSIER-Backlog §11: *Element-Übersicht
|
||||||
|
Geschoss→Kind→Element, Suche, Shift-Klick = Zoom*). Das ist unser Pendant zu
|
||||||
|
Arcols Model Tree + Onshapes Feature-Baum.
|
||||||
|
- **Visibility-Spalte als Erstklass-Interaktion** (VW-Muster): Auge-Icon je Zeile,
|
||||||
|
Klick togglet sofort, kein Dialog. (Wir haben bereits `EyeIcon.tsx` — das ist die
|
||||||
|
Keimzelle.)
|
||||||
|
- **Wichtig:** Geschoss wird **im 3D *oder* Plan** betrachtet, *keine* getrennten
|
||||||
|
Daten — der View-Umschalter (siehe §2) gehört in die Geschoss-Auswahl, nicht in
|
||||||
|
separate Dokumente.
|
||||||
|
|
||||||
|
> **Anti-Pattern vermeiden:** Vectorworks selbst leidet unter
|
||||||
|
> **Paletten-Wildwuchs** (viele frei schwebende Fenster). Für ein fokussiertes
|
||||||
|
> Wohnbau-Tool: **feste 3-Zonen-Shell** (links Navigator, rechts Inspector), nicht
|
||||||
|
> N frei schwebende Palettenfenster. Figmas Striktheit schlägt VWs Flexibilität für
|
||||||
|
> unsere Zielgruppe (Architekt:innen, die schnell ein EFH zeichnen wollen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 3D ⇄ Plan-View-Umschaltung (das Herzstück)
|
||||||
|
|
||||||
|
### 2.1 Was die Tools machen
|
||||||
|
|
||||||
|
- **Snaptrude:** Nutzer arbeiten **in 2D *und* 3D gleichzeitig**; Änderung in einer
|
||||||
|
Sicht spiegelt sich automatisch in der anderen. Objekte sind **nach Geschossen
|
||||||
|
klassifiziert**, was es erlaubt, „3D-Geometrie zu zeichnen, während man in einer
|
||||||
|
2D-Grundriss-Ansicht arbeitet". Push-an-einer-Fläche „fühlt sich an wie eine
|
||||||
|
Linie ziehen", berechnet aber sofort Flächen/BIM-Daten neu
|
||||||
|
([ArchDaily: Snaptrude](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work),
|
||||||
|
[Snaptrude](https://www.snaptrude.com/)).
|
||||||
|
- **Arcol:** „Every view is 3D in Arcol" — und **Boards** (Präsentations-Layouts)
|
||||||
|
sind **live-synced**: ändert sich das Gebäude, aktualisieren sich die Layouts
|
||||||
|
automatisch (kein statischer PDF-Export)
|
||||||
|
([AEC Magazine: Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)).
|
||||||
|
- **TestFit:** explizites **Expand/Collapse von Optionen** — man klappt Varianten
|
||||||
|
auf zum Vergleichen und wieder zu, um sich aufs Detail-Editieren im Canvas zu
|
||||||
|
konzentrieren („smoother flow between setting up a site, reviewing options, and
|
||||||
|
refining a design")
|
||||||
|
([TestFit 5.19](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)).
|
||||||
|
|
||||||
|
### 2.2 Übernahme für uns
|
||||||
|
|
||||||
|
Das ist **genau unsere Kern-Architektur** (ROADMAP §2c: „Ansichtstyp = Kamera-
|
||||||
|
Projektion + optionaler Schnitt"). Konkrete UI-Muster:
|
||||||
|
|
||||||
|
- **View-Switcher als segmented control** direkt am Canvas (oben links oder
|
||||||
|
oben mittig): `3D | Grundriss | Schnitt | Ansicht`. Plan-View = orthogonale
|
||||||
|
Top-Kamera + Clipping-Ebene auf `okff + schnitthöhe` (steht bereits so im Modell).
|
||||||
|
- **Kein** Moduswechsel der *Daten*, nur der **Kamera + Schnitt** — visuell als
|
||||||
|
weicher Übergang (Kamera-Animation) kommunizieren, damit Nutzer Orientierung
|
||||||
|
behalten (Snaptrude/Arcol-Gefühl: „dieselbe Sache aus anderem Winkel").
|
||||||
|
- **Optional, stark differenzierend:** **Split-View** (3D links, Plan rechts) wie
|
||||||
|
Snaptrudes „2D + 3D simultan". Da unsere Sichten ohnehin reaktiv aus *einem*
|
||||||
|
Modell abgeleitet werden (Zustand-Store → SVG-Plan + Three-Scene), ist Split-View
|
||||||
|
technisch billig und ein Wow-Moment im Onboarding.
|
||||||
|
- **Kamera-Presets** (aus DOSSIER-Backlog): Kardinal N/O/S/W, Iso-Oktanten,
|
||||||
|
**Norden-Rotation** (CH/Swisstopo-Georeferenz) — als kleine Würfel-/Kompass-Gizmo
|
||||||
|
oben rechts im 3D (ViewCube-Muster, bekannt aus Onshape/CAD allgemein
|
||||||
|
([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm))).
|
||||||
|
- **Drawing Layers (Schnitte/Ansichten) sind „Saved Views"**: Auswahl in der linken
|
||||||
|
Sidebar = Kamera + Schnitt + Maßstab + Layer-Kombination springt an (VW „Saved
|
||||||
|
Views" / DOSSIER „Ausschnitte"). Das ersetzt Ordner-Wildwuchs bei 50+ Ansichten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Zeichnen & Editieren: Snapping, Inferencing, Grips, Tool-Paletten
|
||||||
|
|
||||||
|
### 3.1 Snapping / Inferencing — das wichtigste Detail im Browser
|
||||||
|
|
||||||
|
**Onshape** ist hier die Referenz (sehr konkret dokumentiert,
|
||||||
|
[Onshape Automatic Inferencing](https://cad.onshape.com/help/Content/Sketch/automatic_inferencing.htm)):
|
||||||
|
|
||||||
|
- **Inference-Typen:** horizontal, vertikal, **midpoint**, parallel, coincident,
|
||||||
|
Ausrichtung zum Origin/zu anderen Entities, Tangente, Perpendikular.
|
||||||
|
- **Visuelles Feedback:**
|
||||||
|
- **gelbe Highlights** auf Vertices/Mittelpunkten beim Hovern,
|
||||||
|
- **orange gestrichelte Linie** zeigt eine vorgeschlagene H/V-Ausrichtung,
|
||||||
|
- **orange Highlight** verwandter Geometrie beim Ziehen (relationales Feedback).
|
||||||
|
- **Steuerung:** Linksklick **akzeptiert** die vorgeschlagene Bedingung; **Shift
|
||||||
|
gedrückt halten unterdrückt** Inferencing temporär (loslassen = wieder an).
|
||||||
|
- **„Wake-up"-Inferences:** kurzes Verweilen über einer Geometrie „weckt" deren
|
||||||
|
Bezugslinien (z. B. erst Mittelpunkt antippen, dann woanders zeichnen → bekommt
|
||||||
|
Ausrichtung zu diesem Mittelpunkt).
|
||||||
|
- **Post-hoc:** vorhandene Geometrie ziehen triggert erneut Inferencing (Center
|
||||||
|
eines Kreises vertikal zum Origin ziehen → fügt automatisch vertikale Constraint).
|
||||||
|
|
||||||
|
**Constraint-Sichtbarkeit:** Constraint-Icons sind farbcodiert (blau =
|
||||||
|
externe/Referenz, weiß = intern) und ein-/ausblendbar
|
||||||
|
([Onshape Working with Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)).
|
||||||
|
|
||||||
|
### 3.2 Übernahme für uns (Snapping)
|
||||||
|
|
||||||
|
Für ein Maus-bedientes Browser-Tool ist **gutes Snapping der Unterschied zwischen
|
||||||
|
„Spielzeug" und „Werkzeug".** Konkret:
|
||||||
|
|
||||||
|
- **Snap-Targets** (Wohnbau-relevant): Wand-Endpunkte/Achsen, Wand-Mittelpunkte,
|
||||||
|
Rechtwinklig/Parallel zu bestehender Wand, **Raster** (Achsraster `00 Raster`),
|
||||||
|
Öffnungs-Achsen, vorhandene 2D-Linien-Endpunkte, Schnittpunkte.
|
||||||
|
- **Feedback exakt wie Onshape übernehmen:** Snap-Glyph am Cursor (●
|
||||||
|
Endpunkt, △ Mitte, ⟂ rechtwinklig), **gestrichelte Hilfslinie** für
|
||||||
|
Achsen-Alignment, Hover-Highlight des Snap-Ziels.
|
||||||
|
- **Modifier:** **Shift unterdrückt Snapping** (Onshape-Konvention) — Nutzer
|
||||||
|
erwarten das bereits aus anderen Tools. Zusätzlich **Ortho-Modus** (z. B. Shift
|
||||||
|
für 0/45/90° beim Linienziehen — Figma-Konvention) sauber davon trennen oder per
|
||||||
|
Toggle.
|
||||||
|
- **Live-Maßeingabe beim Zeichnen** (CAD-Standard, auch Onshape): während des
|
||||||
|
Ziehens Länge/Winkel tippbar (Tab zwischen Feldern). Das ersetzt nachträgliches
|
||||||
|
Dimensionieren und ist für Architekt:innen Pflicht.
|
||||||
|
- Wir brauchen **kein** volles Constraint-Solver-System wie Onshape (mechanisches
|
||||||
|
parametrisches CAD). BIM-Wände sind achs-basiert; **Inferencing beim Setzen**
|
||||||
|
genügt, persistente geometrische Constraints sind Overkill für Wohnbau.
|
||||||
|
|
||||||
|
### 3.3 Grip-Editing / direkte Manipulation
|
||||||
|
|
||||||
|
- **Figma:** Auswahl zeigt Bounding-Box mit **Resize-Handles**; ziehen
|
||||||
|
manipuliert direkt; Smart-Guides/Maße erscheinen relativ zu Nachbarn beim Bewegen
|
||||||
|
([Figma right sidebar](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)).
|
||||||
|
- **Snaptrude:** Push/Pull an Flächen als primäre 3D-Edit-Geste
|
||||||
|
([ArchDaily](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work)).
|
||||||
|
- **DOSSIER-Backlog (§11)** listet **Grip-Editing** (Wand-Endpunkte,
|
||||||
|
Schnitt-Symbole im Plan) explizit als Aufwand L, Phase 3–4 — und das **9-Punkt-
|
||||||
|
Objekt-Info** (lesen + verschieben/skalieren/rotieren direkt).
|
||||||
|
|
||||||
|
**Übernahme:** Grips sind der wichtigste „pro feel"-Hebel.
|
||||||
|
- **Wand-Endpunkt-Grips** im Plan *und* 3D, mit Snapping (s. o.) und Live-Maß.
|
||||||
|
- **Öffnungs-Grips** (Position entlang Wand, Breite) — Host-Beziehung bleibt erhalten.
|
||||||
|
- **Schnittlinien-Grips im Plan** (Schnittlinie ziehen → Schnitt-Ansicht
|
||||||
|
re-deriviert) — das ist Grip-Editing über die Sicht-Grenze hinweg, ein starkes
|
||||||
|
Differenzierungsmerkmal.
|
||||||
|
- Selektion → **bounding handles** (Figma-Muster) für 2D-Plangrafik (Linien,
|
||||||
|
Rechtecke, Text).
|
||||||
|
|
||||||
|
### 3.4 Tool-Palette & kontextuelle Werkzeuge
|
||||||
|
|
||||||
|
**Kontextuelle UI ist das durchgehende Muster:**
|
||||||
|
- **Arcol:** „buttons appear only when they can be used" — Loft/Sweep erscheinen bei
|
||||||
|
Auswahl zweier Sketches, Boolean bei zwei Extrusions
|
||||||
|
([AEC: Arcol sneak peek](https://aecmag.com/bim/arcol-a-sneak-peek/)).
|
||||||
|
- **Onshape:** Sketch-Toolbar erscheint beim Betreten des Sketch-Modus; Tools in
|
||||||
|
Gruppen (vertikale Trennlinien), Dropdown-Pfeile für Varianten; Dialoge mit
|
||||||
|
**blau hinterlegtem Feld**, das eine Auswahl im Graphics-Bereich verlangt
|
||||||
|
([Onshape sketch toolbar](https://cad.onshape.com/help/Content/Sketch/sketch_basics.htm),
|
||||||
|
[Onshape Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)).
|
||||||
|
- **Figma:** schmale Bottom-/Top-Toolbar mit den Kern-Tools; alles Weitere
|
||||||
|
kontextuell rechts.
|
||||||
|
|
||||||
|
**Übernahme:**
|
||||||
|
- **Schlanke Tool-Palette** (Figma-artig), gruppiert nach unserer Domäne:
|
||||||
|
*Wand · Tür/Fenster · Decke · Treppe · Dach · Raum* (BIM) und *Linie · Polylinie
|
||||||
|
· Rechteck · Kreis · Bogen · Text · Bemaßung* (2D auf `80 Plangrafik`).
|
||||||
|
- **Modus-bewusste Tools:** im Grundriss andere Defaults als im 3D; im
|
||||||
|
Schnitt/Ansicht nur Annotation/2D-Tools.
|
||||||
|
- **Contextual action bar bei Auswahl** (Arcol-Muster): selektiere eine Wand →
|
||||||
|
schwebende Mini-Toolbar „Tür einsetzen / Fenster / Wandtyp / verschneiden".
|
||||||
|
Selektiere zwei Wände → „verschneiden / verlängern".
|
||||||
|
- **Aktives Tool sticky + ESC bricht ab**, Leertaste = Pan, Scroll = Zoom
|
||||||
|
(Figma/CAD-Konventionen — Nutzer bringen Muskelgedächtnis mit).
|
||||||
|
- **LoD-bewusste Tool-/Stil-UI** (DOSSIER-Backlog: *zeigt nur passende Controls je
|
||||||
|
Geometrie-Typ*) — keine Füll-Optionen bei 3D-Auswahl. Deckt sich mit Figmas
|
||||||
|
„controls appear based on layer type".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Property-/Inspector-Panel
|
||||||
|
|
||||||
|
### 4.1 Was die Tools machen
|
||||||
|
|
||||||
|
**Figma — rechte Sidebar** (für uns das Vorbild,
|
||||||
|
[Figma right sidebar](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)):
|
||||||
|
- Tabs **Design / Prototype** (bei Edit), **Inspect / Properties** (bei View-only).
|
||||||
|
- Kategorien: **Alignment/Rotation/Position → Dimensions → Constraints/Layout →
|
||||||
|
Appearance (Fill, Stroke, Effects) → Export**.
|
||||||
|
- **Controls erscheinen je Layer-Typ** (kontextuell).
|
||||||
|
- **Dev-Mode/Inspect** liefert konkrete Werte + Abstände zwischen Objekten + Code.
|
||||||
|
|
||||||
|
**Arcol — rechtes Panel** zeigt **Gebäude-Metriken** kontextuell: Geschossfläche,
|
||||||
|
Anzahl Geschosse, GFZ/FAR, Unit-Count, Standortfläche, Kostenschätzung
|
||||||
|
([Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)).
|
||||||
|
|
||||||
|
**TestFit — ein zentrales Parameter-Panel:** „alle Schlüsselparameter an einem
|
||||||
|
Ort" (Unit-Counts, Parkplatz-Ziele, Gebäudegrößen-Limits) → sofortige Wirkung auf
|
||||||
|
generierte Optionen
|
||||||
|
([TestFit 5.19](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)).
|
||||||
|
|
||||||
|
**Onshape — Feature-Dialoge:** Erstellen/Editieren über Dialoge mit Pflicht-
|
||||||
|
Selektionsfeldern (blau)
|
||||||
|
([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm)).
|
||||||
|
|
||||||
|
### 4.2 Übernahme für uns
|
||||||
|
|
||||||
|
- **Rechter Inspector, kontextuell nach Element-Typ** (Figma-Muster):
|
||||||
|
- **Wand** → Wandtyp (→ WallType-Bibliothek), Referenzlage mid/left/right
|
||||||
|
(DOSSIER-Backlog), Höhe, Achs-Endpunkte (9-Punkt/Maße), Stil-Overrides.
|
||||||
|
- **Tür/Fenster** → Breite/Höhe, Brüstung, Schwenkbogen/Anschlag, Detailgrad,
|
||||||
|
Symbol.
|
||||||
|
- **Decke/Slab** → SlabType, UK/OK-Override, Aussparungen.
|
||||||
|
- **Raum** → SIA-416-Kategorie (HNF/NNF/VF/FF/GF/AGF), Fläche (read-only,
|
||||||
|
berechnet), Stempel-Felder.
|
||||||
|
- **2D-Element** → Linienstil (→ Line Manager), Hatch (→ Hatch Manager), Farbe.
|
||||||
|
- **Sektionen kollabierbar** (Figma) — Wohnbau-Inspector kann lang werden;
|
||||||
|
Default-Sektionen offen, Fortgeschrittenes (Overrides) zugeklappt
|
||||||
|
(= progressive disclosure, s. §7).
|
||||||
|
- **Mixed-value-Handling bei Mehrfachauswahl** (Figma): bei Mehrfachauswahl
|
||||||
|
abweichende Werte als „Mixed/—" zeigen, gemeinsames Editieren erlauben. Wichtig
|
||||||
|
z. B. „alle EG-Wände auf Wandtyp X".
|
||||||
|
- **Live-Metriken-Block** (Arcol-Muster) — selbst im Wohnbau wertvoll:
|
||||||
|
Bruttogeschossfläche, **SIA-416-Bilanz**, Raumzahl, Volumen. Im Inspector wenn
|
||||||
|
nichts selektiert ist = „Projekt-Übersicht" (vgl. Figmas Canvas-Level-Optionen
|
||||||
|
bei leerer Auswahl).
|
||||||
|
- **Read-only-Felder klar markieren** (berechnete Flächen/Volumen) vs. editierbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Resource-Manager (Bibliotheken)
|
||||||
|
|
||||||
|
### 5.1 Was Vectorworks macht — unser direktes Vorbild
|
||||||
|
|
||||||
|
Der **Resource Manager** ist „ein zentraler Ort für Assets" (Symbole, Linientypen,
|
||||||
|
Texturen, Materialien …)
|
||||||
|
([VW Resource Manager](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource%20Manager.htm)):
|
||||||
|
- **Zwei-/Drei-Pane-Modell:** **File-Browser** (offene Dateien, Favoriten,
|
||||||
|
VW-Libraries, User-/Workgroup-Libraries) → **Resource-Viewer** (Ressourcen der
|
||||||
|
gewählten Datei) → optional **Preview/Metadaten**
|
||||||
|
([VW File browser pane](https://app-help.vectorworks.net/2023/eng/VW2023_Guide/ResourceManager/Resource_Manager_File_browser_pane.htm),
|
||||||
|
[VW Resource viewer pane](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource_Manager_Resource_viewer_pane.htm)).
|
||||||
|
- **Organisation mehrdimensional:** nach Quelle, **nach Typ** (Dropdown-Filter),
|
||||||
|
nach Ordnerstruktur.
|
||||||
|
- **Ansichten:** Thumbnails / List / Thumbnails-List; **Suchfeld** mit Filtern.
|
||||||
|
- **Resource Selector**: dieselbe Bibliothek erscheint **in Dialogen** und zeigt
|
||||||
|
dort nur **kontextuell passende** Ressourcen.
|
||||||
|
|
||||||
|
### 5.2 Übernahme für uns
|
||||||
|
|
||||||
|
Unsere ROADMAP §2d definiert bereits **Line Manager / Hatch Manager / Component
|
||||||
|
Manager**, mit Verweis-per-id-Architektur (Components → Hatches → LineStyles). Das
|
||||||
|
Vectorworks-Modell passt perfekt:
|
||||||
|
|
||||||
|
- **Ein gemeinsames Resource-Browser-Pattern** für alle Bibliotheken (Linienstile,
|
||||||
|
Schraffuren, Components/Baustoffe, später Wand-/Öffnungs-Stil-Kataloge,
|
||||||
|
Material-PBR, Raumstempel-Layouts). Eine wiederverwendbare React-Komponente,
|
||||||
|
parametrisiert nach Ressourcentyp.
|
||||||
|
- **Zwei Erscheinungsformen** (wie VW):
|
||||||
|
1. **Manager-Ansicht** (großes Panel/Modal) zum Anlegen/Editieren/Duplizieren.
|
||||||
|
2. **Inline-Resource-Selector** im Inspector — beim Setzen eines Wandtyps/Hatch
|
||||||
|
öffnet sich ein kompakter Picker mit Thumbnails, gefiltert auf den passenden
|
||||||
|
Typ. (Figma macht das analog mit „Styles/Variables".)
|
||||||
|
- **Thumbnails sind im CAD-Kontext kritisch**: Hatch-Vorschau, Component-
|
||||||
|
Schichtaufbau, Linienstil-Strich als gerenderte Mini-Previews.
|
||||||
|
- **Zentrale Änderung propagiert** (unsere id-Referenz-Architektur): Component-Farbe
|
||||||
|
ändern → alle Wände mit diesem Component aktualisieren live. Das ist Arcols
|
||||||
|
„single source of truth" auf Ressourcen-Ebene.
|
||||||
|
- **Cross-Projekt-Presets/Favoriten** (DOSSIER-Backlog, LocalStorage; VW-Favoriten):
|
||||||
|
„einmal speichern, überall nutzen".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Perceived Performance (gefühlte Geschwindigkeit)
|
||||||
|
|
||||||
|
Browser-3D + WASM-Geometrie (web-ifc, OpenCascade) + HLR-Projektion = **echte
|
||||||
|
Latenz** an mehreren Stellen. Gefühlte Performance ist hier ein
|
||||||
|
Differenzierungs-Hebel gegenüber schwerfälligem Revit/ArchiCAD.
|
||||||
|
|
||||||
|
### 6.1 Belegte Muster
|
||||||
|
|
||||||
|
- **Skeleton-Screens** lassen Apps **20–30 % schneller** wirken als Spinner bei
|
||||||
|
identischer realer Ladezeit
|
||||||
|
([LogRocket: skeleton screens](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/),
|
||||||
|
[UI Deploy](https://ui-deploy.com/blog/skeleton-screens-vs-spinners-optimizing-perceived-performance)).
|
||||||
|
- **Indikator zur Situation passen:** Spinner für kurze Waits, Skeleton für
|
||||||
|
Content, Progress-Bar für messbare Operationen, **optimistic UI** für
|
||||||
|
„instant-feeling" Aktionen
|
||||||
|
([Onething: skeleton vs spinner](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)).
|
||||||
|
- **Optimistic UI**: UI sofort aktualisieren, Server-Bestätigung abwarten, nur bei
|
||||||
|
Fehler zurückrollen — ideal für häufige, risikoarme Aktionen
|
||||||
|
([Smart Interface Design Patterns](https://smart-interface-design-patterns.com/articles/designing-better-loading-progress-ux/)).
|
||||||
|
- **Verzögerung vor Indikator (100–200 ms):** schließt die Operation vorher ab,
|
||||||
|
gar kein Indikator → kein Flackern
|
||||||
|
([Onething](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)).
|
||||||
|
- **Gescopte Ladezustände** (React Suspense / Next loading.tsx): nur der betroffene
|
||||||
|
Bereich lädt, der Rest bleibt interaktiv; `aria-busy`, Live-Regions,
|
||||||
|
reduced-motion respektieren
|
||||||
|
([LogRocket](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/)).
|
||||||
|
|
||||||
|
### 6.2 Übernahme für uns (konkret)
|
||||||
|
|
||||||
|
- **Optimistic Model-Edits:** Geometrie-Mutation (Wand ziehen, Tür setzen) **sofort**
|
||||||
|
im Zustand-Store + 3D anzeigen; **schwere Booleans/HLR im Web Worker** (Comlink,
|
||||||
|
bereits geplant) nachziehen. Wand erscheint sofort, die *exakte* verschnittene
|
||||||
|
Öffnung/Schnittlinie „schärft nach". UI bleibt flüssig.
|
||||||
|
- **Progressive Plan-Generierung:** SVG-Grundriss zuerst grob (Achsen/Linien),
|
||||||
|
Schraffuren/Symbole nachladen — Skeleton/„low-detail first" statt Spinner.
|
||||||
|
- **HLR-Schnitte (Risiko #4):** Worker + Caching (ROADMAP §6). UI: **Skeleton der
|
||||||
|
Schnitt-Ansicht** + „berechne verdeckte Kanten…" mit Progress, restliche App
|
||||||
|
bleibt nutzbar (gescopter Ladezustand).
|
||||||
|
- **Delay-then-show** für alle Worker-Tasks (150 ms-Schwelle), sonst Flicker beim
|
||||||
|
schnellen Editieren.
|
||||||
|
- **Three.js-Disziplin:** stabile 60 fps beim Orbit/Pan ist selbst „perceived
|
||||||
|
performance" — instanziertes Rendering, Frustum-Culling, LoD für ferne Geometrie
|
||||||
|
(deckt sich mit ROADMAP-Phase-7-Performance-Härtung). Lieber 60 fps bei grober
|
||||||
|
Geometrie als ruckelnde Präzision.
|
||||||
|
- **Auto-Save-Status klar, unaufdringlich** kommunizieren („Gespeichert"/„Speichern…"),
|
||||||
|
optimistic — nie blockierend (Figma-Muster).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Onboarding & Discoverability
|
||||||
|
|
||||||
|
### 7.1 Belegte Muster
|
||||||
|
|
||||||
|
- **Progressive Disclosure:** zuerst nur Essenzielles zeigen, Komplexität schrittweise
|
||||||
|
enthüllen — reduziert kognitive Last; drei Typen: step-by-step, conditional,
|
||||||
|
contextual
|
||||||
|
([IxDF: Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure),
|
||||||
|
[UXPin](https://www.uxpin.com/studio/blog/what-is-progressive-disclosure/)).
|
||||||
|
- **„Learn by doing" an Sample-Dokument:** Grammarly startet Nutzer mit einem
|
||||||
|
Beispiel-Dokument mit Fehlern; Hotspots/Tooltips führen durch Features
|
||||||
|
([Userpilot: onboarding examples](https://userpilot.com/blog/user-onboarding-examples/)).
|
||||||
|
- **Stufenweises Aufdecken fortgeschrittener Features** (Asana: erst Projekt
|
||||||
|
anlegen, später Dependencies/Kanban/Gantt)
|
||||||
|
([Userpilot: progressive disclosure](https://userpilot.com/blog/progressive-disclosure-examples/)).
|
||||||
|
- **Command-Palette als Discovery-Layer:** durchsuchbare Befehlsliste hilft, Features
|
||||||
|
zu entdecken — „incredible effect on exploration and feature discoverability",
|
||||||
|
besonders für neue/seltene Nutzer
|
||||||
|
([Mobbin: command palette](https://mobbin.com/glossary/command-palette),
|
||||||
|
[Untitled UI: command menus](https://www.untitledui.com/components/command-menus)).
|
||||||
|
- **Arcol** wirbt explizit mit „low barrier to entry, gentle learning curve … clean,
|
||||||
|
intuitive, requires minimal training"
|
||||||
|
([AEC: Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)).
|
||||||
|
|
||||||
|
### 7.2 Übernahme für uns
|
||||||
|
|
||||||
|
- **Mitgeliefertes Sample-Projekt** (wir haben bereits `sampleProject.ts`!) als
|
||||||
|
Onboarding-Bühne: ein kleines EFH, fertig modelliert. Nutzer **manipuliert echtes
|
||||||
|
Modell** statt leerem Canvas (Grammarly-Muster). Erste Geste: „zieh diese Wand"
|
||||||
|
→ sieht 3D + Plan live mitlaufen (unser Kern-Wow).
|
||||||
|
- **Progressive Disclosure im Inspector & Tool-Palette:** Default zeigt
|
||||||
|
Wohnbau-Essenz (Wand/Tür/Fenster/Decke/Raum). Fortgeschrittenes (Prioritäts-
|
||||||
|
Verschneidung, Overrides, Detailgrade, Section-Styles) **zugeklappt / hinter
|
||||||
|
„Erweitert"**. Das passt zu unserem radikalen Wohnbau-Fokus.
|
||||||
|
- **Contextual coachmarks** statt langem Tutorial: beim ersten Selektieren einer
|
||||||
|
Wand ein kleiner Tooltip „Endpunkt ziehen zum Verlängern, Doppelklick für Wandtyp".
|
||||||
|
- **Command-Palette (Ctrl/Cmd-K)** — siehe §8 — doppelt als Onboarding: alle
|
||||||
|
Werkzeuge/Befehle durchsuchbar = lebende Feature-Liste.
|
||||||
|
- **Tastatur-Kürzel sichtbar machen** (in Tooltips, in der Palette) — schult
|
||||||
|
beiläufig pro Workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Command-Palette & Tastatur
|
||||||
|
|
||||||
|
### 8.1 Belegte Muster
|
||||||
|
|
||||||
|
- **Ctrl/Cmd-K** ist die De-facto-Konvention (Linear, Figma [Cmd-P], Notion,
|
||||||
|
Vercel, Raycast, Slack, Superhuman); VS Code nutzt Cmd-Shift-P
|
||||||
|
([Mobbin](https://mobbin.com/glossary/command-palette),
|
||||||
|
[Superhuman: command palette](https://blog.superhuman.com/how-to-build-a-remarkable-command-palette/)).
|
||||||
|
- Zwei Haupt-Use-Cases: **Navigation/Suche** und **Shortcuts/Quick Actions**
|
||||||
|
([Outdraw Academy: command palette](https://outdraw-academy.gitbook.io/ux-patterns/command-palette)).
|
||||||
|
- Trigger kann **sichtbar** (Button/Suchleiste) oder nur per Shortcut sein —
|
||||||
|
für Discovery besser **auch sichtbar**
|
||||||
|
([Mobbin](https://mobbin.com/glossary/command-palette)).
|
||||||
|
|
||||||
|
### 8.2 Übernahme für uns
|
||||||
|
|
||||||
|
- **Cmd/Ctrl-K-Palette** für: Werkzeug aktivieren („Wand", „Tür"), Ansicht
|
||||||
|
springen („Grundriss EG", „Schnitt A-A"), Ressource öffnen („Hatch Manager"),
|
||||||
|
globale Aktionen („Norden rotieren", „PDF exportieren", „SIA-Bilanz").
|
||||||
|
- **Sichtbarer Trigger** (Such-/Befehlsfeld in der Top-Bar) für Entdeckung +
|
||||||
|
Shortcut für Speed.
|
||||||
|
- **Konsistente, dokumentierte Shortcuts** (Figma-Disziplin): W=Wand, T=Tür,
|
||||||
|
L=Linie, Space=Pan, Scroll=Zoom, Shift=Snap aus, Esc=Abbrechen, G=Grundriss-Toggle.
|
||||||
|
In Tooltips + Palette anzeigen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Leitplanken für unsere UI (priorisierte Empfehlungen)
|
||||||
|
|
||||||
|
> Sortiert nach **Hebel × Aufwand**. „P#" = grobe Phasen-Zuordnung zur ROADMAP.
|
||||||
|
|
||||||
|
### A. Sofort / Fundament (Phase 0–1) — billig, prägt alles
|
||||||
|
|
||||||
|
1. **Feste 3-Zonen-Shell.** Links **Navigator** (Tabs: *Geschosse/Ansichten · Ebenen
|
||||||
|
· BIM-Tree*, je mit Visibility-Toggle wie VW Navigation Palette). Mitte **Canvas**
|
||||||
|
mit schwebender Tool-Palette + View-Switcher. Rechts **Inspector** (kontextuell).
|
||||||
|
*Keine* frei schwebenden Palettenfenster (Anti-VW-Wildwuchs).
|
||||||
|
2. **View-Switcher als segmented control** `3D | Grundriss | Schnitt | Ansicht`
|
||||||
|
direkt am Canvas; weiche Kamera-Übergänge; *eine* Datenquelle (kein Daten-
|
||||||
|
Moduswechsel). Nutzt unsere bestehende „abgeleitete Sichten"-Architektur.
|
||||||
|
3. **Visibility/Lock pro Zeile** als Erstklass-Interaktion (1 Klick, kein Dialog) —
|
||||||
|
`EyeIcon.tsx` ausbauen.
|
||||||
|
4. **Kontextueller Inspector**: Controls nur für den selektierten Element-Typ
|
||||||
|
(Figma/Arcol); berechnete Felder read-only markiert; Sektionen kollabierbar.
|
||||||
|
5. **Tastatur-Grundlagen + Konventionen festnageln**: Space=Pan, Scroll=Zoom,
|
||||||
|
Esc=Abbrechen, Shift=Snap aus. Früh festlegen → Muskelgedächtnis.
|
||||||
|
|
||||||
|
### B. Kern-„Pro-Feel" (Phase 1–2) — der eigentliche Wert
|
||||||
|
|
||||||
|
6. **Snapping/Inferencing nach Onshape-Vorbild**: Snap-Glyphen am Cursor,
|
||||||
|
gestrichelte Achsen-Hilfslinien, Hover-Highlight, **Shift = unterdrücken**,
|
||||||
|
Wake-up-Inferences. Targets: Wand-Enden/Achsen/Mitten, Raster, Rechtwinklig/
|
||||||
|
Parallel, Öffnungs-Achsen. **Höchste Priorität für „Werkzeug-Gefühl".**
|
||||||
|
7. **Live-Maßeingabe beim Zeichnen** (Länge/Winkel tippbar, Tab zwischen Feldern).
|
||||||
|
8. **Kontextuelle Action-Bar bei Auswahl** (Arcol): Wand selektiert → „Tür/Fenster
|
||||||
|
einsetzen / Wandtyp / verschneiden". Buttons erscheinen nur, wenn anwendbar.
|
||||||
|
9. **Schlanke domänen-gruppierte Tool-Palette** (BIM-Bauteile + 2D-Zeichnen),
|
||||||
|
modus-bewusst je Ansicht.
|
||||||
|
10. **Optimistic Edits + Worker-Nachzug**: Mutation sofort sichtbar, schwere
|
||||||
|
Booleans/HLR im Worker (Comlink); Delay-then-show (150 ms) statt Spinner-Flicker.
|
||||||
|
|
||||||
|
### C. Differenzierung (Phase 3) — hier gewinnen wir
|
||||||
|
|
||||||
|
11. **Grip-Editing** (Wand-Endpunkte, Öffnungs-Position/-Breite, **Schnittlinie im
|
||||||
|
Plan**) mit Snapping + Live-Maß, in 2D *und* 3D. (DOSSIER-Backlog, Aufwand L —
|
||||||
|
aber Kern-Differenzierer.)
|
||||||
|
12. **Saved Views / Ausschnitte** in der linken Sidebar = Kamera + Schnitt + Maßstab
|
||||||
|
+ Layer-Kombination per Klick (VW „Saved Views" / DOSSIER). Skaliert auf 50+
|
||||||
|
Ansichten.
|
||||||
|
13. **Wiederverwendbares Resource-Browser-Pattern** für Line/Hatch/Component-Manager:
|
||||||
|
Zwei-Pane (File-Browser → Viewer mit Thumbnails) als Manager *und* als inline
|
||||||
|
Picker im Inspector (VW-Modell). Zentrale Änderung propagiert (id-Referenzen).
|
||||||
|
14. **Perceived-performance-Politik festschreiben**: Skeletons für Plan-/Schnitt-
|
||||||
|
Generierung, gescopte Ladezustände (restliche App bleibt nutzbar),
|
||||||
|
reduced-motion/`aria-busy` respektieren.
|
||||||
|
15. **Split-View 3D|Plan** (Snaptrude-Muster) — billig dank reaktiver Sichten,
|
||||||
|
starker Wow-Effekt; auch fürs Onboarding.
|
||||||
|
|
||||||
|
### D. Adoption & Politur (Phase 1 fortlaufend → 7)
|
||||||
|
|
||||||
|
16. **Onboarding via Sample-Projekt** (`sampleProject.ts` als fertiges EFH);
|
||||||
|
„learn by doing", erste Geste zeigt 3D⇄Plan-Live-Sync. Progressive Disclosure:
|
||||||
|
Wohnbau-Essenz default, Fortgeschrittenes (Prioritäts-Verschneidung, Overrides,
|
||||||
|
Detailgrade) zugeklappt.
|
||||||
|
17. **Command-Palette (Cmd/Ctrl-K)** + sichtbarer Trigger: Werkzeuge/Ansichten/
|
||||||
|
Ressourcen/Aktionen durchsuchbar; doppelt als Discovery-Layer.
|
||||||
|
18. **Live-Metriken-Block** im Inspector (Arcol): BGF, **SIA-416-Bilanz**, Räume,
|
||||||
|
Volumen — bei leerer Auswahl als Projekt-Übersicht.
|
||||||
|
19. **CH-Spezifika UI-seitig vorsehen**: **Norden-Rotation**-Gizmo (ViewCube/Kompass),
|
||||||
|
SIA-Raumkategorien im Raum-Inspector, später Swisstopo-Import-Flow mit Auto-Zoom
|
||||||
|
+ Nullpunkt-Verschiebung.
|
||||||
|
|
||||||
|
### Übergreifende Designprinzipien (gelten immer)
|
||||||
|
|
||||||
|
- **Kontextualität vor Vollständigkeit** — zeige nur, was jetzt anwendbar ist
|
||||||
|
(Arcol/Onshape/Figma). Direkt verzahnt mit unserem „LoD-bewusste Stil-UI"-Backlog.
|
||||||
|
- **Direkte Manipulation vor Dialogen** — Grips/Drag/Inline-Edit schlägt
|
||||||
|
Properties-Dialog (Figma/Snaptrude/DOSSIER).
|
||||||
|
- **Eine Wahrheit, viele Sichten** — niemals Sicht-spezifische Daten; alles aus dem
|
||||||
|
semantischen Modell ableiten (deckt sich exakt mit unserem Architektur-Prinzip).
|
||||||
|
- **Konventionen respektieren** — Pan/Zoom/Snap/Esc/Cmd-K wie die etablierten Tools;
|
||||||
|
Nutzer bringen Muskelgedächtnis aus Figma/CAD mit.
|
||||||
|
- **Gefühlte > tatsächliche Geschwindigkeit** — optimistic + Skeletons + 60 fps;
|
||||||
|
im schweren Browser-3D-Geometrie-Kontext ein echter Wettbewerbsvorteil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
|
||||||
|
**Arcol**
|
||||||
|
- [Arcol unleashed – BIM 2.0 (AEC Magazine)](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)
|
||||||
|
- [Arcol – BIM in a browser (AEC Magazine)](https://aecmag.com/bim/arcol-bim-cloud-browser/)
|
||||||
|
- [Arcol: a sneak peek (AEC Magazine)](https://aecmag.com/bim/arcol-a-sneak-peek/)
|
||||||
|
- [The Arcol Manifesto](https://arcol.io/blog/the-arcol-manifesto)
|
||||||
|
|
||||||
|
**Snaptrude**
|
||||||
|
- [Snaptrude – browser-based BIM tool (ArchDaily)](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work)
|
||||||
|
- [Snaptrude (offiziell)](https://www.snaptrude.com/)
|
||||||
|
|
||||||
|
**TestFit**
|
||||||
|
- [TestFit 5.19: A New Generative Design Workflow](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)
|
||||||
|
- [TestFit (offiziell)](https://www.testfit.io/)
|
||||||
|
|
||||||
|
**Onshape**
|
||||||
|
- [Onshape: User Interface Basics](https://cad.onshape.com/help/Content/ui-basics.htm)
|
||||||
|
- [Onshape: Automatic Inferencing](https://cad.onshape.com/help/Content/Sketch/automatic_inferencing.htm)
|
||||||
|
- [Onshape: Working with Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)
|
||||||
|
- [Onshape: Sketch Basics](https://cad.onshape.com/help/Content/Sketch/sketch_basics.htm)
|
||||||
|
|
||||||
|
**Vectorworks**
|
||||||
|
- [VW Resource Manager](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource%20Manager.htm)
|
||||||
|
- [VW Resource Manager: File browser pane](https://app-help.vectorworks.net/2023/eng/VW2023_Guide/ResourceManager/Resource_Manager_File_browser_pane.htm)
|
||||||
|
- [VW Resource Manager: Resource viewer pane](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource_Manager_Resource_viewer_pane.htm)
|
||||||
|
- [VW Navigation Palette](https://app-help.vectorworks.net/2022/eng/VW2022_Guide/Structure/The_Navigation_palette.htm)
|
||||||
|
- [VW Palettes and Tool Sets](https://app-help.vectorworks.net/2018/eng/VW2018_Guide/Start/Palettes_and_Tool_Sets.htm)
|
||||||
|
|
||||||
|
**Figma**
|
||||||
|
- [Figma: right sidebar / layer properties](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)
|
||||||
|
- [Figma: left sidebar (layers & pages)](https://help.figma.com/hc/en-us/articles/360039831974-View-layers-and-pages-in-the-left-sidebar)
|
||||||
|
- [Figma for Everyone: The Interface](https://www.inthepocket.design/course/figma-for-everyone/the-interface)
|
||||||
|
|
||||||
|
**Perceived Performance**
|
||||||
|
- [LogRocket: Skeleton loading screen design](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/)
|
||||||
|
- [UI Deploy: Skeleton Screens vs. Spinners](https://ui-deploy.com/blog/skeleton-screens-vs-spinners-optimizing-perceived-performance)
|
||||||
|
- [Onething: Skeleton Screens vs Loading Spinners](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)
|
||||||
|
- [Smart Interface Design Patterns: Loading & Progress UX](https://smart-interface-design-patterns.com/articles/designing-better-loading-progress-ux/)
|
||||||
|
|
||||||
|
**Command Palette**
|
||||||
|
- [Mobbin: Command Palette](https://mobbin.com/glossary/command-palette)
|
||||||
|
- [Untitled UI: Command menus (Cmd-K)](https://www.untitledui.com/components/command-menus)
|
||||||
|
- [Superhuman: How to build a remarkable command palette](https://blog.superhuman.com/how-to-build-a-remarkable-command-palette/)
|
||||||
|
- [Outdraw Academy: Command Palette UX pattern](https://outdraw-academy.gitbook.io/ux-patterns/command-palette)
|
||||||
|
|
||||||
|
**Onboarding / Progressive Disclosure**
|
||||||
|
- [IxDF: Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure)
|
||||||
|
- [UXPin: What Is Progressive Disclosure](https://www.uxpin.com/studio/blog/what-is-progressive-disclosure/)
|
||||||
|
- [Userpilot: User onboarding examples](https://userpilot.com/blog/user-onboarding-examples/)
|
||||||
|
- [Userpilot: Progressive disclosure examples](https://userpilot.com/blog/progressive-disclosure-examples/)
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- Material Symbols (Icon-Font) — DOSSIER-Look; ohne diese Font erschienen
|
||||||
|
die Ligaturnamen (z. B. „content_copy") als roher Text in Menüs/Toolbar. -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232f6df6'/%3E%3Cg fill='white'%3E%3Crect x='3' y='3' width='4' height='4'/%3E%3Crect x='9' y='3' width='4' height='4'/%3E%3Crect x='3' y='9' width='4' height='4'/%3E%3Crect x='9' y='9' width='4' height='4'/%3E%3C/g%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
|
<title>cad — Phase 0 Spike</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2260
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "cad",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mlightcad/libredwg-web": "^0.7.7",
|
||||||
|
"delaunator": "^5.1.0",
|
||||||
|
"dxf-parser": "^1.1.2",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"three": "^0.169.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/three": "^0.169.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
|
"puppeteer": "^25.2.1",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 2 });
|
||||||
|
const logs=[]; page.on("pageerror",e=>logs.push("[PAGEERROR] "+e.message)); page.on("console",m=>logs.push("[console] "+m.text()));
|
||||||
|
try { await page.goto("http://localhost:5173/", { waitUntil:"networkidle0", timeout:8000 }); } catch(e){ logs.push("[goto] "+e.message); }
|
||||||
|
await page.evaluate(()=>{ try{localStorage.removeItem("cad.layout");}catch{} });
|
||||||
|
await page.reload({waitUntil:"domcontentloaded"}).catch(()=>{});
|
||||||
|
await page.waitForSelector(".plan-svg polygon",{timeout:20000});
|
||||||
|
await new Promise(r=>setTimeout(r,800));
|
||||||
|
const dom = await page.evaluate(()=>({
|
||||||
|
docks: [...document.querySelectorAll(".dock")].map(d=>d.className),
|
||||||
|
hasLeft: !!document.querySelector(".dock-left"),
|
||||||
|
hasRight: !!document.querySelector(".dock-right"),
|
||||||
|
resTriggerActive: document.querySelector(".res-trigger")?.classList.contains("active") ?? null,
|
||||||
|
layout: (()=>{try{return JSON.parse(localStorage.getItem("cad.layout"));}catch{return "ERR";}})(),
|
||||||
|
bodyChildren: [...(document.querySelector(".body")?.children||[])].map(c=>c.className),
|
||||||
|
}));
|
||||||
|
console.log(JSON.stringify(dom,null,2));
|
||||||
|
console.log("LOGS:\n"+logs.join("\n"));
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
|
||||||
|
const p = await b.newPage(); await p.setViewport({width:1500,height:950,deviceScaleFactor:2});
|
||||||
|
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
|
||||||
|
try{ await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:15000}); }catch{}
|
||||||
|
await new Promise(r=>setTimeout(r,1000));
|
||||||
|
const info = await p.evaluate(()=>({rootLen:document.getElementById("root")?.innerHTML.length??-1, viteOverlay: !!document.querySelector("vite-error-overlay"), bodyText: document.body.innerText.slice(0,80)}));
|
||||||
|
await p.screenshot({path:"scripts/probe-now.png"});
|
||||||
|
console.log(JSON.stringify(info), "errs:", errs.slice(0,2).join(" | ")||"none");
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Bündel-Einstieg für check-terrain.mjs — re-exportiert die zu testenden
|
||||||
|
// Funktionen, damit esbuild ein einzelnes ESM-Bundle bauen kann.
|
||||||
|
export { parseDxf } from "../src/io/dxfParser";
|
||||||
|
export { generateTerrainFromContours } from "../src/model/terrain";
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
|
||||||
|
const p=await b.newPage(); await p.setViewport({width:1500,height:950,deviceScaleFactor:2});
|
||||||
|
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
|
||||||
|
try{await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:15000});}catch{}
|
||||||
|
await new Promise(r=>setTimeout(r,1000));
|
||||||
|
// Browser-Kontextmenü unterdrückt?
|
||||||
|
const ctxPrevented=await p.evaluate(()=>{const e=new MouseEvent("contextmenu",{bubbles:true,cancelable:true});return !document.body.dispatchEvent(e);});
|
||||||
|
// Marquee: Links-Drag über den Plan
|
||||||
|
const svg=await p.$(".plan-svg"); const box=await svg.boundingBox();
|
||||||
|
await p.mouse.move(box.x+box.width*0.25,box.y+box.height*0.25);
|
||||||
|
await p.mouse.down(); await p.mouse.move(box.x+box.width*0.85,box.y+box.height*0.85,{steps:8}); await p.mouse.up();
|
||||||
|
await new Promise(r=>setTimeout(r,300));
|
||||||
|
const sel=await p.evaluate(()=>{const m=document.body.innerText.match(/Auswahl:?\s*(\d+)?\s*W\w*/i);return m?m[0]:"(keine)";});
|
||||||
|
await p.screenshot({path:"scripts/verify-de.png"});
|
||||||
|
// Englisch
|
||||||
|
const langOk=await p.evaluate(()=>{if(window.__i18n){window.__i18n.setLanguage("en");return true;}return false;});
|
||||||
|
await new Promise(r=>setTimeout(r,400));
|
||||||
|
await p.screenshot({path:"scripts/verify-en.png"});
|
||||||
|
console.log("ctxmenu unterdrückt:",ctxPrevented,"| Marquee-Auswahl:",sel,"| lang-switch:",langOk,"| errs:",errs.slice(0,2).join(" | ")||"keine");
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Node-seitige Geometrie-Prüfung (kein Browser): generiert eine minimale,
|
||||||
|
// gültige Test-DXF (Höhenlinien auf Z=400/401/402), schickt sie durch parseDxf
|
||||||
|
// → generateTerrainFromContours und bestätigt ein plausibles TIN.
|
||||||
|
//
|
||||||
|
// Die TS-Quellen werden mit esbuild on-the-fly zu einem ESM-Bundle gebaut und
|
||||||
|
// importiert (kein tsx nötig).
|
||||||
|
|
||||||
|
import { build } from "esbuild";
|
||||||
|
import { writeFileSync, mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const outdir = mkdtempSync(join(tmpdir(), "cad-terrain-"));
|
||||||
|
const outfile = join(outdir, "bundle.mjs");
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: [join(process.cwd(), "scripts/_terrain-entry.ts")],
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
platform: "node",
|
||||||
|
outfile,
|
||||||
|
logLevel: "warning",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mod = await import(pathToFileURL(outfile).href);
|
||||||
|
const { parseDxf, generateTerrainFromContours } = mod;
|
||||||
|
|
||||||
|
// ── Minimal gültige Test-DXF: drei LWPOLYLINE-Höhenlinien (Z via Elevation 38) ──
|
||||||
|
// Jede Kontur ist ein leicht versetztes Dreieck/Quadrat auf einer eigenen Höhe.
|
||||||
|
function ring(z, pts, layer) {
|
||||||
|
// LWPOLYLINE mit Elevation-Gruppe (38) und 90=Vertex-Zahl, 70=closed(1).
|
||||||
|
let s = `0\nLWPOLYLINE\n8\n${layer}\n38\n${z}\n90\n${pts.length}\n70\n1\n`;
|
||||||
|
for (const [x, y] of pts) s += `10\n${x}\n20\n${y}\n`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dxf =
|
||||||
|
`0\nSECTION\n2\nENTITIES\n` +
|
||||||
|
ring(400, [[0, 0], [10, 0], [10, 10], [0, 10]], "contours") +
|
||||||
|
ring(401, [[2, 2], [8, 2], [8, 8], [2, 8]], "contours") +
|
||||||
|
ring(402, [[4, 4], [6, 4], [6, 6], [4, 6]], "contours") +
|
||||||
|
`0\nENDSEC\n0\nEOF\n`;
|
||||||
|
|
||||||
|
const parsed = parseDxf(dxf);
|
||||||
|
const contourSets = parsed.contours;
|
||||||
|
const allContours = contourSets.flatMap((cs) => cs.contours);
|
||||||
|
|
||||||
|
console.log("=== DXF-Parse ===");
|
||||||
|
console.log("ContourSets:", contourSets.length, "Konturen:", allContours.length);
|
||||||
|
console.log("Z-Werte:", allContours.map((c) => c.z).join(", "));
|
||||||
|
console.log("Meshes (importiert):", parsed.meshes.length);
|
||||||
|
|
||||||
|
const terrain = generateTerrainFromContours(allContours);
|
||||||
|
const vertCount = terrain.positions.length / 3;
|
||||||
|
const triCount = terrain.indices.length / 3;
|
||||||
|
|
||||||
|
let minZ = Infinity, maxZ = -Infinity, nan = 0;
|
||||||
|
for (let i = 0; i < terrain.positions.length; i++) {
|
||||||
|
const v = terrain.positions[i];
|
||||||
|
if (!Number.isFinite(v)) nan++;
|
||||||
|
}
|
||||||
|
for (let i = 2; i < terrain.positions.length; i += 3) {
|
||||||
|
minZ = Math.min(minZ, terrain.positions[i]);
|
||||||
|
maxZ = Math.max(maxZ, terrain.positions[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n=== TIN ===");
|
||||||
|
console.log("Vertices:", vertCount, "Dreiecke:", triCount);
|
||||||
|
console.log("Z-Range:", minZ, "..", maxZ);
|
||||||
|
console.log("NaN/Infinity-Werte:", nan);
|
||||||
|
|
||||||
|
const ok =
|
||||||
|
contourSets.length === 1 &&
|
||||||
|
allContours.length === 3 &&
|
||||||
|
vertCount > 0 &&
|
||||||
|
triCount > 0 &&
|
||||||
|
nan === 0 &&
|
||||||
|
Math.abs(minZ - 400) < 1e-6 &&
|
||||||
|
Math.abs(maxZ - 402) < 1e-6;
|
||||||
|
|
||||||
|
// Zusatz: leere Eingabe → leeres Mesh (kein Crash).
|
||||||
|
const empty = generateTerrainFromContours([]);
|
||||||
|
const emptyOk = empty.positions.length === 0 && empty.indices.length === 0;
|
||||||
|
console.log("\nLeere-Eingabe → leeres Mesh:", emptyOk ? "OK" : "FEHLER");
|
||||||
|
|
||||||
|
console.log("\n" + (ok && emptyOk ? "✅ TIN plausibel" : "❌ TIN NICHT plausibel"));
|
||||||
|
process.exit(ok && emptyOk ? 0 : 1);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
page.on("console", (m) => { if (m.text().includes("DBG")) logs.push(m.text()); });
|
||||||
|
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
const clickTool = (label) => page.evaluate((l) => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === l)?.click(), label);
|
||||||
|
const click = async (x, y) => { await page.mouse.move(x, y); await page.mouse.click(x, y); await new Promise((r) => setTimeout(r, 60)); };
|
||||||
|
const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
|
||||||
|
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
|
||||||
|
|
||||||
|
// Ein kleines Linien-Raster in den Raum zeichnen (3 horizontale + 3 vertikale).
|
||||||
|
await clickTool("Linie");
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
const L = box.x + box.w * 0.34, Rr = box.x + box.w * 0.66;
|
||||||
|
const T = box.y + box.h * 0.42, Bb = box.y + box.h * 0.72;
|
||||||
|
for (let i = 0; i <= 2; i++) {
|
||||||
|
const y = T + ((Bb - T) * i) / 2;
|
||||||
|
await click(L, y); await click(Rr, y);
|
||||||
|
}
|
||||||
|
for (let i = 0; i <= 2; i++) {
|
||||||
|
const x = L + ((Rr - L) * i) / 2;
|
||||||
|
await click(x, T); await click(x, Bb);
|
||||||
|
}
|
||||||
|
await clickTool("Auswahl");
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
const draw2dPlan = await page.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d").length);
|
||||||
|
|
||||||
|
// Obergeschoss ausblenden (Augen-Icon), damit der EG-Boden mit dem Raster oben
|
||||||
|
// offen sichtbar ist.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
let row = [...document.querySelectorAll("*")].find((e) => e.children.length === 0 && e.textContent.trim() === "Obergeschoss");
|
||||||
|
while (row && !row.querySelector?.(".eye")) row = row.parentElement;
|
||||||
|
row?.querySelector(".eye")?.click();
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
|
// In die Perspektive wechseln und rendern lassen.
|
||||||
|
await clickTool("Perspektive");
|
||||||
|
await new Promise((r) => setTimeout(r, 1200));
|
||||||
|
await page.screenshot({ path: "scripts/probe-3d2d.png" });
|
||||||
|
console.log("draw2dPlan lines:", draw2dPlan);
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(no errors)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const b = await puppeteer.launch({ headless: "new", args: ["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] });
|
||||||
|
const page = await b.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
const logs = []; page.on("pageerror", e => logs.push(e.message));
|
||||||
|
page.on("dialog", async d => { await d.accept("3"); });
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,600));
|
||||||
|
const clickTool = l => page.evaluate(x => [...document.querySelectorAll("button")].find(b=>b.textContent.trim()===x)?.click(), l);
|
||||||
|
const click = async (x,y)=>{ await page.mouse.move(x,y); await page.mouse.click(x,y); await new Promise(r=>setTimeout(r,70)); };
|
||||||
|
const nLines = () => page.evaluate(()=>document.querySelectorAll(".plan-svg line.draw2d").length);
|
||||||
|
const box = await page.evaluate(()=>{const r=document.querySelector(".plan-svg").getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height};});
|
||||||
|
const cx=box.x+box.w/2, cy=box.y+box.h/2;
|
||||||
|
await clickTool("Linie"); await new Promise(r=>setTimeout(r,80));
|
||||||
|
const A=[cx-120,cy-140], B=[cx+20,cy-140];
|
||||||
|
await click(...A); await click(...B);
|
||||||
|
await clickTool("Auswahl"); await new Promise(r=>setTimeout(r,60));
|
||||||
|
await click((A[0]+B[0])/2, A[1]); // select midpoint
|
||||||
|
const sel = await page.evaluate(()=>document.querySelectorAll(".plan-svg .plan-grip").length);
|
||||||
|
await page.keyboard.press("m"); await new Promise(r=>setTimeout(r,80));
|
||||||
|
await page.keyboard.press("o"); await new Promise(r=>setTimeout(r,200));
|
||||||
|
const mode = await page.evaluate(()=>document.querySelector(".transform-bar .tf-mode.active .tf-mode-label")?.textContent);
|
||||||
|
await click(A[0], A[1]); // base
|
||||||
|
await click(A[0], A[1]+70); // dest (down by ~70px each step)
|
||||||
|
await new Promise(r=>setTimeout(r,150));
|
||||||
|
const n = await nLines();
|
||||||
|
await page.screenshot({ path:"scripts/probe-array.png" });
|
||||||
|
console.log("selectedGrips:", sel, "activeMode:", mode, "lines(expect 4):", n, logs.length?logs.join("|"):"(no err)");
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Probe für das Rhino-artige Befehlssystem (Tier 0 + Line).
|
||||||
|
// Treibt: Tab → „line"⏎ → „0,0"⏎ → „r3,0"⏎ und screenshotet das Ergebnis.
|
||||||
|
// Erwartung: eine deutlich DUNKLE 3-m-Linie im Plan + Command-Line zeigt Prompts.
|
||||||
|
//
|
||||||
|
// Headless Puppeteer (swiftshader für WebGL). Zweiter Lauf mit „3<45" prüft die
|
||||||
|
// polare Eingabe. Vorlage: scripts/probe-tools.mjs.
|
||||||
|
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
} catch (e) {
|
||||||
|
logs.push(`[GOTO-ERROR] ${e.message}`);
|
||||||
|
}
|
||||||
|
await sleep(600);
|
||||||
|
|
||||||
|
// Zählt 2D-Zeichenlinien + liefert die computed stroke der ersten (Tinte-Check).
|
||||||
|
async function inkInfo() {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const lines = [...document.querySelectorAll(".plan-svg line.draw2d")];
|
||||||
|
const first = lines[0];
|
||||||
|
const stroke = first ? getComputedStyle(first).stroke : null;
|
||||||
|
return { count: lines.length, stroke };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tippt einen Befehl/Eine Zeile in die fokussierte Command-Line + Enter.
|
||||||
|
async function typeLine(text) {
|
||||||
|
await page.type(".cmdline-input", text, { delay: 12 });
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Tab → Command-Line fokussieren (globaler Tab-Handler in App).
|
||||||
|
// Erst in den Body klicken, damit Tab nicht von einem anderen Feld geschluckt wird.
|
||||||
|
await page.mouse.click(600, 400);
|
||||||
|
await sleep(80);
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await sleep(120);
|
||||||
|
const focused1 = await page.evaluate(
|
||||||
|
() => document.activeElement?.className || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2) Befehl + zwei getippte Koordinaten. Startpunkt 1,7 liegt ÜBER dem Raum
|
||||||
|
// (freies Papier), damit die 3-m-Linie eindeutig sichtbar ist (nicht von einer
|
||||||
|
// Wandkante überdeckt). Verlangt: 1,7 ⏎ r3,0 ⏎ → 3-m-Linie nach rechts.
|
||||||
|
await typeLine("line");
|
||||||
|
const promptAfterLine = await page.evaluate(
|
||||||
|
() => document.querySelector(".cmdline-prompt")?.textContent || "",
|
||||||
|
);
|
||||||
|
await typeLine("1,2");
|
||||||
|
const promptAfterStart = await page.evaluate(
|
||||||
|
() => document.querySelector(".cmdline-prompt")?.textContent || "",
|
||||||
|
);
|
||||||
|
await typeLine("r3,0");
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const ink1 = await inkInfo();
|
||||||
|
await page.screenshot({ path: "scripts/probe-command-line.png" });
|
||||||
|
|
||||||
|
// 3) Zweiter Lauf: polare Eingabe 3<45 (neue Linie ab Ursprung).
|
||||||
|
await page.mouse.click(600, 400);
|
||||||
|
await sleep(80);
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await sleep(120);
|
||||||
|
await typeLine("line");
|
||||||
|
await typeLine("1,1");
|
||||||
|
await typeLine("3<45");
|
||||||
|
await sleep(200);
|
||||||
|
const ink2 = await inkInfo();
|
||||||
|
await page.screenshot({ path: "scripts/probe-command-line-polar.png" });
|
||||||
|
|
||||||
|
console.log("focused after Tab:", focused1);
|
||||||
|
console.log("prompt after 'line':", JSON.stringify(promptAfterLine));
|
||||||
|
console.log("prompt after '0,0':", JSON.stringify(promptAfterStart));
|
||||||
|
console.log("ink (after 0,0 + r3,0):", JSON.stringify(ink1));
|
||||||
|
console.log("ink (after polar 3<45):", JSON.stringify(ink2));
|
||||||
|
console.log("=== Logs ===");
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(keine)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
|
||||||
|
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1.5});
|
||||||
|
await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,1200));
|
||||||
|
const opened=await p.evaluate(()=>{const l=[...document.querySelectorAll(".nav-label")].find(e=>/Erdgeschoss|Obergeschoss|Detail|Ansicht|Schnitt/.test(e.textContent||""));if(!l)return "no-row";const row=l.parentElement;const r=row.getBoundingClientRect();const ev=new MouseEvent("contextmenu",{bubbles:true,cancelable:true,clientX:r.x+20,clientY:r.y+r.height/2,button:2});row.dispatchEvent(ev);return "dispatched";});
|
||||||
|
await new Promise(r=>setTimeout(r,400));
|
||||||
|
const info=await p.evaluate(()=>{const m=document.querySelector(".ctx-menu");if(!m)return{menu:false};const icons=[...m.querySelectorAll(".ctx-item-icon .material-symbols-outlined")];const cs=icons[0]?getComputedStyle(icons[0]):null;return{menu:true,nItems:m.querySelectorAll(".ctx-item").length,nIcons:icons.length,iconPx:cs?.fontSize,glyphFont:/Material Symbols/.test(cs?.fontFamily||""),ligature:icons[0]?.textContent,hoverBg:getComputedStyle(m.querySelector(".ctx-item")).getPropertyValue("--ctx-hover")};});
|
||||||
|
await p.screenshot({path:"scripts/probe-ctx.png"});
|
||||||
|
console.log("trigger:",opened,JSON.stringify(info));
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
|
||||||
|
const p=await b.newPage(); await p.setViewport({width:1400,height:880,deviceScaleFactor:1.5});
|
||||||
|
await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,1000));
|
||||||
|
// ersten Dropdown-Trigger (Detailgrad) klicken
|
||||||
|
const tr=await p.$(".tb-dd-trigger"); if(tr){const r=await tr.boundingBox();await p.mouse.click(r.x+r.width/2,r.y+r.height/2);await new Promise(t=>setTimeout(t,350));}
|
||||||
|
const info=await p.evaluate(()=>{const menu=document.querySelector(".tb-dd-menu, .ctx-menu");if(!menu)return{open:false};const item=menu.querySelector(".ctx-item, .tb-dd-item");const cs=item?getComputedStyle(item):null;const ic=menu.querySelector(".ctx-item-icon, .material-symbols-outlined");return{open:true,itemFont:cs?.fontSize,itemPad:cs?.padding,menuFont:getComputedStyle(menu).fontSize,radius:getComputedStyle(menu).borderRadius,iconFont:ic?getComputedStyle(ic).fontSize:"(none)"};});
|
||||||
|
await p.screenshot({path:"scripts/probe-dd-size.png"});
|
||||||
|
console.log(JSON.stringify(info));
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Probe: belegt den kontextabhängigen „Darstellung"-Dropdown.
|
||||||
|
// 1) Grundriss + „Schwarz-Weiss" (mono) → scripts/probe-plan-mono.png
|
||||||
|
// 2) Grundriss + „Farbig" (Referenz) → scripts/probe-plan-color.png
|
||||||
|
// 3) Perspektive + „Weiss" (Clay) → scripts/probe-3d-white.png
|
||||||
|
// 4) Perspektive + „Schattiert" (Ref.) → scripts/probe-3d-shaded.png
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
|
||||||
|
// Den Ansichts-Umschalter-Button per sichtbarem Text klicken.
|
||||||
|
const clickByText = async (sel, text) => {
|
||||||
|
const handle = await page.evaluateHandle(
|
||||||
|
(sel, text) =>
|
||||||
|
[...document.querySelectorAll(sel)].find(
|
||||||
|
(el) => el.textContent.trim() === text,
|
||||||
|
),
|
||||||
|
sel,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
const el = handle.asElement();
|
||||||
|
if (!el) throw new Error(`Button "${text}" (${sel}) nicht gefunden`);
|
||||||
|
await el.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Den „Darstellung"-Dropdown (per title) auf einen Wert setzen.
|
||||||
|
const setStyle = async (value) => {
|
||||||
|
const sel = await page.$('select.layout-menu[title*="Darstellungsart der"]');
|
||||||
|
if (!sel) throw new Error("Darstellung-Dropdown nicht gefunden");
|
||||||
|
await sel.select(value);
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
};
|
||||||
|
|
||||||
|
const shot = (name) => page.screenshot({ path: `scripts/${name}` });
|
||||||
|
|
||||||
|
// 1) Grundriss aktiv (Default), Schwarz-Weiss.
|
||||||
|
await clickByText("button.view-btn", "Grundriss");
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
await setStyle("color");
|
||||||
|
await shot("probe-plan-color.png");
|
||||||
|
await setStyle("mono");
|
||||||
|
await shot("probe-plan-mono.png");
|
||||||
|
|
||||||
|
// 2) Perspektive, Weiss vs. Schattiert.
|
||||||
|
await clickByText("button.view-btn", "Perspektive");
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
await setStyle("shaded");
|
||||||
|
await shot("probe-3d-shaded.png");
|
||||||
|
await setStyle("white");
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
await shot("probe-3d-white.png");
|
||||||
|
|
||||||
|
console.log("Screenshots geschrieben: probe-plan-color/mono, probe-3d-shaded/white");
|
||||||
|
console.log("\n=== Logs ===");
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(keine)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] });
|
||||||
|
const p = await b.newPage(); await p.setViewport({width:1400,height:900,deviceScaleFactor:2});
|
||||||
|
const logs=[]; p.on("pageerror",e=>logs.push(e.message));
|
||||||
|
await p.goto("http://localhost:5174/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,800));
|
||||||
|
// Default = Display (Haarlinien). Screenshot.
|
||||||
|
await p.screenshot({ path:"scripts/probe-display.png" });
|
||||||
|
// Auf Print umschalten.
|
||||||
|
await p.evaluate(()=>[...document.querySelectorAll("button")].find(x=>x.textContent.trim()==="Print")?.click());
|
||||||
|
await new Promise(r=>setTimeout(r,400));
|
||||||
|
await p.screenshot({ path:"scripts/probe-print.png" });
|
||||||
|
const dispActive = await p.evaluate(()=>!![...document.querySelectorAll(".view-btn.active")].find(b=>b.textContent.trim()==="Print"));
|
||||||
|
console.log("printActive:", dispActive, logs.length?logs.join("|"):"(no err)");
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
// Wie probe.mjs, aber klickt zusätzlich die Zeichnungsebene "Grundriss OG"
|
||||||
|
// im Navigator und schießt scripts/probe-doc.png.
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
} catch (e) {
|
||||||
|
logs.push(`[GOTO-ERROR] ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigator-Zeile "Grundriss OG" anklicken.
|
||||||
|
const clicked = await page.evaluate(() => {
|
||||||
|
const rows = [...document.querySelectorAll(".nav-row")];
|
||||||
|
const target = rows.find((r) => r.textContent.includes("Grundriss OG"));
|
||||||
|
if (target) {
|
||||||
|
target.click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
logs.push(clicked ? "[INFO] 'Grundriss OG' geklickt" : "[WARN] 'Grundriss OG' nicht gefunden");
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
await page.screenshot({ path: "scripts/probe-doc.png" });
|
||||||
|
|
||||||
|
const info = await page.evaluate(() => {
|
||||||
|
const title = document.querySelector(".content-single .pane-title")?.textContent ?? "none";
|
||||||
|
const caption = document.querySelector(".content-single .plan-legend")?.textContent ?? "none";
|
||||||
|
const polygons = document.querySelectorAll(".content-single .plan-svg polygon").length;
|
||||||
|
return { title, caption, polygons };
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("=== Drawing-Layer-Ansicht ===", JSON.stringify(info));
|
||||||
|
console.log("\n=== Logs ===");
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(keine)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Probe für das DOSSIER-Dokumentmodell (Augen-Navigator + Mehrgeschoss-3D).
|
||||||
|
// Erfasst fünf Zustände in separate PNGs (deviceScaleFactor 2):
|
||||||
|
// (a) Standard: EG Grundriss mit Augen-Navigator (EG aktiv, andere gedimmt).
|
||||||
|
// (b) Perspektive mit EG + OG sichtbar → zwei Räume vertikal gestapelt.
|
||||||
|
// (c) OG-Auge ausgeschaltet → nur EG bleibt im 3D.
|
||||||
|
// (d) EG Geschosshöhe auf 3.5 → größerer Abstand zu OG; "+ Ebene" anklicken
|
||||||
|
// → neue Kategorie erscheint im Navigator.
|
||||||
|
// (e) Zeichnungsebene "Detail 1" → leerer Zeichnungs-Platzhalter.
|
||||||
|
//
|
||||||
|
// Aufruf: node scripts/probe-dossier.mjs
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const OUT = "scripts";
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1280, height: 820, deviceScaleFactor: 2 });
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Klick auf das Element, dessen Textinhalt `text` enthält (innerhalb selector).
|
||||||
|
async function clickByText(selector, text) {
|
||||||
|
const handle = await page.evaluateHandle(
|
||||||
|
(sel, t) =>
|
||||||
|
[...document.querySelectorAll(sel)].find((el) =>
|
||||||
|
el.textContent.includes(t),
|
||||||
|
),
|
||||||
|
selector,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
const el = handle.asElement();
|
||||||
|
if (!el) throw new Error(`Element nicht gefunden: ${selector} ~ "${text}"`);
|
||||||
|
await el.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klick auf das Augen-Symbol einer Zeichnungsebene mit gegebenem Namen.
|
||||||
|
async function toggleLevelEyeByName(name) {
|
||||||
|
const ok = await page.evaluate((n) => {
|
||||||
|
const row = [...document.querySelectorAll(".nav-row")].find((r) =>
|
||||||
|
r.querySelector(".nav-label")?.textContent.trim().includes(n),
|
||||||
|
);
|
||||||
|
const eye = row?.querySelector(".eye");
|
||||||
|
if (!eye) return false;
|
||||||
|
eye.click();
|
||||||
|
return true;
|
||||||
|
}, name);
|
||||||
|
if (!ok) throw new Error(`Augen-Schalter nicht gefunden für Ebene: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnose: aktueller Navigator-Zustand (für Querprüfung der Screenshots).
|
||||||
|
const navState = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const levels = [...document.querySelectorAll(".nav-row")].map((r) => ({
|
||||||
|
name: r.querySelector(".nav-label")?.textContent.trim(),
|
||||||
|
active: r.classList.contains("active"),
|
||||||
|
dimmed: r.classList.contains("hidden"),
|
||||||
|
elev: r.querySelector(".nav-elev")?.textContent.trim() ?? null,
|
||||||
|
}));
|
||||||
|
const cats = [...document.querySelectorAll(".cat-row")].map((r) =>
|
||||||
|
r.querySelector(".cat-name")?.textContent.trim(),
|
||||||
|
);
|
||||||
|
const canvas = document.querySelector(".viewport canvas");
|
||||||
|
return {
|
||||||
|
levels,
|
||||||
|
catCount: cats.length,
|
||||||
|
catNames: cats,
|
||||||
|
hasCanvas: !!canvas,
|
||||||
|
egHeight: document.querySelector(".props-strip input")?.value ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
} catch (e) {
|
||||||
|
logs.push(`[GOTO-ERROR] ${e.message}`);
|
||||||
|
}
|
||||||
|
await sleep(700);
|
||||||
|
|
||||||
|
// ── (a) Standard: EG Grundriss mit Augen-Navigator ──────────────────────────
|
||||||
|
await page.screenshot({ path: `${OUT}/probe-a-eg-grundriss.png` });
|
||||||
|
logs.push("(a) STATE " + JSON.stringify(await navState()));
|
||||||
|
|
||||||
|
// ── (b) Perspektive mit EG + OG sichtbar ───────────────────────────────────
|
||||||
|
await clickByText(".view-btn", "Perspektive");
|
||||||
|
await sleep(1400);
|
||||||
|
await page.screenshot({ path: `${OUT}/probe-b-eg-og-perspektive.png` });
|
||||||
|
logs.push("(b) STATE " + JSON.stringify(await navState()));
|
||||||
|
|
||||||
|
// ── (c) OG-Auge ausschalten → nur EG bleibt im 3D ──────────────────────────
|
||||||
|
await toggleLevelEyeByName("Obergeschoss");
|
||||||
|
await sleep(1400);
|
||||||
|
await page.screenshot({ path: `${OUT}/probe-c-only-eg-3d.png` });
|
||||||
|
logs.push("(c) STATE " + JSON.stringify(await navState()));
|
||||||
|
|
||||||
|
// OG wieder einblenden, damit (d) den Stapel-Abstand zeigt.
|
||||||
|
await toggleLevelEyeByName("Obergeschoss");
|
||||||
|
await sleep(400);
|
||||||
|
|
||||||
|
// ── (d) EG Geschosshöhe auf 3.5 → größerer Abstand zu OG ───────────────────
|
||||||
|
// EG ist die Standardauswahl; die props-strip zeigt das aktive Geschoss.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const input = document.querySelector(".props-strip input");
|
||||||
|
if (!input) throw new Error("Geschosshöhe-Feld nicht gefunden");
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
).set;
|
||||||
|
setter.call(input, "3.5");
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await sleep(1400);
|
||||||
|
// "+ Ebene" anklicken → neue Kategorie erscheint im Navigator.
|
||||||
|
await clickByText(".nav-add", "+ Ebene");
|
||||||
|
await sleep(500);
|
||||||
|
await page.screenshot({ path: `${OUT}/probe-d-height-and-addlayer.png` });
|
||||||
|
logs.push("(d) STATE " + JSON.stringify(await navState()));
|
||||||
|
|
||||||
|
// ── (e) Zeichnungsebene "Detail 1" → leerer Zeichnungs-Platzhalter ─────────
|
||||||
|
await clickByText(".nav-label", "Detail 1");
|
||||||
|
await sleep(500);
|
||||||
|
await page.screenshot({ path: `${OUT}/probe-e-detail-drawing.png` });
|
||||||
|
logs.push("(e) STATE " + JSON.stringify(await navState()));
|
||||||
|
|
||||||
|
console.log(logs.join("\n"));
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const URL=process.env.PROBE_URL||"http://localhost:5173/";
|
||||||
|
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
|
||||||
|
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1});
|
||||||
|
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
|
||||||
|
await p.goto(URL,{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,700));
|
||||||
|
const lineCount=()=>p.$$eval(".plan-svg line",ls=>ls.length).catch(()=>-1);
|
||||||
|
const focusCmd=async()=>{ await p.mouse.click(640,760); await p.keyboard.press("Tab"); await new Promise(r=>setTimeout(r,80)); };
|
||||||
|
const typeLine=async t=>{ await p.type(".cmdline-input",t,{delay:8}); await p.keyboard.press("Enter"); await new Promise(r=>setTimeout(r,90)); };
|
||||||
|
const prompt=()=>p.evaluate(()=>document.querySelector(".cmdline-prompt")?.textContent||"");
|
||||||
|
|
||||||
|
const l0=await lineCount();
|
||||||
|
// Polyline (offener Zug, Enter beendet)
|
||||||
|
await focusCmd(); await typeLine("polyline");
|
||||||
|
const pp=await prompt();
|
||||||
|
await typeLine("1,1"); await typeLine("r2,0"); await typeLine("r0,1.5");
|
||||||
|
await p.keyboard.press("Enter"); await new Promise(r=>setTimeout(r,120)); // beenden
|
||||||
|
const l1=await lineCount();
|
||||||
|
// Rectangle
|
||||||
|
await focusCmd(); await typeLine("rect");
|
||||||
|
const rp=await prompt();
|
||||||
|
await typeLine("0.5,3"); await typeLine("r3,1.5");
|
||||||
|
const l2=await lineCount();
|
||||||
|
// Circle (Radius getippt)
|
||||||
|
await focusCmd(); await typeLine("circle");
|
||||||
|
const cp=await prompt();
|
||||||
|
await typeLine("4,4"); await typeLine("0.8");
|
||||||
|
const l3=await lineCount();
|
||||||
|
await p.screenshot({path:"scripts/probe-draw-commands.png"});
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
polyPrompt:pp, rectPrompt:rp, circPrompt:cp,
|
||||||
|
lines_start:l0, after_polyline:l1, after_rect:l2, after_circle:l3,
|
||||||
|
polyAdded:l1-l0, rectAdded:l2-l1, circleAdded:l3-l2, errs
|
||||||
|
},null,2));
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const PORT = process.env.PORT || "5173";
|
||||||
|
const b = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const p = await b.newPage();
|
||||||
|
await p.setViewport({ width: 1400, height: 420, deviceScaleFactor: 2 });
|
||||||
|
await p.goto(`http://localhost:${PORT}/`, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
|
||||||
|
// Detailgrad-Dropdown finden (Trigger mit aria-label = Detailgrad-Hint) und öffnen.
|
||||||
|
// Wir klicken den Trigger in der Detailgrad-Gruppe.
|
||||||
|
const opened = await p.evaluate(() => {
|
||||||
|
const groups = [...document.querySelectorAll(".tb-group")];
|
||||||
|
const detailGroup = groups.find((g) =>
|
||||||
|
(g.getAttribute("aria-label") || "").includes("Detailgrad"),
|
||||||
|
);
|
||||||
|
const trig = detailGroup?.querySelector(".tb-dd-trigger");
|
||||||
|
if (!trig) return { ok: false };
|
||||||
|
trig.click();
|
||||||
|
return { ok: true, label: trig.textContent };
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
|
||||||
|
const info = await p.evaluate(() => {
|
||||||
|
const menu = document.querySelector(".tb-dd-menu");
|
||||||
|
if (!menu) return { menu: false };
|
||||||
|
const cs = getComputedStyle(menu);
|
||||||
|
const items = [...menu.querySelectorAll(".tb-dd-item")].map((it) => ({
|
||||||
|
label: it.querySelector(".ctx-item-label")?.textContent,
|
||||||
|
active: it.getAttribute("aria-selected") === "true",
|
||||||
|
isActive: it.classList.contains("is-active"),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
menu: true,
|
||||||
|
bg: cs.backgroundColor,
|
||||||
|
radius: cs.borderRadius,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const stillOpen = await p.evaluate(() => !!document.querySelector(".tb-dd-menu"));
|
||||||
|
console.log(JSON.stringify({ opened, info, stillOpen }, null, 2));
|
||||||
|
|
||||||
|
// Mount-Animation (ctx-pop) für den Screenshot deaktivieren, damit das Popover
|
||||||
|
// im Headless-Capture sicher voll deckend gerendert ist.
|
||||||
|
await p.addStyleTag({ content: ".tb-dd-menu{animation:none !important;}" });
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
await p.screenshot({ path: "scripts/probe-dropdown.png", clip: { x: 0, y: 0, width: 1400, height: 320 } });
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Browser-Laufzeittest: echter DWG-Import über den App-Import-Pfad.
|
||||||
|
// Lädt eine echte DWG über das versteckte File-Input (uploadFile), wartet bis
|
||||||
|
// LibreDWG-WASM geparst hat, und screenshotet den Import-Dialog mit der ECHTEN
|
||||||
|
// Zusammenfassung (Konturen/Layer) statt des ODA-Hinweises.
|
||||||
|
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const URL = process.env.URL || "http://localhost:5173/";
|
||||||
|
const SAMPLE =
|
||||||
|
"/tmp/claude-1000/-home-karim-cad/65983130-c639-428b-bd68-85eafeb48d98/scratchpad/sample.dwg";
|
||||||
|
|
||||||
|
const b = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const p = await b.newPage();
|
||||||
|
await p.setViewport({ width: 1300, height: 900, deviceScaleFactor: 2 });
|
||||||
|
const logs = [];
|
||||||
|
p.on("pageerror", (e) => logs.push("ERR:" + e.message));
|
||||||
|
p.on("console", (m) => {
|
||||||
|
if (m.type() === "error") logs.push("CONSOLE:" + m.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
|
||||||
|
await new Promise((r) => setTimeout(r, 700));
|
||||||
|
|
||||||
|
// File-Input mit accept=".dxf,.dwg" finden und die echte DWG hochladen.
|
||||||
|
const input = await p.$('input[type=file][accept*=".dwg"]');
|
||||||
|
if (!input) {
|
||||||
|
console.log("FAIL: DWG-File-Input nicht gefunden.");
|
||||||
|
await b.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await input.uploadFile(SAMPLE);
|
||||||
|
|
||||||
|
// Auf Ladehinweis screenshoten (kann sehr kurz sein), dann auf Ergebnis warten.
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
await p.screenshot({ path: "scripts/probe-dwg-loading.png" }).catch(() => {});
|
||||||
|
|
||||||
|
// Warten, bis der Dialog die echte Zusammenfassung zeigt (oder bis Timeout).
|
||||||
|
let summaryText = "";
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
summaryText = await p.evaluate(() => {
|
||||||
|
const sum = document.querySelector(".imp-summary");
|
||||||
|
const hint = document.querySelector(".imp-dwg-hint");
|
||||||
|
return JSON.stringify({
|
||||||
|
summary: sum ? sum.textContent : null,
|
||||||
|
hint: hint ? hint.textContent : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const parsed = JSON.parse(summaryText);
|
||||||
|
if (parsed.summary) break; // echte Zusammenfassung erschienen
|
||||||
|
}
|
||||||
|
|
||||||
|
await p.screenshot({ path: "scripts/probe-dwg-import.png" });
|
||||||
|
const parsed = JSON.parse(summaryText);
|
||||||
|
console.log("Dialog-Zusammenfassung:", parsed.summary);
|
||||||
|
console.log("ODA-Hinweis (sollte null sein bei Erfolg):", parsed.hint);
|
||||||
|
console.log("Logs:", logs.length ? logs.join(" | ") : "(keine)");
|
||||||
|
|
||||||
|
if (!parsed.summary) {
|
||||||
|
console.log("FAIL: keine echte Zusammenfassung — DWG nicht geparst.");
|
||||||
|
await b.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("OK: DWG über den Import-Pfad geparst, echte Zusammenfassung sichtbar.");
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Node-Laufzeittest für den echten DWG-Import-Pfad (LibreDWG-WASM).
|
||||||
|
//
|
||||||
|
// Bündelt src/io/dwgParser.ts (samt Model-Typen) per esbuild zu einem ESM-Modul
|
||||||
|
// und ruft parseDwg() mit echten DWG-Beispieldateien auf. Loggt die erkannten
|
||||||
|
// Konturen/Meshes — spiegelt damit exakt das, was der Import-Dialog anzeigen
|
||||||
|
// würde (dieselbe parseDwg-Funktion, die auch der Browser-Import nutzt).
|
||||||
|
//
|
||||||
|
// Aufruf: node scripts/probe-dwg.mjs [pfad/zur.dwg ...]
|
||||||
|
// Ohne Argumente werden einige bekannte System-Beispiele probiert.
|
||||||
|
|
||||||
|
import { build } from "esbuild";
|
||||||
|
import { readFile, writeFile, unlink, access } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = resolve(__dirname, "..");
|
||||||
|
|
||||||
|
// parseDwg + Abhängigkeiten zu einem temporären ESM-Bundle backen. WASM-Lib
|
||||||
|
// bleibt extern (wird zur Laufzeit aus node_modules geladen → echtes WASM).
|
||||||
|
const outFile = resolve(root, "scripts/_dwgparser.bundle.mjs");
|
||||||
|
await build({
|
||||||
|
entryPoints: [resolve(root, "src/io/dwgParser.ts")],
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
platform: "node",
|
||||||
|
outfile: outFile,
|
||||||
|
external: ["@mlightcad/libredwg-web"],
|
||||||
|
logLevel: "warning",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { parseDwg } = await import(outFile + "?t=" + Date.now());
|
||||||
|
|
||||||
|
const candidates =
|
||||||
|
process.argv.slice(2).length > 0
|
||||||
|
? process.argv.slice(2)
|
||||||
|
: [
|
||||||
|
"/home/karim/Downloads/02-15-cad-blocks-net-bushes-elevation.dwg",
|
||||||
|
"/home/karim/ssd/Nextcloud2 (Copy)/00.2 Situation Fluhmatt Bodengeschosse.dwg",
|
||||||
|
"/tmp/probe.dwg",
|
||||||
|
];
|
||||||
|
|
||||||
|
let anyOk = false;
|
||||||
|
for (const path of candidates) {
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
} catch {
|
||||||
|
console.log(`SKIP (nicht gefunden): ${path}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const buf = await readFile(path);
|
||||||
|
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||||||
|
const t0 = Date.now();
|
||||||
|
const res = await parseDwg(ab);
|
||||||
|
const dt = Date.now() - t0;
|
||||||
|
const contourSets = res.contours;
|
||||||
|
const contourCount = contourSets.reduce(
|
||||||
|
(s, set) => s + set.contours.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const layers = new Set();
|
||||||
|
for (const set of contourSets)
|
||||||
|
for (const c of set.contours) if (c.layer) layers.add(c.layer);
|
||||||
|
const triCount = res.meshes.reduce((s, m) => s + m.indices.length / 3, 0);
|
||||||
|
console.log(`\nOK ${path} (${dt} ms)`);
|
||||||
|
console.log(` Konturen-Sätze: ${contourSets.length}`);
|
||||||
|
console.log(` Konturen: ${contourCount}`);
|
||||||
|
console.log(` Meshes: ${res.meshes.length} (${triCount} Dreiecke)`);
|
||||||
|
console.log(
|
||||||
|
` Layer: ${
|
||||||
|
layers.size ? [...layers].slice(0, 12).join(", ") : "(keine)"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
// Beispiel-Kontur (erste mit ≥2 Punkten) ausgeben.
|
||||||
|
const sample = contourSets
|
||||||
|
.flatMap((s) => s.contours)
|
||||||
|
.find((c) => c.pts.length >= 2);
|
||||||
|
if (sample) {
|
||||||
|
console.log(
|
||||||
|
` Beispiel-Kontur: ${sample.pts.length} Pkt, closed=${sample.closed}, z=${sample.z}, layer=${sample.layer ?? "-"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
anyOk = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`\nFEHLER ${path}:`, err?.message ?? err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await unlink(outFile).catch(() => {});
|
||||||
|
|
||||||
|
if (!anyOk) {
|
||||||
|
console.log("\nKeine DWG erfolgreich gelesen.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
|
||||||
|
await new Promise((r) => setTimeout(r, 700));
|
||||||
|
|
||||||
|
const click = async (x, y) => { await page.mouse.move(x, y); await page.mouse.click(x, y); await new Promise((r) => setTimeout(r, 100)); };
|
||||||
|
const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
|
||||||
|
|
||||||
|
// 1) Auswahl-Werkzeug ist Default: die OBERE Wand des Beispiel-Raums anklicken
|
||||||
|
// → Eckpunkt-Griffe (Quadrate) + 1 dreieckiger Kanten-Anfasser erscheinen.
|
||||||
|
const tx = box.x + box.w * 0.5;
|
||||||
|
const ty = box.y + box.h * 0.26; // mittig auf das obere Wandband
|
||||||
|
await click(tx, ty);
|
||||||
|
const c1 = await page.evaluate(() => ({
|
||||||
|
grips: document.querySelectorAll(".plan-svg .plan-grip").length,
|
||||||
|
edges: document.querySelectorAll(".plan-svg .plan-edge-grip").length,
|
||||||
|
}));
|
||||||
|
await page.screenshot({ path: "scripts/probe-edge-grips-selected.png" });
|
||||||
|
|
||||||
|
// 2) Den Kanten-Anfasser (sitzt etwas OBERHALB der Kante, da Außennormale nach
|
||||||
|
// oben zeigt) nach OBEN ziehen → die ganze Wand verschiebt sich senkrecht.
|
||||||
|
const handleX = tx;
|
||||||
|
const handleY = ty - 13; // EDGE_GRIP_OFFSET_PX nach außen (oben)
|
||||||
|
await page.mouse.move(handleX, handleY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(handleX, handleY - 100, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const c2 = await page.evaluate(() => ({
|
||||||
|
grips: document.querySelectorAll(".plan-svg .plan-grip").length,
|
||||||
|
edges: document.querySelectorAll(".plan-svg .plan-edge-grip").length,
|
||||||
|
}));
|
||||||
|
await page.screenshot({ path: "scripts/probe-edge-grips-dragged.png" });
|
||||||
|
|
||||||
|
console.log("after select:", JSON.stringify(c1));
|
||||||
|
console.log("after drag:", JSON.stringify(c2));
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(no errors)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
|
||||||
|
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1});
|
||||||
|
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
|
||||||
|
await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,700));
|
||||||
|
// Screen-bbox aller Wand-Polygone bestimmen, dann auf die obere Bandkante klicken.
|
||||||
|
const bb=await p.evaluate(()=>{
|
||||||
|
const polys=[...document.querySelectorAll(".plan-svg polygon")];
|
||||||
|
if(!polys.length) return null;
|
||||||
|
let minX=1e9,minY=1e9,maxX=-1e9,maxY=-1e9;
|
||||||
|
for(const pl of polys){ const r=pl.getBoundingClientRect(); minX=Math.min(minX,r.left);minY=Math.min(minY,r.top);maxX=Math.max(maxX,r.right);maxY=Math.max(maxY,r.bottom);}
|
||||||
|
return {minX,minY,maxX,maxY};
|
||||||
|
});
|
||||||
|
let sel=false;
|
||||||
|
if(bb){
|
||||||
|
const cx=(bb.minX+bb.maxX)/2;
|
||||||
|
// ein paar Punkte auf der oberen Bandkante probieren
|
||||||
|
for(const dy of [4,8,12,16]){
|
||||||
|
await p.mouse.click(cx, bb.minY+dy); await new Promise(r=>setTimeout(r,120));
|
||||||
|
const k=await p.evaluate(()=>document.querySelector(".objinfo-kind")?.textContent||"");
|
||||||
|
if(/Wand/.test(k)){ sel=true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dims=()=>p.evaluate(()=>[...document.querySelectorAll(".objinfo-input")].map(i=>i.value));
|
||||||
|
const before=await dims();
|
||||||
|
const tri=await p.evaluate(()=>{
|
||||||
|
const el=document.querySelector(".plan-edge-grip"); if(!el) return null;
|
||||||
|
const pts=el.getAttribute("points").trim().split(/\s+/).map(s=>s.split(",").map(Number));
|
||||||
|
const svg=el.ownerSVGElement; const ctm=svg.getScreenCTM();
|
||||||
|
const cs=pts.map(([x,y])=>{const pt=svg.createSVGPoint();pt.x=x;pt.y=y;const sp=pt.matrixTransform(ctm);return [sp.x,sp.y];});
|
||||||
|
return {cx:cs.reduce((a,c)=>a+c[0],0)/3, cy:cs.reduce((a,c)=>a+c[1],0)/3, count:document.querySelectorAll(".plan-edge-grip").length};
|
||||||
|
});
|
||||||
|
let after=before, dragged=false;
|
||||||
|
if(tri){
|
||||||
|
await p.mouse.move(tri.cx,tri.cy); await p.mouse.down();
|
||||||
|
for(let i=1;i<=6;i++){ await p.mouse.move(tri.cx, tri.cy - i*8); await new Promise(r=>setTimeout(r,20)); }
|
||||||
|
await p.mouse.up(); await new Promise(r=>setTimeout(r,200));
|
||||||
|
after=await dims(); dragged=true;
|
||||||
|
}
|
||||||
|
await p.screenshot({path:"scripts/probe-edge2.png"});
|
||||||
|
console.log(JSON.stringify({wallSelected:sel, edgeGripCount:tri&&tri.count, dims_before:before, dims_after:after, errs}, null, 2));
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// Probe für die Tier-1-Editierbefehle: Move / Copy / Offset.
|
||||||
|
//
|
||||||
|
// Treibt das Rhino-artige Befehlssystem headless und prüft die EXAKTE Geometrie
|
||||||
|
// aus dem gerenderten SVG (Drawing2D → `line.draw2d`, Screen→Modell via
|
||||||
|
// model = {x: x/90, y: -y/90}; PX_PER_M=90, Y-flip). Schritte:
|
||||||
|
// 1) line „0,0"→„r4,0" → eine waagerechte 4-m-Linie bei y=0
|
||||||
|
// 2) Linie anklicken (selektieren) → move „0,0"→„0,2" → Linie liegt bei y=2
|
||||||
|
// 3) copy „0,0"→„r2,0" → zwei Linien (Original + Kopie 2 m rechts)
|
||||||
|
// 4) eine Polylinie zeichnen + selektieren → offset Distanz 0.5 + Seite
|
||||||
|
// → zweite, parallel um 0.5 versetzte Kurve
|
||||||
|
//
|
||||||
|
// Vorlage: scripts/probe-command-line.mjs / probe-draw-commands.mjs.
|
||||||
|
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1280, height: 820, deviceScaleFactor: 1 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
} catch (e) {
|
||||||
|
logs.push(`[GOTO-ERROR] ${e.message}`);
|
||||||
|
}
|
||||||
|
await sleep(600);
|
||||||
|
|
||||||
|
const PX = 90; // PX_PER_M
|
||||||
|
|
||||||
|
// Liest alle Drawing2D-Liniensegmente als Modell-Koordinaten (Screen→Modell).
|
||||||
|
async function segs() {
|
||||||
|
return page.evaluate((PX) => {
|
||||||
|
const out = [];
|
||||||
|
for (const ln of document.querySelectorAll(".plan-svg line.draw2d")) {
|
||||||
|
// Bildschirm-CTM: SVG-User → Screen. Wir wollen Modell, daher rechnen wir
|
||||||
|
// über die Element-Endpunkte im SVG-User-Raum (x1/y1/x2/y2) zurück.
|
||||||
|
const x1 = parseFloat(ln.getAttribute("x1"));
|
||||||
|
const y1 = parseFloat(ln.getAttribute("y1"));
|
||||||
|
const x2 = parseFloat(ln.getAttribute("x2"));
|
||||||
|
const y2 = parseFloat(ln.getAttribute("y2"));
|
||||||
|
out.push({
|
||||||
|
a: { x: x1 / PX, y: -y1 / PX },
|
||||||
|
b: { x: x2 / PX, y: -y2 / PX },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, PX);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klick-Zentrum eines .draw2d-Segments in Client-Pixeln (über BoundingClientRect),
|
||||||
|
// damit die Auswahl pan/zoom-unabhängig trifft. `idx` = welches Segment.
|
||||||
|
async function clickSegment(idx) {
|
||||||
|
const box = await page.evaluate((idx) => {
|
||||||
|
const lines = document.querySelectorAll(".plan-svg line.draw2d");
|
||||||
|
const ln = lines[idx];
|
||||||
|
if (!ln) return null;
|
||||||
|
const r = ln.getBoundingClientRect();
|
||||||
|
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||||
|
}, idx);
|
||||||
|
if (!box) {
|
||||||
|
logs.push(`[WARN] Segment ${idx} nicht gefunden (übersprungen)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await page.mouse.click(box.x, box.y);
|
||||||
|
await sleep(120);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fokussiert die Command-Line DIREKT (ohne Plan-Klick — ein Plan-Klick würde die
|
||||||
|
// Auswahl löschen, die Move/Copy/Offset gerade brauchen). Das Input ist immer im
|
||||||
|
// DOM; ein Fokus genügt, danach landet getippter Text in der Engine.
|
||||||
|
const focusCmd = async () => {
|
||||||
|
await page.focus(".cmdline-input");
|
||||||
|
await sleep(60);
|
||||||
|
};
|
||||||
|
const typeLine = async (t) => {
|
||||||
|
await page.type(".cmdline-input", t, { delay: 8 });
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await sleep(110);
|
||||||
|
};
|
||||||
|
const prompt = () =>
|
||||||
|
page.evaluate(() => document.querySelector(".cmdline-prompt")?.textContent || "");
|
||||||
|
|
||||||
|
// Hilfsfunktion: nahe Gleichheit.
|
||||||
|
const near = (a, b, eps = 0.02) => Math.abs(a - b) <= eps;
|
||||||
|
|
||||||
|
// ── 1) Linie zeichnen: 0,0 → r4,0 (waagerecht, y=0) ─────────────────────────
|
||||||
|
await focusCmd();
|
||||||
|
await typeLine("line");
|
||||||
|
await typeLine("0,0");
|
||||||
|
await typeLine("r4,0");
|
||||||
|
await sleep(150);
|
||||||
|
const afterLine = await segs();
|
||||||
|
|
||||||
|
// ── 2) Linie selektieren + Move 0,0 → 0,2 ───────────────────────────────────
|
||||||
|
await clickSegment(0); // die eine Linie wählen
|
||||||
|
const gripsAfterClick = await page.evaluate(
|
||||||
|
() => document.querySelectorAll(".plan-svg .plan-grip").length,
|
||||||
|
);
|
||||||
|
await focusCmd();
|
||||||
|
const gripsAfterFocus = await page.evaluate(
|
||||||
|
() => document.querySelectorAll(".plan-svg .plan-grip").length,
|
||||||
|
);
|
||||||
|
const moveSelInfo = gripsAfterClick;
|
||||||
|
logs.push(`[DBG] grips afterClick=${gripsAfterClick} afterFocus=${gripsAfterFocus}`);
|
||||||
|
await typeLine("move");
|
||||||
|
const movePrompt = await prompt();
|
||||||
|
await typeLine("0,0");
|
||||||
|
const moveTargetPrompt = await prompt();
|
||||||
|
await typeLine("0,2");
|
||||||
|
await sleep(150);
|
||||||
|
const afterMove = await segs();
|
||||||
|
await page.screenshot({ path: "scripts/probe-edit-move.png" });
|
||||||
|
|
||||||
|
// ── 3) Copy: 0,0 → r2,0 (Auswahl bleibt nach Move bestehen) ─────────────────
|
||||||
|
// Move hält die Auswahl (selectedDrawingId unverändert). Falls nicht, neu wählen.
|
||||||
|
await clickSegment(0);
|
||||||
|
await focusCmd();
|
||||||
|
await typeLine("copy");
|
||||||
|
const copyPrompt = await prompt();
|
||||||
|
await typeLine("0,0");
|
||||||
|
logs.push(`[DBG] copy after base: segs=${(await segs()).length}`);
|
||||||
|
await typeLine("r2,0");
|
||||||
|
logs.push(`[DBG] copy after r2,0: segs=${(await segs()).length}`);
|
||||||
|
await page.keyboard.press("Enter"); // Copy-Serie beenden
|
||||||
|
await sleep(150);
|
||||||
|
const afterCopy = await segs();
|
||||||
|
logs.push(`[DBG] copy after Enter: segs=${afterCopy.length}`);
|
||||||
|
await page.screenshot({ path: "scripts/probe-edit-copy.png" });
|
||||||
|
|
||||||
|
// ── 4) Offset einer Polylinie um 0.5 ────────────────────────────────────────
|
||||||
|
// Frische Polylinie: ein offener L-Zug (3,-3)→(7,-3)→(7,-1). Tief unten (y<0),
|
||||||
|
// damit sie sich nicht mit den obigen Linien überlagert.
|
||||||
|
await focusCmd();
|
||||||
|
await typeLine("polyline");
|
||||||
|
await typeLine("3,-3");
|
||||||
|
await typeLine("7,-3");
|
||||||
|
await typeLine("7,-1");
|
||||||
|
await page.keyboard.press("Enter"); // offenen Zug beenden
|
||||||
|
await sleep(150);
|
||||||
|
const afterPoly = await segs();
|
||||||
|
logs.push(`[DBG] afterPoly segs=${JSON.stringify(afterPoly.map((s) => ({ a: { x: +s.a.x.toFixed(2), y: +s.a.y.toFixed(2) }, b: { x: +s.b.x.toFixed(2), y: +s.b.y.toFixed(2) } })))}`);
|
||||||
|
|
||||||
|
// Eines der Polylinien-Segmente selektieren (das letzte gezeichnete Segment).
|
||||||
|
// Wir suchen ein Segment mit y≈-3 (die untere waagerechte Kante).
|
||||||
|
const polyIdx = await page.evaluate((PX) => {
|
||||||
|
const lines = [...document.querySelectorAll(".plan-svg line.draw2d")];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const ln = lines[i];
|
||||||
|
const y1 = -parseFloat(ln.getAttribute("y1")) / PX;
|
||||||
|
const y2 = -parseFloat(ln.getAttribute("y2")) / PX;
|
||||||
|
if (Math.abs(y1 + 3) < 0.05 && Math.abs(y2 + 3) < 0.05) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}, PX);
|
||||||
|
await clickSegment(polyIdx);
|
||||||
|
const offsetSelGrips = await page.evaluate(
|
||||||
|
() => document.querySelectorAll(".plan-svg .plan-grip").length,
|
||||||
|
);
|
||||||
|
|
||||||
|
await focusCmd();
|
||||||
|
await typeLine("offset");
|
||||||
|
const offsetPrompt = await prompt();
|
||||||
|
await typeLine("0.5"); // Distanz tippen (Seite via Vorzeichen/Default links)
|
||||||
|
await sleep(150);
|
||||||
|
const afterOffset = await segs();
|
||||||
|
await page.screenshot({ path: "scripts/probe-edit-offset.png" });
|
||||||
|
|
||||||
|
// ── Auswertung ───────────────────────────────────────────────────────────────
|
||||||
|
function summarize(list) {
|
||||||
|
return list.map((s) => ({
|
||||||
|
a: { x: +s.a.x.toFixed(3), y: +s.a.y.toFixed(3) },
|
||||||
|
b: { x: +s.b.x.toFixed(3), y: +s.b.y.toFixed(3) },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move-Check: nach Move soll genau eine Linie existieren, beide Endpunkte y≈2.
|
||||||
|
const moveLine = afterMove.find(
|
||||||
|
(s) => near(s.a.y, 2) && near(s.b.y, 2) && (near(s.a.x, 0) || near(s.b.x, 0)),
|
||||||
|
);
|
||||||
|
const moveOk =
|
||||||
|
afterLine.length === afterMove.length && // gleiche Anzahl (kein neues Element)
|
||||||
|
!!moveLine &&
|
||||||
|
near(Math.abs(moveLine.b.x - moveLine.a.x), 4); // Länge bleibt 4 m
|
||||||
|
|
||||||
|
// Copy-Check: nach Copy zwei waagerechte Linien bei y≈2; eine bei x∈[0,4], eine
|
||||||
|
// um 2 m nach rechts (x∈[2,6]).
|
||||||
|
const horizAtY2 = afterCopy.filter((s) => near(s.a.y, 2) && near(s.b.y, 2));
|
||||||
|
const xsMin = horizAtY2.map((s) => Math.min(s.a.x, s.b.x)).sort((p, q) => p - q);
|
||||||
|
const copyOk =
|
||||||
|
afterCopy.length === afterMove.length + 1 && // genau ein Segment dazu
|
||||||
|
horizAtY2.length === 2 &&
|
||||||
|
near(xsMin[0], 0) &&
|
||||||
|
near(xsMin[1], 2);
|
||||||
|
|
||||||
|
// Offset-Check: das Offset fügt eine versetzte Polylinie hinzu. Wir prüfen, dass
|
||||||
|
// eine NEUE untere Kante bei y≈-3±0.5 existiert (parallel, Abstand 0.5). Die
|
||||||
|
// Seite (oben/unten) ergibt sich aus der Default-Klickseite; wir akzeptieren
|
||||||
|
// beide Vorzeichen, prüfen aber den Abstand = 0.5.
|
||||||
|
const addedByOffset = afterOffset.length - afterPoly.length;
|
||||||
|
// Untere Original-Kante: y=-3, von x=3..7. Versetzte Kante: y=-3±0.5.
|
||||||
|
const bottomEdges = afterOffset.filter(
|
||||||
|
(s) => near(s.a.y, s.b.y) && Math.abs(s.a.x - s.b.x) > 1.0, // waagerechtes Segment
|
||||||
|
);
|
||||||
|
const ysBottom = [...new Set(bottomEdges.map((s) => +s.a.y.toFixed(3)))].sort(
|
||||||
|
(p, q) => p - q,
|
||||||
|
);
|
||||||
|
// Erwartung: y=-3 (Original) + eine parallele Kante 0.5 entfernt.
|
||||||
|
const hasParallel = ysBottom.some(
|
||||||
|
(y) => near(Math.abs(y - -3), 0.5) && !near(y, -3),
|
||||||
|
);
|
||||||
|
const offsetOk = addedByOffset >= 2 && hasParallel;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
prompts: { movePrompt, moveTargetPrompt, copyPrompt, offsetPrompt },
|
||||||
|
counts: {
|
||||||
|
afterLine: afterLine.length,
|
||||||
|
afterMove: afterMove.length,
|
||||||
|
afterCopy: afterCopy.length,
|
||||||
|
afterPoly: afterPoly.length,
|
||||||
|
afterOffset: afterOffset.length,
|
||||||
|
addedByOffset,
|
||||||
|
},
|
||||||
|
grips: { moveSelInfo, offsetSelGrips },
|
||||||
|
moveLine: moveLine ? summarize([moveLine])[0] : null,
|
||||||
|
horizAtY2: summarize(horizAtY2),
|
||||||
|
ysBottom,
|
||||||
|
checks: { moveOk, copyOk, offsetOk },
|
||||||
|
verdict: moveOk && copyOk && offsetOk ? "PASS" : "FAIL",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (logs.length) {
|
||||||
|
console.log("=== Logs ===");
|
||||||
|
console.log(logs.join("\n"));
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
const clickTool = (label) => page.evaluate((l) => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === l)?.click(), label);
|
||||||
|
const click = async (x, y, opts) => { await page.mouse.move(x, y); await page.mouse.click(x, y, opts); await new Promise((r) => setTimeout(r, 60)); };
|
||||||
|
const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
|
||||||
|
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
|
||||||
|
|
||||||
|
// ── 1) Aktive Ebene auf "60 Plangrafik" (#c0a040) setzen, Linie zeichnen ──
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const sel = [...document.querySelectorAll("select")].find((s) => [...s.options].some((o) => o.value === "60"));
|
||||||
|
if (sel) sel.id = "ebene-test";
|
||||||
|
});
|
||||||
|
await page.select("#ebene-test", "60");
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
await clickTool("Linie");
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
await click(cx - 150, cy - 120); await click(cx + 120, cy - 60);
|
||||||
|
await clickTool("Auswahl");
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
const lineColor = await page.evaluate(() => document.querySelector(".plan-svg line.draw2d")?.getAttribute("stroke"));
|
||||||
|
|
||||||
|
// ── 2) Parallel verschieben: Linie selektieren, Körper greifen + ziehen ──
|
||||||
|
const mid = [(cx - 150 + cx + 120) / 2, (cy - 120 + cy - 60) / 2];
|
||||||
|
await click(mid[0], mid[1]); // selektieren
|
||||||
|
const before = await page.evaluate(() => { const l = document.querySelector(".plan-svg line.draw2d"); return { x1: +l.getAttribute("x1"), y1: +l.getAttribute("y1") }; });
|
||||||
|
await page.mouse.move(mid[0], mid[1]); await page.mouse.down();
|
||||||
|
await page.mouse.move(mid[0] + 60, mid[1] + 120, { steps: 6 }); await page.mouse.up();
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
const after = await page.evaluate(() => { const l = document.querySelector(".plan-svg line.draw2d"); return { x1: +l.getAttribute("x1"), y1: +l.getAttribute("y1") }; });
|
||||||
|
const moved = Math.hypot(after.x1 - before.x1, after.y1 - before.y1) > 5;
|
||||||
|
|
||||||
|
// ── 3) Shift-Ortho: Endpunkt-Griff mit Shift ziehen → achsparallel ──
|
||||||
|
// Neue Linie zeichnen.
|
||||||
|
await clickTool("Linie");
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
const a = [cx - 180, cy + 90], b = [cx - 40, cy + 140];
|
||||||
|
await click(...a); await click(...b);
|
||||||
|
await clickTool("Auswahl");
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
await click((a[0] + b[0]) / 2, (a[1] + b[1]) / 2); // selektieren
|
||||||
|
// Endpunkt b mit Shift senkrecht-ish wegziehen → sollte exakt H oder V werden.
|
||||||
|
await page.keyboard.down("Shift");
|
||||||
|
await page.mouse.move(b[0], b[1]); await page.mouse.down();
|
||||||
|
await page.mouse.move(b[0] + 140, b[1] + 20, { steps: 8 }); // fast horizontal
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.keyboard.up("Shift");
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
// Die gerade bearbeitete Linie ist die ZWEITE draw2d-Linie.
|
||||||
|
const orthoLine = await page.evaluate(() => {
|
||||||
|
const ls = document.querySelectorAll(".plan-svg line.draw2d");
|
||||||
|
const l = ls[ls.length - 1];
|
||||||
|
return { x1: +l.getAttribute("x1"), y1: +l.getAttribute("y1"), x2: +l.getAttribute("x2"), y2: +l.getAttribute("y2") };
|
||||||
|
});
|
||||||
|
const dx = Math.abs(orthoLine.x2 - orthoLine.x1), dy = Math.abs(orthoLine.y2 - orthoLine.y1);
|
||||||
|
const isOrtho = dx < 1 || dy < 1; // exakt H oder V
|
||||||
|
|
||||||
|
await page.screenshot({ path: "scripts/probe-edit2.png" });
|
||||||
|
console.log("lineColor:", lineColor, "(expect #c0a040)");
|
||||||
|
console.log("parallelMoved:", moved);
|
||||||
|
console.log("shiftOrtho dx,dy:", dx.toFixed(2), dy.toFixed(2), "isOrtho:", isOrtho);
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(no errors)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { firefox } from "playwright";
|
||||||
|
const browser = await firefox.launch({ firefoxUserPrefs: { "webgl.disabled": true } });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
try { await page.goto("http://localhost:5173/", { waitUntil: "networkidle", timeout: 20000 }); }
|
||||||
|
catch (e) { logs.push(`[GOTO-ERROR] ${e.message}`); }
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const root = await page.evaluate(() => { const r = document.getElementById("root"); return r ? r.innerHTML.length : -1; });
|
||||||
|
console.log("=== mit WebGL DEAKTIVIERT ===");
|
||||||
|
console.log("#root innerHTML Länge:", root, root === 0 ? "→ KOMPLETT WEISS (reproduziert!)" : "");
|
||||||
|
console.log(logs.join("\n"));
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { firefox } from "playwright";
|
||||||
|
const browser = await firefox.launch({ firefoxUserPrefs: { "webgl.disabled": true } });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1200, height: 760 } });
|
||||||
|
const logs = [];
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
await page.goto("http://localhost:5173/", { waitUntil: "networkidle", timeout: 20000 });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
const info = await page.evaluate(() => ({
|
||||||
|
rootLen: document.getElementById("root")?.innerHTML.length ?? -1,
|
||||||
|
hasFallback: !!document.querySelector(".viewport-fallback"),
|
||||||
|
hasPlanSvg: !!document.querySelector(".plan-svg polygon"),
|
||||||
|
header: document.querySelector(".brand")?.textContent?.trim().slice(0, 20),
|
||||||
|
}));
|
||||||
|
await page.screenshot({ path: "scripts/probe-ff-verify.png" });
|
||||||
|
console.log("=== Firefox OHNE WebGL — nach Fix ===");
|
||||||
|
console.log("root innerHTML Länge:", info.rootLen, info.rootLen > 0 ? "✅ nicht weiß" : "❌ weiß");
|
||||||
|
console.log("Fallback-Meldung sichtbar:", info.hasFallback ? "✅" : "❌");
|
||||||
|
console.log("Grundriss-SVG gerendert:", info.hasPlanSvg ? "✅" : "❌");
|
||||||
|
console.log("Header:", JSON.stringify(info.header));
|
||||||
|
console.log("PageErrors:", logs.length ? logs.join("\n") : "✅ keine");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { firefox } from "playwright";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await firefox.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, deviceScaleFactor: 2 });
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}\n${e.stack || ""}`));
|
||||||
|
page.on("requestfailed", (r) =>
|
||||||
|
logs.push(`[REQFAIL] ${r.url()} :: ${r.failure()?.errorText}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle", timeout: 20000 });
|
||||||
|
} catch (e) {
|
||||||
|
logs.push(`[GOTO-ERROR] ${e.message}`);
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const rootHtml = await page.evaluate(() => {
|
||||||
|
const r = document.getElementById("root");
|
||||||
|
return r ? r.innerHTML.slice(0, 300) : "NO #root";
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.screenshot({ path: "scripts/probe-ff.png" });
|
||||||
|
console.log("=== FIREFOX CONSOLE / ERRORS ===");
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(keine Logs)");
|
||||||
|
console.log("\n=== #root innerHTML (erste 300 Zeichen) ===");
|
||||||
|
console.log(rootHtml);
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
|
||||||
|
const clickTool = (label) =>
|
||||||
|
page.evaluate((l) => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === l)?.click(), label);
|
||||||
|
const click = async (x, y, opts) => { await page.mouse.move(x, y); await page.mouse.click(x, y, opts); await new Promise((r) => setTimeout(r, 80)); };
|
||||||
|
const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
|
||||||
|
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
|
||||||
|
|
||||||
|
// 1) Eine Wand zeichnen (A→B), Rechtsklick beendet.
|
||||||
|
await clickTool("Wand");
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
const A = [cx - 160, cy - 150], B = [cx + 160, cy - 150];
|
||||||
|
await click(...A); await click(...B);
|
||||||
|
await page.mouse.click(B[0], B[1], { button: "right" });
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
|
||||||
|
// 2) Auswahl-Werkzeug, Wand in der Mitte anklicken → Griffe erscheinen.
|
||||||
|
await clickTool("Auswahl");
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
await click(cx, cy - 150);
|
||||||
|
const gripsAfterSelect = await page.evaluate(() => document.querySelectorAll(".plan-svg .plan-grip").length);
|
||||||
|
await page.screenshot({ path: "scripts/probe-grips-selected.png" });
|
||||||
|
|
||||||
|
// 3) Den End-Griff bei B nach unten ziehen.
|
||||||
|
await page.mouse.move(B[0], B[1]);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(B[0], B[1] + 90, { steps: 6 });
|
||||||
|
await page.mouse.move(B[0] - 30, B[1] + 130, { steps: 6 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
await page.screenshot({ path: "scripts/probe-grips-dragged.png" });
|
||||||
|
|
||||||
|
// 4) Eine Linie zeichnen, auswählen, Endpunkt-Griff prüfen.
|
||||||
|
await clickTool("Linie");
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
const L1 = [cx - 180, cy + 120], L2 = [cx + 150, cy + 160];
|
||||||
|
await click(...L1); await click(...L2);
|
||||||
|
await clickTool("Auswahl");
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
// Mittig auf die Linie klicken (Mittelpunkt der beiden Endpunkte).
|
||||||
|
await click((L1[0] + L2[0]) / 2, (L1[1] + L2[1]) / 2);
|
||||||
|
const gripsLine = await page.evaluate(() => document.querySelectorAll(".plan-svg .plan-grip").length);
|
||||||
|
await page.screenshot({ path: "scripts/probe-grips-line.png" });
|
||||||
|
|
||||||
|
// 5) Linie löschen (Entf).
|
||||||
|
await page.keyboard.press("Delete");
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
const draw2dAfterDelete = await page.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d").length);
|
||||||
|
|
||||||
|
console.log("gripsAfterSelect:", gripsAfterSelect);
|
||||||
|
console.log("gripsLine:", gripsLine);
|
||||||
|
console.log("draw2dAfterDelete:", draw2dAfterDelete);
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(no errors)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Probe: DXF-Import-Dialog. Erzeugt eine kleine Test-DXF (LWPOLYLINE auf 2
|
||||||
|
// Layern), füttert sie in das versteckte Datei-Input, screenshotet den offenen
|
||||||
|
// Dialog, wählt „Neue Zeichnung" + Importieren und screenshotet den Grundriss.
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5174/";
|
||||||
|
|
||||||
|
// ── Minimale Test-DXF: 2 LWPOLYLINE auf Layern „Waende" und „Moeblierung". ──
|
||||||
|
const dxf = `0
|
||||||
|
SECTION
|
||||||
|
2
|
||||||
|
ENTITIES
|
||||||
|
0
|
||||||
|
LWPOLYLINE
|
||||||
|
8
|
||||||
|
Waende
|
||||||
|
90
|
||||||
|
5
|
||||||
|
70
|
||||||
|
1
|
||||||
|
10
|
||||||
|
0
|
||||||
|
20
|
||||||
|
0
|
||||||
|
10
|
||||||
|
8
|
||||||
|
20
|
||||||
|
0
|
||||||
|
10
|
||||||
|
8
|
||||||
|
20
|
||||||
|
5
|
||||||
|
10
|
||||||
|
0
|
||||||
|
20
|
||||||
|
5
|
||||||
|
10
|
||||||
|
0
|
||||||
|
20
|
||||||
|
0
|
||||||
|
0
|
||||||
|
LWPOLYLINE
|
||||||
|
8
|
||||||
|
Moeblierung
|
||||||
|
90
|
||||||
|
4
|
||||||
|
70
|
||||||
|
0
|
||||||
|
10
|
||||||
|
2
|
||||||
|
20
|
||||||
|
1
|
||||||
|
10
|
||||||
|
4
|
||||||
|
20
|
||||||
|
1
|
||||||
|
10
|
||||||
|
4
|
||||||
|
20
|
||||||
|
3
|
||||||
|
10
|
||||||
|
2
|
||||||
|
20
|
||||||
|
3
|
||||||
|
0
|
||||||
|
ENDSEC
|
||||||
|
0
|
||||||
|
EOF
|
||||||
|
`;
|
||||||
|
const dxfPath = join(tmpdir(), "probe-import.dxf");
|
||||||
|
writeFileSync(dxfPath, dxf);
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 1 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
|
||||||
|
// Datei in das versteckte DXF-Input legen → onChange öffnet den Dialog.
|
||||||
|
const input = await page.$('input[type="file"][accept*=".dxf"]');
|
||||||
|
if (!input) throw new Error("DXF-Datei-Input nicht gefunden");
|
||||||
|
await input.uploadFile(dxfPath);
|
||||||
|
|
||||||
|
await page.waitForSelector(".imp-dialog", { timeout: 5000 });
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
await page.screenshot({ path: "scripts/probe-import-dialog.png" });
|
||||||
|
console.log("Dialog-Screenshot: scripts/probe-import-dialog.png");
|
||||||
|
|
||||||
|
// Zusammenfassung auslesen (Konturen/Layer sichtbar?).
|
||||||
|
const summary = await page.$$eval(".imp-summary li", (lis) =>
|
||||||
|
lis.map((l) => l.textContent),
|
||||||
|
);
|
||||||
|
console.log("Zusammenfassung:", JSON.stringify(summary));
|
||||||
|
|
||||||
|
// Sicherstellen, dass „Neue Zeichnung" gewählt ist (Default), dann Importieren.
|
||||||
|
const importBtn = await page.$(".imp-btn.primary");
|
||||||
|
const disabled = await page.evaluate((b) => b.disabled, importBtn);
|
||||||
|
console.log("Import-Button disabled?", disabled);
|
||||||
|
await importBtn.click();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
|
||||||
|
// Dialog sollte geschlossen sein; Grundriss/Drawing-Ansicht der neuen Ebene.
|
||||||
|
const dialogGone = (await page.$(".imp-dialog")) === null;
|
||||||
|
console.log("Dialog geschlossen?", dialogGone);
|
||||||
|
|
||||||
|
// Anzahl Zeichnungsebenen-Zeilen + neuer „Zeichnung"-Eintrag prüfen (best effort).
|
||||||
|
const drawingCount = await page.evaluate(() => {
|
||||||
|
const project = window.__store?.getState?.()?.project;
|
||||||
|
if (!project) return null;
|
||||||
|
return {
|
||||||
|
drawingLevels: project.drawingLevels.map((z) => `${z.name}/${z.kind}`),
|
||||||
|
drawings2d: project.drawings2d.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log("Projekt:", JSON.stringify(drawingCount));
|
||||||
|
|
||||||
|
await page.screenshot({ path: "scripts/probe-import-plan.png" });
|
||||||
|
console.log("Plan-Screenshot: scripts/probe-import-plan.png");
|
||||||
|
|
||||||
|
console.log("\n=== Logs ===");
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(keine)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] });
|
||||||
|
const p = await b.newPage(); await p.setViewport({width:1300,height:850,deviceScaleFactor:2});
|
||||||
|
const logs=[]; p.on("pageerror",e=>logs.push(e.message));
|
||||||
|
await p.goto("http://localhost:5174/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,700));
|
||||||
|
// Ressourcen öffnen.
|
||||||
|
await p.evaluate(()=>[...document.querySelectorAll("button")].find(x=>x.textContent.trim().toLowerCase().includes("ressourc"))?.click());
|
||||||
|
await new Promise(r=>setTimeout(r,300));
|
||||||
|
// Tab "Linien".
|
||||||
|
await p.evaluate(()=>[...document.querySelectorAll(".res-tab-btn")].find(x=>/Linien|Lines/.test(x.textContent))?.click());
|
||||||
|
await new Promise(r=>setTimeout(r,200));
|
||||||
|
const before = await p.evaluate(()=>document.querySelectorAll(".res-line-preview").length);
|
||||||
|
const input = await p.$('.res-tab input[type=file]');
|
||||||
|
await input.uploadFile("/tmp/sample.lin");
|
||||||
|
await new Promise(r=>setTimeout(r,400));
|
||||||
|
const after = await p.evaluate(()=>document.querySelectorAll(".res-line-preview").length);
|
||||||
|
await p.screenshot({ path:"scripts/probe-import.png" });
|
||||||
|
console.log("lineStyles before:", before, "after:", after, "(expect +3)", logs.length?logs.join("|"):"(no err)");
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
|
||||||
|
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1});
|
||||||
|
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
|
||||||
|
await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,700));
|
||||||
|
const clickText=async t=>{const h=await p.evaluateHandle(t=>[...document.querySelectorAll("button")].find(b=>b.textContent.trim()===t),t);const el=h.asElement();if(el){await el.click();return true;}return false;};
|
||||||
|
await clickText("Perspektive"); await new Promise(r=>setTimeout(r,500));
|
||||||
|
await clickText("Isometrie"); await new Promise(r=>setTimeout(r,800));
|
||||||
|
await p.screenshot({path:"scripts/probe-iso.png"});
|
||||||
|
console.log("iso done, errs:", errs.join("|")||"none");
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] });
|
||||||
|
const p = await b.newPage();
|
||||||
|
await p.setViewport({ width:1200, height:800, deviceScaleFactor:2 });
|
||||||
|
await p.emulateMediaFeatures([{ name:"prefers-color-scheme", value:"dark" }]);
|
||||||
|
const logs=[]; p.on("pageerror",e=>logs.push(e.message));
|
||||||
|
await p.goto(URL,{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,600));
|
||||||
|
// Auf den "Ebenen"-Tab wechseln (TabStrip braucht Pointer-Events).
|
||||||
|
await p.evaluate(()=>{ const t=[...document.querySelectorAll(".tab")].find(e=>e.textContent.trim()==="Ebenen"); ["pointerdown","pointerup","click"].forEach(ev=>t.dispatchEvent(new MouseEvent(ev,{bubbles:true}))); });
|
||||||
|
await new Promise(r=>setTimeout(r,250));
|
||||||
|
const initialActive = await p.evaluate(()=>document.querySelector(".cat-row.active .cat-name")?.textContent ?? null);
|
||||||
|
// "Türen/Fenster" anklicken.
|
||||||
|
await p.evaluate(()=>{ const r=[...document.querySelectorAll(".cat-name")].find(e=>e.textContent.includes("Türen"))?.closest(".cat-row"); r?.click(); });
|
||||||
|
await new Promise(r=>setTimeout(r,250));
|
||||||
|
const afterActive = await p.evaluate(()=>document.querySelector(".cat-row.active .cat-name")?.textContent ?? null);
|
||||||
|
const statusLayer = await p.evaluate(()=>{ const f=[...document.querySelectorAll(".sb-field")].find(x=>x.textContent.trim().startsWith("EBENE")||x.textContent.includes("Ebene")); return f?.querySelector(".sb-val")?.textContent; });
|
||||||
|
// Jetzt eine Linie zeichnen → sollte auf Kategorie 21 (blau #5080c8) landen.
|
||||||
|
await p.evaluate(()=>[...document.querySelectorAll("button")].find(x=>x.textContent.trim()==="Linie")?.click());
|
||||||
|
await new Promise(r=>setTimeout(r,80));
|
||||||
|
const box = await p.evaluate(()=>{const r=document.querySelector(".plan-svg").getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height};});
|
||||||
|
const cx=box.x+box.w/2, cy=box.y+box.h/2;
|
||||||
|
for(const [x,y] of [[cx-120,cy-30],[cx+120,cy+30]]){ await p.mouse.move(x,y); await p.mouse.click(x,y); await new Promise(r=>setTimeout(r,60)); }
|
||||||
|
await p.evaluate(()=>[...document.querySelectorAll("button")].find(x=>x.textContent.trim()==="Auswahl")?.click());
|
||||||
|
await new Promise(r=>setTimeout(r,80));
|
||||||
|
const lineColor = await p.evaluate(()=>document.querySelector(".plan-svg line.draw2d")?.getAttribute("stroke"));
|
||||||
|
await p.screenshot({ path:"scripts/probe-layer.png" });
|
||||||
|
console.log("initialActive:", initialActive, "afterActive:", afterActive, "statusLayer:", statusLayer, "lineColor(expect #5080c8):", lineColor, logs.length?logs.join("|"):"(no err)");
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === "Linie")?.click());
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
|
||||||
|
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
|
||||||
|
// Zwei-Klick-Strecke quer durch den Raum.
|
||||||
|
for (const [x, y] of [[cx - 200, cy - 40], [cx + 200, cy + 90]]) {
|
||||||
|
await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.up();
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === "Auswahl")?.click());
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const draw2d = await page.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d").length);
|
||||||
|
await page.screenshot({ path: "scripts/probe-line.png" });
|
||||||
|
console.log("draw2d lines:", draw2d);
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(no errors)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
|
||||||
|
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1});
|
||||||
|
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
|
||||||
|
await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,700));
|
||||||
|
const focusCmd=async()=>{ const el=await p.$(".cmdline-input"); const bx=await el.boundingBox(); await p.mouse.click(bx.x+bx.width/2, bx.y+bx.height/2); await new Promise(r=>setTimeout(r,60)); };
|
||||||
|
const typeLine=async t=>{ await p.type(".cmdline-input",t,{delay:8}); await p.keyboard.press("Enter"); await new Promise(r=>setTimeout(r,90)); };
|
||||||
|
// 1) Linie zeichnen (1,4)->(4,4)
|
||||||
|
await focusCmd(); await typeLine("line"); await typeLine("1,2.5"); await typeLine("r3,0");
|
||||||
|
// 2) gerenderte draw2d-Linie finden, Mittelpunkt klicken (selektieren)
|
||||||
|
const mid=await p.evaluate(()=>{
|
||||||
|
const l=[...document.querySelectorAll(".plan-svg line.draw2d")][0]; if(!l) return null;
|
||||||
|
const svg=l.ownerSVGElement, ctm=svg.getScreenCTM();
|
||||||
|
const pt=(x,y)=>{const q=svg.createSVGPoint();q.x=x;q.y=y;const s=q.matrixTransform(ctm);return[s.x,s.y];};
|
||||||
|
const a=pt(+l.getAttribute("x1"),+l.getAttribute("y1")), b=pt(+l.getAttribute("x2"),+l.getAttribute("y2"));
|
||||||
|
return {x:(a[0]+b[0])/2, y:(a[1]+b[1])/2};
|
||||||
|
});
|
||||||
|
if(mid){ await p.mouse.click(mid.x, mid.y); await new Promise(r=>setTimeout(r,150)); }
|
||||||
|
const selKind=await p.evaluate(()=>document.querySelector(".objinfo-kind")?.textContent||"none");
|
||||||
|
// 3) offset 0.5
|
||||||
|
await focusCmd(); await typeLine("offset"); await typeLine("0.5");
|
||||||
|
// 4) Linien (Modellkoordinaten) auslesen
|
||||||
|
const segs=await p.evaluate(()=>[...document.querySelectorAll(".plan-svg line.draw2d")].map(l=>{
|
||||||
|
// toScreen: x*90, -y*90 → invert: model = (vbx/90, -vby/90)
|
||||||
|
return {x1:+l.getAttribute("x1")/90, y1:-l.getAttribute("y1")/90, x2:+l.getAttribute("x2")/90, y2:-l.getAttribute("y2")/90};
|
||||||
|
}));
|
||||||
|
await p.screenshot({path:"scripts/probe-offset2.png"});
|
||||||
|
console.log(JSON.stringify({selKind, segCount:segs.length, segs, errs},null,2));
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
|
||||||
|
const clickTool = (label) =>
|
||||||
|
page.evaluate((l) => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === l)?.click(), label);
|
||||||
|
const svgBox = () => page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
|
||||||
|
const clickAt = async (x, y) => { await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.up(); await new Promise((r) => setTimeout(r, 70)); };
|
||||||
|
|
||||||
|
const box = await svgBox();
|
||||||
|
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
|
||||||
|
|
||||||
|
// 1) Rechteck (zwei Ecken).
|
||||||
|
await clickTool("Rechteck");
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
await clickAt(cx - 210, cy - 110);
|
||||||
|
await clickAt(cx - 40, cy + 40);
|
||||||
|
|
||||||
|
// 2) Polylinie (offen, 4 Punkte, Rechtsklick beendet).
|
||||||
|
await clickTool("Polylinie");
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
const poly = [[cx + 40, cy - 100], [cx + 200, cy - 100], [cx + 200, cy + 30], [cx + 90, cy + 60]];
|
||||||
|
for (const [x, y] of poly) await clickAt(x, y);
|
||||||
|
await page.mouse.move(cx + 90, cy + 60);
|
||||||
|
await page.mouse.click(cx + 90, cy + 60, { button: "right" });
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
|
||||||
|
// 3) Snap-Menü öffnen (Fang) und screenshoten.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const b = [...document.querySelectorAll("button")].find((x) => x.textContent.trim().startsWith("Fang"));
|
||||||
|
if (b) b.click();
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
const popover = await page.evaluate(() => !!document.querySelector(".snap-popover"));
|
||||||
|
await page.screenshot({ path: "scripts/probe-phase2-snapmenu.png" });
|
||||||
|
// Menü schließen.
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
|
await clickTool("Auswahl");
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
const counts = await page.evaluate(() => ({
|
||||||
|
draw2dLines: document.querySelectorAll(".plan-svg line.draw2d").length,
|
||||||
|
}));
|
||||||
|
await page.screenshot({ path: "scripts/probe-phase2.png" });
|
||||||
|
console.log("popoverOpened:", popover);
|
||||||
|
console.log("counts:", JSON.stringify(counts));
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(no errors)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 2 });
|
||||||
|
const errs = [];
|
||||||
|
page.on("pageerror", (e) => errs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
try { await page.goto("http://localhost:5173/", { waitUntil: "networkidle0", timeout: 20000 }); }
|
||||||
|
catch { /* networkidle timeout ok */ }
|
||||||
|
await new Promise((r) => setTimeout(r, 1200));
|
||||||
|
|
||||||
|
// 1) Plan zoom: wheel-in over the plan center, then screenshot
|
||||||
|
const svg = await page.$(".plan-svg");
|
||||||
|
if (svg) {
|
||||||
|
const box = await svg.boundingBox();
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await page.mouse.wheel({ deltaY: -600 });
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
await page.screenshot({ path: "scripts/probe-zoom.png" });
|
||||||
|
|
||||||
|
// 2) Open Ressourcen manager, screenshot the table
|
||||||
|
const opened = await page.evaluate(() => {
|
||||||
|
const el = [...document.querySelectorAll("button, .res-trigger, [role=button]")]
|
||||||
|
.find((b) => /Ressourcen/i.test(b.textContent || ""));
|
||||||
|
if (el) { el.click(); return true; }
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
await page.screenshot({ path: "scripts/probe-rm.png" });
|
||||||
|
|
||||||
|
const info = await page.evaluate(() => ({
|
||||||
|
managerOpen: !!document.querySelector(".res-drawer, .res-table, [class*=res-]"),
|
||||||
|
hasTable: !!document.querySelector(".res-table, table, .res-thead"),
|
||||||
|
triggerFound: [...document.querySelectorAll("button")].some((b)=>/Ressourcen/i.test(b.textContent||"")),
|
||||||
|
}));
|
||||||
|
console.log("opened trigger:", opened, JSON.stringify(info));
|
||||||
|
console.log("pageerrors:", errs.length ? errs.join("\n") : "none");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// Probe für die Migration auf Component/HatchStyle/LineStyle + den Ressourcen-
|
||||||
|
// Manager. Erfasst (deviceScaleFactor 2, headless, SwiftShader) vier Zustände:
|
||||||
|
//
|
||||||
|
// (a) Standard-App: EG-Grundriss (mehrschichtige Wände, Schraffuren, saubere
|
||||||
|
// Ecken, Tür) — beweist, dass die Modell-Migration das Verhalten erhält.
|
||||||
|
// (b) Ressourcen-Manager → Tab „Bauteile": Liste der Components MIT ihren
|
||||||
|
// joinPriority-Werten.
|
||||||
|
// (c) Live-Edit: Farbe eines Bauteils (Backstein) ändern → der Grundriss
|
||||||
|
// aktualisiert sich live auf die neue Farbe (Vorher/Nachher + DOM-Fills).
|
||||||
|
// (d) Tab „Schraffuren" und Tab „Linien" mit editierbaren Feldern.
|
||||||
|
//
|
||||||
|
// Aufruf: node scripts/probe-resources.mjs
|
||||||
|
// Hinweis: networkidle-GOTO-Timeout wird ignoriert (Vite-HMR hält Verbindungen
|
||||||
|
// offen); wir warten stattdessen auf konkrete DOM-Knoten.
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const OUT = "scripts";
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1360, height: 900, deviceScaleFactor: 2 });
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Klick auf das Element, dessen Textinhalt `text` enthält (innerhalb selector).
|
||||||
|
async function clickByText(selector, text) {
|
||||||
|
const handle = await page.evaluateHandle(
|
||||||
|
(sel, t) =>
|
||||||
|
[...document.querySelectorAll(sel)].find((el) =>
|
||||||
|
el.textContent.includes(t),
|
||||||
|
),
|
||||||
|
selector,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
const el = handle.asElement();
|
||||||
|
if (!el) throw new Error(`Element nicht gefunden: ${selector} ~ "${text}"`);
|
||||||
|
await el.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setzt den Wert eines Inputs so, dass Reacts onChange feuert.
|
||||||
|
async function setInputValue(elHandle, value) {
|
||||||
|
await page.evaluate(
|
||||||
|
(el, v) => {
|
||||||
|
const proto =
|
||||||
|
el.type === "color"
|
||||||
|
? window.HTMLInputElement.prototype
|
||||||
|
: window.HTMLInputElement.prototype;
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(proto, "value").set;
|
||||||
|
setter.call(el, v);
|
||||||
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
},
|
||||||
|
elHandle,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liest die Füllfarben aller Grundriss-Polygone (Component-Poché) + Anzahl.
|
||||||
|
const planFills = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const polys = [...document.querySelectorAll(".plan-svg polygon")];
|
||||||
|
const fills = polys
|
||||||
|
.map((p) => p.getAttribute("fill"))
|
||||||
|
.filter((f) => f && !f.startsWith("url("));
|
||||||
|
const patterns = [...document.querySelectorAll(".plan-svg defs pattern")]
|
||||||
|
.length;
|
||||||
|
const arcs = document.querySelectorAll(".plan-svg .door-swing").length;
|
||||||
|
const leaves = document.querySelectorAll(".plan-svg .door-leaf").length;
|
||||||
|
return {
|
||||||
|
polyCount: polys.length,
|
||||||
|
fills,
|
||||||
|
uniqueFills: [...new Set(fills)],
|
||||||
|
patternDefs: patterns,
|
||||||
|
doorArcs: arcs,
|
||||||
|
doorLeaves: leaves,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Liest die Bauteil-Tabelle des Managers: Name + joinPriority je Zeile.
|
||||||
|
const componentRows = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const rows = [...document.querySelectorAll(".res-list .res-row")];
|
||||||
|
return rows.map((r) => {
|
||||||
|
const name = r.querySelector('input[type="text"]')?.value ?? null;
|
||||||
|
const prio = r.querySelector(".res-prio input")?.value ?? null;
|
||||||
|
const color = r.querySelector('input[type="color"]')?.value ?? null;
|
||||||
|
const hatch =
|
||||||
|
r.querySelector("select")?.selectedOptions?.[0]?.textContent ?? null;
|
||||||
|
return { name, joinPriority: prio, color, hatch };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Liest die aktiven Tab-Beschriftungen + welcher Tab aktiv ist.
|
||||||
|
const tabState = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const tabs = [...document.querySelectorAll(".res-tab-btn")].map((b) => ({
|
||||||
|
label: b.textContent.trim(),
|
||||||
|
active: b.classList.contains("active"),
|
||||||
|
}));
|
||||||
|
const rowCount = document.querySelectorAll(".res-list .res-row").length;
|
||||||
|
const fieldLabels = [
|
||||||
|
...document.querySelectorAll(".res-list .res-row:first-child .res-field-label"),
|
||||||
|
].map((s) => s.textContent.trim());
|
||||||
|
const inputCount = document.querySelectorAll(
|
||||||
|
".res-list .res-row:first-child input, .res-list .res-row:first-child select",
|
||||||
|
).length;
|
||||||
|
return { tabs, rowCount, firstRowFieldLabels: fieldLabels, firstRowInputCount: inputCount };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 8000 });
|
||||||
|
} catch (e) {
|
||||||
|
logs.push(`[GOTO-INFO ignoriert] ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auf den gerenderten Grundriss warten (statt networkidle).
|
||||||
|
await page.waitForSelector(".plan-svg polygon", { timeout: 15000 });
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// ── (a) Standard: EG-Grundriss ──────────────────────────────────────────────
|
||||||
|
const fillsBefore = await planFills();
|
||||||
|
logs.push("(a) PLAN-VORHER " + JSON.stringify(fillsBefore));
|
||||||
|
await page.screenshot({ path: `${OUT}/probe-resources-a-grundriss.png` });
|
||||||
|
|
||||||
|
// ── (b) Ressourcen-Manager öffnen → Bauteile-Tab ────────────────────────────
|
||||||
|
await clickByText(".res-trigger", "Ressourcen");
|
||||||
|
await page.waitForSelector(".res-drawer", { timeout: 8000 });
|
||||||
|
await page.waitForSelector(".res-list .res-row", { timeout: 8000 });
|
||||||
|
await sleep(300);
|
||||||
|
logs.push("(b) TAB " + JSON.stringify(await tabState()));
|
||||||
|
logs.push("(b) BAUTEILE " + JSON.stringify(await componentRows()));
|
||||||
|
await page.screenshot({ path: `${OUT}/probe-resources-b-bauteile.png` });
|
||||||
|
|
||||||
|
// ── (c) Live-Edit: Backstein-Farbe auf knalliges Magenta ────────────────────
|
||||||
|
// Backstein-Zeile finden (Name-Textfeld == "Backstein"), dessen color-Input setzen.
|
||||||
|
const NEW_COLOR = "#ff00cc";
|
||||||
|
const colorHandle = await page.evaluateHandle((target) => {
|
||||||
|
const row = [...document.querySelectorAll(".res-list .res-row")].find(
|
||||||
|
(r) => r.querySelector('input[type="text"]')?.value === target,
|
||||||
|
);
|
||||||
|
return row?.querySelector('input[type="color"]') ?? null;
|
||||||
|
}, "Backstein");
|
||||||
|
if (!colorHandle.asElement())
|
||||||
|
throw new Error("Backstein-Farbfeld nicht gefunden");
|
||||||
|
await setInputValue(colorHandle.asElement(), NEW_COLOR);
|
||||||
|
await sleep(300);
|
||||||
|
logs.push("(c) BAUTEILE-NACH-EDIT " + JSON.stringify(await componentRows()));
|
||||||
|
|
||||||
|
// Screenshot mit offenem Manager (zeigt neuen Swatch im Manager).
|
||||||
|
await page.screenshot({ path: `${OUT}/probe-resources-c1-manager-edit.png` });
|
||||||
|
|
||||||
|
// Manager schließen → Grundriss frei sichtbar, Live-Update prüfen.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const overlay = document.querySelector(".res-overlay");
|
||||||
|
// auf das Overlay (außerhalb der Schublade) klicken schließt den Manager
|
||||||
|
overlay?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
// Falls der Overlay-Klick nicht griff, den ×-Knopf nutzen.
|
||||||
|
await sleep(200);
|
||||||
|
const stillOpen = await page.$(".res-drawer");
|
||||||
|
if (stillOpen) {
|
||||||
|
await page.evaluate(() => document.querySelector(".res-close")?.click());
|
||||||
|
}
|
||||||
|
await page.waitForSelector(".res-drawer", { hidden: true, timeout: 5000 }).catch(() => {});
|
||||||
|
await sleep(400);
|
||||||
|
|
||||||
|
const fillsAfter = await planFills();
|
||||||
|
logs.push("(c) PLAN-NACHHER " + JSON.stringify(fillsAfter));
|
||||||
|
const colorApplied = fillsAfter.fills.includes(NEW_COLOR);
|
||||||
|
logs.push(
|
||||||
|
`(c) LIVE-UPDATE ${colorApplied ? "OK" : "FEHLT"} — neue Farbe ${NEW_COLOR} ` +
|
||||||
|
`${colorApplied ? "im" : "NICHT im"} Grundriss; ` +
|
||||||
|
`uniqueFills vorher=${JSON.stringify(fillsBefore.uniqueFills)} ` +
|
||||||
|
`nachher=${JSON.stringify(fillsAfter.uniqueFills)}`,
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: `${OUT}/probe-resources-c2-plan-updated.png` });
|
||||||
|
|
||||||
|
// ── (d) Schraffuren-Tab und Linien-Tab ──────────────────────────────────────
|
||||||
|
await clickByText(".res-trigger", "Ressourcen");
|
||||||
|
await page.waitForSelector(".res-drawer", { timeout: 8000 });
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
await clickByText(".res-tab-btn", "Schraffuren");
|
||||||
|
await page.waitForSelector(".res-list .res-row", { timeout: 8000 });
|
||||||
|
await sleep(300);
|
||||||
|
logs.push("(d) SCHRAFFUREN " + JSON.stringify(await tabState()));
|
||||||
|
await page.screenshot({ path: `${OUT}/probe-resources-d1-schraffuren.png` });
|
||||||
|
|
||||||
|
await clickByText(".res-tab-btn", "Linien");
|
||||||
|
await page.waitForSelector(".res-list .res-row", { timeout: 8000 });
|
||||||
|
await sleep(300);
|
||||||
|
logs.push("(d) LINIEN " + JSON.stringify(await tabState()));
|
||||||
|
await page.screenshot({ path: `${OUT}/probe-resources-d2-linien.png` });
|
||||||
|
|
||||||
|
console.log(logs.join("\n"));
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
|
||||||
|
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1});
|
||||||
|
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
|
||||||
|
await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,700));
|
||||||
|
const focusCmd=async()=>{const el=await p.$(".cmdline-input");const bx=await el.boundingBox();await p.mouse.click(bx.x+bx.width/2,bx.y+bx.height/2);await new Promise(r=>setTimeout(r,60));};
|
||||||
|
const typeLine=async t=>{await p.type(".cmdline-input",t,{delay:6});await p.keyboard.press("Enter");await new Promise(r=>setTimeout(r,80));};
|
||||||
|
const lineCount=()=>p.$$eval(".plan-svg line.draw2d",ls=>ls.length).catch(()=>-1);
|
||||||
|
// zwei Linien zeichnen
|
||||||
|
await focusCmd(); await typeLine("line"); await typeLine("1,2.2"); await typeLine("r3,0");
|
||||||
|
await focusCmd(); await typeLine("line"); await typeLine("1,2.8"); await typeLine("r3,0");
|
||||||
|
const drawn=await lineCount();
|
||||||
|
// Screen-bbox der draw2d-Linien
|
||||||
|
const bb=await p.evaluate(()=>{const ls=[...document.querySelectorAll(".plan-svg line.draw2d")];const svg=ls[0].ownerSVGElement,ctm=svg.getScreenCTM();const pt=(x,y)=>{const q=svg.createSVGPoint();q.x=x;q.y=y;const s=q.matrixTransform(ctm);return[s.x,s.y];};let xs=[],ys=[];for(const l of ls){const a=pt(+l.getAttribute("x1"),+l.getAttribute("y1")),c=pt(+l.getAttribute("x2"),+l.getAttribute("y2"));xs.push(a[0],c[0]);ys.push(a[1],c[1]);}return{minX:Math.min(...xs),maxX:Math.max(...xs),minY:Math.min(...ys),maxY:Math.max(...ys)};});
|
||||||
|
// Marquee links→rechts (window) um beide Linien
|
||||||
|
await p.mouse.move(bb.minX-20, bb.minY-20); await p.mouse.down();
|
||||||
|
await p.mouse.move(bb.maxX+20, bb.maxY+20, {steps:6}); await new Promise(r=>setTimeout(r,60));
|
||||||
|
await p.mouse.up(); await new Promise(r=>setTimeout(r,200));
|
||||||
|
const selHi=await p.$$eval(".plan-svg .plan-sel-draw",e=>e.length).catch(()=>0);
|
||||||
|
const statusSel=await p.evaluate(()=>document.querySelector("footer,.statusbar")?.textContent?.match(/Auswahl[^|]*/)?.[0]||"");
|
||||||
|
// Löschen
|
||||||
|
await p.keyboard.press("Delete"); await new Promise(r=>setTimeout(r,200));
|
||||||
|
const afterDel=await lineCount();
|
||||||
|
await p.screenshot({path:"scripts/probe-sel.png"});
|
||||||
|
console.log(JSON.stringify({drawnLines:drawn, selHighlightPrims:selHi, statusSel, linesAfterDelete:afterDel, errs},null,2));
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
// Probe für Shell-Phase: native-App-Verhalten, Marquee-Auswahl, i18n.
|
||||||
|
// Headless, dpr 2, swiftshader. Schreibt PNGs nach scripts/ und meldet je
|
||||||
|
// Test (a)-(e) ein Ergebnis als JSON auf stdout (letzte Zeile).
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const TARGET = "http://localhost:5173/";
|
||||||
|
const OUT = new URL("./", import.meta.url).pathname;
|
||||||
|
|
||||||
|
const results = {};
|
||||||
|
const log = (k, v) => {
|
||||||
|
results[k] = v;
|
||||||
|
console.log(`[${k}]`, JSON.stringify(v));
|
||||||
|
};
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--use-gl=swiftshader",
|
||||||
|
"--enable-unsafe-swiftshader",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 2 });
|
||||||
|
page.on("pageerror", (e) => console.log("[pageerror]", e.message));
|
||||||
|
page.on("console", (m) => {
|
||||||
|
const t = m.text();
|
||||||
|
if (m.type() === "error") console.log("[console.error]", t);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(TARGET, { waitUntil: "networkidle0", timeout: 8000 });
|
||||||
|
} catch {
|
||||||
|
// networkidle-Timeout ignorieren (Dev-Server hält Verbindungen offen).
|
||||||
|
}
|
||||||
|
await page.waitForSelector(".plan-svg", { timeout: 15000 });
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
|
||||||
|
// ── (a) Deutsch: Sichtbarer Text unverändert ────────────────────────────
|
||||||
|
const ger = await page.evaluate(() => {
|
||||||
|
const body = document.body.innerText;
|
||||||
|
return {
|
||||||
|
grundriss: body.includes("Grundriss"),
|
||||||
|
perspektive: body.includes("Perspektive"),
|
||||||
|
auswahl: body.includes("Auswahl"),
|
||||||
|
lang: window.__i18n?.getLanguage?.() ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: OUT + "probe-shell-a-de.png" });
|
||||||
|
log("a_german", {
|
||||||
|
...ger,
|
||||||
|
ok: ger.grundriss && ger.perspektive && ger.lang === "de",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── (b) Native-App: contextmenu unterdrückt, user-select ────────────────
|
||||||
|
const native = await page.evaluate(() => {
|
||||||
|
const ev = new MouseEvent("contextmenu", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
const prevented = !document.body.dispatchEvent(ev);
|
||||||
|
const bodySel = getComputedStyle(document.body).userSelect ||
|
||||||
|
getComputedStyle(document.body).webkitUserSelect;
|
||||||
|
return { defaultPrevented: prevented, bodyUserSelect: bodySel };
|
||||||
|
});
|
||||||
|
log("b_native_contextmenu", native);
|
||||||
|
|
||||||
|
// Ressourcen-Fenster öffnen, dann ein <input> auf user-select:text prüfen.
|
||||||
|
// Die Ressourcen-Aktion in der Topbar anklicken.
|
||||||
|
const openedResources = await page.evaluate(() => {
|
||||||
|
const btns = [...document.querySelectorAll("button, [role=button], .nav-tab, .topbar *")];
|
||||||
|
const hit = btns.find((b) => /Ressourcen/i.test(b.textContent || ""));
|
||||||
|
if (hit) { hit.click(); return true; }
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
// Falls noch kein input sichtbar, einen Manager-Tab öffnen (Bauteile etc.).
|
||||||
|
const inputSel = await page.evaluate(() => {
|
||||||
|
const inp = document.querySelector("input");
|
||||||
|
if (!inp) return { found: false };
|
||||||
|
const cs = getComputedStyle(inp);
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
userSelect: cs.userSelect || cs.webkitUserSelect,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: OUT + "probe-shell-b-resources.png" });
|
||||||
|
log("b_input_userselect", {
|
||||||
|
openedResources,
|
||||||
|
...inputSel,
|
||||||
|
ok:
|
||||||
|
native.defaultPrevented === true &&
|
||||||
|
native.bodyUserSelect === "none" &&
|
||||||
|
inputSel.userSelect === "text",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ressourcen-Fenster wieder schließen, damit der Plan frei liegt (ESC).
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
|
||||||
|
// ── (c) Marquee: Links-Drag über den Raum wählt mehrere Wände ───────────
|
||||||
|
const planBox = await page.evaluate(() => {
|
||||||
|
const svg = document.querySelector(".plan-svg");
|
||||||
|
const r = svg.getBoundingClientRect();
|
||||||
|
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||||
|
});
|
||||||
|
// Crossing-Marquee (rechts→rechts unten nach links oben): ein großes Rechteck,
|
||||||
|
// das den ganzen Raum kreuzt und damit ALLE vier Wände berührt. Rechts→links
|
||||||
|
// = crossing window (berührend), robust gegen exakte Wandlage.
|
||||||
|
const cx = planBox.x + planBox.w / 2;
|
||||||
|
const cy = planBox.y + planBox.h / 2;
|
||||||
|
const x0 = cx + planBox.w * 0.42;
|
||||||
|
const y0 = cy + planBox.h * 0.4;
|
||||||
|
const x1 = cx - planBox.w * 0.42;
|
||||||
|
const y1 = cy - planBox.h * 0.4;
|
||||||
|
|
||||||
|
await page.mouse.move(x0, y0);
|
||||||
|
await page.mouse.down({ button: "left" });
|
||||||
|
// Mehrere Zwischenschritte, damit pointermove + Schwelle greifen.
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
await page.mouse.move(
|
||||||
|
x0 + ((x1 - x0) * i) / 8,
|
||||||
|
y0 + ((y1 - y0) * i) / 8,
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
}
|
||||||
|
await page.mouse.up({ button: "left" });
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
|
||||||
|
const marquee = await page.evaluate(() => {
|
||||||
|
const highlights = document.querySelectorAll(".plan-selected").length;
|
||||||
|
const footer = document.body.innerText;
|
||||||
|
const m = footer.match(/Auswahl:\s*(\d+)\s*(Wand|Wände)/);
|
||||||
|
return {
|
||||||
|
highlightPolys: highlights,
|
||||||
|
footerMatch: m ? m[0] : null,
|
||||||
|
footerN: m ? Number(m[1]) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: OUT + "probe-shell-c-marquee.png" });
|
||||||
|
log("c_marquee", {
|
||||||
|
...marquee,
|
||||||
|
ok: marquee.footerN >= 2 && marquee.highlightPolys >= 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Einzel-Klick wählt genau eine Wand. Eine echte Wandfläche im DOM finden
|
||||||
|
// (gefülltes <polygon> innerhalb der SVG, das nicht Hintergrund/Auswahl ist),
|
||||||
|
// dann auf ihren Schwerpunkt in Client-Koordinaten klicken — deterministisch.
|
||||||
|
const wallPoint = await page.evaluate(() => {
|
||||||
|
const svg = document.querySelector(".plan-svg");
|
||||||
|
const polys = [...svg.querySelectorAll("polygon")].filter(
|
||||||
|
(p) =>
|
||||||
|
!p.classList.contains("plan-selected") &&
|
||||||
|
!p.classList.contains("plan-bg"),
|
||||||
|
);
|
||||||
|
const ctm = svg.getScreenCTM();
|
||||||
|
let best = null;
|
||||||
|
let bestArea = 0;
|
||||||
|
for (const poly of polys) {
|
||||||
|
const pts = poly.getAttribute("points");
|
||||||
|
if (!pts) continue;
|
||||||
|
const nums = pts.trim().split(/[\s,]+/).map(Number);
|
||||||
|
let cxv = 0,
|
||||||
|
cyv = 0,
|
||||||
|
n = 0;
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i + 1 < nums.length; i += 2) {
|
||||||
|
cxv += nums[i];
|
||||||
|
cyv += nums[i + 1];
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
if (n < 3) continue;
|
||||||
|
// grobe Bounding-Box-Fläche als Größenmaß
|
||||||
|
const xs = nums.filter((_, i) => i % 2 === 0);
|
||||||
|
const ys = nums.filter((_, i) => i % 2 === 1);
|
||||||
|
area =
|
||||||
|
(Math.max(...xs) - Math.min(...xs)) *
|
||||||
|
(Math.max(...ys) - Math.min(...ys));
|
||||||
|
if (area > bestArea) {
|
||||||
|
bestArea = area;
|
||||||
|
// SVG-User-Space → Client über CTM transformieren.
|
||||||
|
const ptObj = svg.createSVGPoint();
|
||||||
|
ptObj.x = cxv / n;
|
||||||
|
ptObj.y = cyv / n;
|
||||||
|
const sp = ptObj.matrixTransform(ctm);
|
||||||
|
best = { x: sp.x, y: sp.y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
});
|
||||||
|
if (wallPoint) {
|
||||||
|
await page.mouse.click(wallPoint.x, wallPoint.y, { button: "left" });
|
||||||
|
await new Promise((r) => setTimeout(r, 350));
|
||||||
|
}
|
||||||
|
const single = await page.evaluate(() => {
|
||||||
|
const footer = document.body.innerText;
|
||||||
|
const m = footer.match(/Auswahl:\s*(\d+)\s*(Wand|Wände)/);
|
||||||
|
return {
|
||||||
|
footerMatch: m ? m[0] : null,
|
||||||
|
footerN: m ? Number(m[1]) : 0,
|
||||||
|
highlightPolys: document.querySelectorAll(".plan-selected").length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: OUT + "probe-shell-c2-single.png" });
|
||||||
|
log("c_single_click", { ...single, ok: single.footerN === 1 });
|
||||||
|
|
||||||
|
// ── (d) Rechtsklick auf den Plan öffnet UNSER ContextMenu (.ctx-menu) ────
|
||||||
|
// Erst sicherstellen, dass eine Wand gewählt ist (Einzelklick oben hat das
|
||||||
|
// getan). Rechtsklick auf den Plan.
|
||||||
|
await page.evaluate(
|
||||||
|
({ x, y }) => {
|
||||||
|
const svg = document.querySelector(".plan-svg");
|
||||||
|
const ev = new MouseEvent("contextmenu", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
button: 2,
|
||||||
|
});
|
||||||
|
svg.dispatchEvent(ev);
|
||||||
|
},
|
||||||
|
{ x: cx, y: planBox.y + planBox.h * 0.22 },
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
const planCtx = await page.evaluate(() => ({
|
||||||
|
ctxMenu: document.querySelectorAll(".ctx-menu").length,
|
||||||
|
}));
|
||||||
|
await page.screenshot({ path: OUT + "probe-shell-d-plan-context.png" });
|
||||||
|
log("d_plan_context", { ...planCtx, ok: planCtx.ctxMenu >= 1 });
|
||||||
|
// Menü schliessen.
|
||||||
|
await page.mouse.click(planBox.x + 4, planBox.y + 4, { button: "left" });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
|
// Rechtsklick auf eine Ebenen-Zeile (linke Liste) öffnet ebenfalls .ctx-menu.
|
||||||
|
const layerCtx = await page.evaluate(() => {
|
||||||
|
const row =
|
||||||
|
document.querySelector(".nav-list .cat-name")?.closest("div") ||
|
||||||
|
document.querySelector(".nav-list > *");
|
||||||
|
if (!row) return { found: false, ctxMenu: 0 };
|
||||||
|
const r = row.getBoundingClientRect();
|
||||||
|
const ev = new MouseEvent("contextmenu", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
clientX: r.x + 10,
|
||||||
|
clientY: r.y + r.height / 2,
|
||||||
|
button: 2,
|
||||||
|
});
|
||||||
|
row.dispatchEvent(ev);
|
||||||
|
return { found: true };
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
const layerCtxCount = await page.evaluate(() => ({
|
||||||
|
ctxMenu: document.querySelectorAll(".ctx-menu").length,
|
||||||
|
}));
|
||||||
|
await page.screenshot({ path: OUT + "probe-shell-d2-layer-context.png" });
|
||||||
|
log("d_layer_context", {
|
||||||
|
...layerCtx,
|
||||||
|
...layerCtxCount,
|
||||||
|
ok: layerCtx.found && layerCtxCount.ctxMenu >= 1,
|
||||||
|
});
|
||||||
|
await page.mouse.click(planBox.x + 4, planBox.y + 4, { button: "left" });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
|
// ── (e) i18n auf Englisch schalten + Screenshot ─────────────────────────
|
||||||
|
const enFlip = await page.evaluate(() => {
|
||||||
|
if (!window.__i18n?.setLanguage) return { exposed: false };
|
||||||
|
window.__i18n.setLanguage("en");
|
||||||
|
return { exposed: true, lang: window.__i18n.getLanguage() };
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
const enText = await page.evaluate(() => {
|
||||||
|
const body = document.body.innerText;
|
||||||
|
return {
|
||||||
|
plan: body.includes("Plan"),
|
||||||
|
perspective: body.includes("Perspective"),
|
||||||
|
stillGerman: body.includes("Grundriss"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: OUT + "probe-shell-e-en.png" });
|
||||||
|
log("e_english", {
|
||||||
|
...enFlip,
|
||||||
|
...enText,
|
||||||
|
ok:
|
||||||
|
enFlip.exposed === true &&
|
||||||
|
enFlip.lang === "en" &&
|
||||||
|
enText.perspective &&
|
||||||
|
!enText.stillGerman,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zurück auf Deutsch.
|
||||||
|
await page.evaluate(() => window.__i18n?.setLanguage("de"));
|
||||||
|
|
||||||
|
const allOk = Object.values(results).every((r) => r.ok !== false);
|
||||||
|
console.log("\nRESULT " + JSON.stringify({ allOk, results }));
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
|
||||||
|
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1});
|
||||||
|
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
|
||||||
|
await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,800));
|
||||||
|
const focus=async()=>{const el=await p.$(".cmdline-input");const bx=await el.boundingBox();await p.mouse.click(bx.x+bx.width/2,bx.y+bx.height/2);await new Promise(r=>setTimeout(r,50));};
|
||||||
|
const cmd=async t=>{await p.type(".cmdline-input",t,{delay:5});await p.keyboard.press("Enter");await new Promise(r=>setTimeout(r,70));};
|
||||||
|
await focus();await cmd("line");await cmd("1,2.2");await cmd("4,2.2");
|
||||||
|
await focus();await cmd("line");await cmd("1,2.8");await cmd("4,2.8");
|
||||||
|
// Bildschirmkoordinaten der zwei Linien
|
||||||
|
const mids=await p.evaluate(()=>{const ls=[...document.querySelectorAll(".plan-svg line.draw2d")];const svg=ls[0].ownerSVGElement,ctm=svg.getScreenCTM();const pt=(x,y)=>{const q=svg.createSVGPoint();q.x=x;q.y=y;const s=q.matrixTransform(ctm);return[s.x,s.y];};return ls.map(l=>{const a=pt(+l.getAttribute("x1"),+l.getAttribute("y1")),c=pt(+l.getAttribute("x2"),+l.getAttribute("y2"));return[(a[0]+c[0])/2,(a[1]+c[1])/2];});});
|
||||||
|
const selCount=()=>p.$$eval(".plan-svg .plan-sel-draw",e=>e.length).catch(()=>0);
|
||||||
|
// Linie A klicken
|
||||||
|
await p.mouse.click(mids[0][0],mids[0][1]); await new Promise(r=>setTimeout(r,150));
|
||||||
|
const after1=await selCount();
|
||||||
|
// Linie B shift-klicken
|
||||||
|
await p.keyboard.down("Shift"); await p.mouse.click(mids[1][0],mids[1][1]); await p.keyboard.up("Shift");
|
||||||
|
await new Promise(r=>setTimeout(r,150));
|
||||||
|
const after2=await selCount();
|
||||||
|
await p.screenshot({path:"scripts/probe-shiftsel.png"});
|
||||||
|
console.log(JSON.stringify({afterClickA:after1, afterShiftClickB:after2, errs:errs.slice(0,2)}));
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Verifikation Split / Join / Segment-Löschen (Ctrl+S / Ctrl+J / Alt+Klick).
|
||||||
|
// Liest das Modell über den TEMP-Hook window.__drawings2d (id + geom).
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const b = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const p = await b.newPage();
|
||||||
|
await p.setViewport({ width: 1280, height: 820, deviceScaleFactor: 1 });
|
||||||
|
const errs = [];
|
||||||
|
p.on("pageerror", (e) => errs.push(e.message));
|
||||||
|
await p.goto("http://localhost:5187/", { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const focusCmd = async () => {
|
||||||
|
const el = await p.$(".cmdline-input");
|
||||||
|
const bx = await el.boundingBox();
|
||||||
|
await p.mouse.click(bx.x + bx.width / 2, bx.y + bx.height / 2);
|
||||||
|
await sleep(60);
|
||||||
|
};
|
||||||
|
const typeLine = async (t) => {
|
||||||
|
await p.type(".cmdline-input", t, { delay: 5 });
|
||||||
|
await p.keyboard.press("Enter");
|
||||||
|
await sleep(80);
|
||||||
|
};
|
||||||
|
// Laufenden Befehl beenden (sonst übernimmt er die linke Maustaste der Plan-
|
||||||
|
// Ansicht und Klicks selektieren nicht). Escape im Eingabefeld bricht ab.
|
||||||
|
const endCommand = async () => {
|
||||||
|
// Eingabefeld behält Fokus nach typeLine → Escape bricht den Befehl ab.
|
||||||
|
await p.keyboard.press("Escape");
|
||||||
|
await sleep(80);
|
||||||
|
await p.keyboard.press("Escape");
|
||||||
|
await sleep(80);
|
||||||
|
};
|
||||||
|
const drawings = () => p.evaluate(() => window.__drawings2d || []);
|
||||||
|
// Modellpunkt (Meter) → Client-Pixel über das aktuelle SVG-CTM (umgekehrt).
|
||||||
|
const modelToClient = (mx, my) =>
|
||||||
|
p.evaluate(
|
||||||
|
(mx, my) => {
|
||||||
|
const svg = document.querySelector(".plan-svg");
|
||||||
|
// toScreen aus PlanView: x*40, -y*40 (PX_PER_M=40, Y gespiegelt). Wir nutzen
|
||||||
|
// direkt das CTM: viewBox-Punkt (x*40, -y*40) → Bildschirm.
|
||||||
|
const ctm = svg.getScreenCTM();
|
||||||
|
const q = svg.createSVGPoint();
|
||||||
|
q.x = mx * 90;
|
||||||
|
q.y = -my * 90;
|
||||||
|
const s = q.matrixTransform(ctm);
|
||||||
|
return [s.x, s.y];
|
||||||
|
},
|
||||||
|
mx,
|
||||||
|
my,
|
||||||
|
);
|
||||||
|
const ctrlPress = async (key) => {
|
||||||
|
await p.keyboard.down("Control");
|
||||||
|
await p.keyboard.press(key);
|
||||||
|
await p.keyboard.up("Control");
|
||||||
|
await sleep(250);
|
||||||
|
};
|
||||||
|
const isClosed = (g) =>
|
||||||
|
g.shape === "rect" || (g.shape === "polyline" && g.closed);
|
||||||
|
|
||||||
|
// ── 1) SPLIT RECHTECK ────────────────────────────────────────────────────────
|
||||||
|
// Rechteck (0,0)-(4,4) + quer kreuzende vertikale Linie x=2.
|
||||||
|
await focusCmd();
|
||||||
|
await typeLine("rect");
|
||||||
|
await typeLine("0,0");
|
||||||
|
await typeLine("4,4");
|
||||||
|
await focusCmd();
|
||||||
|
await typeLine("line");
|
||||||
|
await typeLine("2,-1");
|
||||||
|
await typeLine("2,5");
|
||||||
|
await endCommand();
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
const before = await drawings();
|
||||||
|
// Rechteck wählen: auf eine Rechteck-Kante klicken (untere Kante y=0, x=1).
|
||||||
|
let [cx, cy] = await modelToClient(1, 0);
|
||||||
|
await p.mouse.click(cx, cy);
|
||||||
|
await sleep(150);
|
||||||
|
await ctrlPress("s");
|
||||||
|
await sleep(200);
|
||||||
|
const afterSplit = await drawings();
|
||||||
|
// Erwartung: Rechteck (1 closed) ersetzt durch 2 closed Elemente; Linie bleibt.
|
||||||
|
const closedAfter = afterSplit.filter((d) => isClosed(d.geom));
|
||||||
|
await p.screenshot({ path: "scripts/probe-splitjoin-1-split-rect.png" });
|
||||||
|
|
||||||
|
// ── 2) JOIN zwei Linien ──────────────────────────────────────────────────────
|
||||||
|
await p.reload({ waitUntil: "networkidle0" });
|
||||||
|
await sleep(800);
|
||||||
|
await focusCmd();
|
||||||
|
await typeLine("line");
|
||||||
|
await typeLine("-3,-3");
|
||||||
|
await typeLine("0,-3");
|
||||||
|
await focusCmd();
|
||||||
|
await typeLine("line");
|
||||||
|
await typeLine("0,-3");
|
||||||
|
await typeLine("0,0");
|
||||||
|
await endCommand();
|
||||||
|
await sleep(150);
|
||||||
|
const beforeJoin = await drawings();
|
||||||
|
// Beide wählen: erste klicken, dann Shift+zweite.
|
||||||
|
let [ax, ay] = await modelToClient(-1.5, -3);
|
||||||
|
await p.mouse.click(ax, ay);
|
||||||
|
await sleep(100);
|
||||||
|
let [bx2, by2] = await modelToClient(0, -1.5);
|
||||||
|
await p.keyboard.down("Shift");
|
||||||
|
await p.mouse.click(bx2, by2);
|
||||||
|
await p.keyboard.up("Shift");
|
||||||
|
await sleep(150);
|
||||||
|
await ctrlPress("j");
|
||||||
|
await sleep(200);
|
||||||
|
const afterJoin = await drawings();
|
||||||
|
const polylines = afterJoin.filter((d) => d.geom.shape === "polyline");
|
||||||
|
await p.screenshot({ path: "scripts/probe-splitjoin-2-join.png" });
|
||||||
|
|
||||||
|
// ── 3) ALT-SEGMENT (Schere) ──────────────────────────────────────────────────
|
||||||
|
await p.reload({ waitUntil: "networkidle0" });
|
||||||
|
await sleep(800);
|
||||||
|
await focusCmd();
|
||||||
|
await typeLine("rect");
|
||||||
|
await typeLine("0,0");
|
||||||
|
await typeLine("4,4");
|
||||||
|
await endCommand();
|
||||||
|
await sleep(150);
|
||||||
|
const beforeCut = await drawings();
|
||||||
|
// Alt+Klick auf die untere Kante (y=0, x=2).
|
||||||
|
let [ex, ey] = await modelToClient(2, 0);
|
||||||
|
await p.keyboard.down("Alt");
|
||||||
|
await p.mouse.click(ex, ey);
|
||||||
|
await p.keyboard.up("Alt");
|
||||||
|
await sleep(200);
|
||||||
|
const afterCut = await drawings();
|
||||||
|
const cutGeom = afterCut[0]?.geom;
|
||||||
|
await p.screenshot({ path: "scripts/probe-splitjoin-3-alt-cut.png" });
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
split: {
|
||||||
|
beforeCount: before.length,
|
||||||
|
afterCount: afterSplit.length,
|
||||||
|
closedCountAfter: closedAfter.length,
|
||||||
|
geomsAfter: afterSplit.map((d) => ({ shape: d.geom.shape, closed: isClosed(d.geom) })),
|
||||||
|
},
|
||||||
|
join: {
|
||||||
|
beforeCount: beforeJoin.length,
|
||||||
|
afterCount: afterJoin.length,
|
||||||
|
polylineCount: polylines.length,
|
||||||
|
joinedPts: polylines.map((d) => d.geom.pts?.length),
|
||||||
|
},
|
||||||
|
altCut: {
|
||||||
|
beforeShape: beforeCut[0]?.geom.shape,
|
||||||
|
afterCount: afterCut.length,
|
||||||
|
afterShape: cutGeom?.shape,
|
||||||
|
afterClosed: cutGeom ? isClosed(cutGeom) : null,
|
||||||
|
afterPts: cutGeom?.pts?.length,
|
||||||
|
},
|
||||||
|
errs,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Probe für den Tab-Feld-Zyklus (Rhino-Präzisionseingabe §2.7).
|
||||||
|
//
|
||||||
|
// Treibt headless:
|
||||||
|
// A) Tab → „line"⏎ → Startpunkt „0,0"⏎ → „3"⏎ (lockt Länge, weiter zu Winkel)
|
||||||
|
// → „45"⏎ (lockt Winkel ⇒ committet) ⇒ EXAKTE 3-m-Linie unter 45°.
|
||||||
|
// B) Tab → „rect"⏎ → erste Ecke „0,-3"⏎ → Maus nach rechts-oben (Quadrant)
|
||||||
|
// → „4"⏎ (Breite) → „2"⏎ (Höhe ⇒ committet) ⇒ 4×2-Rechteck.
|
||||||
|
//
|
||||||
|
// Verifikation: liest die gerenderten `.plan-svg line.draw2d`-Endpunkte. Die
|
||||||
|
// Plan-Transform ist toScreen(p) = { x: p.x*90, y: -p.y*90 } (PX_PER_M=90, Y
|
||||||
|
// geflippt), also gilt für Modell-Längen/-Winkel:
|
||||||
|
// modelLen = pixelLen / 90
|
||||||
|
// modelAngle = atan2(-(y2-y1), x2-x1) (Y-Flip rückgängig)
|
||||||
|
// Screenshot scripts/probe-tab-fields.png zeigt Linie + Rechteck + Feld-Boxen.
|
||||||
|
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const PX_PER_M = 90;
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
} catch (e) {
|
||||||
|
logs.push(`[GOTO-ERROR] ${e.message}`);
|
||||||
|
}
|
||||||
|
await sleep(600);
|
||||||
|
|
||||||
|
// Liest die draw2d-Segmente (in toScreen-Koordinaten = x*90 / -y*90) und rechnet
|
||||||
|
// in Modell-Länge/-Winkel zurück.
|
||||||
|
async function segments() {
|
||||||
|
const raw = await page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll(".plan-svg line.draw2d")].map((l) => ({
|
||||||
|
x1: +l.getAttribute("x1"),
|
||||||
|
y1: +l.getAttribute("y1"),
|
||||||
|
x2: +l.getAttribute("x2"),
|
||||||
|
y2: +l.getAttribute("y2"),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return raw.map((s) => {
|
||||||
|
const dxPx = s.x2 - s.x1;
|
||||||
|
const dyPx = s.y2 - s.y1;
|
||||||
|
const lenM = Math.hypot(dxPx, dyPx) / PX_PER_M;
|
||||||
|
// Y-Flip rückgängig: Modell-dy = -(Screen-dy).
|
||||||
|
let ang = (Math.atan2(-dyPx, dxPx) * 180) / Math.PI;
|
||||||
|
if (ang <= -0.0001) ang += 360; // 0..360
|
||||||
|
return { lenM: +lenM.toFixed(4), angleDeg: +ang.toFixed(2) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liest die aktuellen Feld-Boxen der Command-Line (Label + Wert/„—" + aktiv).
|
||||||
|
async function fieldBoxes() {
|
||||||
|
return page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll(".cmdline-field")].map((f) => ({
|
||||||
|
label: f.querySelector(".cmdline-field-label")?.textContent || "",
|
||||||
|
val: f.querySelector(".cmdline-field-val")?.textContent || "",
|
||||||
|
active: f.classList.contains("active"),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusCmdline() {
|
||||||
|
await page.mouse.click(600, 400); // in den Body, damit Tab nicht ein Feld trifft
|
||||||
|
await sleep(80);
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await sleep(120);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function typeLine(text) {
|
||||||
|
await page.type(".cmdline-input", text, { delay: 12 });
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await sleep(140);
|
||||||
|
}
|
||||||
|
|
||||||
|
const box = await page.evaluate(() => {
|
||||||
|
const r = document.querySelector(".plan-svg").getBoundingClientRect();
|
||||||
|
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||||
|
});
|
||||||
|
const cx = box.x + box.w / 2;
|
||||||
|
const cy = box.y + box.h / 2;
|
||||||
|
|
||||||
|
// ── A) Linie über Felder: Länge 3, Winkel 45° ───────────────────────────────
|
||||||
|
await focusCmdline();
|
||||||
|
await typeLine("line");
|
||||||
|
const promptLine = await page.evaluate(
|
||||||
|
() => document.querySelector(".cmdline-prompt")?.textContent || "",
|
||||||
|
);
|
||||||
|
await typeLine("0,0"); // Startpunkt → Schritt „end" (Feld-Modus aktiv)
|
||||||
|
const fieldsAtStart = await fieldBoxes();
|
||||||
|
// Maus woanders hin (zeigt: ungelockte Felder folgen der Maus, gelockte nicht).
|
||||||
|
await page.mouse.move(cx + 120, cy - 30);
|
||||||
|
await sleep(80);
|
||||||
|
await typeLine("3"); // lockt Länge → weiter zu Winkel
|
||||||
|
const fieldsAfterLen = await fieldBoxes();
|
||||||
|
// Screenshot MITTEN im Befehl: zeigt die Feld-Boxen (Länge=3, Winkel aktiv „—")
|
||||||
|
// in der Command-Line + die schon gelockte Vorschau-Linie.
|
||||||
|
await page.screenshot({ path: "scripts/probe-tab-fields-midcmd.png" });
|
||||||
|
await typeLine("45"); // lockt Winkel ⇒ committet die Linie
|
||||||
|
await sleep(150);
|
||||||
|
const segsA = await segments();
|
||||||
|
|
||||||
|
await page.screenshot({ path: "scripts/probe-tab-fields.png" });
|
||||||
|
|
||||||
|
// ── B) Rechteck über Felder: Breite 4, Höhe 2 ───────────────────────────────
|
||||||
|
await focusCmdline();
|
||||||
|
await typeLine("rect");
|
||||||
|
await typeLine("0,-3"); // erste Ecke (unter dem Ursprung, freie Fläche)
|
||||||
|
// Maus nach rechts-oben → Quadrant +x,+y (bestimmt Vorzeichen der Locks).
|
||||||
|
await page.mouse.move(cx + 150, cy - 80);
|
||||||
|
await sleep(80);
|
||||||
|
await typeLine("4"); // Breite
|
||||||
|
const rectFieldsAfterW = await fieldBoxes();
|
||||||
|
await typeLine("2"); // Höhe ⇒ committet
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
// Rechteck wird als 4 draw2d-Linien gerendert; sammeln + auf Auswahl umschalten,
|
||||||
|
// damit das PNG das fertige Rechteck zeigt.
|
||||||
|
const allSegs = await segments();
|
||||||
|
await page.screenshot({ path: "scripts/probe-tab-fields-rect.png" });
|
||||||
|
|
||||||
|
console.log("prompt after 'line':", JSON.stringify(promptLine));
|
||||||
|
console.log("fields at end-step start:", JSON.stringify(fieldsAtStart));
|
||||||
|
console.log("fields after locking length=3:", JSON.stringify(fieldsAfterLen));
|
||||||
|
console.log("LINE segments (model len/angle):", JSON.stringify(segsA));
|
||||||
|
console.log("rect fields after width=4:", JSON.stringify(rectFieldsAfterW));
|
||||||
|
console.log("ALL segments after rect (len/angle):", JSON.stringify(allSegs));
|
||||||
|
|
||||||
|
// ── Assertions ───────────────────────────────────────────────────────────────
|
||||||
|
const line3 = segsA.find((s) => Math.abs(s.lenM - 3) < 0.02 && Math.abs(s.angleDeg - 45) < 0.5);
|
||||||
|
console.log(
|
||||||
|
line3
|
||||||
|
? `PASS: line length≈3 (${line3.lenM}) angle≈45 (${line3.angleDeg})`
|
||||||
|
: "FAIL: no 3m/45° line found",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rechteck: zwei horizontale Kanten der Länge 4 + zwei vertikale der Länge 2.
|
||||||
|
const horiz4 = allSegs.filter(
|
||||||
|
(s) => Math.abs(s.lenM - 4) < 0.03 && (Math.abs(s.angleDeg) < 1 || Math.abs(s.angleDeg - 180) < 1 || Math.abs(s.angleDeg - 360) < 1),
|
||||||
|
);
|
||||||
|
const vert2 = allSegs.filter(
|
||||||
|
(s) => Math.abs(s.lenM - 2) < 0.03 && (Math.abs(s.angleDeg - 90) < 1 || Math.abs(s.angleDeg - 270) < 1),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
horiz4.length >= 2 && vert2.length >= 2
|
||||||
|
? `PASS: rect 4×2 (horiz4=${horiz4.length}, vert2=${vert2.length})`
|
||||||
|
: `FAIL: rect not 4×2 (horiz4=${horiz4.length}, vert2=${vert2.length})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("=== Logs ===");
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(keine)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Probe: Kontext/Gelände-Integration (Phase 2, UI).
|
||||||
|
//
|
||||||
|
// 1) Erzeugt eine kleine Test-DXF (LWPOLYLINE-Höhenlinien Z=400/401/402).
|
||||||
|
// 2) Treibt den Import über das versteckte DXF-<input type=file> (uploadFile) —
|
||||||
|
// derselbe Pfad wie der Panel-Button „DXF importieren".
|
||||||
|
// 3) Screenshot Grundriss: Konturen als dezente Linien sichtbar.
|
||||||
|
// 4) Site-Panel-Tab aktivieren, „Gelände erzeugen" klicken.
|
||||||
|
// 5) In die Perspektive/Isometrie wechseln, orbiten, Screenshot: TIN im 3D.
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
import { writeFileSync, mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
|
||||||
|
// ── Test-DXF: drei geschlossene LWPOLYLINE-Höhenlinien (Elevation-Gruppe 38) ──
|
||||||
|
function ring(z, pts, layer) {
|
||||||
|
let s = `0\nLWPOLYLINE\n8\n${layer}\n38\n${z}\n90\n${pts.length}\n70\n1\n`;
|
||||||
|
for (const [x, y] of pts) s += `10\n${x}\n20\n${y}\n`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
// Etwas größere Konturen, damit sie im Plan gut sichtbar sind (Meter).
|
||||||
|
const dxf =
|
||||||
|
`0\nSECTION\n2\nENTITIES\n` +
|
||||||
|
ring(400, [[0, 0], [20, 0], [20, 20], [0, 20]], "contours") +
|
||||||
|
ring(401, [[4, 4], [16, 4], [16, 16], [4, 16]], "contours") +
|
||||||
|
ring(402, [[8, 8], [12, 8], [12, 12], [8, 12]], "contours") +
|
||||||
|
`0\nENDSEC\n0\nEOF\n`;
|
||||||
|
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "cad-dxf-"));
|
||||||
|
const dxfPath = join(dir, "contours.dxf");
|
||||||
|
writeFileSync(dxfPath, dxf);
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
await wait(600);
|
||||||
|
|
||||||
|
// Frisches Default-Layout erzwingen (neuer „site"-Tab), localStorage leeren.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
try { localStorage.clear(); } catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
await page.reload({ waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
await wait(600);
|
||||||
|
|
||||||
|
const clickByText = async (sel, re) => {
|
||||||
|
const el = (
|
||||||
|
await page.evaluateHandle(
|
||||||
|
(sel, reSrc) => {
|
||||||
|
const re = new RegExp(reSrc);
|
||||||
|
return [...document.querySelectorAll(sel)].find((e) => re.test(e.textContent.trim()));
|
||||||
|
},
|
||||||
|
sel,
|
||||||
|
re.source,
|
||||||
|
)
|
||||||
|
).asElement();
|
||||||
|
if (!el) throw new Error(`kein ${sel} matcht ${re}`);
|
||||||
|
await el.click();
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 2) Import über das versteckte DXF-Input ───────────────────────────────────
|
||||||
|
const input = await page.$('input[type=file][accept*=".dxf"]');
|
||||||
|
if (!input) throw new Error("DXF-Datei-Input nicht gefunden");
|
||||||
|
await input.uploadFile(dxfPath);
|
||||||
|
await wait(700);
|
||||||
|
|
||||||
|
// ── 3) Grundriss-Screenshot (Konturen sichtbar) ──────────────────────────────
|
||||||
|
// „Einpassen" über die Plan-Zoom-Buttons, damit die Konturen gerahmt werden.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const btn = [...document.querySelectorAll("button")].find((b) =>
|
||||||
|
/einpassen|fit|⊡|⤢/i.test(b.textContent || b.title || ""),
|
||||||
|
);
|
||||||
|
btn?.click();
|
||||||
|
});
|
||||||
|
await wait(500);
|
||||||
|
await page.screenshot({ path: "scripts/probe-terrain-ui-a-plan.png" });
|
||||||
|
|
||||||
|
// ── 4) Site-Panel öffnen + „Gelände erzeugen" ────────────────────────────────
|
||||||
|
// Den „Gelände/Terrain"-Tab in der rechten Gruppe aktivieren (TabStrip nutzt
|
||||||
|
// .tab-Buttons; ein Klick aktiviert den Tab über den Drag-Controller).
|
||||||
|
await clickByText(".tab", /^(Gelände|Terrain)$/);
|
||||||
|
await wait(400);
|
||||||
|
await page.screenshot({ path: "scripts/probe-terrain-ui-b-panel.png" });
|
||||||
|
|
||||||
|
// „Gelände erzeugen"-Button im Site-Panel klicken.
|
||||||
|
const genClicked = await page.evaluate(() => {
|
||||||
|
const btn = [...document.querySelectorAll(".site-panel .site-btn")].find((b) =>
|
||||||
|
/Gelände erzeugen|Generate terrain/i.test(b.textContent),
|
||||||
|
);
|
||||||
|
if (btn) { btn.click(); return true; }
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
console.log("Gelände-erzeugen geklickt:", genClicked);
|
||||||
|
await wait(600);
|
||||||
|
|
||||||
|
// Kontext-Objekte zählen (Liste im Site-Panel).
|
||||||
|
const rows = await page.evaluate(
|
||||||
|
() => document.querySelectorAll(".site-panel .site-row").length,
|
||||||
|
);
|
||||||
|
const badges = await page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll(".site-panel .site-badge")].map((b) => b.textContent.trim()),
|
||||||
|
);
|
||||||
|
console.log("Site-Listenzeilen:", rows, "Badges:", JSON.stringify(badges));
|
||||||
|
|
||||||
|
// ── 5) Isometrie → 3D-Screenshot (TIN sichtbar) ──────────────────────────────
|
||||||
|
// Der „Isometrie"-view-btn setzt view3d=iso UND wechselt zugleich in die
|
||||||
|
// Perspektive (echter Ansichtswechsel) — ein Klick genügt.
|
||||||
|
await clickByText("button.view-btn", /^Isometrie$|^Isometric$/);
|
||||||
|
await wait(1200);
|
||||||
|
|
||||||
|
const vp = await page.evaluate(() => {
|
||||||
|
const r = document.querySelector(".viewport")?.getBoundingClientRect();
|
||||||
|
return r ? { x: r.x, y: r.y, w: r.width, h: r.height } : null;
|
||||||
|
});
|
||||||
|
if (vp) {
|
||||||
|
const cx = vp.x + vp.w / 2, cy = vp.y + vp.h / 2;
|
||||||
|
await page.mouse.move(cx, cy);
|
||||||
|
await page.mouse.down({ button: "middle" });
|
||||||
|
await page.mouse.move(cx + 120, cy + 30, { steps: 12 });
|
||||||
|
await page.mouse.up({ button: "middle" });
|
||||||
|
await wait(500);
|
||||||
|
}
|
||||||
|
await page.screenshot({ path: "scripts/probe-terrain-ui-c-3d.png" });
|
||||||
|
|
||||||
|
console.log("logs:", logs.length ? logs.join("\n") : "(keine)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Probe: Gelände-TIN (Kontext-Schicht) im 3D sichtbar.
|
||||||
|
// Seedet über window.__seedTerrain() einen Höhenlinien-Satz + abgeleitetes TIN,
|
||||||
|
// wechselt in die Perspektive, orbitet ein wenig und screenshotet.
|
||||||
|
// Erwartung: eine plausible Geländefläche (TIN) im 3D.
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// 1) Gelände seeden (echte siteSlice-Actions über window.__seedTerrain).
|
||||||
|
const seed = await page.evaluate(() => window.__seedTerrain?.());
|
||||||
|
console.log("seed:", JSON.stringify(seed));
|
||||||
|
await wait(400);
|
||||||
|
|
||||||
|
// 2) In die Perspektive wechseln (löst Szenen-Neuaufbau inkl. Kontext aus).
|
||||||
|
const clickByText = async (sel, text) => {
|
||||||
|
const handle = await page.evaluateHandle(
|
||||||
|
(sel, text) =>
|
||||||
|
[...document.querySelectorAll(sel)].find((el) => el.textContent.trim() === text),
|
||||||
|
sel,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
const el = handle.asElement();
|
||||||
|
if (!el) throw new Error(`Button "${text}" (${sel}) nicht gefunden`);
|
||||||
|
await el.click();
|
||||||
|
};
|
||||||
|
await clickByText("button.view-btn", "Perspektive");
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
// 3) Isometrie für einen guten Blick auf die Geländefläche.
|
||||||
|
await clickByText("button.view-btn", "Isometrie");
|
||||||
|
await wait(900);
|
||||||
|
|
||||||
|
// 4) Etwas orbiten (mittlere Maustaste über dem Viewport).
|
||||||
|
const vp = await page.evaluate(() => {
|
||||||
|
const r = document.querySelector(".viewport")?.getBoundingClientRect();
|
||||||
|
return r ? { x: r.x, y: r.y, w: r.width, h: r.height } : null;
|
||||||
|
});
|
||||||
|
if (vp) {
|
||||||
|
const cx = vp.x + vp.w / 2;
|
||||||
|
const cy = vp.y + vp.h / 2;
|
||||||
|
await page.mouse.move(cx, cy);
|
||||||
|
await page.mouse.down({ button: "middle" });
|
||||||
|
await page.mouse.move(cx + 120, cy + 40, { steps: 12 });
|
||||||
|
await page.mouse.up({ button: "middle" });
|
||||||
|
await wait(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.screenshot({ path: "scripts/probe-terrain.png" });
|
||||||
|
|
||||||
|
// Diagnostik: Anzahl Meshes in der Szene ist im Headless nicht direkt lesbar;
|
||||||
|
// wir berichten Seed-Resultat + Konsolenfehler.
|
||||||
|
console.log("logs:", logs.length ? logs.join("\n") : "(keine)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] });
|
||||||
|
const p = await b.newPage(); await p.setViewport({width:1300,height:850,deviceScaleFactor:2});
|
||||||
|
const logs=[]; p.on("pageerror",e=>logs.push(e.message));
|
||||||
|
await p.goto("http://localhost:5174/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,800));
|
||||||
|
const info = await p.evaluate(()=>({
|
||||||
|
toolsPanel: !!document.querySelector(".tools-panel"),
|
||||||
|
toolRows: document.querySelectorAll(".tools-panel .tool-row").length,
|
||||||
|
activeTab: [...document.querySelectorAll(".tab")].find(t=>t.classList.contains("active"))?.textContent?.trim() ?? null,
|
||||||
|
}));
|
||||||
|
// Wand-Werkzeug im Panel klicken → wallType-Dropdown sollte erscheinen.
|
||||||
|
await p.evaluate(()=>{ const r=[...document.querySelectorAll(".tools-panel .tool-row")].find(x=>/Wand/.test(x.textContent)); r?.click(); });
|
||||||
|
await new Promise(r=>setTimeout(r,200));
|
||||||
|
const afterWall = await p.evaluate(()=>({ active: [...document.querySelectorAll(".tool-row.active")].map(x=>x.textContent.trim()), hasWallTypeSelect: !!document.querySelector(".tools-panel select") }));
|
||||||
|
await p.screenshot({ path:"scripts/probe-tools-panel.png" });
|
||||||
|
console.log("info:", JSON.stringify(info), "afterWall:", JSON.stringify(afterWall), logs.length?logs.join("|"):"(no err)");
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
} catch (e) {
|
||||||
|
logs.push(`[GOTO-ERROR] ${e.message}`);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
|
||||||
|
// 1) Wand-Werkzeug aktivieren (Button per Text finden).
|
||||||
|
const clickedWall = await page.evaluate(() => {
|
||||||
|
const btns = [...document.querySelectorAll("button")];
|
||||||
|
const b = btns.find((x) => x.textContent.trim() === "Wand");
|
||||||
|
if (b) { b.click(); return true; }
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
|
// 2) SVG-Box ermitteln und eine L-Polylinie zeichnen (3 Punkte + Rechtsklick).
|
||||||
|
const box = await page.evaluate(() => {
|
||||||
|
const svg = document.querySelector(".plan-svg");
|
||||||
|
const r = svg.getBoundingClientRect();
|
||||||
|
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||||
|
});
|
||||||
|
const cx = box.x + box.w / 2;
|
||||||
|
const cy = box.y + box.h / 2;
|
||||||
|
const pts = [
|
||||||
|
[cx - 160, cy - 120],
|
||||||
|
[cx - 160, cy + 60],
|
||||||
|
[cx + 80, cy + 60],
|
||||||
|
];
|
||||||
|
for (const [x, y] of pts) {
|
||||||
|
await page.mouse.move(x, y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.up();
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
}
|
||||||
|
// Hover, damit das letzte lebende Segment + Snap-Marker zu sehen sind.
|
||||||
|
await page.mouse.move(cx + 80, cy - 60);
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
await page.screenshot({ path: "scripts/probe-tools-drawing.png" });
|
||||||
|
|
||||||
|
// Rechtsklick = Polylinie abschließen (commit).
|
||||||
|
await page.mouse.click(cx + 80, cy - 60, { button: "right" });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
|
// 3) Zurück auf Auswahl und Ergebnis screenshoten.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const b = [...document.querySelectorAll("button")].find(
|
||||||
|
(x) => x.textContent.trim() === "Auswahl",
|
||||||
|
);
|
||||||
|
if (b) b.click();
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
|
// Wand-/Drawing-Zählung aus dem DOM (Polygone mit Wandbezug sind schwer zu
|
||||||
|
// zählen; stattdessen Anzahl Pfade/Polygone als grober Indikator).
|
||||||
|
const counts = await page.evaluate(() => ({
|
||||||
|
polygons: document.querySelectorAll(".plan-svg polygon").length,
|
||||||
|
lines: document.querySelectorAll(".plan-svg line").length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await page.screenshot({ path: "scripts/probe-tools.png" });
|
||||||
|
console.log("clickedWall:", clickedWall);
|
||||||
|
console.log("counts:", JSON.stringify(counts));
|
||||||
|
console.log("=== Logs ===");
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(keine)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
|
||||||
|
const p=await b.newPage(); await p.setViewport({width:1400,height:200,deviceScaleFactor:3});
|
||||||
|
await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,700));
|
||||||
|
const tb=await p.$(".topbar"); const box=await tb.boundingBox();
|
||||||
|
await p.screenshot({path:"scripts/probe-topbar.png", clip:{x:0,y:0,width:1400,height:Math.ceil(box.height)+8}});
|
||||||
|
// zähle die Icon-Buttons + ihre computed Größe/Farbe
|
||||||
|
const info=await p.evaluate(()=>{
|
||||||
|
const icons=[...document.querySelectorAll(".view-icon")];
|
||||||
|
const svg=icons[0]?.querySelector("svg");
|
||||||
|
const cs=icons[0]?getComputedStyle(icons[0]):null;
|
||||||
|
return { count:icons.length, cellW:cs?.width, cellH:cs?.height, color:cs?.color, svgW:svg?.getAttribute("width"), gridRows: document.querySelector(".view-grid")?getComputedStyle(document.querySelector(".view-grid")).gridTemplateColumns:null };
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(info));
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
const logs = [];
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
page.on("dialog", async (d) => { await d.accept("3"); }); // Kopienanzahl-Prompt → 3
|
||||||
|
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
const clickTool = (l) => page.evaluate((x) => [...document.querySelectorAll("button")].find((b) => b.textContent.trim() === x)?.click(), l);
|
||||||
|
const click = async (x, y) => { await page.mouse.move(x, y); await page.mouse.click(x, y); await new Promise((r) => setTimeout(r, 60)); };
|
||||||
|
const nLines = () => page.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d").length);
|
||||||
|
const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
|
||||||
|
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
|
||||||
|
|
||||||
|
// Eine Linie zeichnen + selektieren.
|
||||||
|
await clickTool("Linie"); await new Promise((r) => setTimeout(r, 80));
|
||||||
|
const A = [cx - 160, cy - 120], B = [cx - 40, cy - 120];
|
||||||
|
await click(...A); await click(...B);
|
||||||
|
await clickTool("Auswahl"); await new Promise((r) => setTimeout(r, 60));
|
||||||
|
await click((A[0] + B[0]) / 2, A[1]); // selektieren
|
||||||
|
const n0 = await nLines();
|
||||||
|
|
||||||
|
// ── Modusleiste: 'm' drücken → Bar erscheint (Screenshot) ──
|
||||||
|
await page.keyboard.press("m");
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
const barVisible = await page.evaluate(() => !!document.querySelector(".transform-bar"));
|
||||||
|
await page.screenshot({ path: "scripts/probe-transform-bar.png" });
|
||||||
|
|
||||||
|
// ── Move (U, default): Basispunkt → Ziel ──
|
||||||
|
await click(A[0], A[1]); // Basispunkt
|
||||||
|
await click(A[0] + 80, A[1] + 140); // Ziel
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
const nMove = await nLines();
|
||||||
|
const movedPos = await page.evaluate(() => { const l = document.querySelector(".plan-svg line.draw2d"); return +l.getAttribute("y1"); });
|
||||||
|
|
||||||
|
// ── Copy (I): m, i, base, dest → +1 ──
|
||||||
|
await click((A[0] + B[0]) / 2 + 80, A[1] + 140); // selektieren (verschobene Linie)
|
||||||
|
await page.keyboard.press("m");
|
||||||
|
await page.keyboard.press("i");
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
await click(cx, cy); await click(cx + 100, cy + 40);
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
const nCopy = await nLines();
|
||||||
|
|
||||||
|
// ── Array (O=3): select a line, m, o(→3), base, dest → +3 ──
|
||||||
|
await click(cx + 50, cy + 20); // selektieren (eine der Linien)
|
||||||
|
await page.keyboard.press("m");
|
||||||
|
await page.keyboard.press("o");
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
await click(cx - 50, cy + 120); await click(cx - 20, cy + 150);
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
const nArray = await nLines();
|
||||||
|
|
||||||
|
await page.screenshot({ path: "scripts/probe-transform.png" });
|
||||||
|
console.log("n0(initial):", n0, "barVisible:", barVisible);
|
||||||
|
console.log("nMove(expect ==n0):", nMove, "movedY:", movedPos);
|
||||||
|
console.log("nCopy(expect n0+1):", nCopy);
|
||||||
|
console.log("nArray(expect nCopy+3):", nArray);
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(no errors)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1500, height: 950, deviceScaleFactor: 2 });
|
||||||
|
const errs = [];
|
||||||
|
page.on("pageerror", (e) => errs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
try { await page.goto("http://localhost:5173/", { waitUntil: "networkidle0", timeout: 20000 }); } catch {}
|
||||||
|
await new Promise((r) => setTimeout(r, 1200));
|
||||||
|
|
||||||
|
const footerScale = () => page.evaluate(() => {
|
||||||
|
const t = document.body.innerText;
|
||||||
|
const m = t.match(/MASSSTAB\s*(1:\d+)/i);
|
||||||
|
return m ? m[1] : "?";
|
||||||
|
});
|
||||||
|
|
||||||
|
const before = await footerScale();
|
||||||
|
// Massstab-Select auf 1:50 setzen
|
||||||
|
const changed = await page.evaluate(() => {
|
||||||
|
const selects = [...document.querySelectorAll("select")];
|
||||||
|
for (const s of selects) {
|
||||||
|
const opt = [...s.options].find((o) => /1:50\b/.test(o.textContent || o.value));
|
||||||
|
if (opt) {
|
||||||
|
s.value = opt.value;
|
||||||
|
s.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
const after = await footerScale();
|
||||||
|
|
||||||
|
// Ressourcen öffnen
|
||||||
|
const resOpened = await page.evaluate(() => {
|
||||||
|
const b = [...document.querySelectorAll("button")].find((x) => /Ressourcen/i.test(x.textContent || ""));
|
||||||
|
if (b) { b.click(); return true; }
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
const resInfo = await page.evaluate(() => {
|
||||||
|
const ov = document.querySelector(".res-drawer, .res-overlay, [class*=overlay], [class*=drawer]");
|
||||||
|
const z = ov ? getComputedStyle(ov).zIndex : null;
|
||||||
|
return { hasOverlay: !!ov, z };
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: "scripts/probe-ressourcen-window.png" });
|
||||||
|
|
||||||
|
console.log("Massstab-Select gefunden:", changed);
|
||||||
|
console.log("Footer Massstab vorher:", before, "→ nachher:", after, after !== before ? "✅ ändert sich" : "⚠️ gleich");
|
||||||
|
console.log("Ressourcen geöffnet:", resOpened, "Overlay:", resInfo.hasOverlay, "z-index:", resInfo.z);
|
||||||
|
console.log("PageErrors:", errs.length ? errs.join("\n") : "keine");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Probe: belegt die Ansichts-3D-Presets (Front/Top/Seite/Persp./Iso) +
|
||||||
|
// Kamera-FOV. Wechselt in die Perspektive, klickt je Preset den Button und
|
||||||
|
// screenshotet. Die PNGs müssen sich deutlich unterscheiden.
|
||||||
|
// probe-view3d-perspective.png — 3/4-Blick
|
||||||
|
// probe-view3d-top.png — Draufsicht
|
||||||
|
// probe-view3d-front.png — frontal
|
||||||
|
// probe-view3d-side.png — seitlich
|
||||||
|
// probe-view3d-iso.png — isometrisch
|
||||||
|
// probe-view3d-fov.png — Kamera-Popover mit FOV-Regler (weit)
|
||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
|
||||||
|
const clickByText = async (sel, text) => {
|
||||||
|
const handle = await page.evaluateHandle(
|
||||||
|
(sel, text) =>
|
||||||
|
[...document.querySelectorAll(sel)].find(
|
||||||
|
(el) => el.textContent.trim() === text,
|
||||||
|
),
|
||||||
|
sel,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
const el = handle.asElement();
|
||||||
|
if (!el) throw new Error(`Button "${text}" (${sel}) nicht gefunden`);
|
||||||
|
await el.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const shot = (name) => page.screenshot({ path: `scripts/${name}` });
|
||||||
|
|
||||||
|
// In die Perspektive wechseln.
|
||||||
|
await clickByText("button.view-btn", "Perspektive");
|
||||||
|
await wait(1000);
|
||||||
|
|
||||||
|
// Default-Preset (perspective) — Referenz.
|
||||||
|
await shot("probe-view3d-perspective.png");
|
||||||
|
|
||||||
|
// Presets durchschalten (per sichtbarem Button-Text in der 3D-Gruppe).
|
||||||
|
for (const [label, file] of [
|
||||||
|
["Oben", "probe-view3d-top.png"],
|
||||||
|
["Front", "probe-view3d-front.png"],
|
||||||
|
["Seite", "probe-view3d-side.png"],
|
||||||
|
["Isometrie", "probe-view3d-iso.png"],
|
||||||
|
["Perspektive", "probe-view3d-perspective2.png"],
|
||||||
|
]) {
|
||||||
|
await clickByText("button.view-btn", label);
|
||||||
|
await wait(700);
|
||||||
|
await shot(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kamera-Popover öffnen + FOV weit aufdrehen (sichtbarer Effekt).
|
||||||
|
await clickByText("button.tb-btn", "Kamera");
|
||||||
|
await wait(300);
|
||||||
|
const range = await page.$(".tb-popover input[type=range]");
|
||||||
|
if (range) {
|
||||||
|
await page.evaluate((el) => {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
).set;
|
||||||
|
setter.call(el, "90");
|
||||||
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}, range);
|
||||||
|
await wait(500);
|
||||||
|
}
|
||||||
|
await shot("probe-view3d-fov.png");
|
||||||
|
|
||||||
|
console.log("Screenshots: probe-view3d-{perspective,top,front,side,iso,fov}.png");
|
||||||
|
console.log("\n=== Logs ===");
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(keine)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
|
||||||
|
const p=await b.newPage(); await p.setViewport({width:1400,height:880,deviceScaleFactor:1.25});
|
||||||
|
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
|
||||||
|
await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
|
||||||
|
await new Promise(r=>setTimeout(r,1200));
|
||||||
|
// auf eine Wandkante des Rechtecks klicken (linke Wand)
|
||||||
|
const hit=await p.evaluate(()=>{const polys=[...document.querySelectorAll(".plan-svg polygon, .plan-svg path")];if(!polys.length)return null;const svg=document.querySelector(".plan-svg");const r=svg.getBoundingClientRect();return{x:r.x+r.width*0.34,y:r.y+r.height*0.5};});
|
||||||
|
if(hit){await p.mouse.click(hit.x,hit.y);await new Promise(r=>setTimeout(r,400));}
|
||||||
|
const hasWallSec=await p.evaluate(()=>!!document.querySelector(".objinfo-panel")&&/Aufbau|Referenz|UK|OK/.test(document.querySelector(".objinfo-panel")?.textContent||""));
|
||||||
|
await p.screenshot({path:"scripts/probe-wall-sel.png"});
|
||||||
|
console.log("wallSection:",hasWallSec,"errs:",errs.slice(0,2));
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5187/";
|
||||||
|
const b = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const p = await b.newPage();
|
||||||
|
await p.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
|
||||||
|
const errs = [];
|
||||||
|
p.on("pageerror", (e) => errs.push(e.message));
|
||||||
|
p.on("console", (m) => { if (m.type() === "error") errs.push("[console] " + m.text()); });
|
||||||
|
await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
|
||||||
|
const clickTab = (label) =>
|
||||||
|
p.evaluate((x) => {
|
||||||
|
const el = [...document.querySelectorAll("button, .tab, [role=tab]")].find(
|
||||||
|
(b) => b.textContent.trim() === x,
|
||||||
|
);
|
||||||
|
el?.click();
|
||||||
|
return !!el;
|
||||||
|
}, label);
|
||||||
|
|
||||||
|
// Object-Info-Tab aktivieren (falls vorhanden).
|
||||||
|
const tabOk = await clickTab("Objekt-Info");
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
|
// Eine Wand im Grundriss anklicken (obere Bandkante eines Wandpolygons).
|
||||||
|
async function selectWall() {
|
||||||
|
const bb = await p.evaluate(() => {
|
||||||
|
const polys = [...document.querySelectorAll(".plan-svg polygon")];
|
||||||
|
if (!polys.length) return null;
|
||||||
|
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
|
||||||
|
for (const pl of polys) {
|
||||||
|
const r = pl.getBoundingClientRect();
|
||||||
|
minX = Math.min(minX, r.left); minY = Math.min(minY, r.top);
|
||||||
|
maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom);
|
||||||
|
}
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
});
|
||||||
|
if (!bb) return false;
|
||||||
|
const cx = (bb.minX + bb.maxX) / 2;
|
||||||
|
for (const dy of [4, 8, 12, 16, 20]) {
|
||||||
|
await p.mouse.click(cx, bb.minY + dy);
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
const k = await p.evaluate(
|
||||||
|
() => document.querySelector(".objinfo-kind")?.textContent || "",
|
||||||
|
);
|
||||||
|
if (/Wand/.test(k)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = await selectWall();
|
||||||
|
|
||||||
|
// (1) Wand-Abschnitt sichtbar?
|
||||||
|
const hasWallSection = await p.evaluate(() =>
|
||||||
|
[...document.querySelectorAll(".objinfo-section-label")].some(
|
||||||
|
(e) => e.textContent.trim() === "Wand",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const hasRefDropdown = await p.evaluate(
|
||||||
|
() => !!document.querySelector(".objinfo-head-ref"),
|
||||||
|
);
|
||||||
|
await p.screenshot({ path: "scripts/probe-wallattr-1-panel.png" });
|
||||||
|
|
||||||
|
// Plan-Footprint VORHER (bbox aller Wandpolygone).
|
||||||
|
const planBBox = () =>
|
||||||
|
p.evaluate(() => {
|
||||||
|
const polys = [...document.querySelectorAll(".plan-svg polygon")];
|
||||||
|
let minY = 1e9, maxY = -1e9;
|
||||||
|
for (const pl of polys) {
|
||||||
|
const r = pl.getBoundingClientRect();
|
||||||
|
minY = Math.min(minY, r.top); maxY = Math.max(maxY, r.bottom);
|
||||||
|
}
|
||||||
|
return { minY: Math.round(minY), maxY: Math.round(maxY) };
|
||||||
|
});
|
||||||
|
const beforeRef = await planBBox();
|
||||||
|
|
||||||
|
// (2) Referenzlinie auf „Außen (links)" stellen über das Kopf-Dropdown.
|
||||||
|
async function pickDropdown(triggerSel, optionText) {
|
||||||
|
const ok = await p.evaluate((sel) => {
|
||||||
|
const t = document.querySelector(sel);
|
||||||
|
if (!t) return false;
|
||||||
|
t.click();
|
||||||
|
return true;
|
||||||
|
}, triggerSel);
|
||||||
|
if (!ok) return false;
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
const picked = await p.evaluate((txt) => {
|
||||||
|
const item = [...document.querySelectorAll(".tb-dd-menu .tb-dd-item")].find(
|
||||||
|
(i) => i.textContent.trim().includes(txt),
|
||||||
|
);
|
||||||
|
item?.click();
|
||||||
|
return !!item;
|
||||||
|
}, optionText);
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refPicked = await pickDropdown(
|
||||||
|
".objinfo-head-ref .tb-dd-trigger",
|
||||||
|
"Außen",
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
const afterRef = await planBBox();
|
||||||
|
await p.screenshot({ path: "scripts/probe-wallattr-2-refline-aussen.png" });
|
||||||
|
|
||||||
|
// (3) OK auf „eigene Höhe" → Z-Wert ändern, dann 3D.
|
||||||
|
// Den OK-Block (zweite objinfo-wall-anchor) auf „Eigene Höhe" schalten.
|
||||||
|
const okCustom = await p.evaluate(() => {
|
||||||
|
const blocks = [...document.querySelectorAll(".objinfo-wall-anchor")];
|
||||||
|
const okBlock = blocks[1];
|
||||||
|
if (!okBlock) return false;
|
||||||
|
const btn = [...okBlock.querySelectorAll(".objinfo-seg-btn")].find(
|
||||||
|
(b) => b.textContent.trim() === "Eigene Höhe",
|
||||||
|
);
|
||||||
|
btn?.click();
|
||||||
|
return !!btn;
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
// Z-Feld setzen (5.0 m) im OK-Block.
|
||||||
|
const heightBefore = await p.evaluate(
|
||||||
|
() =>
|
||||||
|
[...document.querySelectorAll(".objinfo-field")]
|
||||||
|
.find((f) => f.querySelector(".objinfo-flabel")?.textContent === "Höhe")
|
||||||
|
?.querySelector(".objinfo-fval")?.textContent || "",
|
||||||
|
);
|
||||||
|
const setZ = await p.evaluate(() => {
|
||||||
|
const blocks = [...document.querySelectorAll(".objinfo-wall-anchor")];
|
||||||
|
const okBlock = blocks[1];
|
||||||
|
const input = okBlock?.querySelector("input.objinfo-input");
|
||||||
|
if (!input) return false;
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
).set;
|
||||||
|
setter.call(input, "5");
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
input.blur();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
const heightAfter = await p.evaluate(
|
||||||
|
() =>
|
||||||
|
[...document.querySelectorAll(".objinfo-field")]
|
||||||
|
.find((f) => f.querySelector(".objinfo-flabel")?.textContent === "Höhe")
|
||||||
|
?.querySelector(".objinfo-fval")?.textContent || "",
|
||||||
|
);
|
||||||
|
await p.screenshot({ path: "scripts/probe-wallattr-3-ok-custom.png" });
|
||||||
|
|
||||||
|
// 3D (Isometrie) umschalten und Screenshot — der Umschalter ist ein Icon-Button
|
||||||
|
// mit title „Isometrie …" in der Oberleiste (kein Tab).
|
||||||
|
await p.evaluate(() => {
|
||||||
|
const el = [...document.querySelectorAll("button[title]")].find((b) =>
|
||||||
|
(b.getAttribute("title") || "").startsWith("Isometrie"),
|
||||||
|
);
|
||||||
|
el?.click();
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
await p.screenshot({ path: "scripts/probe-wallattr-4-3d.png" });
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
tabOk,
|
||||||
|
selected,
|
||||||
|
hasWallSection,
|
||||||
|
hasRefDropdown,
|
||||||
|
refPicked,
|
||||||
|
beforeRef,
|
||||||
|
afterRef,
|
||||||
|
footprintShifted: beforeRef.minY !== afterRef.minY || beforeRef.maxY !== afterRef.maxY,
|
||||||
|
okCustom,
|
||||||
|
setZ,
|
||||||
|
heightBefore,
|
||||||
|
heightAfter,
|
||||||
|
errs,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
const URL = process.env.PROBE_URL || "http://localhost:5173/";
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
// HiDPI simulieren — das ist die Bedingung, die die Schleife auslöste.
|
||||||
|
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
|
||||||
|
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
|
||||||
|
} catch (e) {
|
||||||
|
logs.push(`[GOTO-ERROR] ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const measure = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const c = document.querySelector(".viewport canvas");
|
||||||
|
const v = document.querySelector(".viewport");
|
||||||
|
const r = c?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
canvasCss: r ? `${Math.round(r.width)}x${Math.round(r.height)}` : "none",
|
||||||
|
canvasBuffer: c ? `${c.width}x${c.height}` : "none",
|
||||||
|
viewport: v ? `${Math.round(v.clientWidth)}x${Math.round(v.clientHeight)}` : "none",
|
||||||
|
bodyScrollH: document.body.scrollHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const m1 = await measure();
|
||||||
|
await new Promise((r) => setTimeout(r, 1200));
|
||||||
|
const m2 = await measure();
|
||||||
|
|
||||||
|
await page.screenshot({ path: "scripts/probe.png" });
|
||||||
|
|
||||||
|
const stable = JSON.stringify(m1) === JSON.stringify(m2);
|
||||||
|
console.log("=== Messung 1 ===", JSON.stringify(m1));
|
||||||
|
console.log("=== Messung 2 ===", JSON.stringify(m2));
|
||||||
|
console.log(stable ? "✅ STABIL — keine Schleife" : "❌ INSTABIL — wächst/schrumpft noch");
|
||||||
|
console.log("\n=== Logs ===");
|
||||||
|
console.log(logs.length ? logs.join("\n") : "(keine)");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// Numerische Verifikation des 2D-Kernels (esbuild → node). Kein UI nötig.
|
||||||
|
import {
|
||||||
|
segmentIntersect,
|
||||||
|
offsetPolyline,
|
||||||
|
offsetSegment,
|
||||||
|
trimSegment,
|
||||||
|
extendSegment,
|
||||||
|
filletCorner,
|
||||||
|
pointSegmentDistance,
|
||||||
|
lineCircleIntersect,
|
||||||
|
circleCircleIntersect,
|
||||||
|
signedArea,
|
||||||
|
isCCW,
|
||||||
|
joinChains,
|
||||||
|
removeSegment,
|
||||||
|
splitAtIntersections,
|
||||||
|
splitClosedByChord,
|
||||||
|
splitPolylineAtParam,
|
||||||
|
vecEqual,
|
||||||
|
} from "../src/geometry/kernel2d.ts";
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
let fail = 0;
|
||||||
|
const approx = (a: number, b: number, eps = 1e-6) => Math.abs(a - b) <= eps;
|
||||||
|
function check(name: string, cond: boolean, detail?: unknown) {
|
||||||
|
if (cond) { pass++; console.log(" ok " + name); }
|
||||||
|
else { fail++; console.log(" FAIL " + name, JSON.stringify(detail)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Strecken-Schnitt: X durch (1,1)
|
||||||
|
{
|
||||||
|
const h = segmentIntersect({x:0,y:0},{x:2,y:2},{x:0,y:2},{x:2,y:0});
|
||||||
|
check("segmentIntersect cross at (1,1)", !!h && approx(h!.point.x,1) && approx(h!.point.y,1) && approx(h!.t,0.5), h);
|
||||||
|
}
|
||||||
|
// 2) parallele Strecken → null
|
||||||
|
{
|
||||||
|
const h = segmentIntersect({x:0,y:0},{x:2,y:0},{x:0,y:1},{x:2,y:1});
|
||||||
|
check("segmentIntersect parallel -> null", h === null);
|
||||||
|
}
|
||||||
|
// 3) kein Schnitt innerhalb der Segmente → null
|
||||||
|
{
|
||||||
|
const h = segmentIntersect({x:0,y:0},{x:1,y:0},{x:2,y:-1},{x:2,y:1});
|
||||||
|
check("segmentIntersect outside -> null", h === null);
|
||||||
|
}
|
||||||
|
// 4) Offset einer Strecke nach links (d>0): +y
|
||||||
|
{
|
||||||
|
const [a,b] = offsetSegment({x:0,y:0},{x:1,y:0},0.5);
|
||||||
|
check("offsetSegment left +0.5y", approx(a.y,0.5) && approx(b.y,0.5) && approx(a.x,0) && approx(b.x,1), {a,b});
|
||||||
|
}
|
||||||
|
// 5) Offset geschlossenes Quadrat: Konvention +leftNormal zeigt bei CCW NACH
|
||||||
|
// INNEN (CONVENTIONS.md), also d>0 = innen → (1,1)..(3,3); d<0 = außen → (-1,-1)..(5,5).
|
||||||
|
{
|
||||||
|
const sq = [{x:0,y:0},{x:4,y:0},{x:4,y:4},{x:0,y:4}]; // CCW
|
||||||
|
const inn = offsetPolyline(sq, 1, true);
|
||||||
|
const okIn = inn.length===4 &&
|
||||||
|
approx(inn[0].x,1)&&approx(inn[0].y,1)&&
|
||||||
|
approx(inn[1].x,3)&&approx(inn[1].y,1)&&
|
||||||
|
approx(inn[2].x,3)&&approx(inn[2].y,3)&&
|
||||||
|
approx(inn[3].x,1)&&approx(inn[3].y,3);
|
||||||
|
check("offsetPolyline square inward by 1 (CCW +n=inside)", okIn, inn);
|
||||||
|
const out = offsetPolyline(sq, -1, true);
|
||||||
|
const okOut = out.length===4 && approx(out[0].x,-1)&&approx(out[0].y,-1)&&approx(out[2].x,5)&&approx(out[2].y,5);
|
||||||
|
check("offsetPolyline square outward by 1 (d<0)", okOut, out);
|
||||||
|
}
|
||||||
|
// 6) Offset offene L-Polylinie (Gehrung am Innenknick)
|
||||||
|
{
|
||||||
|
const L = [{x:0,y:0},{x:4,y:0},{x:4,y:4}];
|
||||||
|
const off = offsetPolyline(L, 1, false); // links
|
||||||
|
// Endkanten verschoben; mittlerer Punkt = Gehrung bei (3,1) für links-offset
|
||||||
|
check("offsetPolyline open L mitre", off.length===3 && approx(off[1].x,3) && approx(off[1].y,1), off);
|
||||||
|
}
|
||||||
|
// 7) Trim: Strecke 0..4 von Cutter bei x=1 und x=3, Pick im Mittelstück → 2 Reststücke
|
||||||
|
{
|
||||||
|
const cutters = [
|
||||||
|
{pts:[{x:1,y:-1},{x:1,y:1}], closed:false},
|
||||||
|
{pts:[{x:3,y:-1},{x:3,y:1}], closed:false},
|
||||||
|
];
|
||||||
|
const rem = trimSegment({x:0,y:0},{x:4,y:0},cutters,{x:2,y:0});
|
||||||
|
const ok = rem.length===2 &&
|
||||||
|
approx(rem[0][0].x,0)&&approx(rem[0][1].x,1)&&
|
||||||
|
approx(rem[1][0].x,3)&&approx(rem[1][1].x,4);
|
||||||
|
check("trimSegment removes middle piece", ok, rem);
|
||||||
|
}
|
||||||
|
// 8) Extend: Strecke 0..1 auf x-Achse, Ende bis Cutter bei x=3
|
||||||
|
{
|
||||||
|
const cutters=[{pts:[{x:3,y:-1},{x:3,y:1}],closed:false}];
|
||||||
|
const ex = extendSegment({x:0,y:0},{x:1,y:0},"end",cutters);
|
||||||
|
check("extendSegment to x=3", !!ex && approx(ex![1].x,3) && approx(ex![1].y,0), ex);
|
||||||
|
}
|
||||||
|
// 9) Extend start-Ende rückwärts bis x=-2
|
||||||
|
{
|
||||||
|
const cutters=[{pts:[{x:-2,y:-1},{x:-2,y:1}],closed:false}];
|
||||||
|
const ex = extendSegment({x:0,y:0},{x:1,y:0},"start",cutters);
|
||||||
|
check("extendSegment start to x=-2", !!ex && approx(ex![0].x,-2), ex);
|
||||||
|
}
|
||||||
|
// 10) Fillet 90°-Ecke, r=1 → setback 1, center (1,1)
|
||||||
|
{
|
||||||
|
const f = filletCorner({x:0,y:0},{x:5,y:0},{x:0,y:5},1);
|
||||||
|
const ok = !!f && approx(f!.tangentA.x,1)&&approx(f!.tangentA.y,0)&&
|
||||||
|
approx(f!.tangentB.x,0)&&approx(f!.tangentB.y,1)&&
|
||||||
|
approx(f!.center.x,1)&&approx(f!.center.y,1)&&approx(f!.radius,1);
|
||||||
|
check("filletCorner 90deg r1", ok, f);
|
||||||
|
}
|
||||||
|
// 11) Fillet kollinear → null
|
||||||
|
{
|
||||||
|
const f = filletCorner({x:0,y:0},{x:1,y:0},{x:-1,y:0},1);
|
||||||
|
check("filletCorner collinear -> null", f === null);
|
||||||
|
}
|
||||||
|
// 12) pointSegmentDistance
|
||||||
|
{
|
||||||
|
check("pointSegmentDistance perpendicular", approx(pointSegmentDistance({x:1,y:2},{x:0,y:0},{x:4,y:0}),2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13) Linie durch Kreis (Mittelpunkt-Sekante) → 2 Punkte ±r
|
||||||
|
{
|
||||||
|
const ps = lineCircleIntersect({x:-5,y:0},{x:5,y:0},{x:0,y:0},2);
|
||||||
|
const xs = ps.map(p=>p.x).sort((a,b)=>a-b);
|
||||||
|
check("lineCircleIntersect secant", ps.length===2 && approx(xs[0],-2) && approx(xs[1],2), ps);
|
||||||
|
}
|
||||||
|
// 14) Zwei Kreise (Abstand 2, r=√2 je) schneiden sich bei (1,±1)
|
||||||
|
{
|
||||||
|
const ps = circleCircleIntersect({x:0,y:0},Math.SQRT2,{x:2,y:0},Math.SQRT2);
|
||||||
|
const ok = ps.length===2 && ps.every(p=>approx(Math.abs(p.x),1)&&approx(Math.abs(p.y),1)) && approx(ps[0].x,1);
|
||||||
|
check("circleCircleIntersect at (1,±1)", ok, ps);
|
||||||
|
}
|
||||||
|
// 15) Wicklung
|
||||||
|
{
|
||||||
|
const ccw = [{x:0,y:0},{x:4,y:0},{x:4,y:4},{x:0,y:4}];
|
||||||
|
check("signedArea/isCCW", approx(signedArea(ccw),16) && isCCW(ccw) && !isCCW([...ccw].reverse()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Split / Join / Segment-Löschen ───────────────────────────────────────────
|
||||||
|
const V = (x: number, y: number) => ({ x, y });
|
||||||
|
|
||||||
|
// 16) splitPolylineAtParam: offene Linie → 2 Stücke an (5,0)
|
||||||
|
{
|
||||||
|
const parts = splitPolylineAtParam([V(0,0),V(10,0)], false, 0, 0.5);
|
||||||
|
check("splitPolyline open -> 2 pieces", parts.length===2 && approx(parts[0][1].x,5) && approx(parts[1][0].x,5), parts);
|
||||||
|
}
|
||||||
|
// 17) splitPolylineAtParam: geschlossenes Quadrat → eine offene Kette von/bis Schnitt
|
||||||
|
{
|
||||||
|
const sq=[V(0,0),V(4,0),V(4,4),V(0,4)];
|
||||||
|
const parts = splitPolylineAtParam(sq, true, 0, 0.5);
|
||||||
|
const c = parts[0];
|
||||||
|
check("splitPolyline closed -> 1 open chain", parts.length===1 && vecEqual(c[0],V(2,0)) && vecEqual(c[c.length-1],V(2,0)), c);
|
||||||
|
}
|
||||||
|
// 18) splitClosedByChord: Quadrat unten/oben → 2 geschlossene Hälften
|
||||||
|
{
|
||||||
|
const sq=[V(0,0),V(4,0),V(4,4),V(0,4)];
|
||||||
|
const res = splitClosedByChord(sq, 0, 0.5, 2, 0.5);
|
||||||
|
const has=(p:{x:number;y:number}[],q:{x:number;y:number})=>p.some(r=>vecEqual(r,q));
|
||||||
|
const ok = !!res && res[0].length>=3 && res[1].length>=3 &&
|
||||||
|
has(res![0],V(2,0)) && has(res![0],V(2,4)) && has(res![1],V(2,0)) && has(res![1],V(2,4));
|
||||||
|
check("splitClosedByChord -> 2 closed halves", ok, res);
|
||||||
|
check("splitClosedByChord same edge -> null", splitClosedByChord(sq,1,0.5,1,0.7)===null);
|
||||||
|
}
|
||||||
|
// 19) removeSegment: geschlossenes Quadrat → offen, Start nach Kante
|
||||||
|
{
|
||||||
|
const sq=[V(0,0),V(4,0),V(4,4),V(0,4)];
|
||||||
|
const r = removeSegment(sq, true, 0);
|
||||||
|
check("removeSegment closed -> open chain", r.closed===false && r.pts.length===4 && vecEqual(r.pts[0],V(4,0)), r);
|
||||||
|
}
|
||||||
|
// 20) splitAtIntersections: Rechteck × querende Linie → 2 geschlossene Hälften
|
||||||
|
{
|
||||||
|
const rect=[V(0,0),V(4,0),V(4,4),V(0,4)];
|
||||||
|
const cutter=[V(2,-1),V(2,5)];
|
||||||
|
const pieces = splitAtIntersections(rect, true, [cutter]);
|
||||||
|
check("splitAtIntersections rect×line -> 2 closed", pieces.length===2 && pieces[0].length>=3 && pieces[1].length>=3, pieces);
|
||||||
|
}
|
||||||
|
// 21) splitAtIntersections: offene Linie × Cutter → 2 Stücke an (5,0)
|
||||||
|
{
|
||||||
|
const pieces = splitAtIntersections([V(0,0),V(10,0)], false, [[V(5,-2),V(5,2)]]);
|
||||||
|
check("splitAtIntersections open line -> 2 pieces", pieces.length===2 && approx(pieces[0][pieces[0].length-1].x,5), pieces);
|
||||||
|
}
|
||||||
|
// 22) joinChains: zwei Linien mit gemeinsamem Endpunkt → eine Polylinie
|
||||||
|
{
|
||||||
|
const out = joinChains([{pts:[V(0,0),V(5,0)],closed:false},{pts:[V(5,0),V(5,5)],closed:false}]);
|
||||||
|
check("joinChains 2 chains -> 1 polyline (3 pts)", out.length===1 && out[0].pts.length===3 && out[0].closed===false, out);
|
||||||
|
}
|
||||||
|
// 23) joinChains: vier Kanten schließen sich → eine geschlossene Schleife
|
||||||
|
{
|
||||||
|
const out = joinChains([
|
||||||
|
{pts:[V(0,0),V(4,0)],closed:false},
|
||||||
|
{pts:[V(4,0),V(4,4)],closed:false},
|
||||||
|
{pts:[V(4,4),V(0,4)],closed:false},
|
||||||
|
{pts:[V(0,4),V(0,0)],closed:false},
|
||||||
|
]);
|
||||||
|
check("joinChains loop -> closed ring (4 pts)", out.length===1 && out[0].closed===true && out[0].pts.length===4, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nKERNEL2D: ${pass} ok, ${fail} fail`);
|
||||||
|
if (fail > 0) process.exit(1);
|
||||||
+2783
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
|||||||
|
// Circle — Mittelpunkt + Radius. Schritte:
|
||||||
|
// 1) „Mittelpunkt:" → Punkt
|
||||||
|
// 2) „Radius:" → Punkt (Abstand) ODER getippte Zahl (Radius) → commit
|
||||||
|
//
|
||||||
|
// Radius akzeptiert sowohl einen Maus-Punkt (Abstand zum Mittelpunkt) als auch
|
||||||
|
// eine getippte nackte Zahl. Der Kreis wird in generatePlan als geschlossene
|
||||||
|
// Polygon-Approximation gerendert.
|
||||||
|
|
||||||
|
import type { Drawing2D } from "../../model/types";
|
||||||
|
import { uniqueId } from "../../tools/types";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandField,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
|
||||||
|
interface CircleIdle extends CommandState {
|
||||||
|
phase: "center";
|
||||||
|
}
|
||||||
|
interface CircleDrawing extends CommandState {
|
||||||
|
phase: "radius";
|
||||||
|
center: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
type CircleState = CircleIdle | CircleDrawing;
|
||||||
|
|
||||||
|
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
|
||||||
|
/** Tab-Feld des Radius-Schritts: der Radius selbst. */
|
||||||
|
const CIRCLE_FIELDS: CommandField[] = [{ id: "radius", labelKey: "cmd.field.radius" }];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Punkt auf dem Kreis aus gelocktem Radius (oder Cursor-Abstand), so dass
|
||||||
|
* dist(center, point) = Radius. Die Richtung folgt dem Cursor (sonst +X).
|
||||||
|
*/
|
||||||
|
function circlePointFromFields(center: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
|
||||||
|
const r = "radius" in locks ? Math.abs(locks.radius) : cursor ? dist(center, cursor) : 0;
|
||||||
|
if (cursor) {
|
||||||
|
const d = dist(center, cursor);
|
||||||
|
if (d > EPS) {
|
||||||
|
return { x: center.x + ((cursor.x - center.x) / d) * r, y: center.y + ((cursor.y - center.y) / d) * r };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: center.x + r, y: center.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Punkte einer Kreis-Approximation (für die Vorschau). */
|
||||||
|
function circlePts(center: Vec2, r: number): Vec2[] {
|
||||||
|
const N = 48;
|
||||||
|
const pts: Vec2[] = [];
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const t = (i / N) * Math.PI * 2;
|
||||||
|
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau (Kreis) + HUD (Radius). */
|
||||||
|
function circleDraft(center: Vec2, r: number, at: Vec2 | null): ToolDraft {
|
||||||
|
if (r < EPS) return { preview: [], vertices: [center] };
|
||||||
|
const preview: DraftShape[] = [{ kind: "poly", pts: circlePts(center, r), closed: true }];
|
||||||
|
const draft: ToolDraft = { preview, vertices: [center] };
|
||||||
|
if (at) draft.hud = { at, text: `r ${r.toFixed(2)} m` };
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCircle(p: Project, center: Vec2, r: number, ctx: CommandContext): Project {
|
||||||
|
if (r < EPS) return p;
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: uniqueId("dr2d"),
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: ctx.level.id,
|
||||||
|
categoryCode: ctx.defaultCategoryCode,
|
||||||
|
geom: { shape: "circle", center, r },
|
||||||
|
};
|
||||||
|
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "center", lastPoint: null },
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const circleCommand: Command = {
|
||||||
|
name: "circle",
|
||||||
|
labelKey: "cmd.circle.label",
|
||||||
|
prompt: (s) => ((s as CircleState).phase === "radius" ? "cmd.circle.radius" : "cmd.circle.center"),
|
||||||
|
accepts: (s) => ((s as CircleState).phase === "radius" ? ["point", "number"] : ["point"]),
|
||||||
|
options: () => [],
|
||||||
|
init: (): CircleIdle => ({ phase: "center", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as CircleState;
|
||||||
|
if (s.phase !== "radius") {
|
||||||
|
if (input.kind !== "point") return [s, { draft: null }];
|
||||||
|
const ns: CircleDrawing = { phase: "radius", center: input.point, cursor: input.point, lastPoint: input.point };
|
||||||
|
return [ns, { draft: circleDraft(input.point, 0, null) }];
|
||||||
|
}
|
||||||
|
// Radius-Schritt: Zahl = Radius, Punkt = Abstand zum Mittelpunkt.
|
||||||
|
let r: number;
|
||||||
|
if (input.kind === "number") r = input.value;
|
||||||
|
else if (input.kind === "point") r = dist(s.center, input.point);
|
||||||
|
else {
|
||||||
|
const rr = s.cursor ? dist(s.center, s.cursor) : 0;
|
||||||
|
return [s, { draft: circleDraft(s.center, rr, s.cursor) }];
|
||||||
|
}
|
||||||
|
const center = s.center;
|
||||||
|
return [
|
||||||
|
{ phase: "center", lastPoint: null },
|
||||||
|
{ draft: null, done: true, commit: (p) => appendCircle(p, center, r, ctx) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as CircleState;
|
||||||
|
if (s.phase !== "radius") return [s, { draft: null }];
|
||||||
|
const r = dist(s.center, point);
|
||||||
|
const ns: CircleDrawing = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: circleDraft(s.center, r, point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
|
||||||
|
// Tab-Feld-Zyklus nur im „radius"-Schritt: ein Radius-Feld; der gelockte Wert
|
||||||
|
// committet direkt (Punkt liegt auf dem Kreis, dist(center,point)=radius).
|
||||||
|
fields: (state) => ((state as CircleState).phase === "radius" ? CIRCLE_FIELDS : []),
|
||||||
|
pointFromFields: (state, locks, cursor) => {
|
||||||
|
const s = state as CircleState;
|
||||||
|
if (s.phase !== "radius") return null;
|
||||||
|
return circlePointFromFields(s.center, locks, cursor);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Copy — wie Move, aber jeder Zielpunkt fügt eine KOPIE der Auswahl (neue IDs)
|
||||||
|
// an der Ziellage hinzu; das Original bleibt (Rhino-Copy, docs §2.10). Schritte:
|
||||||
|
// 1) „Basispunkt:" → Punkt
|
||||||
|
// 2) „Zielpunkt:" → Punkt (Tab length/angle) → Kopie + BLEIBT AKTIV
|
||||||
|
// → weitere Zielpunkte = weitere Kopien (Basispunkt bleibt), bis Esc/Enter.
|
||||||
|
//
|
||||||
|
// Die Auswahl kommt vorab über ctx.selection. Leere Auswahl → No-op + Hinweis.
|
||||||
|
// Der Commit nutzt `commitTransform` (op:"move", mode:"copy") — eine Kopie der
|
||||||
|
// Auswahl an die Endlage, Original unverändert; neue IDs via uniqueId (dort).
|
||||||
|
|
||||||
|
import { commitTransform } from "../../tools/transform";
|
||||||
|
import type { TransformSelection } from "../../tools/transform";
|
||||||
|
import { transformPreview } from "../../tools/transform";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandField,
|
||||||
|
CommandResult,
|
||||||
|
CommandSelection,
|
||||||
|
CommandState,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||||
|
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||||
|
|
||||||
|
/** Tab-Felder des Zielschritts: Länge + Winkel des Kopier-Versatzes (§2.7). */
|
||||||
|
const COPY_FIELDS: CommandField[] = [
|
||||||
|
{ id: "length", labelKey: "cmd.field.length" },
|
||||||
|
{ id: "angle", labelKey: "cmd.field.angle" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
|
||||||
|
const ref = cursor ?? base;
|
||||||
|
const length = "length" in locks ? locks.length : segLen(base, ref);
|
||||||
|
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref);
|
||||||
|
const ang = angleDeg * DEG;
|
||||||
|
return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSelection(sel: CommandSelection): boolean {
|
||||||
|
return sel.wallIds.length > 0 || sel.drawingId !== null;
|
||||||
|
}
|
||||||
|
function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||||
|
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CopyBase extends CommandState {
|
||||||
|
phase: "base";
|
||||||
|
}
|
||||||
|
interface CopyTarget extends CommandState {
|
||||||
|
phase: "target";
|
||||||
|
sel: CommandSelection;
|
||||||
|
base: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
interface CopyDone extends CommandState {
|
||||||
|
phase: "done";
|
||||||
|
}
|
||||||
|
type CopyState = CopyBase | CopyTarget | CopyDone;
|
||||||
|
|
||||||
|
/** Vorschau EINER Kopie der Auswahl an der Cursor-Lage. */
|
||||||
|
function copyDraft(
|
||||||
|
project: Project,
|
||||||
|
sel: CommandSelection,
|
||||||
|
base: Vec2,
|
||||||
|
target: Vec2,
|
||||||
|
): ToolDraft {
|
||||||
|
const preview = transformPreview(
|
||||||
|
project,
|
||||||
|
toTransformSel(sel),
|
||||||
|
"move",
|
||||||
|
[base, target],
|
||||||
|
"copy",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
preview,
|
||||||
|
vertices: [base],
|
||||||
|
hud: {
|
||||||
|
at: target,
|
||||||
|
text: `${segLen(base, target).toFixed(2)} m · ${Math.abs(
|
||||||
|
(segAngleDeg(base, target) + 360) % 360,
|
||||||
|
).toFixed(0)}°`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const finish = (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "done", lastPoint: null } as CopyDone,
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const copyCommand: Command = {
|
||||||
|
name: "copy",
|
||||||
|
labelKey: "cmd.copy.label",
|
||||||
|
prompt: (s) => {
|
||||||
|
const cs = s as CopyState;
|
||||||
|
if (cs.phase === "target") return "cmd.copy.target";
|
||||||
|
if (cs.phase === "done") return "cmd.copy.empty";
|
||||||
|
return "cmd.copy.base";
|
||||||
|
},
|
||||||
|
accepts: () => ["point"],
|
||||||
|
options: () => [],
|
||||||
|
init: (): CopyBase => ({ phase: "base", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as CopyState;
|
||||||
|
if (s.phase === "done") return finish();
|
||||||
|
if (!hasSelection(ctx.selection)) {
|
||||||
|
return [{ phase: "done", lastPoint: null } as CopyDone, { draft: null, done: true }];
|
||||||
|
}
|
||||||
|
if (input.kind !== "point") {
|
||||||
|
if (s.phase === "target") {
|
||||||
|
return [s, { draft: s.cursor ? copyDraft(ctx.project, s.sel, s.base, s.cursor) : null }];
|
||||||
|
}
|
||||||
|
return [s, { draft: null }];
|
||||||
|
}
|
||||||
|
const pt = input.point;
|
||||||
|
if (s.phase !== "target") {
|
||||||
|
const next: CopyTarget = {
|
||||||
|
phase: "target",
|
||||||
|
sel: ctx.selection,
|
||||||
|
base: pt,
|
||||||
|
cursor: pt,
|
||||||
|
lastPoint: pt,
|
||||||
|
};
|
||||||
|
return [next, { draft: copyDraft(ctx.project, next.sel, pt, pt) }];
|
||||||
|
}
|
||||||
|
// Zielpunkt → eine Kopie committen, ABER aktiv bleiben (wiederholend): der
|
||||||
|
// Befehl kehrt NICHT in den Ruhezustand zurück (kein done), Basispunkt
|
||||||
|
// bleibt; jeder weitere Zielpunkt = weitere Kopie, bis Esc/Enter.
|
||||||
|
const base = s.base;
|
||||||
|
const sel = s.sel;
|
||||||
|
const next: CopyTarget = { ...s, cursor: pt, lastPoint: pt };
|
||||||
|
return [
|
||||||
|
next,
|
||||||
|
{
|
||||||
|
draft: copyDraft(ctx.project, sel, base, pt),
|
||||||
|
commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, pt], "copy", 1),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as CopyState;
|
||||||
|
if (s.phase !== "target") return [s, { draft: null }];
|
||||||
|
const ns: CopyTarget = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: copyDraft(ctx.project, s.sel, s.base, point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enter/Space/Rechtsklick: Copy-Serie beenden (kein Commit hier).
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => finish(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => finish(),
|
||||||
|
|
||||||
|
fields: (state) => ((state as CopyState).phase === "target" ? COPY_FIELDS : []),
|
||||||
|
pointFromFields: (state, locks, cursor) => {
|
||||||
|
const s = state as CopyState;
|
||||||
|
if (s.phase !== "target") return null;
|
||||||
|
return targetFromFields(s.base, locks, cursor);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Import — öffnet den DXF-Datei-Dialog (in App montiert) und beendet sich
|
||||||
|
// sofort. Der eigentliche Import (Datei lesen → parseDxf → addContextObjects)
|
||||||
|
// läuft asynchron über das App-onChange des versteckten <input type=file>; ein
|
||||||
|
// Command kann den Datei-Dialog nicht selbst „committen" (er ist DOM-/async-
|
||||||
|
// seitig). Daher koppelt dieser Befehl nur den Trigger an (über den Modul-Hook
|
||||||
|
// dxfImportHook) und gibt keine Mutation zurück.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, sichtbarer Text via t() (Namespace `cmd.import.*`).
|
||||||
|
|
||||||
|
import { openDxfImport } from "../../io/dxfImportHook";
|
||||||
|
import type { Command, CommandResult, CommandState } from "../types";
|
||||||
|
|
||||||
|
const IDLE: CommandState = { phase: "idle", lastPoint: null };
|
||||||
|
|
||||||
|
/** Öffnet den Datei-Dialog und beendet den Befehl sofort (keine Mutation). */
|
||||||
|
function open(): [CommandState, CommandResult] {
|
||||||
|
openDxfImport();
|
||||||
|
return [{ ...IDLE }, { draft: null, done: true }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const importCommand: Command = {
|
||||||
|
name: "import",
|
||||||
|
labelKey: "cmd.import.label",
|
||||||
|
prompt: () => "cmd.import.prompt",
|
||||||
|
accepts: () => [],
|
||||||
|
options: () => [],
|
||||||
|
init: () => ({ ...IDLE }),
|
||||||
|
|
||||||
|
// Der Befehl hat keine Schritte: Start/Enter/Input öffnet den Dialog + endet.
|
||||||
|
onInput: () => open(),
|
||||||
|
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
|
||||||
|
onConfirm: () => open(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => [
|
||||||
|
{ ...IDLE },
|
||||||
|
{ draft: null, done: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Line — erster Befehl der Command-Engine (portiert lineTool aus
|
||||||
|
// src/tools/tools.ts). Zwei Punkte → Drawing2D{shape:"line"}.
|
||||||
|
//
|
||||||
|
// Schritte:
|
||||||
|
// 1) „Start of line:" → Punkt (Klick ODER getippt: 0,0 / r3,0 / 3<45)
|
||||||
|
// 2) „End of line:" → Punkt → commit Drawing2D line → done
|
||||||
|
//
|
||||||
|
// Akzeptiert in JEDEM Punkt-Schritt sowohl Maus-Picks (gesnappt) als auch
|
||||||
|
// getippte Koordinaten — das ist die Kern-Vereinheitlichung gegenüber dem alten
|
||||||
|
// Tool. Live-Vorschau + HUD (Länge/Winkel) wie die bestehenden Tools.
|
||||||
|
|
||||||
|
import type { Drawing2D } from "../../model/types";
|
||||||
|
import { uniqueId } from "../../tools/types";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandField,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
DraftShape,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||||
|
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||||
|
|
||||||
|
/** Tab-Felder des End-Schritts: erst Länge, dann Winkel (§2.7). */
|
||||||
|
const LINE_FIELDS: CommandField[] = [
|
||||||
|
{ id: "length", labelKey: "cmd.field.length" },
|
||||||
|
{ id: "angle", labelKey: "cmd.field.angle" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpunkt aus gelockten Feldern (length/angle) + Cursor: ungelockte Werte
|
||||||
|
* folgen dem Cursor relativ zum Startpunkt `a`.
|
||||||
|
*/
|
||||||
|
function lineEndFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
|
||||||
|
const ref = cursor ?? a;
|
||||||
|
const length = "length" in locks ? locks.length : segLen(a, ref);
|
||||||
|
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(a, ref);
|
||||||
|
const ang = angleDeg * DEG;
|
||||||
|
return { x: a.x + Math.cos(ang) * length, y: a.y + Math.sin(ang) * length };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineIdle extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
}
|
||||||
|
interface LineDrawing extends CommandState {
|
||||||
|
phase: "end";
|
||||||
|
a: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
type LineState = LineIdle | LineDrawing;
|
||||||
|
|
||||||
|
/** Vorschau (Rubber-Band) + HUD (Länge·Winkel) für das lebende Segment. */
|
||||||
|
function lineDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
|
const preview: DraftShape[] = [];
|
||||||
|
if (cursor && segLen(a, cursor) >= EPS) preview.push({ kind: "line", a, b: cursor });
|
||||||
|
const draft: ToolDraft = { preview, vertices: [a] };
|
||||||
|
if (cursor && segLen(a, cursor) >= EPS) {
|
||||||
|
draft.hud = {
|
||||||
|
at: cursor,
|
||||||
|
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs(
|
||||||
|
(segAngleDeg(a, cursor) + 360) % 360,
|
||||||
|
).toFixed(0)}°`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hängt eine 2D-Linie ans Projekt (immutabel). Farbe/Strich erbt die Kategorie. */
|
||||||
|
function appendLine(p: import("../types").Project, a: Vec2, b: Vec2, ctx: CommandContext) {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: uniqueId("dr2d"),
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: ctx.level.id,
|
||||||
|
categoryCode: ctx.defaultCategoryCode,
|
||||||
|
geom: { shape: "line", a, b },
|
||||||
|
};
|
||||||
|
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lineCommand: Command = {
|
||||||
|
name: "line",
|
||||||
|
labelKey: "cmd.line.label",
|
||||||
|
prompt: (s) => ((s as LineState).phase === "end" ? "cmd.line.end" : "cmd.line.start"),
|
||||||
|
accepts: () => ["point", "number"],
|
||||||
|
options: () => [],
|
||||||
|
init: (): LineIdle => ({ phase: "start", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as LineState;
|
||||||
|
// Beide Punkt-Schritte erwarten einen aufgelösten Punkt; die Engine reicht
|
||||||
|
// getippte Koordinaten bereits als {kind:"point"} herein.
|
||||||
|
if (input.kind !== "point") return [s, { draft: s.phase === "end" ? lineDraft(s.a, s.cursor) : null }];
|
||||||
|
const pt = input.point;
|
||||||
|
if (s.phase !== "end") {
|
||||||
|
const next: LineDrawing = { phase: "end", a: pt, cursor: pt, lastPoint: pt };
|
||||||
|
return [next, { draft: lineDraft(pt, pt) }];
|
||||||
|
}
|
||||||
|
// Zweiter Punkt: Null-Strecke verwerfen, sonst committen.
|
||||||
|
if (segLen(s.a, pt) < EPS) {
|
||||||
|
return [{ phase: "start", lastPoint: null }, { draft: null, done: true }];
|
||||||
|
}
|
||||||
|
const a = s.a;
|
||||||
|
return [
|
||||||
|
{ phase: "start", lastPoint: null },
|
||||||
|
{ draft: null, done: true, commit: (p) => appendLine(p, a, pt, ctx) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as LineState;
|
||||||
|
if (s.phase !== "end") return [s, { draft: null }];
|
||||||
|
const next: LineDrawing = { ...s, cursor: point };
|
||||||
|
return [next, { draft: lineDraft(s.a, point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Line braucht genau zwei Punkte; Enter/Rechtsklick im offenen Entwurf bricht ab.
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", lastPoint: null },
|
||||||
|
{ draft: null, done: true },
|
||||||
|
],
|
||||||
|
onCancel: (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", lastPoint: null },
|
||||||
|
{ draft: null, done: true },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Tab-Feld-Zyklus nur im End-Schritt: Länge + Winkel relativ zum Startpunkt.
|
||||||
|
fields: (state) => ((state as LineState).phase === "end" ? LINE_FIELDS : []),
|
||||||
|
pointFromFields: (state, locks, cursor) => {
|
||||||
|
const s = state as LineState;
|
||||||
|
if (s.phase !== "end") return null;
|
||||||
|
return lineEndFromFields(s.a, locks, cursor);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Move — verschiebt die AKTUELLE Auswahl (Wände + ein Drawing2D) um
|
||||||
|
// delta = Ziel − Basis (Rhino-Move, docs §2.10/§3.4 Tier-1). Schritte:
|
||||||
|
// 1) „Basispunkt:" → Punkt
|
||||||
|
// 2) „Zielpunkt:" → Punkt (mit Tab-Feldern length/angle wie Line) → commit
|
||||||
|
//
|
||||||
|
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl).
|
||||||
|
// Ist sie leer, ist nichts zu verschieben: kurzer No-op mit Hinweis-Prompt
|
||||||
|
// („Nichts gewählt") und Beenden — das ist der einfache, robuste Weg (statt
|
||||||
|
// einen zweiten, divergierenden Pick-Pfad für die Objektwahl aufzumachen; die
|
||||||
|
// Selektion läuft schon sauber über die Plan-Ansicht/Store).
|
||||||
|
//
|
||||||
|
// Der Commit nutzt den bestehenden, getesteten `commitTransform` (op:"move",
|
||||||
|
// mode:"move") statt eine zweite Verschiebe-Logik zu duplizieren — er bewegt
|
||||||
|
// Wände UND alle 2D-Formen immutabel an die Endlage.
|
||||||
|
|
||||||
|
import { commitTransform } from "../../tools/transform";
|
||||||
|
import type { TransformSelection } from "../../tools/transform";
|
||||||
|
import { transformPreview } from "../../tools/transform";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandField,
|
||||||
|
CommandResult,
|
||||||
|
CommandSelection,
|
||||||
|
CommandState,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||||
|
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||||
|
|
||||||
|
/** Tab-Felder des Zielschritts: Länge + Winkel des Verschiebevektors (§2.7). */
|
||||||
|
const MOVE_FIELDS: CommandField[] = [
|
||||||
|
{ id: "length", labelKey: "cmd.field.length" },
|
||||||
|
{ id: "angle", labelKey: "cmd.field.angle" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Zielpunkt aus gelockten Feldern (length/angle) + Cursor, relativ zum Basispunkt. */
|
||||||
|
function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
|
||||||
|
const ref = cursor ?? base;
|
||||||
|
const length = "length" in locks ? locks.length : segLen(base, ref);
|
||||||
|
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref);
|
||||||
|
const ang = angleDeg * DEG;
|
||||||
|
return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hat die Auswahl überhaupt etwas Verschiebbares? */
|
||||||
|
function hasSelection(sel: CommandSelection): boolean {
|
||||||
|
return sel.wallIds.length > 0 || sel.drawingId !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auswahl → TransformSelection (gleiche Form). */
|
||||||
|
function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||||
|
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zustand: erst Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start).
|
||||||
|
interface MoveBase extends CommandState {
|
||||||
|
phase: "base";
|
||||||
|
}
|
||||||
|
interface MoveTarget extends CommandState {
|
||||||
|
phase: "target";
|
||||||
|
sel: CommandSelection;
|
||||||
|
base: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
// „done": leere Auswahl → No-op, Befehl beendet.
|
||||||
|
interface MoveDone extends CommandState {
|
||||||
|
phase: "done";
|
||||||
|
}
|
||||||
|
type MoveState = MoveBase | MoveTarget | MoveDone;
|
||||||
|
|
||||||
|
/** Live-Vorschau der Auswahl an der aktuellen Cursor-Lage (Move). */
|
||||||
|
function moveDraft(
|
||||||
|
project: Project,
|
||||||
|
sel: CommandSelection,
|
||||||
|
base: Vec2,
|
||||||
|
target: Vec2,
|
||||||
|
): ToolDraft {
|
||||||
|
const preview = transformPreview(
|
||||||
|
project,
|
||||||
|
toTransformSel(sel),
|
||||||
|
"move",
|
||||||
|
[base, target],
|
||||||
|
"move",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
preview,
|
||||||
|
vertices: [base],
|
||||||
|
hud: {
|
||||||
|
at: target,
|
||||||
|
text: `${segLen(base, target).toFixed(2)} m · ${Math.abs(
|
||||||
|
(segAngleDeg(base, target) + 360) % 360,
|
||||||
|
).toFixed(0)}°`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "done", lastPoint: null } as MoveDone,
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const moveCommand: Command = {
|
||||||
|
name: "move",
|
||||||
|
labelKey: "cmd.move.label",
|
||||||
|
prompt: (s) => {
|
||||||
|
const ms = s as MoveState;
|
||||||
|
if (ms.phase === "target") return "cmd.move.target";
|
||||||
|
if (ms.phase === "done") return "cmd.move.empty";
|
||||||
|
return "cmd.move.base";
|
||||||
|
},
|
||||||
|
accepts: () => ["point"],
|
||||||
|
options: () => [],
|
||||||
|
|
||||||
|
// Beim Start die Auswahl aus dem Kontext einfrieren — dafür braucht init()
|
||||||
|
// den Kontext nicht, das macht onInput beim ersten Aufruf; bis dahin „base".
|
||||||
|
// Damit die leere Auswahl SOFORT (vor dem ersten Klick) auffällt, prüfen wir
|
||||||
|
// sie zusätzlich im ersten onMove/onInput.
|
||||||
|
init: (): MoveBase => ({ phase: "base", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as MoveState;
|
||||||
|
if (s.phase === "done") return idle();
|
||||||
|
// Leere Auswahl → nichts zu verschieben (No-op + Hinweis, dann beenden).
|
||||||
|
if (!hasSelection(ctx.selection)) {
|
||||||
|
return [{ phase: "done", lastPoint: null } as MoveDone, { draft: null, done: true }];
|
||||||
|
}
|
||||||
|
if (input.kind !== "point") {
|
||||||
|
if (s.phase === "target") {
|
||||||
|
return [s, { draft: s.cursor ? moveDraft(ctx.project, s.sel, s.base, s.cursor) : null }];
|
||||||
|
}
|
||||||
|
return [s, { draft: null }];
|
||||||
|
}
|
||||||
|
const pt = input.point;
|
||||||
|
if (s.phase !== "target") {
|
||||||
|
// Basispunkt gesetzt → Auswahl-Snapshot mitführen.
|
||||||
|
const next: MoveTarget = {
|
||||||
|
phase: "target",
|
||||||
|
sel: ctx.selection,
|
||||||
|
base: pt,
|
||||||
|
cursor: pt,
|
||||||
|
lastPoint: pt,
|
||||||
|
};
|
||||||
|
return [next, { draft: moveDraft(ctx.project, next.sel, pt, pt) }];
|
||||||
|
}
|
||||||
|
// Zielpunkt → commit (Auswahl an die Endlage verschieben).
|
||||||
|
const base = s.base;
|
||||||
|
const sel = s.sel;
|
||||||
|
return [
|
||||||
|
{ phase: "done", lastPoint: null } as MoveDone,
|
||||||
|
{
|
||||||
|
draft: null,
|
||||||
|
done: true,
|
||||||
|
commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, pt], "move", 1),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as MoveState;
|
||||||
|
if (s.phase !== "target") return [s, { draft: null }];
|
||||||
|
const ns: MoveTarget = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: moveDraft(ctx.project, s.sel, s.base, point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
|
||||||
|
// Tab-Feld-Zyklus nur im Zielschritt: Länge + Winkel relativ zum Basispunkt.
|
||||||
|
fields: (state) => ((state as MoveState).phase === "target" ? MOVE_FIELDS : []),
|
||||||
|
pointFromFields: (state, locks, cursor) => {
|
||||||
|
const s = state as MoveState;
|
||||||
|
if (s.phase !== "target") return null;
|
||||||
|
return targetFromFields(s.base, locks, cursor);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
// Offset — DAS Architektur-Primitiv (docs §2.10/§3.4 Tier-1, §3.6 persistente
|
||||||
|
// Distanz). Erzeugt eine parallel versetzte Kopie einer 2D-Kurve (line/polyline/
|
||||||
|
// rect). Schritte:
|
||||||
|
// 1) Kurve bestimmen: ist genau EIN passendes Drawing2D (line/polyline/rect)
|
||||||
|
// selektiert, wird es genommen; sonst „Kurve wählen:" → Klick auf eine Kurve.
|
||||||
|
// 2) „Seite/Distanz:" → Distanz tippen (PERSISTENT als Default über Aufrufe,
|
||||||
|
// modulweite Variable) ODER Seite per Klick (Vorzeichen aus der Klickseite
|
||||||
|
// relativ zur Kurve). → commit neue versetzte Drawing2D-Kurve.
|
||||||
|
//
|
||||||
|
// Distanz-Vorzeichen: Konvention `+d = links` (kernel2d/leftNormal). Die
|
||||||
|
// Klickseite bestimmt links/rechts: liegt der Klick links der Kurve (bzw. der
|
||||||
|
// nächsten Kante), wird +|d| genommen, sonst −|d|. Eine getippte Zahl nutzt das
|
||||||
|
// Vorzeichen der zuletzt gewählten Seite (Default links/+), bzw. ihr eigenes
|
||||||
|
// Vorzeichen, wenn negativ getippt.
|
||||||
|
|
||||||
|
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
|
||||||
|
import { uniqueId } from "../../tools/types";
|
||||||
|
import { leftNormal, normalize, sub } from "../../model/geometry";
|
||||||
|
import {
|
||||||
|
offsetPolyline,
|
||||||
|
offsetSegment,
|
||||||
|
pointSegmentDistance,
|
||||||
|
polylineEdges,
|
||||||
|
} from "../../geometry/kernel2d";
|
||||||
|
import type {
|
||||||
|
CmdInput,
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandResult,
|
||||||
|
CommandSelection,
|
||||||
|
CommandState,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistente Offset-Distanz über Aufrufe hinweg (§2.4/§3.6 — Nutzer erwarten,
|
||||||
|
* dass die letzte Distanz als Default wiederkommt). Modulweite Variable, |d|;
|
||||||
|
* das Vorzeichen (Seite) wird je Offset frisch aus Klick/Eingabe bestimmt.
|
||||||
|
*/
|
||||||
|
let lastDistance = 0.5;
|
||||||
|
/** Zuletzt gewählte Seite als Vorzeichen (+1 = links, −1 = rechts). */
|
||||||
|
let lastSign = 1;
|
||||||
|
|
||||||
|
// ── Kurven-Abstraktion: line/polyline/rect → Punktliste + closed ──────────────
|
||||||
|
|
||||||
|
interface Curve {
|
||||||
|
pts: Vec2[];
|
||||||
|
closed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wandelt eine offset-fähige 2D-Form in eine Punktliste (+closed) oder null. */
|
||||||
|
function geomToCurve(g: Drawing2DGeom): Curve | null {
|
||||||
|
if (g.shape === "line") return { pts: [g.a, g.b], closed: false };
|
||||||
|
if (g.shape === "polyline") return { pts: g.pts, closed: g.closed };
|
||||||
|
if (g.shape === "rect") {
|
||||||
|
// Rechteck als geschlossene 4-Punkt-Polylinie offsetten (Task).
|
||||||
|
return {
|
||||||
|
pts: [
|
||||||
|
g.min,
|
||||||
|
{ x: g.max.x, y: g.min.y },
|
||||||
|
g.max,
|
||||||
|
{ x: g.min.x, y: g.max.y },
|
||||||
|
],
|
||||||
|
closed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null; // circle/arc/text: hier nicht offset-fähig
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist diese Form offset-fähig (line/polyline/rect)? */
|
||||||
|
function isOffsetable(d: Drawing2D): boolean {
|
||||||
|
return d.geom.shape === "line" || d.geom.shape === "polyline" || d.geom.shape === "rect";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Genau ein passendes Drawing2D vorselektiert? Dann liefere es. */
|
||||||
|
function singleSelectedCurve(project: Project, sel: CommandSelection): Drawing2D | null {
|
||||||
|
if (!sel.drawingId || sel.wallIds.length > 0) return null;
|
||||||
|
const d = project.drawings2d.find((x) => x.id === sel.drawingId);
|
||||||
|
if (d && isOffsetable(d)) return d;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nächstes offset-fähiges Drawing2D zum Klickpunkt (innerhalb Toleranz). */
|
||||||
|
function pickCurveAt(project: Project, levelId: string, pt: Vec2): Drawing2D | null {
|
||||||
|
let best: Drawing2D | null = null;
|
||||||
|
let bestD = HIT_TOL;
|
||||||
|
for (const d of project.drawings2d) {
|
||||||
|
if (d.levelId !== levelId) continue;
|
||||||
|
const curve = geomToCurve(d.geom);
|
||||||
|
if (!curve) continue;
|
||||||
|
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
|
||||||
|
const dd = pointSegmentDistance(pt, a, b);
|
||||||
|
if (dd < bestD) {
|
||||||
|
bestD = dd;
|
||||||
|
best = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorzeichen (+links / −rechts) für einen Klickpunkt relativ zur Kurve: die
|
||||||
|
* nächste Kante bestimmen, dann das Vorzeichen der Skalarprojektion von
|
||||||
|
* (klick − Kantenmitte) auf die LINKS-Normale der Kante. So zeigt das Resultat
|
||||||
|
* auf die angeklickte Seite (Konvention `+d = links`).
|
||||||
|
*/
|
||||||
|
function sideSign(curve: Curve, pt: Vec2): number {
|
||||||
|
let bestD = Infinity;
|
||||||
|
let sign = lastSign;
|
||||||
|
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
|
||||||
|
const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||||
|
const dd = pointSegmentDistance(pt, a, b);
|
||||||
|
if (dd < bestD) {
|
||||||
|
bestD = dd;
|
||||||
|
const u = normalize(sub(b, a));
|
||||||
|
const n = leftNormal(u);
|
||||||
|
const rel = sub(pt, mid);
|
||||||
|
const s = rel.x * n.x + rel.y * n.y;
|
||||||
|
sign = s >= 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offset einer Kurve um die vorzeichenbehaftete Distanz `d` → neue Punktliste. */
|
||||||
|
function offsetCurve(curve: Curve, d: number): Vec2[] {
|
||||||
|
if (!curve.closed && curve.pts.length === 2) {
|
||||||
|
const [a, b] = offsetSegment(curve.pts[0], curve.pts[1], d);
|
||||||
|
return [a, b];
|
||||||
|
}
|
||||||
|
return offsetPolyline(curve.pts, d, curve.closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Versetzte Form als neues Drawing2D (gleiche Attribute, neue ID/Geometrie). */
|
||||||
|
function offsetGeom(src: Drawing2D, d: number): Drawing2DGeom | null {
|
||||||
|
const g = src.geom;
|
||||||
|
if (g.shape === "line") {
|
||||||
|
const [a, b] = offsetSegment(g.a, g.b, d);
|
||||||
|
return { shape: "line", a, b };
|
||||||
|
}
|
||||||
|
if (g.shape === "polyline") {
|
||||||
|
return { shape: "polyline", pts: offsetPolyline(g.pts, d, g.closed), closed: g.closed };
|
||||||
|
}
|
||||||
|
if (g.shape === "rect") {
|
||||||
|
const curve = geomToCurve(g)!;
|
||||||
|
return { shape: "polyline", pts: offsetPolyline(curve.pts, d, true), closed: true };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau-Formen der versetzten Kurve (für den Draft). */
|
||||||
|
function offsetDraftShapes(curve: Curve, d: number): DraftShape[] {
|
||||||
|
const pts = offsetCurve(curve, d);
|
||||||
|
if (pts.length < 2) return [];
|
||||||
|
if (!curve.closed && pts.length === 2) return [{ kind: "line", a: pts[0], b: pts[1] }];
|
||||||
|
return [{ kind: "poly", pts, closed: curve.closed }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendOffset(p: Project, src: Drawing2D, d: number): Project {
|
||||||
|
const geom = offsetGeom(src, d);
|
||||||
|
if (!geom) return p;
|
||||||
|
const copy: Drawing2D = { ...src, id: uniqueId("dr2d"), geom };
|
||||||
|
return { ...p, drawings2d: [...p.drawings2d, copy] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zustand ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// „pick": Kurve wählen (keine passende Vorselektion). „side": Kurve steht fest,
|
||||||
|
// jetzt Seite/Distanz. „done": Befehl beendet.
|
||||||
|
interface OffPick extends CommandState {
|
||||||
|
phase: "pick";
|
||||||
|
}
|
||||||
|
interface OffSide extends CommandState {
|
||||||
|
phase: "side";
|
||||||
|
drawingId: string;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
interface OffDone extends CommandState {
|
||||||
|
phase: "done";
|
||||||
|
}
|
||||||
|
type OffState = OffPick | OffSide | OffDone;
|
||||||
|
|
||||||
|
const finish = (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "done", lastPoint: null } as OffDone,
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Aktuelle Kurve aus dem Side-Zustand (oder null, wenn weg). */
|
||||||
|
function curveOf(project: Project, drawingId: string): { src: Drawing2D; curve: Curve } | null {
|
||||||
|
const src = project.drawings2d.find((x) => x.id === drawingId);
|
||||||
|
if (!src) return null;
|
||||||
|
const curve = geomToCurve(src.geom);
|
||||||
|
if (!curve) return null;
|
||||||
|
return { src, curve };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const offsetCommand: Command = {
|
||||||
|
name: "offset",
|
||||||
|
labelKey: "cmd.offset.label",
|
||||||
|
prompt: (s) => {
|
||||||
|
const os = s as OffState;
|
||||||
|
if (os.phase === "side") return "cmd.offset.side";
|
||||||
|
return "cmd.offset.pick";
|
||||||
|
},
|
||||||
|
// Side-Schritt: Punkt (Seite klicken) ODER Zahl (Distanz tippen). Auch der
|
||||||
|
// „pick"-Schritt nimmt eine Zahl an, damit eine getippte Distanz sofort greift,
|
||||||
|
// wenn eine Kurve vorselektiert ist (dann wird „pick" live wie „side" behandelt).
|
||||||
|
accepts: () => ["point", "number"],
|
||||||
|
options: () => [],
|
||||||
|
|
||||||
|
// init() kennt den Kontext nicht; die Vorselektion wird im ersten onMove/
|
||||||
|
// onInput ausgewertet (geht direkt auf „side", wenn genau eine Kurve gewählt
|
||||||
|
// ist). Bis dahin „pick".
|
||||||
|
init: (): OffPick => ({ phase: "pick", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as OffState;
|
||||||
|
if (s.phase === "done") return finish();
|
||||||
|
|
||||||
|
// Vorselektion: gibt es genau eine passende Kurve, direkt in den Side-Schritt
|
||||||
|
// springen (auch wenn die Engine erst onInput aufruft).
|
||||||
|
if (s.phase === "pick") {
|
||||||
|
const pre = singleSelectedCurve(ctx.project, ctx.selection);
|
||||||
|
if (pre) {
|
||||||
|
// Eingabe im selben Schritt direkt als Side/Distanz behandeln.
|
||||||
|
return offsetSideInput(
|
||||||
|
{ phase: "side", drawingId: pre.id, cursor: null, lastPoint: null },
|
||||||
|
input,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Keine Vorselektion → Kurve per Klick wählen.
|
||||||
|
if (input.kind === "point") {
|
||||||
|
const hit = pickCurveAt(ctx.project, ctx.level.id, input.point);
|
||||||
|
if (!hit) return [s, { draft: null }];
|
||||||
|
const ns: OffSide = { phase: "side", drawingId: hit.id, cursor: null, lastPoint: null };
|
||||||
|
return [ns, { draft: null }];
|
||||||
|
}
|
||||||
|
return [s, { draft: null }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase === "side"
|
||||||
|
return offsetSideInput(s as OffSide, input, ctx);
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as OffState;
|
||||||
|
// Vorselektion live: in „pick" mit genau einer Kurve → als Side behandeln.
|
||||||
|
if (s.phase === "pick") {
|
||||||
|
const pre = singleSelectedCurve(ctx.project, ctx.selection);
|
||||||
|
if (pre) {
|
||||||
|
return offsetSideMove({ phase: "side", drawingId: pre.id, cursor: point, lastPoint: null }, point, ctx);
|
||||||
|
}
|
||||||
|
return [s, { draft: null }];
|
||||||
|
}
|
||||||
|
if (s.phase === "side") return offsetSideMove(s as OffSide, point, ctx);
|
||||||
|
return [s, { draft: null }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => finish(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => finish(),
|
||||||
|
|
||||||
|
// KEIN Tab-Feld-Zyklus: die Offset-Distanz ist ein reiner Skalar, der als
|
||||||
|
// `number`-Eingabe in den Schritt läuft (nicht als feld-erzeugter Punkt). So
|
||||||
|
// committet eine getippte Distanz direkt; die Seite kommt per Klick/Vorzeichen.
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Side-Schritt-Logik (für Vorselektion UND echten pick wiederverwendet) ─────
|
||||||
|
|
||||||
|
/** Eingabe im Side-Schritt: Punkt (Seite klicken) ODER Zahl (Distanz tippen). */
|
||||||
|
function offsetSideInput(
|
||||||
|
s: OffSide,
|
||||||
|
input: CmdInput,
|
||||||
|
ctx: CommandContext,
|
||||||
|
): [CommandState, CommandResult] {
|
||||||
|
const cc = curveOf(ctx.project, s.drawingId);
|
||||||
|
if (!cc) return finish();
|
||||||
|
const { src, curve } = cc;
|
||||||
|
|
||||||
|
if (input.kind === "number") {
|
||||||
|
// Getippte Distanz: Betrag merken; Vorzeichen aus der zuletzt gewählten
|
||||||
|
// Seite (bzw. dem getippten Vorzeichen, falls negativ).
|
||||||
|
const typed = input.value;
|
||||||
|
const mag = Math.abs(typed);
|
||||||
|
if (mag < EPS) return [s, { draft: null }];
|
||||||
|
lastDistance = mag;
|
||||||
|
const sign = typed < 0 ? -1 : lastSign;
|
||||||
|
lastSign = sign;
|
||||||
|
const d = sign * mag;
|
||||||
|
return [
|
||||||
|
{ phase: "done", lastPoint: null } as OffDone,
|
||||||
|
{ draft: null, done: true, commit: (p) => appendOffset(p, src, d) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.kind === "point") {
|
||||||
|
// Seite per Klick: Vorzeichen aus der Klickseite, Betrag = letzte Distanz.
|
||||||
|
const sign = sideSign(curve, input.point);
|
||||||
|
lastSign = sign;
|
||||||
|
const d = sign * lastDistance;
|
||||||
|
return [
|
||||||
|
{ phase: "done", lastPoint: null } as OffDone,
|
||||||
|
{ draft: null, done: true, commit: (p) => appendOffset(p, src, d) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [s, { draft: null }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hover im Side-Schritt: Vorschau der versetzten Kurve an der Cursor-Seite. */
|
||||||
|
function offsetSideMove(
|
||||||
|
s: OffSide,
|
||||||
|
point: Vec2,
|
||||||
|
ctx: CommandContext,
|
||||||
|
): [CommandState, CommandResult] {
|
||||||
|
const cc = curveOf(ctx.project, s.drawingId);
|
||||||
|
if (!cc) return [s, { draft: null }];
|
||||||
|
const { curve } = cc;
|
||||||
|
const sign = sideSign(curve, point);
|
||||||
|
const d = sign * lastDistance;
|
||||||
|
const preview = offsetDraftShapes(curve, d);
|
||||||
|
const ns: OffSide = { ...s, cursor: point };
|
||||||
|
return [
|
||||||
|
ns,
|
||||||
|
{
|
||||||
|
draft: {
|
||||||
|
preview,
|
||||||
|
vertices: [],
|
||||||
|
hud: { at: point, text: `${lastDistance.toFixed(2)} m` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Polyline — mehrteiliger Zug (portiert polylineTool). Schritte:
|
||||||
|
// 1) „Startpunkt:" → Punkt
|
||||||
|
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … (Enter beendet offen,
|
||||||
|
// „Schliessen" schließt; Klick auf den Startpunkt schließt ebenfalls)
|
||||||
|
//
|
||||||
|
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45).
|
||||||
|
|
||||||
|
import type { Drawing2D } from "../../model/types";
|
||||||
|
import { uniqueId } from "../../tools/types";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandField,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
CmdOption,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Zug
|
||||||
|
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||||
|
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||||
|
|
||||||
|
/** Tab-Felder je weiteren Punkt: Länge + Winkel relativ zum letzten Punkt. */
|
||||||
|
const POLY_FIELDS: CommandField[] = [
|
||||||
|
{ id: "length", labelKey: "cmd.field.length" },
|
||||||
|
{ id: "angle", labelKey: "cmd.field.angle" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Nächster Punkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `last`. */
|
||||||
|
function polyNextFromFields(last: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
|
||||||
|
const ref = cursor ?? last;
|
||||||
|
const length = "length" in locks ? locks.length : segLen(last, ref);
|
||||||
|
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
|
||||||
|
const ang = angleDeg * DEG;
|
||||||
|
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PolyIdle extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
}
|
||||||
|
interface PolyDrawing extends CommandState {
|
||||||
|
phase: "next";
|
||||||
|
points: Vec2[];
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
type PolyState = PolyIdle | PolyDrawing;
|
||||||
|
|
||||||
|
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
|
||||||
|
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
|
||||||
|
|
||||||
|
/** Vorschau (Zug + Gummiband zum Cursor) + HUD (Länge·Winkel des letzten Segments). */
|
||||||
|
function polyDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
|
||||||
|
const pts = cursor ? [...points, cursor] : points;
|
||||||
|
const preview: DraftShape[] = [{ kind: "poly", pts, closed: false }];
|
||||||
|
const draft: ToolDraft = { preview, vertices: points };
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
if (cursor && last && segLen(last, cursor) >= EPS) {
|
||||||
|
draft.hud = {
|
||||||
|
at: cursor,
|
||||||
|
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
|
||||||
|
(segAngleDeg(last, cursor) + 360) % 360,
|
||||||
|
).toFixed(0)}°`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hängt eine 2D-Polylinie ans Projekt (immutabel). Farbe/Strich erbt die Kategorie. */
|
||||||
|
function appendPolyline(
|
||||||
|
p: Project,
|
||||||
|
pts: Vec2[],
|
||||||
|
closed: boolean,
|
||||||
|
ctx: CommandContext,
|
||||||
|
): Project {
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: uniqueId("dr2d"),
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: ctx.level.id,
|
||||||
|
categoryCode: ctx.defaultCategoryCode,
|
||||||
|
geom: { shape: "polyline", pts, closed },
|
||||||
|
};
|
||||||
|
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", lastPoint: null },
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const polylineCommand: Command = {
|
||||||
|
name: "polyline",
|
||||||
|
labelKey: "cmd.polyline.label",
|
||||||
|
prompt: (s) => ((s as PolyState).phase === "next" ? "cmd.polyline.next" : "cmd.polyline.start"),
|
||||||
|
accepts: (s) =>
|
||||||
|
(s as PolyState).phase === "next" ? ["point", "number", "option"] : ["point", "number"],
|
||||||
|
options: (s) => {
|
||||||
|
const ps = s as PolyState;
|
||||||
|
if (ps.phase !== "next") return [];
|
||||||
|
return ps.points.length >= 2 ? [CLOSE, UNDO] : [UNDO];
|
||||||
|
},
|
||||||
|
init: (): PolyIdle => ({ phase: "start", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as PolyState;
|
||||||
|
|
||||||
|
if (input.kind === "option") {
|
||||||
|
if (s.phase !== "next") return [s, { draft: null }];
|
||||||
|
if (input.id === "undo") {
|
||||||
|
const pts = s.points.slice(0, -1);
|
||||||
|
if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }];
|
||||||
|
const ns: PolyDrawing = { phase: "next", points: pts, cursor: s.cursor, lastPoint: pts[pts.length - 1] };
|
||||||
|
return [ns, { draft: polyDraft(pts, s.cursor) }];
|
||||||
|
}
|
||||||
|
if (input.id === "close" && s.points.length >= 3) {
|
||||||
|
const pts = s.points;
|
||||||
|
return [
|
||||||
|
{ phase: "start", lastPoint: null },
|
||||||
|
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [s, { draft: polyDraft(s.points, s.cursor) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.kind !== "point") {
|
||||||
|
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor) : null }];
|
||||||
|
}
|
||||||
|
const pt = input.point;
|
||||||
|
if (s.phase !== "next") {
|
||||||
|
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
|
||||||
|
return [ns, { draft: polyDraft([pt], pt) }];
|
||||||
|
}
|
||||||
|
// Klick nahe Startpunkt → schließen.
|
||||||
|
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
|
||||||
|
const pts = s.points;
|
||||||
|
return [
|
||||||
|
{ phase: "start", lastPoint: null },
|
||||||
|
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const points = [...s.points, pt];
|
||||||
|
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
|
||||||
|
return [ns, { draft: polyDraft(points, pt) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as PolyState;
|
||||||
|
if (s.phase !== "next") return [s, { draft: null }];
|
||||||
|
const ns: PolyDrawing = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: polyDraft(s.points, point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen.
|
||||||
|
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as PolyState;
|
||||||
|
if (s.phase === "next" && s.points.length >= 2) {
|
||||||
|
const pts = s.points;
|
||||||
|
return [
|
||||||
|
{ phase: "start", lastPoint: null },
|
||||||
|
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, false, ctx) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return idle();
|
||||||
|
},
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
|
||||||
|
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten
|
||||||
|
// Punkt. (Erster Punkt = freie Koordinate, kein Feld-Modus.)
|
||||||
|
fields: (state) => ((state as PolyState).phase === "next" ? POLY_FIELDS : []),
|
||||||
|
pointFromFields: (state, locks, cursor) => {
|
||||||
|
const s = state as PolyState;
|
||||||
|
if (s.phase !== "next" || s.points.length === 0) return null;
|
||||||
|
return polyNextFromFields(s.points[s.points.length - 1], locks, cursor);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Rectangle — zwei Ecken (portiert rectTool). Schritte:
|
||||||
|
// 1) „Erste Ecke:" → Punkt
|
||||||
|
// 2) „Gegenüberliegende Ecke:" → Punkt → commit Drawing2D rect → done
|
||||||
|
//
|
||||||
|
// Getippte Maße: die zweite Ecke kann als `r<breite>,<höhe>` relativ zur ersten
|
||||||
|
// eingegeben werden (die Engine löst `r…` relativ zu lastPoint auf).
|
||||||
|
|
||||||
|
import type { Drawing2D } from "../../model/types";
|
||||||
|
import { uniqueId } from "../../tools/types";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandField,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
|
||||||
|
/** Tab-Felder des zweiten-Ecke-Schritts: Breite, Höhe. */
|
||||||
|
const RECT_FIELDS: CommandField[] = [
|
||||||
|
{ id: "width", labelKey: "cmd.field.width" },
|
||||||
|
{ id: "height", labelKey: "cmd.field.height" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gegenüberliegende Ecke aus gelockten Feldern (width/height) + Cursor: eine
|
||||||
|
* gelockte Kantenlänge nimmt ihren Betrag, das Vorzeichen (Quadrant) folgt dem
|
||||||
|
* Cursor relativ zur ersten Ecke `a`; ungelockt folgt die Kante direkt dem Cursor.
|
||||||
|
*/
|
||||||
|
function rectCornerFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
|
||||||
|
const cur = cursor ?? a;
|
||||||
|
const dx = cur.x - a.x;
|
||||||
|
const dy = cur.y - a.y;
|
||||||
|
const sx = dx < 0 ? -1 : 1;
|
||||||
|
const sy = dy < 0 ? -1 : 1;
|
||||||
|
const w = "width" in locks ? sx * Math.abs(locks.width) : dx;
|
||||||
|
const h = "height" in locks ? sy * Math.abs(locks.height) : dy;
|
||||||
|
return { x: a.x + w, y: a.y + h };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RectIdle extends CommandState {
|
||||||
|
phase: "corner1";
|
||||||
|
}
|
||||||
|
interface RectDrawing extends CommandState {
|
||||||
|
phase: "corner2";
|
||||||
|
a: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
type RectState = RectIdle | RectDrawing;
|
||||||
|
|
||||||
|
function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
|
||||||
|
return {
|
||||||
|
min: { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) },
|
||||||
|
max: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau (geschlossenes Rechteck) + HUD (Breite × Höhe). */
|
||||||
|
function rectDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
|
if (!cursor) return { preview: [], vertices: [a] };
|
||||||
|
const g = rectGeom(a, cursor);
|
||||||
|
const pts: Vec2[] = [
|
||||||
|
g.min,
|
||||||
|
{ x: g.max.x, y: g.min.y },
|
||||||
|
g.max,
|
||||||
|
{ x: g.min.x, y: g.max.y },
|
||||||
|
];
|
||||||
|
const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
|
||||||
|
const w = g.max.x - g.min.x;
|
||||||
|
const h = g.max.y - g.min.y;
|
||||||
|
return {
|
||||||
|
preview,
|
||||||
|
vertices: [a],
|
||||||
|
hud: { at: cursor, text: `${w.toFixed(2)} × ${h.toFixed(2)} m` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRect(p: Project, a: Vec2, b: Vec2, ctx: CommandContext): Project {
|
||||||
|
const g = rectGeom(a, b);
|
||||||
|
if (g.max.x - g.min.x < EPS || g.max.y - g.min.y < EPS) return p; // Null-Rechteck
|
||||||
|
const d: Drawing2D = {
|
||||||
|
id: uniqueId("dr2d"),
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: ctx.level.id,
|
||||||
|
categoryCode: ctx.defaultCategoryCode,
|
||||||
|
geom: { shape: "rect", min: g.min, max: g.max },
|
||||||
|
};
|
||||||
|
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "corner1", lastPoint: null },
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const rectCommand: Command = {
|
||||||
|
name: "rect",
|
||||||
|
labelKey: "cmd.rect.label",
|
||||||
|
prompt: (s) => ((s as RectState).phase === "corner2" ? "cmd.rect.second" : "cmd.rect.first"),
|
||||||
|
accepts: () => ["point", "number"],
|
||||||
|
options: () => [],
|
||||||
|
init: (): RectIdle => ({ phase: "corner1", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as RectState;
|
||||||
|
if (input.kind !== "point") {
|
||||||
|
return [s, { draft: s.phase === "corner2" ? rectDraft(s.a, s.cursor) : null }];
|
||||||
|
}
|
||||||
|
const pt = input.point;
|
||||||
|
if (s.phase !== "corner2") {
|
||||||
|
const ns: RectDrawing = { phase: "corner2", a: pt, cursor: pt, lastPoint: pt };
|
||||||
|
return [ns, { draft: rectDraft(pt, pt) }];
|
||||||
|
}
|
||||||
|
const a = s.a;
|
||||||
|
return [
|
||||||
|
{ phase: "corner1", lastPoint: null },
|
||||||
|
{ draft: null, done: true, commit: (p) => appendRect(p, a, pt, ctx) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as RectState;
|
||||||
|
if (s.phase !== "corner2") return [s, { draft: null }];
|
||||||
|
const ns: RectDrawing = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: rectDraft(s.a, point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
|
||||||
|
// Tab-Feld-Zyklus nur im „corner2"-Schritt: Breite + Höhe (signiert nach Cursor).
|
||||||
|
fields: (state) => ((state as RectState).phase === "corner2" ? RECT_FIELDS : []),
|
||||||
|
pointFromFields: (state, locks, cursor) => {
|
||||||
|
const s = state as RectState;
|
||||||
|
if (s.phase !== "corner2") return null;
|
||||||
|
return rectCornerFromFields(s.a, locks, cursor);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Terrain — erzeugt aus dem (ersten) ContourSet der Kontext-Schicht ein
|
||||||
|
// Gelände-TIN und hängt es an `project.context`. Null-Schritt-Befehl: er
|
||||||
|
// committet sofort beim Start (über onConfirm, das die Engine direkt aufruft,
|
||||||
|
// wenn keine Punkte erwartet werden) — wir lösen das robust über onInput/
|
||||||
|
// onConfirm, die beide dieselbe Mutation liefern.
|
||||||
|
//
|
||||||
|
// Die Mutation ist rein (kein Store/React): sie sucht den ersten ContourSet in
|
||||||
|
// p.context, ruft generateTerrainFromContours und fügt das TIN hinzu. Liefert
|
||||||
|
// das TIN kein sinnvolles Mesh (zu wenige/kollineare Punkte), bleibt p
|
||||||
|
// unverändert.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, sichtbarer Text via t() (Namespace `cmd.terrain.*`).
|
||||||
|
|
||||||
|
import { generateTerrainFromContours } from "../../model/terrain";
|
||||||
|
import type { ContextObject } from "../../model/types";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
Project,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
/** Hängt ein aus dem ersten ContourSet abgeleitetes TIN an (immutabel). */
|
||||||
|
function appendTerrain(p: Project): Project {
|
||||||
|
const context: ContextObject[] = p.context ?? [];
|
||||||
|
const set = context.find((o) => o.type === "contourSet");
|
||||||
|
if (!set || set.type !== "contourSet") return p;
|
||||||
|
const terrain = generateTerrainFromContours(set.contours);
|
||||||
|
if (terrain.indices.length === 0) return p; // kein sinnvolles TIN
|
||||||
|
return { ...p, context: [...context, terrain] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ergebnis, das den Befehl sofort beendet und das TIN committet. */
|
||||||
|
const COMMIT_DONE: CommandResult = {
|
||||||
|
draft: null,
|
||||||
|
done: true,
|
||||||
|
commit: appendTerrain,
|
||||||
|
};
|
||||||
|
|
||||||
|
const IDLE: CommandState = { phase: "idle", lastPoint: null };
|
||||||
|
|
||||||
|
export const terrainCommand: Command = {
|
||||||
|
name: "terrain",
|
||||||
|
labelKey: "cmd.terrain.label",
|
||||||
|
prompt: () => "cmd.terrain.prompt",
|
||||||
|
// Erwartet keine echten Eingaben; Enter/Start committet sofort.
|
||||||
|
accepts: () => [],
|
||||||
|
options: () => [],
|
||||||
|
init: () => ({ ...IDLE }),
|
||||||
|
|
||||||
|
// Jeglicher Input beendet den Befehl mit dem Commit (es gibt keine Schritte).
|
||||||
|
onInput: (): [CommandState, CommandResult] => [{ ...IDLE }, COMMIT_DONE],
|
||||||
|
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
|
||||||
|
// Enter/Space/Rechtsklick: Gelände erzeugen + beenden.
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => [{ ...IDLE }, COMMIT_DONE],
|
||||||
|
onCancel: (): [CommandState, CommandResult] => [
|
||||||
|
{ ...IDLE },
|
||||||
|
{ draft: null, done: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
// Command-Runner / State-Machine (docs/design/rhino-command-system.md §3.2, §2.3,
|
||||||
|
// §2.6). Hält den aktiven Befehl + Schritt-Zustand, einen Prompt-Stack-Grundbau
|
||||||
|
// (für künftige transparente Befehle §2.6) und `lastCommand` (Leer-Enter bzw.
|
||||||
|
// Rechtsklick wiederholt den letzten Befehl §2.3).
|
||||||
|
//
|
||||||
|
// Eingaben kommen aus DREI Quellen, die alle in DENSELBEN Schritt fließen:
|
||||||
|
// • getippte Command-Line-Eingabe (submitText)
|
||||||
|
// • Maus-Pick aus PlanView (pick) / Hover (move)
|
||||||
|
// • Klick auf eine Inline-Option (chooseOption)
|
||||||
|
//
|
||||||
|
// Die Engine ist UI-agnostisch: Kontext (Projekt/Level/Kategorie), Snapping und
|
||||||
|
// das Anwenden von draft/commit liefert ein injizierter `EngineHost`. Bei jeder
|
||||||
|
// Zustandsänderung ruft sie `emit()` → React-Komponenten (CommandLine, App)
|
||||||
|
// abonnieren via subscribe() und re-rendern.
|
||||||
|
|
||||||
|
import type { Project, Vec2 } from "../model/types";
|
||||||
|
import type { SnapResult, ToolDraft } from "../tools/types";
|
||||||
|
import { parseInput, resolvePoint } from "./parseInput";
|
||||||
|
import { getCommand, resolveCommand, autocomplete } from "./registry";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
CmdInput,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brücke zur App: liefert den Live-Kontext + Snapping und wendet die Ergebnisse
|
||||||
|
* an (Vorschau zeigen, committen). So bleibt die Engine frei von React/Store.
|
||||||
|
*/
|
||||||
|
export interface EngineHost {
|
||||||
|
/** Aktueller Befehls-Kontext (Projekt, aktive Ebene/Kategorie, lastPoint). */
|
||||||
|
context(lastPoint: Vec2 | null): CommandContext;
|
||||||
|
/** Snap für einen rohen Cursor-Punkt (oder null), inkl. der Draft-Punkte. */
|
||||||
|
snap(raw: Vec2, draftPoints: Vec2[]): SnapResult | null;
|
||||||
|
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz). */
|
||||||
|
pxPerMeter(): number;
|
||||||
|
/** Vorschau setzen (oder löschen). */
|
||||||
|
setDraft(draft: ToolDraft | null): void;
|
||||||
|
/** Commit immutabel anwenden. */
|
||||||
|
commit(mutate: (p: Project) => Project): void;
|
||||||
|
/** Ist die aktive Ebene ein Geschoss? (für floorOnly-Befehle). */
|
||||||
|
isFloor(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffentlicher Schnappschuss für die Command-Line-UI (rein lesend). */
|
||||||
|
export interface EngineView {
|
||||||
|
/** Läuft gerade ein Befehl? */
|
||||||
|
active: boolean;
|
||||||
|
/** Aktueller Prompt-Key (i18n) oder null (Ruhezustand). */
|
||||||
|
promptKey: string | null;
|
||||||
|
/** Inline-Optionen des aktuellen Schritts. */
|
||||||
|
options: { id: string; labelKey: string; value?: string }[];
|
||||||
|
/** Name des aktiven Befehls (für Anzeige), null im Ruhezustand. */
|
||||||
|
commandName: string | null;
|
||||||
|
/**
|
||||||
|
* Geordnete Zahlenfelder des aktuellen Schritts (Tab-Feld-Zyklus §2.7). Leer,
|
||||||
|
* wenn der aktive Befehl/Schritt keine Felder anbietet. `value` ist der
|
||||||
|
* gelockte Wert (oder null = folgt der Maus), `active` markiert das aktuelle
|
||||||
|
* Tab-Ziel.
|
||||||
|
*/
|
||||||
|
fields: { id: string; labelKey: string; value: number | null; active: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CommandEngine {
|
||||||
|
private host: EngineHost;
|
||||||
|
private command: Command | null = null;
|
||||||
|
private state: CommandState | null = null;
|
||||||
|
private lastCommandName: string | null = null;
|
||||||
|
/** Letzter (gesnappter) Cursor-Punkt für Distanz-/Winkel-Lock-Auflösung. */
|
||||||
|
private lastCursor: Vec2 | null = null;
|
||||||
|
/** Tab-Feld-Zyklus: aktiver Feld-Index des aktuellen Schritts (§2.7). */
|
||||||
|
private activeFieldIndex = 0;
|
||||||
|
/** Tab-Feld-Zyklus: gelockte Feldwerte (id → Zahl); ungelockt folgt der Maus. */
|
||||||
|
private locks: Record<string, number> = {};
|
||||||
|
/** Identität des Schritts, für den der Feld-Zustand gilt (Reset bei Wechsel). */
|
||||||
|
private fieldPhaseKey: string | null = null;
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(host: EngineHost) {
|
||||||
|
this.host = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Abo / Snapshot ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
subscribe(fn: () => void): () => void {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
}
|
||||||
|
private emit(): void {
|
||||||
|
for (const l of this.listeners) l();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Läuft gerade ein Befehl? */
|
||||||
|
isActive(): boolean {
|
||||||
|
return this.command !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lesender Schnappschuss für die Command-Line. */
|
||||||
|
view(): EngineView {
|
||||||
|
if (!this.command || !this.state) {
|
||||||
|
return { active: false, promptKey: null, options: [], commandName: null, fields: [] };
|
||||||
|
}
|
||||||
|
this.syncFields();
|
||||||
|
const defs = this.currentFields();
|
||||||
|
const fields = defs.map((f, i) => ({
|
||||||
|
id: f.id,
|
||||||
|
labelKey: f.labelKey as string,
|
||||||
|
value: f.id in this.locks ? this.locks[f.id] : null,
|
||||||
|
active: i === this.activeFieldIndex,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
active: true,
|
||||||
|
promptKey: this.command.prompt(this.state),
|
||||||
|
options: this.command.options(this.state),
|
||||||
|
commandName: this.command.name,
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Autocomplete-Kandidaten für die getippte Eingabe (Präfix). */
|
||||||
|
suggest(prefix: string): string[] {
|
||||||
|
return autocomplete(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab-Feld-Zyklus (§2.7) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Feld-Definitionen des aktuellen Schritts (leer, wenn keine angeboten). */
|
||||||
|
private currentFields(): { id: string; labelKey: string }[] {
|
||||||
|
if (!this.command || !this.state || !this.command.fields) return [];
|
||||||
|
return this.command.fields(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hat der aktuelle Schritt einen Tab-Feld-Modus? */
|
||||||
|
hasFields(): boolean {
|
||||||
|
return this.currentFields().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Feld-Zustand zurück, wenn der Schritt wechselt — Signatur =
|
||||||
|
* phase + Feld-IDs + lastPoint. Der lastPoint-Teil sorgt dafür, dass ein
|
||||||
|
* mehrteiliger Befehl (Polyline) je gesetztem Punkt FRISCHE Felder/Locks
|
||||||
|
* bekommt, obwohl die phase ("next") gleich bleibt.
|
||||||
|
*/
|
||||||
|
private syncFields(): void {
|
||||||
|
const defs = this.currentFields();
|
||||||
|
const lp = this.state?.lastPoint;
|
||||||
|
const lpKey = lp ? `${lp.x.toFixed(6)},${lp.y.toFixed(6)}` : "∅";
|
||||||
|
const key = this.state
|
||||||
|
? `${this.state.phase}:${defs.map((f) => f.id).join(",")}:${lpKey}`
|
||||||
|
: null;
|
||||||
|
if (key !== this.fieldPhaseKey) {
|
||||||
|
this.fieldPhaseKey = key;
|
||||||
|
this.activeFieldIndex = 0;
|
||||||
|
this.locks = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab: nächstes Feld aktiv (zyklisch). No-op ohne Felder. */
|
||||||
|
cycleField(): void {
|
||||||
|
this.syncFields();
|
||||||
|
const n = this.currentFields().length;
|
||||||
|
if (n === 0) return;
|
||||||
|
this.activeFieldIndex = (this.activeFieldIndex + 1) % n;
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lockt einen getippten Zahlenwert ins aktive Feld und rückt aufs nächste vor
|
||||||
|
* (§2.7, Task-Schritt 2). Sind danach ALLE Felder gelockt, committet der Schritt
|
||||||
|
* sofort den daraus gebildeten Punkt — so commitet ein Ein-Feld-Befehl (Kreis:
|
||||||
|
* Radius) direkt und ein Zwei-Feld-Befehl (Linie) nach dem zweiten Wert.
|
||||||
|
*/
|
||||||
|
private lockActiveField(value: number): void {
|
||||||
|
this.syncFields();
|
||||||
|
const defs = this.currentFields();
|
||||||
|
if (defs.length === 0) return;
|
||||||
|
const field = defs[this.activeFieldIndex];
|
||||||
|
this.locks = { ...this.locks, [field.id]: value };
|
||||||
|
const allLocked = defs.every((f) => f.id in this.locks);
|
||||||
|
if (allLocked) {
|
||||||
|
this.commitFields();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.activeFieldIndex = (this.activeFieldIndex + 1) % defs.length;
|
||||||
|
this.refreshFieldPreview();
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Committet den aus den Feld-Locks (+ Cursor) gebildeten Punkt als Schritt-Eingabe. */
|
||||||
|
private commitFields(): void {
|
||||||
|
const pt = this.fieldResultPoint(this.lastCursor);
|
||||||
|
if (pt) this.feed({ kind: "point", point: pt });
|
||||||
|
else this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau aus den gelockten Feldern + dem letzten Cursor neu zeichnen. */
|
||||||
|
private refreshFieldPreview(): void {
|
||||||
|
if (!this.command || !this.state || !this.command.pointFromFields) return;
|
||||||
|
const pt = this.command.pointFromFields(this.state, this.locks, this.lastCursor);
|
||||||
|
if (!pt) return;
|
||||||
|
const ctx = this.context();
|
||||||
|
const [st, res] = this.command.onMove(this.state, pt, null, ctx);
|
||||||
|
this.state = st;
|
||||||
|
if (res.draft) this.host.setDraft(res.draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Befehls-Lebenszyklus ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Startet einen Befehl per Name/Alias/Präfix. Liefert true bei Erfolg. */
|
||||||
|
start(nameOrPrefix: string): boolean {
|
||||||
|
const cmd = resolveCommand(nameOrPrefix) ?? getCommand(nameOrPrefix);
|
||||||
|
if (!cmd) return false;
|
||||||
|
return this.startCommand(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startet einen konkreten Befehl (z. B. zur Wiederholung). */
|
||||||
|
startCommand(cmd: Command): boolean {
|
||||||
|
if (cmd.floorOnly && !this.host.isFloor()) return false;
|
||||||
|
this.command = cmd;
|
||||||
|
this.state = cmd.init();
|
||||||
|
this.lastCommandName = cmd.name;
|
||||||
|
this.lastCursor = null;
|
||||||
|
this.resetFields();
|
||||||
|
this.host.setDraft(null);
|
||||||
|
this.emit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab-Feld-Zustand vollständig zurücksetzen (Befehlsstart/-ende/Abbruch). */
|
||||||
|
private resetFields(): void {
|
||||||
|
this.activeFieldIndex = 0;
|
||||||
|
this.locks = {};
|
||||||
|
this.fieldPhaseKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bricht den laufenden Befehl ab (Esc). */
|
||||||
|
cancel(): void {
|
||||||
|
if (this.command && this.state) {
|
||||||
|
const [, res] = this.command.onCancel(this.state);
|
||||||
|
this.applyResult(res);
|
||||||
|
}
|
||||||
|
this.command = null;
|
||||||
|
this.state = null;
|
||||||
|
this.resetFields();
|
||||||
|
this.host.setDraft(null);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eingabe-Routing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getippte Command-Line-Eingabe (Enter). Im Ruhezustand: Befehl starten / bei
|
||||||
|
* leerer Zeile letzten wiederholen (§2.3). In einem Befehl: als Koordinate/
|
||||||
|
* Zahl/Option in den aktuellen Schritt routen.
|
||||||
|
*/
|
||||||
|
submitText(raw: string): void {
|
||||||
|
const text = raw.trim();
|
||||||
|
if (!this.command || !this.state) {
|
||||||
|
// Ruhezustand: leere Zeile = letzten Befehl wiederholen.
|
||||||
|
if (text === "") {
|
||||||
|
this.repeatLast();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.start(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Laufender Befehl: leere Zeile = bestätigen/beenden (Enter §2.3).
|
||||||
|
if (text === "") {
|
||||||
|
this.confirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.routeTypedInput(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Routet getippten Text in den aktuellen Schritt (Koordinate/Zahl/Option). */
|
||||||
|
private routeTypedInput(text: string): void {
|
||||||
|
if (!this.command || !this.state) return;
|
||||||
|
const accepts = this.command.accepts(this.state);
|
||||||
|
|
||||||
|
// Optionsbuchstabe(n)? (nur wenn der Schritt Optionen annimmt) — Präfix-Match.
|
||||||
|
if (accepts.includes("option")) {
|
||||||
|
const opts = this.command.options(this.state);
|
||||||
|
const q = text.toLowerCase();
|
||||||
|
const hit = opts.find((o) => o.id.toLowerCase().startsWith(q));
|
||||||
|
if (hit) {
|
||||||
|
this.feed({ kind: "option", id: hit.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseInput(text);
|
||||||
|
|
||||||
|
// Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als
|
||||||
|
// distance/number durchzureichen) und rückt aufs nächste Feld vor. Absolute/
|
||||||
|
// relative/polare Koordinaten umgehen die Felder (committen wie gewohnt).
|
||||||
|
if (parsed.kind === "distance" && this.hasFields()) {
|
||||||
|
this.lockActiveField(parsed.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastPoint = this.state.lastPoint;
|
||||||
|
const cursor = this.lastCursor;
|
||||||
|
|
||||||
|
if (parsed.kind === "text") {
|
||||||
|
// Unparsbar im Schritt: ignorieren (kein gültiger Punkt/Option).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Punktbezogene Eingaben → zu Modellpunkt auflösen, wenn der Schritt Punkte
|
||||||
|
// annimmt; sonst nackte Zahl als number-Input.
|
||||||
|
if (parsed.kind === "distance" && accepts.includes("number") && !accepts.includes("point")) {
|
||||||
|
this.feed({ kind: "number", value: parsed.value });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pt = resolvePoint(parsed, lastPoint, cursor);
|
||||||
|
if (pt && accepts.includes("point")) {
|
||||||
|
this.feed({ kind: "point", point: pt });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.kind === "distance" && accepts.includes("number")) {
|
||||||
|
this.feed({ kind: "number", value: parsed.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maus-Pick aus PlanView (linker Klick) — gesnappter Modellpunkt. */
|
||||||
|
pick(raw: Vec2): void {
|
||||||
|
if (!this.command || !this.state) return;
|
||||||
|
if (!this.command.accepts(this.state).includes("point")) return;
|
||||||
|
const snap = this.host.snap(raw, this.draftPoints());
|
||||||
|
const point = snap?.point ?? raw;
|
||||||
|
this.lastCursor = point;
|
||||||
|
// Tab-Feld-Modus: gelockte Felder überschreiben den Klick (z. B. Länge fix,
|
||||||
|
// Richtung per Maus). Der finale Punkt kommt aus pointFromFields.
|
||||||
|
const fieldPt = this.fieldResultPoint(point);
|
||||||
|
if (fieldPt) {
|
||||||
|
this.feed({ kind: "point", point: fieldPt });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.feed({ kind: "point", point, snap });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hover/Drag aus PlanView — nur Vorschau (kein Commit). */
|
||||||
|
move(raw: Vec2): void {
|
||||||
|
if (!this.command || !this.state) return;
|
||||||
|
const snap = this.host.snap(raw, this.draftPoints());
|
||||||
|
const point = snap?.point ?? raw;
|
||||||
|
this.lastCursor = point;
|
||||||
|
const ctx = this.context();
|
||||||
|
// Tab-Feld-Modus: ungelockte Felder folgen der Maus, gelockte überschreiben.
|
||||||
|
const previewPoint = this.fieldResultPoint(point) ?? point;
|
||||||
|
const [st, res] = this.command.onMove(this.state, previewPoint, snap, ctx);
|
||||||
|
this.state = st;
|
||||||
|
// Snap-Marker mit in den Draft hängen.
|
||||||
|
if (res.draft) this.host.setDraft({ ...res.draft, snap });
|
||||||
|
else if (snap) this.host.setDraft({ preview: [], vertices: [], snap });
|
||||||
|
else this.host.setDraft(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Punkt aus dem Tab-Feld-Modus für einen gegebenen Cursor — oder null, wenn der
|
||||||
|
* Schritt keine Felder hat (dann gilt der rohe Cursor/Pick).
|
||||||
|
*/
|
||||||
|
private fieldResultPoint(cursor: Vec2 | null): Vec2 | null {
|
||||||
|
if (!this.command || !this.state || !this.command.pointFromFields) return null;
|
||||||
|
if (!this.hasFields()) return null;
|
||||||
|
return this.command.pointFromFields(this.state, this.locks, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Klick auf eine Inline-Option (§2.4). */
|
||||||
|
chooseOption(id: string): void {
|
||||||
|
if (!this.command || !this.state) return;
|
||||||
|
this.feed({ kind: "option", id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enter = Space = Rechtsklick: bestätigen/Default/mehrteilig-beenden (§2.3). */
|
||||||
|
confirm(): void {
|
||||||
|
if (!this.command || !this.state) {
|
||||||
|
this.repeatLast();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tab-Feld-Modus (§2.7): existieren Felder, committet Enter den Schritt mit
|
||||||
|
// dem aus (Locks + Cursor) gebildeten Punkt — statt den Befehl abzubrechen.
|
||||||
|
const fieldPt = this.fieldResultPoint(this.lastCursor);
|
||||||
|
if (fieldPt) {
|
||||||
|
this.feed({ kind: "point", point: fieldPt });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = this.context();
|
||||||
|
const [st, res] = this.command.onConfirm(this.state, ctx);
|
||||||
|
this.state = st;
|
||||||
|
this.applyResult(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leer-Enter/Rechtsklick im Ruhezustand: letzten Befehl wiederholen. */
|
||||||
|
repeatLast(): void {
|
||||||
|
if (this.lastCommandName) {
|
||||||
|
const cmd = getCommand(this.lastCommandName);
|
||||||
|
if (cmd) this.startCommand(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── interne Mechanik ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private feed(input: CmdInput): void {
|
||||||
|
if (!this.command || !this.state) return;
|
||||||
|
if (input.kind === "point") this.lastCursor = input.point;
|
||||||
|
const ctx = this.context();
|
||||||
|
const [st, res] = this.command.onInput(this.state, input, ctx);
|
||||||
|
this.state = st;
|
||||||
|
this.applyResult(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyResult(res: CommandResult): void {
|
||||||
|
if (res.commit) this.host.commit(res.commit);
|
||||||
|
if (res.draft) this.host.setDraft(res.draft);
|
||||||
|
else this.host.setDraft(null);
|
||||||
|
if (res.done) {
|
||||||
|
this.command = null;
|
||||||
|
this.state = null;
|
||||||
|
this.resetFields();
|
||||||
|
}
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private context(): CommandContext {
|
||||||
|
return this.host.context(this.state?.lastPoint ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bereits gesetzte Punkte des laufenden Befehls (für Snap-Quellen). */
|
||||||
|
private draftPoints(): Vec2[] {
|
||||||
|
const s = this.state as (CommandState & { a?: Vec2; points?: Vec2[] }) | null;
|
||||||
|
if (!s) return [];
|
||||||
|
if (s.points) return s.points;
|
||||||
|
if (s.a) return [s.a];
|
||||||
|
if (s.lastPoint) return [s.lastPoint];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Koordinaten- & Numerik-Parser (docs/design/rhino-command-system.md §2.7).
|
||||||
|
//
|
||||||
|
// Parst rohen Command-Line-Text TOLERANT (Komma/Space/Semikolon als Trenner) zu
|
||||||
|
// einer Discriminated Union; die Engine löst die punktbezogenen Varianten mit
|
||||||
|
// `lastPoint` + Cursor-Richtung zu einem konkreten Modellpunkt auf.
|
||||||
|
//
|
||||||
|
// Unterstützte Formen:
|
||||||
|
// `5,3` → absolut X,Y
|
||||||
|
// `5,3,2` → absolut X,Y (Z ignoriert, 2D)
|
||||||
|
// `r5,3` → relativ zum letzten Punkt
|
||||||
|
// `5<45` → polar: Distanz 5 unter 45° vom letzten Punkt
|
||||||
|
// `<45` → nur Winkel-Constraint; Distanz folgt Maus/Zahl
|
||||||
|
// `5` → nackte Zahl = Distanz-Lock entlang Cursor-/Constraint-Richtung
|
||||||
|
// sonst → Roh-Text (Befehlsname / Optionsbuchstabe) — von der Engine geroutet
|
||||||
|
//
|
||||||
|
// Reine Funktionen, kein Zustand. Winkel in Grad, gegen die +X-Achse, CCW positiv
|
||||||
|
// (passend zur Y-flip-freien Modellkonvention: +Y ist „oben").
|
||||||
|
|
||||||
|
import type { Vec2 } from "../model/types";
|
||||||
|
|
||||||
|
/** Geparste, noch nicht aufgelöste Eingabe. */
|
||||||
|
export type ParsedInput =
|
||||||
|
| { kind: "absolute"; point: Vec2 } // 5,3
|
||||||
|
| { kind: "relative"; delta: Vec2 } // r5,3
|
||||||
|
| { kind: "polar"; dist: number; angleDeg: number } // 5<45
|
||||||
|
| { kind: "angle"; angleDeg: number } // <45
|
||||||
|
| { kind: "distance"; value: number } // 5 (nackte Zahl)
|
||||||
|
| { kind: "text"; text: string }; // Befehlsname / Option / unparsbar
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
|
/** Tolerant: Zahl aus einem Token (erlaubt führendes +, Dezimalpunkt/-komma). */
|
||||||
|
function parseNumber(raw: string): number | null {
|
||||||
|
const s = raw.trim().replace(",", "."); // Hilfsfall: einzelnes Dezimalkomma
|
||||||
|
if (s === "" || s === "+" || s === "-") return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zerlegt eine Koordinatenliste (X,Y[,Z]) tolerant in Zahlen. Trenner: Komma,
|
||||||
|
* Semikolon oder Whitespace. Liefert null, wenn nicht ≥2 valide Zahlen vorliegen.
|
||||||
|
* (Wir nutzen die echten Trenner, daher kollidiert das NICHT mit dem
|
||||||
|
* Dezimalkomma-Hilfsfall der nackten Zahl.)
|
||||||
|
*/
|
||||||
|
function parseCoordList(raw: string): number[] | null {
|
||||||
|
const parts = raw
|
||||||
|
.trim()
|
||||||
|
.split(/[\s,;]+/)
|
||||||
|
.filter((p) => p.length > 0);
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
const nums: number[] = [];
|
||||||
|
for (const p of parts) {
|
||||||
|
const n = Number(p.trim());
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
nums.push(n);
|
||||||
|
}
|
||||||
|
return nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst rohen Command-Line-Text zu einer {@link ParsedInput}. Befehlsnamen/
|
||||||
|
* Optionsbuchstaben fallen als `{kind:"text"}` durch (die Engine routet sie).
|
||||||
|
*/
|
||||||
|
export function parseInput(raw: string): ParsedInput {
|
||||||
|
const s = raw.trim();
|
||||||
|
if (s === "") return { kind: "text", text: "" };
|
||||||
|
|
||||||
|
// ── polar / Winkel: enthält '<' ───────────────────────────────────────────
|
||||||
|
if (s.includes("<")) {
|
||||||
|
const i = s.indexOf("<");
|
||||||
|
const left = s.slice(0, i).trim();
|
||||||
|
const right = s.slice(i + 1).trim();
|
||||||
|
const angle = parseNumber(right);
|
||||||
|
if (angle == null) return { kind: "text", text: s };
|
||||||
|
if (left === "") return { kind: "angle", angleDeg: angle };
|
||||||
|
const dist = parseNumber(left);
|
||||||
|
if (dist == null) return { kind: "text", text: s };
|
||||||
|
return { kind: "polar", dist, angleDeg: angle };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── relativ: führendes r / @ ──────────────────────────────────────────────
|
||||||
|
if (/^[r@]/i.test(s)) {
|
||||||
|
const rest = s.slice(1);
|
||||||
|
const nums = parseCoordList(rest);
|
||||||
|
if (nums && nums.length >= 2) {
|
||||||
|
return { kind: "relative", delta: { x: nums[0], y: nums[1] } };
|
||||||
|
}
|
||||||
|
// `r5` → relative Distanz entlang Cursor-Richtung.
|
||||||
|
const one = parseNumber(rest);
|
||||||
|
if (one != null) return { kind: "distance", value: one };
|
||||||
|
return { kind: "text", text: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── absolute Koordinate: ≥2 zahlen mit echtem Trenner ─────────────────────
|
||||||
|
const coords = parseCoordList(s);
|
||||||
|
if (coords && coords.length >= 2) {
|
||||||
|
return { kind: "absolute", point: { x: coords[0], y: coords[1] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── nackte Zahl = Distanz-Lock ────────────────────────────────────────────
|
||||||
|
const one = parseNumber(s);
|
||||||
|
if (one != null) return { kind: "distance", value: one };
|
||||||
|
|
||||||
|
// ── sonst: Text (Befehl/Option) ───────────────────────────────────────────
|
||||||
|
return { kind: "text", text: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Punkt aus polarer Form (Distanz/Winkel) relativ zu einem Basispunkt. */
|
||||||
|
export function polarPoint(base: Vec2, dist: number, angleDeg: number): Vec2 {
|
||||||
|
const a = angleDeg * DEG;
|
||||||
|
return { x: base.x + Math.cos(a) * dist, y: base.y + Math.sin(a) * dist };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst eine {@link ParsedInput} zu einem konkreten Modellpunkt auf — falls sie
|
||||||
|
* sich als Punkt auflösen lässt. `lastPoint` ist der letzte gesetzte Punkt im
|
||||||
|
* Befehl (für relativ/polar/Distanz), `cursorDir` die aktuelle (gesnappte)
|
||||||
|
* Cursor-Richtung als Roh-Cursorpunkt (für nackte Zahl / reinen Winkel-Lock).
|
||||||
|
*
|
||||||
|
* Liefert null, wenn die Eingabe keinen Punkt ergibt (z. B. reiner Text oder
|
||||||
|
* Distanz ohne Bezug).
|
||||||
|
*/
|
||||||
|
export function resolvePoint(
|
||||||
|
parsed: ParsedInput,
|
||||||
|
lastPoint: Vec2 | null,
|
||||||
|
cursor: Vec2 | null,
|
||||||
|
): Vec2 | null {
|
||||||
|
switch (parsed.kind) {
|
||||||
|
case "absolute":
|
||||||
|
return parsed.point;
|
||||||
|
case "relative":
|
||||||
|
if (!lastPoint) return parsed.delta; // ohne Bezug = vom Ursprung
|
||||||
|
return { x: lastPoint.x + parsed.delta.x, y: lastPoint.y + parsed.delta.y };
|
||||||
|
case "polar": {
|
||||||
|
const base = lastPoint ?? { x: 0, y: 0 };
|
||||||
|
return polarPoint(base, parsed.dist, parsed.angleDeg);
|
||||||
|
}
|
||||||
|
case "angle": {
|
||||||
|
// Reiner Winkel-Lock: ohne Distanz braucht es den Cursor, um die Länge
|
||||||
|
// entlang der gelockten Richtung zu projizieren.
|
||||||
|
if (!lastPoint || !cursor) return null;
|
||||||
|
const a = parsed.angleDeg * DEG;
|
||||||
|
const dir = { x: Math.cos(a), y: Math.sin(a) };
|
||||||
|
const dx = cursor.x - lastPoint.x;
|
||||||
|
const dy = cursor.y - lastPoint.y;
|
||||||
|
const proj = dx * dir.x + dy * dir.y; // skalare Projektion
|
||||||
|
const len = Math.max(0, proj);
|
||||||
|
return { x: lastPoint.x + dir.x * len, y: lastPoint.y + dir.y * len };
|
||||||
|
}
|
||||||
|
case "distance": {
|
||||||
|
// Distanz-Lock: Richtung per Cursor, Länge per Zahl.
|
||||||
|
if (!lastPoint || !cursor) return null;
|
||||||
|
const dx = cursor.x - lastPoint.x;
|
||||||
|
const dy = cursor.y - lastPoint.y;
|
||||||
|
const l = Math.hypot(dx, dy);
|
||||||
|
if (l < 1e-9) return { x: lastPoint.x + parsed.value, y: lastPoint.y };
|
||||||
|
return {
|
||||||
|
x: lastPoint.x + (dx / l) * parsed.value,
|
||||||
|
y: lastPoint.y + (dy / l) * parsed.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Befehls-Registry (docs/design/rhino-command-system.md §3.2) — `COMMANDS` +
|
||||||
|
// Aliase + Präfix-Autocomplete. Einzelne Befehle leben als eigene Module unter
|
||||||
|
// `cmds/`, sodass weitere Befehle ADDITIV (ohne Änderung an Engine/App)
|
||||||
|
// dazukommen: hier importieren + registrieren, fertig.
|
||||||
|
|
||||||
|
import type { Command } from "./types";
|
||||||
|
import { lineCommand } from "./cmds/line";
|
||||||
|
import { polylineCommand } from "./cmds/polyline";
|
||||||
|
import { rectCommand } from "./cmds/rect";
|
||||||
|
import { circleCommand } from "./cmds/circle";
|
||||||
|
import { moveCommand } from "./cmds/move";
|
||||||
|
import { copyCommand } from "./cmds/copy";
|
||||||
|
import { offsetCommand } from "./cmds/offset";
|
||||||
|
import { importCommand } from "./cmds/import";
|
||||||
|
import { terrainCommand } from "./cmds/terrain";
|
||||||
|
|
||||||
|
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
|
||||||
|
export const COMMANDS: Record<string, Command> = {
|
||||||
|
line: lineCommand,
|
||||||
|
polyline: polylineCommand,
|
||||||
|
rect: rectCommand,
|
||||||
|
circle: circleCommand,
|
||||||
|
move: moveCommand,
|
||||||
|
copy: copyCommand,
|
||||||
|
offset: offsetCommand,
|
||||||
|
import: importCommand,
|
||||||
|
terrain: terrainCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aliase / Kürzel → Befehlsname (§2.2). Werden VOR dem Autocomplete gematcht
|
||||||
|
* (case-insensitiv). Minimal: Einzelbuchstaben. NICHT `r` (kollidiert mit dem
|
||||||
|
* relativ-Koordinaten-Präfix `r5,3`).
|
||||||
|
*/
|
||||||
|
export const ALIASES: Record<string, string> = {
|
||||||
|
l: "line",
|
||||||
|
pl: "polyline",
|
||||||
|
rec: "rect",
|
||||||
|
c: "circle",
|
||||||
|
m: "move",
|
||||||
|
cp: "copy",
|
||||||
|
o: "offset",
|
||||||
|
imp: "import",
|
||||||
|
ter: "terrain",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Liefert den Befehl zu einem exakten Namen (oder undefined). */
|
||||||
|
export function getCommand(name: string): Command | undefined {
|
||||||
|
return COMMANDS[name.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst eine getippte Eingabe zu einem Befehl auf: erst exakter Name, dann Alias,
|
||||||
|
* dann eindeutiges Präfix (case-insensitiv). Mehrdeutige/leere Präfixe → null.
|
||||||
|
*/
|
||||||
|
export function resolveCommand(raw: string): Command | null {
|
||||||
|
const q = raw.trim().toLowerCase();
|
||||||
|
if (q === "") return null;
|
||||||
|
if (COMMANDS[q]) return COMMANDS[q];
|
||||||
|
if (ALIASES[q]) return COMMANDS[ALIASES[q]] ?? null;
|
||||||
|
const matches = autocomplete(q);
|
||||||
|
if (matches.length === 1) return COMMANDS[matches[0]] ?? null;
|
||||||
|
// Exaktes Präfix, das auch ein Alias ist, hat Vorrang, wenn eindeutig.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Präfix-Autocomplete: alle Befehlsnamen, die mit dem (lowercase) Präfix
|
||||||
|
* beginnen, alphabetisch. Aliase werden NICHT mitgelistet (sie sind Kürzel,
|
||||||
|
* keine Vorschläge), aber ein exakter Alias-Treffer wird vorangestellt.
|
||||||
|
*/
|
||||||
|
export function autocomplete(prefix: string): string[] {
|
||||||
|
const q = prefix.trim().toLowerCase();
|
||||||
|
if (q === "") return Object.keys(COMMANDS).sort();
|
||||||
|
const names = Object.keys(COMMANDS)
|
||||||
|
.filter((n) => n.startsWith(q))
|
||||||
|
.sort();
|
||||||
|
return names;
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// Command-Engine — Verallgemeinerung des `Tool`-Interfaces (src/tools/types.ts)
|
||||||
|
// zu einem Rhino-artigen Befehlsmodell (docs/design/rhino-command-system.md §3.1).
|
||||||
|
//
|
||||||
|
// Ein `Command` ist eine kleine State-Machine: jeder Schritt zeigt einen Prompt,
|
||||||
|
// nimmt bestimmte Eingabe-Arten an (Punkt | Zahl | Option | Auswahl) und liefert
|
||||||
|
// aus (state, input) den nächsten Zustand + ein `CommandResult` (Vorschau +
|
||||||
|
// optional commit/done). Damit speisen Maus-Picks UND getippte Koordinaten/
|
||||||
|
// Optionen DENSELBEN Schritt — es gibt nur einen Eingabepfad.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, sichtbarer Text via t() (i18n-Keys, Namespace `cmd.*`).
|
||||||
|
|
||||||
|
import type { Project, Vec2 } from "../model/types";
|
||||||
|
import type { TranslationKey } from "../i18n";
|
||||||
|
import type { DrawingLevel } from "../model/types";
|
||||||
|
import type { SnapResult, ToolDraft } from "../tools/types";
|
||||||
|
|
||||||
|
export type { DraftShape, SnapResult, ToolDraft } from "../tools/types";
|
||||||
|
|
||||||
|
// ── Eingabe-Arten ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Arten von Eingaben, die ein Schritt annehmen kann. */
|
||||||
|
export type AcceptKind = "point" | "number" | "option" | "selection";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der
|
||||||
|
* Maus. Discriminated Union (§2.7). Die Engine löst `point`-bezogene Varianten
|
||||||
|
* mit `lastPoint` + Cursor-Richtung zu einem konkreten Modellpunkt auf, bevor
|
||||||
|
* sie den Schritt aufruft.
|
||||||
|
*/
|
||||||
|
export type CmdInput =
|
||||||
|
| { kind: "point"; point: Vec2; snap?: SnapResult | null }
|
||||||
|
| { kind: "number"; value: number }
|
||||||
|
| { kind: "option"; id: string }
|
||||||
|
| { kind: "text"; text: string }
|
||||||
|
| { kind: "selection"; wallIds: string[]; drawingIds: string[] };
|
||||||
|
|
||||||
|
// ── Optionen (klickbare Inline-Klammern, §2.4) ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Inline-Option eines Schritts. `value` (gesetzt) → wird als „Name=Value"
|
||||||
|
* angezeigt und ist togglebar/abfragbar; ohne `value` ist es eine Action-Option
|
||||||
|
* (verzweigt sofort). `id` ist der englische Schlüssel (auch zum Tippen, Präfix).
|
||||||
|
*/
|
||||||
|
export interface CmdOption {
|
||||||
|
id: string;
|
||||||
|
/** Sichtbares Label (i18n). */
|
||||||
|
labelKey: TranslationKey;
|
||||||
|
/** Aktueller Wert (Toggle/Value-Option); fehlt → Action-Option. */
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kontext + Ergebnis ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die aktuelle Auswahl (Rhino: erst selektieren, dann Befehl). Editierbefehle
|
||||||
|
* (Move/Copy/Offset) lesen sie hieraus, statt sie selbst zu picken. `wallIds`
|
||||||
|
* sind die gewählten Wände, `drawingId` ist das gewählte 2D-Element (sich
|
||||||
|
* gegenseitig ausschließend, wie in selectionSlice).
|
||||||
|
*/
|
||||||
|
export interface CommandSelection {
|
||||||
|
wallIds: string[];
|
||||||
|
drawingId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */
|
||||||
|
export interface CommandContext {
|
||||||
|
project: Project;
|
||||||
|
/** Aktive Zeichnungsebene (Ziel neuer Elemente). */
|
||||||
|
level: DrawingLevel;
|
||||||
|
/** Default-Kategorie-Code für neue Elemente. */
|
||||||
|
defaultCategoryCode: string;
|
||||||
|
/** Aktiver Linienstil-Code für 2D-Primitive. */
|
||||||
|
activeLineStyleId: string;
|
||||||
|
/**
|
||||||
|
* Letzter gesetzter Punkt im laufenden Befehl (für `r`/polar-Auflösung); null
|
||||||
|
* am ersten Punkt.
|
||||||
|
*/
|
||||||
|
lastPoint: Vec2 | null;
|
||||||
|
/**
|
||||||
|
* Vorab gesetzte Auswahl (Wände + ein 2D-Element). Editierbefehle operieren
|
||||||
|
* darauf (Rhino: erst selektieren, dann Befehl). Beim Befehlsstart aus dem
|
||||||
|
* Store gefüllt.
|
||||||
|
*/
|
||||||
|
selection: CommandSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was ein Befehls-Schritt nach außen meldet (analog ToolResult + commit/done). */
|
||||||
|
export interface CommandResult {
|
||||||
|
/** Neuer Vorschau-Zustand; null = nichts zu zeigen. */
|
||||||
|
draft: ToolDraft | null;
|
||||||
|
/** Bei Abschluss: immutable Mutation, die die Engine über setProject anwendet. */
|
||||||
|
commit?: (p: Project) => Project;
|
||||||
|
/** true → Befehl ist fertig und kehrt in den Ruhezustand zurück. */
|
||||||
|
done?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Befehls-Zustand ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interner Zustand eines laufenden Befehls. Basis-Form (`phase` + `lastPoint`);
|
||||||
|
* jeder Befehl verfeinert sie zu seiner eigenen Discriminated Union und castet
|
||||||
|
* beim Eintritt. `lastPoint` lebt im Zustand, damit der Parser `r`/polar relativ
|
||||||
|
* dazu auflösen kann.
|
||||||
|
*/
|
||||||
|
export interface CommandState {
|
||||||
|
phase: string;
|
||||||
|
/** Letzter gesetzter Punkt (für relative/polare Eingabe), null am Anfang. */
|
||||||
|
lastPoint: Vec2 | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab-Feld-Zyklus (Präzisionseingabe §2.7, erweitert) ──────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein geordnetes Zahlenfeld eines Schritts (z. B. Länge, Winkel, Breite, Höhe,
|
||||||
|
* Radius). Mit Tab zyklt der Nutzer durch die Felder; eine getippte Zahl LOCKT
|
||||||
|
* das aktive Feld, ungelockte Felder folgen weiter der Maus.
|
||||||
|
*/
|
||||||
|
export interface CommandField {
|
||||||
|
/** Englischer Schlüssel (Identität des Feldes, z. B. „length"). */
|
||||||
|
id: string;
|
||||||
|
/** Sichtbares Label (i18n, Namespace `cmd.field.*`). */
|
||||||
|
labelKey: TranslationKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Befehls-Schnittstelle ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Die Command-Schnittstelle (reine Funktionen über einen internen State). */
|
||||||
|
export interface Command {
|
||||||
|
/** Befehlsname (englisch, in der Command-Line getippt), z. B. „line". */
|
||||||
|
name: string;
|
||||||
|
/** UI-Label-Key (i18n). */
|
||||||
|
labelKey: TranslationKey;
|
||||||
|
/** Prompt-Text-Key des aktuellen Schritts. */
|
||||||
|
prompt(state: CommandState): TranslationKey;
|
||||||
|
/** Eingabe-Arten, die der aktuelle Schritt annimmt. */
|
||||||
|
accepts(state: CommandState): AcceptKind[];
|
||||||
|
/** Inline-Optionen des aktuellen Schritts (klickbar/tippbar). */
|
||||||
|
options(state: CommandState): CmdOption[];
|
||||||
|
/** Nur auf Geschossen aktiv? (analog floorOnly bei Tools.) */
|
||||||
|
floorOnly?: boolean;
|
||||||
|
/** Initialer Schritt-Zustand. */
|
||||||
|
init(): CommandState;
|
||||||
|
/** Eine Eingabe (Punkt/Zahl/Option/Auswahl) verarbeiten. */
|
||||||
|
onInput(
|
||||||
|
state: CommandState,
|
||||||
|
input: CmdInput,
|
||||||
|
ctx: CommandContext,
|
||||||
|
): [CommandState, CommandResult];
|
||||||
|
/** Hover/Bewegung (roher bzw. gesnappter Cursor): nur Vorschau. */
|
||||||
|
onMove(
|
||||||
|
state: CommandState,
|
||||||
|
point: Vec2,
|
||||||
|
snap: SnapResult | null,
|
||||||
|
ctx: CommandContext,
|
||||||
|
): [CommandState, CommandResult];
|
||||||
|
/** Enter/Space/Rechtsklick: Schritt bestätigen / mehrteilig beenden. */
|
||||||
|
onConfirm(state: CommandState, ctx: CommandContext): [CommandState, CommandResult];
|
||||||
|
/** Esc: Entwurf verwerfen, Befehl beenden. */
|
||||||
|
onCancel(state: CommandState): [CommandState, CommandResult];
|
||||||
|
|
||||||
|
// ── Optionaler Tab-Feld-Zyklus (additiv; Befehle ohne diese Methoden
|
||||||
|
// verhalten sich exakt wie bisher) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geordnete Zahlenfelder des AKTUELLEN Schritts (leer/undefined = kein
|
||||||
|
* Feld-Modus). Beispiel Linie-Endschritt: [length, angle].
|
||||||
|
*/
|
||||||
|
fields?(state: CommandState): CommandField[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet den resultierenden Modellpunkt aus den gelockten Feldern + dem
|
||||||
|
* Cursor. Ungelockte Felder folgen dem Cursor (Maus); gelockte Felder
|
||||||
|
* überschreiben. Liefert null, wenn (noch) kein Punkt bestimmbar ist.
|
||||||
|
*/
|
||||||
|
pointFromFields?(
|
||||||
|
state: CommandState,
|
||||||
|
locks: Record<string, number>,
|
||||||
|
cursor: Vec2 | null,
|
||||||
|
): Vec2 | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience-Re-Export für Befehls-Module. */
|
||||||
|
export type { Project, Vec2 } from "../model/types";
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
// Editier-Operationen Split / Join / Segment-Löschen für 2D-Zeichenelemente
|
||||||
|
// (Drawing2D). Wandeln Drawing2D ↔ {pts, closed}, rufen den reinen Geometrie-
|
||||||
|
// Kernel (`../geometry/kernel2d`) und liefern neue Drawing2D-Listen. Stil-,
|
||||||
|
// Kategorie- und Level-Attribute der Quelle werden geerbt.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
|
||||||
|
|
||||||
|
import type { Drawing2D, Drawing2DGeom, Vec2 } from "../model/types";
|
||||||
|
import {
|
||||||
|
joinChains,
|
||||||
|
removeSegment,
|
||||||
|
splitAtIntersections,
|
||||||
|
splitClosedByChord,
|
||||||
|
splitPolylineAtParam,
|
||||||
|
vecEqual,
|
||||||
|
} from "../geometry/kernel2d";
|
||||||
|
|
||||||
|
/** Eine Polylinien-Darstellung eines Drawing2D (für die Kernel-Funktionen). */
|
||||||
|
export interface PolyForm {
|
||||||
|
pts: Vec2[];
|
||||||
|
closed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt die Geometrie eines Drawing2D in eine Polylinien-Form `{pts, closed}`.
|
||||||
|
* Nur line/polyline/rect werden unterstützt (circle/arc/text → `null`).
|
||||||
|
*/
|
||||||
|
export function toPolyForm(geom: Drawing2DGeom): PolyForm | null {
|
||||||
|
switch (geom.shape) {
|
||||||
|
case "line":
|
||||||
|
return { pts: [geom.a, geom.b], closed: false };
|
||||||
|
case "polyline":
|
||||||
|
return { pts: geom.pts.slice(), closed: geom.closed };
|
||||||
|
case "rect":
|
||||||
|
return {
|
||||||
|
pts: [
|
||||||
|
geom.min,
|
||||||
|
{ x: geom.max.x, y: geom.min.y },
|
||||||
|
geom.max,
|
||||||
|
{ x: geom.min.x, y: geom.max.y },
|
||||||
|
],
|
||||||
|
closed: true,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut aus einer Polylinien-Form die passende Drawing2D-Geometrie. Zwei Punkte +
|
||||||
|
* offen → `line`; sonst `polyline` (geschlossen oder offen). Rechtecke werden
|
||||||
|
* NICHT rekonstruiert (nach dem Schneiden sind die Teile beliebige Polygone).
|
||||||
|
*/
|
||||||
|
export function fromPolyForm(form: PolyForm): Drawing2DGeom {
|
||||||
|
const pts = form.pts;
|
||||||
|
if (pts.length === 2 && !form.closed) {
|
||||||
|
return { shape: "line", a: pts[0], b: pts[1] };
|
||||||
|
}
|
||||||
|
return { shape: "polyline", pts, closed: form.closed };
|
||||||
|
}
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
/** Eindeutige Drawing2D-ID (zeitbasiert + Zähler, damit ein Split nicht kollidiert). */
|
||||||
|
function newId(prefix: string): string {
|
||||||
|
counter += 1;
|
||||||
|
return `${prefix}-${Date.now()}-${counter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übernimmt Stil-/Kategorie-/Level-Attribute der Quelle auf ein neues Drawing2D. */
|
||||||
|
function inherit(src: Drawing2D, geom: Drawing2DGeom): Drawing2D {
|
||||||
|
const out: Drawing2D = {
|
||||||
|
id: newId("d2d"),
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId: src.levelId,
|
||||||
|
categoryCode: src.categoryCode,
|
||||||
|
geom,
|
||||||
|
};
|
||||||
|
if (src.lineStyleId !== undefined) out.lineStyleId = src.lineStyleId;
|
||||||
|
if (src.color !== undefined) out.color = src.color;
|
||||||
|
if (src.weightMm !== undefined) out.weightMm = src.weightMm;
|
||||||
|
// Füllung/Schraffur nur an geschlossene Ergebnisse weitergeben.
|
||||||
|
const closed =
|
||||||
|
geom.shape === "polyline" ? geom.closed : geom.shape === "rect";
|
||||||
|
if (closed) {
|
||||||
|
if (src.hatchId !== undefined) out.hatchId = src.hatchId;
|
||||||
|
if (src.fillColor !== undefined) out.fillColor = src.fillColor;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split eines Drawing2D gegen alle ANDEREN Elemente (`others`, als PolyForm).
|
||||||
|
* • Trifft das Ziel andere Elemente, wird an den Schnittpunkten geteilt
|
||||||
|
* (geschlossen + 2 Schnitte → zwei GESCHLOSSENE Polygone via Sehne).
|
||||||
|
* • Ohne Schnitt: Fallback — an der Kanten-MITTE der „längsten" Kante teilen
|
||||||
|
* (offen → zwei Stücke; geschlossen → an einer Kantenmitte + der gegenüber-
|
||||||
|
* liegenden Kantenmitte zu zwei geschlossenen Hälften).
|
||||||
|
* Liefert ≥2 neue Drawing2D, oder `[src]` (unverändert), wenn keine Teilung
|
||||||
|
* möglich war (z. B. nicht-polyline Form). Stil/Kategorie/Level werden geerbt.
|
||||||
|
*/
|
||||||
|
export function splitDrawing(
|
||||||
|
src: Drawing2D,
|
||||||
|
others: PolyForm[],
|
||||||
|
): Drawing2D[] {
|
||||||
|
const form = toPolyForm(src.geom);
|
||||||
|
if (!form) return [src];
|
||||||
|
|
||||||
|
const otherPts = others.map((o) =>
|
||||||
|
// Geschlossene als Ring (erster==letzter) übergeben, damit der Kernel die
|
||||||
|
// Schlusskante als Kante erkennt.
|
||||||
|
o.closed && o.pts.length > 2 ? [...o.pts, o.pts[0]] : o.pts,
|
||||||
|
);
|
||||||
|
|
||||||
|
let pieces = splitAtIntersections(form.pts, form.closed, otherPts);
|
||||||
|
|
||||||
|
// Kein Schnitt-Ergebnis (eine Liste, identisch zur Quelle) → Fallback.
|
||||||
|
const noCut = pieces.length < 2;
|
||||||
|
if (noCut) {
|
||||||
|
pieces = fallbackSplit(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pieces.length < 2) return [src];
|
||||||
|
|
||||||
|
// Jedes Stück: geschlossen bleibt geschlossen NUR, wenn das Original
|
||||||
|
// geschlossen war UND der Schnitt zwei geschlossene Hälften erzeugte
|
||||||
|
// (Sehnen-Split / Fallback-geschlossen). Bei offenem Aufschneiden sind die
|
||||||
|
// Stücke offen.
|
||||||
|
const piecesClosed = form.closed && isClosedSplit(form, pieces);
|
||||||
|
return pieces.map((pts) =>
|
||||||
|
inherit(src, fromPolyForm({ pts, closed: piecesClosed })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristik: erzeugte der Schnitt zwei GESCHLOSSENE Hälften (Sehnen-Split)?
|
||||||
|
* Wahr, wenn das Original geschlossen war und genau zwei Stücke entstanden
|
||||||
|
* (Chord-Split) — diese sollen wieder geschlossen sein. Bei mehr Stücken
|
||||||
|
* (geschlossen an >2 Schnitten aufgetrennt) bleiben sie offen.
|
||||||
|
*/
|
||||||
|
function isClosedSplit(form: PolyForm, pieces: Vec2[][]): boolean {
|
||||||
|
return form.closed && pieces.length === 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback ohne Schnitt mit anderen Elementen: an der Mitte teilen.
|
||||||
|
* • Offen: längste Kante an t=0.5 in zwei offene Stücke.
|
||||||
|
* • Geschlossen: an den Mitten zweier „gegenüberliegender" Kanten in zwei
|
||||||
|
* geschlossene Polygone (Sehne zwischen den beiden Kantenmitten).
|
||||||
|
*/
|
||||||
|
function fallbackSplit(form: PolyForm): Vec2[][] {
|
||||||
|
const n = form.pts.length;
|
||||||
|
if (n < 2) return [form.pts];
|
||||||
|
if (!form.closed) {
|
||||||
|
// Längste Kante suchen.
|
||||||
|
let best = 0;
|
||||||
|
let bestLen = -1;
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
const d = dist(form.pts[i], form.pts[i + 1]);
|
||||||
|
if (d > bestLen) {
|
||||||
|
bestLen = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return splitPolylineAtParam(form.pts, false, best, 0.5);
|
||||||
|
}
|
||||||
|
// Geschlossen: Kante 0 und die „gegenüberliegende" Kante.
|
||||||
|
const i = 0;
|
||||||
|
const j = Math.max(1, Math.floor(n / 2)) % n;
|
||||||
|
if (i === j) return [form.pts];
|
||||||
|
// Direkt den Kernel-Chord-Split nutzen.
|
||||||
|
const res = splitClosedHalf(form.pts, i, j);
|
||||||
|
return res ?? [form.pts];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Geschlossenes Polygon an den Mitten der Kanten i und j halbieren. */
|
||||||
|
function splitClosedHalf(pts: Vec2[], i: number, j: number): Vec2[][] | null {
|
||||||
|
// Reuse: splitClosedByChord ist im Kernel; hier über splitAtIntersections-Form
|
||||||
|
// nachzubauen wäre umständlich — direkt importieren.
|
||||||
|
const res = chordSplit(pts, i, 0.5, j, 0.5);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chordSplit(
|
||||||
|
pts: Vec2[],
|
||||||
|
i: number,
|
||||||
|
ti: number,
|
||||||
|
j: number,
|
||||||
|
tj: number,
|
||||||
|
): Vec2[][] | null {
|
||||||
|
const r = splitClosedByChord(pts, i, ti, j, tj);
|
||||||
|
return r ? [r[0], r[1]] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dist = (a: Vec2, b: Vec2): number =>
|
||||||
|
Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join: verbindet mehrere Drawing2D an koinzidenten Endpunkten zu Polylinien
|
||||||
|
* (geschlossen, wenn sich eine Kette schließt). Nicht-polyline Elemente
|
||||||
|
* (circle/arc/text) werden unverändert durchgereicht. Liefert die NEUE Menge
|
||||||
|
* der Drawing2D, die die Eingabe-Elemente ersetzt. Attribute werden vom ersten
|
||||||
|
* verbundenen Quell-Element geerbt.
|
||||||
|
*/
|
||||||
|
export function joinDrawings(srcs: Drawing2D[]): Drawing2D[] {
|
||||||
|
const joinable: { form: PolyForm; src: Drawing2D }[] = [];
|
||||||
|
const passthrough: Drawing2D[] = [];
|
||||||
|
for (const d of srcs) {
|
||||||
|
const form = toPolyForm(d.geom);
|
||||||
|
if (form) joinable.push({ form, src: d });
|
||||||
|
else passthrough.push(d);
|
||||||
|
}
|
||||||
|
if (joinable.length === 0) return srcs;
|
||||||
|
|
||||||
|
const chains = joinChains(joinable.map((j) => j.form));
|
||||||
|
const template = joinable[0].src;
|
||||||
|
const joined = chains.map((c) =>
|
||||||
|
inherit(template, fromPolyForm({ pts: c.pts, closed: c.closed })),
|
||||||
|
);
|
||||||
|
return [...joined, ...passthrough];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segment-Löschen (Schere): entfernt das Segment `edgeIndex` aus einem
|
||||||
|
* Drawing2D. Geschlossene Form → offene Polylinie. Liefert das geänderte
|
||||||
|
* Drawing2D, oder `null`, wenn weniger als 2 Punkte übrig blieben (Element
|
||||||
|
* komplett löschen) bzw. die Form nicht unterstützt wird.
|
||||||
|
*/
|
||||||
|
export function cutSegment(src: Drawing2D, edgeIndex: number): Drawing2D | null {
|
||||||
|
const form = toPolyForm(src.geom);
|
||||||
|
if (!form) return src; // nicht unterstützte Form unverändert lassen
|
||||||
|
const res = removeSegment(form.pts, form.closed, edgeIndex);
|
||||||
|
if (res.pts.length < 2) return null;
|
||||||
|
return inherit(src, fromPolyForm(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Index der Kante eines Drawing2D, die dem Modellpunkt `p` am
|
||||||
|
* nächsten liegt (für Alt+Klick = Segment-Löschen). `null`, wenn die Form keine
|
||||||
|
* Kanten hat (circle/arc/text) oder zu wenige Punkte. Reuse: Polyline-Form +
|
||||||
|
* Punkt-Strecke-Distanz.
|
||||||
|
*/
|
||||||
|
export function nearestEdgeIndex(geom: Drawing2DGeom, p: Vec2): number | null {
|
||||||
|
const form = toPolyForm(geom);
|
||||||
|
if (!form || form.pts.length < 2) return null;
|
||||||
|
const edges: [number, Vec2, Vec2][] = [];
|
||||||
|
for (let i = 0; i < form.pts.length - 1; i++) {
|
||||||
|
edges.push([i, form.pts[i], form.pts[i + 1]]);
|
||||||
|
}
|
||||||
|
if (form.closed && form.pts.length > 2) {
|
||||||
|
edges.push([form.pts.length - 1, form.pts[form.pts.length - 1], form.pts[0]]);
|
||||||
|
}
|
||||||
|
let best: number | null = null;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (const [i, a, b] of edges) {
|
||||||
|
const d = segDist(p, a, b);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abstand Punkt → Strecke (lokal, klein gehalten). */
|
||||||
|
function segDist(p: Vec2, a: Vec2, b: Vec2): number {
|
||||||
|
const abx = b.x - a.x;
|
||||||
|
const aby = b.y - a.y;
|
||||||
|
const l2 = abx * abx + aby * aby;
|
||||||
|
let t = l2 > 0 ? ((p.x - a.x) * abx + (p.y - a.y) * aby) / l2 : 0;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
const cx = a.x + abx * t;
|
||||||
|
const cy = a.y + aby * t;
|
||||||
|
return Math.hypot(p.x - cx, p.y - cy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `vecEqual` re-export für etwaige Aufrufer (Auswahl-Vergleich).
|
||||||
|
export { vecEqual };
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
// 2D-Geometrie-Kernel — die „echte Arbeit" für Offset / Trim / Extend / Fillet /
|
||||||
|
// Split (Rhino-artige Editierbefehle, Tier 1). Reine Funktionen über `Vec2`,
|
||||||
|
// kein externer Kernel. Baut auf den Helfern in `../model/geometry` auf.
|
||||||
|
//
|
||||||
|
// Konvention: Polylinien sind `Vec2[]`; `closed` heißt, die letzte Kante geht
|
||||||
|
// von pts[n-1] zu pts[0] (der Schlusspunkt wird NICHT dupliziert). Offsets sind
|
||||||
|
// vorzeichenbehaftet: positiv = nach LINKS bezogen auf die Laufrichtung
|
||||||
|
// (`leftNormal`-Konvention des Projekts), negativ = nach rechts.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import type { Vec2 } from "../model/types";
|
||||||
|
import {
|
||||||
|
add,
|
||||||
|
cross,
|
||||||
|
leftNormal,
|
||||||
|
len,
|
||||||
|
lineIntersect,
|
||||||
|
normalize,
|
||||||
|
scale,
|
||||||
|
sub,
|
||||||
|
} from "../model/geometry";
|
||||||
|
|
||||||
|
/** Numerische Toleranz (Meter) für Schnitt-/Gleichheitstests. */
|
||||||
|
export const EPS = 1e-7;
|
||||||
|
|
||||||
|
const dot = (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y;
|
||||||
|
const dist = (a: Vec2, b: Vec2): number => len(sub(a, b));
|
||||||
|
|
||||||
|
/** Punkt-Gleichheit innerhalb Toleranz. */
|
||||||
|
export function vecEqual(a: Vec2, b: Vec2, eps = EPS): boolean {
|
||||||
|
return Math.abs(a.x - b.x) <= eps && Math.abs(a.y - b.y) <= eps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Punkt/Strecke ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Projektionsparameter t von p auf die Gerade a→b (nicht geklemmt). */
|
||||||
|
export function projectParam(p: Vec2, a: Vec2, b: Vec2): number {
|
||||||
|
const ab = sub(b, a);
|
||||||
|
const l2 = dot(ab, ab);
|
||||||
|
if (l2 < EPS) return 0;
|
||||||
|
return dot(sub(p, a), ab) / l2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nächster Punkt auf der STRECKE a→b zu p (t auf [0,1] geklemmt). */
|
||||||
|
export function closestPointOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 {
|
||||||
|
const t = Math.max(0, Math.min(1, projectParam(p, a, b)));
|
||||||
|
return add(a, scale(sub(b, a), t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abstand von p zur Strecke a→b. */
|
||||||
|
export function pointSegmentDistance(p: Vec2, a: Vec2, b: Vec2): number {
|
||||||
|
return dist(p, closestPointOnSegment(p, a, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schnitt ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Ergebnis eines Strecken-/Linienschnitts. */
|
||||||
|
export interface Hit {
|
||||||
|
point: Vec2;
|
||||||
|
/** Parameter auf der ersten Strecke (0 = a1, 1 = a2). */
|
||||||
|
t: number;
|
||||||
|
/** Parameter auf der zweiten Strecke (0 = b1, 1 = b2). */
|
||||||
|
s: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schnitt zweier STRECKEN a1→a2 und b1→b2. Liefert den Schnittpunkt nur, wenn er
|
||||||
|
* auf BEIDEN Strecken liegt (t,s ∈ [-eps, 1+eps]); sonst null. Parallel → null.
|
||||||
|
*/
|
||||||
|
export function segmentIntersect(
|
||||||
|
a1: Vec2,
|
||||||
|
a2: Vec2,
|
||||||
|
b1: Vec2,
|
||||||
|
b2: Vec2,
|
||||||
|
eps = EPS,
|
||||||
|
): Hit | null {
|
||||||
|
const da = sub(a2, a1);
|
||||||
|
const db = sub(b2, b1);
|
||||||
|
const denom = cross(da, db);
|
||||||
|
if (Math.abs(denom) < EPS) return null; // parallel/kollinear
|
||||||
|
const t = cross(sub(b1, a1), db) / denom;
|
||||||
|
const s = cross(sub(b1, a1), da) / denom;
|
||||||
|
if (t < -eps || t > 1 + eps || s < -eps || s > 1 + eps) return null;
|
||||||
|
return { point: add(a1, scale(da, t)), t, s };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schnitt der unendlichen GERADE durch a1→a2 mit der STRECKE b1→b2 (für Extend/
|
||||||
|
* Trim gegen einen Schneider): Schnittpunkt muss auf der Strecke b liegen
|
||||||
|
* (s ∈ [0,1]), t (auf a) ist frei. Null bei parallel / kein Treffer auf b.
|
||||||
|
*/
|
||||||
|
export function lineSegmentIntersect(
|
||||||
|
a1: Vec2,
|
||||||
|
a2: Vec2,
|
||||||
|
b1: Vec2,
|
||||||
|
b2: Vec2,
|
||||||
|
eps = EPS,
|
||||||
|
): Hit | null {
|
||||||
|
const da = sub(a2, a1);
|
||||||
|
const db = sub(b2, b1);
|
||||||
|
const denom = cross(da, db);
|
||||||
|
if (Math.abs(denom) < EPS) return null;
|
||||||
|
const t = cross(sub(b1, a1), db) / denom;
|
||||||
|
const s = cross(sub(b1, a1), da) / denom;
|
||||||
|
if (s < -eps || s > 1 + eps) return null;
|
||||||
|
return { point: add(a1, scale(da, t)), t, s };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kanten einer Polylinie als [from,to]-Paare (Schlusskante bei `closed`). */
|
||||||
|
export function polylineEdges(pts: Vec2[], closed: boolean): [Vec2, Vec2][] {
|
||||||
|
const out: [Vec2, Vec2][] = [];
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) out.push([pts[i], pts[i + 1]]);
|
||||||
|
if (closed && pts.length > 2) out.push([pts[pts.length - 1], pts[0]]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Schnittpunkte einer STRECKE a1→a2 mit den Kanten einer Polylinie, nach
|
||||||
|
* Parameter t auf der Strecke sortiert (für Trim/Split). Endpunkt-Duplikate
|
||||||
|
* (gleicher Punkt aufeinanderfolgender Kanten) werden zusammengefasst.
|
||||||
|
*/
|
||||||
|
export function segmentPolylineHits(
|
||||||
|
a1: Vec2,
|
||||||
|
a2: Vec2,
|
||||||
|
pts: Vec2[],
|
||||||
|
closed: boolean,
|
||||||
|
): Hit[] {
|
||||||
|
const hits: Hit[] = [];
|
||||||
|
for (const [b1, b2] of polylineEdges(pts, closed)) {
|
||||||
|
const h = segmentIntersect(a1, a2, b1, b2);
|
||||||
|
if (h) hits.push(h);
|
||||||
|
}
|
||||||
|
hits.sort((p, q) => p.t - q.t);
|
||||||
|
const dedup: Hit[] = [];
|
||||||
|
for (const h of hits) {
|
||||||
|
if (dedup.length === 0 || Math.abs(dedup[dedup.length - 1].t - h.t) > 1e-6) {
|
||||||
|
dedup.push(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedup;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Offset (DAS Architektur-Primitiv) ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** Offset einer einzelnen Strecke um `d` (links positiv). */
|
||||||
|
export function offsetSegment(a: Vec2, b: Vec2, d: number): [Vec2, Vec2] {
|
||||||
|
const n = leftNormal(normalize(sub(b, a)));
|
||||||
|
const off = scale(n, d);
|
||||||
|
return [add(a, off), add(b, off)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offset einer Polylinie um `d` (links positiv) mit GEHRUNG (miter): jede Kante
|
||||||
|
* wird parallel verschoben, benachbarte verschobene Kanten werden geschnitten.
|
||||||
|
* Bei (nahezu) geraden/parallelen Nachbarn wird der verschobene Endpunkt direkt
|
||||||
|
* genommen. Funktioniert für offene und geschlossene Polylinien; degenerierte
|
||||||
|
* (zu kurze) Kanten werden übersprungen. Selbstüberschneidungen bei starkem
|
||||||
|
* Offset werden NICHT bereinigt (genügt für den Architektur-Workflow; ein
|
||||||
|
* vollständiger Polygon-Offset mit Selbstschnitt-Heilung wäre Tier-2-Arbeit).
|
||||||
|
*/
|
||||||
|
export function offsetPolyline(pts: Vec2[], d: number, closed: boolean): Vec2[] {
|
||||||
|
// Auf signifikante Kanten reduzieren (Duplikate verwerfen).
|
||||||
|
const clean: Vec2[] = [];
|
||||||
|
for (const p of pts) {
|
||||||
|
if (clean.length === 0 || dist(clean[clean.length - 1], p) > EPS) clean.push(p);
|
||||||
|
}
|
||||||
|
if (closed && clean.length > 1 && dist(clean[0], clean[clean.length - 1]) <= EPS) {
|
||||||
|
clean.pop();
|
||||||
|
}
|
||||||
|
const n = clean.length;
|
||||||
|
if (n < 2) return [...pts];
|
||||||
|
|
||||||
|
const edges = polylineEdges(clean, closed).map(([a, b]) => offsetSegment(a, b, d));
|
||||||
|
if (edges.length === 0) return [...pts];
|
||||||
|
|
||||||
|
const result: Vec2[] = [];
|
||||||
|
|
||||||
|
// Hilfsfunktion: Schnitt zweier (verschobener) Kanten als unendliche Geraden.
|
||||||
|
const join = (e1: [Vec2, Vec2], e2: [Vec2, Vec2], fallback: Vec2): Vec2 => {
|
||||||
|
const d1 = sub(e1[1], e1[0]);
|
||||||
|
const d2 = sub(e2[1], e2[0]);
|
||||||
|
const p = lineIntersect(e1[0], d1, e2[0], d2);
|
||||||
|
return p ?? fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!closed) {
|
||||||
|
// Offener Zug: erster Punkt = Start der ersten Kante; innere Punkte =
|
||||||
|
// Schnitt aufeinanderfolgender Kanten; letzter Punkt = Ende der letzten Kante.
|
||||||
|
result.push(edges[0][0]);
|
||||||
|
for (let i = 0; i < edges.length - 1; i++) {
|
||||||
|
result.push(join(edges[i], edges[i + 1], edges[i][1]));
|
||||||
|
}
|
||||||
|
result.push(edges[edges.length - 1][1]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geschlossen: jeder Ausgabepunkt = Schnitt von Kante i mit Kante i+1 (zyklisch).
|
||||||
|
for (let i = 0; i < edges.length; i++) {
|
||||||
|
const prev = edges[(i - 1 + edges.length) % edges.length];
|
||||||
|
const curr = edges[i];
|
||||||
|
result.push(join(prev, curr, curr[0]));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trim / Split ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schneidet eine STRECKE seg=[a1,a2] an allen Schnittpunkten mit den Schneider-
|
||||||
|
* Kurven (Polylinien) und liefert die resultierenden Teilstrecken. `cutters`
|
||||||
|
* sind Polylinien (jede mit `closed`-Flag). Reine Geometrie; welche Teile
|
||||||
|
* behalten/verworfen werden, entscheidet der Aufrufer (z. B. Trim verwirft das
|
||||||
|
* Stück, das den Pick-Punkt enthält — siehe {@link trimSegment}).
|
||||||
|
*/
|
||||||
|
export function splitSegmentByCutters(
|
||||||
|
a1: Vec2,
|
||||||
|
a2: Vec2,
|
||||||
|
cutters: { pts: Vec2[]; closed: boolean }[],
|
||||||
|
): [Vec2, Vec2][] {
|
||||||
|
const ts: number[] = [0, 1];
|
||||||
|
for (const c of cutters) {
|
||||||
|
for (const h of segmentPolylineHits(a1, a2, c.pts, c.closed)) {
|
||||||
|
if (h.t > EPS && h.t < 1 - EPS) ts.push(h.t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts.sort((p, q) => p - q);
|
||||||
|
const da = sub(a2, a1);
|
||||||
|
const pieces: [Vec2, Vec2][] = [];
|
||||||
|
for (let i = 0; i < ts.length - 1; i++) {
|
||||||
|
if (ts[i + 1] - ts[i] < 1e-6) continue;
|
||||||
|
pieces.push([add(a1, scale(da, ts[i])), add(a1, scale(da, ts[i + 1]))]);
|
||||||
|
}
|
||||||
|
return pieces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim: schneidet seg an den Cuttern und VERWIRFT das Teilstück, das `pick`
|
||||||
|
* am nächsten liegt (das angeklickte „wegzuschneidende" Stück). Liefert die
|
||||||
|
* verbleibenden Teilstücke (0..n).
|
||||||
|
*/
|
||||||
|
export function trimSegment(
|
||||||
|
a1: Vec2,
|
||||||
|
a2: Vec2,
|
||||||
|
cutters: { pts: Vec2[]; closed: boolean }[],
|
||||||
|
pick: Vec2,
|
||||||
|
): [Vec2, Vec2][] {
|
||||||
|
const pieces = splitSegmentByCutters(a1, a2, cutters);
|
||||||
|
if (pieces.length <= 1) return pieces;
|
||||||
|
let best = 0;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (let i = 0; i < pieces.length; i++) {
|
||||||
|
const d = pointSegmentDistance(pick, pieces[i][0], pieces[i][1]);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pieces.filter((_, i) => i !== best);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extend ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verlängert das gewählte Ende (`end`: "start" → a1, "end" → a2) der Strecke
|
||||||
|
* a1→a2 bis zur NÄCHSTGELEGENEN Schneider-Kante in Verlängerungsrichtung.
|
||||||
|
* Liefert die neue Strecke oder null, wenn kein Treffer in der korrekten
|
||||||
|
* Richtung existiert.
|
||||||
|
*/
|
||||||
|
export function extendSegment(
|
||||||
|
a1: Vec2,
|
||||||
|
a2: Vec2,
|
||||||
|
end: "start" | "end",
|
||||||
|
cutters: { pts: Vec2[]; closed: boolean }[],
|
||||||
|
): [Vec2, Vec2] | null {
|
||||||
|
// Richtung der Verlängerung: bei "end" über a2 hinaus (t>1), bei "start"
|
||||||
|
// über a1 hinaus (t<0). Wir parametrisieren stets entlang a1→a2.
|
||||||
|
let bestT: number | null = null;
|
||||||
|
for (const c of cutters) {
|
||||||
|
for (const [b1, b2] of polylineEdges(c.pts, c.closed)) {
|
||||||
|
const h = lineSegmentIntersect(a1, a2, b1, b2);
|
||||||
|
if (!h) continue;
|
||||||
|
if (end === "end" && h.t > 1 + EPS) {
|
||||||
|
if (bestT === null || h.t < bestT) bestT = h.t; // nächstgelegen über a2
|
||||||
|
} else if (end === "start" && h.t < -EPS) {
|
||||||
|
if (bestT === null || h.t > bestT) bestT = h.t; // nächstgelegen unter a1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestT === null) return null;
|
||||||
|
const da = sub(a2, a1);
|
||||||
|
const hitPoint = add(a1, scale(da, bestT));
|
||||||
|
return end === "end" ? [a1, hitPoint] : [hitPoint, a2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kreis-Schnitte (für Circle/Arc, 2P/3P/Tangenten) ────────────────────────
|
||||||
|
|
||||||
|
/** Schnittpunkte einer unendlichen GERADE a→b mit einem Kreis (0/1/2 Punkte). */
|
||||||
|
export function lineCircleIntersect(
|
||||||
|
a: Vec2,
|
||||||
|
b: Vec2,
|
||||||
|
center: Vec2,
|
||||||
|
r: number,
|
||||||
|
): Vec2[] {
|
||||||
|
const d = sub(b, a);
|
||||||
|
const f = sub(a, center);
|
||||||
|
const A = dot(d, d);
|
||||||
|
if (A < EPS) return [];
|
||||||
|
const B = 2 * dot(f, d);
|
||||||
|
const C = dot(f, f) - r * r;
|
||||||
|
let disc = B * B - 4 * A * C;
|
||||||
|
if (disc < -EPS) return [];
|
||||||
|
if (disc < 0) disc = 0;
|
||||||
|
const sq = Math.sqrt(disc);
|
||||||
|
const t1 = (-B - sq) / (2 * A);
|
||||||
|
const t2 = (-B + sq) / (2 * A);
|
||||||
|
const out = [add(a, scale(d, t1))];
|
||||||
|
if (Math.abs(t1 - t2) > EPS) out.push(add(a, scale(d, t2)));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schnittpunkte einer STRECKE a→b mit einem Kreis (nur t ∈ [0,1]). */
|
||||||
|
export function segmentCircleIntersect(
|
||||||
|
a: Vec2,
|
||||||
|
b: Vec2,
|
||||||
|
center: Vec2,
|
||||||
|
r: number,
|
||||||
|
): Vec2[] {
|
||||||
|
return lineCircleIntersect(a, b, center, r).filter((p) => {
|
||||||
|
const t = projectParam(p, a, b);
|
||||||
|
return t >= -EPS && t <= 1 + EPS;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schnittpunkte zweier Kreise (0/1/2 Punkte). */
|
||||||
|
export function circleCircleIntersect(
|
||||||
|
c1: Vec2,
|
||||||
|
r1: number,
|
||||||
|
c2: Vec2,
|
||||||
|
r2: number,
|
||||||
|
): Vec2[] {
|
||||||
|
const d = dist(c1, c2);
|
||||||
|
if (d < EPS) return []; // konzentrisch
|
||||||
|
if (d > r1 + r2 + EPS || d < Math.abs(r1 - r2) - EPS) return []; // getrennt/innen
|
||||||
|
const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d);
|
||||||
|
const h2 = r1 * r1 - a * a;
|
||||||
|
const h = h2 > 0 ? Math.sqrt(h2) : 0;
|
||||||
|
const u = normalize(sub(c2, c1));
|
||||||
|
const mid = add(c1, scale(u, a));
|
||||||
|
const n = leftNormal(u);
|
||||||
|
if (h < EPS) return [mid];
|
||||||
|
return [add(mid, scale(n, h)), add(mid, scale(n, -h))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Polygon-Fläche / Wicklung ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Vorzeichenbehaftete Polygonfläche (Shoelace); >0 = CCW, <0 = CW. */
|
||||||
|
export function signedArea(pts: Vec2[]): number {
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const a = pts[i];
|
||||||
|
const b = pts[(i + 1) % pts.length];
|
||||||
|
s += a.x * b.y - b.x * a.y;
|
||||||
|
}
|
||||||
|
return s / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ob ein Polygonzug gegen den Uhrzeigersinn (CCW) gewickelt ist. */
|
||||||
|
export function isCCW(pts: Vec2[]): boolean {
|
||||||
|
return signedArea(pts) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fillet (Eck-Verrundung) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Ergebnis einer Eck-Verrundung. */
|
||||||
|
export interface Fillet {
|
||||||
|
center: Vec2;
|
||||||
|
radius: number;
|
||||||
|
/** Tangentenpunkt auf dem ersten Schenkel (corner→p1). */
|
||||||
|
tangentA: Vec2;
|
||||||
|
/** Tangentenpunkt auf dem zweiten Schenkel (corner→p2). */
|
||||||
|
tangentB: Vec2;
|
||||||
|
/** Bogen-Endwinkel in Radiant (für ein Arc-Primitiv). */
|
||||||
|
startAngle: number;
|
||||||
|
endAngle: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verrundet die Ecke bei `corner`, gebildet von den beiden Schenkeln corner→p1
|
||||||
|
* und corner→p2, mit Radius `r`. Liefert Bogenmittelpunkt + Tangentenpunkte
|
||||||
|
* (an denen die Schenkel zurückgetrimmt werden) oder null, wenn die Schenkel
|
||||||
|
* (nahezu) kollinear sind oder zu kurz für den Radius. Standard-Kanten-Fillet
|
||||||
|
* (Distanz vom Eck = r / tan(θ/2)).
|
||||||
|
*/
|
||||||
|
export function filletCorner(
|
||||||
|
corner: Vec2,
|
||||||
|
p1: Vec2,
|
||||||
|
p2: Vec2,
|
||||||
|
r: number,
|
||||||
|
): Fillet | null {
|
||||||
|
const u1 = normalize(sub(p1, corner));
|
||||||
|
const u2 = normalize(sub(p2, corner));
|
||||||
|
const cosTheta = Math.max(-1, Math.min(1, dot(u1, u2)));
|
||||||
|
const theta = Math.acos(cosTheta);
|
||||||
|
if (theta < 1e-4 || Math.PI - theta < 1e-4) return null; // kollinear
|
||||||
|
const tanHalf = Math.tan(theta / 2);
|
||||||
|
if (tanHalf < EPS) return null;
|
||||||
|
const setback = r / tanHalf; // Abstand Eck → Tangentenpunkt entlang je Schenkel
|
||||||
|
// Genügend Schenkellänge?
|
||||||
|
if (setback > len(sub(p1, corner)) + EPS || setback > len(sub(p2, corner)) + EPS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tangentA = add(corner, scale(u1, setback));
|
||||||
|
const tangentB = add(corner, scale(u2, setback));
|
||||||
|
// Mittelpunkt liegt auf der Winkelhalbierenden, Abstand r/sin(θ/2) vom Eck.
|
||||||
|
const bis = normalize(add(u1, u2));
|
||||||
|
const centerDist = r / Math.sin(theta / 2);
|
||||||
|
const center = add(corner, scale(bis, centerDist));
|
||||||
|
const startAngle = Math.atan2(tangentA.y - center.y, tangentA.x - center.x);
|
||||||
|
const endAngle = Math.atan2(tangentB.y - center.y, tangentB.x - center.x);
|
||||||
|
return { center, radius: r, tangentA, tangentB, startAngle, endAngle };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Split / Join / Segment-Löschen (Drawing2D-Editieren) ─────────────────────
|
||||||
|
// Reine Geometrie auf Polylinien (`Vec2[]` + `closed`). Die Operations-Schicht
|
||||||
|
// (`src/editors/splitJoin.ts`) wandelt Drawing2D ↔ {pts, closed} und ruft diese
|
||||||
|
// Funktionen. Punkt P einer Kante: `lerp(pts[i], pts[i+1], t)`.
|
||||||
|
|
||||||
|
/** Lineare Interpolation zweier Punkte. */
|
||||||
|
const lerp = (a: Vec2, b: Vec2, t: number): Vec2 => add(a, scale(sub(b, a), t));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teilt eine Polylinie an EINEM Punkt P = lerp(pts[edgeIndex], pts[edgeIndex+1],
|
||||||
|
* t) (t ∈ (0,1) entlang der Kante; bei geschlossenen Zügen ist die Schlusskante
|
||||||
|
* `edgeIndex === pts.length-1`).
|
||||||
|
* • OFFEN → zwei offene Polylinien [start…P] und [P…ende].
|
||||||
|
* • GESCHLOSSEN → EINE offene Polylinie, am Punkt P aufgeschnitten (der Zug
|
||||||
|
* läuft von P einmal um die Schleife zurück nach P).
|
||||||
|
* Liegt P (nahezu) auf einem vorhandenen Vertex, wird KEIN Duplikat eingefügt.
|
||||||
|
*/
|
||||||
|
export function splitPolylineAtParam(
|
||||||
|
pts: Vec2[],
|
||||||
|
closed: boolean,
|
||||||
|
edgeIndex: number,
|
||||||
|
t: number,
|
||||||
|
): Vec2[][] {
|
||||||
|
const n = pts.length;
|
||||||
|
if (n < 2) return [pts.slice()];
|
||||||
|
const a = pts[edgeIndex];
|
||||||
|
const b = pts[(edgeIndex + 1) % n];
|
||||||
|
const p = lerp(a, b, t);
|
||||||
|
const atStart = t <= EPS;
|
||||||
|
const atEnd = t >= 1 - EPS;
|
||||||
|
|
||||||
|
if (!closed) {
|
||||||
|
// Offen: [0..edgeIndex] + P + [edgeIndex+1..n-1].
|
||||||
|
const left = pts.slice(0, edgeIndex + 1);
|
||||||
|
if (!atStart && !atEnd) left.push(p);
|
||||||
|
else if (atEnd) left.push(b); // P == b → b gehört noch links dazu
|
||||||
|
const right: Vec2[] = [];
|
||||||
|
if (!atStart && !atEnd) right.push(p);
|
||||||
|
else if (atStart) right.push(a);
|
||||||
|
for (let i = edgeIndex + 1; i < n; i++) right.push(pts[i]);
|
||||||
|
const pieces = [left, right].filter((s) => s.length >= 2);
|
||||||
|
return pieces.length ? pieces : [pts.slice()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geschlossen: an P aufschneiden → eine offene Kette, die einmal herumläuft.
|
||||||
|
const out: Vec2[] = [];
|
||||||
|
if (!atStart) out.push(p);
|
||||||
|
// Ab dem Punkt NACH der Schnittkante einmal komplett um die Schleife.
|
||||||
|
for (let k = 1; k <= n; k++) {
|
||||||
|
out.push(pts[(edgeIndex + k) % n]);
|
||||||
|
}
|
||||||
|
// out endet bei pts[edgeIndex] (== a); P schließt den Zug am Ende ab.
|
||||||
|
if (!atStart) out.push(p);
|
||||||
|
else out.push(a); // bei P==a: a doppelt (Start==Ende) ergäbe eine Schleife;
|
||||||
|
// dedupe identische aufeinanderfolgende Punkte
|
||||||
|
return [dedupeConsecutive(out)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt aufeinanderfolgende (nahezu) gleiche Punkte (kein Ringschluss). */
|
||||||
|
function dedupeConsecutive(pts: Vec2[]): Vec2[] {
|
||||||
|
const out: Vec2[] = [];
|
||||||
|
for (const p of pts) {
|
||||||
|
if (out.length === 0 || !vecEqual(out[out.length - 1], p)) out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teilt ein GESCHLOSSENES Polygon an zwei Randpunkten (auf den Kanten `i` bzw.
|
||||||
|
* `j`, Parameter `ti`/`tj`) in ZWEI GESCHLOSSENE Polygone: jedes besteht aus dem
|
||||||
|
* jeweiligen Rand-Bogen plus der Sehne (Verbindung Pi—Pj). Liefert `null`, wenn
|
||||||
|
* i==j (dieselbe Kante → keine sinnvolle Sehne).
|
||||||
|
*/
|
||||||
|
export function splitClosedByChord(
|
||||||
|
pts: Vec2[],
|
||||||
|
i: number,
|
||||||
|
ti: number,
|
||||||
|
j: number,
|
||||||
|
tj: number,
|
||||||
|
): [Vec2[], Vec2[]] | null {
|
||||||
|
const n = pts.length;
|
||||||
|
if (n < 3 || i === j) return null;
|
||||||
|
// Reihenfolge sicherstellen: a vor b entlang des Ringes.
|
||||||
|
let ia = i, ta = ti, ib = j, tb = tj;
|
||||||
|
if (i > j) {
|
||||||
|
ia = j; ta = tj; ib = i; tb = ti;
|
||||||
|
}
|
||||||
|
const Pa = lerp(pts[ia], pts[(ia + 1) % n], ta);
|
||||||
|
const Pb = lerp(pts[ib], pts[(ib + 1) % n], tb);
|
||||||
|
|
||||||
|
// Bogen 1: Pa → (Vertices ia+1 … ib) → Pb.
|
||||||
|
const arc1: Vec2[] = [Pa];
|
||||||
|
for (let k = ia + 1; k <= ib; k++) arc1.push(pts[k % n]);
|
||||||
|
arc1.push(Pb);
|
||||||
|
// Bogen 2: Pb → (Vertices ib+1 … ia, modular) → Pa.
|
||||||
|
const arc2: Vec2[] = [Pb];
|
||||||
|
for (let k = ib + 1; k <= ia + n; k++) arc2.push(pts[k % n]);
|
||||||
|
arc2.push(Pa);
|
||||||
|
|
||||||
|
return [dedupeRing(arc1), dedupeRing(arc2)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedupliziert aufeinanderfolgende gleiche Punkte UND einen schließenden
|
||||||
|
* Duplikat-Endpunkt (Ring: pts[last]==pts[0] → letzten verwerfen).
|
||||||
|
*/
|
||||||
|
function dedupeRing(pts: Vec2[]): Vec2[] {
|
||||||
|
const out = dedupeConsecutive(pts);
|
||||||
|
if (out.length > 1 && vecEqual(out[0], out[out.length - 1])) out.pop();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt das Segment `edgeIndex` aus einer Polylinie. Liefert die neue
|
||||||
|
* Punktliste plus neues `closed`-Flag:
|
||||||
|
* • OFFEN: innere Kante → die Polylinie zerfällt in zwei Stücke; hier wird das
|
||||||
|
* LÄNGERE Stück (mehr Punkte) zurückgegeben (Schere). Erste/letzte Kante →
|
||||||
|
* einfach den End-Vertex abschneiden.
|
||||||
|
* • GESCHLOSSEN: die Schleife wird an dieser Kante zur OFFENEN Polylinie
|
||||||
|
* aufgetrennt (Start = Punkt nach der Kante, einmal herum bis zum Punkt davor).
|
||||||
|
*/
|
||||||
|
export function removeSegment(
|
||||||
|
pts: Vec2[],
|
||||||
|
closed: boolean,
|
||||||
|
edgeIndex: number,
|
||||||
|
): { pts: Vec2[]; closed: boolean } {
|
||||||
|
const n = pts.length;
|
||||||
|
if (closed) {
|
||||||
|
// Auftrennen: Start = (edgeIndex+1) … einmal herum bis edgeIndex.
|
||||||
|
const out: Vec2[] = [];
|
||||||
|
for (let k = 1; k <= n; k++) out.push(pts[(edgeIndex + k) % n]);
|
||||||
|
return { pts: dedupeConsecutive(out), closed: false };
|
||||||
|
}
|
||||||
|
// Offen.
|
||||||
|
if (edgeIndex <= 0) return { pts: pts.slice(1), closed: false };
|
||||||
|
if (edgeIndex >= n - 1) return { pts: pts.slice(0, n - 1), closed: false };
|
||||||
|
const left = pts.slice(0, edgeIndex + 1);
|
||||||
|
const right = pts.slice(edgeIndex + 1);
|
||||||
|
// Das punktreichere (geometrisch i. d. R. „größere") Teilstück behalten.
|
||||||
|
return { pts: left.length >= right.length ? left : right, closed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Schnitt-Treffer auf einer Ziel-Kante: Kantenindex + Parameter + Punkt. */
|
||||||
|
interface EdgeHit {
|
||||||
|
edge: number;
|
||||||
|
t: number;
|
||||||
|
point: Vec2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teilt das Ziel (`targetPts`, `closed`) an allen Schnittpunkten mit den anderen
|
||||||
|
* Polylinien (`others`). Schnittpunkte entstehen je Kantenpaar via
|
||||||
|
* {@link segmentIntersect}.
|
||||||
|
* • GESCHLOSSEN + genau zwei Schnitte → {@link splitClosedByChord} (zwei
|
||||||
|
* geschlossene Polygone). Bei !=2 Schnitten: unverändert (eine Liste).
|
||||||
|
* • OFFEN → an den (nach Bogenlänge geordneten) Schnittpunkten in Stücke
|
||||||
|
* zerschnitten.
|
||||||
|
* Schnitte an vorhandenen Vertices/Endpunkten werden zusammengefasst.
|
||||||
|
*/
|
||||||
|
export function splitAtIntersections(
|
||||||
|
targetPts: Vec2[],
|
||||||
|
closed: boolean,
|
||||||
|
others: Vec2[][],
|
||||||
|
): Vec2[][] {
|
||||||
|
const edges = polylineEdges(targetPts, closed);
|
||||||
|
const hits: EdgeHit[] = [];
|
||||||
|
for (let ei = 0; ei < edges.length; ei++) {
|
||||||
|
const [a1, a2] = edges[ei];
|
||||||
|
for (const o of others) {
|
||||||
|
// jede Kante jeder anderen Polylinie (offen UND geschlossen behandeln wir
|
||||||
|
// gleich: die Aufrufer-Schicht liefert `others` bereits als Polylinien-
|
||||||
|
// Punktlisten; geschlossene Schließkante über vecEqual-Heuristik unnötig,
|
||||||
|
// da der Aufrufer Endpunkt nicht dupliziert — wir testen offene Kanten).
|
||||||
|
const oEdges = polylineEdgesAuto(o);
|
||||||
|
for (const [b1, b2] of oEdges) {
|
||||||
|
const h = segmentIntersect(a1, a2, b1, b2);
|
||||||
|
if (!h) continue;
|
||||||
|
// Nur „echte" innere Schnitte zählen (nicht exakt am Ziel-Vertex 0/1,
|
||||||
|
// die ohnehin Trennstellen sind).
|
||||||
|
hits.push({ edge: ei, t: h.t, point: h.point });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pro Ziel-Kante nach t sortieren + dedupen.
|
||||||
|
const dedup: EdgeHit[] = [];
|
||||||
|
hits.sort((p, q) => (p.edge - q.edge) || (p.t - q.t));
|
||||||
|
for (const h of hits) {
|
||||||
|
const prev = dedup[dedup.length - 1];
|
||||||
|
if (prev && prev.edge === h.edge && Math.abs(prev.t - h.t) < 1e-6) continue;
|
||||||
|
// Schnitte exakt auf einem Ziel-Vertex (t≈0/1) ignorieren — keine echte Teilung.
|
||||||
|
if (h.t <= EPS || h.t >= 1 - EPS) continue;
|
||||||
|
dedup.push(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dedup.length === 0) return [targetPts.slice()];
|
||||||
|
|
||||||
|
if (closed) {
|
||||||
|
if (dedup.length === 2) {
|
||||||
|
const [h1, h2] = dedup;
|
||||||
|
const res = splitClosedByChord(targetPts, h1.edge, h1.t, h2.edge, h2.t);
|
||||||
|
return res ? res : [targetPts.slice()];
|
||||||
|
}
|
||||||
|
// Mehr/weniger als 2 Schnitte: geschlossen an allen Schnitten zur offenen
|
||||||
|
// Polylinie aufgetrennt (am ersten Schnitt aufgeschnitten, dann in Stücke).
|
||||||
|
// Pragmatisch: am ersten Schnitt aufschneiden, dann wie offen weiter.
|
||||||
|
const opened = splitPolylineAtParam(targetPts, true, dedup[0].edge, dedup[0].t)[0];
|
||||||
|
return splitOpenAtHits(opened, dedup.slice(1).map((h) => h.point));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offen: an den geordneten Schnittpunkten zerschneiden.
|
||||||
|
return splitOpenByEdgeHits(targetPts, dedup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kanten einer (offenen ODER implizit geschlossenen) Punktliste. */
|
||||||
|
function polylineEdgesAuto(pts: Vec2[]): [Vec2, Vec2][] {
|
||||||
|
// Heuristik: ist der erste == letzter Punkt, gilt sie als geschlossen.
|
||||||
|
const closed = pts.length > 2 && vecEqual(pts[0], pts[pts.length - 1]);
|
||||||
|
return polylineEdges(closed ? pts.slice(0, -1) : pts, closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offene Polylinie an gegebenen EdgeHits (auf ihren Kanten) zerschneiden. */
|
||||||
|
function splitOpenByEdgeHits(pts: Vec2[], hits: EdgeHit[]): Vec2[][] {
|
||||||
|
const pieces: Vec2[][] = [];
|
||||||
|
let cur: Vec2[] = [pts[0]];
|
||||||
|
let hi = 0;
|
||||||
|
for (let ei = 0; ei < pts.length - 1; ei++) {
|
||||||
|
while (hi < hits.length && hits[hi].edge === ei) {
|
||||||
|
const p = hits[hi].point;
|
||||||
|
cur.push(p);
|
||||||
|
pieces.push(cur);
|
||||||
|
cur = [p];
|
||||||
|
hi++;
|
||||||
|
}
|
||||||
|
cur.push(pts[ei + 1]);
|
||||||
|
}
|
||||||
|
pieces.push(cur);
|
||||||
|
return pieces.map(dedupeConsecutive).filter((s) => s.length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offene Polylinie an einer Liste von Schnitt-PUNKTEN (auf dem Zug) zerschneiden. */
|
||||||
|
function splitOpenAtHits(pts: Vec2[], cutPoints: Vec2[]): Vec2[][] {
|
||||||
|
if (cutPoints.length === 0) return [pts.slice()];
|
||||||
|
const hits: EdgeHit[] = [];
|
||||||
|
for (let ei = 0; ei < pts.length - 1; ei++) {
|
||||||
|
const a = pts[ei];
|
||||||
|
const b = pts[ei + 1];
|
||||||
|
for (const cp of cutPoints) {
|
||||||
|
const t = projectParam(cp, a, b);
|
||||||
|
if (t > EPS && t < 1 - EPS && pointSegmentDistance(cp, a, b) < 1e-6) {
|
||||||
|
hits.push({ edge: ei, t, point: cp });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hits.sort((p, q) => (p.edge - q.edge) || (p.t - q.t));
|
||||||
|
return splitOpenByEdgeHits(pts, hits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschmilzt Polylinien an koinzidenten Endpunkten zu längeren Ketten. Jeder
|
||||||
|
* Eintrag ist `{pts, closed}`. Bereits geschlossene Ketten bleiben unverändert.
|
||||||
|
* Offene Ketten werden an gemeinsamen Endpunkten (vecEqual) aneinandergehängt;
|
||||||
|
* schließt sich eine Kette (erster == letzter Punkt), wird sie als `closed`
|
||||||
|
* markiert (der Schlusspunkt-Duplikat entfernt). Reihenfolge-/Richtungs-stabil
|
||||||
|
* genug für den Editier-Workflow (kein Verzweigungs-Handling: Knoten mit >2
|
||||||
|
* Enden werden greedy verbunden).
|
||||||
|
*/
|
||||||
|
export function joinChains(
|
||||||
|
polylines: { pts: Vec2[]; closed: boolean }[],
|
||||||
|
): { pts: Vec2[]; closed: boolean }[] {
|
||||||
|
const closedOut: { pts: Vec2[]; closed: boolean }[] = [];
|
||||||
|
// Offene Ketten als veränderbare Punktlisten sammeln (geschlossene bleiben).
|
||||||
|
const open: Vec2[][] = [];
|
||||||
|
for (const pl of polylines) {
|
||||||
|
if (pl.closed) closedOut.push({ pts: pl.pts.slice(), closed: true });
|
||||||
|
else if (pl.pts.length >= 2) open.push(pl.pts.slice());
|
||||||
|
else if (pl.pts.length === 1) open.push(pl.pts.slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
let merged = true;
|
||||||
|
while (merged) {
|
||||||
|
merged = false;
|
||||||
|
outer: for (let i = 0; i < open.length; i++) {
|
||||||
|
for (let j = i + 1; j < open.length; j++) {
|
||||||
|
const A = open[i];
|
||||||
|
const B = open[j];
|
||||||
|
const aS = A[0], aE = A[A.length - 1];
|
||||||
|
const bS = B[0], bE = B[B.length - 1];
|
||||||
|
let combined: Vec2[] | null = null;
|
||||||
|
if (vecEqual(aE, bS)) combined = [...A, ...B.slice(1)];
|
||||||
|
else if (vecEqual(aE, bE)) combined = [...A, ...B.slice(0, -1).reverse()];
|
||||||
|
else if (vecEqual(aS, bE)) combined = [...B, ...A.slice(1)];
|
||||||
|
else if (vecEqual(aS, bS)) combined = [...B.slice().reverse(), ...A.slice(1)];
|
||||||
|
if (combined) {
|
||||||
|
open.splice(j, 1);
|
||||||
|
open.splice(i, 1, combined);
|
||||||
|
merged = true;
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: { pts: Vec2[]; closed: boolean }[] = [...closedOut];
|
||||||
|
for (const chain of open) {
|
||||||
|
if (chain.length > 2 && vecEqual(chain[0], chain[chain.length - 1])) {
|
||||||
|
out.push({ pts: chain.slice(0, -1), closed: true });
|
||||||
|
} else {
|
||||||
|
out.push({ pts: chain, closed: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+500
@@ -0,0 +1,500 @@
|
|||||||
|
// Deutsches Wörterbuch (Default-Sprache). Die Werte hier sind EXAKT die bisher
|
||||||
|
// sichtbaren UI-Texte — die i18n-Umstellung darf optisch nichts ändern.
|
||||||
|
//
|
||||||
|
// Identifier/Keys sind englisch und namespaced wie bei DOSSIER
|
||||||
|
// (z. B. `topbar.grundriss`); nur die Werte sind übersetzbarer Text.
|
||||||
|
// Platzhalter in geschweiften Klammern (`{name}`) werden von t(...) ersetzt.
|
||||||
|
|
||||||
|
export const de = {
|
||||||
|
// ── Marke / Topbar ───────────────────────────────────────────────────────
|
||||||
|
"brand.tag": "Dokumentmodell",
|
||||||
|
|
||||||
|
"topbar.viewGroup": "Ansicht",
|
||||||
|
"topbar.grundriss": "Grundriss",
|
||||||
|
"topbar.perspektive": "Perspektive",
|
||||||
|
|
||||||
|
"view3d.group": "Ansicht 3D",
|
||||||
|
"view3d.front": "Front",
|
||||||
|
"view3d.front.hint": "Frontalansicht von vorne",
|
||||||
|
"view3d.top": "Oben",
|
||||||
|
"view3d.top.hint": "Draufsicht (senkrecht von oben)",
|
||||||
|
"view3d.side": "Seite",
|
||||||
|
"view3d.side.hint": "Seitenansicht (90° zur Front)",
|
||||||
|
"view3d.perspective": "Perspektive",
|
||||||
|
"view3d.perspective.hint": "Freie Perspektive (3/4-Blick)",
|
||||||
|
"view3d.iso": "Isometrie",
|
||||||
|
"view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)",
|
||||||
|
"view3d.camera": "Kamera",
|
||||||
|
"view3d.camera.hint": "Kamera-Einstellungen (Sichtwinkel)",
|
||||||
|
"view3d.fov": "Sichtwinkel",
|
||||||
|
"view3d.fov.hint": "Sichtwinkel (FOV) der Perspektivkamera in Grad",
|
||||||
|
|
||||||
|
"topbar.detailGroup": "Detailgrad",
|
||||||
|
"topbar.detail": "Detailgrad",
|
||||||
|
"topbar.detail.hint": "Detailgrad der Plandarstellung (Symbole, Schraffuren, Linien)",
|
||||||
|
"topbar.detail.hintDisabled": "Detailgrad wirkt im Grundriss (in der Perspektive noch nicht)",
|
||||||
|
"topbar.detail.grob": "grob",
|
||||||
|
"topbar.detail.mittel": "mittel",
|
||||||
|
"topbar.detail.fein": "fein",
|
||||||
|
|
||||||
|
"topbar.scaleGroup": "Massstab und Zoom",
|
||||||
|
"topbar.scale": "Massstab",
|
||||||
|
"topbar.scale.apply": "Papier-Massstab 1:N anwenden",
|
||||||
|
"topbar.scale.custom": "frei …",
|
||||||
|
"topbar.scale.live": "Aktueller Papier-Massstab (live)",
|
||||||
|
"topbar.scale.prompt": "Massstab eingeben (z. B. 1:35 oder 35):",
|
||||||
|
"topbar.zoom100": "100%",
|
||||||
|
"topbar.zoom100.hint": "Zoom exakt auf den gewählten Massstab 1:{n}",
|
||||||
|
"topbar.fit": "Einpassen",
|
||||||
|
"topbar.fit.hint": "Auf Plan einpassen",
|
||||||
|
"topbar.fitSelection": "Auswahl",
|
||||||
|
"topbar.fitSelection.hint": "Auf Auswahl einpassen (sonst auf Plan)",
|
||||||
|
"topbar.zoom.current": "Aktueller Zoom",
|
||||||
|
|
||||||
|
"topbar.renderGroup": "Render-Modus",
|
||||||
|
"topbar.render.shaded": "Schattiert",
|
||||||
|
"topbar.render.shaded.hint": "Schattiert",
|
||||||
|
"topbar.render.wireframe": "Draht",
|
||||||
|
"topbar.render.wireframe.hint": "Drahtgitter",
|
||||||
|
"topbar.render.hidden": "Kanten",
|
||||||
|
"topbar.render.hidden.hint": "Verdeckte Kanten (Hidden-Line)",
|
||||||
|
"topbar.render.hintDisabled": "Render-Modus wirkt in der Perspektive",
|
||||||
|
// Darstellungsart (kontextabhängiger Dropdown): Grundriss = Farbig/SW,
|
||||||
|
// Perspektive = Schattiert/Weiss/Draht/Kanten.
|
||||||
|
"display.style": "Darstellung",
|
||||||
|
"display.style.group": "Darstellungsart",
|
||||||
|
"display.style.hint": "Darstellungsart der aktiven Ansicht",
|
||||||
|
"display.style.color": "Farbig",
|
||||||
|
"display.style.mono": "Schwarz-Weiss",
|
||||||
|
"display.style.shaded": "Schattiert",
|
||||||
|
"display.style.white": "Weiss",
|
||||||
|
"display.style.wireframe": "Drahtgitter",
|
||||||
|
"display.style.hidden": "Kanten",
|
||||||
|
|
||||||
|
"topbar.referenceLinesGroup": "Referenzlinien",
|
||||||
|
"topbar.referenceLines": "Referenzlinien",
|
||||||
|
"topbar.referenceLines.hint": "Referenzlinien (Wandachsen) ein-/ausblenden",
|
||||||
|
"topbar.referenceLines.hintDisabled": "Referenzlinien wirken im Grundriss",
|
||||||
|
|
||||||
|
"topbar.resources": "Ressourcen",
|
||||||
|
"topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden",
|
||||||
|
|
||||||
|
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
|
||||||
|
"layout.title": "Layout",
|
||||||
|
"layout.placeholder": "Layout …",
|
||||||
|
"layout.saveCurrent": "+ Aktuelles speichern …",
|
||||||
|
"layout.loadGroup": "Laden",
|
||||||
|
"layout.deleteGroup": "Löschen",
|
||||||
|
"layout.savePrompt": "Layout speichern als:",
|
||||||
|
|
||||||
|
// ── Sichtbarkeits-Kombinationen (Topbar) ─────────────────────────────────
|
||||||
|
"combo.layers": "Ebenen-Komb.",
|
||||||
|
"combo.drawings": "Zeichn.-Komb.",
|
||||||
|
"combo.placeholder": "Kombination …",
|
||||||
|
"combo.saveCurrent": "+ Aktuelle speichern …",
|
||||||
|
"combo.loadGroup": "Laden",
|
||||||
|
"combo.deleteGroup": "Löschen",
|
||||||
|
"combo.layers.title": "Ebenenkombinationen",
|
||||||
|
"combo.drawings.title": "Zeichnungskombinationen",
|
||||||
|
"combo.layers.savePrompt": "Ebenenkombination speichern als:",
|
||||||
|
"combo.drawings.savePrompt": "Zeichnungskombination speichern als:",
|
||||||
|
|
||||||
|
// ── Zeichenwerkzeuge (Topbar-Werkzeugleiste + Statushinweise) ────────────
|
||||||
|
"tool.group": "Werkzeuge",
|
||||||
|
"tool.select": "Auswahl",
|
||||||
|
"tool.wall": "Wand",
|
||||||
|
"tool.line": "Linie",
|
||||||
|
"tool.polyline": "Polylinie",
|
||||||
|
"tool.rect": "Rechteck",
|
||||||
|
"tool.select.hint": "Auswahl — Elemente klicken/aufziehen",
|
||||||
|
"tool.wall.firstPoint": "Wand: ersten Achspunkt setzen",
|
||||||
|
"tool.wall.nextPoint": "Wand: nächsten Punkt — Doppelklick/Rechtsklick beendet",
|
||||||
|
"tool.line.firstPoint": "Linie: Startpunkt setzen",
|
||||||
|
"tool.line.secondPoint": "Linie: Endpunkt setzen",
|
||||||
|
"tool.polyline.firstPoint": "Polylinie: ersten Punkt setzen",
|
||||||
|
"tool.polyline.nextPoint": "Polylinie: nächster Punkt — Klick auf Start schließt, Doppelklick beendet",
|
||||||
|
"tool.rect.firstCorner": "Rechteck: erste Ecke setzen",
|
||||||
|
"tool.rect.secondCorner": "Rechteck: gegenüberliegende Ecke setzen",
|
||||||
|
"tool.wallType": "Wandtyp",
|
||||||
|
"tool.layer": "Ebene",
|
||||||
|
"tool.layer.hint": "Aktive Ebene — alles Gezeichnete kommt auf diese Kategorie",
|
||||||
|
"tool.floorOnlyDisabled": "Wand nur auf einem Geschoss zeichenbar",
|
||||||
|
"tool.snap": "Fang",
|
||||||
|
"tool.snap.hint": "Fang an Endpunkten/Raster; Shift = Ortho, Ctrl = Fang aus",
|
||||||
|
"snap.enabled": "Fang aktiv",
|
||||||
|
"snap.endpoint": "Endpunkt",
|
||||||
|
"snap.midpoint": "Mittelpunkt",
|
||||||
|
"snap.intersection": "Schnittpunkt",
|
||||||
|
"snap.onEdge": "Auf Kante (Lot)",
|
||||||
|
"snap.grid": "Raster",
|
||||||
|
"snap.ortho": "Ortho (Shift)",
|
||||||
|
"snap.gridSize": "Rasterweite (m)",
|
||||||
|
"snap.angleStep": "Winkelraster",
|
||||||
|
|
||||||
|
// ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ───────────────
|
||||||
|
"transform.bar": "Transformation",
|
||||||
|
"transform.cancel": "Abbrechen (Esc)",
|
||||||
|
"transform.countPrompt": "Anzahl Kopien:",
|
||||||
|
"transform.move": "Bewegen (M)",
|
||||||
|
"transform.mirror": "Spiegeln (S)",
|
||||||
|
"transform.rotate": "Drehen (D)",
|
||||||
|
"transform.mode.move": "Bewegen",
|
||||||
|
"transform.mode.copy": "Kopie",
|
||||||
|
"transform.mode.array": "N Kopien",
|
||||||
|
"transform.mode.distribute": "Verteilen",
|
||||||
|
"transform.hint.move.0": "Basispunkt klicken",
|
||||||
|
"transform.hint.move.1": "Zielpunkt klicken",
|
||||||
|
"transform.hint.rotate.0": "Drehzentrum klicken",
|
||||||
|
"transform.hint.rotate.1": "Bezugspunkt (Startwinkel) klicken",
|
||||||
|
"transform.hint.rotate.2": "Zielwinkel klicken",
|
||||||
|
"transform.hint.mirror.0": "Erster Punkt der Spiegelachse",
|
||||||
|
"transform.hint.mirror.1": "Zweiter Punkt der Spiegelachse",
|
||||||
|
|
||||||
|
// ── Linien-Darstellung (Display/Print) ───────────────────────────────────
|
||||||
|
"topbar.lineModeGroup": "Linien-Darstellung",
|
||||||
|
"topbar.lineMode.display": "Display",
|
||||||
|
"topbar.lineMode.print": "Print",
|
||||||
|
"topbar.lineMode.display.hint": "Display: alle Linien als konstante Haarlinie (ruhiges Arbeiten)",
|
||||||
|
"topbar.lineMode.print.hint": "Print: echte mm-Strichstärken (Papier-/Druckbild)",
|
||||||
|
|
||||||
|
// ── Statusleiste ─────────────────────────────────────────────────────────
|
||||||
|
"status.selection": "Auswahl",
|
||||||
|
"status.selectionN": "Auswahl: {n} {noun}",
|
||||||
|
"status.wall": "Wand",
|
||||||
|
"status.walls": "Wände",
|
||||||
|
"status.xy": "X/Y",
|
||||||
|
"status.unit": "Einheit",
|
||||||
|
"status.scale": "Massstab",
|
||||||
|
"status.zoom": "Zoom",
|
||||||
|
"status.floor": "Geschoss",
|
||||||
|
"status.layer": "Ebene",
|
||||||
|
"status.none": "—",
|
||||||
|
|
||||||
|
// ── Navigator-Panels ─────────────────────────────────────────────────────
|
||||||
|
"nav.drawingLevels": "Zeichnungsebenen",
|
||||||
|
"nav.layers": "Ebenen",
|
||||||
|
"nav.tools": "Werkzeuge",
|
||||||
|
"nav.attributes": "Attribute",
|
||||||
|
"nav.objectInfo": "Objekt-Info",
|
||||||
|
|
||||||
|
// Attribute-Palette (AttributesPanel)
|
||||||
|
"attr.title": "Attribute",
|
||||||
|
"attr.empty": "Keine Auswahl",
|
||||||
|
"attr.stroke": "Stift",
|
||||||
|
"attr.color": "Farbe",
|
||||||
|
"attr.weight": "Strichstärke (mm)",
|
||||||
|
"attr.lineStyle": "Linienstil",
|
||||||
|
"attr.fill": "Füllung",
|
||||||
|
"attr.fillColor": "Füllfarbe",
|
||||||
|
"attr.clearFill": "Füllfarbe entfernen",
|
||||||
|
"attr.hatch": "Schraffur",
|
||||||
|
"attr.none": "keine",
|
||||||
|
"attr.inheritedFromLayer": "Wände erben die Strichstärke aus der Ebene",
|
||||||
|
"attr.fillNeedsClosed": "Füllung nur für geschlossene Formen",
|
||||||
|
"attr.size": "Maße",
|
||||||
|
"attr.width": "Breite",
|
||||||
|
"attr.height": "Höhe",
|
||||||
|
|
||||||
|
// Objekt-Info-Palette (ObjectInfoPanel)
|
||||||
|
"objinfo.title": "Objekt-Info",
|
||||||
|
"objinfo.empty": "Kein Objekt gewählt",
|
||||||
|
"objinfo.refpoint": "Bezugspunkt",
|
||||||
|
"objinfo.setRefpoint": "Bezugspunkt wählen",
|
||||||
|
"objinfo.x": "X",
|
||||||
|
"objinfo.y": "Y",
|
||||||
|
"objinfo.z": "Z",
|
||||||
|
"objinfo.zHint": "2D-Ebene – keine Höhe am Element",
|
||||||
|
"objinfo.dimensions": "Maße",
|
||||||
|
"objinfo.width": "Breite",
|
||||||
|
"objinfo.height": "Höhe",
|
||||||
|
"objinfo.type": "Typ",
|
||||||
|
"objinfo.kind.wall": "Wand",
|
||||||
|
"objinfo.kind.drawing2d": "2D-Objekt",
|
||||||
|
"objinfo.kind.door": "Tür",
|
||||||
|
// ── Wand-Attribute (Object-Info-Panel) ──────────────────────────────────
|
||||||
|
"objinfo.wall.section": "Wand",
|
||||||
|
"objinfo.wall.refLine": "Referenzlinie",
|
||||||
|
"objinfo.wall.refLine.left": "Außen (links)",
|
||||||
|
"objinfo.wall.refLine.center": "Mitte",
|
||||||
|
"objinfo.wall.refLine.right": "Innen (rechts)",
|
||||||
|
"objinfo.wall.buildup": "Aufbau",
|
||||||
|
"objinfo.wall.single": "Einschichtig",
|
||||||
|
"objinfo.wall.multi": "Mehrschichtig",
|
||||||
|
"objinfo.wall.thickness": "Dicke",
|
||||||
|
"objinfo.wall.preset": "Wandaufbau",
|
||||||
|
"objinfo.wall.presetLabel": "{name} · {thickness} m",
|
||||||
|
"objinfo.wall.refFloor": "Referenzgeschoss",
|
||||||
|
"objinfo.wall.linkAbove": "An oberes Geschoss binden",
|
||||||
|
"objinfo.wall.bottom": "Unterkante (UK)",
|
||||||
|
"objinfo.wall.top": "Oberkante (OK)",
|
||||||
|
"objinfo.wall.anchorFloor": "An Geschoss gebunden",
|
||||||
|
"objinfo.wall.anchorCustom": "Eigene Höhe",
|
||||||
|
"objinfo.wall.height": "Höhe",
|
||||||
|
"objinfo.wall.floorAboveNone": "Kein oberes Geschoss",
|
||||||
|
"levels.addFloor": "+ Geschoss",
|
||||||
|
"levels.addDrawing": "+ Zeichnung",
|
||||||
|
"layers.addCategory": "+ Ebene",
|
||||||
|
"nav.visibility": "Sichtbarkeit",
|
||||||
|
"nav.hide": "Ausblenden",
|
||||||
|
"nav.show": "Einblenden",
|
||||||
|
"nav.categoryVisible": "{name} sichtbar",
|
||||||
|
"nav.categorySelect": "{name} als aktive Ebene wählen (Zeichen-Ziel)",
|
||||||
|
|
||||||
|
// ── Badges (Art einer Zeichnungsebene) ───────────────────────────────────
|
||||||
|
"badge.floor": "Geschoss",
|
||||||
|
"badge.section": "Schnitt",
|
||||||
|
"badge.elevation": "Ansicht",
|
||||||
|
"badge.drawing": "Zeichnung",
|
||||||
|
|
||||||
|
// ── Darstellungsmodus-Umschalter ─────────────────────────────────────────
|
||||||
|
"display.title": "Darstellungsmodus",
|
||||||
|
"display.all_force": "Alle anzeigen",
|
||||||
|
"display.all": "Ausgewählte",
|
||||||
|
"display.active": "Nur aktive",
|
||||||
|
"display.grey": "Andere grau",
|
||||||
|
"display.grey_locked": "Andere grau & gesperrt",
|
||||||
|
|
||||||
|
// ── Dock ─────────────────────────────────────────────────────────────────
|
||||||
|
"dock.resize": "Breite anpassen",
|
||||||
|
"dock.dock": "Andocken",
|
||||||
|
"dock.close": "Schließen",
|
||||||
|
"dock.float": "Lösen",
|
||||||
|
"dock.redock": "Andocken",
|
||||||
|
"dock.resizePanel": "Größe ändern",
|
||||||
|
"dock.dropLeft": "Links andocken",
|
||||||
|
"dock.dropRight": "Rechts andocken",
|
||||||
|
|
||||||
|
// ── Kontextmenü (Einträge) ───────────────────────────────────────────────
|
||||||
|
"ctx.layerSettings": "Ebeneneinstellungen…",
|
||||||
|
"ctx.addSubLayer": "Sub-Ebene hinzufügen…",
|
||||||
|
"ctx.assignSelection": "Selektion hierher übertragen",
|
||||||
|
"ctx.assignSelection.disabled": "Keine Wand ausgewählt — zuerst im Grundriss/3D eine Wand anklicken.",
|
||||||
|
"ctx.duplicate": "Duplizieren",
|
||||||
|
"ctx.copyProps": "Eigenschaften kopieren",
|
||||||
|
"ctx.pasteProps": "Eigenschaften einfügen",
|
||||||
|
"ctx.pasteProps.disabled": "Zwischenablage leer — zuerst „Eigenschaften kopieren“.",
|
||||||
|
"ctx.delete": "Löschen",
|
||||||
|
"ctx.delete.lastLayer": "Die letzte Ebene kann nicht gelöscht werden.",
|
||||||
|
"ctx.delete.lastLevel": "Die letzte Zeichnungsebene kann nicht gelöscht werden.",
|
||||||
|
"ctx.levelSettings": "Einstellungen…",
|
||||||
|
"ctx.fitSelection": "Auf Auswahl einpassen",
|
||||||
|
"ctx.clearSelection": "Auswahl aufheben",
|
||||||
|
"ctx.fit": "Ansicht einpassen",
|
||||||
|
"ctx.noWallHit": "Keine Wand getroffen",
|
||||||
|
"ctx.noWallHit.hint": "Rechtsklick auf eine Wand wählt sie und zeigt Wand-Aktionen.",
|
||||||
|
|
||||||
|
// Kontextmenü-Titel je Quelle.
|
||||||
|
"ctx.title.level": "Zeichnungsebene",
|
||||||
|
"ctx.title.wall": "Wand",
|
||||||
|
"ctx.title.view": "Ansicht",
|
||||||
|
|
||||||
|
// ── Inline-Editor (Ebene / Geschoss) ─────────────────────────────────────
|
||||||
|
"editor.close": "Schließen",
|
||||||
|
"editor.layerTitle": "Ebene {code}",
|
||||||
|
"editor.name": "Name",
|
||||||
|
"editor.color": "Farbe",
|
||||||
|
"editor.lineWeight": "Linienstärke",
|
||||||
|
"editor.floorHeight": "Geschosshöhe",
|
||||||
|
"editor.cutHeight": "Schnitthöhe",
|
||||||
|
"editor.unit.mm": "mm",
|
||||||
|
"editor.unit.m": "m",
|
||||||
|
"editor.noHeightDrawing": "Für Zeichnungen gibt es keine Geschoss-/Schnitthöhe.",
|
||||||
|
"editor.noHeightSection": "Für Schnitte/Ansichten gibt es keine Geschoss-/Schnitthöhe.",
|
||||||
|
|
||||||
|
// ── Hauptinhalt (Panes / Eigenschaften) ──────────────────────────────────
|
||||||
|
"content.perspektiveTitle": "Perspektive · {name}",
|
||||||
|
"content.grundrissTitle": "Grundriss · {name}",
|
||||||
|
"content.grundrissLegend": "Grundriss · {name} · Schnitthöhe {cutHeight}",
|
||||||
|
"content.drawingTitle": "Zeichnung · {name}",
|
||||||
|
"content.drawingCanvas": "Zeichenfläche",
|
||||||
|
"content.drawingLegend": "Freie 2D-Zeichnung",
|
||||||
|
"props.floorHeight": "Geschosshöhe",
|
||||||
|
"props.cutHeight": "Schnitthöhe",
|
||||||
|
"props.okff": "OKFF",
|
||||||
|
"props.okffValue": "+{value} m",
|
||||||
|
|
||||||
|
// ── Stub (Schnitt / Ansicht) ─────────────────────────────────────────────
|
||||||
|
"stub.section": "Schnitt",
|
||||||
|
"stub.elevation": "Ansicht",
|
||||||
|
"stub.note": "{kind} — abgeleitete Projektion folgt in einer späteren Phase",
|
||||||
|
|
||||||
|
// ── Default-Namen neuer Elemente ─────────────────────────────────────────
|
||||||
|
"default.floorName": "Geschoss {n}",
|
||||||
|
"default.drawingName": "Zeichnung {n}",
|
||||||
|
"default.categoryName": "Neue Ebene",
|
||||||
|
"default.subCategoryName": "Neue Sub-Ebene",
|
||||||
|
"default.componentName": "Neues Bauteil",
|
||||||
|
"default.hatchName": "Neue Schraffur",
|
||||||
|
"default.lineStyleName": "Neuer Linienstil",
|
||||||
|
"default.hatchNone": "Ohne",
|
||||||
|
"default.copySuffixLevel": "{name} Kopie",
|
||||||
|
"default.copySuffixLayer": "{name} KOPIE",
|
||||||
|
"default.wallTypeSingle": "Wand {thickness} m",
|
||||||
|
|
||||||
|
// ── Warnungen (Lösch-Schutz) ─────────────────────────────────────────────
|
||||||
|
"alert.layerInUse": "Ebene „{code} {name}\" wird von Bauteilen verwendet und kann nicht gelöscht werden.",
|
||||||
|
"alert.componentInUse": "Bauteil „{name}\" wird von einem Wandtyp verwendet und kann nicht gelöscht werden.",
|
||||||
|
"alert.hatchInUse": "Schraffur „{name}\" wird von einem Bauteil verwendet und kann nicht gelöscht werden.",
|
||||||
|
"alert.lineStyleInUse": "Linienstil „{name}\" wird von einer Schraffur verwendet und kann nicht gelöscht werden.",
|
||||||
|
|
||||||
|
// ── Ressourcen-Manager ───────────────────────────────────────────────────
|
||||||
|
"resources.title": "Ressourcen",
|
||||||
|
"resources.label": "Ressourcen-Manager",
|
||||||
|
"resources.close": "Schließen",
|
||||||
|
"resources.tab.components": "Bauteile",
|
||||||
|
"resources.tab.hatches": "Schraffuren",
|
||||||
|
"resources.tab.lines": "Linien",
|
||||||
|
"resources.add": "Neu",
|
||||||
|
"resources.import.lin": "⤓ .lin importieren",
|
||||||
|
"resources.import.pat": "⤓ .pat importieren",
|
||||||
|
|
||||||
|
// Bauteile.
|
||||||
|
"resources.components.hint": "Höhere Priorität läuft am Stoß durch (verschneidet zuerst); niedrigere stößt seitlich an.",
|
||||||
|
"resources.components.empty": "Noch keine Bauteile.",
|
||||||
|
"resources.components.placeholder": "Bauteil",
|
||||||
|
"resources.col.name": "Name",
|
||||||
|
"resources.col.color": "Farbe",
|
||||||
|
"resources.col.hatch": "Schraffur",
|
||||||
|
"resources.col.prio": "Prio",
|
||||||
|
"resources.col.prio.hint": "Höhere Priorität läuft am Stoß durch",
|
||||||
|
"resources.col.texture": "Textur",
|
||||||
|
"resources.delete.component": "Bauteil „{name}\" löschen",
|
||||||
|
|
||||||
|
// Schraffuren.
|
||||||
|
"resources.hatches.hint": "Muster, Maßstab und Winkel steuern die Schnitt-Schraffur; der Linienstil bestimmt die Stärke der Musterlinien.",
|
||||||
|
"resources.hatches.empty": "Noch keine Schraffuren.",
|
||||||
|
"resources.hatches.placeholder": "Schraffur",
|
||||||
|
"resources.col.pattern": "Muster",
|
||||||
|
"resources.col.scale": "Maßstab",
|
||||||
|
"resources.col.angle": "Winkel °",
|
||||||
|
"resources.col.lineStyle": "Linienstil",
|
||||||
|
"resources.lineStyle.none": "— ohne —",
|
||||||
|
"resources.delete.hatch": "Schraffur „{name}\" löschen",
|
||||||
|
|
||||||
|
// Schraffur-Muster.
|
||||||
|
"pattern.none": "Ohne",
|
||||||
|
"pattern.solid": "Vollfläche",
|
||||||
|
"pattern.insulation": "Wärmedämmung",
|
||||||
|
"pattern.diagonal": "Diagonal",
|
||||||
|
"pattern.crosshatch": "Kreuzschraffur",
|
||||||
|
|
||||||
|
// Linien.
|
||||||
|
"resources.lines.hint": "Strichstärke in Millimetern (Paper-Space). Strichmuster: durchgezogen, gestrichelt oder gepunktet.",
|
||||||
|
"resources.lines.empty": "Noch keine Linienstile.",
|
||||||
|
"resources.lines.placeholder": "Linienstil",
|
||||||
|
"resources.col.weight": "Stärke (mm)",
|
||||||
|
"resources.col.stroke": "Strich",
|
||||||
|
"resources.col.preview": "Vorschau",
|
||||||
|
"resources.delete.lineStyle": "Linienstil „{name}\" löschen",
|
||||||
|
"resources.texture.empty": "—",
|
||||||
|
|
||||||
|
// Strichmuster.
|
||||||
|
"dash.solid": "Durchgezogen",
|
||||||
|
"dash.dashed": "Gestrichelt",
|
||||||
|
"dash.dotted": "Gepunktet",
|
||||||
|
|
||||||
|
// ── Command-Line / Befehle (Rhino-artiges Befehlssystem) ──────────────────
|
||||||
|
"cmd.aria": "Befehlszeile",
|
||||||
|
"cmd.idle.prompt": "Befehl:",
|
||||||
|
"cmd.idle.placeholder": "Befehl eingeben … (Tab)",
|
||||||
|
"cmd.line.label": "Linie",
|
||||||
|
"cmd.line.start": "Startpunkt der Linie:",
|
||||||
|
"cmd.line.end": "Endpunkt der Linie:",
|
||||||
|
"cmd.polyline.label": "Polylinie",
|
||||||
|
"cmd.polyline.start": "Startpunkt:",
|
||||||
|
"cmd.polyline.next": "Nächster Punkt:",
|
||||||
|
"cmd.polyline.close": "Schliessen",
|
||||||
|
"cmd.polyline.undo": "Zurück",
|
||||||
|
"cmd.rect.label": "Rechteck",
|
||||||
|
"cmd.rect.first": "Erste Ecke:",
|
||||||
|
"cmd.rect.second": "Gegenüberliegende Ecke:",
|
||||||
|
"cmd.circle.label": "Kreis",
|
||||||
|
"cmd.circle.center": "Mittelpunkt:",
|
||||||
|
"cmd.circle.radius": "Radius:",
|
||||||
|
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
|
||||||
|
"cmd.move.label": "Verschieben",
|
||||||
|
"cmd.move.base": "Basispunkt:",
|
||||||
|
"cmd.move.target": "Zielpunkt:",
|
||||||
|
"cmd.move.empty": "Nichts gewählt (erst Objekte wählen).",
|
||||||
|
"cmd.copy.label": "Kopieren",
|
||||||
|
"cmd.copy.base": "Basispunkt:",
|
||||||
|
"cmd.copy.target": "Zielpunkt (Enter beendet):",
|
||||||
|
"cmd.copy.empty": "Nichts gewählt (erst Objekte wählen).",
|
||||||
|
"cmd.offset.label": "Versatz",
|
||||||
|
"cmd.offset.pick": "Kurve wählen:",
|
||||||
|
"cmd.offset.side": "Seite oder Distanz:",
|
||||||
|
// Editier-Werkzeuge Split / Join / Segment-Löschen (Tastatur: Ctrl+S / Ctrl+J /
|
||||||
|
// Alt+Klick). Labels für Befehlsleiste/Menüs.
|
||||||
|
"edit.split.label": "Teilen",
|
||||||
|
"edit.join.label": "Verbinden",
|
||||||
|
"edit.cutSegment.label": "Segment löschen",
|
||||||
|
// Tab-Feld-Zyklus (Präzisionseingabe): geordnete Zahlenfelder je Schritt.
|
||||||
|
"cmd.field.length": "Länge",
|
||||||
|
"cmd.field.angle": "Winkel",
|
||||||
|
"cmd.field.width": "Breite",
|
||||||
|
"cmd.field.height": "Höhe",
|
||||||
|
"cmd.field.radius": "Radius",
|
||||||
|
"cmd.field.distance": "Distanz",
|
||||||
|
|
||||||
|
// ── Befehle: Import / Gelände ────────────────────────────────────────────
|
||||||
|
"cmd.import.label": "Importieren",
|
||||||
|
"cmd.import.prompt": "DXF-Datei wählen …",
|
||||||
|
"cmd.terrain.label": "Gelände",
|
||||||
|
"cmd.terrain.prompt": "Gelände aus Konturen erzeugen …",
|
||||||
|
|
||||||
|
// ── Site-/Kontext-Panel (SitePanel) ──────────────────────────────────────
|
||||||
|
"nav.site": "Gelände",
|
||||||
|
"site.title": "Gelände & Kontext",
|
||||||
|
"site.import": "⤓ DXF importieren",
|
||||||
|
"site.generateTerrain": "Gelände erzeugen",
|
||||||
|
"site.contourSet": "Kontur-Satz",
|
||||||
|
"site.empty": "Kein Kontext geladen. Importiere eine DXF-Datei (Höhenlinien oder Mesh).",
|
||||||
|
"site.delete": "Entfernen",
|
||||||
|
"site.type.mesh": "Mesh",
|
||||||
|
"site.type.contours": "Konturen",
|
||||||
|
"site.type.terrain": "Gelände",
|
||||||
|
"site.dwgHint": "DWG? Zuerst mit dem gratis ODA File Converter in DXF umwandeln.",
|
||||||
|
"site.terrainFailed": "Kein Gelände erzeugbar (zu wenige/kollineare Konturpunkte).",
|
||||||
|
|
||||||
|
// ── Import-Dialog (ImportDialog) ─────────────────────────────────────────
|
||||||
|
"import.title": "DXF importieren",
|
||||||
|
"import.close": "Schließen",
|
||||||
|
"import.file": "Datei",
|
||||||
|
"import.summary.heading": "Inhalt",
|
||||||
|
"import.summary.contours": "{n} Konturen",
|
||||||
|
"import.summary.meshes": "{n} Meshes",
|
||||||
|
"import.summary.layers": "Layer: {layers}",
|
||||||
|
"import.summary.noLayers": "keine Layer-Namen",
|
||||||
|
"import.summary.empty": "Keine importierbare 2D-Geometrie gefunden.",
|
||||||
|
"import.target.heading": "Ziel-Zeichnungsebene",
|
||||||
|
"import.target.existing": "In bestehende Zeichnungsebene importieren",
|
||||||
|
"import.target.new": "Neue Zeichnung anlegen",
|
||||||
|
"import.target.newNamePlaceholder": "Name der neuen Zeichnung",
|
||||||
|
"import.layers.heading": "Ebenen-Handling (DXF-Layer)",
|
||||||
|
"import.layers.asCategories": "DXF-Layer als Kategorien übernehmen",
|
||||||
|
"import.layers.toActive": "Alles auf die aktive Kategorie ({code})",
|
||||||
|
"import.mesh.heading": "Meshes",
|
||||||
|
"import.mesh.asContext": "Meshes als Kontext-Geometrie (3D) importieren",
|
||||||
|
"import.mesh.note": "Meshes sind keine 2D-Zeichnung — sie landen in der Kontext-Schicht.",
|
||||||
|
"import.dwg.heading": "DWG-Datei",
|
||||||
|
"import.dwg.loading": "DWG wird gelesen …",
|
||||||
|
"import.dwg.hint":
|
||||||
|
"DWG konnte nicht gelesen werden. Wandle die Datei alternativ mit dem gratis ODA File Converter in DXF um und importiere die DXF-Datei.",
|
||||||
|
"import.confirm": "Importieren",
|
||||||
|
"import.cancel": "Abbrechen",
|
||||||
|
"import.badge.floor": "Geschoss",
|
||||||
|
"import.badge.section": "Schnitt",
|
||||||
|
"import.badge.elevation": "Ansicht",
|
||||||
|
"import.badge.drawing": "Zeichnung",
|
||||||
|
|
||||||
|
// ── Drag & Drop (App-weiter Datei-Drop) ──────────────────────────────────
|
||||||
|
"drop.hint": "DXF/DWG-Datei hier ablegen",
|
||||||
|
|
||||||
|
// ── Fehler-Boundary ──────────────────────────────────────────────────────
|
||||||
|
"error.console": "Unerwarteter Fehler:",
|
||||||
|
"error.title": "Etwas ist schiefgelaufen",
|
||||||
|
"error.body": "Ein unerwarteter Fehler ist aufgetreten. Details in der Konsole.",
|
||||||
|
"error.reload": "Neu laden",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TranslationKey = keyof typeof de;
|
||||||
+490
@@ -0,0 +1,490 @@
|
|||||||
|
// English dictionary — proves translatability. Same keys as de.ts; only the
|
||||||
|
// values differ. Identifiers/keys stay English (DOSSIER namespacing); values are
|
||||||
|
// the translated, user-visible text.
|
||||||
|
|
||||||
|
import type { TranslationKey } from "./de";
|
||||||
|
|
||||||
|
export const en: Record<TranslationKey, string> = {
|
||||||
|
// Brand / Topbar.
|
||||||
|
"brand.tag": "Document model",
|
||||||
|
|
||||||
|
"topbar.viewGroup": "View",
|
||||||
|
"topbar.grundriss": "Plan",
|
||||||
|
"topbar.perspektive": "Perspective",
|
||||||
|
|
||||||
|
"view3d.group": "3D view",
|
||||||
|
"view3d.front": "Front",
|
||||||
|
"view3d.front.hint": "Front view (frontal)",
|
||||||
|
"view3d.top": "Top",
|
||||||
|
"view3d.top.hint": "Top view (straight down)",
|
||||||
|
"view3d.side": "Side",
|
||||||
|
"view3d.side.hint": "Side view (90° to front)",
|
||||||
|
"view3d.perspective": "Perspective",
|
||||||
|
"view3d.perspective.hint": "Free perspective (3/4 view)",
|
||||||
|
"view3d.iso": "Isometric",
|
||||||
|
"view3d.iso.hint": "Isometric view (equiangular from front-top-right)",
|
||||||
|
"view3d.camera": "Camera",
|
||||||
|
"view3d.camera.hint": "Camera settings (field of view)",
|
||||||
|
"view3d.fov": "Field of view",
|
||||||
|
"view3d.fov.hint": "Field of view (FOV) of the perspective camera in degrees",
|
||||||
|
|
||||||
|
"topbar.detailGroup": "Detail level",
|
||||||
|
"topbar.detail": "Detail level",
|
||||||
|
"topbar.detail.hint": "Detail level of the plan (symbols, hatches, lines)",
|
||||||
|
"topbar.detail.hintDisabled": "Detail level applies in the plan (not yet in perspective)",
|
||||||
|
"topbar.detail.grob": "coarse",
|
||||||
|
"topbar.detail.mittel": "medium",
|
||||||
|
"topbar.detail.fein": "fine",
|
||||||
|
|
||||||
|
"topbar.scaleGroup": "Scale and zoom",
|
||||||
|
"topbar.scale": "Scale",
|
||||||
|
"topbar.scale.apply": "Apply paper scale 1:N",
|
||||||
|
"topbar.scale.custom": "custom …",
|
||||||
|
"topbar.scale.live": "Current paper scale (live)",
|
||||||
|
"topbar.scale.prompt": "Enter scale (e.g. 1:35 or 35):",
|
||||||
|
"topbar.zoom100": "100%",
|
||||||
|
"topbar.zoom100.hint": "Zoom exactly to the chosen scale 1:{n}",
|
||||||
|
"topbar.fit": "Fit",
|
||||||
|
"topbar.fit.hint": "Fit to plan",
|
||||||
|
"topbar.fitSelection": "Selection",
|
||||||
|
"topbar.fitSelection.hint": "Fit to selection (otherwise to plan)",
|
||||||
|
"topbar.zoom.current": "Current zoom",
|
||||||
|
|
||||||
|
"topbar.renderGroup": "Render mode",
|
||||||
|
"topbar.render.shaded": "Shaded",
|
||||||
|
"topbar.render.shaded.hint": "Shaded",
|
||||||
|
"topbar.render.wireframe": "Wire",
|
||||||
|
"topbar.render.wireframe.hint": "Wireframe",
|
||||||
|
"topbar.render.hidden": "Edges",
|
||||||
|
"topbar.render.hidden.hint": "Hidden line",
|
||||||
|
"topbar.render.hintDisabled": "Render mode applies in perspective",
|
||||||
|
// Display style (context-dependent dropdown): plan = Color/Mono,
|
||||||
|
// perspective = Shaded/White/Wireframe/Hidden.
|
||||||
|
"display.style": "Display",
|
||||||
|
"display.style.group": "Display style",
|
||||||
|
"display.style.hint": "Display style of the active view",
|
||||||
|
"display.style.color": "Color",
|
||||||
|
"display.style.mono": "Black & white",
|
||||||
|
"display.style.shaded": "Shaded",
|
||||||
|
"display.style.white": "White",
|
||||||
|
"display.style.wireframe": "Wireframe",
|
||||||
|
"display.style.hidden": "Hidden line",
|
||||||
|
|
||||||
|
"topbar.referenceLinesGroup": "Reference lines",
|
||||||
|
"topbar.referenceLines": "Reference lines",
|
||||||
|
"topbar.referenceLines.hint": "Show/hide reference lines (wall axes)",
|
||||||
|
"topbar.referenceLines.hintDisabled": "Reference lines apply in the plan",
|
||||||
|
|
||||||
|
"topbar.resources": "Resources",
|
||||||
|
"topbar.resources.hint": "Show/hide the resources window",
|
||||||
|
|
||||||
|
// Layout menu.
|
||||||
|
"layout.title": "Layout",
|
||||||
|
"layout.placeholder": "Layout …",
|
||||||
|
"layout.saveCurrent": "+ Save current …",
|
||||||
|
"layout.loadGroup": "Load",
|
||||||
|
"layout.deleteGroup": "Delete",
|
||||||
|
"layout.savePrompt": "Save layout as:",
|
||||||
|
|
||||||
|
// Visibility combinations (topbar).
|
||||||
|
"combo.layers": "Layer comb.",
|
||||||
|
"combo.drawings": "Drawing comb.",
|
||||||
|
"combo.placeholder": "Combination …",
|
||||||
|
"combo.saveCurrent": "+ Save current …",
|
||||||
|
"combo.loadGroup": "Load",
|
||||||
|
"combo.deleteGroup": "Delete",
|
||||||
|
"combo.layers.title": "Layer combinations",
|
||||||
|
"combo.drawings.title": "Drawing combinations",
|
||||||
|
"combo.layers.savePrompt": "Save layer combination as:",
|
||||||
|
"combo.drawings.savePrompt": "Save drawing combination as:",
|
||||||
|
|
||||||
|
// Drawing tools (topbar toolbar + status hints).
|
||||||
|
"tool.group": "Tools",
|
||||||
|
"tool.select": "Select",
|
||||||
|
"tool.wall": "Wall",
|
||||||
|
"tool.line": "Line",
|
||||||
|
"tool.polyline": "Polyline",
|
||||||
|
"tool.rect": "Rectangle",
|
||||||
|
"tool.select.hint": "Select — click/drag elements",
|
||||||
|
"tool.wall.firstPoint": "Wall: set first axis point",
|
||||||
|
"tool.wall.nextPoint": "Wall: next point — double/right-click to finish",
|
||||||
|
"tool.line.firstPoint": "Line: set start point",
|
||||||
|
"tool.line.secondPoint": "Line: set end point",
|
||||||
|
"tool.polyline.firstPoint": "Polyline: set first point",
|
||||||
|
"tool.polyline.nextPoint": "Polyline: next point — click start to close, double-click to finish",
|
||||||
|
"tool.rect.firstCorner": "Rectangle: set first corner",
|
||||||
|
"tool.rect.secondCorner": "Rectangle: set opposite corner",
|
||||||
|
"tool.wallType": "Wall type",
|
||||||
|
"tool.layer": "Layer",
|
||||||
|
"tool.layer.hint": "Active layer — everything drawn goes onto this category",
|
||||||
|
"tool.floorOnlyDisabled": "Walls can only be drawn on a floor",
|
||||||
|
"tool.snap": "Snap",
|
||||||
|
"tool.snap.hint": "Snap to endpoints/grid; Shift = ortho, Ctrl = snap off",
|
||||||
|
"snap.enabled": "Snap on",
|
||||||
|
"snap.endpoint": "Endpoint",
|
||||||
|
"snap.midpoint": "Midpoint",
|
||||||
|
"snap.intersection": "Intersection",
|
||||||
|
"snap.onEdge": "On edge (perp.)",
|
||||||
|
"snap.grid": "Grid",
|
||||||
|
"snap.ortho": "Ortho (Shift)",
|
||||||
|
"snap.gridSize": "Grid size (m)",
|
||||||
|
"snap.angleStep": "Angle step",
|
||||||
|
|
||||||
|
// Transform (move/mirror/rotate) + mode bar.
|
||||||
|
"transform.bar": "Transform",
|
||||||
|
"transform.cancel": "Cancel (Esc)",
|
||||||
|
"transform.countPrompt": "Number of copies:",
|
||||||
|
"transform.move": "Move (M)",
|
||||||
|
"transform.mirror": "Mirror (S)",
|
||||||
|
"transform.rotate": "Rotate (D)",
|
||||||
|
"transform.mode.move": "Move",
|
||||||
|
"transform.mode.copy": "Copy",
|
||||||
|
"transform.mode.array": "N copies",
|
||||||
|
"transform.mode.distribute": "Distribute",
|
||||||
|
"transform.hint.move.0": "Click base point",
|
||||||
|
"transform.hint.move.1": "Click destination point",
|
||||||
|
"transform.hint.rotate.0": "Click rotation centre",
|
||||||
|
"transform.hint.rotate.1": "Click reference point (start angle)",
|
||||||
|
"transform.hint.rotate.2": "Click target angle",
|
||||||
|
"transform.hint.mirror.0": "First point of mirror axis",
|
||||||
|
"transform.hint.mirror.1": "Second point of mirror axis",
|
||||||
|
|
||||||
|
// Line display (Display/Print).
|
||||||
|
"topbar.lineModeGroup": "Line display",
|
||||||
|
"topbar.lineMode.display": "Display",
|
||||||
|
"topbar.lineMode.print": "Print",
|
||||||
|
"topbar.lineMode.display.hint": "Display: all lines as constant hairlines (calm editing)",
|
||||||
|
"topbar.lineMode.print.hint": "Print: real mm line weights (paper/print look)",
|
||||||
|
|
||||||
|
// Status bar.
|
||||||
|
"status.selection": "Selection",
|
||||||
|
"status.selectionN": "Selection: {n} {noun}",
|
||||||
|
"status.wall": "wall",
|
||||||
|
"status.walls": "walls",
|
||||||
|
"status.xy": "X/Y",
|
||||||
|
"status.unit": "Unit",
|
||||||
|
"status.scale": "Scale",
|
||||||
|
"status.zoom": "Zoom",
|
||||||
|
"status.floor": "Floor",
|
||||||
|
"status.layer": "Layer",
|
||||||
|
"status.none": "—",
|
||||||
|
|
||||||
|
// Navigator panels.
|
||||||
|
"nav.drawingLevels": "Drawing levels",
|
||||||
|
"nav.layers": "Layers",
|
||||||
|
"nav.tools": "Tools",
|
||||||
|
"nav.attributes": "Attributes",
|
||||||
|
"nav.objectInfo": "Object Info",
|
||||||
|
|
||||||
|
// Attribute-Palette (AttributesPanel)
|
||||||
|
"attr.title": "Attributes",
|
||||||
|
"attr.empty": "No selection",
|
||||||
|
"attr.stroke": "Pen",
|
||||||
|
"attr.color": "Color",
|
||||||
|
"attr.weight": "Line weight (mm)",
|
||||||
|
"attr.lineStyle": "Line style",
|
||||||
|
"attr.fill": "Fill",
|
||||||
|
"attr.fillColor": "Fill color",
|
||||||
|
"attr.clearFill": "Remove fill color",
|
||||||
|
"attr.hatch": "Hatch",
|
||||||
|
"attr.none": "none",
|
||||||
|
"attr.inheritedFromLayer": "Walls inherit line weight from the layer",
|
||||||
|
"attr.fillNeedsClosed": "Fill only for closed shapes",
|
||||||
|
"attr.size": "Size",
|
||||||
|
"attr.width": "Width",
|
||||||
|
"attr.height": "Height",
|
||||||
|
|
||||||
|
// Objekt-Info-Palette (ObjectInfoPanel)
|
||||||
|
"objinfo.title": "Object Info",
|
||||||
|
"objinfo.empty": "No object selected",
|
||||||
|
"objinfo.refpoint": "Reference point",
|
||||||
|
"objinfo.setRefpoint": "Set reference point",
|
||||||
|
"objinfo.x": "X",
|
||||||
|
"objinfo.y": "Y",
|
||||||
|
"objinfo.z": "Z",
|
||||||
|
"objinfo.zHint": "2D layer – elements have no height",
|
||||||
|
"objinfo.dimensions": "Dimensions",
|
||||||
|
"objinfo.width": "Width",
|
||||||
|
"objinfo.height": "Height",
|
||||||
|
"objinfo.type": "Type",
|
||||||
|
"objinfo.kind.wall": "Wall",
|
||||||
|
"objinfo.kind.drawing2d": "2D object",
|
||||||
|
"objinfo.kind.door": "Door",
|
||||||
|
// ── Wall attributes (object info panel) ──────────────────────────────────
|
||||||
|
"objinfo.wall.section": "Wall",
|
||||||
|
"objinfo.wall.refLine": "Reference line",
|
||||||
|
"objinfo.wall.refLine.left": "Exterior (left)",
|
||||||
|
"objinfo.wall.refLine.center": "Center",
|
||||||
|
"objinfo.wall.refLine.right": "Interior (right)",
|
||||||
|
"objinfo.wall.buildup": "Build-up",
|
||||||
|
"objinfo.wall.single": "Single layer",
|
||||||
|
"objinfo.wall.multi": "Multi layer",
|
||||||
|
"objinfo.wall.thickness": "Thickness",
|
||||||
|
"objinfo.wall.preset": "Wall style",
|
||||||
|
"objinfo.wall.presetLabel": "{name} · {thickness} m",
|
||||||
|
"objinfo.wall.refFloor": "Reference floor",
|
||||||
|
"objinfo.wall.linkAbove": "Bind to floor above",
|
||||||
|
"objinfo.wall.bottom": "Bottom (UK)",
|
||||||
|
"objinfo.wall.top": "Top (OK)",
|
||||||
|
"objinfo.wall.anchorFloor": "Bound to floor",
|
||||||
|
"objinfo.wall.anchorCustom": "Custom height",
|
||||||
|
"objinfo.wall.height": "Height",
|
||||||
|
"objinfo.wall.floorAboveNone": "No floor above",
|
||||||
|
"levels.addFloor": "+ Floor",
|
||||||
|
"levels.addDrawing": "+ Drawing",
|
||||||
|
"layers.addCategory": "+ Layer",
|
||||||
|
"nav.visibility": "Visibility",
|
||||||
|
"nav.hide": "Hide",
|
||||||
|
"nav.show": "Show",
|
||||||
|
"nav.categoryVisible": "{name} visible",
|
||||||
|
"nav.categorySelect": "Make {name} the active layer (drawing target)",
|
||||||
|
|
||||||
|
// Badges.
|
||||||
|
"badge.floor": "Floor",
|
||||||
|
"badge.section": "Section",
|
||||||
|
"badge.elevation": "Elevation",
|
||||||
|
"badge.drawing": "Drawing",
|
||||||
|
|
||||||
|
// Display mode select.
|
||||||
|
"display.title": "Display mode",
|
||||||
|
"display.all_force": "Show all",
|
||||||
|
"display.all": "Selected",
|
||||||
|
"display.active": "Active only",
|
||||||
|
"display.grey": "Others grey",
|
||||||
|
"display.grey_locked": "Others grey & locked",
|
||||||
|
|
||||||
|
// Dock.
|
||||||
|
"dock.resize": "Adjust width",
|
||||||
|
"dock.dock": "Dock",
|
||||||
|
"dock.close": "Close",
|
||||||
|
"dock.float": "Float",
|
||||||
|
"dock.redock": "Dock",
|
||||||
|
"dock.resizePanel": "Resize",
|
||||||
|
"dock.dropLeft": "Dock left",
|
||||||
|
"dock.dropRight": "Dock right",
|
||||||
|
|
||||||
|
// Context menu.
|
||||||
|
"ctx.layerSettings": "Layer settings…",
|
||||||
|
"ctx.addSubLayer": "Add sub-layer…",
|
||||||
|
"ctx.assignSelection": "Assign selection here",
|
||||||
|
"ctx.assignSelection.disabled": "No wall selected — first click a wall in the plan/3D.",
|
||||||
|
"ctx.duplicate": "Duplicate",
|
||||||
|
"ctx.copyProps": "Copy properties",
|
||||||
|
"ctx.pasteProps": "Paste properties",
|
||||||
|
"ctx.pasteProps.disabled": "Clipboard empty — first use “Copy properties”.",
|
||||||
|
"ctx.delete": "Delete",
|
||||||
|
"ctx.delete.lastLayer": "The last layer cannot be deleted.",
|
||||||
|
"ctx.delete.lastLevel": "The last drawing level cannot be deleted.",
|
||||||
|
"ctx.levelSettings": "Settings…",
|
||||||
|
"ctx.fitSelection": "Fit to selection",
|
||||||
|
"ctx.clearSelection": "Clear selection",
|
||||||
|
"ctx.fit": "Fit view",
|
||||||
|
"ctx.noWallHit": "No wall hit",
|
||||||
|
"ctx.noWallHit.hint": "Right-clicking a wall selects it and shows wall actions.",
|
||||||
|
|
||||||
|
"ctx.title.level": "Drawing level",
|
||||||
|
"ctx.title.wall": "Wall",
|
||||||
|
"ctx.title.view": "View",
|
||||||
|
|
||||||
|
// Inline editor.
|
||||||
|
"editor.close": "Close",
|
||||||
|
"editor.layerTitle": "Layer {code}",
|
||||||
|
"editor.name": "Name",
|
||||||
|
"editor.color": "Color",
|
||||||
|
"editor.lineWeight": "Line weight",
|
||||||
|
"editor.floorHeight": "Floor height",
|
||||||
|
"editor.cutHeight": "Cut height",
|
||||||
|
"editor.unit.mm": "mm",
|
||||||
|
"editor.unit.m": "m",
|
||||||
|
"editor.noHeightDrawing": "Drawings have no floor/cut height.",
|
||||||
|
"editor.noHeightSection": "Sections/elevations have no floor/cut height.",
|
||||||
|
|
||||||
|
// Main content.
|
||||||
|
"content.perspektiveTitle": "Perspective · {name}",
|
||||||
|
"content.grundrissTitle": "Plan · {name}",
|
||||||
|
"content.grundrissLegend": "Plan · {name} · Cut height {cutHeight}",
|
||||||
|
"content.drawingTitle": "Drawing · {name}",
|
||||||
|
"content.drawingCanvas": "Drawing canvas",
|
||||||
|
"content.drawingLegend": "Free 2D drawing",
|
||||||
|
"props.floorHeight": "Floor height",
|
||||||
|
"props.cutHeight": "Cut height",
|
||||||
|
"props.okff": "FFL",
|
||||||
|
"props.okffValue": "+{value} m",
|
||||||
|
|
||||||
|
// Stub.
|
||||||
|
"stub.section": "Section",
|
||||||
|
"stub.elevation": "Elevation",
|
||||||
|
"stub.note": "{kind} — derived projection follows in a later phase",
|
||||||
|
|
||||||
|
// Default names.
|
||||||
|
"default.floorName": "Floor {n}",
|
||||||
|
"default.drawingName": "Drawing {n}",
|
||||||
|
"default.categoryName": "New layer",
|
||||||
|
"default.subCategoryName": "New sub-layer",
|
||||||
|
"default.componentName": "New component",
|
||||||
|
"default.hatchName": "New hatch",
|
||||||
|
"default.lineStyleName": "New line style",
|
||||||
|
"default.hatchNone": "None",
|
||||||
|
"default.copySuffixLevel": "{name} copy",
|
||||||
|
"default.copySuffixLayer": "{name} COPY",
|
||||||
|
"default.wallTypeSingle": "Wall {thickness} m",
|
||||||
|
|
||||||
|
// Alerts.
|
||||||
|
"alert.layerInUse": "Layer “{code} {name}” is used by components and cannot be deleted.",
|
||||||
|
"alert.componentInUse": "Component “{name}” is used by a wall type and cannot be deleted.",
|
||||||
|
"alert.hatchInUse": "Hatch “{name}” is used by a component and cannot be deleted.",
|
||||||
|
"alert.lineStyleInUse": "Line style “{name}” is used by a hatch and cannot be deleted.",
|
||||||
|
|
||||||
|
// Resource manager.
|
||||||
|
"resources.title": "Resources",
|
||||||
|
"resources.label": "Resource manager",
|
||||||
|
"resources.close": "Close",
|
||||||
|
"resources.tab.components": "Components",
|
||||||
|
"resources.tab.hatches": "Hatches",
|
||||||
|
"resources.tab.lines": "Lines",
|
||||||
|
"resources.add": "New",
|
||||||
|
"resources.import.lin": "⤓ Import .lin",
|
||||||
|
"resources.import.pat": "⤓ Import .pat",
|
||||||
|
|
||||||
|
"resources.components.hint": "Higher priority runs through at the join (intersects first); lower butts against the side.",
|
||||||
|
"resources.components.empty": "No components yet.",
|
||||||
|
"resources.components.placeholder": "Component",
|
||||||
|
"resources.col.name": "Name",
|
||||||
|
"resources.col.color": "Color",
|
||||||
|
"resources.col.hatch": "Hatch",
|
||||||
|
"resources.col.prio": "Prio",
|
||||||
|
"resources.col.prio.hint": "Higher priority runs through at the join",
|
||||||
|
"resources.col.texture": "Texture",
|
||||||
|
"resources.delete.component": "Delete component “{name}”",
|
||||||
|
|
||||||
|
"resources.hatches.hint": "Pattern, scale and angle drive the section hatch; the line style sets the weight of the pattern lines.",
|
||||||
|
"resources.hatches.empty": "No hatches yet.",
|
||||||
|
"resources.hatches.placeholder": "Hatch",
|
||||||
|
"resources.col.pattern": "Pattern",
|
||||||
|
"resources.col.scale": "Scale",
|
||||||
|
"resources.col.angle": "Angle °",
|
||||||
|
"resources.col.lineStyle": "Line style",
|
||||||
|
"resources.lineStyle.none": "— none —",
|
||||||
|
"resources.delete.hatch": "Delete hatch “{name}”",
|
||||||
|
|
||||||
|
"pattern.none": "None",
|
||||||
|
"pattern.solid": "Solid",
|
||||||
|
"pattern.insulation": "Insulation",
|
||||||
|
"pattern.diagonal": "Diagonal",
|
||||||
|
"pattern.crosshatch": "Crosshatch",
|
||||||
|
|
||||||
|
"resources.lines.hint": "Line weight in millimetres (paper space). Stroke: solid, dashed or dotted.",
|
||||||
|
"resources.lines.empty": "No line styles yet.",
|
||||||
|
"resources.lines.placeholder": "Line style",
|
||||||
|
"resources.col.weight": "Weight (mm)",
|
||||||
|
"resources.col.stroke": "Stroke",
|
||||||
|
"resources.col.preview": "Preview",
|
||||||
|
"resources.delete.lineStyle": "Delete line style “{name}”",
|
||||||
|
"resources.texture.empty": "—",
|
||||||
|
|
||||||
|
"dash.solid": "Solid",
|
||||||
|
"dash.dashed": "Dashed",
|
||||||
|
"dash.dotted": "Dotted",
|
||||||
|
|
||||||
|
// Command line / commands (Rhino-style command system).
|
||||||
|
"cmd.aria": "Command line",
|
||||||
|
"cmd.idle.prompt": "Command:",
|
||||||
|
"cmd.idle.placeholder": "Type a command … (Tab)",
|
||||||
|
"cmd.line.label": "Line",
|
||||||
|
"cmd.line.start": "Start of line:",
|
||||||
|
"cmd.line.end": "End of line:",
|
||||||
|
"cmd.polyline.label": "Polyline",
|
||||||
|
"cmd.polyline.start": "Start point:",
|
||||||
|
"cmd.polyline.next": "Next point:",
|
||||||
|
"cmd.polyline.close": "Close",
|
||||||
|
"cmd.polyline.undo": "Undo",
|
||||||
|
"cmd.rect.label": "Rectangle",
|
||||||
|
"cmd.rect.first": "First corner:",
|
||||||
|
"cmd.rect.second": "Opposite corner:",
|
||||||
|
"cmd.circle.label": "Circle",
|
||||||
|
"cmd.circle.center": "Center:",
|
||||||
|
"cmd.circle.radius": "Radius:",
|
||||||
|
"cmd.move.label": "Move",
|
||||||
|
"cmd.move.base": "Base point:",
|
||||||
|
"cmd.move.target": "Target point:",
|
||||||
|
"cmd.move.empty": "Nothing selected (select objects first).",
|
||||||
|
"cmd.copy.label": "Copy",
|
||||||
|
"cmd.copy.base": "Base point:",
|
||||||
|
"cmd.copy.target": "Target point (Enter to finish):",
|
||||||
|
"cmd.copy.empty": "Nothing selected (select objects first).",
|
||||||
|
"cmd.offset.label": "Offset",
|
||||||
|
"cmd.offset.pick": "Select curve:",
|
||||||
|
"cmd.offset.side": "Side or distance:",
|
||||||
|
// Edit tools Split / Join / Delete segment (keyboard: Ctrl+S / Ctrl+J /
|
||||||
|
// Alt+Click). Labels for the command line / menus.
|
||||||
|
"edit.split.label": "Split",
|
||||||
|
"edit.join.label": "Join",
|
||||||
|
"edit.cutSegment.label": "Delete segment",
|
||||||
|
// Tab field cycle (precision input): ordered numeric fields per step.
|
||||||
|
"cmd.field.length": "Length",
|
||||||
|
"cmd.field.angle": "Angle",
|
||||||
|
"cmd.field.width": "Width",
|
||||||
|
"cmd.field.height": "Height",
|
||||||
|
"cmd.field.radius": "Radius",
|
||||||
|
"cmd.field.distance": "Distance",
|
||||||
|
|
||||||
|
// Commands: import / terrain.
|
||||||
|
"cmd.import.label": "Import",
|
||||||
|
"cmd.import.prompt": "Choose DXF file …",
|
||||||
|
"cmd.terrain.label": "Terrain",
|
||||||
|
"cmd.terrain.prompt": "Generate terrain from contours …",
|
||||||
|
|
||||||
|
// Site / context panel (SitePanel).
|
||||||
|
"nav.site": "Terrain",
|
||||||
|
"site.title": "Terrain & Context",
|
||||||
|
"site.import": "⤓ Import DXF",
|
||||||
|
"site.generateTerrain": "Generate terrain",
|
||||||
|
"site.contourSet": "Contour set",
|
||||||
|
"site.empty": "No context loaded. Import a DXF file (contour lines or mesh).",
|
||||||
|
"site.delete": "Remove",
|
||||||
|
"site.type.mesh": "Mesh",
|
||||||
|
"site.type.contours": "Contours",
|
||||||
|
"site.type.terrain": "Terrain",
|
||||||
|
"site.dwgHint": "DWG? Convert it to DXF first with the free ODA File Converter.",
|
||||||
|
"site.terrainFailed": "No terrain could be generated (too few/collinear contour points).",
|
||||||
|
|
||||||
|
// Import dialog.
|
||||||
|
"import.title": "Import DXF",
|
||||||
|
"import.close": "Close",
|
||||||
|
"import.file": "File",
|
||||||
|
"import.summary.heading": "Contents",
|
||||||
|
"import.summary.contours": "{n} contours",
|
||||||
|
"import.summary.meshes": "{n} meshes",
|
||||||
|
"import.summary.layers": "Layers: {layers}",
|
||||||
|
"import.summary.noLayers": "no layer names",
|
||||||
|
"import.summary.empty": "No importable 2D geometry found.",
|
||||||
|
"import.target.heading": "Target drawing level",
|
||||||
|
"import.target.existing": "Import into existing drawing level",
|
||||||
|
"import.target.new": "Create new drawing",
|
||||||
|
"import.target.newNamePlaceholder": "New drawing name",
|
||||||
|
"import.layers.heading": "Layer handling (DXF layers)",
|
||||||
|
"import.layers.asCategories": "Keep DXF layers as categories",
|
||||||
|
"import.layers.toActive": "Everything onto the active category ({code})",
|
||||||
|
"import.mesh.heading": "Meshes",
|
||||||
|
"import.mesh.asContext": "Import meshes as context geometry (3D)",
|
||||||
|
"import.mesh.note": "Meshes are not 2D drawings — they go into the context layer.",
|
||||||
|
"import.dwg.heading": "DWG file",
|
||||||
|
"import.dwg.loading": "Reading DWG …",
|
||||||
|
"import.dwg.hint":
|
||||||
|
"DWG could not be read. Alternatively, convert the file to DXF with the free ODA File Converter, then import the DXF.",
|
||||||
|
"import.confirm": "Import",
|
||||||
|
"import.cancel": "Cancel",
|
||||||
|
"import.badge.floor": "Floor",
|
||||||
|
"import.badge.section": "Section",
|
||||||
|
"import.badge.elevation": "Elevation",
|
||||||
|
"import.badge.drawing": "Drawing",
|
||||||
|
|
||||||
|
// Drag & drop (app-wide file drop).
|
||||||
|
"drop.hint": "Drop a DXF/DWG file here",
|
||||||
|
|
||||||
|
// Error boundary.
|
||||||
|
"error.console": "Unexpected error:",
|
||||||
|
"error.title": "Something went wrong",
|
||||||
|
"error.body": "An unexpected error occurred. Details in the console.",
|
||||||
|
"error.reload": "Reload",
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// i18n-Schicht — eine einzige Übersetzungsfunktion `t(key, params?)` plus ein
|
||||||
|
// optionaler React-Hook `useT`. Default-Sprache ist Deutsch; das deutsche
|
||||||
|
// Wörterbuch (de.ts) hält die EXAKTEN bisherigen UI-Texte, sodass die Umstellung
|
||||||
|
// optisch nichts ändert. en.ts beweist die Übersetzbarkeit.
|
||||||
|
//
|
||||||
|
// Bewusst minimal gehalten: ein modulweiter „current language" + Wörterbuch-
|
||||||
|
// Lookup mit {param}-Interpolation. Kein Provider-Zwang — t(...) ist überall
|
||||||
|
// direkt aufrufbar; useT() liefert dieselbe Funktion und re-rendert Komponenten
|
||||||
|
// beim Sprachwechsel (subscribe), falls je ein Umschalter gebaut wird.
|
||||||
|
//
|
||||||
|
// Identifier/Keys bleiben englisch (DOSSIER-Namespacing); nur die Wörterbuch-
|
||||||
|
// Werte sind übersetzbarer Text.
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { de } from "./de";
|
||||||
|
import { en } from "./en";
|
||||||
|
import type { TranslationKey } from "./de";
|
||||||
|
|
||||||
|
export type { TranslationKey } from "./de";
|
||||||
|
|
||||||
|
/** Unterstützte Sprachen. Default ist „de". */
|
||||||
|
export type Language = "de" | "en";
|
||||||
|
|
||||||
|
/** Wörterbücher je Sprache. de ist die Quelle der Keys (Default-Sprache). */
|
||||||
|
const DICTS: Record<Language, Record<TranslationKey, string>> = { de, en };
|
||||||
|
|
||||||
|
/** Werte für die Platzhalter-Interpolation (`{name}` → params.name). */
|
||||||
|
export type TParams = Record<string, string | number>;
|
||||||
|
|
||||||
|
// ── Modulweiter Sprachzustand + Abonnenten (für useT) ──────────────────────
|
||||||
|
|
||||||
|
let currentLanguage: Language = "de";
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
/** Aktuell gewählte Sprache. */
|
||||||
|
export function getLanguage(): Language {
|
||||||
|
return currentLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprache umschalten. Benachrichtigt abonnierte Komponenten (useT), damit sie
|
||||||
|
* mit den neuen Texten neu rendern. (Noch kein UI-Umschalter — die Naht ist
|
||||||
|
* aber vorhanden und verdrahtet.)
|
||||||
|
*/
|
||||||
|
export function setLanguage(lang: Language): void {
|
||||||
|
if (lang === currentLanguage) return;
|
||||||
|
currentLanguage = lang;
|
||||||
|
for (const l of listeners) l();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kern: Lookup + Interpolation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Ersetzt `{name}`-Platzhalter im Wert durch die übergebenen Parameter. */
|
||||||
|
function interpolate(value: string, params?: TParams): string {
|
||||||
|
if (!params) return value;
|
||||||
|
return value.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||||
|
name in params ? String(params[name]) : match,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übersetzt einen Key in der aktuellen Sprache mit optionaler {param}-
|
||||||
|
* Interpolation. Fehlt der Key in der gewählten Sprache, greift Deutsch als
|
||||||
|
* Fallback; fehlt er auch dort, wird der Key selbst zurückgegeben (sichtbar,
|
||||||
|
* damit fehlende Texte auffallen statt leer zu bleiben).
|
||||||
|
*/
|
||||||
|
export function t(key: TranslationKey | string, params?: TParams): string {
|
||||||
|
const dict = DICTS[currentLanguage];
|
||||||
|
const value =
|
||||||
|
(dict as Record<string, string>)[key] ??
|
||||||
|
(de as Record<string, string>)[key] ??
|
||||||
|
key;
|
||||||
|
return interpolate(value, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── React-Hook ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook-Variante: liefert dieselbe `t`-Funktion, abonniert aber den
|
||||||
|
* Sprachzustand, sodass die Komponente beim Sprachwechsel neu rendert. In den
|
||||||
|
* meisten Komponenten reicht der direkte `t`-Import; useT() ist für den Fall
|
||||||
|
* gedacht, dass ein Live-Sprachumschalter existiert.
|
||||||
|
*/
|
||||||
|
export function useT(): typeof t {
|
||||||
|
useSyncExternalStore(
|
||||||
|
(cb) => {
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => listeners.delete(cb);
|
||||||
|
},
|
||||||
|
getLanguage,
|
||||||
|
getLanguage,
|
||||||
|
);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
// DWG-Import → Kontext-Geometrie (three-frei), echtes In-Browser-Parsing.
|
||||||
|
//
|
||||||
|
// DWG ist ein geschlossenes Binärformat; wir lesen es hier über LibreDWG-WASM
|
||||||
|
// (`@mlightcad/libredwg-web`) DIREKT im Browser — KEIN ODA-Zwischenschritt mehr.
|
||||||
|
// Ergebnis ist dasselbe `{meshes, contours}`-Format wie der DXF-Parser, damit der
|
||||||
|
// gesamte nachgelagerte Import-Fluss (Dialog, Ziel-Ebene, Layer-Handling,
|
||||||
|
// Mesh→Kontext) unverändert wiederverwendet wird.
|
||||||
|
//
|
||||||
|
// API (aus den .d.ts der Lib abgelesen, NICHT geraten):
|
||||||
|
// LibreDwg.create() → Promise<LibreDwgEx> (lädt WASM)
|
||||||
|
// dwg.dwg_read_data(buffer, Dwg_File_Type.DWG) → Dwg_Data-Pointer
|
||||||
|
// dwg.convert(ptr) → DwgDatabase (stark typisiert)
|
||||||
|
// dwg.dwg_free(ptr) → Speicher freigeben
|
||||||
|
// Der WASM-Build dieser Lib hat DXF-Schreiben/-Lesen DEAKTIVIERT (disable-dxf),
|
||||||
|
// daher gehen wir bewusst NICHT über DWG→DXF-Text, sondern mappen die geparste
|
||||||
|
// DwgDatabase direkt (ein Mapping, gespiegelt aus dxfParser).
|
||||||
|
//
|
||||||
|
// Unterstützte Entities (Modellraum):
|
||||||
|
// • 3DFACE → Dreiecks-Mesh (3 bzw. 4 Ecken → 1–2 Tri).
|
||||||
|
// • POLYLINE2D (Polyface, Flag 64) → Dreiecks-Mesh (Polyface-Faces).
|
||||||
|
// • LWPOLYLINE / POLYLINE2D / 3D → Kontur (z aus elevation/Vertex-Z).
|
||||||
|
// • LINE → Kontur (zwei-Punkt-Linienzug).
|
||||||
|
//
|
||||||
|
// Lazy: Das Paket wird per dynamischem import() geladen (kein Bloat im Initial-
|
||||||
|
// Bundle); WASM startet erst beim ersten DWG-Import.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
|
||||||
|
|
||||||
|
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
|
||||||
|
import type { DxfImportResult } from "./dxfParser";
|
||||||
|
|
||||||
|
// Lose getippte Sicht auf die libredwg-web-Entities (wir greifen tolerant auf die
|
||||||
|
// Felder zu, die wir brauchen; alle Punkte sind {x,y,z}).
|
||||||
|
interface DwgPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z?: number;
|
||||||
|
}
|
||||||
|
// Polyface-/Polyline-Vertex: Punktkoordinaten + optionale Polyface-Indizes.
|
||||||
|
interface DwgVertexLike extends DwgPoint {
|
||||||
|
polyfaceIndex0?: number;
|
||||||
|
polyfaceIndex1?: number;
|
||||||
|
polyfaceIndex2?: number;
|
||||||
|
polyfaceIndex3?: number;
|
||||||
|
flag?: number;
|
||||||
|
}
|
||||||
|
interface DwgEntityLike {
|
||||||
|
type?: string;
|
||||||
|
layer?: string;
|
||||||
|
flag?: number;
|
||||||
|
elevation?: number;
|
||||||
|
startPoint?: DwgPoint;
|
||||||
|
endPoint?: DwgPoint;
|
||||||
|
corner1?: DwgPoint;
|
||||||
|
corner2?: DwgPoint;
|
||||||
|
corner3?: DwgPoint;
|
||||||
|
corner4?: DwgPoint;
|
||||||
|
vertices?: DwgVertexLike[];
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schmale Sicht auf die LibreDWG-Instanz (nur die Methoden, die wir nutzen).
|
||||||
|
interface LibreDwgApi {
|
||||||
|
dwg_read_data(data: ArrayBuffer, fileType: number): number | undefined;
|
||||||
|
convert(ptr: number): { entities?: unknown[] };
|
||||||
|
dwg_free(ptr: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton der WASM-Instanz (einmal laden, dann wiederverwenden).
|
||||||
|
let libredwgPromise: Promise<LibreDwgApi> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt + initialisiert LibreDWG-WASM (lazy, einmalig). Wir laden die Emscripten-
|
||||||
|
* Glue + die `.wasm`-URL explizit über Vite (`?url`), damit die WASM-Datei in
|
||||||
|
* Dev UND Build mit korrektem MIME-Type aufgelöst wird (sonst liefert der Dev-
|
||||||
|
* Server `index.html` statt der WASM → „Incorrect response MIME type").
|
||||||
|
*/
|
||||||
|
function createLibreDwg(): Promise<LibreDwgApi> {
|
||||||
|
if (!libredwgPromise) {
|
||||||
|
libredwgPromise = (async () => {
|
||||||
|
const [{ LibreDwg }, glue, wasmUrlMod] = await Promise.all([
|
||||||
|
import("@mlightcad/libredwg-web"),
|
||||||
|
// Glue + WASM über ein lokales virtuelles Modul (Alias in vite.config),
|
||||||
|
// da das Paket seine `wasm/`-Dateien nicht über `exports` freigibt.
|
||||||
|
import("virtual:libredwg-glue"),
|
||||||
|
// Vite gibt die WASM als gehashtes Asset aus und liefert ihre URL.
|
||||||
|
import("virtual:libredwg-wasm-url"),
|
||||||
|
]);
|
||||||
|
const createModule = glue.default;
|
||||||
|
const wasmUrl = wasmUrlMod.default;
|
||||||
|
const wasmInstance = await createModule({
|
||||||
|
// locateFile bekommt den Original-Dateinamen; wir liefern die von Vite
|
||||||
|
// aufgelöste (gehashte) URL zurück → korrektes Laden in Dev + Build.
|
||||||
|
locateFile: () => wasmUrl,
|
||||||
|
});
|
||||||
|
// createByWasmInstance erwartet das (intern getippte) MainModule; die
|
||||||
|
// Glue liefert es als unknown → bewusster Cast, Rückgabe schmal getippt.
|
||||||
|
return LibreDwg.createByWasmInstance(
|
||||||
|
wasmInstance as Parameters<typeof LibreDwg.createByWasmInstance>[0],
|
||||||
|
) as unknown as LibreDwgApi;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return libredwgPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
let importSeq = 0;
|
||||||
|
const nextId = (prefix: string): string => `${prefix}-${Date.now()}-${importSeq++}`;
|
||||||
|
|
||||||
|
// POLYLINE-Flag 64 = Polyface-Mesh (siehe DwgPolylineFlag in der Lib).
|
||||||
|
const POLYFACE_FLAG = 64;
|
||||||
|
// POLYLINE-Flag 1 = geschlossen.
|
||||||
|
const CLOSED_FLAG = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst eine DWG-Datei (als ArrayBuffer) in dasselbe Kontext-Geometrie-Format
|
||||||
|
* wie der DXF-Parser. Lädt LibreDWG-WASM lazy. Wirft bei fatalen Fehlern (der
|
||||||
|
* Aufrufer fängt das ab und zeigt den ODA-Fallback) — pro-Entity-Fehler nicht.
|
||||||
|
*/
|
||||||
|
export async function parseDwg(data: ArrayBuffer): Promise<DxfImportResult> {
|
||||||
|
const libredwg = await createLibreDwg();
|
||||||
|
|
||||||
|
let dwgPtr: number | undefined;
|
||||||
|
try {
|
||||||
|
// Dwg_File_Type.DWG === 0 (siehe enums.d.ts der Lib). Literal, damit wir den
|
||||||
|
// Enum-Wert nicht zusätzlich in den lazy Chunk ziehen.
|
||||||
|
const FILE_TYPE_DWG = 0;
|
||||||
|
dwgPtr = libredwg.dwg_read_data(data, FILE_TYPE_DWG);
|
||||||
|
if (dwgPtr == null) {
|
||||||
|
throw new Error("LibreDWG: dwg_read_data lieferte keinen Pointer.");
|
||||||
|
}
|
||||||
|
const db = libredwg.convert(dwgPtr);
|
||||||
|
const entities = (db?.entities ?? []) as unknown as DwgEntityLike[];
|
||||||
|
|
||||||
|
const meshTriangles: number[] = [];
|
||||||
|
const meshIndices: number[] = [];
|
||||||
|
const contours: Contour[] = [];
|
||||||
|
|
||||||
|
for (const e of entities) {
|
||||||
|
const type = (e.type ?? "").toUpperCase();
|
||||||
|
switch (type) {
|
||||||
|
case "3DFACE":
|
||||||
|
addFace(meshTriangles, meshIndices, e);
|
||||||
|
break;
|
||||||
|
case "POLYLINE2D":
|
||||||
|
case "POLYLINE3D":
|
||||||
|
if (isPolyfaceMesh(e)) {
|
||||||
|
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
||||||
|
} else {
|
||||||
|
const ct = polylineContour(e);
|
||||||
|
if (ct) contours.push(ct);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "LWPOLYLINE": {
|
||||||
|
const ct = polylineContour(e);
|
||||||
|
if (ct) contours.push(ct);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "LINE": {
|
||||||
|
const ct = lineContour(e);
|
||||||
|
if (ct) contours.push(ct);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const meshes: ImportedMesh[] = [];
|
||||||
|
if (meshIndices.length > 0) {
|
||||||
|
meshes.push({
|
||||||
|
id: nextId("imported"),
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "DWG-Mesh",
|
||||||
|
positions: meshTriangles,
|
||||||
|
indices: meshIndices,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const contourSets: ContourSet[] = [];
|
||||||
|
if (contours.length > 0) {
|
||||||
|
contourSets.push({
|
||||||
|
id: nextId("contours"),
|
||||||
|
type: "contourSet",
|
||||||
|
name: "DWG-Konturen",
|
||||||
|
contours,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { meshes, contours: contourSets };
|
||||||
|
} finally {
|
||||||
|
// Speicher der WASM-Instanz freigeben (auch im Fehlerfall).
|
||||||
|
if (dwgPtr != null) {
|
||||||
|
try {
|
||||||
|
libredwg.dwg_free(dwgPtr);
|
||||||
|
} catch {
|
||||||
|
// Freigabe-Fehler ignorieren — nicht fatal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mesh-Entities ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fügt einen Vertex zu positions hinzu und liefert seinen Index. */
|
||||||
|
function pushVertex(
|
||||||
|
positions: number[],
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
z: number,
|
||||||
|
): number {
|
||||||
|
const idx = positions.length / 3;
|
||||||
|
positions.push(x, y, z);
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3DFACE: 3 oder 4 Eckpunkte (corner1..4). Bei 4 ≠ 3 Punkten zwei Dreiecke (Fan).
|
||||||
|
*/
|
||||||
|
function addFace(
|
||||||
|
positions: number[],
|
||||||
|
indices: number[],
|
||||||
|
e: DwgEntityLike,
|
||||||
|
): void {
|
||||||
|
const corners = [e.corner1, e.corner2, e.corner3, e.corner4].filter(
|
||||||
|
(c): c is DwgPoint => !!c && Number.isFinite(c.x) && Number.isFinite(c.y),
|
||||||
|
);
|
||||||
|
if (corners.length < 3) return;
|
||||||
|
const idx = corners.map((c) =>
|
||||||
|
pushVertex(positions, c.x, c.y, c.z ?? 0),
|
||||||
|
);
|
||||||
|
pushTri(indices, positions, idx[0], idx[1], idx[2]);
|
||||||
|
if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ob eine POLYLINE eine Polyface-Mesh-Variante ist (Flag 64 oder Polyface-Indizes). */
|
||||||
|
function isPolyfaceMesh(e: DwgEntityLike): boolean {
|
||||||
|
if (((e.flag ?? 0) & POLYFACE_FLAG) !== 0) return true;
|
||||||
|
return (e.vertices ?? []).some(
|
||||||
|
(v) =>
|
||||||
|
Number.isFinite(v.polyfaceIndex0) ||
|
||||||
|
Number.isFinite(v.polyfaceIndex1) ||
|
||||||
|
Number.isFinite(v.polyfaceIndex2) ||
|
||||||
|
Number.isFinite(v.polyfaceIndex3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polyface-Mesh (POLYLINE, Flag 64): Geometrie-Vertices (mit Koordinaten, keine
|
||||||
|
* Polyface-Indizes) und Face-Records (mit 1-basierten, ggf. negativen = unsicht-
|
||||||
|
* bare Kante Polyface-Indizes). Wir trennen beide und fan-triangulieren jede Face.
|
||||||
|
*/
|
||||||
|
function addPolyfaceMesh(
|
||||||
|
positions: number[],
|
||||||
|
indices: number[],
|
||||||
|
e: DwgEntityLike,
|
||||||
|
): void {
|
||||||
|
const all = e.vertices ?? [];
|
||||||
|
const hasFaceIdx = (v: DwgVertexLike): boolean =>
|
||||||
|
!!(
|
||||||
|
v.polyfaceIndex0 ||
|
||||||
|
v.polyfaceIndex1 ||
|
||||||
|
v.polyfaceIndex2 ||
|
||||||
|
v.polyfaceIndex3
|
||||||
|
);
|
||||||
|
const geom = all.filter((v) => !hasFaceIdx(v));
|
||||||
|
const faceRecs = all.filter((v) => hasFaceIdx(v));
|
||||||
|
if (geom.length < 3) return;
|
||||||
|
const base = positions.length / 3;
|
||||||
|
for (const v of geom) positions.push(v.x, v.y, v.z ?? 0);
|
||||||
|
for (const fr of faceRecs) {
|
||||||
|
// 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs().
|
||||||
|
const ring = [
|
||||||
|
fr.polyfaceIndex0,
|
||||||
|
fr.polyfaceIndex1,
|
||||||
|
fr.polyfaceIndex2,
|
||||||
|
fr.polyfaceIndex3,
|
||||||
|
]
|
||||||
|
.map((i) => Math.abs(i ?? 0))
|
||||||
|
.filter((i) => i >= 1)
|
||||||
|
.map((i) => base + (i - 1));
|
||||||
|
if (ring.length < 3) continue;
|
||||||
|
for (let i = 1; i + 1 < ring.length; i++) {
|
||||||
|
pushTri(indices, positions, ring[0], ring[i], ring[i + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fügt ein Dreieck hinzu, sofern es nicht entartet ist (gleiche/ungültige
|
||||||
|
* Indizes). Hält das importierte Mesh sauber.
|
||||||
|
*/
|
||||||
|
function pushTri(
|
||||||
|
indices: number[],
|
||||||
|
positions: number[],
|
||||||
|
a: number,
|
||||||
|
b: number,
|
||||||
|
c: number,
|
||||||
|
): void {
|
||||||
|
if (a === b || b === c || a === c) return;
|
||||||
|
const n = positions.length / 3;
|
||||||
|
if (a < 0 || b < 0 || c < 0 || a >= n || b >= n || c >= n) return;
|
||||||
|
indices.push(a, b, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kontur-Entities ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LWPOLYLINE / POLYLINE2D / POLYLINE3D → Kontur. Z-Höhe: erster gültiger
|
||||||
|
* Vertex-Z, sonst `elevation`, sonst 0. `closed` aus Flag 1. < 2 Punkte → null.
|
||||||
|
*/
|
||||||
|
function polylineContour(e: DwgEntityLike): Contour | null {
|
||||||
|
const vs = e.vertices ?? [];
|
||||||
|
const pts: Vec2[] = [];
|
||||||
|
let zFromVertex: number | undefined;
|
||||||
|
for (const v of vs) {
|
||||||
|
if (!Number.isFinite(v.x) || !Number.isFinite(v.y)) continue;
|
||||||
|
pts.push({ x: v.x, y: v.y });
|
||||||
|
if (zFromVertex === undefined && Number.isFinite(v.z)) zFromVertex = v.z;
|
||||||
|
}
|
||||||
|
if (pts.length < 2) return null;
|
||||||
|
const z =
|
||||||
|
zFromVertex ??
|
||||||
|
(Number.isFinite(e.elevation) ? (e.elevation as number) : 0);
|
||||||
|
const closed = ((e.flag ?? 0) & CLOSED_FLAG) !== 0;
|
||||||
|
return { z, pts, closed, layer: e.layer };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LINE → zweipunktige (offene) Kontur. Z aus dem Startpunkt (sonst 0). */
|
||||||
|
function lineContour(e: DwgEntityLike): Contour | null {
|
||||||
|
const a = e.startPoint;
|
||||||
|
const b = e.endPoint;
|
||||||
|
if (
|
||||||
|
!a ||
|
||||||
|
!b ||
|
||||||
|
!Number.isFinite(a.x) ||
|
||||||
|
!Number.isFinite(a.y) ||
|
||||||
|
!Number.isFinite(b.x) ||
|
||||||
|
!Number.isFinite(b.y)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const z = Number.isFinite(a.z) ? (a.z as number) : 0;
|
||||||
|
return {
|
||||||
|
z,
|
||||||
|
pts: [
|
||||||
|
{ x: a.x, y: a.y },
|
||||||
|
{ x: b.x, y: b.y },
|
||||||
|
],
|
||||||
|
closed: false,
|
||||||
|
layer: e.layer,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Kleiner Modul-Hook, der das (in App montierte) versteckte DXF-Datei-Input
|
||||||
|
// mit dem `import`-Befehl entkoppelt verbindet. App registriert beim Mount eine
|
||||||
|
// Trigger-Funktion (öffnet den Datei-Dialog); der Befehl ruft sie auf, ohne die
|
||||||
|
// Command-Engine an React/DOM zu koppeln. So bleibt der Import-Befehl additiv.
|
||||||
|
//
|
||||||
|
// Bewusst KEIN window-Global: ein Modul-Singleton genügt (eine App-Instanz).
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
type DxfImportTrigger = () => void;
|
||||||
|
|
||||||
|
let trigger: DxfImportTrigger | null = null;
|
||||||
|
|
||||||
|
/** App registriert hier den Datei-Dialog-Öffner (oder hebt ihn beim Unmount auf). */
|
||||||
|
export function setDxfImportTrigger(fn: DxfImportTrigger | null): void {
|
||||||
|
trigger = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffnet den DXF-Datei-Dialog, falls App ihn registriert hat. */
|
||||||
|
export function openDxfImport(): void {
|
||||||
|
trigger?.();
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
// DXF-Import → Kontext-Geometrie (three-frei).
|
||||||
|
//
|
||||||
|
// Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres
|
||||||
|
// Snap-Ziel), NICHT semantisch. Dieser Parser liefert nur rohe Buffer-Daten
|
||||||
|
// (positions/indices) bzw. Konturen (Vec2 + Z), die in `Project.context` landen.
|
||||||
|
//
|
||||||
|
// ── DWG-Hinweis (bewusst KEIN DWG-Parser) ──────────────────────────────────
|
||||||
|
// DWG ist ein geschlossenes Binärformat ohne robusten Pure-JS-Parser; wir bauen
|
||||||
|
// hier KEINEN DWG-Parser. Der Import-Weg führt über DXF:
|
||||||
|
// DWG → DXF konvertieren (gratis „ODA File Converter" von der Open Design
|
||||||
|
// Alliance), dann die DXF-Datei hier importieren.
|
||||||
|
// LibreDWG-WASM ist nicht zuverlässig genug und wird bewusst nicht verfolgt.
|
||||||
|
//
|
||||||
|
// Unterstützte DXF-Entities:
|
||||||
|
// • 3DFACE → Dreiecks-Mesh (3 bzw. 4 Ecken → 1–2 Tri).
|
||||||
|
// • POLYLINE (Polyface/PolygonMesh)→ Dreiecks-Mesh (Faces bzw. Gitter).
|
||||||
|
// • MESH → Dreiecks-Mesh (Vertices + Face-Liste).
|
||||||
|
// • LWPOLYLINE / POLYLINE (2D/3D) → Kontur (z aus elevation/Vertex-Z).
|
||||||
|
// • LINE → Kontur (zwei-Punkt-Linienzug).
|
||||||
|
|
||||||
|
import DxfParser from "dxf-parser";
|
||||||
|
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
|
||||||
|
|
||||||
|
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */
|
||||||
|
export interface DxfImportResult {
|
||||||
|
meshes: ImportedMesh[];
|
||||||
|
contours: ContourSet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lose getippte Sicht auf die dxf-parser-Ausgabe (das Paket liefert keine engen
|
||||||
|
// Typen); wir greifen tolerant auf die Felder zu, die wir brauchen.
|
||||||
|
interface DxfVertex {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
z?: number;
|
||||||
|
// Polyface-Mesh: 1-basierte, ggf. negative Vertex-Indizes (negativ = unsichtbare Kante).
|
||||||
|
faces?: number[];
|
||||||
|
}
|
||||||
|
interface DxfEntity {
|
||||||
|
type?: string;
|
||||||
|
layer?: string;
|
||||||
|
elevation?: number;
|
||||||
|
vertices?: DxfVertex[];
|
||||||
|
// MESH-Entity-Felder (variieren je nach Parser-Version):
|
||||||
|
position?: DxfVertex[];
|
||||||
|
faces?: number[][];
|
||||||
|
isPolyfaceMesh?: boolean;
|
||||||
|
is3dPolygonMesh?: boolean;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
interface DxfDocument {
|
||||||
|
entities?: DxfEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let importSeq = 0;
|
||||||
|
const nextId = (prefix: string) =>
|
||||||
|
`${prefix}-${Date.now()}-${importSeq++}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst DXF-Text in Kontext-Geometrie. Tolerant gegen fehlende Felder; nicht
|
||||||
|
* unterstützte/leere Entities werden übersprungen. Ein fataler Parse-Fehler wird
|
||||||
|
* geworfen (der Aufrufer kann ihn anzeigen) — pro-Entity-Fehler nicht.
|
||||||
|
*/
|
||||||
|
export function parseDxf(text: string): DxfImportResult {
|
||||||
|
const parser = new DxfParser();
|
||||||
|
// parseSync wirft bei strukturell kaputtem DXF; das soll nach oben.
|
||||||
|
const doc = parser.parseSync(text) as unknown as DxfDocument;
|
||||||
|
const entities = doc?.entities ?? [];
|
||||||
|
|
||||||
|
const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…)
|
||||||
|
const meshIndices: number[] = [];
|
||||||
|
const contours: Contour[] = [];
|
||||||
|
|
||||||
|
for (const e of entities) {
|
||||||
|
const type = (e.type ?? "").toUpperCase();
|
||||||
|
switch (type) {
|
||||||
|
case "3DFACE":
|
||||||
|
addFace(meshTriangles, meshIndices, e);
|
||||||
|
break;
|
||||||
|
case "MESH":
|
||||||
|
addMesh(meshTriangles, meshIndices, e);
|
||||||
|
break;
|
||||||
|
case "POLYLINE":
|
||||||
|
// POLYLINE ist mehrdeutig: Polyface/PolygonMesh → Mesh; sonst → Kontur.
|
||||||
|
if (isMeshPolyline(e)) {
|
||||||
|
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
||||||
|
} else {
|
||||||
|
const ct = polylineContour(e);
|
||||||
|
if (ct) contours.push(ct);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "LWPOLYLINE": {
|
||||||
|
const ct = polylineContour(e);
|
||||||
|
if (ct) contours.push(ct);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "LINE": {
|
||||||
|
const ct = lineContour(e);
|
||||||
|
if (ct) contours.push(ct);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const meshes: ImportedMesh[] = [];
|
||||||
|
if (meshIndices.length > 0) {
|
||||||
|
meshes.push({
|
||||||
|
id: nextId("imported"),
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "DXF-Mesh",
|
||||||
|
positions: meshTriangles,
|
||||||
|
indices: meshIndices,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const contourSets: ContourSet[] = [];
|
||||||
|
if (contours.length > 0) {
|
||||||
|
contourSets.push({
|
||||||
|
id: nextId("contours"),
|
||||||
|
type: "contourSet",
|
||||||
|
name: "DXF-Konturen",
|
||||||
|
contours,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { meshes, contours: contourSets };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mesh-Entities ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fügt einen Vertex zu positions hinzu und liefert seinen Index. */
|
||||||
|
function pushVertex(
|
||||||
|
positions: number[],
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
z: number,
|
||||||
|
): number {
|
||||||
|
const idx = positions.length / 3;
|
||||||
|
positions.push(x, y, z);
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3DFACE: 3 oder 4 Eckpunkte. Bei 4 Punkten zwei Dreiecke (Fan). Entartete
|
||||||
|
* (degenerierte) Punkte (4. == 3.) ergeben nur ein Dreieck.
|
||||||
|
*/
|
||||||
|
function addFace(positions: number[], indices: number[], e: DxfEntity): void {
|
||||||
|
const vs = (e.vertices ?? []).filter(
|
||||||
|
(v) => Number.isFinite(v.x) && Number.isFinite(v.y),
|
||||||
|
);
|
||||||
|
if (vs.length < 3) return;
|
||||||
|
const idx = vs.map((v) =>
|
||||||
|
pushVertex(positions, v.x ?? 0, v.y ?? 0, v.z ?? 0),
|
||||||
|
);
|
||||||
|
// Dreieck (0,1,2).
|
||||||
|
pushTri(indices, positions, idx[0], idx[1], idx[2]);
|
||||||
|
// Viereck → zweites Dreieck (0,2,3), falls 4. Punkt vorhanden und ≠ 3.
|
||||||
|
if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MESH-Entity: explizite Vertex-Liste + Face-Liste. Die Face-Liste kann Drei-
|
||||||
|
* oder Vierecke enthalten (jeweils 0-basierte Vertex-Indizes); Vierecke werden
|
||||||
|
* fan-trianguliert.
|
||||||
|
*/
|
||||||
|
function addMesh(positions: number[], indices: number[], e: DxfEntity): void {
|
||||||
|
const verts = e.position ?? e.vertices ?? [];
|
||||||
|
if (verts.length === 0) return;
|
||||||
|
const base = positions.length / 3;
|
||||||
|
for (const v of verts) {
|
||||||
|
positions.push(v.x ?? 0, v.y ?? 0, v.z ?? 0);
|
||||||
|
}
|
||||||
|
const faces = Array.isArray(e.faces) ? e.faces : [];
|
||||||
|
for (const face of faces) {
|
||||||
|
if (!Array.isArray(face) || face.length < 3) continue;
|
||||||
|
for (let i = 1; i + 1 < face.length; i++) {
|
||||||
|
pushTri(
|
||||||
|
indices,
|
||||||
|
positions,
|
||||||
|
base + face[0],
|
||||||
|
base + face[i],
|
||||||
|
base + face[i + 1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ob eine POLYLINE eine Mesh-Variante ist (Polyface- oder Polygon-Gitter). */
|
||||||
|
function isMeshPolyline(e: DxfEntity): boolean {
|
||||||
|
if (e.isPolyfaceMesh === true || e.is3dPolygonMesh === true) return true;
|
||||||
|
// Heuristik: enthält irgendein Vertex eine `faces`-Liste → Polyface-Mesh.
|
||||||
|
return (e.vertices ?? []).some(
|
||||||
|
(v) => Array.isArray(v.faces) && v.faces.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polyface-Mesh (POLYLINE, Flag 64): erst Geometrie-Vertices, dann Face-Records
|
||||||
|
* mit 1-basierten (ggf. negativen = unsichtbare Kante) Vertex-Indizes. Wir
|
||||||
|
* trennen Geometrie- von Face-Vertices und fan-triangulieren jede Face.
|
||||||
|
*/
|
||||||
|
function addPolyfaceMesh(
|
||||||
|
positions: number[],
|
||||||
|
indices: number[],
|
||||||
|
e: DxfEntity,
|
||||||
|
): void {
|
||||||
|
const all = e.vertices ?? [];
|
||||||
|
const geom = all.filter((v) => !(Array.isArray(v.faces) && v.faces.length));
|
||||||
|
const faceRecs = all.filter((v) => Array.isArray(v.faces) && v.faces.length);
|
||||||
|
if (geom.length < 3) return;
|
||||||
|
const base = positions.length / 3;
|
||||||
|
for (const v of geom) {
|
||||||
|
positions.push(v.x ?? 0, v.y ?? 0, v.z ?? 0);
|
||||||
|
}
|
||||||
|
for (const fr of faceRecs) {
|
||||||
|
// 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs().
|
||||||
|
const ring = (fr.faces ?? [])
|
||||||
|
.map((i) => Math.abs(i))
|
||||||
|
.filter((i) => i >= 1)
|
||||||
|
.map((i) => base + (i - 1));
|
||||||
|
if (ring.length < 3) continue;
|
||||||
|
for (let i = 1; i + 1 < ring.length; i++) {
|
||||||
|
pushTri(indices, positions, ring[0], ring[i], ring[i + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fügt ein Dreieck hinzu, sofern es nicht entartet ist (zwei gleiche Indizes
|
||||||
|
* oder kollineare/Null-Flächen-Ecken). Hält das importierte Mesh sauber.
|
||||||
|
*/
|
||||||
|
function pushTri(
|
||||||
|
indices: number[],
|
||||||
|
positions: number[],
|
||||||
|
a: number,
|
||||||
|
b: number,
|
||||||
|
c: number,
|
||||||
|
): void {
|
||||||
|
if (a === b || b === c || a === c) return;
|
||||||
|
const n = positions.length / 3;
|
||||||
|
if (a < 0 || b < 0 || c < 0 || a >= n || b >= n || c >= n) return;
|
||||||
|
indices.push(a, b, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kontur-Entities ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LWPOLYLINE / 2D-/3D-POLYLINE → Kontur. Z-Höhe: bei 3D-Polylinie aus den
|
||||||
|
* Vertex-Z (erster gültiger), sonst aus `elevation`, sonst 0. Die 2D-Punkte sind
|
||||||
|
* (x,y) jedes Vertex. Bei < 2 Punkten wird nichts erzeugt.
|
||||||
|
*/
|
||||||
|
function polylineContour(e: DxfEntity): Contour | null {
|
||||||
|
const vs = e.vertices ?? [];
|
||||||
|
const pts: Vec2[] = [];
|
||||||
|
let zFromVertex: number | undefined;
|
||||||
|
for (const v of vs) {
|
||||||
|
if (!Number.isFinite(v.x) || !Number.isFinite(v.y)) continue;
|
||||||
|
pts.push({ x: v.x ?? 0, y: v.y ?? 0 });
|
||||||
|
if (zFromVertex === undefined && Number.isFinite(v.z)) zFromVertex = v.z;
|
||||||
|
}
|
||||||
|
if (pts.length < 2) return null;
|
||||||
|
const z = zFromVertex ?? (Number.isFinite(e.elevation) ? (e.elevation as number) : 0);
|
||||||
|
// closed-Flag aus dem Parser ableiten (LWPOLYLINE: `shape`, POLYLINE: `shape`).
|
||||||
|
const closed = e.shape === true || e.closed === true;
|
||||||
|
return { z, pts, closed, layer: e.layer };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LINE → zweipunktige (offene) Kontur. Z aus dem Start-Vertex (sonst 0). */
|
||||||
|
function lineContour(e: DxfEntity): Contour | null {
|
||||||
|
const vs = e.vertices ?? [];
|
||||||
|
if (vs.length < 2) return null;
|
||||||
|
const a = vs[0];
|
||||||
|
const b = vs[1];
|
||||||
|
if (
|
||||||
|
!Number.isFinite(a.x) ||
|
||||||
|
!Number.isFinite(a.y) ||
|
||||||
|
!Number.isFinite(b.x) ||
|
||||||
|
!Number.isFinite(b.y)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const z = Number.isFinite(a.z) ? (a.z as number) : 0;
|
||||||
|
return {
|
||||||
|
z,
|
||||||
|
pts: [
|
||||||
|
{ x: a.x ?? 0, y: a.y ?? 0 },
|
||||||
|
{ x: b.x ?? 0, y: b.y ?? 0 },
|
||||||
|
],
|
||||||
|
closed: false,
|
||||||
|
layer: e.layer,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// DXF-Konturen → semantische 2D-Zeichengeometrie (Drawing2D).
|
||||||
|
//
|
||||||
|
// Der DXF-Parser liefert „dumme" Konturen (Vec2 + Z + optionaler Layer-Name).
|
||||||
|
// Beim Import in eine ZEICHNUNGSEBENE werden daraus echte Drawing2D-Elemente
|
||||||
|
// (line/polyline). Die Z-Höhe der Kontur wird in 2D bewusst IGNORIERT (eine
|
||||||
|
// Zeichnungsebene ist flach). Das `closed`-Flag steuert line vs. polyline:
|
||||||
|
// • genau zwei Punkte, nicht geschlossen → `line`.
|
||||||
|
// • sonst → `polyline` (mit `closed`-Flag).
|
||||||
|
//
|
||||||
|
// Kategorie-Zuordnung (categoryMode):
|
||||||
|
// • "active" — alles bekommt die aktive Kategorie (activeCategoryCode).
|
||||||
|
// • "byLayer" — der DXF-Layer-Name wird auf einen Kategorie-Code gemappt.
|
||||||
|
// `layerToCode` liefert (deterministisch) einen Code je
|
||||||
|
// Layer-Name; unbekannte/leere Layer fallen auf `fallbackCode`.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
|
||||||
|
|
||||||
|
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom } from "../model/types";
|
||||||
|
|
||||||
|
/** Wie DXF-Layer auf Kategorien abgebildet werden. */
|
||||||
|
export type CategoryMode = "active" | "byLayer";
|
||||||
|
|
||||||
|
let drawingSeq = 0;
|
||||||
|
const nextId = (): string => `dxf2d-${Date.now()}-${drawingSeq++}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt einen Satz Konturen-Sätze in Drawing2D-Elemente für die Ziel-Ebene
|
||||||
|
* `levelId` um. Bei `categoryMode === "byLayer"` liefert `layerToCode` den
|
||||||
|
* Kategorie-Code je DXF-Layer-Name; sonst bekommt alles `fallbackCode`.
|
||||||
|
*/
|
||||||
|
export function contoursToDrawings(
|
||||||
|
sets: ContourSet[],
|
||||||
|
levelId: string,
|
||||||
|
categoryMode: CategoryMode,
|
||||||
|
fallbackCode: string,
|
||||||
|
layerToCode: (layerName: string) => string,
|
||||||
|
): Drawing2D[] {
|
||||||
|
const out: Drawing2D[] = [];
|
||||||
|
for (const set of sets) {
|
||||||
|
for (const contour of set.contours) {
|
||||||
|
const geom = contourGeom(contour);
|
||||||
|
if (!geom) continue;
|
||||||
|
const layerName = contour.layer ?? set.layer ?? "";
|
||||||
|
const categoryCode =
|
||||||
|
categoryMode === "byLayer" && layerName
|
||||||
|
? layerToCode(layerName)
|
||||||
|
: fallbackCode;
|
||||||
|
out.push({
|
||||||
|
id: nextId(),
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId,
|
||||||
|
categoryCode,
|
||||||
|
geom,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Kontur → 2D-Geometrie (line bei 2 offenen Punkten, sonst polyline). */
|
||||||
|
function contourGeom(contour: Contour): Drawing2DGeom | null {
|
||||||
|
const pts = contour.pts;
|
||||||
|
if (pts.length < 2) return null;
|
||||||
|
if (pts.length === 2 && !contour.closed) {
|
||||||
|
return { shape: "line", a: { ...pts[0] }, b: { ...pts[1] } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
shape: "polyline",
|
||||||
|
pts: pts.map((p) => ({ ...p })),
|
||||||
|
closed: contour.closed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste der unterschiedlichen DXF-Layer-Namen über alle Konturen-Sätze. */
|
||||||
|
export function distinctLayers(sets: ContourSet[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const set of sets) {
|
||||||
|
for (const contour of set.contours) {
|
||||||
|
const name = contour.layer ?? set.layer ?? "";
|
||||||
|
if (name) seen.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gesamtzahl der Konturen über alle Sätze (für die Datei-Zusammenfassung). */
|
||||||
|
export function countContours(sets: ContourSet[]): number {
|
||||||
|
return sets.reduce((sum, s) => sum + s.contours.length, 0);
|
||||||
|
}
|
||||||
Vendored
+19
@@ -0,0 +1,19 @@
|
|||||||
|
// Ambient-Deklarationen für die WASM-Glue-/Asset-Importe von
|
||||||
|
// @mlightcad/libredwg-web. Das Paket gibt seine `wasm/`-Dateien NICHT über
|
||||||
|
// `exports` frei, daher laden wir sie über zwei virtuelle Module (Aliase in
|
||||||
|
// vite.config.ts → echte Dateien im Paket). Hier nur schmale Modul-Stubs.
|
||||||
|
// (Identifier englisch.)
|
||||||
|
|
||||||
|
declare module "virtual:libredwg-glue" {
|
||||||
|
/** Emscripten-Glue: erzeugt die WASM-Instanz; `locateFile` löst die .wasm-URL. */
|
||||||
|
const createModule: (opts?: {
|
||||||
|
locateFile?: (filename: string, scriptDir: string) => string;
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
export default createModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "virtual:libredwg-wasm-url" {
|
||||||
|
/** Von Vite aufgelöste URL der WASM-Datei (gehashtes Asset im Build). */
|
||||||
|
const url: string;
|
||||||
|
export default url;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user