fabf97a402
Ein selektiertes Bauteil wird jetzt im 3D-Viewport mit einer orangen Umrisslinie markiert, die dank No-Depth-Pipeline auch hinter Wänden durchscheint (klare Selektions-Rückmeldung). Zusammen mit Objekt-Info (numerisch) und Attribute-Panel ist die 3D-Auswahl damit vollständig. - Engine: set_highlight_lines(vertices) + highlight_pipeline (LineList, depth_compare Always, kein Depth-Write), als letzter Draw-Call obenauf. Reuse der Grid-Linien-Infrastruktur. - TS: selectionHighlightLines() baut die Quader-/Prisma-Kanten der Auswahl in Akzentfarbe; App berechnet sie per useMemo aus der Store-Auswahl und reicht sie durch.
2526 lines
102 KiB
TypeScript
2526 lines
102 KiB
TypeScript
// 3D-Viewport mit Three.js. Baut das Gebäude aus dem semantischen Modell:
|
||
// Wände als Boxen, Türen als echte Aussparungen (Segmente + Sturz).
|
||
// Es werden ALLE sichtbaren Geschosse gerendert, vertikal gestapelt nach
|
||
// ihrer baseElevation (EG bei 0, OG darüber).
|
||
|
||
import type { MutableRefObject } from "react";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import * as THREE from "three";
|
||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
|
||
import type {
|
||
Ceiling,
|
||
ContextObject,
|
||
Drawing2D,
|
||
DrawingLevel,
|
||
Opening,
|
||
Project,
|
||
Stair,
|
||
Vec2,
|
||
Wall,
|
||
} from "../model/types";
|
||
import {
|
||
flattenCategories,
|
||
getCeilingType,
|
||
getComponent,
|
||
getWallType,
|
||
stairsOfFloor,
|
||
wallTypeThickness,
|
||
} from "../model/types";
|
||
import { along, clippedBand } from "../model/geometry";
|
||
import { stairGeometry } from "../geometry/stair";
|
||
import {
|
||
doorSymbol,
|
||
openingInterval,
|
||
openingVerticalExtent,
|
||
wallAxisFrame,
|
||
} from "../geometry/opening";
|
||
import { computeJoins } from "../model/joins";
|
||
import type { WallCuts } from "../model/joins";
|
||
import {
|
||
ceilingVerticalExtent,
|
||
stairVerticalExtent,
|
||
wallReferenceOffset,
|
||
wallVerticalExtent,
|
||
} from "../model/wall";
|
||
import { MaterialRuntime } from "../materials/runtime";
|
||
import { Wasm3DViewport } from "./Wasm3DViewport";
|
||
import type { Pick3dHit } from "./Wasm3DViewport";
|
||
import type { RenderMode, View3d } from "../ui/TopBar";
|
||
import type { ToolDraft } from "../tools/types";
|
||
import { applyAngleConstraint } from "../tools/snapping";
|
||
|
||
/**
|
||
* Render-Entscheidung je Element (Geschoss bzw. Kategorie) im 3D — gespiegelt
|
||
* aus dem Panel-Darstellungsmodus (vgl. ItemDisplay). `render`=false lässt das
|
||
* Element weg, `greyed`=true zeichnet es mit gedämpftem, transparentem Material.
|
||
*/
|
||
export interface RenderDisplay {
|
||
render: boolean;
|
||
greyed: boolean;
|
||
}
|
||
|
||
/** Resolver: liefert je Geschoss-ID / Kategorie-Code die Render-Entscheidung. */
|
||
export type DisplayResolver = (key: string) => RenderDisplay;
|
||
|
||
/** Standard-Resolver: alles normal zeichnen (kein Greying). */
|
||
const SHOW_ALL: DisplayResolver = () => ({ render: true, greyed: false });
|
||
|
||
/** Prüft, ob WebGL im Browser verfügbar ist (kann z. B. in Firefox aus sein). */
|
||
function isWebGLAvailable(): boolean {
|
||
try {
|
||
const canvas = document.createElement("canvas");
|
||
return (
|
||
!!window.WebGLRenderingContext &&
|
||
!!(canvas.getContext("webgl") || canvas.getContext("experimental-webgl"))
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Rechtsklick-Info im 3D-Viewport: Viewport-Pixel + optional getroffene Wand. */
|
||
export interface ViewportContextInfo {
|
||
clientX: number;
|
||
clientY: number;
|
||
/** ID der per Raycast getroffenen Wand, oder null (Leerraum/Boden). */
|
||
wallId: string | null;
|
||
}
|
||
|
||
/**
|
||
* Engine-Umschalter für die 3D-Sicht. Die Engine (render3d/wgpu) ist der
|
||
* STANDARD-3D-Renderer; three.js ist nur noch Fallback — aktiv, wenn WebGPU
|
||
* fehlt oder explizit WebGL2 gewählt ist (`?engine=webgl2` bzw. Statusleiste →
|
||
* localStorage `cad.rendererMode === "webgl2"`). `?engine=wasm` erzwingt weiter
|
||
* die Engine. Einmal je Seitenladung ausgewertet: ein Wechsel läuft über einen
|
||
* Reload (setRendererMode).
|
||
*/
|
||
const WASM_ENGINE_ACTIVE: boolean = (() => {
|
||
if (typeof window === "undefined" || typeof navigator === "undefined") return false;
|
||
const fromUrl = new URLSearchParams(window.location.search).get("engine");
|
||
const wanted = fromUrl
|
||
? fromUrl === "wasm"
|
||
: window.localStorage.getItem("cad.rendererMode") !== "webgl2";
|
||
if (!wanted) return false;
|
||
if (!("gpu" in navigator)) {
|
||
console.warn("engine=wasm angefragt, aber navigator.gpu fehlt → three.js-Fallback (3D)");
|
||
return false;
|
||
}
|
||
return true;
|
||
})();
|
||
|
||
/**
|
||
* Öffentliche 3D-Sicht: wählt zwischen der nativen Engine (render3d als
|
||
* WASM/WebGPU, Wasm3DViewport) und der three.js-Szene (ThreeViewport3D,
|
||
* unveränderter Standardpfad). Der Engine-Pfad ist ein Integrations-Spike und
|
||
* nutzt vorerst nur das Projekt (Massenmodell + Orbit); alle weiteren Props
|
||
* (Auswahl, Render-Modi, Editier-Griffe) wirken nur im three.js-Pfad.
|
||
*/
|
||
export function Viewport3D(
|
||
props: Parameters<typeof ThreeViewport3D>[0] & {
|
||
/** Boden-Referenzraster ein-/ausblenden — wirkt NUR im WASM-Pfad (der
|
||
* Overlay-Umschalter sitzt im Wasm3DViewport selbst); die three.js-Sicht
|
||
* zeigt ihr Raster unveraendert immer (kein Toggle dort). */
|
||
gridVisible?: boolean;
|
||
/** Klick auf den Grid-Umschalter-Button im WASM-Viewport — Single Source
|
||
* of Truth bleibt der Aufrufer (App/viewSlice `grid3dVisible`). */
|
||
onToggleGrid?: () => void;
|
||
/** Links-Klick-Auswahl im WASM-Viewport (TS-Raycast) — wirkt NUR im WASM-Pfad;
|
||
* die three.js-Sicht nutzt weiter `onSelectWall`/`onSelectCeiling`. */
|
||
onPick3d?: (hit: Pick3dHit, additive: boolean) => void;
|
||
/** Umriss-Linien der aktuellen Auswahl — wirkt NUR im WASM-Pfad (s.
|
||
* Wasm3DViewport); die three.js-Sicht hebt die Auswahl weiterhin über ihr
|
||
* eigenes Material hervor (highlightMat in ThreeViewport3D). */
|
||
highlightLines?: Float32Array | null;
|
||
},
|
||
) {
|
||
const { gridVisible, onToggleGrid, onPick3d, highlightLines, ...threeProps } = props;
|
||
if (WASM_ENGINE_ACTIVE) {
|
||
return (
|
||
<Wasm3DViewport
|
||
project={props.project}
|
||
view3d={props.view3d}
|
||
renderMode={props.renderMode}
|
||
gridVisible={gridVisible}
|
||
groundElevation={props.gridElevation}
|
||
onToggleGrid={onToggleGrid}
|
||
onPick3d={onPick3d}
|
||
highlightLines={highlightLines}
|
||
/>
|
||
);
|
||
}
|
||
return <ThreeViewport3D {...threeProps} />;
|
||
}
|
||
|
||
function ThreeViewport3D({
|
||
project,
|
||
visibleCodes,
|
||
floorDisplay = SHOW_ALL,
|
||
categoryDisplay = SHOW_ALL,
|
||
displayKey = "",
|
||
renderMode = "shaded",
|
||
view3d = "perspective",
|
||
fov = 50,
|
||
gridElevation = 0,
|
||
onSelectWall,
|
||
onSelectCeiling,
|
||
onSelectOpening,
|
||
onSelectStair,
|
||
onContextMenu,
|
||
commandActive = false,
|
||
draft = null,
|
||
onWorkplanePoint,
|
||
onWorkplaneConfirm,
|
||
editWalls = [],
|
||
editDrawings = [],
|
||
onEditVertex,
|
||
onEditBody,
|
||
onEditWallTop,
|
||
onEditEnd,
|
||
}: {
|
||
project: Project;
|
||
visibleCodes: Set<string>;
|
||
/** Z-Höhe (Meter) des Bodenrasters = OKFF/baseElevation des aktiven Geschosses. */
|
||
gridElevation?: number;
|
||
/**
|
||
* Kanonischer Blickwinkel der 3D-Kamera (Vectorworks/DOSSIER-Presets):
|
||
* front/top/side/iso/perspective. Beim Wechsel springt die Kamera auf den
|
||
* passenden Standpunkt (formatfüllend, Modell zentriert); OrbitControls bleibt
|
||
* danach frei nutzbar (Preset = Sprung, kein Lock).
|
||
*/
|
||
view3d?: View3d;
|
||
/** Sichtwinkel der Perspektivkamera in Grad (Kamera-Einstellungen). */
|
||
fov?: number;
|
||
/**
|
||
* Render-Modus der 3D-Szene (Oberleiste): „shaded" = normal beleuchtet,
|
||
* „white" = einheitliches mattes Hellgrau (Clay-Look, beleuchtet),
|
||
* „wireframe" = Drahtgitter, „hidden" = Verdeckte Kanten (flache weiße Faces +
|
||
* schwarze Kantenlinien, tiefen-getestet → Hidden-Line-Look). Default shaded.
|
||
*/
|
||
renderMode?: RenderMode;
|
||
/**
|
||
* Render-Entscheidung je Geschoss-ID (Darstellungsmodus „Zeichnungsebenen").
|
||
* Ohne Angabe werden alle sichtbaren Geschosse normal gezeigt.
|
||
*/
|
||
floorDisplay?: DisplayResolver;
|
||
/**
|
||
* Render-Entscheidung je Kategorie-Code (Darstellungsmodus „Ebenen").
|
||
* Ohne Angabe werden alle (sichtbaren) Kategorien normal gezeigt.
|
||
*/
|
||
categoryDisplay?: DisplayResolver;
|
||
/**
|
||
* Stabiler String, der die aktuellen Display-Entscheidungen kodiert. Dient
|
||
* nur als Effekt-Abhängigkeit, damit ein Moduswechsel den Neuaufbau auslöst
|
||
* (die Resolver-Funktionen selbst sind nicht referenzstabil).
|
||
*/
|
||
displayKey?: string;
|
||
/**
|
||
* Links-Klick (Auswahl): meldet die per Raycast getroffene Wand-ID, oder
|
||
* null bei Klick in den Leerraum. Optional.
|
||
*/
|
||
onSelectWall?: (wallId: string | null) => void;
|
||
/**
|
||
* Links-Klick (Auswahl): meldet die per Raycast getroffene Decken-ID, oder
|
||
* null bei Klick in den Leerraum bzw. wenn eine Wand getroffen wurde. Optional.
|
||
*/
|
||
onSelectCeiling?: (ceilingId: string | null) => void;
|
||
/**
|
||
* Links-Klick (Auswahl): meldet die per Raycast getroffene Öffnungs-ID
|
||
* (Fenster/Tür), oder null bei Leerraum bzw. wenn Wand/Decke getroffen wurde.
|
||
*/
|
||
onSelectOpening?: (openingId: string | null) => void;
|
||
/**
|
||
* Links-Klick (Auswahl): meldet die per Raycast getroffene Treppen-ID, oder
|
||
* null bei Leerraum bzw. wenn Wand/Decke/Öffnung getroffen wurde.
|
||
*/
|
||
onSelectStair?: (stairId: string | null) => void;
|
||
/**
|
||
* Rechts-Klick: Kontextmenü an der Cursorposition. Das Standard-Browser-Menü
|
||
* wird immer unterdrückt; ein Rechts-Drag-Pan ist deaktiviert. Optional.
|
||
*/
|
||
onContextMenu?: (info: ViewportContextInfo) => void;
|
||
/**
|
||
* Läuft gerade ein zeichnender Befehl (CommandEngine aktiv)? Dann wird der
|
||
* 3D-Viewport zur Erstellungs-Fläche: Linksklick = Punkt auf der Arbeitsebene
|
||
* setzen (statt Wandauswahl), Pointer-Move = Vorschau, Rechtsklick/Doppelklick =
|
||
* bestätigen. Ohne aktiven Befehl bleibt das Verhalten unverändert (Orbit +
|
||
* Wandauswahl + Kontextmenü).
|
||
*/
|
||
commandActive?: boolean;
|
||
/** Aktuelle Werkzeug-Vorschau (Rubber-Band) zum Spiegeln in 3D. */
|
||
draft?: ToolDraft | null;
|
||
/**
|
||
* Punkt-Eingang in die ansichts-agnostische CommandEngine. `kind="move"` =
|
||
* Hover-Vorschau, `kind="pick"` = Klick. `model` ist der auf die Geschoss-
|
||
* Arbeitsebene projizierte Modell-`Vec2` (Meter).
|
||
*/
|
||
onWorkplanePoint?: (kind: "move" | "pick", model: Vec2) => void;
|
||
/** Befehl bestätigen/beenden (Rechtsklick/Enter/Doppelklick im 3D). */
|
||
onWorkplaneConfirm?: () => void;
|
||
/**
|
||
* Alle gewählten Wände — Quelle der 3D-Editier-Griffe (Endpunkt-Kugeln +
|
||
* Höhen-/Verschiebe-Griff). Bei Mehrfachauswahl erscheinen Griffe für ALLE,
|
||
* damit koinzidente Ecken gemeinsam gezogen werden können. Griffe nur bei
|
||
* inaktivem Befehl (!commandActive). Wände und 2D-Elemente schließen sich aus.
|
||
*/
|
||
editWalls?: Wall[];
|
||
/** Alle gewählten 2D-Elemente — Vertex-Griffe in 3D (Mehrfachauswahl ⇒ alle). */
|
||
editDrawings?: Drawing2D[];
|
||
/**
|
||
* Endpunkt-/Vertex-Griff gezogen: neuer XY-Modellpunkt für den Vertex `index`
|
||
* des durch `target` benannten Elements (Wand-ID ODER Drawing-ID). Optional
|
||
* `excludeWallIds`/`excludeDrawingIds` = Koinzidenz-Gruppe, die aus den
|
||
* Snap-Quellen genommen wird; nur dann snappt App den Punkt. Liefert den
|
||
* effektiv angewandten (gesnappten) Punkt zurück (für Marker + Mitführen).
|
||
* Wird auf dieselbe Store-Action (`moveGripOf`) gelegt wie die Plan-Griffe.
|
||
*/
|
||
onEditVertex?: (
|
||
target: { wallId?: string; drawingId?: string },
|
||
index: number,
|
||
model: Vec2,
|
||
excludeWallIds?: Set<string>,
|
||
excludeDrawingIds?: Set<string>,
|
||
) => Vec2;
|
||
/** Verschiebe-Griff gezogen: das `target`-Element um `delta` (XY) verschieben. */
|
||
onEditBody?: (target: { wallId?: string; drawingId?: string }, delta: Vec2) => void;
|
||
/** Höhen-Griff gezogen: neue absolute Oberkanten-Z der Wand. */
|
||
onEditWallTop?: (wallId: string, topZ: number) => void;
|
||
/** Ende eines Griff-Drags (Commit/Aufräumen). */
|
||
onEditEnd?: () => void;
|
||
}) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const [unavailable, setUnavailable] = useState(false);
|
||
|
||
// Callbacks in Refs spiegeln, damit der (schwere) Aufbau-Effekt nicht von
|
||
// referenzwechselnden Handlern abhängt — die Listener lesen stets aktuell.
|
||
const onSelectWallRef = useRef(onSelectWall);
|
||
const onSelectCeilingRef = useRef(onSelectCeiling);
|
||
const onSelectOpeningRef = useRef(onSelectOpening);
|
||
const onSelectStairRef = useRef(onSelectStair);
|
||
const onContextMenuRef = useRef(onContextMenu);
|
||
onSelectWallRef.current = onSelectWall;
|
||
onSelectCeilingRef.current = onSelectCeiling;
|
||
onSelectOpeningRef.current = onSelectOpening;
|
||
onSelectStairRef.current = onSelectStair;
|
||
onContextMenuRef.current = onContextMenu;
|
||
|
||
// Erstellungs-Modus (CommandEngine aktiv) + Punkt-Eingänge ebenfalls als Refs,
|
||
// damit die Pointer-Listener stets den aktuellen Zustand lesen, ohne dass der
|
||
// schwere Aufbau-Effekt neu läuft.
|
||
const commandActiveRef = useRef(commandActive);
|
||
const onWorkplanePointRef = useRef(onWorkplanePoint);
|
||
const onWorkplaneConfirmRef = useRef(onWorkplaneConfirm);
|
||
// Arbeitsebenen-Höhe (y) für den Raycast — gespiegelt, damit der Listener die
|
||
// aktuelle Geschoss-OKFF liest (gridElevation wird ohne Neuaufbau aktualisiert).
|
||
const gridElevationRef = useRef(gridElevation);
|
||
commandActiveRef.current = commandActive;
|
||
onWorkplanePointRef.current = onWorkplanePoint;
|
||
onWorkplaneConfirmRef.current = onWorkplaneConfirm;
|
||
gridElevationRef.current = gridElevation;
|
||
|
||
// ── 3D-Editier-Griffe: Auswahl + Callbacks in Refs ──────────────────────────
|
||
// Die Pointer-Listener (im schweren Aufbau-Effekt registriert) lesen die
|
||
// aktuelle Auswahl/Callbacks über diese Refs, ohne dass die Szene neu gebaut
|
||
// wird. Ein separater leichter Effekt zeichnet die Griff-Kugeln bei Auswahl-
|
||
// /Modelländerung neu (drawGripsRef).
|
||
const editWallsRef = useRef(editWalls);
|
||
const editDrawingsRef = useRef(editDrawings);
|
||
const onEditVertexRef = useRef(onEditVertex);
|
||
const onEditBodyRef = useRef(onEditBody);
|
||
const onEditWallTopRef = useRef(onEditWallTop);
|
||
const onEditEndRef = useRef(onEditEnd);
|
||
editWallsRef.current = editWalls;
|
||
editDrawingsRef.current = editDrawings;
|
||
onEditVertexRef.current = onEditVertex;
|
||
onEditBodyRef.current = onEditBody;
|
||
onEditWallTopRef.current = onEditWallTop;
|
||
onEditEndRef.current = onEditEnd;
|
||
// Vom schweren Aufbau-Effekt gesetzte Funktion, die die Griff-Kugeln aus der
|
||
// aktuellen Auswahl neu aufbaut (ohne Szenen-Neuaufbau). Von einem leichten
|
||
// Effekt bei Auswahl-/Modell-Änderung gerufen.
|
||
const drawGripsRef = useRef<(() => void) | null>(null);
|
||
// Laufender Griff-Drag — als KOMPONENTEN-Ref, damit er einen Szenen-Neuaufbau
|
||
// (Live-Mutation des Projekts während des Ziehens ⇒ heavy effect läuft neu)
|
||
// ÜBERLEBT. Sonst würde jeder pointermove den Drag abbrechen. Die (bei jedem
|
||
// Aufbau neu registrierten) Pointer-Listener lesen denselben Ref weiter.
|
||
const gripDragRef = useRef<{
|
||
kind: "vertex" | "height" | "move";
|
||
vertexIndex: number;
|
||
lastModel: Vec2 | null;
|
||
/**
|
||
* Ortho-Anker beim Vertex-Drag (Shift): die FIXE Bezugsecke, relativ zu der
|
||
* der gezogene Punkt auf H/V eingerastet wird (Original-Nachbarvertex bzw.
|
||
* das andere Wandende). null = kein Anker (keine Ortho-Beschränkung möglich).
|
||
*/
|
||
anchor: Vec2 | null;
|
||
pointerId: number;
|
||
/** Element, dessen Griff gezogen wird (Wand-ID ODER Drawing-ID). */
|
||
target: { wallId?: string; drawingId?: string };
|
||
/** Für den Höhen-Griff: die Wand-ID (OK-Z setzen). */
|
||
wallId: string | null;
|
||
/**
|
||
* Koinzidente Endpunkt-Griffe ANDERER gewählter Elemente, die mitgeführt
|
||
* werden (gemeinsame Ecke). Beim Drag-Start einmal bestimmt.
|
||
*/
|
||
coincident: Array<{ target: { wallId?: string; drawingId?: string }; index: number }>;
|
||
/**
|
||
* Beim KÖRPER-Verschieben (move): koinzidente Endpunkte ANDERER gewählter
|
||
* Elemente, die an einer Ecke des verschobenen Elements hängen. Sie wandern um
|
||
* dasselbe Delta mit (gemeinsame Ecke bleibt verbunden). `pos` ist die LIVE
|
||
* Position des Sibling-Vertex (inkrementell fortgeschrieben).
|
||
*/
|
||
moveCoincident: Array<{
|
||
target: { wallId?: string; drawingId?: string };
|
||
index: number;
|
||
pos: Vec2;
|
||
}>;
|
||
/** IDs der gesamten Koinzidenz-Gruppe (Selbst + Mitgeführte) → Snap-Ausschluss. */
|
||
excludeWallIds: Set<string>;
|
||
excludeDrawingIds: Set<string>;
|
||
} | null>(null);
|
||
|
||
// Kamera + Controls in Refs spiegeln, damit ein leichter Effekt (Blickwinkel-
|
||
// Preset, FOV) sie ohne kompletten Szenen-Neuaufbau aktualisieren kann.
|
||
// Es gibt ZWEI Kameras: die Perspektivkamera (Preset `perspective`) und eine
|
||
// Orthografische (parallele) Kamera für front/top/side/iso — damit „Isometrie"
|
||
// eine echte parallele Projektion ohne Fluchtpunkt-Verzerrung ist.
|
||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||
const orthoCameraRef = useRef<THREE.OrthographicCamera | null>(null);
|
||
// Aktuell aktive Kamera (Render-Loop + Raycast lesen diese).
|
||
const activeCameraRef = useRef<THREE.Camera | null>(null);
|
||
const controlsRef = useRef<OrbitControls | null>(null);
|
||
// Bounding-Box der gebauten Szene (für formatfüllende Presets).
|
||
const boundsRef = useRef<THREE.Box3 | null>(null);
|
||
// Aktueller Blickwinkel/FOV in Refs, damit der schwere Aufbau-Effekt sie beim
|
||
// Neuaufbau (Initialwert) liest, ohne von ihnen abzuhängen.
|
||
const view3dRef = useRef(view3d);
|
||
const fovRef = useRef(fov);
|
||
// Gespiegelter Kamera-Zustand (Position + Target + Ortho-Zoom), der einen
|
||
// Szenen-Neuaufbau ÜBERLEBT. Bei jeder Projekt-Mutation läuft der schwere
|
||
// Aufbau-Effekt neu; ohne diesen Snapshot würde die frisch erzeugte Kamera auf
|
||
// den Preset-Standpunkt zurückspringen (Bug: „Kamera bewegt sich beim
|
||
// Editieren / springt in die Top-Ansicht"). Der Snapshot wird im Render-Loop
|
||
// laufend fortgeschrieben und beim Neuaufbau als Startzustand wiederhergestellt.
|
||
// null = noch kein Snapshot (erster Aufbau → Preset anwenden).
|
||
const camStateRef = useRef<{
|
||
position: THREE.Vector3;
|
||
target: THREE.Vector3;
|
||
up: THREE.Vector3;
|
||
perspective: boolean;
|
||
orthoZoom: number;
|
||
} | null>(null);
|
||
// Bodenraster (separat gehalten, damit es ohne Szenen-Neuaufbau auf die Höhe
|
||
// des aktiven Geschosses (OKFF) gehoben werden kann).
|
||
const gridRef = useRef<THREE.GridHelper | null>(null);
|
||
// Funktion, die die 3D-Draft-Vorschau (Linien + Snap-Marker) aus einem
|
||
// ToolDraft neu aufbaut. Vom schweren Aufbau-Effekt gesetzt, von einem leichten
|
||
// Effekt bei `draft`-Änderung gerufen — so wird die Vorschau ohne Szenen-
|
||
// Neuaufbau aktualisiert.
|
||
const drawDraftRef = useRef<((d: ToolDraft | null) => void) | null>(null);
|
||
view3dRef.current = view3d;
|
||
fovRef.current = fov;
|
||
|
||
useEffect(() => {
|
||
const mount = ref.current;
|
||
if (!mount) return;
|
||
|
||
if (!isWebGLAvailable()) {
|
||
setUnavailable(true);
|
||
return;
|
||
}
|
||
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0xf4f5f7);
|
||
|
||
const camera = new THREE.PerspectiveCamera(fovRef.current, 1, 0.1, 1000);
|
||
camera.position.set(8, 7, 9);
|
||
cameraRef.current = camera;
|
||
|
||
// Orthografische (parallele) Kamera für front/top/side/iso. Das Frustum wird
|
||
// unten aus den Modell-Bounds + Canvas-Seitenverhältnis gesetzt; near/far
|
||
// großzügig, damit das Modell bei jeder Distanz sicher dazwischen liegt.
|
||
const orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 5000);
|
||
orthoCameraRef.current = orthoCamera;
|
||
|
||
let renderer: THREE.WebGLRenderer;
|
||
try {
|
||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
} catch {
|
||
setUnavailable(true);
|
||
return;
|
||
}
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
// Filmisches Tonemapping + sRGB-Ausgabe für einen fotorealistischeren,
|
||
// nicht ausgefressenen Kontrast (ACES komprimiert Lichter sanft, statt sie
|
||
// hart zu clippen). Belichtung leicht über 1, damit die PBR-Materialien
|
||
// (s. u.) nicht zu dunkel wirken.
|
||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||
renderer.toneMappingExposure = 1.1;
|
||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||
mount.appendChild(renderer.domElement);
|
||
|
||
// Neutrale Raum-Umgebung (PMREMGenerator + RoomEnvironment) als IBL-Quelle:
|
||
// gibt MeshStandardMaterial weiche, plausible Reflexionen/Ambient-Licht,
|
||
// ohne eine externe HDR-Datei laden zu müssen. Nur `scene.environment`
|
||
// gesetzt (NICHT `scene.background`) — der helle Papier-Hintergrund bleibt
|
||
// wie bisher erhalten.
|
||
const pmremGenerator = new THREE.PMREMGenerator(renderer);
|
||
const envRenderTarget = pmremGenerator.fromScene(new RoomEnvironment(), 0.04);
|
||
scene.environment = envRenderTarget.texture;
|
||
pmremGenerator.dispose();
|
||
|
||
// OrbitControls startet an der Perspektivkamera; bei Preset-Wechsel wird
|
||
// `.object` auf die aktive Kamera umgehängt (siehe applyView3d). Aktive
|
||
// Kamera initial = Perspektive (wird vom Initial-Preset ggf. überschrieben).
|
||
activeCameraRef.current = camera;
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
controlsRef.current = controls;
|
||
// Kein Nachlauf/Trägheit — die Ansicht stoppt sofort beim Loslassen
|
||
// (kontrollierter, weniger „verspielt"; Wunsch des Nutzers).
|
||
controls.enableDamping = false;
|
||
controls.target.set(2.5, 1, 2);
|
||
|
||
// Maus-Schema (docs/design/context-menu.md): MITTE = navigieren (Orbit),
|
||
// Shift+Mitte = Pan, RAD = Zoom, LINKS = Auswahl, RECHTS = Kontextmenü.
|
||
// OrbitControls kennt den Shift-Modifier eingebaut: ist eine Taste auf
|
||
// ROTATE gesetzt und Shift gedrückt, schwenkt es stattdessen (PAN). Links
|
||
// und Rechts auf -1 (deaktiviert) → kein Orbit links, kein Pan rechts;
|
||
// beides bedienen wir selbst (Raycast-Auswahl bzw. Kontextmenü).
|
||
controls.mouseButtons = {
|
||
LEFT: -1 as unknown as THREE.MOUSE,
|
||
MIDDLE: THREE.MOUSE.ROTATE,
|
||
RIGHT: -1 as unknown as THREE.MOUSE,
|
||
};
|
||
controls.enableZoom = true; // Rad-Zoom (Standard, explizit).
|
||
controls.enablePan = true; // für Shift+Mitte.
|
||
// Top-Ansicht ohne Orbit (senkrechter Blick bleibt gesperrt); auch nach einem
|
||
// Szenen-Neuaufbau konsistent, da die Controls hier frisch entstehen.
|
||
controls.enableRotate = view3dRef.current !== "top";
|
||
|
||
// Licht — mit der Raum-Umgebung (s. o.) als weichem IBL-Ambient reichen
|
||
// gedämpftes Umgebungslicht + eine gerichtete „Sonne" für Modellierung
|
||
// (Schlagschatten sind hier bewusst NICHT aktiv, wie zuvor).
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
|
||
const sun = new THREE.DirectionalLight(0xffffff, 0.9);
|
||
sun.position.set(6, 12, 4);
|
||
scene.add(sun);
|
||
|
||
// Boden + Raster — auf der Z-Höhe (OKFF) des aktiven Geschosses.
|
||
const grid = new THREE.GridHelper(40, 40, 0xcccccc, 0xe6e6e6);
|
||
grid.position.y = gridElevation;
|
||
scene.add(grid);
|
||
gridRef.current = grid;
|
||
|
||
// Gebäude bauen — sichtbare Geschosse (gemäß floorDisplay), vertikal
|
||
// gestapelt; je Geschoss nur Wände mit sichtbarer Kategorie (categoryDisplay).
|
||
// Normale und gedimmte Materialien getrennt cachen.
|
||
const mats: MatCaches = {
|
||
normal: new Map<string, THREE.MeshStandardMaterial>(),
|
||
greyed: new Map<string, THREE.MeshStandardMaterial>(),
|
||
lines: new Map<string, THREE.LineBasicMaterial>(),
|
||
};
|
||
// Geteilte Materialien für den „Verdeckte Kanten"-Modus: flache weiße Faces
|
||
// (polygonOffset, damit die Kantenlinien nicht z-fighten) + schwarze Kanten.
|
||
const hiddenFace = new THREE.MeshBasicMaterial({
|
||
color: 0xffffff,
|
||
polygonOffset: true,
|
||
polygonOffsetFactor: 1,
|
||
polygonOffsetUnits: 1,
|
||
});
|
||
const edgeMat = new THREE.LineBasicMaterial({ color: 0x1a1a1a });
|
||
// „Weiss"-Modus: einheitliches mattes Hellgrau (Clay-Look) für alle Volumen.
|
||
const whiteFace = new THREE.MeshStandardMaterial({
|
||
color: 0xe8e8e8,
|
||
roughness: 0.85,
|
||
metalness: 0,
|
||
envMapIntensity: 0.6,
|
||
});
|
||
// Laufzeit für PBR-Texturen (Render-Modus „textured"). Lädt Karten lazy +
|
||
// gecacht; im Cleanup werden Texturen/Materialien freigegeben.
|
||
const matRuntime = new MaterialRuntime();
|
||
const opts: BuildOpts = {
|
||
mats,
|
||
renderMode,
|
||
hiddenFace,
|
||
edgeMat,
|
||
whiteFace,
|
||
matRuntime,
|
||
};
|
||
const building = buildBuilding(
|
||
project,
|
||
visibleCodes,
|
||
floorDisplay,
|
||
categoryDisplay,
|
||
opts,
|
||
);
|
||
scene.add(building);
|
||
|
||
// Kontext-Schicht (importierte Meshes, Konturen, Gelände-TIN). Eigene
|
||
// dezente Materialien, klar vom BIM unterscheidbar. Die three-Objekte
|
||
// entstehen erst hier aus den rohen positions/indices des Modells. Plan-
|
||
// Konvention: Modell (x,y,z) → Three (x, z, y) (Z = Höhe nach oben).
|
||
// CONTEXT_WIREFRAME als Flag, um das TIN als Drahtgitter zu zeigen.
|
||
const CONTEXT_WIREFRAME = false;
|
||
const ctxMats: ContextMats = {
|
||
terrain: new THREE.MeshStandardMaterial({
|
||
color: 0xa9b0a2, // gedämpftes Lehm-/Geländegrün-grau (Clay-Look)
|
||
roughness: 0.95,
|
||
metalness: 0,
|
||
side: THREE.DoubleSide,
|
||
wireframe: CONTEXT_WIREFRAME,
|
||
flatShading: true,
|
||
envMapIntensity: 0.5,
|
||
}),
|
||
mesh: new THREE.MeshStandardMaterial({
|
||
color: 0x9fa6ae, // neutrales Hellgrau für importierte Meshes
|
||
roughness: 0.9,
|
||
metalness: 0,
|
||
side: THREE.DoubleSide,
|
||
wireframe: CONTEXT_WIREFRAME,
|
||
envMapIntensity: 0.5,
|
||
}),
|
||
contour: new THREE.LineBasicMaterial({ color: 0x6b7a5a }),
|
||
};
|
||
const contextGroup = buildContext(project.context ?? [], ctxMats);
|
||
scene.add(contextGroup);
|
||
|
||
// Modell-Bounds aus den gebauten Wänden bestimmen (Boden/Raster ignorieren)
|
||
// und den Initial-Preset anwenden, damit beim Aufbau der gewünschte
|
||
// Blickwinkel formatfüllend sitzt.
|
||
const bounds = new THREE.Box3();
|
||
if (building.children.length > 0) bounds.setFromObject(building);
|
||
// Kontext-Geometrie (Gelände/Meshes) in die Bounds einbeziehen, damit
|
||
// Presets sie formatfüllend einrahmen — auch wenn (noch) kein Gebäude steht.
|
||
if (contextGroup.children.length > 0) bounds.expandByObject(contextGroup);
|
||
if (bounds.isEmpty()) bounds.set(new THREE.Vector3(0, 0, 0), new THREE.Vector3(5, 3, 5));
|
||
boundsRef.current = bounds;
|
||
|
||
// Aktuelles Seitenverhältnis des Canvas (für das Ortho-Frustum + Perspektiv-
|
||
// aspect). Beim ersten Aufbau aus den Mount-Maßen abgeleitet.
|
||
const currentAspect = () => {
|
||
const w = mount.clientWidth || 1;
|
||
const h = mount.clientHeight || 1;
|
||
return w / h;
|
||
};
|
||
|
||
// Kamera-Standpunkt bestimmen: Beim ERSTEN Aufbau (kein Snapshot) den
|
||
// Blickwinkel-Preset formatfüllend anwenden. Bei jedem NEUAUFBAU (z. B. weil
|
||
// eine Griff-Mutation die Szene neu baut) den zuvor gespiegelten Kamera-
|
||
// Zustand WIEDERHERSTELLEN, statt den Preset erneut anzuwenden — so bleibt
|
||
// die Kamera exakt stehen (der Nutzer steuert sie allein).
|
||
const snap = camStateRef.current;
|
||
if (snap) {
|
||
const active: THREE.PerspectiveCamera | THREE.OrthographicCamera =
|
||
snap.perspective ? camera : orthoCamera;
|
||
if (!snap.perspective) {
|
||
updateOrthoFrustum(orthoCamera, bounds, currentAspect());
|
||
orthoCamera.zoom = snap.orthoZoom;
|
||
} else {
|
||
camera.aspect = currentAspect();
|
||
}
|
||
active.position.copy(snap.position);
|
||
active.up.copy(snap.up);
|
||
active.updateProjectionMatrix();
|
||
if (controls.object !== active) controls.object = active;
|
||
controls.target.copy(snap.target);
|
||
controls.update();
|
||
activeCameraRef.current = active;
|
||
} else {
|
||
applyView3d(
|
||
camera,
|
||
orthoCamera,
|
||
activeCameraRef,
|
||
controls,
|
||
bounds,
|
||
view3dRef.current,
|
||
currentAspect(),
|
||
);
|
||
}
|
||
|
||
// Resize handhaben. updateStyle=false: die CSS-Größe steuert das Layout,
|
||
// wir setzen nur die Renderauflösung. Nur bei echter Änderung neu setzen,
|
||
// um Observer-Rückkopplung zu vermeiden. Sowohl das Perspektiv-aspect als
|
||
// auch das Ortho-Frustum werden nachgeführt.
|
||
let lastW = -1;
|
||
let lastH = -1;
|
||
const resize = () => {
|
||
const w = mount.clientWidth;
|
||
const h = mount.clientHeight;
|
||
if (w === 0 || h === 0 || (w === lastW && h === lastH)) return;
|
||
lastW = w;
|
||
lastH = h;
|
||
renderer.setSize(w, h, false);
|
||
const aspect = w / h;
|
||
camera.aspect = aspect;
|
||
camera.updateProjectionMatrix();
|
||
// Ortho-Frustum dem neuen Seitenverhältnis anpassen (formatfüllend),
|
||
// ohne Position/Target zu verändern.
|
||
updateOrthoFrustum(orthoCamera, boundsRef.current ?? bounds, aspect);
|
||
};
|
||
resize();
|
||
const ro = new ResizeObserver(resize);
|
||
ro.observe(mount);
|
||
|
||
let raf = 0;
|
||
const animate = () => {
|
||
controls.update();
|
||
renderer.render(scene, activeCameraRef.current ?? camera);
|
||
// Kamera-Zustand fortlaufend spiegeln, damit ein Szenen-Neuaufbau (etwa
|
||
// durch eine Live-Mutation beim Griff-Drag) den aktuellen Standpunkt
|
||
// wiederherstellen kann, statt auf den Preset zurückzuspringen.
|
||
const active = (activeCameraRef.current ?? camera) as
|
||
| THREE.PerspectiveCamera
|
||
| THREE.OrthographicCamera;
|
||
const isPersp = active === camera;
|
||
const snap = camStateRef.current;
|
||
if (snap) {
|
||
snap.position.copy(active.position);
|
||
snap.target.copy(controls.target);
|
||
snap.up.copy(active.up);
|
||
snap.perspective = isPersp;
|
||
snap.orthoZoom = orthoCamera.zoom;
|
||
} else {
|
||
camStateRef.current = {
|
||
position: active.position.clone(),
|
||
target: controls.target.clone(),
|
||
up: active.up.clone(),
|
||
perspective: isPersp,
|
||
orthoZoom: orthoCamera.zoom,
|
||
};
|
||
}
|
||
raf = requestAnimationFrame(animate);
|
||
};
|
||
animate();
|
||
|
||
// ── Maus-Auswahl + Kontextmenü (Raycast) ────────────────────────────────
|
||
const raycaster = new THREE.Raycaster();
|
||
const ndc = new THREE.Vector2();
|
||
|
||
// ── Erstellungs-Modus: Raycast auf die Arbeitsebene ─────────────────────
|
||
// Horizontale Ebene bei y = gridElevation (OKFF des aktiven Geschosses).
|
||
// Pointer → NDC → Strahl der AKTIVEN Kamera → Schnitt mit der Ebene → Welt-
|
||
// punkt P → Modell-Vec2. Inverses Mapping aus addDrawing2DLines abgeleitet:
|
||
// dort Modell (x,y) → three (x, baseElevation, y); also model.x = P.x,
|
||
// model.y = P.z. Kein Vorzeichen-/Z-Flip.
|
||
const workplane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
||
const hitVec = new THREE.Vector3();
|
||
const workplaneModel = (clientX: number, clientY: number): Vec2 | null => {
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||
raycaster.setFromCamera(ndc, activeCameraRef.current ?? camera);
|
||
// Ebene auf die aktuelle Geschoss-Höhe heben: Plane n·x + d = 0 mit
|
||
// n=(0,1,0) → d = -y. constant = -gridElevation.
|
||
workplane.constant = -gridElevationRef.current;
|
||
const hit = raycaster.ray.intersectPlane(workplane, hitVec);
|
||
if (!hit) return null; // Strahl parallel zur Ebene (Blick exakt horizontal)
|
||
return { x: hit.x, y: hit.z };
|
||
};
|
||
|
||
// ── 3D-Draft-Vorschau (Linien + Snap-Marker auf der Arbeitsebene) ───────
|
||
const draftGroup = new THREE.Group();
|
||
draftGroup.frustumCulled = false;
|
||
scene.add(draftGroup);
|
||
const draftLineMat = new THREE.LineBasicMaterial({ color: 0x2f8fff });
|
||
// Draft-Stützpunkte + Snap-Marker „immer oben" (depthTest/Write aus), damit
|
||
// sie nie hinter Wänden verschwinden — wie die Editier-Griffe.
|
||
const draftVertMat = new THREE.MeshBasicMaterial({
|
||
color: 0x2f8fff,
|
||
depthTest: false,
|
||
depthWrite: false,
|
||
});
|
||
const snapMarkMat = new THREE.MeshBasicMaterial({
|
||
color: 0xffcc44,
|
||
depthTest: false,
|
||
depthWrite: false,
|
||
});
|
||
const draftSphereGeo = new THREE.SphereGeometry(0.045, 12, 10);
|
||
const clearDraftGroup = () => {
|
||
for (let i = draftGroup.children.length - 1; i >= 0; i--) {
|
||
const child = draftGroup.children[i];
|
||
const ls = child as THREE.LineSegments;
|
||
if (ls.isLineSegments || (child as THREE.Line).isLine) {
|
||
(child as THREE.Line).geometry.dispose();
|
||
}
|
||
const m = child as THREE.Mesh;
|
||
if (m.isMesh) m.geometry.dispose();
|
||
draftGroup.remove(child);
|
||
}
|
||
};
|
||
// Modell (x,y) → three (x, y_plane, y); y leicht über der Ebene gegen Z-Fighting.
|
||
const drawDraft = (d: ToolDraft | null) => {
|
||
clearDraftGroup();
|
||
if (!d) return;
|
||
const yPlane = gridElevationRef.current + 0.012;
|
||
const addSeg = (a: Vec2, b: Vec2) => {
|
||
const geo = new THREE.BufferGeometry();
|
||
geo.setAttribute(
|
||
"position",
|
||
new THREE.BufferAttribute(
|
||
new Float32Array([a.x, yPlane, a.y, b.x, yPlane, b.y]),
|
||
3,
|
||
),
|
||
);
|
||
const line = new THREE.Line(geo, draftLineMat);
|
||
line.frustumCulled = false;
|
||
draftGroup.add(line);
|
||
};
|
||
for (const shape of d.preview) {
|
||
if (shape.kind === "line") addSeg(shape.a, shape.b);
|
||
else if (shape.kind === "poly") {
|
||
for (let i = 0; i < shape.pts.length - 1; i++) addSeg(shape.pts[i], shape.pts[i + 1]);
|
||
if (shape.closed && shape.pts.length > 2)
|
||
addSeg(shape.pts[shape.pts.length - 1], shape.pts[0]);
|
||
}
|
||
}
|
||
// Gesetzte Stützpunkte als kleine Kugeln (immer oben).
|
||
for (const v of d.vertices) {
|
||
const m = new THREE.Mesh(draftSphereGeo, draftVertMat);
|
||
m.position.set(v.x, yPlane, v.y);
|
||
m.renderOrder = 1000;
|
||
draftGroup.add(m);
|
||
}
|
||
// Snap-Marker (orange Kugel) am gefangenen Punkt (immer oben).
|
||
if (d.snap) {
|
||
const m = new THREE.Mesh(draftSphereGeo, snapMarkMat);
|
||
m.position.set(d.snap.point.x, yPlane, d.snap.point.y);
|
||
m.renderOrder = 1001;
|
||
draftGroup.add(m);
|
||
}
|
||
};
|
||
drawDraftRef.current = drawDraft;
|
||
drawDraft(draft);
|
||
|
||
// ── 3D-Editier-Griffe (Endpunkt/Vertex, Höhe, Verschieben) ───────────────
|
||
// Eigene Gruppe in der Szene mit Griff-Kugeln. Jede Kugel trägt in userData
|
||
// ihren Griff-Deskriptor (kind + index + Element-ID). Wird per drawGrips aus
|
||
// der aktuellen Auswahl (editWallsRef/editDrawingsRef) neu aufgebaut — bei
|
||
// inaktivem Befehl. Bei Mehrfachauswahl bekommen ALLE Elemente Griffe (Basis
|
||
// fürs gemeinsame Ziehen koinzidenter Ecken). Re-Render ohne Szenen-Neuaufbau
|
||
// über drawGripsRef.
|
||
const gripGroup = new THREE.Group();
|
||
gripGroup.frustumCulled = false;
|
||
// „Immer oben": Griff-Gruppe nach dem Rest rendern; die Materialien deaktivieren
|
||
// den Tiefentest (s. u.), sodass die Kugeln nie hinter Wänden verschwinden.
|
||
gripGroup.renderOrder = 999;
|
||
scene.add(gripGroup);
|
||
// Akzentmaterialien: Endpunkt-/Vertex-Griffe (Endpunkt-Ziehen), Höhen-Griff
|
||
// (oben, andere Farbe) und Verschiebe-Griff (Achsmitte). depthTest=false +
|
||
// depthWrite=false → die Kugeln werden von KEINER Geometrie verdeckt (immer
|
||
// sichtbar, auch von hinter/unter der Wand).
|
||
const onTop = { depthTest: false, depthWrite: false } as const;
|
||
const gripVertMat = new THREE.MeshBasicMaterial({ color: 0xff8c1a, ...onTop });
|
||
const gripHeightMat = new THREE.MeshBasicMaterial({ color: 0x2f8fff, ...onTop });
|
||
const gripMoveMat = new THREE.MeshBasicMaterial({ color: 0x18b85a, ...onTop });
|
||
const gripGeo = new THREE.SphereGeometry(0.075, 16, 12);
|
||
// Griff-Deskriptor in userData der Kugel.
|
||
type GripKind = "vertex" | "height" | "move";
|
||
const clearGripGroup = () => {
|
||
for (let i = gripGroup.children.length - 1; i >= 0; i--) {
|
||
gripGroup.remove(gripGroup.children[i]);
|
||
}
|
||
};
|
||
// Ein Griff. `target` benennt das Element (Wand-ID ODER Drawing-ID), damit
|
||
// App den richtigen Vertex mutiert und der Drag die Koinzidenz-Gruppe findet.
|
||
const addGrip = (
|
||
kind: GripKind,
|
||
index: number,
|
||
pos: THREE.Vector3,
|
||
target: { wallId?: string; drawingId?: string },
|
||
) => {
|
||
const mat =
|
||
kind === "height" ? gripHeightMat : kind === "move" ? gripMoveMat : gripVertMat;
|
||
const m = new THREE.Mesh(gripGeo, mat);
|
||
m.position.copy(pos);
|
||
m.userData.gripKind = kind;
|
||
m.userData.gripIndex = index;
|
||
m.userData.gripTarget = target;
|
||
m.frustumCulled = false;
|
||
m.renderOrder = 1000;
|
||
gripGroup.add(m);
|
||
};
|
||
// Baut die Griff-Kugeln aus der aktuellen Auswahl. Modell (x,y) → three
|
||
// (x, z, y). Bei aktivem Befehl: keine Griffe (Erstellungs-Fläche).
|
||
const drawGrips = () => {
|
||
clearGripGroup();
|
||
if (commandActiveRef.current) return;
|
||
const walls = editWallsRef.current ?? [];
|
||
const drawings = editDrawingsRef.current ?? [];
|
||
for (const wall of walls) {
|
||
// Wand-UK/OK absolut (Z) — Endpunkt-Griffe sitzen auf der UK (Boden der
|
||
// Wand), Höhen-Griff auf der OK in der Achsmitte.
|
||
const { zBottom, zTop } = wallVerticalExtent(project, wall);
|
||
const tgt = { wallId: wall.id };
|
||
addGrip("vertex", 0, new THREE.Vector3(wall.start.x, zBottom, wall.start.y), tgt);
|
||
addGrip("vertex", 1, new THREE.Vector3(wall.end.x, zBottom, wall.end.y), tgt);
|
||
const mid: Vec2 = {
|
||
x: (wall.start.x + wall.end.x) / 2,
|
||
y: (wall.start.y + wall.end.y) / 2,
|
||
};
|
||
addGrip("height", -1, new THREE.Vector3(mid.x, zTop, mid.y), tgt);
|
||
// Verschiebe-Griff etwas über der UK in der Achsmitte (vom Höhen-Griff
|
||
// farblich/vertikal getrennt, damit die beiden nicht koinzidieren).
|
||
addGrip("move", -1, new THREE.Vector3(mid.x, zBottom + 0.05, mid.y), tgt);
|
||
}
|
||
for (const drawing of drawings) {
|
||
const verts = drawingGripVertices(drawing);
|
||
const yPlane = gridElevationRef.current + 0.02;
|
||
const tgt = { drawingId: drawing.id };
|
||
verts.forEach((v, i) =>
|
||
addGrip("vertex", i, new THREE.Vector3(v.x, yPlane, v.y), tgt),
|
||
);
|
||
// Verschiebe-Griff (ganzes 2D-Element bewegen) im Schwerpunkt der Ecken —
|
||
// analog zum Wand-Verschiebe-Griff, damit 2D-Elemente auch in 3D frei
|
||
// verschiebbar sind (nicht nur ihre einzelnen Vertices). Formen ohne
|
||
// Vertex-Griffe (circle/arc/text) bekommen ihren Anker aus dem Geometrie-
|
||
// Ursprung, damit auch sie einen Verschiebe-Griff erhalten.
|
||
const anchor = drawingMoveAnchor(drawing, verts);
|
||
if (anchor)
|
||
addGrip("move", -1, new THREE.Vector3(anchor.x, yPlane + 0.03, anchor.y), tgt);
|
||
}
|
||
};
|
||
drawGripsRef.current = drawGrips;
|
||
drawGrips();
|
||
|
||
// Snap-Marker für den 3D-Vertex-Drag (orange Kugel, immer oben). Wird beim
|
||
// Drag positioniert/ein- bzw. ausgeblendet; eigene Geometrie/Material, im
|
||
// Cleanup verworfen.
|
||
const gripSnapMat = new THREE.MeshBasicMaterial({ color: 0xffcc44, ...onTop });
|
||
const gripSnapGeo = new THREE.SphereGeometry(0.06, 14, 12);
|
||
const gripSnapMarker = new THREE.Mesh(gripSnapGeo, gripSnapMat);
|
||
gripSnapMarker.frustumCulled = false;
|
||
gripSnapMarker.renderOrder = 1001;
|
||
gripSnapMarker.visible = false;
|
||
gripGroup.add(gripSnapMarker);
|
||
// Zeigt den Snap-Marker am Modellpunkt (x,y) auf der Arbeitsebene, oder
|
||
// blendet ihn aus (p=null).
|
||
const showGripSnap = (p: Vec2 | null) => {
|
||
if (!p) {
|
||
gripSnapMarker.visible = false;
|
||
return;
|
||
}
|
||
gripSnapMarker.position.set(p.x, gridElevationRef.current + 0.02, p.y);
|
||
gripSnapMarker.visible = true;
|
||
};
|
||
|
||
// Per Raycast die getroffene Wand-ID bestimmen (klettert von der Mesh zur
|
||
// userData.wallId; null bei Leerraum/Boden/Raster).
|
||
const pickWall = (clientX: number, clientY: number): string | null => {
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||
raycaster.setFromCamera(ndc, activeCameraRef.current ?? camera);
|
||
const hits = raycaster.intersectObjects(building.children, true);
|
||
for (const hit of hits) {
|
||
const id = hit.object.userData?.wallId;
|
||
if (typeof id === "string") return id;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// Per Raycast die getroffene Decken-ID bestimmen (userData.ceilingId). Wird
|
||
// nur ausgewertet, wenn keine Wand getroffen wurde (Wände haben Vorrang).
|
||
const pickCeiling = (clientX: number, clientY: number): string | null => {
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||
raycaster.setFromCamera(ndc, activeCameraRef.current ?? camera);
|
||
const hits = raycaster.intersectObjects(building.children, true);
|
||
for (const hit of hits) {
|
||
const id = hit.object.userData?.ceilingId;
|
||
if (typeof id === "string") return id;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// Per Raycast die getroffene Öffnungs-ID bestimmen (userData.openingId).
|
||
const pickOpening = (clientX: number, clientY: number): string | null => {
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||
raycaster.setFromCamera(ndc, activeCameraRef.current ?? camera);
|
||
const hits = raycaster.intersectObjects(building.children, true);
|
||
for (const hit of hits) {
|
||
const id = hit.object.userData?.openingId;
|
||
if (typeof id === "string") return id;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// Per Raycast die getroffene Treppen-ID bestimmen (userData.stairId).
|
||
const pickStair = (clientX: number, clientY: number): string | null => {
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||
raycaster.setFromCamera(ndc, activeCameraRef.current ?? camera);
|
||
const hits = raycaster.intersectObjects(building.children, true);
|
||
for (const hit of hits) {
|
||
const id = hit.object.userData?.stairId;
|
||
if (typeof id === "string") return id;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// Auswahl-Hervorhebung: die Meshes der gewählten Wand mit einem geteilten
|
||
// Highlight-Material überziehen; Originale merken und beim Wechsel/Cleanup
|
||
// wiederherstellen. Material wird im Cleanup verworfen.
|
||
const highlightMat = new THREE.MeshStandardMaterial({
|
||
color: 0x5fa896,
|
||
emissive: 0x1a655a,
|
||
emissiveIntensity: 0.6,
|
||
roughness: 0.6,
|
||
metalness: 0,
|
||
transparent: true,
|
||
opacity: 0.92,
|
||
});
|
||
let selectedMeshes: THREE.Mesh[] = [];
|
||
const restoreSelection = () => {
|
||
for (const m of selectedMeshes) {
|
||
const orig = m.userData?.origMaterial;
|
||
if (orig) m.material = orig as THREE.Material;
|
||
}
|
||
selectedMeshes = [];
|
||
};
|
||
const selectWall = (wallId: string | null) => {
|
||
restoreSelection();
|
||
if (wallId) {
|
||
building.traverse((o) => {
|
||
const mesh = o as THREE.Mesh;
|
||
if (mesh.isMesh && mesh.userData?.wallId === wallId) {
|
||
mesh.userData.origMaterial = mesh.material;
|
||
mesh.material = highlightMat;
|
||
selectedMeshes.push(mesh);
|
||
}
|
||
});
|
||
}
|
||
onSelectWallRef.current?.(wallId);
|
||
};
|
||
// Auswahl-Hervorhebung einer Decke (analog selectWall, über userData.ceilingId).
|
||
const selectCeiling = (ceilingId: string | null) => {
|
||
restoreSelection();
|
||
if (ceilingId) {
|
||
building.traverse((o) => {
|
||
const mesh = o as THREE.Mesh;
|
||
if (mesh.isMesh && mesh.userData?.ceilingId === ceilingId) {
|
||
mesh.userData.origMaterial = mesh.material;
|
||
mesh.material = highlightMat;
|
||
selectedMeshes.push(mesh);
|
||
}
|
||
});
|
||
}
|
||
onSelectCeilingRef.current?.(ceilingId);
|
||
};
|
||
// Auswahl-Hervorhebung einer Öffnung (über userData.openingId).
|
||
const selectOpening = (openingId: string | null) => {
|
||
restoreSelection();
|
||
if (openingId) {
|
||
building.traverse((o) => {
|
||
const mesh = o as THREE.Mesh;
|
||
if (mesh.isMesh && mesh.userData?.openingId === openingId) {
|
||
mesh.userData.origMaterial = mesh.material;
|
||
mesh.material = highlightMat;
|
||
selectedMeshes.push(mesh);
|
||
}
|
||
});
|
||
}
|
||
onSelectOpeningRef.current?.(openingId);
|
||
};
|
||
// Auswahl-Hervorhebung einer Treppe (über userData.stairId).
|
||
const selectStair = (stairId: string | null) => {
|
||
restoreSelection();
|
||
if (stairId) {
|
||
building.traverse((o) => {
|
||
const mesh = o as THREE.Mesh;
|
||
if (mesh.isMesh && mesh.userData?.stairId === stairId) {
|
||
mesh.userData.origMaterial = mesh.material;
|
||
mesh.material = highlightMat;
|
||
selectedMeshes.push(mesh);
|
||
}
|
||
});
|
||
}
|
||
onSelectStairRef.current?.(stairId);
|
||
};
|
||
// Links-Klick-Auswahl: erst Öffnung, dann Wand, dann Decke, dann Treppe;
|
||
// Auswahlen schließen sich gegenseitig aus.
|
||
const selectAt = (clientX: number, clientY: number) => {
|
||
const openingId = pickOpening(clientX, clientY);
|
||
if (openingId) {
|
||
selectOpening(openingId);
|
||
onSelectWallRef.current?.(null);
|
||
onSelectCeilingRef.current?.(null);
|
||
onSelectStairRef.current?.(null);
|
||
return;
|
||
}
|
||
const wallId = pickWall(clientX, clientY);
|
||
if (wallId) {
|
||
selectWall(wallId);
|
||
onSelectCeilingRef.current?.(null);
|
||
onSelectOpeningRef.current?.(null);
|
||
onSelectStairRef.current?.(null);
|
||
return;
|
||
}
|
||
const ceilingId = pickCeiling(clientX, clientY);
|
||
if (ceilingId) {
|
||
selectCeiling(ceilingId);
|
||
onSelectOpeningRef.current?.(null);
|
||
onSelectStairRef.current?.(null);
|
||
return;
|
||
}
|
||
const stairId = pickStair(clientX, clientY);
|
||
if (stairId) {
|
||
selectStair(stairId);
|
||
onSelectWallRef.current?.(null);
|
||
onSelectCeilingRef.current?.(null);
|
||
onSelectOpeningRef.current?.(null);
|
||
return;
|
||
}
|
||
// Leerraum: alle Auswahlen leeren.
|
||
selectWall(null);
|
||
onSelectCeilingRef.current?.(null);
|
||
onSelectOpeningRef.current?.(null);
|
||
onSelectStairRef.current?.(null);
|
||
};
|
||
|
||
// ── Griff-Raycast + Drag-Zustand ─────────────────────────────────────────
|
||
// Der laufende Drag liegt im KOMPONENTEN-Ref gripDragRef (überlebt Neuaufbau).
|
||
// War beim (Re-)Aufbau bereits ein Drag aktiv, OrbitControls sofort sperren,
|
||
// damit die Geste nahtlos weiterläuft.
|
||
if (gripDragRef.current) controls.enabled = false;
|
||
// Vertikale Drag-Ebene durch die Wandachse (für den Höhen-Griff): die OK-Z
|
||
// wird aus dem Schnitt des Strahls mit dieser Ebene gelesen.
|
||
const heightPlane = new THREE.Plane();
|
||
const heightHit = new THREE.Vector3();
|
||
|
||
// Getroffene Griff-Kugel (oder null). Liefert ihren Deskriptor inkl. Element.
|
||
const pickGrip = (
|
||
clientX: number,
|
||
clientY: number,
|
||
): {
|
||
kind: GripKind;
|
||
index: number;
|
||
target: { wallId?: string; drawingId?: string };
|
||
} | null => {
|
||
if (gripGroup.children.length === 0) return null;
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||
raycaster.setFromCamera(ndc, activeCameraRef.current ?? camera);
|
||
const hits = raycaster.intersectObjects(gripGroup.children, false);
|
||
for (const hit of hits) {
|
||
const kind = hit.object.userData?.gripKind as GripKind | undefined;
|
||
if (kind)
|
||
return {
|
||
kind,
|
||
index: hit.object.userData?.gripIndex ?? -1,
|
||
target: hit.object.userData?.gripTarget ?? {},
|
||
};
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// Endpunkt eines gewählten Elements an Vertex-Index `index`. Wände: 0=start,
|
||
// 1=end. 2D-Elemente: via drawingGripVertices (gleiche Indizierung wie App).
|
||
const vertexPointOf = (
|
||
target: { wallId?: string; drawingId?: string },
|
||
index: number,
|
||
): Vec2 | null => {
|
||
if (target.wallId) {
|
||
const w = (editWallsRef.current ?? []).find((x) => x.id === target.wallId);
|
||
if (!w) return null;
|
||
return index === 0 ? w.start : w.end;
|
||
}
|
||
if (target.drawingId) {
|
||
const d = (editDrawingsRef.current ?? []).find((x) => x.id === target.drawingId);
|
||
if (!d) return null;
|
||
return drawingGripVertices(d)[index] ?? null;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// Ortho-Anker eines Vertex-Griffs: die FIXE Nachbarecke DESSELBEN Elements,
|
||
// relativ zu der ein Shift-Drag auf H/V eingerastet wird. Wand: das andere
|
||
// Ende. 2D-Element: der vorige Vertex (sonst der nächste). null, wenn es
|
||
// keinen Nachbarn gibt.
|
||
const anchorFor = (
|
||
target: { wallId?: string; drawingId?: string },
|
||
index: number,
|
||
): Vec2 | null => {
|
||
if (target.wallId) return vertexPointOf(target, index === 0 ? 1 : 0);
|
||
if (target.drawingId) {
|
||
const d = (editDrawingsRef.current ?? []).find((x) => x.id === target.drawingId);
|
||
if (!d) return null;
|
||
const verts = drawingGripVertices(d);
|
||
if (verts.length < 2) return null;
|
||
const ref = index > 0 ? index - 1 : 1;
|
||
return verts[ref] ?? null;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// Koinzidente Endpunkt-Griffe der ÜBRIGEN gewählten Elemente zum Punkt `p`
|
||
// bestimmen (gemeinsame Ecke). Scope: nur unter den GEWÄHLTEN Elementen.
|
||
// Toleranz: ~ Snap-Toleranz (tolerancePx/pxPerMeter); 2 mm Mindestabstand,
|
||
// damit minimale Float-Abweichungen einer geteilten Ecke greifen.
|
||
const COINCIDENT_TOL = 0.05; // m
|
||
const findCoincident = (
|
||
p: Vec2,
|
||
self: { wallId?: string; drawingId?: string },
|
||
): Array<{ target: { wallId?: string; drawingId?: string }; index: number }> => {
|
||
const out: Array<{ target: { wallId?: string; drawingId?: string }; index: number }> = [];
|
||
const near = (q: Vec2) => Math.hypot(q.x - p.x, q.y - p.y) <= COINCIDENT_TOL;
|
||
for (const w of editWallsRef.current ?? []) {
|
||
if (w.id === self.wallId) continue;
|
||
if (near(w.start)) out.push({ target: { wallId: w.id }, index: 0 });
|
||
if (near(w.end)) out.push({ target: { wallId: w.id }, index: 1 });
|
||
}
|
||
for (const d of editDrawingsRef.current ?? []) {
|
||
if (d.id === self.drawingId) continue;
|
||
drawingGripVertices(d).forEach((v, i) => {
|
||
if (near(v)) out.push({ target: { drawingId: d.id }, index: i });
|
||
});
|
||
}
|
||
return out;
|
||
};
|
||
|
||
// Alle Eckpunkte eines Elements (Wand: start/end; 2D: Griff-Vertices) — Basis
|
||
// für die Koinzidenz-Erkennung beim Körper-Verschieben (jede Ecke prüfen).
|
||
const elementVertices = (target: { wallId?: string; drawingId?: string }): Vec2[] => {
|
||
if (target.wallId) {
|
||
const w = (editWallsRef.current ?? []).find((x) => x.id === target.wallId);
|
||
return w ? [w.start, w.end] : [];
|
||
}
|
||
if (target.drawingId) {
|
||
const d = (editDrawingsRef.current ?? []).find((x) => x.id === target.drawingId);
|
||
return d ? drawingGripVertices(d) : [];
|
||
}
|
||
return [];
|
||
};
|
||
|
||
// Koinzidente Sibling-Vertices für ALLE Ecken des verschobenen Elements
|
||
// sammeln (Körper-Drag). Dedupliziert je (target,index); die LIVE-Position
|
||
// wird mitgeführt, damit das Delta inkrementell aufaddiert werden kann.
|
||
const findMoveCoincident = (
|
||
self: { wallId?: string; drawingId?: string },
|
||
): Array<{ target: { wallId?: string; drawingId?: string }; index: number; pos: Vec2 }> => {
|
||
const seen = new Set<string>();
|
||
const out: Array<{ target: { wallId?: string; drawingId?: string }; index: number; pos: Vec2 }> = [];
|
||
for (const corner of elementVertices(self)) {
|
||
for (const c of findCoincident(corner, self)) {
|
||
const key = `${c.target.wallId ?? ""}|${c.target.drawingId ?? ""}|${c.index}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
const pos = vertexPointOf(c.target, c.index);
|
||
if (pos) out.push({ ...c, pos: { ...pos } });
|
||
}
|
||
}
|
||
return out;
|
||
};
|
||
|
||
// Strahl auf eine vertikale Ebene durch die Wandachse projizieren → Höhen-Z.
|
||
// Ebene: Normale waagerecht, senkrecht zur Achse, durch den Achsmittelpunkt
|
||
// (so liegt der Cursor „an der Wand" und die vertikale Bewegung steuert die OK).
|
||
const heightZAt = (clientX: number, clientY: number, wall: Wall): number | null => {
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||
raycaster.setFromCamera(ndc, activeCameraRef.current ?? camera);
|
||
const ux = wall.end.x - wall.start.x;
|
||
const uy = wall.end.y - wall.start.y;
|
||
const len = Math.hypot(ux, uy) || 1;
|
||
// Achsrichtung in three (x,_,z) = Modell (x,_,y); Ebenen-Normale = Achse.
|
||
const nrm = new THREE.Vector3(ux / len, 0, uy / len);
|
||
const mid = new THREE.Vector3(
|
||
(wall.start.x + wall.end.x) / 2,
|
||
0,
|
||
(wall.start.y + wall.end.y) / 2,
|
||
);
|
||
heightPlane.setFromNormalAndCoplanarPoint(nrm, mid);
|
||
const hit = raycaster.ray.intersectPlane(heightPlane, heightHit);
|
||
return hit ? hit.y : null;
|
||
};
|
||
|
||
// Beginnt ein Griff-Drag, falls eine Griff-Kugel getroffen wurde (Links).
|
||
// OrbitControls bleibt ohnehin auf Links inaktiv; wir blockieren zusätzlich
|
||
// die Selektion/den Klick auf der pointerup-Seite, solange gripDrag aktiv ist.
|
||
const tryStartGripDrag = (e: PointerEvent): boolean => {
|
||
if (commandActiveRef.current) return false;
|
||
const g = pickGrip(e.clientX, e.clientY);
|
||
if (!g) return false;
|
||
// Koinzidenz-Gruppe nur bei Vertex-Drags: die Endpunkte der übrigen
|
||
// gewählten Elemente, die mit dem gezogenen Punkt zusammenfallen, werden
|
||
// mitgeführt (gemeinsame Ecke bleibt verbunden).
|
||
let coincident: Array<{
|
||
target: { wallId?: string; drawingId?: string };
|
||
index: number;
|
||
}> = [];
|
||
if (g.kind === "vertex") {
|
||
const self = vertexPointOf(g.target, g.index);
|
||
if (self) coincident = findCoincident(self, g.target);
|
||
}
|
||
// Körper-Drag: koinzidente Enden anderer GEWÄHLTER Elemente an JEDER Ecke
|
||
// des verschobenen Elements mitführen (gemeinsame Ecke bleibt verbunden).
|
||
const moveCoincident =
|
||
g.kind === "move" ? findMoveCoincident(g.target) : [];
|
||
// Snap-Ausschluss = gesamte Koinzidenz-Gruppe (Selbst + Mitgeführte), damit
|
||
// der Punkt nicht an sich selbst oder einem verbundenen Ende fängt.
|
||
const excludeWallIds = new Set<string>();
|
||
const excludeDrawingIds = new Set<string>();
|
||
const addExclude = (t: { wallId?: string; drawingId?: string }) => {
|
||
if (t.wallId) excludeWallIds.add(t.wallId);
|
||
if (t.drawingId) excludeDrawingIds.add(t.drawingId);
|
||
};
|
||
addExclude(g.target);
|
||
coincident.forEach((c) => addExclude(c.target));
|
||
gripDragRef.current = {
|
||
kind: g.kind,
|
||
vertexIndex: g.index,
|
||
lastModel: g.kind === "move" ? workplaneModel(e.clientX, e.clientY) : null,
|
||
anchor: g.kind === "vertex" ? anchorFor(g.target, g.index) : null,
|
||
wallId: g.kind === "height" ? g.target.wallId ?? null : null,
|
||
pointerId: e.pointerId,
|
||
target: g.target,
|
||
coincident,
|
||
moveCoincident,
|
||
excludeWallIds,
|
||
excludeDrawingIds,
|
||
};
|
||
controls.enabled = false; // OrbitControls aus, solange wir ziehen
|
||
dom.setPointerCapture?.(e.pointerId);
|
||
return true;
|
||
};
|
||
|
||
// Klick-Erkennung ohne Drag: Position bei pointerdown merken, bei pointerup
|
||
// nur als Klick werten, wenn der Zeiger kaum bewegt wurde.
|
||
let downX = 0;
|
||
let downY = 0;
|
||
let downButton = -1;
|
||
const CLICK_SLOP = 4; // px
|
||
const onPointerDownDom = (e: PointerEvent) => {
|
||
downX = e.clientX;
|
||
downY = e.clientY;
|
||
downButton = e.button;
|
||
// Linksklick auf eine Griff-Kugel startet einen Editier-Drag (vor Auswahl).
|
||
if (e.button === 0) tryStartGripDrag(e);
|
||
};
|
||
const onPointerUpDom = (e: PointerEvent) => {
|
||
// Lief ein Griff-Drag, ist die Geste verbraucht: committen, KEINE Auswahl.
|
||
if (gripDragRef.current) {
|
||
gripDragRef.current = null;
|
||
controls.enabled = true;
|
||
showGripSnap(null); // Snap-Marker ausblenden
|
||
dom.releasePointerCapture?.(e.pointerId);
|
||
onEditEndRef.current?.();
|
||
return;
|
||
}
|
||
const moved = Math.hypot(e.clientX - downX, e.clientY - downY);
|
||
if (moved > CLICK_SLOP) return;
|
||
// LINKS = Punkt setzen (Erstellungs-Modus) bzw. Auswahl (Ruhe). Kein Orbit
|
||
// links (OrbitControls LEFT = -1), daher ist Links hier immer frei.
|
||
if (e.button === 0 && downButton === 0) {
|
||
if (commandActiveRef.current) {
|
||
const m = workplaneModel(e.clientX, e.clientY);
|
||
if (m) onWorkplanePointRef.current?.("pick", m);
|
||
} else {
|
||
selectAt(e.clientX, e.clientY);
|
||
}
|
||
}
|
||
};
|
||
// Pointer-Move: laufender Griff-Drag hat Vorrang (Endpunkt/Vertex/Höhe/
|
||
// Verschieben). Sonst im Erstellungs-Modus Vorschau-Punkt an die Engine; ohne
|
||
// aktiven Befehl unverändert (OrbitControls behandelt Mitte/Rechts selbst).
|
||
const onPointerMoveDom = (e: PointerEvent) => {
|
||
const drag = gripDragRef.current;
|
||
if (drag) {
|
||
if (drag.kind === "vertex") {
|
||
const raw = workplaneModel(e.clientX, e.clientY);
|
||
if (raw) {
|
||
// Shift = Ortho-Lock: den gezogenen Punkt auf H/V gegenüber dem
|
||
// FIXEN Anker (Nachbarecke) zwingen — Achse = die, der der Cursor
|
||
// näher ist (applyAngleConstraint mit 90°, wie Ortho beim Zeichnen).
|
||
// Der Lock dominiert (kein Snap), damit die Endkoordinate garantiert
|
||
// eine Achse mit dem Anker teilt.
|
||
const orthoLock = e.shiftKey && drag.anchor;
|
||
const m = orthoLock
|
||
? applyAngleConstraint(drag.anchor!, raw, 90)
|
||
: raw;
|
||
// Ohne Lock: DIESELBE Snap-Logik wie die Plan-Griffe (App:
|
||
// computeSnap mit Ausschluss der Koinzidenz-Gruppe). Mit Lock: den
|
||
// gezwungenen Punkt direkt anwenden (kein erneutes Snappen).
|
||
const applied = orthoLock
|
||
? (onEditVertexRef.current?.(drag.target, drag.vertexIndex, m) ?? m)
|
||
: (onEditVertexRef.current?.(
|
||
drag.target,
|
||
drag.vertexIndex,
|
||
m,
|
||
drag.excludeWallIds,
|
||
drag.excludeDrawingIds,
|
||
) ?? m);
|
||
// Snap-Marker am gesnappten Punkt, falls vom rohen Punkt verschieden.
|
||
const snapped =
|
||
!orthoLock && Math.hypot(applied.x - raw.x, applied.y - raw.y) > 1e-6;
|
||
showGripSnap(snapped ? applied : null);
|
||
// Koinzidente Endpunkte der übrigen gewählten Elemente mitführen:
|
||
// exakt auf denselben gesnappten Punkt (snapped=false ⇒ direkt).
|
||
for (const c of drag.coincident) {
|
||
onEditVertexRef.current?.(c.target, c.index, applied);
|
||
}
|
||
}
|
||
} else if (drag.kind === "move") {
|
||
const m = workplaneModel(e.clientX, e.clientY);
|
||
if (m && drag.lastModel) {
|
||
const delta = { x: m.x - drag.lastModel.x, y: m.y - drag.lastModel.y };
|
||
if (delta.x !== 0 || delta.y !== 0) {
|
||
onEditBodyRef.current?.(drag.target, delta);
|
||
// Koinzidente Sibling-Ecken um dasselbe Delta mitführen: ihre LIVE
|
||
// Position fortschreiben und absolut neu setzen (kein Snap).
|
||
for (const c of drag.moveCoincident) {
|
||
c.pos = { x: c.pos.x + delta.x, y: c.pos.y + delta.y };
|
||
onEditVertexRef.current?.(c.target, c.index, c.pos);
|
||
}
|
||
}
|
||
drag.lastModel = m;
|
||
} else if (m) {
|
||
drag.lastModel = m;
|
||
}
|
||
} else if (drag.kind === "height") {
|
||
const wall = (editWallsRef.current ?? []).find((w) => w.id === drag.wallId);
|
||
if (wall && drag.wallId) {
|
||
const z = heightZAt(e.clientX, e.clientY, wall);
|
||
if (z !== null) onEditWallTopRef.current?.(drag.wallId, z);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (!commandActiveRef.current) return;
|
||
const m = workplaneModel(e.clientX, e.clientY);
|
||
if (m) onWorkplanePointRef.current?.("move", m);
|
||
};
|
||
// Doppelklick im Erstellungs-Modus = bestätigen/beenden (z. B. Polylinie).
|
||
const onDblClickDom = (e: MouseEvent) => {
|
||
if (!commandActiveRef.current) return;
|
||
e.preventDefault();
|
||
onWorkplaneConfirmRef.current?.();
|
||
};
|
||
// RECHTS: im Erstellungs-Modus = bestätigen (kein Kontextmenü); sonst
|
||
// Kontextmenü. Browser-Menü immer unterdrücken, Rechts-Drag-Pan ist aus.
|
||
const onContextMenuDom = (e: MouseEvent) => {
|
||
e.preventDefault();
|
||
const moved = Math.hypot(e.clientX - downX, e.clientY - downY);
|
||
if (commandActiveRef.current) {
|
||
// Nach einem Rechts-Drag (Orbit/Pan) NICHT bestätigen.
|
||
if (moved > CLICK_SLOP && downButton === 2) return;
|
||
onWorkplaneConfirmRef.current?.();
|
||
return;
|
||
}
|
||
const cb = onContextMenuRef.current;
|
||
if (!cb) return;
|
||
// Nach einem Drag (falls doch) kein Menü öffnen.
|
||
if (moved > CLICK_SLOP && downButton === 2) return;
|
||
cb({ clientX: e.clientX, clientY: e.clientY, wallId: pickWall(e.clientX, e.clientY) });
|
||
};
|
||
const dom = renderer.domElement;
|
||
dom.addEventListener("pointerdown", onPointerDownDom);
|
||
dom.addEventListener("pointerup", onPointerUpDom);
|
||
dom.addEventListener("pointermove", onPointerMoveDom);
|
||
dom.addEventListener("dblclick", onDblClickDom);
|
||
dom.addEventListener("contextmenu", onContextMenuDom);
|
||
|
||
return () => {
|
||
cancelAnimationFrame(raf);
|
||
dom.removeEventListener("pointerdown", onPointerDownDom);
|
||
dom.removeEventListener("pointerup", onPointerUpDom);
|
||
dom.removeEventListener("pointermove", onPointerMoveDom);
|
||
dom.removeEventListener("dblclick", onDblClickDom);
|
||
dom.removeEventListener("contextmenu", onContextMenuDom);
|
||
drawDraftRef.current = null;
|
||
clearDraftGroup();
|
||
scene.remove(draftGroup);
|
||
draftLineMat.dispose();
|
||
draftVertMat.dispose();
|
||
snapMarkMat.dispose();
|
||
draftSphereGeo.dispose();
|
||
// Editier-Griffe abräumen.
|
||
drawGripsRef.current = null;
|
||
clearGripGroup();
|
||
scene.remove(gripGroup);
|
||
gripVertMat.dispose();
|
||
gripHeightMat.dispose();
|
||
gripMoveMat.dispose();
|
||
gripGeo.dispose();
|
||
gripSnapMat.dispose();
|
||
gripSnapGeo.dispose();
|
||
restoreSelection();
|
||
highlightMat.dispose();
|
||
ro.disconnect();
|
||
controls.dispose();
|
||
controlsRef.current = null;
|
||
cameraRef.current = null;
|
||
orthoCameraRef.current = null;
|
||
activeCameraRef.current = null;
|
||
boundsRef.current = null;
|
||
renderer.dispose();
|
||
envRenderTarget.dispose();
|
||
for (const m of mats.normal.values()) m.dispose();
|
||
for (const m of mats.greyed.values()) m.dispose();
|
||
for (const m of mats.lines.values()) m.dispose();
|
||
hiddenFace.dispose();
|
||
edgeMat.dispose();
|
||
whiteFace.dispose();
|
||
matRuntime.dispose();
|
||
// EdgesGeometry der Hidden-Line-Kanten freigeben (Faces teilen ihre Geo
|
||
// mit dem Mesh und werden über die Mesh-Geo unten/ohnehin verworfen).
|
||
building.traverse((o) => {
|
||
const seg = o as THREE.LineSegments;
|
||
if (seg.isLineSegments) seg.geometry.dispose();
|
||
const mesh = o as THREE.Mesh;
|
||
if (mesh.isMesh) mesh.geometry.dispose();
|
||
});
|
||
// Kontext-Gruppe sauber freigeben (Geometrien + geteilte Materialien).
|
||
contextGroup.traverse((o) => {
|
||
const seg = o as THREE.LineSegments;
|
||
if (seg.isLineSegments) seg.geometry.dispose();
|
||
const mesh = o as THREE.Mesh;
|
||
if (mesh.isMesh) mesh.geometry.dispose();
|
||
});
|
||
ctxMats.terrain.dispose();
|
||
ctxMats.mesh.dispose();
|
||
ctxMats.contour.dispose();
|
||
mount.removeChild(renderer.domElement);
|
||
};
|
||
// Sichtbare Geschoss-IDs + sichtbare Codes als stabiler String, damit eine
|
||
// neue Set-Instanz gleichen Inhalts den Effekt nicht unnötig neu auslöst
|
||
// und Umschalten von Geschoss/Kategorie den Neuaufbau auslöst. displayKey
|
||
// kodiert die aktuellen Display-Modi (Resolver sind nicht referenzstabil).
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [
|
||
project,
|
||
visibleFloorKey(project),
|
||
[...visibleCodes].sort().join(","),
|
||
displayKey,
|
||
renderMode,
|
||
]);
|
||
|
||
// Leichter Effekt: Blickwinkel-Preset anwenden, wenn `view3d` wechselt — ohne
|
||
// die (teure) Szene neu zu bauen. Springt die Kamera auf den Standpunkt;
|
||
// OrbitControls bleibt danach frei.
|
||
useEffect(() => {
|
||
// Während eines laufenden Griff-Drags NIE die Kamera umsetzen (der Nutzer hat
|
||
// einen Punkt „in der Hand"); der Preset wird erst nach dem Drag wirksam.
|
||
if (gripDragRef.current) return;
|
||
const camera = cameraRef.current;
|
||
const orthoCamera = orthoCameraRef.current;
|
||
const controls = controlsRef.current;
|
||
const bounds = boundsRef.current;
|
||
const mount = ref.current;
|
||
if (camera && orthoCamera && controls && bounds && mount) {
|
||
const aspect = (mount.clientWidth || 1) / (mount.clientHeight || 1);
|
||
applyView3d(camera, orthoCamera, activeCameraRef, controls, bounds, view3d, aspect);
|
||
}
|
||
}, [view3d]);
|
||
|
||
// Leichter Effekt: FOV (Sichtwinkel) der Perspektivkamera live setzen.
|
||
useEffect(() => {
|
||
const camera = cameraRef.current;
|
||
if (!camera) return;
|
||
camera.fov = fov;
|
||
camera.updateProjectionMatrix();
|
||
}, [fov]);
|
||
|
||
// Leichter Effekt: Top-Ansicht sperren — in der orthografischen Draufsicht kein
|
||
// Orbit (Rotieren), damit die Ansicht ruhig senkrecht von oben bleibt (Wunsch:
|
||
// „am liebsten Top-View gesperrt"). Pan (Shift+Mitte) + Zoom bleiben erlaubt,
|
||
// damit man den Ausschnitt in x/y noch verschieben kann. In allen anderen
|
||
// Ansichten ist der Orbit wieder frei.
|
||
useEffect(() => {
|
||
const controls = controlsRef.current;
|
||
if (!controls) return;
|
||
controls.enableRotate = view3d !== "top";
|
||
}, [view3d]);
|
||
|
||
// Leichter Effekt: Bodenraster auf die Z-Höhe des aktiven Geschosses heben
|
||
// (ohne Szenen-Neuaufbau), passend zu den 2D-Zeichnungen auf derselben Höhe.
|
||
useEffect(() => {
|
||
if (gridRef.current) gridRef.current.position.y = gridElevation;
|
||
}, [gridElevation]);
|
||
|
||
// Leichter Effekt: 3D-Draft-Vorschau bei `draft`-Änderung neu zeichnen (ohne
|
||
// Szenen-Neuaufbau). Leert die Vorschau bei null.
|
||
useEffect(() => {
|
||
drawDraftRef.current?.(draft);
|
||
}, [draft]);
|
||
|
||
// Leichter Effekt: Editier-Griffe neu zeichnen, wenn sich die Auswahl, die
|
||
// gezogene Geometrie (Live-Mutation), der Befehlszustand oder die Geschoss-
|
||
// höhe ändert — ohne Szenen-Neuaufbau (die Kugeln folgen so dem Element).
|
||
useEffect(() => {
|
||
drawGripsRef.current?.();
|
||
}, [editWalls, editDrawings, commandActive, gridElevation]);
|
||
|
||
return (
|
||
<div className="viewport" ref={ref}>
|
||
{unavailable && (
|
||
<div className="viewport-fallback">
|
||
<strong>3D-Ansicht nicht verfügbar</strong>
|
||
<span>
|
||
WebGL ist in deinem Browser deaktiviert. Der Grundriss funktioniert
|
||
weiterhin. So aktivierst du 3D in Firefox: <code>about:config</code> →
|
||
<code>webgl.disabled</code> auf <code>false</code> setzen.
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Getrennte Material-Caches: normal und gedimmt (je Component-ID). */
|
||
interface MatCaches {
|
||
normal: Map<string, THREE.MeshStandardMaterial>;
|
||
greyed: Map<string, THREE.MeshStandardMaterial>;
|
||
/** Linien-Materialien der 2D-Zeichengeometrie (Schlüssel: `farbe|greyed`). */
|
||
lines: Map<string, THREE.LineBasicMaterial>;
|
||
}
|
||
|
||
/** Bündel der Bau-Optionen, das durch die Bau-Helfer gereicht wird. */
|
||
interface BuildOpts {
|
||
mats: MatCaches;
|
||
renderMode: RenderMode;
|
||
/** Geteiltes flaches Face-Material für den Hidden-Line-Modus. */
|
||
hiddenFace: THREE.MeshBasicMaterial;
|
||
/** Geteiltes Kanten-Material für den Hidden-Line-Modus. */
|
||
edgeMat: THREE.LineBasicMaterial;
|
||
/** Geteiltes mattes helles Material für den „Weiss"-Modus (Clay-Look). */
|
||
whiteFace: THREE.MeshStandardMaterial;
|
||
/** Laufzeit für PBR-Texturen (Render-Modus „textured"); sonst ungenutzt. */
|
||
matRuntime: MaterialRuntime;
|
||
}
|
||
|
||
/** Stabiler Schlüssel der sichtbaren Geschosse (IDs in Dokumentreihenfolge). */
|
||
function visibleFloorKey(project: Project): string {
|
||
return project.drawingLevels
|
||
.filter((l) => l.kind === "floor" && l.visible)
|
||
.map((l) => l.id)
|
||
.join(",");
|
||
}
|
||
|
||
/**
|
||
* Setzt das Frustum einer OrthographicCamera so, dass die Modell-Bounds bei
|
||
* gegebenem Canvas-Seitenverhältnis formatfüllend (mit etwas Rand) abgebildet
|
||
* werden. Die parallele Projektion zeigt keine Fluchtpunkt-Verzerrung — die
|
||
* Größe im Bild bestimmt allein das Frustum (nicht die Kamera-Distanz).
|
||
*
|
||
* Bemessung: Halb-Höhe/Halb-Breite aus dem Bounds-Radius (Diagonale/2, damit das
|
||
* Modell aus jeder Richtung — auch iso — komplett passt), dann ans aspect
|
||
* angepasst (an der jeweils engeren Achse). near/far bleiben fix (großzügig).
|
||
*/
|
||
function updateOrthoFrustum(
|
||
camera: THREE.OrthographicCamera,
|
||
bounds: THREE.Box3,
|
||
aspect: number,
|
||
): void {
|
||
const size = bounds.getSize(new THREE.Vector3());
|
||
// Diagonal-Radius: deckt jede Blickrichtung (inkl. iso) formatfüllend ab.
|
||
const radius = Math.max(0.5 * Math.hypot(size.x, size.y, size.z), 0.001);
|
||
const margin = 1.1; // etwas Rand
|
||
// Halb-Ausdehnungen so, dass ein 2·radius großes Quadrat ins Bild passt.
|
||
let halfW: number;
|
||
let halfH: number;
|
||
if (aspect >= 1) {
|
||
halfH = radius * margin;
|
||
halfW = halfH * aspect;
|
||
} else {
|
||
halfW = radius * margin;
|
||
halfH = halfW / aspect;
|
||
}
|
||
camera.left = -halfW;
|
||
camera.right = halfW;
|
||
camera.top = halfH;
|
||
camera.bottom = -halfH;
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
|
||
/**
|
||
* Wendet einen kanonischen Blickwinkel-Preset (Vectorworks/DOSSIER) an und
|
||
* bestimmt die aktive Kamera:
|
||
* perspective → PerspectiveCamera (Fluchtpunkt-Projektion, FOV wirkt).
|
||
* front/side/top/iso → OrthographicCamera (echte parallele Projektion;
|
||
* „Isometrie" zeigt parallele Kanten parallel, ohne Konvergenz).
|
||
*
|
||
* Für die aktive Kamera: Ziel = Modell-Zentrum, Position = Zentrum + Richtung ×
|
||
* Distanz, up=(0,1,0) (für top eine stabile up-Achse über winzigen Z-Anteil).
|
||
* Bei der Perspektive macht die Distanz das Modell formatfüllend (Radius/FOV);
|
||
* bei Ortho ist die Distanz nur fürs Clipping relevant (Größe macht das Frustum,
|
||
* siehe updateOrthoFrustum) — sie wird so gewählt, dass das Modell sicher
|
||
* zwischen near/far liegt.
|
||
*
|
||
* OrbitControls wird auf die aktive Kamera umgehängt (`.object`) und sein target
|
||
* aufs Zentrum gesetzt; danach kann frei orbitiert/gezoomt/geschwenkt werden
|
||
* (Preset = „dorthin springen", kein Lock).
|
||
*/
|
||
function applyView3d(
|
||
camera: THREE.PerspectiveCamera,
|
||
orthoCamera: THREE.OrthographicCamera,
|
||
activeCameraRef: MutableRefObject<THREE.Camera | null>,
|
||
controls: OrbitControls,
|
||
bounds: THREE.Box3,
|
||
view3d: View3d,
|
||
aspect: number,
|
||
): void {
|
||
const center = bounds.getCenter(new THREE.Vector3());
|
||
const size = bounds.getSize(new THREE.Vector3());
|
||
const radius = Math.max(0.5 * Math.hypot(size.x, size.y, size.z), 0.001);
|
||
|
||
// Blickrichtung je Preset (vom Zentrum zur Kamera, normalisiert).
|
||
let dir: THREE.Vector3;
|
||
switch (view3d) {
|
||
case "front":
|
||
dir = new THREE.Vector3(0, 0, 1);
|
||
break;
|
||
case "side":
|
||
dir = new THREE.Vector3(1, 0, 0);
|
||
break;
|
||
case "top":
|
||
dir = new THREE.Vector3(0, 1, 0.0001); // winziger Z-Anteil → stabiles Up
|
||
break;
|
||
case "iso":
|
||
dir = new THREE.Vector3(1, 1, 1).normalize();
|
||
break;
|
||
case "perspective":
|
||
default:
|
||
// Heutiger 3/4-Blick (vorne-oben-rechts), etwas flacher als die Isometrie.
|
||
dir = new THREE.Vector3(0.62, 0.5, 0.7).normalize();
|
||
break;
|
||
}
|
||
|
||
const perspective = view3d === "perspective";
|
||
const active: THREE.PerspectiveCamera | THREE.OrthographicCamera = perspective
|
||
? camera
|
||
: orthoCamera;
|
||
|
||
if (perspective) {
|
||
// Distanz so, dass die Bounds im FOV liegen (+ Reserve).
|
||
const half = Math.max(THREE.MathUtils.degToRad(camera.fov) * 0.5, 0.1);
|
||
const dist = (radius / Math.sin(half)) * 1.25 + radius;
|
||
camera.aspect = aspect;
|
||
camera.position.copy(center).addScaledVector(dir, dist);
|
||
camera.up.set(0, 1, 0);
|
||
camera.lookAt(center);
|
||
camera.updateProjectionMatrix();
|
||
} else {
|
||
// Frustum (Größe) ans aspect anpassen; Distanz nur fürs Clipping relevant,
|
||
// weit genug, damit das Modell sicher zwischen near (0.1) und far liegt.
|
||
updateOrthoFrustum(orthoCamera, bounds, aspect);
|
||
const dist = radius * 4 + 10;
|
||
orthoCamera.position.copy(center).addScaledVector(dir, dist);
|
||
orthoCamera.up.set(0, 1, 0);
|
||
orthoCamera.lookAt(center);
|
||
orthoCamera.updateProjectionMatrix();
|
||
}
|
||
|
||
// OrbitControls an die aktive Kamera binden. `.object` lässt sich live tauschen
|
||
// (robuster als Neu-Erstellen: Maus-Schema/Listener bleiben erhalten); danach
|
||
// update() für die korrekte sphärische Ausrichtung relativ zum Target.
|
||
if (controls.object !== active) controls.object = active;
|
||
controls.target.copy(center);
|
||
controls.update();
|
||
activeCameraRef.current = active;
|
||
}
|
||
|
||
/** Geteilte Materialien der Kontext-Schicht (Gelände/Mesh/Kontur). */
|
||
interface ContextMats {
|
||
terrain: THREE.MeshStandardMaterial;
|
||
mesh: THREE.MeshStandardMaterial;
|
||
contour: THREE.LineBasicMaterial;
|
||
}
|
||
|
||
/**
|
||
* Baut die Kontext-Gruppe aus `Project.context`:
|
||
* • TerrainMesh / ImportedMesh → BufferGeometry aus positions/indices.
|
||
* • ContourSet → LineSegments je Kontur auf ihrer Z-Höhe.
|
||
*
|
||
* Achs-Konvention: das Modell speichert (x, y, z) mit z = Höhe; Three nutzt y als
|
||
* Höhe. Wir bilden Modell (x,y,z) → Three (x, z, y) ab (wie die übrige Szene, in
|
||
* der Plan-Y auf die Three-Z-Achse fällt und die Höhe auf Three-Y).
|
||
*/
|
||
function buildContext(
|
||
context: ContextObject[],
|
||
mats: ContextMats,
|
||
): THREE.Group {
|
||
const group = new THREE.Group();
|
||
for (const obj of context) {
|
||
if (obj.type === "terrainMesh" || obj.type === "importedMesh") {
|
||
const mesh = buildContextMesh(
|
||
obj.positions,
|
||
obj.indices,
|
||
obj.type === "terrainMesh" ? mats.terrain : mats.mesh,
|
||
);
|
||
if (mesh) group.add(mesh);
|
||
} else if (obj.type === "contourSet") {
|
||
const lines = buildContourLines(obj.contours, mats.contour);
|
||
if (lines) group.add(lines);
|
||
}
|
||
}
|
||
return group;
|
||
}
|
||
|
||
/** BufferGeometry-Mesh aus rohen (x,y,z)-positions/indices; Modell→Three-Achsen. */
|
||
function buildContextMesh(
|
||
positions: number[],
|
||
indices: number[],
|
||
mat: THREE.Material,
|
||
): THREE.Mesh | null {
|
||
if (positions.length < 9 || indices.length < 3) return null;
|
||
// Modell (x,y,z) → Three (x, z, y).
|
||
const verts = new Float32Array(positions.length);
|
||
for (let i = 0; i < positions.length; i += 3) {
|
||
verts[i] = positions[i]; // x
|
||
verts[i + 1] = positions[i + 2]; // y(Three) = z(Modell) = Höhe
|
||
verts[i + 2] = positions[i + 1]; // z(Three) = y(Modell)
|
||
}
|
||
const geo = new THREE.BufferGeometry();
|
||
geo.setAttribute("position", new THREE.BufferAttribute(verts, 3));
|
||
geo.setIndex(indices);
|
||
geo.computeVertexNormals();
|
||
geo.computeBoundingSphere();
|
||
return new THREE.Mesh(geo, mat);
|
||
}
|
||
|
||
/** LineSegments aller Konturen (jede Kontur als zusammenhängender Linienzug). */
|
||
function buildContourLines(
|
||
contours: import("../model/types").Contour[],
|
||
mat: THREE.LineBasicMaterial,
|
||
): THREE.LineSegments | null {
|
||
const pos: number[] = [];
|
||
for (const c of contours) {
|
||
const n = c.pts.length;
|
||
if (n < 2) continue;
|
||
const segCount = c.closed ? n : n - 1;
|
||
for (let i = 0; i < segCount; i++) {
|
||
const a = c.pts[i];
|
||
const b = c.pts[(i + 1) % n];
|
||
// Modell (x,y,z=c.z) → Three (x, z, y).
|
||
pos.push(a.x, c.z, a.y, b.x, c.z, b.y);
|
||
}
|
||
}
|
||
if (pos.length === 0) return null;
|
||
const geo = new THREE.BufferGeometry();
|
||
geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(pos), 3));
|
||
geo.computeBoundingSphere();
|
||
const seg = new THREE.LineSegments(geo, mat);
|
||
seg.frustumCulled = false;
|
||
return seg;
|
||
}
|
||
|
||
function buildBuilding(
|
||
project: Project,
|
||
visibleCodes: Set<string>,
|
||
floorDisplay: DisplayResolver,
|
||
categoryDisplay: DisplayResolver,
|
||
opts: BuildOpts,
|
||
): THREE.Group {
|
||
const group = new THREE.Group();
|
||
// Geschosse gemäß Darstellungsmodus auswählen; jedes vertikal um seine
|
||
// baseElevation versetzt stapeln. Ein gedimmtes Geschoss färbt all seine
|
||
// Wände gedämpft ein.
|
||
const floors = project.drawingLevels.filter((l) => l.kind === "floor");
|
||
for (const floor of floors) {
|
||
const d = floorDisplay(floor.id);
|
||
if (!d.render) continue;
|
||
buildFloor(group, project, floor, visibleCodes, categoryDisplay, d.greyed, opts);
|
||
}
|
||
return group;
|
||
}
|
||
|
||
/**
|
||
* Baut ein einzelnes Geschoss in die Gruppe. Es werden nur die Wände dieses
|
||
* Geschosses mit sichtbarer Kategorie verwendet; die Gehrung wird auf genau
|
||
* dieser Wandmenge berechnet. Jede vertikale Koordinate wird um die
|
||
* baseElevation des Geschosses verschoben, sodass die Geschosse stapeln.
|
||
*/
|
||
function buildFloor(
|
||
group: THREE.Group,
|
||
project: Project,
|
||
floor: DrawingLevel,
|
||
visibleCodes: Set<string>,
|
||
categoryDisplay: DisplayResolver,
|
||
floorGreyed: boolean,
|
||
opts: BuildOpts,
|
||
): void {
|
||
const baseElevation = floor.baseElevation ?? 0;
|
||
// Sichtbarkeit wie bisher; der Kategorie-Darstellungsmodus verfeinert sie.
|
||
const walls = project.walls.filter(
|
||
(w) =>
|
||
w.floorId === floor.id &&
|
||
visibleCodes.has(w.categoryCode) &&
|
||
categoryDisplay(w.categoryCode).render,
|
||
);
|
||
// Gehrungs-Schnittlinien auf der gefilterten Wandmenge berechnen, damit 3D
|
||
// mit dem gehrten Grundriss übereinstimmt.
|
||
const joins = computeJoins(project, walls);
|
||
for (const wall of walls) {
|
||
// Gedimmt, wenn das Geschoss ODER die Kategorie der Wand gedimmt ist.
|
||
const greyed = floorGreyed || categoryDisplay(wall.categoryCode).greyed;
|
||
const doors = project.doors.filter((d) => d.hostWallId === wall.id);
|
||
const wallOpenings = (project.openings ?? []).filter(
|
||
(o) => o.hostWallId === wall.id,
|
||
);
|
||
const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
|
||
addWallMeshes(group, project, wall, doors, wallOpenings, cuts, baseElevation, greyed, opts);
|
||
// Rahmen/Glas/Blatt der Öffnungen NUR, wenn die Öffnungs-Kategorie sichtbar ist.
|
||
for (const o of wallOpenings) {
|
||
const oGreyed = floorGreyed || categoryDisplay(o.categoryCode).greyed;
|
||
if (visibleCodes.has(o.categoryCode) && categoryDisplay(o.categoryCode).render) {
|
||
addOpeningMeshes(group, project, wall, o, oGreyed, opts);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Decken dieses Geschosses als extrudierte Slabs (Umriss × Dicke) an der
|
||
// korrekten Z-Lage (OK = Geschoss-Oberkante, wächst nach unten).
|
||
const ceilings = (project.ceilings ?? []).filter(
|
||
(c) =>
|
||
c.floorId === floor.id &&
|
||
visibleCodes.has(c.categoryCode) &&
|
||
categoryDisplay(c.categoryCode).render,
|
||
);
|
||
for (const ceiling of ceilings) {
|
||
const greyed = floorGreyed || categoryDisplay(ceiling.categoryCode).greyed;
|
||
addCeilingMesh(group, project, ceiling, greyed, opts);
|
||
}
|
||
|
||
// Treppen dieses Geschosses als gestufte Boxen (Tritte + Setzstufen), von der
|
||
// Geschoss-UK bis zur nächsten Geschoss-OK aufsteigend.
|
||
const stairs = stairsOfFloor(project, floor.id).filter(
|
||
(s) =>
|
||
visibleCodes.has(s.categoryCode) && categoryDisplay(s.categoryCode).render,
|
||
);
|
||
for (const stair of stairs) {
|
||
const greyed = floorGreyed || categoryDisplay(stair.categoryCode).greyed;
|
||
addStairMeshes(group, project, stair, greyed, opts);
|
||
}
|
||
|
||
// Freie 2D-Zeichengeometrie dieses Geschosses flach auf der Geschossebene
|
||
// (Z = baseElevation) — z. B. ein Linien-Raster liegt auf dem Boden des Geschosses.
|
||
const drawings = project.drawings2d.filter(
|
||
(d) =>
|
||
d.levelId === floor.id &&
|
||
visibleCodes.has(d.categoryCode) &&
|
||
categoryDisplay(d.categoryCode).render,
|
||
);
|
||
for (const d of drawings) {
|
||
const greyed = floorGreyed || categoryDisplay(d.categoryCode).greyed;
|
||
addDrawing2DLines(group, project, d, baseElevation, greyed, opts);
|
||
}
|
||
}
|
||
|
||
/** Modellfarbe eines 2D-Zeichenelements (color → LineStyle → Kategorie). */
|
||
function drawing2DColor(project: Project, d: Drawing2D): string {
|
||
if (d.color) return d.color;
|
||
const ls = d.lineStyleId
|
||
? project.lineStyles.find((l) => l.id === d.lineStyleId)
|
||
: undefined;
|
||
if (ls?.color) return ls.color;
|
||
const cat = flattenCategories(project.layers).find((c) => c.code === d.categoryCode);
|
||
return cat?.color ?? "#888888";
|
||
}
|
||
|
||
/**
|
||
* Editierbare Eckpunkte eines 2D-Elements für die 3D-Vertex-Griffe — in genau
|
||
* derselben Reihenfolge/Indizierung wie `drawingVertices`/`moveGripOf` im
|
||
* Store, damit ein gezogener Griff den richtigen Vertex mutiert. line/polyline/
|
||
* rect liefern Punkte; andere Formen (circle/arc/text) → keine Vertex-Griffe.
|
||
*/
|
||
function drawingGripVertices(d: Drawing2D): Vec2[] {
|
||
const g = d.geom;
|
||
if (g.shape === "line") return [g.a, g.b];
|
||
if (g.shape === "polyline") return [...g.pts];
|
||
if (g.shape === "rect") {
|
||
return [
|
||
g.min,
|
||
{ x: g.max.x, y: g.min.y },
|
||
g.max,
|
||
{ x: g.min.x, y: g.max.y },
|
||
];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* Ankerpunkt für den Verschiebe-Griff eines 2D-Elements (Schwerpunkt der
|
||
* Vertex-Griffe). Formen ohne Vertex-Griffe (circle/arc/text) liefern ihren
|
||
* Geometrie-Ursprung (Mittelpunkt bzw. Textanker), damit auch sie einen
|
||
* Verschiebe-Griff bekommen. null, wenn kein sinnvoller Anker existiert.
|
||
*/
|
||
function drawingMoveAnchor(d: Drawing2D, verts: Vec2[]): Vec2 | null {
|
||
if (verts.length > 0) {
|
||
let sx = 0;
|
||
let sy = 0;
|
||
for (const v of verts) {
|
||
sx += v.x;
|
||
sy += v.y;
|
||
}
|
||
return { x: sx / verts.length, y: sy / verts.length };
|
||
}
|
||
const g = d.geom;
|
||
if (g.shape === "circle" || g.shape === "arc") return g.center;
|
||
if (g.shape === "text") return g.at;
|
||
return null;
|
||
}
|
||
|
||
/** Strecken eines 2D-Zeichenelements (line/polyline/rect); andere → leer. */
|
||
function drawing2DSegments(d: Drawing2D): [Vec2, Vec2][] {
|
||
const g = d.geom;
|
||
const out: [Vec2, Vec2][] = [];
|
||
if (g.shape === "line") out.push([g.a, g.b]);
|
||
else if (g.shape === "polyline") {
|
||
for (let i = 0; i < g.pts.length - 1; i++) out.push([g.pts[i], g.pts[i + 1]]);
|
||
if (g.closed && g.pts.length > 2) out.push([g.pts[g.pts.length - 1], g.pts[0]]);
|
||
} else if (g.shape === "rect") {
|
||
const c1 = g.min, c3 = g.max;
|
||
const c2 = { x: g.max.x, y: g.min.y }, c4 = { x: g.min.x, y: g.max.y };
|
||
out.push([c1, c2], [c2, c3], [c3, c4], [c4, c1]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Zeichnet ein 2D-Element als flache Linien auf der Geschossebene. Plan (x,y) →
|
||
* Three (x, baseElevation, y); ein winziger Höhen-Offset verhindert Z-Fighting mit
|
||
* dem Boden/Raster. Material je Farbe (+ gedimmt) gecached.
|
||
*/
|
||
function addDrawing2DLines(
|
||
group: THREE.Group,
|
||
project: Project,
|
||
d: Drawing2D,
|
||
baseElevation: number,
|
||
greyed: boolean,
|
||
opts: BuildOpts,
|
||
): void {
|
||
const segs = drawing2DSegments(d);
|
||
if (segs.length === 0) return;
|
||
const color = drawing2DColor(project, d);
|
||
const key = `${color}|${greyed}`;
|
||
let mat = opts.mats.lines.get(key);
|
||
if (!mat) {
|
||
mat = new THREE.LineBasicMaterial({
|
||
color: new THREE.Color(color),
|
||
transparent: greyed,
|
||
opacity: greyed ? 0.35 : 1,
|
||
});
|
||
opts.mats.lines.set(key, mat);
|
||
}
|
||
const y = baseElevation + 0.01; // knapp über der Geschossebene (kein Z-Fighting)
|
||
const positions = new Float32Array(segs.length * 6);
|
||
segs.forEach(([a, b], i) => {
|
||
positions.set([a.x, y, a.y, b.x, y, b.y], i * 6);
|
||
});
|
||
const geo = new THREE.BufferGeometry();
|
||
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||
geo.computeBoundingSphere();
|
||
const seg = new THREE.LineSegments(geo, mat);
|
||
// Dünne Liniengeometrie nicht vorzeitig wegcullen; Tiefentest bleibt aktiv,
|
||
// damit Wände die bodennahen Linien korrekt verdecken (natürliche Verdeckung).
|
||
seg.frustumCulled = false;
|
||
group.add(seg);
|
||
}
|
||
|
||
/**
|
||
* Wand als extrudierte Schicht-Bänder — pro Schicht ein eigenes Prisma, um
|
||
* Türen ausgespart, mit Sturz darüber. Es wird DASSELBE gehrte Band-Polygon
|
||
* wie im Grundriss extrudiert, sodass Ecken auch in 3D sauber stoßen.
|
||
* `baseElevation` hebt das gesamte Geschoss vertikal an.
|
||
*/
|
||
function addWallMeshes(
|
||
group: THREE.Group,
|
||
project: Project,
|
||
wall: Wall,
|
||
doors: Project["doors"],
|
||
wallOpenings: Opening[],
|
||
cuts: WallCuts,
|
||
// baseElevation wird jetzt über wallVerticalExtent (UK/OK-Auflösung) bezogen;
|
||
// der Parameter bleibt für die Aufrufstelle erhalten, wird hier aber nicht
|
||
// mehr direkt genutzt.
|
||
_baseElevation: number,
|
||
greyed: boolean,
|
||
opts: BuildOpts,
|
||
): void {
|
||
const wt = getWallType(project, wall);
|
||
const total = wallTypeThickness(wt);
|
||
const length = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
|
||
|
||
// Vertikale Ausdehnung (absolute Z): UK/OK aus den Wand-Attributen, sonst
|
||
// baseElevation des Geschosses … +height (= heutiges Verhalten). Wir geben
|
||
// die absoluten Z-Werte direkt an addLayerPrism (baseElevation dort = 0).
|
||
const { zBottom, zTop } = wallVerticalExtent(project, wall);
|
||
// Tür-/Sturzhöhen sind relativ zur Wand-UK definiert (Türen sitzen am Boden).
|
||
const wallTopRel = zTop - zBottom;
|
||
|
||
// Öffnungen (Legacy-Türen + neue Öffnungen) mit sill/head RELATIV zur Wand-UK:
|
||
// • Tür — sillRel 0 … headRel = height (Segment darunter entfällt).
|
||
// • Fenster— sillRel = Brüstung … headRel = sillRel + height (Brüstungsmauer
|
||
// UNTER dem Fenster bleibt stehen, Sturz DARÜBER ebenfalls).
|
||
const openings = [
|
||
...doors.map((d) => ({
|
||
from: d.position,
|
||
to: d.position + d.width,
|
||
sillRel: 0,
|
||
headRel: Math.min(d.height, wallTopRel),
|
||
})),
|
||
...wallOpenings
|
||
.map((o) => {
|
||
const iv = openingInterval(wall, o);
|
||
if (!iv) return null;
|
||
const sillRel = Math.max(0, Math.min(o.sillHeight, wallTopRel));
|
||
const headRel = Math.min(sillRel + o.height, wallTopRel);
|
||
return { from: iv.from, to: iv.to, sillRel, headRel };
|
||
})
|
||
.filter((x): x is { from: number; to: number; sillRel: number; headRel: number } => x != null),
|
||
].sort((a, b) => a.from - b.from);
|
||
|
||
// Referenzlinien-Versatz über die Dicke (center=0 → unverändert).
|
||
const refOff = wallReferenceOffset(wall, total);
|
||
|
||
// Schichten von außen (-T/2) nach innen (+T/2) stapeln, um den Referenzversatz
|
||
// verschoben (damit die Achse bei „außen"/„innen" auf der Wandfläche liegt).
|
||
let off = refOff - total / 2;
|
||
for (const layer of wt.layers) {
|
||
// Material je Modus: Hidden-Line nutzt die geteilten flachen weißen Faces,
|
||
// „Weiss" das geteilte matte Clay-Material, „Texturiert" ein PBR-Material je
|
||
// Fläche (s. u., daher hier null), shaded/wireframe das (ggf. gedimmte)
|
||
// Component-Material.
|
||
const textured = opts.renderMode === "textured";
|
||
// Im Texturiert-Modus das (gecachte) PBR-Material des Bauteils (oder null →
|
||
// matte Default-Farbe). Da die Seitenwand-UVs in Welt-Metern liegen und das
|
||
// Material `repeat = 1/sizeM` trägt, gilt EIN Material für alle Flächen.
|
||
const textureMat = textured
|
||
? opts.matRuntime.get(getComponent(project, layer.componentId).material)
|
||
: null;
|
||
// Material je Modus: Hidden-Line die geteilten flachen weißen Faces, „Weiss"
|
||
// das matte Clay-Material, „Texturiert" das PBR-Material (sonst Fallback auf
|
||
// das matte Component-Material — Wände ohne Textur bleiben sichtbar),
|
||
// shaded/wireframe das (ggf. gedimmte) Component-Material.
|
||
const mat =
|
||
opts.renderMode === "hidden"
|
||
? opts.hiddenFace
|
||
: opts.renderMode === "white"
|
||
? opts.whiteFace
|
||
: textured && textureMat
|
||
? textureMat
|
||
: layerMaterial(project, layer.componentId, greyed, opts);
|
||
const offA = off;
|
||
const offB = off + layer.thickness;
|
||
|
||
// Vollhohe Segmente zwischen den Öffnungen. Z-Werte sind ABSOLUT (zBottom…
|
||
// zTop); baseElevation wurde bereits in die Extent-Auflösung eingerechnet,
|
||
// daher hier 0. Tür-/Sturzhöhen sind relativ zur Wand-UK → zBottom + rel.
|
||
let cursor = 0;
|
||
for (const op of openings) {
|
||
if (op.from > cursor)
|
||
addLayerPrism(group, wall, cursor, op.from, zBottom, zTop, offA, offB, cuts, 0, mat, opts);
|
||
// Brüstungsmauer UNTER der Öffnung (Fenster: sillRel > 0; Tür: sillRel 0 → nichts).
|
||
if (op.sillRel > 1e-4)
|
||
addLayerPrism(group, wall, op.from, op.to, zBottom, zBottom + op.sillRel, offA, offB, cuts, 0, mat, opts);
|
||
// Sturz ÜBER der Öffnung (türnahe Enden bleiben rechtwinklig).
|
||
if (op.headRel < wallTopRel)
|
||
addLayerPrism(group, wall, op.from, op.to, zBottom + op.headRel, zTop, offA, offB, cuts, 0, mat, opts);
|
||
cursor = Math.max(cursor, op.to);
|
||
}
|
||
if (cursor < length)
|
||
addLayerPrism(group, wall, cursor, length, zBottom, zTop, offA, offB, cuts, 0, mat, opts);
|
||
|
||
off += layer.thickness;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 3D-Geometrie einer Öffnung: Rahmen (dünne Box um die lichte Öffnung) + Glas
|
||
* (Fenster, halbtransparente Scheibe) bzw. Türblatt (Fenster: entfällt; Tür:
|
||
* flaches Panel in geöffneter Stellung, Winkel aus swingAngle). Alle Meshes
|
||
* tragen `userData.openingId` für die Raycast-Auswahl. Plan (x,y) → Three (x,z=y),
|
||
* Höhe entlang Three-Y. Materialien werden je Aufruf erzeugt (leichtgewichtig).
|
||
*/
|
||
function addOpeningMeshes(
|
||
group: THREE.Group,
|
||
project: Project,
|
||
wall: Wall,
|
||
o: Opening,
|
||
greyed: boolean,
|
||
opts: BuildOpts,
|
||
): void {
|
||
const iv = openingInterval(wall, o);
|
||
if (!iv) return;
|
||
const { u, n } = wallAxisFrame(wall);
|
||
const total = wallTypeThickness(getWallType(project, wall));
|
||
const refOff = wallReferenceOffset(wall, total);
|
||
const { zBottom, zTop } = openingVerticalExtent(project, wall, o);
|
||
const h = zTop - zBottom;
|
||
if (h <= 1e-4) return;
|
||
const p0 = along(wall.start, wall.end, iv.from);
|
||
const width = iv.to - iv.from;
|
||
const frameDepth = Math.min(o.frameThickness ?? total, total);
|
||
const opacity = greyed ? 0.3 : 1;
|
||
|
||
// Ein Balken (Box) entlang (u, n, y): Länge len entlang `dir` in der XZ-Ebene,
|
||
// Querschnitt breadth×heightBox. `centerAlong` = Abstand vom p0 entlang `along`,
|
||
// `offN` = Versatz quer (n), `y0..y1` vertikale Lage. Rotiert um Y auf die
|
||
// Achsrichtung.
|
||
const angle = Math.atan2(u.y, u.x); // Achswinkel in der XZ-Ebene (Plan)
|
||
const addBar = (
|
||
mat: THREE.Material,
|
||
aFrom: number,
|
||
aTo: number,
|
||
offN: number,
|
||
breadthN: number,
|
||
y0: number,
|
||
y1: number,
|
||
) => {
|
||
const lenA = aTo - aFrom;
|
||
const hy = y1 - y0;
|
||
if (lenA <= 1e-5 || hy <= 1e-5 || breadthN <= 1e-5) return;
|
||
const geo = new THREE.BoxGeometry(lenA, hy, breadthN);
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
// Mittelpunkt: entlang der Achse (aFrom+aTo)/2, quer offN, Höhe (y0+y1)/2.
|
||
const midA = (aFrom + aTo) / 2;
|
||
const cx = p0.x + u.x * midA + n.x * offN;
|
||
const cz = p0.y + u.y * midA + n.y * offN;
|
||
mesh.position.set(cx, (y0 + y1) / 2, cz);
|
||
// Box-Länge liegt auf lokaler X → um Y auf die Plan-Achsrichtung drehen.
|
||
// Plan-Y bildet auf Three-Z ab, daher −angle als Drehung um die Three-Y-Achse.
|
||
mesh.rotation.y = -angle;
|
||
mesh.userData.openingId = o.id;
|
||
if (opts.renderMode === "hidden") {
|
||
const edges = new THREE.EdgesGeometry(geo, 1);
|
||
mesh.add(new THREE.LineSegments(edges, opts.edgeMat));
|
||
}
|
||
group.add(mesh);
|
||
};
|
||
|
||
// Rahmen-Material (dunkelgrau) + Glas-Material (bläulich, transparent).
|
||
const frameMat = new THREE.MeshStandardMaterial({
|
||
color: greyed ? 0x9aa0a6 : 0x55606c,
|
||
roughness: 0.55,
|
||
metalness: 0.15,
|
||
envMapIntensity: 0.7,
|
||
transparent: greyed,
|
||
opacity,
|
||
});
|
||
const jambW = 0.05; // 5 cm Rahmenprofil
|
||
// Vertikale Rahmenprofile an beiden Pfosten + oben/unten (nur so breit wie die
|
||
// lichte Öffnung; sie sitzen INNERHALB der Lücke).
|
||
addBar(frameMat, 0, jambW, refOff, frameDepth, zBottom, zTop);
|
||
addBar(frameMat, width - jambW, width, refOff, frameDepth, zBottom, zTop);
|
||
addBar(frameMat, jambW, width - jambW, refOff, frameDepth, zTop - jambW, zTop);
|
||
addBar(frameMat, jambW, width - jambW, refOff, frameDepth, zBottom, zBottom + jambW);
|
||
|
||
if (o.kind === "window") {
|
||
// Glasscheibe in der Wandmitte, zwischen den Rahmenprofilen.
|
||
const glassMat = new THREE.MeshStandardMaterial({
|
||
color: 0x9fc4e8,
|
||
roughness: 0.08,
|
||
metalness: 0,
|
||
envMapIntensity: 1,
|
||
transparent: true,
|
||
opacity: greyed ? 0.18 : 0.35,
|
||
depthWrite: false,
|
||
});
|
||
addBar(
|
||
glassMat,
|
||
jambW,
|
||
width - jambW,
|
||
refOff,
|
||
Math.min(0.02, frameDepth * 0.4),
|
||
zBottom + jambW,
|
||
zTop - jambW,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Tür: flaches Blatt in geöffneter Stellung. Anschlag am Scharnier-Pfosten,
|
||
// gedreht um swingAngle in die Aufschlagrichtung. Das Blatt liegt VERTIKAL vom
|
||
// Boden (zBottom) bis zTop; sein Grundriss-Verlauf folgt der doorSymbol-Achse.
|
||
const sym = doorSymbol(wall, o);
|
||
if (!sym) return;
|
||
const leafMat = new THREE.MeshStandardMaterial({
|
||
color: greyed ? 0xb0b4b8 : 0x8a6f57,
|
||
roughness: 0.75,
|
||
metalness: 0,
|
||
envMapIntensity: 0.6,
|
||
transparent: greyed,
|
||
opacity,
|
||
});
|
||
// Blatt als dünne Box zwischen hinge und openEnd (im Grundriss), vertikal
|
||
// extrudiert. Länge = Türbreite, Dicke ~4 cm.
|
||
const dx = sym.openEnd.x - sym.hinge.x;
|
||
const dz = sym.openEnd.y - sym.hinge.y;
|
||
const leafLen = Math.hypot(dx, dz);
|
||
if (leafLen <= 1e-4) return;
|
||
const leafAngle = Math.atan2(dz, dx);
|
||
const leafGeo = new THREE.BoxGeometry(leafLen, zTop - zBottom, 0.04);
|
||
const leaf = new THREE.Mesh(leafGeo, leafMat);
|
||
leaf.position.set(
|
||
sym.hinge.x + dx / 2,
|
||
(zBottom + zTop) / 2,
|
||
sym.hinge.y + dz / 2,
|
||
);
|
||
leaf.rotation.y = -leafAngle;
|
||
leaf.userData.openingId = o.id;
|
||
if (opts.renderMode === "hidden") {
|
||
const edges = new THREE.EdgesGeometry(leafGeo, 1);
|
||
leaf.add(new THREE.LineSegments(edges, opts.edgeMat));
|
||
}
|
||
group.add(leaf);
|
||
}
|
||
|
||
/**
|
||
* MeshStandardMaterial pro Component-ID gecached (3D-Farbe = component.color).
|
||
* Rauigkeit hoch (mattes Putz-/Wandbild), keine Metallizität — passend für
|
||
* Wände/Decken. Gedimmte Variante: Bauteilfarbe Richtung Grau gemischt +
|
||
* transparent, damit gedimmte Geschosse/Kategorien als „andere" sichtbar,
|
||
* aber zurückgenommen sind.
|
||
*/
|
||
function layerMaterial(
|
||
project: Project,
|
||
componentId: string,
|
||
greyed: boolean,
|
||
opts: BuildOpts,
|
||
): THREE.MeshStandardMaterial {
|
||
const { mats } = opts;
|
||
// Drahtgitter: Component-Material mit wireframe=true. Wird mitgecacht; da der
|
||
// Render-Modus den gesamten Szenen-Neuaufbau auslöst, ist der Cache pro Modus
|
||
// konsistent (die Caches leben innerhalb des Aufbau-Effekts).
|
||
const wireframe = opts.renderMode === "wireframe";
|
||
const cache = greyed ? mats.greyed : mats.normal;
|
||
let mat = cache.get(componentId);
|
||
if (!mat) {
|
||
const color = new THREE.Color(getComponent(project, componentId).color);
|
||
if (greyed) {
|
||
// Zu mittlerem Grau hin mischen (entsättigen) und transparent machen.
|
||
color.lerp(new THREE.Color(0x9aa0a6), 0.7);
|
||
mat = new THREE.MeshStandardMaterial({
|
||
color,
|
||
wireframe,
|
||
roughness: 0.9,
|
||
metalness: 0,
|
||
envMapIntensity: 0.6,
|
||
transparent: true,
|
||
opacity: 0.35,
|
||
depthWrite: false,
|
||
});
|
||
} else {
|
||
mat = new THREE.MeshStandardMaterial({
|
||
color,
|
||
wireframe,
|
||
roughness: 0.9,
|
||
metalness: 0,
|
||
envMapIntensity: 0.6,
|
||
});
|
||
}
|
||
cache.set(componentId, mat);
|
||
}
|
||
return mat;
|
||
}
|
||
|
||
/**
|
||
* Ein Schicht-Prisma: das (ggf. gehrte) Band-Polygon von Achs-Abstand s..e
|
||
* zwischen den Offsets offA..offB wird vertikal von baseY bis topY extrudiert.
|
||
* Es wird exakt dasselbe Polygon wie im Grundriss verwendet.
|
||
* Gehrung greift nur an den echten Wandenden (s==0 bzw. e==length).
|
||
* `baseElevation` verschiebt das Prisma vertikal (Geschoss-Stapelung).
|
||
*/
|
||
function addLayerPrism(
|
||
group: THREE.Group,
|
||
wall: Wall,
|
||
s: number,
|
||
e: number,
|
||
baseY: number,
|
||
topY: number,
|
||
offA: number,
|
||
offB: number,
|
||
cuts: WallCuts,
|
||
baseElevation: number,
|
||
mat: THREE.Material,
|
||
opts: BuildOpts,
|
||
): void {
|
||
if (e - s <= 1e-6) return;
|
||
const length = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
|
||
const p1 = along(wall.start, wall.end, s);
|
||
const p2 = along(wall.start, wall.end, e);
|
||
const startCut = s <= 1e-6 ? cuts.startCut : null;
|
||
const endCut = e >= length - 1e-6 ? cuts.endCut : null;
|
||
|
||
const quad = clippedBand(p1, p2, offA, offB, startCut, endCut);
|
||
|
||
// Plan (x,y) → Three (x, y). Y-Spiegelung, damit die Form im Three-XY-
|
||
// System dieselbe Wicklung behält wie im Grundriss; nach der Extrusion
|
||
// wird das Prisma so rotiert, dass Plan-Y auf die Three-Z-Achse fällt.
|
||
const shape = new THREE.Shape();
|
||
const pt = (v: Vec2, i: number) => (i === 0 ? shape.moveTo(v.x, v.y) : shape.lineTo(v.x, v.y));
|
||
quad.forEach(pt);
|
||
shape.closePath();
|
||
|
||
const depth = topY - baseY;
|
||
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
|
||
|
||
// Texturiert-Modus mit aoMap: die Ambient-Occlusion-Karte liest aus `uv2`.
|
||
// ExtrudeGeometry erzeugt nur `uv`; wir kopieren sie nach `uv2` (gleiche UV-
|
||
// Koordinaten in Welt-Metern → identische Kachelung wie die übrigen Karten).
|
||
const std = mat as THREE.MeshStandardMaterial;
|
||
if (opts.renderMode === "textured" && std.isMeshStandardMaterial && std.aoMap) {
|
||
const uv = geo.getAttribute("uv");
|
||
if (uv && !geo.getAttribute("uv2")) {
|
||
geo.setAttribute(
|
||
"uv2",
|
||
new THREE.BufferAttribute((uv as THREE.BufferAttribute).array, 2),
|
||
);
|
||
}
|
||
}
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
// Wand-ID für die Raycast-Auswahl mitführen (Links-Klick im Viewport).
|
||
mesh.userData.wallId = wall.id;
|
||
// ExtrudeGeometry liegt in der XY-Ebene und wächst entlang +Z. Drehung +90°
|
||
// um X bildet Plan (x,y) → Three (x, z=y) ab (wie zuvor bei den Boxen); die
|
||
// Extrusion zeigt dadurch nach −Y, deshalb sitzt die Form auf topY und
|
||
// wächst hinunter bis baseY. Die baseElevation hebt das gesamte Geschoss an.
|
||
mesh.rotation.x = Math.PI / 2;
|
||
mesh.position.y = topY + baseElevation;
|
||
// Verdeckte Kanten: schwarze EdgesGeometry-Linien als Kind des Mesh (erben
|
||
// dessen Transform). Zusammen mit den (tiefen-getesteten) weißen Faces +
|
||
// polygonOffset ergibt das den Hidden-Line-Look (Kanten dahinter verdeckt).
|
||
if (opts.renderMode === "hidden") {
|
||
const edges = new THREE.EdgesGeometry(geo, 1);
|
||
mesh.add(new THREE.LineSegments(edges, opts.edgeMat));
|
||
}
|
||
group.add(mesh);
|
||
}
|
||
|
||
/**
|
||
* Eine Decke als extrudierter Slab: das geschlossene Umriss-Polygon wird um die
|
||
* Deckendicke extrudiert und an die OK (zTop) der Decke gesetzt (wächst nach
|
||
* unten bis zBottom) — dieselbe Plan→Three-Abbildung und Extrusions-Konvention
|
||
* wie beim Wand-Schicht-Prisma. Das Material folgt dem Render-Modus (analog Wand)
|
||
* und wird aus dem BAUTEIL der ersten Schicht des Aufbau-Typs aufgelöst.
|
||
* `userData.ceilingId` trägt die Auswahl-ID für den Raycast.
|
||
*/
|
||
function addCeilingMesh(
|
||
group: THREE.Group,
|
||
project: Project,
|
||
ceiling: Ceiling,
|
||
greyed: boolean,
|
||
opts: BuildOpts,
|
||
): void {
|
||
const outline = ceiling.outline;
|
||
if (outline.length < 3) return;
|
||
const wt = getCeilingType(project, ceiling);
|
||
if (wt.layers.length === 0) return;
|
||
const componentId = wt.layers[0].componentId;
|
||
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
|
||
const depth = zTop - zBottom;
|
||
if (depth <= 1e-6) return;
|
||
|
||
// Material je Render-Modus (wie addWallMeshes).
|
||
const textured = opts.renderMode === "textured";
|
||
const textureMat = textured
|
||
? opts.matRuntime.get(getComponent(project, componentId).material)
|
||
: null;
|
||
const mat =
|
||
opts.renderMode === "hidden"
|
||
? opts.hiddenFace
|
||
: opts.renderMode === "white"
|
||
? opts.whiteFace
|
||
: textured && textureMat
|
||
? textureMat
|
||
: layerMaterial(project, componentId, greyed, opts);
|
||
|
||
const shape = new THREE.Shape();
|
||
outline.forEach((v, i) => (i === 0 ? shape.moveTo(v.x, v.y) : shape.lineTo(v.x, v.y)));
|
||
shape.closePath();
|
||
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
|
||
|
||
// aoMap-UV2-Kopie wie beim Wand-Prisma (Texturiert-Modus).
|
||
const std = mat as THREE.MeshStandardMaterial;
|
||
if (opts.renderMode === "textured" && std.isMeshStandardMaterial && std.aoMap) {
|
||
const uv = geo.getAttribute("uv");
|
||
if (uv && !geo.getAttribute("uv2")) {
|
||
geo.setAttribute(
|
||
"uv2",
|
||
new THREE.BufferAttribute((uv as THREE.BufferAttribute).array, 2),
|
||
);
|
||
}
|
||
}
|
||
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.userData.ceilingId = ceiling.id;
|
||
// Plan (x,y) → Three (x, z=y); Extrusion zeigt nach −Y, daher an OK setzen.
|
||
mesh.rotation.x = Math.PI / 2;
|
||
mesh.position.y = zTop;
|
||
if (opts.renderMode === "hidden") {
|
||
const edges = new THREE.EdgesGeometry(geo, 1);
|
||
mesh.add(new THREE.LineSegments(edges, opts.edgeMat));
|
||
}
|
||
group.add(mesh);
|
||
}
|
||
|
||
/**
|
||
* Eine Treppe als gestufte Boxen: jeder Tritt wird als Vollblock von der
|
||
* Treppen-UK (zBottom) bis zur Oberkante SEINER Setzstufe (zBottom + topRise)
|
||
* extrudiert — so entsteht das treppenartige Stufenprofil (Tritt + Setzstufe in
|
||
* einem). Das Zwischenpodest (L/Wendel) wird bis zu seiner Höhe extrudiert. Das
|
||
* Material folgt dem Render-Modus; ein neutraler Beton-Grauton dient als Default.
|
||
* `userData.stairId` trägt die Auswahl-ID für den Raycast.
|
||
*/
|
||
function addStairMeshes(
|
||
group: THREE.Group,
|
||
project: Project,
|
||
stair: Stair,
|
||
greyed: boolean,
|
||
opts: BuildOpts,
|
||
): void {
|
||
const { zBottom, zTop } = stairVerticalExtent(project, stair);
|
||
const totalRise = zTop - zBottom;
|
||
if (totalRise <= 1e-6) return;
|
||
const geo = stairGeometry(stair, totalRise);
|
||
|
||
// Material je Render-Modus. Ohne Component-Referenz nutzen wir einen neutralen
|
||
// Beton-Grauton (gedimmt heller/transparent).
|
||
const baseMat =
|
||
opts.renderMode === "hidden"
|
||
? opts.hiddenFace
|
||
: opts.renderMode === "white"
|
||
? opts.whiteFace
|
||
: new THREE.MeshStandardMaterial({
|
||
color: greyed ? 0xb8bcc2 : 0x9aa0a6,
|
||
roughness: 0.85,
|
||
metalness: 0,
|
||
envMapIntensity: 0.6,
|
||
transparent: greyed,
|
||
opacity: greyed ? 0.35 : 1,
|
||
});
|
||
|
||
const extrudeBlock = (pts: Vec2[], top: number, idKey: string) => {
|
||
if (pts.length < 3 || top <= 1e-6) return;
|
||
const shape = new THREE.Shape();
|
||
pts.forEach((v, i) => (i === 0 ? shape.moveTo(v.x, v.y) : shape.lineTo(v.x, v.y)));
|
||
shape.closePath();
|
||
const g = new THREE.ExtrudeGeometry(shape, { depth: top, bevelEnabled: false });
|
||
const mesh = new THREE.Mesh(g, baseMat);
|
||
mesh.userData.stairId = stair.id;
|
||
void idKey;
|
||
// Plan (x,y) → Three (x, z=y); Extrusion nach −Y, daher an die Block-OK setzen.
|
||
mesh.rotation.x = Math.PI / 2;
|
||
mesh.position.y = zBottom + top;
|
||
if (opts.renderMode === "hidden") {
|
||
const edges = new THREE.EdgesGeometry(g, 1);
|
||
mesh.add(new THREE.LineSegments(edges, opts.edgeMat));
|
||
}
|
||
group.add(mesh);
|
||
};
|
||
|
||
for (const tr of geo.treads) extrudeBlock(tr.pts, tr.topRise, `t${tr.index}`);
|
||
// Podest (L/Wendel-Auge): bis zur mittleren Höhe extrudieren, damit es sichtbar
|
||
// als Absatz erscheint (halber Gesamt-Rise als sinnvoller Default).
|
||
if (geo.landing && geo.landing.length >= 3) {
|
||
// Höhe des Podests = Höhe des höchsten Tritts unter/an der Podestposition;
|
||
// wir nehmen die halbe Gesamt-Steighöhe als robusten Näherungswert.
|
||
extrudeBlock(geo.landing, totalRise * 0.5, "landing");
|
||
}
|
||
}
|