();
+ 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.
@@ -408,21 +1207,121 @@ export function Viewport3D({
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 = Auswahl (kein Orbit links).
+ // 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) {
- selectWall(pickWall(e.clientX, e.clientY));
+ if (commandActiveRef.current) {
+ const m = workplaneModel(e.clientX, e.clientY);
+ if (m) onWorkplanePointRef.current?.("pick", m);
+ } else {
+ selectAt(e.clientX, e.clientY);
+ }
}
};
- // RECHTS = Kontextmenü; Browser-Menü unterdrücken, Rechts-Drag-Pan ist aus.
+ // 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;
- const moved = Math.hypot(e.clientX - downX, e.clientY - downY);
// 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) });
@@ -430,13 +1329,34 @@ export function Viewport3D({
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();
@@ -453,6 +1373,7 @@ export function Viewport3D({
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) => {
@@ -490,6 +1411,9 @@ export function Viewport3D({
// 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;
@@ -509,12 +1433,36 @@ export function Viewport3D({
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 (
{unavailable && (
@@ -549,6 +1497,8 @@ interface BuildOpts {
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). */
@@ -817,8 +1767,42 @@ function buildFloor(
// 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, cuts, baseElevation, greyed, opts);
+ 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
@@ -846,6 +1830,49 @@ function drawing2DColor(project: Project, d: Drawing2D): string {
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;
@@ -914,6 +1941,7 @@ function addWallMeshes(
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
@@ -925,9 +1953,6 @@ function addWallMeshes(
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);
- const openings = doors
- .map((d) => ({ from: d.position, to: d.position + d.width, top: d.height }))
- .sort((a, b) => a.from - b.from);
// Vertikale Ausdehnung (absolute Z): UK/OK aus den Wand-Attributen, sonst
// baseElevation des Geschosses … +height (= heutiges Verhalten). Wir geben
@@ -936,6 +1961,28 @@ function addWallMeshes(
// 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);
@@ -944,14 +1991,28 @@ function addWallMeshes(
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, shaded/wireframe das (ggf.
- // gedimmte) Component-Material.
+ // „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
- : layerMaterial(project, layer.componentId, greyed, opts);
+ : textured && textureMat
+ ? textureMat
+ : layerMaterial(project, layer.componentId, greyed, opts);
const offA = off;
const offB = off + layer.thickness;
@@ -962,9 +2023,12 @@ function addWallMeshes(
for (const op of openings) {
if (op.from > cursor)
addLayerPrism(group, wall, cursor, op.from, zBottom, zTop, offA, offB, cuts, 0, mat, opts);
- // Sturz über der Öffnung (türnahe Enden bleiben rechtwinklig).
- if (op.top < wallTopRel)
- addLayerPrism(group, wall, op.from, op.to, zBottom + op.top, 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)
@@ -974,6 +2038,136 @@ function addWallMeshes(
}
}
+/**
+ * 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.MeshLambertMaterial({
+ color: greyed ? 0x9aa0a6 : 0x55606c,
+ 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.MeshLambertMaterial({
+ color: 0x9fc4e8,
+ 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.MeshLambertMaterial({
+ color: greyed ? 0xb0b4b8 : 0x8a6f57,
+ 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);
+}
+
/**
* MeshLambertMaterial pro Component-ID gecached (3D-Farbe = component.color).
* Gedimmte Variante: Bauteilfarbe Richtung Grau gemischt + transparent, damit
@@ -1052,6 +2246,20 @@ function addLayerPrism(
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;
@@ -1070,3 +2278,132 @@ function addLayerPrism(
}
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.MeshLambertMaterial({
+ color: greyed ? 0xb8bcc2 : 0x9aa0a6,
+ 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");
+ }
+}
diff --git a/vite.config.ts b/vite.config.ts
index 39a3e86..f1b0d21 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -21,16 +21,63 @@ const wasmPath = fileURLToPath(
),
);
+// opencascade.js (Welle-C-HLR-Spike) verpackt Emscripten-Glue + `.wasm` in
+// `dist/`, ohne sinnvolle `exports`. Analog zu LibreDWG mappen wir zwei
+// virtuelle Module auf die echten Dateien, damit Vite die (große) WASM als
+// gehashtes Asset ausgibt (Dev mit korrektem MIME, Build als Asset). Der Import
+// im Code ist dynamisch → die WASM bleibt aus dem Haupt-Bundle heraus.
+const occtGluePath = fileURLToPath(
+ new URL(
+ "./node_modules/opencascade.js/dist/opencascade.wasm.js",
+ import.meta.url,
+ ),
+);
+const occtWasmPath = fileURLToPath(
+ new URL(
+ "./node_modules/opencascade.js/dist/opencascade.wasm.wasm",
+ import.meta.url,
+ ),
+);
+
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
+ // Tauri lädt die App über den Vite-Dev-Server; dessen Logs (cargo/tauri)
+ // sollen nicht vom Bildschirm gewischt werden, und Tauri-eigene Env-Vars
+ // müssen im Client sichtbar sein.
+ clearScreen: false,
+ envPrefix: ["VITE_", "TAURI_ENV_"],
resolve: {
alias: {
"virtual:libredwg-glue": gluePath,
"virtual:libredwg-wasm-url": wasmPath + "?url",
+ "virtual:occt-glue": occtGluePath,
+ "virtual:occt-wasm-url": occtWasmPath + "?url",
},
},
+ // Emscripten-Glue nicht vor-bündeln (esbuild kommt mit dem UMD-Wrapper +
+ // Node-Shims nicht klar); der dynamische Import lädt sie zur Laufzeit.
+ optimizeDeps: {
+ exclude: ["opencascade.js"],
+ },
server: {
host: true,
+ // Fester Port für die Tauri-Integration (tauri.conf.json zeigt hierauf).
+ port: 5187,
+ strictPort: true,
+ // ambientCG-Proxy: Die Such-JSON (`/api/...`) und der Download-Starter
+ // (`/get`, ein 302-Redirect) senden KEINE CORS-Header und sind daher aus
+ // dem Browser nicht direkt abrufbar. Der Dev-Proxy reicht `/ambientcg/*`
+ // an `https://ambientcg.com` durch (Redirects werden gefolgt), sodass der
+ // Live-Browse ohne separaten Server funktioniert. Für Prod eine analoge
+ // Proxy-Route bereitstellen und via `VITE_AMBIENTCG_PROXY` konfigurieren.
+ proxy: {
+ "/ambientcg": {
+ target: "https://ambientcg.com",
+ changeOrigin: true,
+ followRedirects: true,
+ rewrite: (path: string) => path.replace(/^\/ambientcg/, ""),
+ },
+ },
},
});