diff --git a/src/App.tsx b/src/App.tsx index 000e47e..c1cd8c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,7 +20,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { collectVisibleCodes, flattenCategories, - formatM, getWallType, wallTypeThickness, } from "./model/types"; @@ -32,9 +31,7 @@ import type { ContextObject, DoorType, Drawing2D, - DrawingLevel, EdgeGrip, - LayerCategory, Layout, OverrideRule, Project, @@ -47,13 +44,9 @@ import type { WallType, WindowType, } from "./model/types"; -import { generatePlan, generateSectionPlan } from "./plan/generatePlan"; +import { generatePlan } from "./plan/generatePlan"; import type { CategoryDisplayResolver } from "./plan/generatePlan"; -import { computeSection, sectionPlaneFromLevel } from "./plan/toSection"; -import type { SectionOutput } from "./plan/toSection"; -import { generateElevationPlan } from "./plan/toElevation"; -import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync"; -import { selectionHighlightLines } from "./plan/toWalls3d"; +import { sectionPlaneFromLevel } from "./plan/toSection"; import { roofBaseElevation, roofGeometry } from "./geometry/roof"; import { parseDxf } from "./io/dxfParser"; import type { DxfImportResult } from "./io/dxfParser"; @@ -88,15 +81,11 @@ import { isTauriRuntime, } from "./io/projectFile"; import type { LockInfo } from "./io/projectFile"; -import { PlanView } from "./plan/PlanView"; import type { PlanViewHandle, PlanSelection, MarqueeSelection, - GripHandlers, - HudFieldsState, } from "./plan/PlanView"; -import { Viewport3D } from "./viewport/Viewport3D"; import type { DisplayResolver, ViewportContextInfo } from "./viewport/Viewport3D"; import type { Pick3dHit } from "./viewport/Wasm3DViewport"; import { ResourceManager } from "./ui/ResourceManager"; @@ -142,7 +131,6 @@ import { import { TextEditorDialog } from "./ui/TextEditorDialog"; import { RoomStampEditor } from "./panels/RoomStampEditor"; import { defaultRoomStamp } from "./model/roomStamp"; -import type { DetailLevel, RenderMode, View3d, ViewType } from "./ui/TopBar"; import { StatusBar, type RendererMode } from "./ui/StatusBar"; import { type ThemeMode, @@ -169,8 +157,11 @@ import type { ToolPointer, ToolState, } from "./tools/types"; -import { ContextMenu, MenuIcon } from "./ui/ContextMenu"; -import type { ContextMenuItem } from "./ui/ContextMenu"; +import { ContextMenu } from "./ui/ContextMenu"; +import { buildMenuItems, menuTitle } from "./menus/contextMenu"; +import type { MenuState } from "./menus/contextMenu"; +import { InlineEditor } from "./views/InlineEditor"; +import { Content } from "./views/viewportContent"; import { CommandLine } from "./ui/CommandLine"; import type { CommandLineHandle, CommandLineField, CommandLineOption } from "./ui/CommandLine"; import { CommandEngine } from "./commands/engine"; @@ -240,8 +231,7 @@ import { } from "./state/viewSnapshotFolders"; // Seiteneffekt: registriert die zwei dockbaren Kern-Panels in der Registry. import { BUILTIN_PANEL_IDS } from "./panels/builtinPanels"; -import { DrawingTabs, deriveDrawings } from "./DrawingTabs"; -import type { Drawing } from "./DrawingTabs"; +import { deriveDrawings } from "./DrawingTabs"; /** * Offenes Kontextmenü — genau eine Quelle. `x/y` sind Viewport-Pixel (clientX/Y). @@ -250,13 +240,6 @@ import type { Drawing } from "./DrawingTabs"; * • "plan" — Rechtsklick im Grundriss (optional getroffene Wand). * • "viewport" — Rechtsklick im 3D (optional getroffene Wand). */ -type MenuState = - | { kind: "layer"; code: string; x: number; y: number } - | { kind: "level"; id: string; x: number; y: number } - | { kind: "plan"; wallId: string | null; x: number; y: number } - | { kind: "viewport"; wallId: string | null; x: number; y: number } - | null; - /** Offener Inline-Editor (aus einem Menüpunkt). `x/y` = Anker in Viewport-Px. */ type EditorState = | { kind: "layer"; code: string; x: number; y: number } @@ -271,13 +254,6 @@ type EditorState = * getipptes „wall"/„wand" münden in denselben Befehl; der Legacy-Wand-Pfad ist * damit deaktiviert (siehe toolHandlers/onSelectTool). */ -/** - * Akzentfarbe der 3D-Auswahl-Hervorhebung (Umriss-Linien, WASM-Viewport, s. - * `Content`/`selectionHighlightLines`). Kräftiges Orange — hebt sich vom - * grauen Wand-/Deckenton, dem hellen Hidden-Line-Weiss und dem Bodenraster ab. - */ -const HIGHLIGHT_RGB: [number, number, number] = [1.0, 0.5, 0.1]; - const TOOL_COMMAND: Partial> = { wall: "wall", ceiling: "ceiling", @@ -5939,1192 +5915,3 @@ function pointInPoly(pt: Vec2, poly: Vec2[]): boolean { } return inside; } - -// ── Kontextmenü-Aufbau (Einträge je Quelle) ────────────────────────────────── - -/** Titel des Kontextmenüs je Quelle (Ebenenname/-code, Geschossname, „Wand"). */ -function menuTitle(menu: NonNullable, project: Project): string { - switch (menu.kind) { - case "layer": { - const c = findCategory(project.layers, menu.code); - return c ? `${c.code} · ${c.name}` : menu.code; - } - case "level": { - const l = project.drawingLevels.find((z) => z.id === menu.id); - return l ? l.name : t("ctx.title.level"); - } - case "plan": - case "viewport": - return menu.wallId ? t("ctx.title.wall") : t("ctx.title.view"); - } -} - -/** Aktionen, die der Kontextmenü-Builder von App braucht (alle verdrahtet). */ -interface MenuActions { - project: Project; - selectedWallId: string | null; - layerClipboard: { color: string; lw: number } | null; - openLayerEditor: (code: string) => void; - openLevelEditor: (id: string) => void; - addSubCategory: (parentCode: string) => void; - assignWallCategory: (wallId: string, code: string) => void; - duplicateCategory: (code: string) => void; - deleteCategory: (code: string) => void; - setLayerClipboard: (clip: { color: string; lw: number } | null) => void; - patchCategory: (code: string, patch: Partial) => void; - duplicateLevel: (id: string) => void; - deleteLevel: (id: string) => void; - clearSelection: () => void; - fit: () => void; - fitSelection: () => void; - /** Anzahl geschlossener Flächen in der aktuellen 2D-Auswahl (Boolean-Aktivierung). */ - closedSelectionCount: number; - union: () => void; - difference: () => void; - intersect: () => void; - /** Anzahl aktuell selektierter 2D-Zeichnungselemente (z-Anordnen-Menü). */ - drawingSelectionCount: number; - bringToFront: () => void; - bringForward: () => void; - sendBackward: () => void; - sendToBack: () => void; -} - -/** - * Baut die Kontextmenü-Einträge je Quelle — 1:1 nach docs/design/context-menu.md. - * JEDER Eintrag ist verdrahtet ODER klar deaktiviert mit Tooltip (`title`, - * nennt den Grund) — kein stiller No-Op. - */ -function buildMenuItems( - menu: NonNullable, - a: MenuActions, -): ContextMenuItem[] { - switch (menu.kind) { - case "layer": - return layerMenuItems(menu.code, a); - case "level": - return levelMenuItems(menu.id, a); - case "plan": - return planMenuItems(menu.wallId, a); - case "viewport": - return viewportMenuItems(menu.wallId, a); - } -} - -/** Ebenen-Kontextmenü (Rechtsklick auf Ebenen-Zeile). */ -function layerMenuItems(code: string, a: MenuActions): ContextMenuItem[] { - const total = flattenCategories(a.project.layers).length; - const hasSelection = a.selectedWallId !== null; - const hasClipboard = a.layerClipboard !== null; - return [ - { - label: t("ctx.layerSettings"), - icon: , - onClick: () => a.openLayerEditor(code), - }, - { divider: true }, - { - label: t("ctx.addSubLayer"), - icon: , - onClick: () => a.addSubCategory(code), - }, - { - label: t("ctx.assignSelection"), - icon: , - disabled: !hasSelection, - title: hasSelection ? undefined : t("ctx.assignSelection.disabled"), - onClick: () => { - if (a.selectedWallId) a.assignWallCategory(a.selectedWallId, code); - }, - }, - { divider: true }, - { - label: t("ctx.duplicate"), - icon: , - onClick: () => a.duplicateCategory(code), - }, - { - label: t("ctx.copyProps"), - icon: , - onClick: () => { - const c = findCategory(a.project.layers, code); - if (c) a.setLayerClipboard({ color: c.color, lw: c.lw }); - }, - }, - { - label: t("ctx.pasteProps"), - icon: , - disabled: !hasClipboard, - title: hasClipboard ? undefined : t("ctx.pasteProps.disabled"), - onClick: () => { - if (a.layerClipboard) - a.patchCategory(code, { - color: a.layerClipboard.color, - lw: a.layerClipboard.lw, - }); - }, - }, - { divider: true }, - { - label: t("ctx.delete"), - icon: , - danger: true, - disabled: total <= 1, - title: total <= 1 ? t("ctx.delete.lastLayer") : undefined, - onClick: () => a.deleteCategory(code), - }, - ]; -} - -/** Zeichnungsebenen-Kontextmenü (Rechtsklick auf Geschoss/Schnitt/Zeichnung). */ -function levelMenuItems(id: string, a: MenuActions): ContextMenuItem[] { - const total = a.project.drawingLevels.length; - return [ - { - label: t("ctx.levelSettings"), - icon: , - onClick: () => a.openLevelEditor(id), - }, - { divider: true }, - { - label: t("ctx.duplicate"), - icon: , - onClick: () => a.duplicateLevel(id), - }, - { divider: true }, - { - label: t("ctx.delete"), - icon: , - danger: true, - disabled: total <= 1, - title: total <= 1 ? t("ctx.delete.lastLevel") : undefined, - onClick: () => a.deleteLevel(id), - }, - ]; -} - -/** - * Grundriss-Kontextmenü. Bezug ist die getroffene/gewählte Wand. Einträge sind - * verdrahtet: „Auf Auswahl einpassen" (PlanView-Handle) und „Auswahl aufheben". - * Ohne getroffene Wand bleibt nur „Einpassen". (Eigenschaften-Panel/Bemaßung - * folgen später — daher hier KEIN Schein-Eintrag.) - */ -function planMenuItems( - wallId: string | null, - a: MenuActions, -): ContextMenuItem[] { - // Boolean-Einträge (Vereinigen/Subtrahieren/Schneiden) auf die 2D-Auswahl. - // Aktiv ab 2 geschlossenen Flächen; sonst deaktiviert mit erklärendem Tooltip. - const boolEnabled = a.closedSelectionCount >= 2; - const boolTitle = boolEnabled ? undefined : t("ctx.boolean.disabled"); - const booleanItems: ContextMenuItem[] = [ - { - label: t("ctx.union"), - icon: , - disabled: !boolEnabled, - title: boolTitle, - onClick: a.union, - }, - { - label: t("ctx.subtract"), - icon: , - disabled: !boolEnabled, - title: boolTitle, - onClick: a.difference, - }, - { - label: t("ctx.intersect"), - icon: , - disabled: !boolEnabled, - title: boolTitle, - onClick: a.intersect, - }, - ]; - - // Z-Anordnen (nach vorn/hinten holen) — wirkt auf die aktuelle 2D-Auswahl, - // sortiert `drawings2d` um (Array-Reihenfolge = Zeichenreihenfolge). Aktiv - // ab 1 selektiertem Element; sonst deaktiviert mit erklärendem Tooltip. - const arrangeEnabled = a.drawingSelectionCount >= 1; - const arrangeTitle = arrangeEnabled ? undefined : t("ctx.arrange.disabled"); - const arrangeItems: ContextMenuItem[] = [ - { - label: t("ctx.arrange.front"), - icon: , - disabled: !arrangeEnabled, - title: arrangeTitle, - onClick: a.bringToFront, - }, - { - label: t("ctx.arrange.forward"), - icon: , - disabled: !arrangeEnabled, - title: arrangeTitle, - onClick: a.bringForward, - }, - { - label: t("ctx.arrange.backward"), - icon: , - disabled: !arrangeEnabled, - title: arrangeTitle, - onClick: a.sendBackward, - }, - { - label: t("ctx.arrange.back"), - icon: , - disabled: !arrangeEnabled, - title: arrangeTitle, - onClick: a.sendToBack, - }, - ]; - - if (wallId) { - return [ - { - label: t("ctx.fitSelection"), - icon: , - onClick: a.fitSelection, - }, - { - label: t("ctx.clearSelection"), - icon: , - onClick: a.clearSelection, - }, - { divider: true }, - ...booleanItems, - { divider: true }, - ...arrangeItems, - { divider: true }, - { - label: t("ctx.fit"), - icon: , - onClick: a.fit, - }, - ]; - } - return [ - ...booleanItems, - { divider: true }, - ...arrangeItems, - { divider: true }, - { - label: t("ctx.fit"), - icon: , - onClick: a.fit, - }, - ]; -} - -/** - * 3D-Kontextmenü. Im 3D gibt es (noch) kein Einpassen-Handle wie im Grundriss; - * verdrahtet ist „Auswahl aufheben" (mit getroffener Wand). Ohne Wand ist das - * Menü leer-sinnvoll: ein deaktivierter Hinweis, kein stiller No-Op. - */ -function viewportMenuItems( - wallId: string | null, - a: MenuActions, -): ContextMenuItem[] { - if (wallId) { - return [ - { - label: t("ctx.clearSelection"), - icon: , - onClick: a.clearSelection, - }, - ]; - } - return [ - { - label: t("ctx.noWallHit"), - disabled: true, - title: t("ctx.noWallHit.hint"), - }, - ]; -} - -// ── Inline-Editor (Ebene / Geschoss) ───────────────────────────────────────── - -/** - * Kleiner schwebender Editor (dunkel, wie das Kontextmenü) am Anker. Schließt - * über Außenklick / Esc. Rein darstellend: die Felder rufen die übergebenen - * Patch-Handler, die live auf den State wirken (kein Bestätigen nötig). - */ -function InlineEditor({ - x, - y, - title, - onClose, - children, -}: { - x: number; - y: number; - title: string; - onClose: () => void; - children: React.ReactNode; -}) { - const ref = useRef(null); - // An die Viewport-Ränder klemmen (wie das Kontextmenü). - const [pos, setPos] = useState({ x, y }); - useEffect(() => { - const el = ref.current; - if (!el) return; - const r = el.getBoundingClientRect(); - let nx = x; - let ny = y; - if (nx + r.width > window.innerWidth - 4) nx = window.innerWidth - r.width - 4; - if (ny + r.height > window.innerHeight - 4) - ny = window.innerHeight - r.height - 4; - setPos({ x: Math.max(4, nx), y: Math.max(4, ny) }); - }, [x, y]); - // Schließen bei Außenklick / Esc. - useEffect(() => { - const onDown = (e: PointerEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) onClose(); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("pointerdown", onDown, true); - window.addEventListener("keydown", onKey); - return () => { - window.removeEventListener("pointerdown", onDown, true); - window.removeEventListener("keydown", onKey); - }; - }, [onClose]); - - return ( -
-
- {title} - -
-
{children}
-
- ); -} - -/** Eine Editor-Zeile: Beschriftung links, Steuerelement rechts. */ - -// ── Layout-Menü (Oberleiste) ───────────────────────────────────────────────── - -/** - * Kleines Layout-Menü: aktuelles Layout unter einem Namen sichern und ein - * gespeichertes Layout wählen (oder löschen). Liest die Namen frisch aus dem - * localStorage, sobald das Menü geöffnet wird. - */ -// ── Inhalt (Mitte) ─────────────────────────────────────────────────────────── - -/** Hauptinhalt — abhängig von der aktiven Zeichnungsebene und dem Ansichtstyp. */ -function Content({ - project, - level, - viewType, - visibleCodes, - detail, - referenceLines, - hairline, - marqueeColor, - snapColor, - renderMode, - view3d, - fov, - grid3dVisible, - onToggleGrid3d, - section3d, - onClearSection3d, - mono, - drawings, - onSelectLevel, - floorDisplay, - categoryDisplay, - displayKey, - planViewRef, - onCursor, - onScale, - selectedWallIds, - selectedCeilingIds, - selectedOpeningIds, - selectedStairIds, - selectedExtrudedSolidIds, - selectedColumnIds, - selectedRoomIds, - selectedRoofIds, - selectedContextObjectIds, - selectedSectionLineId, - roomStamp, - onStampMove, - onStampEdit, - onEditDrawingText, - onPlanSelect, - onPlanMarquee, - onSegmentCut, - onPlanContextMenu, - onViewportSelectWall, - onViewportSelectCeiling, - onViewportSelectOpening, - onViewportSelectStair, - onViewportContextMenu, - onViewport3dPick, - commandActive, - draft, - onWorkplanePoint, - onWorkplaneConfirm, - activeTool, - toolInputActive, - toolHandlers, - hudFields, - grips, - edgeGrips, - selectedDrawingId, - selectedDrawingIds, - gripHandlers, - edit3dWalls, - edit3dDrawings, - edit3dCeilings, - edit3dRoofs, - onEdit3dVertex, - onEdit3dBody, - onEdit3dEdge, - onEdit3dRoofPitch, - onEdit3dWallTop, - onEdit3dEnd, -}: { - project: Project; - level: DrawingLevel; - viewType: ViewType; - visibleCodes: Set; - detail: DetailLevel; - referenceLines: boolean; - hairline: boolean; - /** Farbe des Auswahlrahmens (Marquee) im Grundriss. */ - marqueeColor: string; - /** Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss. */ - snapColor: string; - renderMode: RenderMode; - /** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */ - view3d: View3d; - /** Sichtwinkel der 3D-Perspektivkamera in Grad. */ - fov: number; - /** Boden-Referenzraster im 3D-Viewport sichtbar (wirkt nur im WASM-Pfad). */ - grid3dVisible: boolean; - /** Klick auf den Grid-Umschalter-Button im 3D-Viewport (WASM-Pfad). */ - onToggleGrid3d: () => void; - /** Aktive 3D-Schnittebene (world point+normal) der gebundenen Schnittlinie, - * oder null. Speist Viewport3D/Wasm3DViewport (Live-Schnitt). */ - section3d: { point: [number, number, number]; normal: [number, number, number] } | null; - /** Schnitt-Bindung im 3D-Viewport lösen (Klick auf den gebundenen Knopf). */ - onClearSection3d: () => void; - /** Grundriss in Schwarz-Weiss (mono) statt mit Kategorie-/Element-Farben. */ - mono: boolean; - /** Dokument-Tabs der Kopfzeile — ein Tab je Geschoss-Grundriss. */ - drawings: Drawing[]; - /** Aktiviert eine Zeichnungsebene (Geschoss) — geteilt mit dem Panel - * „Zeichnungsebenen"; siehe DrawingTabs.tsx. */ - onSelectLevel: (id: string) => void; - floorDisplay: DisplayResolver; - categoryDisplay: CategoryDisplayResolver & DisplayResolver; - displayKey: string; - planViewRef: React.RefObject; - onCursor: (model: Vec2 | null) => void; - onScale: (n: number) => void; - selectedWallIds: string[]; - selectedCeilingIds: string[]; - selectedOpeningIds: string[]; - selectedStairIds: string[]; - selectedExtrudedSolidIds: string[]; - selectedColumnIds: string[]; - selectedRoomIds: string[]; - selectedRoofIds: string[]; - selectedContextObjectIds: string[]; - selectedSectionLineId: string | null; - roomStamp: { roomId: string; anchor: Vec2 } | null; - onStampMove: (roomId: string, delta: Vec2) => void; - onStampEdit: (roomId: string) => void; - onEditDrawingText?: (drawingId: string) => void; - onPlanSelect: (sel: PlanSelection) => void; - onPlanMarquee: (sel: MarqueeSelection) => void; - onSegmentCut: (drawingId: string, model: Vec2) => void; - onPlanContextMenu: (info: { - clientX: number; - clientY: number; - model: Vec2; - }) => void; - onViewportSelectWall: (wallId: string | null) => void; - onViewportSelectCeiling: (ceilingId: string | null) => void; - onViewportSelectOpening: (openingId: string | null) => void; - onViewportSelectStair: (stairId: string | null) => void; - onViewportContextMenu: (info: ViewportContextInfo) => void; - /** Links-KLICK-Auswahl im WASM-3D-Viewport (TS-Raycast, Wand/Decke). */ - onViewport3dPick: (hit: Pick3dHit, additive: boolean) => void; - /** Läuft ein zeichnender Befehl? Macht den 3D-Viewport zur Erstellungs-Fläche. */ - commandActive: boolean; - /** Aktuelle Werkzeug-Vorschau (Rubber-Band) — auch in 3D gespiegelt. */ - draft: ToolDraft | null; - /** Auf die Arbeitsebene projizierter Punkt → CommandEngine (move/pick). */ - onWorkplanePoint: (kind: "move" | "pick", model: Vec2) => void; - /** Befehl bestätigen/beenden aus dem 3D-Viewport. */ - onWorkplaneConfirm: () => void; - activeTool: ToolId; - toolInputActive: boolean; - toolHandlers: ToolHandlers; - hudFields: HudFieldsState | null; - grips: Vec2[]; - edgeGrips: EdgeGrip[]; - selectedDrawingId: string | null; - selectedDrawingIds?: string[]; - gripHandlers: GripHandlers; - /** Alle gewählten Wände — Quelle der 3D-Editier-Griffe (Mehrfachauswahl ⇒ alle). */ - edit3dWalls: Wall[]; - /** Alle gewählten 2D-Elemente — 3D-Vertex-Griffe (Mehrfachauswahl ⇒ alle). */ - edit3dDrawings: Drawing2D[]; - /** Alle gewählten Decken — 3D-Eckpunkt-/Kanten-/Verschiebe-Griffe. */ - edit3dCeilings: Ceiling[]; - edit3dRoofs: Roof[]; - onEdit3dVertex: ( - target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }, - index: number, - model: Vec2, - excludeWallIds?: Set, - excludeDrawingIds?: Set, - ) => Vec2; - onEdit3dBody: ( - target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }, - delta: Vec2, - ) => void; - onEdit3dEdge: (ceilingId: string, aIndex: number, bIndex: number, delta: Vec2) => void; - onEdit3dRoofPitch: (roofId: string, apexHeight: number) => void; - onEdit3dWallTop: (wallId: string, topZ: number) => void; - onEdit3dEnd: () => void; -}) { - // Auswahl-Hervorhebung im WASM-3D-Viewport: Umriss der gewählten Wand(e)/ - // Decke(n) in Akzent-Orange, immer obenauf (s. selectionHighlightLines in - // plan/toWalls3d.ts + set_highlight_lines in useWasm3dRenderer). Wirkt NUR - // im WASM-Zweig (Viewport3D reicht sie nur dort an Wasm3DViewport durch); - // die three.js-Sicht hebt die Auswahl weiterhin über ihr eigenes Material - // hervor. Leere Auswahl → null (kein Highlight). - const highlightLines = useMemo( - () => - selectedWallIds.length === 0 && - selectedCeilingIds.length === 0 && - selectedOpeningIds.length === 0 && - selectedStairIds.length === 0 && - selectedRoofIds.length === 0 && - selectedContextObjectIds.length === 0 - ? null - : selectionHighlightLines( - project, - selectedWallIds, - selectedCeilingIds, - HIGHLIGHT_RGB, - selectedOpeningIds, - selectedStairIds, - selectedRoofIds, - selectedContextObjectIds, - ), - [ - project, - selectedWallIds, - selectedCeilingIds, - selectedOpeningIds, - selectedStairIds, - selectedRoofIds, - selectedContextObjectIds, - ], - ); - - if (level.kind === "section" || level.kind === "elevation") { - return ( -
- - -
- ); - } - - // Freie Zeichnung: dieselbe Plan-Ansicht wie ein Geschoss (generatePlan filtert - // `drawings2d` nach `level.id`), nur OHNE Geschoss-Eigenschaftsleiste und ohne - // 3D-Umschaltung — eine Zeichnung hat keine Höhe/3D. So sind importierte - // DXF-Polylinien hier sofort sichtbar und mit den 2D-Werkzeugen bearbeitbar. - if (level.kind === "drawing") { - return ( -
- - -
- ); - } - - // Geschoss: Dokument-Tabs + gewählte Ansicht. Geschosshöhe/Schnitthöhe - // werden in den Geschoss-Einstellungen gepflegt (Rechtsklick auf das - // Geschoss im Panel „Zeichnungsebenen"), nicht mehr hier in der Kopfzeile. - return ( -
- - {viewType === "perspektive" ? ( -
-
- {t("content.perspektiveTitle", { name: level.name })} -
- -
- ) : ( - - )} -
- ); -} - -/** Abgeleiteter Grundriss eines Geschosses. */ -function LevelPlanView({ - project, - level, - visibleCodes, - categoryDisplay, - detail, - referenceLines, - hairline, - marqueeColor, - snapColor, - mono, - planViewRef, - onCursor, - onScale, - selectedWallIds, - selectedCeilingIds, - selectedOpeningIds, - selectedStairIds, - selectedExtrudedSolidIds, - selectedColumnIds, - selectedRoomIds, - selectedRoofIds, - selectedContextObjectIds, - selectedSectionLineId, - roomStamp, - onStampMove, - onStampEdit, - onEditDrawingText, - onOpenSectionLevel, - onPlanSelect, - onPlanMarquee, - onSegmentCut, - onPlanContextMenu, - activeTool, - toolInputActive, - toolHandlers, - hudFields, - grips, - edgeGrips, - selectedDrawingId, - selectedDrawingIds, - gripHandlers, -}: { - project: Project; - level: DrawingLevel; - visibleCodes: Set; - categoryDisplay: CategoryDisplayResolver; - detail: DetailLevel; - referenceLines: boolean; - hairline: boolean; - marqueeColor: string; - snapColor: string; - mono: boolean; - planViewRef: React.RefObject; - onCursor: (model: Vec2 | null) => void; - onScale: (n: number) => void; - selectedWallIds: string[]; - selectedCeilingIds: string[]; - selectedOpeningIds: string[]; - selectedStairIds: string[]; - selectedExtrudedSolidIds: string[]; - selectedColumnIds: string[]; - selectedRoomIds: string[]; - selectedRoofIds: string[]; - selectedContextObjectIds?: string[]; - selectedSectionLineId: string | null; - roomStamp: { roomId: string; anchor: Vec2 } | null; - onStampMove: (roomId: string, delta: Vec2) => void; - onStampEdit: (roomId: string) => void; - onEditDrawingText?: (drawingId: string) => void; - onOpenSectionLevel?: (id: string) => void; - onPlanSelect: (sel: PlanSelection) => void; - onPlanMarquee: (sel: MarqueeSelection) => void; - onSegmentCut: (drawingId: string, model: Vec2) => void; - onPlanContextMenu: (info: { - clientX: number; - clientY: number; - model: Vec2; - }) => void; - activeTool: ToolId; - toolInputActive: boolean; - toolHandlers: ToolHandlers; - hudFields: HudFieldsState | null; - grips: Vec2[]; - edgeGrips: EdgeGrip[]; - selectedDrawingId: string | null; - selectedDrawingIds?: string[]; - gripHandlers: GripHandlers; -}) { - // Detailgrad + Referenzlinien fließen in die Plan-Erzeugung ein (Symbole, - // Schraffuren/Linienfeinheit bzw. Wand-Achslinien). - const plan = useMemo( - () => - generatePlan( - project, - level.id, - visibleCodes, - categoryDisplay, - detail, - referenceLines, - mono, - ), - [project, level.id, visibleCodes, categoryDisplay, detail, referenceLines, mono], - ); - - // Live-Spiegelung in die nativen wgpu-Fenster (No-op ohne Tauri, debounced): - // EXAKT der sichtbare Plan (gleiches Geschoss, gleicher Detailgrad) fürs - // 2D-Fenster, die Projekt-Wände fürs 3D-Fenster. - useEffect(() => { - pushNativeScene(plan); - }, [plan]); - useEffect(() => { - pushNativeWalls(project); - }, [project]); - - // Bei einer freien Zeichnung gibt es keine Schnitthöhe → eigene Legende/Titel. - const isDrawing = level.kind === "drawing"; - const caption = isDrawing - ? t("content.drawingLegend") - : t("content.grundrissLegend", { - name: level.name, - cutHeight: formatM(level.cutHeight ?? 0), - }); - - return ( -
-
- {isDrawing - ? t("content.drawingTitle", { name: level.name }) - : t("content.grundrissTitle", { name: level.name })} -
- -
{caption}
-
- ); -} - -/** - * Schnitt-/Ansichts-Ebene: der render3d-Schnitt-Extraktor (WASM, plan/toSection) - * durchschneidet das Modell entlang der Schnittlinie der Ebene und übersetzt das - * Ergebnis (Cut-Polygone + sichtbare/verdeckte Kanten) über generateSectionPlan - * in Plan-Primitive, die dieselbe PlanView wie ein Grundriss zeichnet. - * - * Die Berechnung ist asynchron (WASM lazy geladen); bis das Ergebnis da ist, - * bzw. wenn die Ebene keine Schnittlinie trägt oder das WASM-Modul den Export - * (noch) nicht kennt, wird ein Hinweis eingeblendet. - */ -function SectionPlanView({ - project, - level, - hairline, - marqueeColor, - snapColor, - mono, - planViewRef, - onCursor, - onScale, - selectedWallIds, - selectedCeilingIds, - selectedOpeningIds, - selectedStairIds, - selectedExtrudedSolidIds, - selectedColumnIds, - selectedRoomIds, - selectedRoofIds, - selectedSectionLineId, - roomStamp, - onStampMove, - onStampEdit, - onEditDrawingText, - onOpenSectionLevel, - onPlanSelect, - onPlanMarquee, - onSegmentCut, - onPlanContextMenu, - activeTool, - toolInputActive, - toolHandlers, - hudFields, - grips, - edgeGrips, - selectedDrawingId, - selectedDrawingIds, - gripHandlers, -}: { - project: Project; - level: DrawingLevel; - hairline: boolean; - marqueeColor: string; - snapColor: string; - mono: boolean; - planViewRef: React.RefObject; - onCursor: (model: Vec2 | null) => void; - onScale: (n: number) => void; - selectedWallIds: string[]; - selectedCeilingIds: string[]; - selectedOpeningIds: string[]; - selectedStairIds: string[]; - selectedExtrudedSolidIds: string[]; - selectedColumnIds: string[]; - selectedRoomIds: string[]; - selectedRoofIds: string[]; - selectedSectionLineId: string | null; - roomStamp: { roomId: string; anchor: Vec2 } | null; - onStampMove: (roomId: string, delta: Vec2) => void; - onStampEdit: (roomId: string) => void; - onEditDrawingText?: (drawingId: string) => void; - onOpenSectionLevel?: (id: string) => void; - onPlanSelect: (sel: PlanSelection) => void; - onPlanMarquee: (sel: MarqueeSelection) => void; - onSegmentCut: (drawingId: string, model: Vec2) => void; - onPlanContextMenu: (info: { clientX: number; clientY: number; model: Vec2 }) => void; - activeTool: ToolId; - toolInputActive: boolean; - toolHandlers: ToolHandlers; - hudFields: HudFieldsState | null; - grips: Vec2[]; - edgeGrips: EdgeGrip[]; - selectedDrawingId: string | null; - selectedDrawingIds?: string[]; - gripHandlers: GripHandlers; -}) { - // Die Ansicht (kind "elevation") läuft NICHT über den WASM-Schnitt, sondern - // über den rein TS-seitigen Flächen-Generator (Painter, toElevation.ts) — - // synchron, kein Lazy-Load. Nur der echte Schnitt braucht die WASM-Pipeline. - const isElevation = level.kind === "elevation"; - const [output, setOutput] = useState(null); - const [status, setStatus] = useState<"loading" | "ready" | "empty" | "error">( - "loading", - ); - - useEffect(() => { - if (isElevation) return; // Ansicht: kein WASM-Schnitt (synchroner Generator). - let disposed = false; - setStatus("loading"); - setOutput(null); - void (async () => { - try { - const out = await computeSection(project, level); - if (disposed) return; - if (!out) { - setStatus("empty"); - return; - } - setOutput(out); - setStatus("ready"); - } catch (e) { - console.warn("render3d-Schnitt fehlgeschlagen:", e); - if (!disposed) setStatus("error"); - } - })(); - return () => { - disposed = true; - }; - }, [project, level, isElevation]); - - // Ansichts-Plan: gefüllte Fassadenansicht inkl. optionalem Schatten - // (level.shadows). Liefert null, wenn die Ebene keine Ansichtslinie trägt. - const elevationPlan = useMemo( - () => (isElevation ? generateElevationPlan(project, level, { mono }) : null), - [isElevation, project, level, mono], - ); - - const sectionPlan = useMemo( - () => - !isElevation && output - ? generateSectionPlan(output, mono, { hiddenLines: level.hiddenLines }) - : null, - [isElevation, output, mono, level.hiddenLines], - ); - const plan = isElevation ? elevationPlan : sectionPlan; - const effStatus = isElevation ? (elevationPlan ? "ready" : "empty") : status; - - const kindLabel = level.kind === "section" ? t("stub.section") : t("stub.elevation"); - - if (!plan || effStatus !== "ready") { - const note = - effStatus === "empty" - ? t("section.empty", { kind: kindLabel }) - : effStatus === "error" - ? t("section.error", { kind: kindLabel }) - : t("section.loading", { kind: kindLabel }); - return ( -
- {level.name} - {note} -
- ); - } - - return ( -
-
- {t("content.sectionTitle", { name: level.name, kind: kindLabel })} -
- -
{t("section.legend", { kind: kindLabel })}
-
- ); -} diff --git a/src/menus/contextMenu.tsx b/src/menus/contextMenu.tsx new file mode 100644 index 0000000..5d95190 --- /dev/null +++ b/src/menus/contextMenu.tsx @@ -0,0 +1,322 @@ +// Kontextmenü-Builder — baut die Menü-Einträge je Quelle (Ebene / Zeichnungs- +// ebene / Grundriss / 3D-Viewport). 1:1 nach docs/design/context-menu.md. +// JEDER Eintrag ist verdrahtet ODER klar deaktiviert mit Tooltip (`title`, nennt +// den Grund) — kein stiller No-Op. +// +// Extrahiert aus App.tsx (reine Funktionen, keine Hooks/kein Store-Zugriff — die +// Aktionen kommen als `MenuActions` von App herein). Bezeichner englisch, +// Kommentare deutsch (CONVENTIONS.md). + +import { t } from "../i18n"; +import type { LayerCategory, Project } from "../model/types"; +import { flattenCategories } from "../model/types"; +import { findCategory } from "../state/projectSlice"; +import { MenuIcon } from "../ui/ContextMenu"; +import type { ContextMenuItem } from "../ui/ContextMenu"; + +/** Offenes Kontextmenü (genau eines). `x/y` = Anker in Viewport-Px. */ +export type MenuState = + | { kind: "layer"; code: string; x: number; y: number } + | { kind: "level"; id: string; x: number; y: number } + | { kind: "plan"; wallId: string | null; x: number; y: number } + | { kind: "viewport"; wallId: string | null; x: number; y: number } + | null; + +/** Titel des Kontextmenüs je Quelle (Ebenenname / Geschossname / Wand/Ansicht). */ +export function menuTitle(menu: NonNullable, project: Project): string { + switch (menu.kind) { + case "layer": { + const c = findCategory(project.layers, menu.code); + return c ? `${c.code} · ${c.name}` : menu.code; + } + case "level": { + const l = project.drawingLevels.find((z) => z.id === menu.id); + return l ? l.name : t("ctx.title.level"); + } + case "plan": + case "viewport": + return menu.wallId ? t("ctx.title.wall") : t("ctx.title.view"); + } +} + +/** Aktionen, die der Kontextmenü-Builder von App braucht (alle verdrahtet). */ +export interface MenuActions { + project: Project; + selectedWallId: string | null; + layerClipboard: { color: string; lw: number } | null; + openLayerEditor: (code: string) => void; + openLevelEditor: (id: string) => void; + addSubCategory: (parentCode: string) => void; + assignWallCategory: (wallId: string, code: string) => void; + duplicateCategory: (code: string) => void; + deleteCategory: (code: string) => void; + setLayerClipboard: (clip: { color: string; lw: number } | null) => void; + patchCategory: (code: string, patch: Partial) => void; + duplicateLevel: (id: string) => void; + deleteLevel: (id: string) => void; + clearSelection: () => void; + fit: () => void; + fitSelection: () => void; + /** Anzahl geschlossener Flächen in der aktuellen 2D-Auswahl (Boolean-Aktivierung). */ + closedSelectionCount: number; + union: () => void; + difference: () => void; + intersect: () => void; + /** Anzahl aktuell selektierter 2D-Zeichnungselemente (z-Anordnen-Menü). */ + drawingSelectionCount: number; + bringToFront: () => void; + bringForward: () => void; + sendBackward: () => void; + sendToBack: () => void; +} + +/** + * Baut die Kontextmenü-Einträge je Quelle — 1:1 nach docs/design/context-menu.md. + * JEDER Eintrag ist verdrahtet ODER klar deaktiviert mit Tooltip (`title`, + * nennt den Grund) — kein stiller No-Op. + */ +export function buildMenuItems( + menu: NonNullable, + a: MenuActions, +): ContextMenuItem[] { + switch (menu.kind) { + case "layer": + return layerMenuItems(menu.code, a); + case "level": + return levelMenuItems(menu.id, a); + case "plan": + return planMenuItems(menu.wallId, a); + case "viewport": + return viewportMenuItems(menu.wallId, a); + } +} + +/** Ebenen-Kontextmenü (Rechtsklick auf Ebenen-Zeile). */ +function layerMenuItems(code: string, a: MenuActions): ContextMenuItem[] { + const total = flattenCategories(a.project.layers).length; + const hasSelection = a.selectedWallId !== null; + const hasClipboard = a.layerClipboard !== null; + return [ + { + label: t("ctx.layerSettings"), + icon: , + onClick: () => a.openLayerEditor(code), + }, + { divider: true }, + { + label: t("ctx.addSubLayer"), + icon: , + onClick: () => a.addSubCategory(code), + }, + { + label: t("ctx.assignSelection"), + icon: , + disabled: !hasSelection, + title: hasSelection ? undefined : t("ctx.assignSelection.disabled"), + onClick: () => { + if (a.selectedWallId) a.assignWallCategory(a.selectedWallId, code); + }, + }, + { divider: true }, + { + label: t("ctx.duplicate"), + icon: , + onClick: () => a.duplicateCategory(code), + }, + { + label: t("ctx.copyProps"), + icon: , + onClick: () => { + const c = findCategory(a.project.layers, code); + if (c) a.setLayerClipboard({ color: c.color, lw: c.lw }); + }, + }, + { + label: t("ctx.pasteProps"), + icon: , + disabled: !hasClipboard, + title: hasClipboard ? undefined : t("ctx.pasteProps.disabled"), + onClick: () => { + if (a.layerClipboard) + a.patchCategory(code, { + color: a.layerClipboard.color, + lw: a.layerClipboard.lw, + }); + }, + }, + { divider: true }, + { + label: t("ctx.delete"), + icon: , + danger: true, + disabled: total <= 1, + title: total <= 1 ? t("ctx.delete.lastLayer") : undefined, + onClick: () => a.deleteCategory(code), + }, + ]; +} + +/** Zeichnungsebenen-Kontextmenü (Rechtsklick auf Geschoss/Schnitt/Zeichnung). */ +function levelMenuItems(id: string, a: MenuActions): ContextMenuItem[] { + const total = a.project.drawingLevels.length; + return [ + { + label: t("ctx.levelSettings"), + icon: , + onClick: () => a.openLevelEditor(id), + }, + { divider: true }, + { + label: t("ctx.duplicate"), + icon: , + onClick: () => a.duplicateLevel(id), + }, + { divider: true }, + { + label: t("ctx.delete"), + icon: , + danger: true, + disabled: total <= 1, + title: total <= 1 ? t("ctx.delete.lastLevel") : undefined, + onClick: () => a.deleteLevel(id), + }, + ]; +} + +/** + * Grundriss-Kontextmenü. Bezug ist die getroffene/gewählte Wand. Einträge sind + * verdrahtet: „Auf Auswahl einpassen" (PlanView-Handle) und „Auswahl aufheben". + * Ohne getroffene Wand bleibt nur „Einpassen". (Eigenschaften-Panel/Bemaßung + * folgen später — daher hier KEIN Schein-Eintrag.) + */ +function planMenuItems( + wallId: string | null, + a: MenuActions, +): ContextMenuItem[] { + // Boolean-Einträge (Vereinigen/Subtrahieren/Schneiden) auf die 2D-Auswahl. + // Aktiv ab 2 geschlossenen Flächen; sonst deaktiviert mit erklärendem Tooltip. + const boolEnabled = a.closedSelectionCount >= 2; + const boolTitle = boolEnabled ? undefined : t("ctx.boolean.disabled"); + const booleanItems: ContextMenuItem[] = [ + { + label: t("ctx.union"), + icon: , + disabled: !boolEnabled, + title: boolTitle, + onClick: a.union, + }, + { + label: t("ctx.subtract"), + icon: , + disabled: !boolEnabled, + title: boolTitle, + onClick: a.difference, + }, + { + label: t("ctx.intersect"), + icon: , + disabled: !boolEnabled, + title: boolTitle, + onClick: a.intersect, + }, + ]; + + // Z-Anordnen (nach vorn/hinten holen) — wirkt auf die aktuelle 2D-Auswahl, + // sortiert `drawings2d` um (Array-Reihenfolge = Zeichenreihenfolge). Aktiv + // ab 1 selektiertem Element; sonst deaktiviert mit erklärendem Tooltip. + const arrangeEnabled = a.drawingSelectionCount >= 1; + const arrangeTitle = arrangeEnabled ? undefined : t("ctx.arrange.disabled"); + const arrangeItems: ContextMenuItem[] = [ + { + label: t("ctx.arrange.front"), + icon: , + disabled: !arrangeEnabled, + title: arrangeTitle, + onClick: a.bringToFront, + }, + { + label: t("ctx.arrange.forward"), + icon: , + disabled: !arrangeEnabled, + title: arrangeTitle, + onClick: a.bringForward, + }, + { + label: t("ctx.arrange.backward"), + icon: , + disabled: !arrangeEnabled, + title: arrangeTitle, + onClick: a.sendBackward, + }, + { + label: t("ctx.arrange.back"), + icon: , + disabled: !arrangeEnabled, + title: arrangeTitle, + onClick: a.sendToBack, + }, + ]; + + if (wallId) { + return [ + { + label: t("ctx.fitSelection"), + icon: , + onClick: a.fitSelection, + }, + { + label: t("ctx.clearSelection"), + icon: , + onClick: a.clearSelection, + }, + { divider: true }, + ...booleanItems, + { divider: true }, + ...arrangeItems, + { divider: true }, + { + label: t("ctx.fit"), + icon: , + onClick: a.fit, + }, + ]; + } + return [ + ...booleanItems, + { divider: true }, + ...arrangeItems, + { divider: true }, + { + label: t("ctx.fit"), + icon: , + onClick: a.fit, + }, + ]; +} + +/** + * 3D-Kontextmenü. Im 3D gibt es (noch) kein Einpassen-Handle wie im Grundriss; + * verdrahtet ist „Auswahl aufheben" (mit getroffener Wand). Ohne Wand ist das + * Menü leer-sinnvoll: ein deaktivierter Hinweis, kein stiller No-Op. + */ +function viewportMenuItems( + wallId: string | null, + a: MenuActions, +): ContextMenuItem[] { + if (wallId) { + return [ + { + label: t("ctx.clearSelection"), + icon: , + onClick: a.clearSelection, + }, + ]; + } + return [ + { + label: t("ctx.noWallHit"), + disabled: true, + title: t("ctx.noWallHit.hint"), + }, + ]; +} diff --git a/src/styles.css b/src/styles.css index d1f2114..0513f4f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2080,9 +2080,16 @@ body { opacity: 0.85; } -/* Auswahl-Hervorhebung (Links-Klick): Akzentkontur + leichte Füllung. */ +/* Auswahl-Hervorhebung (Links-Klick): Akzentkontur + leichte Füllung. + `fill-opacity` erzwingt die Transparenz unabhängig vom Theme — im Hell-Theme + ist `--accent-dim` fast deckend weiss (Nutzung als UI-Hintergrundfarbe an + ~70 anderen Stellen), würde ohne eigene Opacity die Schraffur der gewählten + Wand/Decke/des Raums vollständig verdecken (nur im Dunkel-Theme zufällig + unauffällig, da dort selbst schon rgba mit niedriger Alpha). Gleiches Muster + wie .plan-marquee/.tool-preview-fill weiter unten. */ .plan-svg .plan-selected { fill: var(--accent-dim); + fill-opacity: 0.35; stroke: var(--accent); stroke-width: 2; vector-effect: non-scaling-stroke; diff --git a/src/views/InlineEditor.tsx b/src/views/InlineEditor.tsx new file mode 100644 index 0000000..5bcc619 --- /dev/null +++ b/src/views/InlineEditor.tsx @@ -0,0 +1,74 @@ +// Kleiner schwebender Editor (dunkel, wie das Kontextmenü) am Anker. Schließt +// über Außenklick / Esc. Rein darstellend: die Felder rufen die übergebenen +// Patch-Handler, die live auf den State wirken (kein Bestätigen nötig). +// +// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import React, { useEffect, useRef, useState } from "react"; +import { t } from "../i18n"; + +export function InlineEditor({ + x, + y, + title, + onClose, + children, +}: { + x: number; + y: number; + title: string; + onClose: () => void; + children: React.ReactNode; +}) { + const ref = useRef(null); + // An die Viewport-Ränder klemmen (wie das Kontextmenü). + const [pos, setPos] = useState({ x, y }); + useEffect(() => { + const el = ref.current; + if (!el) return; + const r = el.getBoundingClientRect(); + let nx = x; + let ny = y; + if (nx + r.width > window.innerWidth - 4) nx = window.innerWidth - r.width - 4; + if (ny + r.height > window.innerHeight - 4) + ny = window.innerHeight - r.height - 4; + setPos({ x: Math.max(4, nx), y: Math.max(4, ny) }); + }, [x, y]); + // Schließen bei Außenklick / Esc. + useEffect(() => { + const onDown = (e: PointerEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("pointerdown", onDown, true); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("pointerdown", onDown, true); + window.removeEventListener("keydown", onKey); + }; + }, [onClose]); + + return ( +
+
+ {title} + +
+
{children}
+
+ ); +} diff --git a/src/views/viewportContent.tsx b/src/views/viewportContent.tsx new file mode 100644 index 0000000..c2168da --- /dev/null +++ b/src/views/viewportContent.tsx @@ -0,0 +1,855 @@ +// Haupt-Viewport-Inhalt (Mitte) — abhängig von aktiver Zeichnungsebene und +// Ansichtstyp. `Content` ist der View-Router: Geschoss→Grundriss/3D, +// Schnitt/Ansicht→SectionPlanView, freie Zeichnung→LevelPlanView. Die beiden +// Plan-Ableitungen (Grundriss bzw. Schnitt/Ansicht) zeichnen dieselbe PlanView. +// +// Extrahiert aus App.tsx (top-level, alles über Props — keine App-Closure). +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import { useEffect, useMemo, useState } from "react"; + +import { t } from "../i18n"; +import { formatM } from "../model/types"; +import type { + Ceiling, + Drawing2D, + DrawingLevel, + EdgeGrip, + Project, + Roof, + Vec2, + Wall, +} from "../model/types"; +import { generatePlan, generateSectionPlan } from "../plan/generatePlan"; +import type { CategoryDisplayResolver } from "../plan/generatePlan"; +import { computeSection } from "../plan/toSection"; +import type { SectionOutput } from "../plan/toSection"; +import { generateElevationPlan } from "../plan/toElevation"; +import { pushNativeScene, pushNativeWalls } from "../plan/nativeSync"; +import { selectionHighlightLines } from "../plan/toWalls3d"; +import { PlanView } from "../plan/PlanView"; +import type { + GripHandlers, + HudFieldsState, + MarqueeSelection, + PlanSelection, + PlanViewHandle, +} from "../plan/PlanView"; +import { Viewport3D } from "../viewport/Viewport3D"; +import type { DisplayResolver, ViewportContextInfo } from "../viewport/Viewport3D"; +import type { Pick3dHit } from "../viewport/Wasm3DViewport"; +import { DrawingTabs } from "../DrawingTabs"; +import type { Drawing } from "../DrawingTabs"; +import type { ToolDraft, ToolHandlers, ToolId } from "../tools/types"; +import type { DetailLevel, RenderMode, View3d, ViewType } from "../ui/TopBar"; + +/** + * Akzentfarbe der 3D-Auswahl-Hervorhebung (Umriss-Linien, WASM-Viewport, s. + * `Content`/`selectionHighlightLines`). Kräftiges Orange — hebt sich vom + * grauen Wand-/Deckenton, dem hellen Hidden-Line-Weiss und dem Bodenraster ab. + */ +const HIGHLIGHT_RGB: [number, number, number] = [1.0, 0.5, 0.1]; + +/** Hauptinhalt — abhängig von der aktiven Zeichnungsebene und dem Ansichtstyp. */ +export function Content({ + project, + level, + viewType, + visibleCodes, + detail, + referenceLines, + hairline, + marqueeColor, + snapColor, + renderMode, + view3d, + fov, + grid3dVisible, + onToggleGrid3d, + section3d, + onClearSection3d, + mono, + drawings, + onSelectLevel, + floorDisplay, + categoryDisplay, + displayKey, + planViewRef, + onCursor, + onScale, + selectedWallIds, + selectedCeilingIds, + selectedOpeningIds, + selectedStairIds, + selectedExtrudedSolidIds, + selectedColumnIds, + selectedRoomIds, + selectedRoofIds, + selectedContextObjectIds, + selectedSectionLineId, + roomStamp, + onStampMove, + onStampEdit, + onEditDrawingText, + onPlanSelect, + onPlanMarquee, + onSegmentCut, + onPlanContextMenu, + onViewportSelectWall, + onViewportSelectCeiling, + onViewportSelectOpening, + onViewportSelectStair, + onViewportContextMenu, + onViewport3dPick, + commandActive, + draft, + onWorkplanePoint, + onWorkplaneConfirm, + activeTool, + toolInputActive, + toolHandlers, + hudFields, + grips, + edgeGrips, + selectedDrawingId, + selectedDrawingIds, + gripHandlers, + edit3dWalls, + edit3dDrawings, + edit3dCeilings, + edit3dRoofs, + onEdit3dVertex, + onEdit3dBody, + onEdit3dEdge, + onEdit3dRoofPitch, + onEdit3dWallTop, + onEdit3dEnd, +}: { + project: Project; + level: DrawingLevel; + viewType: ViewType; + visibleCodes: Set; + detail: DetailLevel; + referenceLines: boolean; + hairline: boolean; + /** Farbe des Auswahlrahmens (Marquee) im Grundriss. */ + marqueeColor: string; + /** Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss. */ + snapColor: string; + renderMode: RenderMode; + /** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */ + view3d: View3d; + /** Sichtwinkel der 3D-Perspektivkamera in Grad. */ + fov: number; + /** Boden-Referenzraster im 3D-Viewport sichtbar (wirkt nur im WASM-Pfad). */ + grid3dVisible: boolean; + /** Klick auf den Grid-Umschalter-Button im 3D-Viewport (WASM-Pfad). */ + onToggleGrid3d: () => void; + /** Aktive 3D-Schnittebene (world point+normal) der gebundenen Schnittlinie, + * oder null. Speist Viewport3D/Wasm3DViewport (Live-Schnitt). */ + section3d: { point: [number, number, number]; normal: [number, number, number] } | null; + /** Schnitt-Bindung im 3D-Viewport lösen (Klick auf den gebundenen Knopf). */ + onClearSection3d: () => void; + /** Grundriss in Schwarz-Weiss (mono) statt mit Kategorie-/Element-Farben. */ + mono: boolean; + /** Dokument-Tabs der Kopfzeile — ein Tab je Geschoss-Grundriss. */ + drawings: Drawing[]; + /** Aktiviert eine Zeichnungsebene (Geschoss) — geteilt mit dem Panel + * „Zeichnungsebenen"; siehe DrawingTabs.tsx. */ + onSelectLevel: (id: string) => void; + floorDisplay: DisplayResolver; + categoryDisplay: CategoryDisplayResolver & DisplayResolver; + displayKey: string; + planViewRef: React.RefObject; + onCursor: (model: Vec2 | null) => void; + onScale: (n: number) => void; + selectedWallIds: string[]; + selectedCeilingIds: string[]; + selectedOpeningIds: string[]; + selectedStairIds: string[]; + selectedExtrudedSolidIds: string[]; + selectedColumnIds: string[]; + selectedRoomIds: string[]; + selectedRoofIds: string[]; + selectedContextObjectIds: string[]; + selectedSectionLineId: string | null; + roomStamp: { roomId: string; anchor: Vec2 } | null; + onStampMove: (roomId: string, delta: Vec2) => void; + onStampEdit: (roomId: string) => void; + onEditDrawingText?: (drawingId: string) => void; + onPlanSelect: (sel: PlanSelection) => void; + onPlanMarquee: (sel: MarqueeSelection) => void; + onSegmentCut: (drawingId: string, model: Vec2) => void; + onPlanContextMenu: (info: { + clientX: number; + clientY: number; + model: Vec2; + }) => void; + onViewportSelectWall: (wallId: string | null) => void; + onViewportSelectCeiling: (ceilingId: string | null) => void; + onViewportSelectOpening: (openingId: string | null) => void; + onViewportSelectStair: (stairId: string | null) => void; + onViewportContextMenu: (info: ViewportContextInfo) => void; + /** Links-KLICK-Auswahl im WASM-3D-Viewport (TS-Raycast, Wand/Decke). */ + onViewport3dPick: (hit: Pick3dHit, additive: boolean) => void; + /** Läuft ein zeichnender Befehl? Macht den 3D-Viewport zur Erstellungs-Fläche. */ + commandActive: boolean; + /** Aktuelle Werkzeug-Vorschau (Rubber-Band) — auch in 3D gespiegelt. */ + draft: ToolDraft | null; + /** Auf die Arbeitsebene projizierter Punkt → CommandEngine (move/pick). */ + onWorkplanePoint: (kind: "move" | "pick", model: Vec2) => void; + /** Befehl bestätigen/beenden aus dem 3D-Viewport. */ + onWorkplaneConfirm: () => void; + activeTool: ToolId; + toolInputActive: boolean; + toolHandlers: ToolHandlers; + hudFields: HudFieldsState | null; + grips: Vec2[]; + edgeGrips: EdgeGrip[]; + selectedDrawingId: string | null; + selectedDrawingIds?: string[]; + gripHandlers: GripHandlers; + /** Alle gewählten Wände — Quelle der 3D-Editier-Griffe (Mehrfachauswahl ⇒ alle). */ + edit3dWalls: Wall[]; + /** Alle gewählten 2D-Elemente — 3D-Vertex-Griffe (Mehrfachauswahl ⇒ alle). */ + edit3dDrawings: Drawing2D[]; + /** Alle gewählten Decken — 3D-Eckpunkt-/Kanten-/Verschiebe-Griffe. */ + edit3dCeilings: Ceiling[]; + edit3dRoofs: Roof[]; + onEdit3dVertex: ( + target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }, + index: number, + model: Vec2, + excludeWallIds?: Set, + excludeDrawingIds?: Set, + ) => Vec2; + onEdit3dBody: ( + target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }, + delta: Vec2, + ) => void; + onEdit3dEdge: (ceilingId: string, aIndex: number, bIndex: number, delta: Vec2) => void; + onEdit3dRoofPitch: (roofId: string, apexHeight: number) => void; + onEdit3dWallTop: (wallId: string, topZ: number) => void; + onEdit3dEnd: () => void; +}) { + // Auswahl-Hervorhebung im WASM-3D-Viewport: Umriss der gewählten Wand(e)/ + // Decke(n) in Akzent-Orange, immer obenauf (s. selectionHighlightLines in + // plan/toWalls3d.ts + set_highlight_lines in useWasm3dRenderer). Wirkt NUR + // im WASM-Zweig (Viewport3D reicht sie nur dort an Wasm3DViewport durch); + // die three.js-Sicht hebt die Auswahl weiterhin über ihr eigenes Material + // hervor. Leere Auswahl → null (kein Highlight). + const highlightLines = useMemo( + () => + selectedWallIds.length === 0 && + selectedCeilingIds.length === 0 && + selectedOpeningIds.length === 0 && + selectedStairIds.length === 0 && + selectedRoofIds.length === 0 && + selectedContextObjectIds.length === 0 + ? null + : selectionHighlightLines( + project, + selectedWallIds, + selectedCeilingIds, + HIGHLIGHT_RGB, + selectedOpeningIds, + selectedStairIds, + selectedRoofIds, + selectedContextObjectIds, + ), + [ + project, + selectedWallIds, + selectedCeilingIds, + selectedOpeningIds, + selectedStairIds, + selectedRoofIds, + selectedContextObjectIds, + ], + ); + + if (level.kind === "section" || level.kind === "elevation") { + return ( +
+ + +
+ ); + } + + // Freie Zeichnung: dieselbe Plan-Ansicht wie ein Geschoss (generatePlan filtert + // `drawings2d` nach `level.id`), nur OHNE Geschoss-Eigenschaftsleiste und ohne + // 3D-Umschaltung — eine Zeichnung hat keine Höhe/3D. So sind importierte + // DXF-Polylinien hier sofort sichtbar und mit den 2D-Werkzeugen bearbeitbar. + if (level.kind === "drawing") { + return ( +
+ + +
+ ); + } + + // Geschoss: Dokument-Tabs + gewählte Ansicht. Geschosshöhe/Schnitthöhe + // werden in den Geschoss-Einstellungen gepflegt (Rechtsklick auf das + // Geschoss im Panel „Zeichnungsebenen"), nicht mehr hier in der Kopfzeile. + return ( +
+ + {viewType === "perspektive" ? ( +
+
+ {t("content.perspektiveTitle", { name: level.name })} +
+ +
+ ) : ( + + )} +
+ ); +} + +/** Abgeleiteter Grundriss eines Geschosses. */ +function LevelPlanView({ + project, + level, + visibleCodes, + categoryDisplay, + detail, + referenceLines, + hairline, + marqueeColor, + snapColor, + mono, + planViewRef, + onCursor, + onScale, + selectedWallIds, + selectedCeilingIds, + selectedOpeningIds, + selectedStairIds, + selectedExtrudedSolidIds, + selectedColumnIds, + selectedRoomIds, + selectedRoofIds, + selectedContextObjectIds, + selectedSectionLineId, + roomStamp, + onStampMove, + onStampEdit, + onEditDrawingText, + onOpenSectionLevel, + onPlanSelect, + onPlanMarquee, + onSegmentCut, + onPlanContextMenu, + activeTool, + toolInputActive, + toolHandlers, + hudFields, + grips, + edgeGrips, + selectedDrawingId, + selectedDrawingIds, + gripHandlers, +}: { + project: Project; + level: DrawingLevel; + visibleCodes: Set; + categoryDisplay: CategoryDisplayResolver; + detail: DetailLevel; + referenceLines: boolean; + hairline: boolean; + marqueeColor: string; + snapColor: string; + mono: boolean; + planViewRef: React.RefObject; + onCursor: (model: Vec2 | null) => void; + onScale: (n: number) => void; + selectedWallIds: string[]; + selectedCeilingIds: string[]; + selectedOpeningIds: string[]; + selectedStairIds: string[]; + selectedExtrudedSolidIds: string[]; + selectedColumnIds: string[]; + selectedRoomIds: string[]; + selectedRoofIds: string[]; + selectedContextObjectIds?: string[]; + selectedSectionLineId: string | null; + roomStamp: { roomId: string; anchor: Vec2 } | null; + onStampMove: (roomId: string, delta: Vec2) => void; + onStampEdit: (roomId: string) => void; + onEditDrawingText?: (drawingId: string) => void; + onOpenSectionLevel?: (id: string) => void; + onPlanSelect: (sel: PlanSelection) => void; + onPlanMarquee: (sel: MarqueeSelection) => void; + onSegmentCut: (drawingId: string, model: Vec2) => void; + onPlanContextMenu: (info: { + clientX: number; + clientY: number; + model: Vec2; + }) => void; + activeTool: ToolId; + toolInputActive: boolean; + toolHandlers: ToolHandlers; + hudFields: HudFieldsState | null; + grips: Vec2[]; + edgeGrips: EdgeGrip[]; + selectedDrawingId: string | null; + selectedDrawingIds?: string[]; + gripHandlers: GripHandlers; +}) { + // Detailgrad + Referenzlinien fließen in die Plan-Erzeugung ein (Symbole, + // Schraffuren/Linienfeinheit bzw. Wand-Achslinien). + const plan = useMemo( + () => + generatePlan( + project, + level.id, + visibleCodes, + categoryDisplay, + detail, + referenceLines, + mono, + ), + [project, level.id, visibleCodes, categoryDisplay, detail, referenceLines, mono], + ); + + // Live-Spiegelung in die nativen wgpu-Fenster (No-op ohne Tauri, debounced): + // EXAKT der sichtbare Plan (gleiches Geschoss, gleicher Detailgrad) fürs + // 2D-Fenster, die Projekt-Wände fürs 3D-Fenster. + useEffect(() => { + pushNativeScene(plan); + }, [plan]); + useEffect(() => { + pushNativeWalls(project); + }, [project]); + + // Bei einer freien Zeichnung gibt es keine Schnitthöhe → eigene Legende/Titel. + const isDrawing = level.kind === "drawing"; + const caption = isDrawing + ? t("content.drawingLegend") + : t("content.grundrissLegend", { + name: level.name, + cutHeight: formatM(level.cutHeight ?? 0), + }); + + return ( +
+
+ {isDrawing + ? t("content.drawingTitle", { name: level.name }) + : t("content.grundrissTitle", { name: level.name })} +
+ +
{caption}
+
+ ); +} + +/** + * Schnitt-/Ansichts-Ebene: der render3d-Schnitt-Extraktor (WASM, plan/toSection) + * durchschneidet das Modell entlang der Schnittlinie der Ebene und übersetzt das + * Ergebnis (Cut-Polygone + sichtbare/verdeckte Kanten) über generateSectionPlan + * in Plan-Primitive, die dieselbe PlanView wie ein Grundriss zeichnet. + * + * Die Berechnung ist asynchron (WASM lazy geladen); bis das Ergebnis da ist, + * bzw. wenn die Ebene keine Schnittlinie trägt oder das WASM-Modul den Export + * (noch) nicht kennt, wird ein Hinweis eingeblendet. + */ +function SectionPlanView({ + project, + level, + hairline, + marqueeColor, + snapColor, + mono, + planViewRef, + onCursor, + onScale, + selectedWallIds, + selectedCeilingIds, + selectedOpeningIds, + selectedStairIds, + selectedExtrudedSolidIds, + selectedColumnIds, + selectedRoomIds, + selectedRoofIds, + selectedSectionLineId, + roomStamp, + onStampMove, + onStampEdit, + onEditDrawingText, + onOpenSectionLevel, + onPlanSelect, + onPlanMarquee, + onSegmentCut, + onPlanContextMenu, + activeTool, + toolInputActive, + toolHandlers, + hudFields, + grips, + edgeGrips, + selectedDrawingId, + selectedDrawingIds, + gripHandlers, +}: { + project: Project; + level: DrawingLevel; + hairline: boolean; + marqueeColor: string; + snapColor: string; + mono: boolean; + planViewRef: React.RefObject; + onCursor: (model: Vec2 | null) => void; + onScale: (n: number) => void; + selectedWallIds: string[]; + selectedCeilingIds: string[]; + selectedOpeningIds: string[]; + selectedStairIds: string[]; + selectedExtrudedSolidIds: string[]; + selectedColumnIds: string[]; + selectedRoomIds: string[]; + selectedRoofIds: string[]; + selectedSectionLineId: string | null; + roomStamp: { roomId: string; anchor: Vec2 } | null; + onStampMove: (roomId: string, delta: Vec2) => void; + onStampEdit: (roomId: string) => void; + onEditDrawingText?: (drawingId: string) => void; + onOpenSectionLevel?: (id: string) => void; + onPlanSelect: (sel: PlanSelection) => void; + onPlanMarquee: (sel: MarqueeSelection) => void; + onSegmentCut: (drawingId: string, model: Vec2) => void; + onPlanContextMenu: (info: { clientX: number; clientY: number; model: Vec2 }) => void; + activeTool: ToolId; + toolInputActive: boolean; + toolHandlers: ToolHandlers; + hudFields: HudFieldsState | null; + grips: Vec2[]; + edgeGrips: EdgeGrip[]; + selectedDrawingId: string | null; + selectedDrawingIds?: string[]; + gripHandlers: GripHandlers; +}) { + // Die Ansicht (kind "elevation") läuft NICHT über den WASM-Schnitt, sondern + // über den rein TS-seitigen Flächen-Generator (Painter, toElevation.ts) — + // synchron, kein Lazy-Load. Nur der echte Schnitt braucht die WASM-Pipeline. + const isElevation = level.kind === "elevation"; + const [output, setOutput] = useState(null); + const [status, setStatus] = useState<"loading" | "ready" | "empty" | "error">( + "loading", + ); + + useEffect(() => { + if (isElevation) return; // Ansicht: kein WASM-Schnitt (synchroner Generator). + let disposed = false; + setStatus("loading"); + setOutput(null); + void (async () => { + try { + const out = await computeSection(project, level); + if (disposed) return; + if (!out) { + setStatus("empty"); + return; + } + setOutput(out); + setStatus("ready"); + } catch (e) { + console.warn("render3d-Schnitt fehlgeschlagen:", e); + if (!disposed) setStatus("error"); + } + })(); + return () => { + disposed = true; + }; + }, [project, level, isElevation]); + + // Ansichts-Plan: gefüllte Fassadenansicht inkl. optionalem Schatten + // (level.shadows). Liefert null, wenn die Ebene keine Ansichtslinie trägt. + const elevationPlan = useMemo( + () => (isElevation ? generateElevationPlan(project, level, { mono }) : null), + [isElevation, project, level, mono], + ); + + const sectionPlan = useMemo( + () => + !isElevation && output + ? generateSectionPlan(output, mono, { hiddenLines: level.hiddenLines }) + : null, + [isElevation, output, mono, level.hiddenLines], + ); + const plan = isElevation ? elevationPlan : sectionPlan; + const effStatus = isElevation ? (elevationPlan ? "ready" : "empty") : status; + + const kindLabel = level.kind === "section" ? t("stub.section") : t("stub.elevation"); + + if (!plan || effStatus !== "ready") { + const note = + effStatus === "empty" + ? t("section.empty", { kind: kindLabel }) + : effStatus === "error" + ? t("section.error", { kind: kindLabel }) + : t("section.loading", { kind: kindLabel }); + return ( +
+ {level.name} + {note} +
+ ); + } + + return ( +
+
+ {t("content.sectionTitle", { name: level.name, kind: kindLabel })} +
+ +
{t("section.legend", { kind: kindLabel })}
+
+ ); +}