Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)

Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+156 -53
View File
@@ -23,6 +23,7 @@ import {
flattenCategories,
getCeilingType,
getComponent,
getStairType,
getWallType,
stairsOfFloor,
wallTypeThickness,
@@ -49,6 +50,7 @@ import type { Pick3dHit } from "./Wasm3DViewport";
import type { RenderMode, View3d } from "../ui/TopBar";
import type { ToolDraft } from "../tools/types";
import { applyAngleConstraint } from "../tools/snapping";
import { drawingGripVertices, drawingMoveAnchor } from "./drawingGrips";
/**
* Render-Entscheidung je Element (Geschoss bzw. Kategorie) im 3D — gespiegelt
@@ -146,6 +148,14 @@ export function Viewport3D(
onToggleGrid={onToggleGrid}
onPick3d={onPick3d}
highlightLines={highlightLines}
// 3D-Editier-Griffe (s. Wasm3DViewport.tsx): dieselben Props/Callbacks
// wie die three.js-Sicht (renderer-agnostisch).
editWalls={props.editWalls}
editDrawings={props.editDrawings}
onEditVertex={props.onEditVertex}
onEditBody={props.onEditBody}
onEditWallTop={props.onEditWallTop}
onEditEnd={props.onEditEnd}
/>
);
}
@@ -1642,8 +1652,9 @@ function updateOrthoFrustum(
* 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).
* front/back/side/left/top/iso/isoFrontLeft/isoBackRight/isoBackLeft →
* 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).
@@ -1669,21 +1680,40 @@ function applyView3d(
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).
// Blickrichtung je Preset (vom Zentrum zur Kamera, normalisiert). Kardinal-
// Paare sind exakte Gegenrichtungen (back = -front, left = -side); die vier
// Iso-Richtungen sind die oberen Oktanten (untere bleiben im Hochbau
// ungenutzt) — dieselbe Achs-Konvention wie `preset_camera` in render3d/
// src/math.rs (WASM-Pfad), s. View3d in TopBar.tsx.
let dir: THREE.Vector3;
switch (view3d) {
case "front":
dir = new THREE.Vector3(0, 0, 1);
break;
case "back":
dir = new THREE.Vector3(0, 0, -1);
break;
case "side":
dir = new THREE.Vector3(1, 0, 0);
break;
case "left":
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 "isoFrontLeft":
dir = new THREE.Vector3(-1, 1, 1).normalize();
break;
case "isoBackRight":
dir = new THREE.Vector3(1, 1, -1).normalize();
break;
case "isoBackLeft":
dir = new THREE.Vector3(-1, 1, -1).normalize();
break;
case "perspective":
default:
// Heutiger 3/4-Blick (vorne-oben-rechts), etwas flacher als die Isometrie.
@@ -1923,48 +1953,8 @@ 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;
}
// drawingGripVertices/drawingMoveAnchor sind nach `./drawingGrips` ausgelagert
// (gemeinsam mit Wasm3DViewport.tsx genutzt — s. dortiger Modul-Kommentar).
/** Strecken eines 2D-Zeichenelements (line/polyline/rect); andere → leer. */
function drawing2DSegments(d: Drawing2D): [Vec2, Vec2][] {
@@ -2466,6 +2456,12 @@ function addCeilingMesh(
* 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.
*
* Ist ein Treppentyp (`StairType`) aufgelöst und dessen Tragart ungleich
* "massiv", wird die Treppe stattdessen UNTEN OFFEN dargestellt: jeder Tritt
* wird nur als dünne Platte (Dicke `treadThickness`) an seiner Oberkante
* modelliert, optional mit Trittkanten-Überstand (Nase) und/oder geschlossenen
* Setzstufen-Platten — je nach `StairType`-Feldern.
*/
function addStairMeshes(
group: THREE.Group,
@@ -2478,6 +2474,7 @@ function addStairMeshes(
const totalRise = zTop - zBottom;
if (totalRise <= 1e-6) return;
const geo = stairGeometry(stair, totalRise);
const st = getStairType(project, stair);
// Material je Render-Modus. Ohne Component-Referenz nutzen wir einen neutralen
// Beton-Grauton (gedimmt heller/transparent).
@@ -2495,16 +2492,19 @@ function addStairMeshes(
opacity: greyed ? 0.35 : 1,
});
const extrudeBlock = (pts: Vec2[], top: number, idKey: string) => {
if (pts.length < 3 || top <= 1e-6) return;
// Extrudiert ein geschlossenes Grundriss-Polygon zu einer Platte zwischen
// `bottom` und `top` (beide relativ zur Treppen-UK `zBottom`). Analog zum
// Vollblock, aber mit frei wählbarer Unterkante statt immer 0.
const extrudeSlab = (pts: Vec2[], bottom: number, top: number) => {
const depth = top - bottom;
if (pts.length < 3 || depth <= 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 g = new THREE.ExtrudeGeometry(shape, { depth, 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.
// Plan (x,y) → Three (x, z=y); Extrusion nach Y, daher an die Platten-OK setzen.
mesh.rotation.x = Math.PI / 2;
mesh.position.y = zBottom + top;
if (opts.renderMode === "hidden") {
@@ -2514,12 +2514,115 @@ function addStairMeshes(
group.add(mesh);
};
for (const tr of geo.treads) extrudeBlock(tr.pts, tr.topRise, `t${tr.index}`);
// Vollblock (Default/"massiv"): von der Treppen-UK bis zur Tritt-Oberkante.
const extrudeBlock = (pts: Vec2[], top: number) => extrudeSlab(pts, 0, top);
const centroidOf = (pts: Vec2[]): Vec2 => {
let x = 0, y = 0;
for (const p of pts) { x += p.x; y += p.y; }
return { x: x / pts.length, y: y / pts.length };
};
// Bestimmt die "vordere" Kante eines Tritt-Vierecks (die Kante in Richtung
// des nächsten Tritts). `target` ist der Schwerpunkt eines Nachbartritts;
// `preferNear` wählt die dem Ziel NÄCHSTE Kante (Nachbar = nächster Tritt),
// sonst die FERNSTE (Nachbar = vorheriger Tritt, Vorderkante liegt entgegengesetzt).
// Rückgabe: Indizes der beiden Eckpunkte der Vorderkante + deren Richtung
// (vom Trittschwerpunkt zur Kantenmitte, normiert).
const frontEdgeOf = (
pts: Vec2[],
target: Vec2,
preferNear: boolean,
): { a: number; b: number; dir: Vec2 } => {
const c = centroidOf(pts);
let best = 0;
let bestDist = preferNear ? Infinity : -Infinity;
for (let e = 0; e < pts.length; e++) {
const p0 = pts[e];
const p1 = pts[(e + 1) % pts.length];
const mid = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
const d = Math.hypot(mid.x - target.x, mid.y - target.y);
if (preferNear ? d < bestDist : d > bestDist) {
bestDist = d;
best = e;
}
}
const p0 = pts[best];
const p1 = pts[(best + 1) % pts.length];
const mid = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
const dx = mid.x - c.x;
const dy = mid.y - c.y;
const len = Math.hypot(dx, dy) || 1e-9;
return { a: best, b: (best + 1) % pts.length, dir: { x: dx / len, y: dy / len } };
};
if (!st || st.structure === "massiv") {
// Default/rückwärtskompatibel: durchgehend massive Vollblöcke.
for (const tr of geo.treads) extrudeBlock(tr.pts, tr.topRise);
} else {
// Offene Treppe (Wangen-, aufgesattelte oder Spindeltreppe): dünne
// Tritt-Platten statt Vollblöcken, darunter bleibt die Treppe offen.
const treads = geo.treads;
for (let k = 0; k < treads.length; k++) {
const tr = treads[k];
let pts = tr.pts;
// Nase (Nosing): die Vorderkante des Tritts (Richtung nächster Tritt)
// um den Überstand nach vorne verlängern. Die Vorderkante wird über den
// Nachbartritt (Schwerpunkt) bestimmt — für den letzten Tritt (kein
// Nachfolger) wird stattdessen die dem Vorgänger abgewandte Kante genutzt.
const nosing = st.nosing ?? 0;
if (nosing > 1e-6) {
const hasNext = k + 1 < treads.length;
const hasPrev = k > 0;
const ref = hasNext ? treads[k + 1] : hasPrev ? treads[k - 1] : null;
if (ref) {
const { a, b, dir } = frontEdgeOf(pts, centroidOf(ref.pts), hasNext);
const nosed = pts.slice();
nosed[a] = { x: pts[a].x + dir.x * nosing, y: pts[a].y + dir.y * nosing };
nosed[b] = { x: pts[b].x + dir.x * nosing, y: pts[b].y + dir.y * nosing };
pts = nosed;
}
}
// Tritt-Platte: dünne Scheibe, Oberkante an der Setzstufen-Oberkante.
extrudeSlab(pts, tr.topRise - st.treadThickness, tr.topRise);
// Geschlossene Setzstufe: vertikale Platte an der RÜCKKante dieses Tritts
// (= Vorderkante des Vorgängers), von dessen UK (`baseRise`) bis zu
// dieser Oberkante (`topRise`). Offene Treppe (closedRisers=false) lässt
// diese Fläche bewusst weg.
if (st.closedRisers) {
const hasNext = k + 1 < treads.length;
const ref = hasNext ? treads[k + 1] : k > 0 ? treads[k - 1] : null;
if (ref) {
// Rückkante = Gegenkante der Vorderkante desselben Tritts.
const front = frontEdgeOf(tr.pts, centroidOf(ref.pts), hasNext);
const n = tr.pts.length;
const backA = (front.a + Math.floor(n / 2)) % n;
const backB = (front.b + Math.floor(n / 2)) % n;
const riserPts = [tr.pts[backA], tr.pts[backB]];
// Dünne Platte: Rückkante zu einer flachen Wange in Setzrichtung
// aufgeweitet (minimaler Versatz nach vorn), damit ein extrudierbares
// Polygon entsteht.
const thin = Math.min(st.treadThickness, 0.02) || 0.01;
const offA = { x: tr.pts[backA].x - front.dir.x * thin, y: tr.pts[backA].y - front.dir.y * thin };
const offB = { x: tr.pts[backB].x - front.dir.x * thin, y: tr.pts[backB].y - front.dir.y * thin };
const riserPoly = [riserPts[0], riserPts[1], offB, offA];
extrudeSlab(riserPoly, tr.baseRise, tr.topRise);
}
}
// TODO Geländer (railing) noch nicht umgesetzt.
}
}
// Podest (L/Wendel-Auge): bis zur mittleren Höhe extrudieren, damit es sichtbar
// als Absatz erscheint (halber Gesamt-Rise als sinnvoller Default).
// als Absatz erscheint (halber Gesamt-Rise als sinnvoller Default). Bleibt
// unabhängig von der Tragart immer als Vollblock stehen.
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");
extrudeBlock(geo.landing, totalRise * 0.5);
}
}
+328 -28
View File
@@ -43,21 +43,90 @@
// Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen.
import { useEffect, useRef, useState } from "react";
import type { Project } from "../model/types";
import { projectToModel3d, pickGeometry } from "../plan/toWalls3d";
import type { Project, Wall, Drawing2D, Vec2 } from "../model/types";
import { wallVerticalExtent } from "../model/wall";
import { projectToModel3d, pickGeometry, TRUCK_MESH_READY_EVENT } from "../plan/toWalls3d";
import type { RenderMode, View3d } from "../ui/TopBar";
import { t } from "../i18n";
import { useWasm3dRenderer } from "./useWasm3dRenderer";
import type { Camera3d } from "./useWasm3dRenderer";
import { cameraRay, pickNearest } from "./raycast3d";
import { cameraRay, pickNearest, rayPlaneY, rayPlane, worldToScreen } from "./raycast3d";
import type { Vec3 } from "./raycast3d";
import { drawingGripVertices, drawingMoveAnchor } from "./drawingGrips";
/** Ein per 3D-Klick getroffenes Bauteil (Wand oder Decke), oder null (Leerraum). */
export type Pick3dHit = { kind: "wall" | "ceiling"; id: string } | null;
export type Pick3dHit =
| { kind: "wall" | "ceiling" | "opening" | "stair"; id: string }
| null;
/** Klick-vs-Drag-Schwelle (Pixel): bleibt die Maus zwischen pointerdown und
* pointerup darunter, gilt die Links-Geste als KLICK (Pick) statt als Orbit-Drag. */
const CLICK_THRESHOLD_PX = 4;
// ── 3D-Editier-Griffe ─────────────────────────────────────────────────────
// Volle Parität mit den three.js-Griffen (`Viewport3D.tsx`/`drawGrips`):
// Wand-Endpunkte + Höhen-Griff (OK der Wand) + Verschiebe-Griff (ganze Wand),
// dieselben drei Griff-Arten für gewählte 2D-Zeichnungen (Vertex + Verschieben).
// Bewusst NICHT nachgebaut: Snap/Koinzidenz-Mitführen beim Ziehen (mehrere
// gewählte Elemente mit gemeinsamer Ecke) — das bleibt dem three.js-Pfad
// vorbehalten, bis sich das dort bewährte Muster als eigener Bedarf zeigt.
//
// DARSTELLUNG: ECHTE DOM-Buttons (kleine Kreise, `position:absolute` über dem
// Canvas), nicht In-Szene-Geometrie. Ein erster Versuch zeichnete die Griffe
// als Drahtgitter-Kugeln über denselben `setHighlightLines`-Kanal wie den
// Auswahl-Umriss — Nutzer-Feedback 2026-07-06: "immernoch kein GUI button
// sondern geometrie" (zurecht: dünne Linien-Kugeln lesen sich nicht als
// klickbarer Punkt, ausserdem teilten sie sich die Farbe mit dem Umriss und
// sassen exakt auf dessen Ecken). Echte DOM-Buttons sind trivial zu treffen
// (native Pointer-Events statt Ray-Toleranz-Mathe) und sehen wie ein
// GUI-Element aus. Kosten: die Bildschirm-Position muss der Kamera folgen —
// `useEffect` unten pollt sie per `requestAnimationFrame`, solange Griffe
// aktiv sind (billige 2D-Projektion, kein WASM/GPU-Aufruf).
type GripKind = "vertex" | "height" | "move";
interface Grip {
kind: GripKind;
/** Element, dessen Griff das ist (Wand-ID ODER Drawing-ID). */
target: { wallId?: string; drawingId?: string };
/** Vertex-Index (nur `kind==="vertex"`); -1 sonst. */
index: number;
pos: Vec3;
}
/**
* Alle Griffe der gewählten Wände + 2D-Zeichnungen: pro Wand zwei Endpunkte
* (UK), ein Höhen-Griff (Achsmitte, OK) und ein Verschiebe-Griff (Achsmitte,
* knapp über UK) — Positionen exakt wie `drawGrips` in `Viewport3D.tsx`. Pro
* Zeichnung ein Vertex-Griff je Eckpunkt + ein Verschiebe-Griff im Schwerpunkt,
* auf der Ebene des aktiven Geschosses (`drawingElevation`).
*/
function computeGrips(
project: Project,
walls: readonly Wall[],
drawings: readonly Drawing2D[],
drawingElevation: number,
): Grip[] {
const out: Grip[] = [];
for (const w of walls) {
const { zBottom, zTop } = wallVerticalExtent(project, w);
const target = { wallId: w.id };
out.push({ kind: "vertex", target, index: 0, pos: [w.start.x, zBottom, w.start.y] });
out.push({ kind: "vertex", target, index: 1, pos: [w.end.x, zBottom, w.end.y] });
const midX = (w.start.x + w.end.x) / 2;
const midY = (w.start.y + w.end.y) / 2;
out.push({ kind: "height", target, index: -1, pos: [midX, zTop, midY] });
out.push({ kind: "move", target, index: -1, pos: [midX, zBottom + 0.05, midY] });
}
for (const d of drawings) {
const verts = drawingGripVertices(d);
const target = { drawingId: d.id };
const y = drawingElevation + 0.02;
verts.forEach((v, i) => out.push({ kind: "vertex", target, index: i, pos: [v.x, y, v.y] }));
const anchor = drawingMoveAnchor(d, verts);
if (anchor) out.push({ kind: "move", target, index: -1, pos: [anchor.x, y + 0.03, anchor.y] });
}
return out;
}
/** Vertikaler Öffnungswinkel (Radiant) — wie die three.js-PerspectiveCamera (50°),
* auch für die FREIE Orbit-Kamera hier (die Presets selbst nutzen den Rust-
* seitigen Default-FOV, s. `types::default_fov`, denselben Wert). */
@@ -175,9 +244,14 @@ function fitTargetDist(project: Project): {
const ISO_PITCH = Math.asin(1 / Math.sqrt(3));
const VIEW3D_ORBIT: Record<View3d, { yaw: number; pitch: number }> = {
front: { yaw: Math.PI / 2, pitch: 0 },
back: { yaw: -Math.PI / 2, pitch: 0 },
side: { yaw: 0, pitch: 0 },
left: { yaw: Math.PI, pitch: 0 },
top: { yaw: Math.PI / 2, pitch: PITCH_LIMIT },
iso: { yaw: Math.PI / 4, pitch: ISO_PITCH },
isoFrontLeft: { yaw: (3 * Math.PI) / 4, pitch: ISO_PITCH },
isoBackRight: { yaw: -Math.PI / 4, pitch: ISO_PITCH },
isoBackLeft: { yaw: (-3 * Math.PI) / 4, pitch: ISO_PITCH },
perspective: { yaw: Math.PI / 4, pitch: 0.5 },
};
@@ -190,6 +264,12 @@ export function Wasm3DViewport({
onToggleGrid,
onPick3d,
highlightLines = null,
editWalls = [],
editDrawings = [],
onEditVertex,
onEditBody,
onEditWallTop,
onEditEnd,
}: {
project: Project;
/** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */
@@ -220,6 +300,22 @@ export function Wasm3DViewport({
* `null`/leeres Array = kein Highlight (leere Auswahl).
*/
highlightLines?: Float32Array | null;
/** Gewählte Wände — Quelle der 3D-Endpunkt-/Höhen-/Verschiebe-Griffe. */
editWalls?: Wall[];
/** Gewählte 2D-Zeichnungen — Quelle der 3D-Vertex-/Verschiebe-Griffe. */
editDrawings?: Drawing2D[];
/** Endpunkt-Griff gezogen: neuer XY-Modellpunkt für den Vertex `index` des Elements. */
onEditVertex?: (
target: { wallId?: string; drawingId?: string },
index: number,
model: Vec2,
) => Vec2;
/** Verschiebe-Griff gezogen: das 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. */
onEditEnd?: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte)
@@ -227,6 +323,23 @@ export function Wasm3DViewport({
// die frische Store-Auswahl), ohne den Listener-Effekt neu aufzubauen.
const onPick3dRef = useRef(onPick3d);
onPick3dRef.current = onPick3d;
// Griff-Auswahl/Callbacks ebenfalls als Refs (gleiches Muster) — der Pointer-
// Listener-Effekt unten bleibt stabil ([ready, render]) und liest trotzdem
// stets die aktuelle Auswahl/den aktuellen Callback.
const editWallsRef = useRef(editWalls);
editWallsRef.current = editWalls;
const editDrawingsRef = useRef(editDrawings);
editDrawingsRef.current = editDrawings;
const onEditVertexRef = useRef(onEditVertex);
onEditVertexRef.current = onEditVertex;
const onEditBodyRef = useRef(onEditBody);
onEditBodyRef.current = onEditBody;
const onEditWallTopRef = useRef(onEditWallTop);
onEditWallTopRef.current = onEditWallTop;
const onEditEndRef = useRef(onEditEnd);
onEditEndRef.current = onEditEnd;
const groundElevationRef = useRef(groundElevation);
groundElevationRef.current = groundElevation;
const {
render,
updateModel,
@@ -263,6 +376,20 @@ export function Wasm3DViewport({
redrawRef.current();
}, [ready, project, updateModel]);
// truck-Extrusionen (s. toWalls3d.ts emitExtrudedSolids): die WASM-Extrusion
// läuft asynchron und ist beim ersten `updateModel` oben typischerweise noch
// nicht fertig — ohne diesen Re-Push bliebe sie trotz erfolgreichen Ladens
// unsichtbar, weil `project` sich dafür nicht ändert.
useEffect(() => {
if (!ready) return;
const onMeshReady = () => {
updateModel(projectRef.current);
redrawRef.current();
};
window.addEventListener(TRUCK_MESH_READY_EVENT, onMeshReady);
return () => window.removeEventListener(TRUCK_MESH_READY_EVENT, onMeshReady);
}, [ready, updateModel]);
// Blickwinkel-Praeset anwenden, wenn `view3d` wechselt (oder der Renderer
// bereit wird): springt über `set_view_preset` (Rust) auf die formatfüllende,
// ggf. orthografische Praeset-Kamera — „hinspringen, kein Lock", der
@@ -316,8 +443,19 @@ export function Wasm3DViewport({
// Schnittflaechen-Schraffur (Caps) aus den gecachten Waenden/Decken.
useEffect(() => {
if (!ready) return;
// Bei inaktivem Schnitt braucht es kein Ziel (Ebene ist ohnehin unsichtbar)
// — `fitTargetDist` NICHT aufrufen, das würde sonst bei JEDER Projekt-
// Änderung (auch waehrend eines Griff-Drags) unnoetig die komplette
// Wand-/Oeffnungs-/Decken-Geometrie erneut flatten, nur um eine Bounding-
// Box zu ziehen, die `updateModel` fuer denselben `project`-Stand gerade
// erst berechnet hat.
if (!sectionActive) {
setSectionPlane(false, [0, 0, 0], [0, 0, 1]);
redrawRef.current();
return;
}
const { target } = fitTargetDist(project);
setSectionPlane(sectionActive, target, [0, 0, 1]);
setSectionPlane(true, target, [0, 0, 1]);
redrawRef.current();
}, [ready, sectionActive, project, setSectionPlane]);
@@ -332,6 +470,45 @@ export function Wasm3DViewport({
redrawRef.current();
}, [ready, highlightLines, setHighlightLines]);
// Griff-Bildschirmpositionen: solange Wände/2D-Zeichnungen zum Editieren
// gewählt sind, pollt ein rAF-Loop ihre Griffpunkte durch die aktuelle
// Kamera auf Bildschirm-Pixel — die DOM-Buttons unten im JSX folgen so
// Orbit/Pan/Zoom UND Modelländerungen (Drag), ohne dass Kamerabewegungen
// einen WASM-Push auslösen (reine 2D-Projektionsmathe, kein GPU-Aufruf).
const [gripScreens, setGripScreens] = useState<{ grip: Grip; x: number; y: number }[]>([]);
useEffect(() => {
if (!ready || (editWalls.length === 0 && editDrawings.length === 0)) {
setGripScreens([]);
return;
}
let raf = 0;
const tick = () => {
const canvas = canvasRef.current;
const o = orbitRef.current;
if (canvas && o) {
const rect = canvas.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const cam = orbitCamera(o);
const grips = computeGrips(
projectRef.current,
editWallsRef.current,
editDrawingsRef.current,
groundElevationRef.current,
);
const next: { grip: Grip; x: number; y: number }[] = [];
for (const g of grips) {
const p = worldToScreen(cam, g.pos, rect.width, rect.height);
if (p) next.push({ grip: g, x: p.x, y: p.y });
}
setGripScreens(next);
}
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [ready, editWalls, editDrawings]);
// Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu
// zeichnen (die Surface-Anpassung übernimmt der Hook im render()).
useEffect(() => {
@@ -342,6 +519,88 @@ export function Wasm3DViewport({
return () => ro.disconnect();
}, [ready]);
// ── Griff-Drag (DOM-Buttons, s. Datei-Kommentar oben) ─────────────────────
// Der Drag-Zustand lebt in einem Ref (kein State) — die Pointer-Handler an
// den Buttons sind normale React-Props, aber der Ref überlebt zwischen
// Pointer-Move-Events, ohne bei jedem Frame einen Re-Render auszulösen.
// "vertex"/"move" ziehen auf einer horizontalen Arbeitsebene (Wand-UK bzw.
// Geschossebene für Zeichnungen) — exakt wie `workplaneModel` im three.js-
// Pfad; "height" zieht auf einer VERTIKALEN Ebene entlang der Wandachse
// (analog `heightZAt` dort), damit eine überwiegend vertikale Mausbewegung
// auf eine Höhenänderung abbildet.
type GripDrag =
| { kind: "vertex"; target: { wallId?: string; drawingId?: string }; index: number; planeY: number }
| { kind: "move"; target: { wallId?: string; drawingId?: string }; planeY: number; lastModel: Vec2 | null }
| { kind: "height"; wallId: string };
const gripDragRef = useRef<GripDrag | null>(null);
const startGripDrag = (grip: Grip) => (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
if (grip.kind === "height") {
if (!grip.target.wallId) return;
gripDragRef.current = { kind: "height", wallId: grip.target.wallId };
} else {
let planeY: number;
if (grip.target.wallId) {
const wall = editWallsRef.current.find((w) => w.id === grip.target.wallId);
if (!wall) return;
planeY = wallVerticalExtent(projectRef.current, wall).zBottom;
} else {
planeY = groundElevationRef.current;
}
gripDragRef.current =
grip.kind === "vertex"
? { kind: "vertex", target: grip.target, index: grip.index, planeY }
: { kind: "move", target: grip.target, planeY, lastModel: null };
}
(e.target as Element).setPointerCapture(e.pointerId);
};
const onGripPointerMove = (e: React.PointerEvent) => {
const drag = gripDragRef.current;
const o = orbitRef.current;
const canvas = canvasRef.current;
if (!drag || !o || !canvas) return;
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = 1 - ((e.clientY - rect.top) / rect.height) * 2;
const ray = cameraRay(orbitCamera(o), ndcX, ndcY, rect.width / rect.height);
if (drag.kind === "height") {
const wall = editWallsRef.current.find((w) => w.id === drag.wallId);
if (!wall) return;
const ux = wall.end.x - wall.start.x;
const uy = wall.end.y - wall.start.y;
const len = Math.hypot(ux, uy) || 1;
const midX = (wall.start.x + wall.end.x) / 2;
const midY = (wall.start.y + wall.end.y) / 2;
const hit = rayPlane(ray, [midX, 0, midY], [ux / len, 0, uy / len]);
if (!hit) return;
onEditWallTopRef.current?.(drag.wallId, hit[1]);
return;
}
const hit = rayPlaneY(ray, drag.planeY);
if (!hit) return;
const model: Vec2 = { x: hit[0], y: hit[2] };
if (drag.kind === "vertex") {
onEditVertexRef.current?.(drag.target, drag.index, model);
return;
}
// "move": inkrementelles Delta wie im three.js-Pendant (workplaneModel + lastModel).
if (drag.lastModel) {
const delta: Vec2 = { x: model.x - drag.lastModel.x, y: model.y - drag.lastModel.y };
if (delta.x !== 0 || delta.y !== 0) onEditBodyRef.current?.(drag.target, delta);
}
drag.lastModel = model;
};
const endGripDrag = () => {
if (!gripDragRef.current) return;
gripDragRef.current = null;
onEditEndRef.current?.();
};
// Maus-Navigation (LINKS = Orbit, MITTE/RECHTS = Pan, RAD = Zoom, s.
// Datei-Kommentar MAUS-SCHEMA). Verlässt damit IMMER die Praeset-Kamera und
// wechselt auf die freie perspektivische Orbit-Kamera (Kontinuität über den
@@ -358,40 +617,47 @@ export function Wasm3DViewport({
};
};
let drag:
| {
mode: "orbit" | "pan";
button: number;
x: number;
y: number;
downX: number;
downY: number;
moved: boolean;
}
| null = null;
let drag: {
mode: "orbit" | "pan";
button: number;
x: number;
y: number;
downX: number;
downY: number;
moved: boolean;
} | null = null;
const redraw = () => redrawRef.current();
// Cursor-Pixel (Client) → NDC, relativ zum Canvas-Rechteck. `null`, wenn
// das Canvas gerade keine Ausdehnung hat (z. B. verstecktes Layout).
const toNdc = (clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
return {
ndcX: ((clientX - rect.left) / rect.width) * 2 - 1,
ndcY: 1 - ((clientY - rect.top) / rect.height) * 2, // Pixel-Y invertiert
aspect: rect.width / rect.height,
};
};
// Cursor-Pixel → Kamerastrahl (aktuelle Orbit-Kamera) → nächstes Bauteil.
// Meldet den Treffer (oder null) an den Aufrufer; `additive` wird vom
// pointerup-Modifier bestimmt. Frische Pick-Geometrie je Klick (selten genug).
const doPick = (clientX: number, clientY: number, additive: boolean) => {
const cb = onPick3dRef.current;
const o = orbitRef.current;
if (!cb || !o) return;
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = 1 - ((clientY - rect.top) / rect.height) * 2; // Pixel-Y invertiert
const aspect = rect.width / rect.height;
const ray = cameraRay(orbitCamera(o), ndcX, ndcY, aspect);
const { walls, slabs } = pickGeometry(projectRef.current);
const hit = pickNearest(ray, walls, slabs);
const ndc = toNdc(clientX, clientY);
if (!cb || !o || !ndc) return;
const ray = cameraRay(orbitCamera(o), ndc.ndcX, ndc.ndcY, ndc.aspect);
const { walls, slabs, openings, stairs } = pickGeometry(projectRef.current);
// Erweiterter Pick inkl. Öffnungen (Fenster/Türen) + Treppen — sonst wären
// im WASM-Viewport nur Wände/Decken anwählbar.
const hit = pickNearest(ray, walls, slabs, openings, stairs);
cb(hit ? { kind: hit.kind, id: hit.id } : null, additive);
};
const onPointerDown = (e: PointerEvent) => {
// LINKS (0) = Orbit, MITTE (1)/RECHTS (2) = Pan (s. MAUS-SCHEMA oben).
const mode = e.button === 0 ? "orbit" : e.button === 1 || e.button === 2 ? "pan" : null;
if (!mode) return;
e.preventDefault();
@@ -565,14 +831,48 @@ export function Wasm3DViewport({
Darstellungsart kommen von der Oberleiste (TopBar); das Grid-Overlay
unten ist der einzige eigene Umschalter hier. */}
<canvas ref={canvasRef} data-engine="render3d" />
{/* 3D-Editier-Griffe: echte DOM-Buttons statt In-Szene-Geometrie, s.
Datei-Kommentar oben. Position folgt der Kamera via `gripScreens`
(rAF-Loop). Farben wie die three.js-Griffe (`Viewport3D.tsx`,
gripVertMat/gripHeightMat/gripMoveMat): Vertex orange, Höhe blau,
Verschieben grün. */}
{gripScreens.map(({ grip, x, y }) => (
<div
key={`${grip.kind}:${grip.target.wallId ?? grip.target.drawingId}:${grip.index}`}
onPointerDown={startGripDrag(grip)}
onPointerMove={onGripPointerMove}
onPointerUp={endGripDrag}
onPointerCancel={endGripDrag}
title={t(
grip.kind === "height"
? "viewport3d.grip.height"
: grip.kind === "move"
? "viewport3d.grip.move"
: "viewport3d.grip.vertex",
)}
style={{
position: "absolute",
left: x - 8,
top: y - 8,
width: 16,
height: 16,
borderRadius: "50%",
background: grip.kind === "height" ? "#2f8fff" : grip.kind === "move" ? "#18b85a" : "#ff8c1a",
border: "2px solid rgba(0,0,0,0.6)",
boxShadow: "0 0 5px rgba(0,0,0,0.55)",
cursor: grip.kind === "height" ? "ns-resize" : "grab",
touchAction: "none",
}}
/>
))}
{/* Live-Schnitt-Umschalter (eigener Overlay-Button, MVP): vertikale
Schnittebene durch die Modellmitte, Blick +Y. Sitzt ueber dem
Grid-Button (falls vorhanden). Reine Ansichts-Option des Viewports. */}
<button
type="button"
aria-label="Schnittebene"
aria-label={t("viewport3d.section.label")}
onClick={() => setSectionActive((v) => !v)}
title={sectionActive ? "Schnittebene aus" : "Schnittebene ein"}
title={t(sectionActive ? "viewport3d.section.hide" : "viewport3d.section.show")}
aria-pressed={sectionActive}
style={{
position: "absolute",
+50
View File
@@ -0,0 +1,50 @@
// Griff-Geometrie eines 2D-Elements — gemeinsam genutzt von BEIDEN 3D-Sichten
// (ThreeViewport3D UND Wasm3DViewport), deshalb ein eigenes, neutrales Modul
// statt einer Definition in einer der beiden Viewport-Dateien (die sich sonst
// gegenseitig importieren müssten — Viewport3D.tsx rendert Wasm3DViewport,
// ein Re-Import in die Gegenrichtung wäre ein zyklischer Import).
import type { Drawing2D, Vec2 } from "../model/types";
/**
* 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.
*/
export 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.
*/
export 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;
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Dekodiert die Material-Farbkarten eines Projekts für den nativen render3d-
* Renderer (Stil „Textured"). Der native Renderer bekommt KEINE Bild-Dateien —
* er kennt weder Datei-Pfade noch einen PNG/JPG-Decoder. Deshalb dekodiert diese
* Datei die eindeutigen Material-Farb-Maps IM BROWSER (Image/ImageBitmap →
* Canvas → `getImageData`) zu rohen RGBA8-Pixeln und packt sie zu EINEM Puffer
* (je Textur eine 256×256-Ebene), den {@link WebModelRenderer.set_material_textures}
* als Textur-Array hochlädt.
*
* REIHENFOLGE-VERTRAG: Die Ebenen liegen exakt in der Reihenfolge von
* {@link wallMaterialColorMaps} — dieselbe Funktion, aus der `toWalls3d` den
* `materialIndex` je Wand-Band ableitet (1-basiert; Ebene 0 = Fallback-
* Schachbrett). So zeigt Band-Index i+1 auf die (i)-te dekodierte Ebene, ohne
* dass eine explizite Zuordnungstabelle zwischen JS und Rust wandern muss.
*/
import type { Project } from "../model/types";
import { wallMaterialColorMaps } from "../plan/toWalls3d";
/** Kantenlänge einer Textur-Ebene in Texeln (muss zu render3d `TEXTURE_SIZE`). */
const TEX_SIZE = 256;
/** Ergebnis von {@link decodeMaterialTextures}: gepackte RGBA8-Ebenen + Anzahl. */
export interface DecodedMaterialTextures {
/** `count * TEX_SIZE * TEX_SIZE * 4` Bytes: je Material eine 256×256-RGBA-Ebene. */
data: Uint8Array;
/** Anzahl Material-Ebenen (== `wallMaterialColorMaps(project).length`). */
count: number;
/** Signatur (die URL-Liste), um unveränderte Material-Mengen nicht neu zu dekodieren. */
signature: string;
}
/**
* Dekodiert die eindeutigen Material-Farbkarten von `project` zu gepackten
* RGBA8-Ebenen. `null`, wenn das Projekt keine Material-Farbkarten benutzt oder
* kein Browser-Kontext vorliegt (SSR/Test) — der Aufrufer lädt dann nichts hoch
* (render3d bleibt beim Schachbrett). Fehlgeschlagene Einzelbilder bleiben als
* schwarze Ebene erhalten (kein Abbruch der ganzen Menge).
*/
export async function decodeMaterialTextures(
project: Project,
): Promise<DecodedMaterialTextures | null> {
if (typeof document === "undefined") return null;
const urls = wallMaterialColorMaps(project);
if (urls.length === 0) return null;
const canvas = document.createElement("canvas");
canvas.width = TEX_SIZE;
canvas.height = TEX_SIZE;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
const bytesPerLayer = TEX_SIZE * TEX_SIZE * 4;
const data = new Uint8Array(urls.length * bytesPerLayer);
for (let i = 0; i < urls.length; i++) {
try {
const img = await loadImage(urls[i]);
ctx.clearRect(0, 0, TEX_SIZE, TEX_SIZE);
// Auf 256×256 skalieren (die Ebenen eines Arrays müssen gleich groß sein).
ctx.drawImage(img, 0, 0, TEX_SIZE, TEX_SIZE);
const pixels = ctx.getImageData(0, 0, TEX_SIZE, TEX_SIZE);
data.set(pixels.data, i * bytesPerLayer);
} catch {
// Fehlgeschlagenes Bild: Ebene bleibt schwarz (0) — render3d klemmt bei
// Bedarf ohnehin auf gültige Ebenen; die übrigen Materialien stimmen.
}
}
return { data, count: urls.length, signature: urls.join("") };
}
/**
* Lädt eine Bild-URL (Data-URL, Object-URL oder Asset-Pfad) zu einem
* zeichenbaren Bild. Nutzt `createImageBitmap` (schnell, entkoppelt vom
* DOM-Layout), fällt auf ein `HTMLImageElement` zurück, wo es fehlt.
*/
async function loadImage(url: string): Promise<CanvasImageSource> {
if (typeof createImageBitmap === "function" && typeof fetch === "function") {
try {
const res = await fetch(url);
const blob = await res.blob();
return await createImageBitmap(blob);
} catch {
// Fallback unten (z. B. wenn fetch die URL nicht mag).
}
}
return await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Bild laden fehlgeschlagen: ${url}`));
img.src = url;
});
}
+82
View File
@@ -7,6 +7,8 @@ import {
type RayCamera,
type PickWall,
type PickSlab,
type PickOpening,
type PickStair,
} from "./raycast3d";
// Kamera bei (0,1,10), Blick entlang -Z auf (0,1,0). Zentraler Strahl (ndc 0,0)
@@ -35,6 +37,24 @@ describe("cameraRay", () => {
// normalisiert
expect(Math.hypot(...ray.dir)).toBeCloseTo(1, 6);
});
it("orthografisch (perspective:false): alle Strahlen sind parallel zur Blickrichtung", () => {
const orthoCam: RayCamera = { ...CAM, perspective: false, orthoHalfHeight: 5 };
const center = cameraRay(orthoCam, 0, 0, 16 / 9);
const offset = cameraRay(orthoCam, 0.5, 0.5, 1);
// Beide Strahlen zeigen exakt in dieselbe Richtung (parallel, keine Konvergenz).
expect(offset.dir).toEqual(center.dir);
expect(center.dir[2]).toBeCloseTo(-1, 6);
// Der Ursprung wandert stattdessen lateral mit dem Pixel (Kehrwert von worldToScreen).
expect(offset.origin[0]).toBeCloseTo(2.5, 6); // ndcX·aspect·hh = 0.5·1·5
expect(offset.origin[1]).toBeCloseTo(3.5, 6); // eye.y(1) + ndcY·hh = 1 + 0.5·5
expect(offset.origin[2]).toBeCloseTo(10, 6); // unveraendert (Achse senkrecht zur Blickrichtung)
});
it("orthografisch ohne perspective-Feld bleibt rückwärtskompatibel perspektivisch", () => {
const ray = cameraRay(CAM, 0.5, 0.5, 1);
expect(ray.origin).toEqual([0, 1, 10]); // Ursprung bleibt eye (Perspektive)
});
});
// Wand: Achse model (-1,5)→(1,5) → world x∈[-1,1], z≈5. Dicke 0.2, Höhe 2 ab 0.
@@ -125,3 +145,65 @@ describe("pickNearest", () => {
expect(hit).toBeNull();
});
});
// Öffnung: dieselbe Achse/Front wie WALL (model y=5 → world z≈5), aber nur über
// die Fenster-Ausdehnung (Brüstung 0.9m..1.9m) statt der vollen Wandhöhe.
const OPENING: PickOpening = {
start: [-0.5, 5],
end: [0.5, 5],
thickness: 0.2,
height: 1.0,
baseElevation: 0.9,
openingId: "op1",
};
// Treppe: Fußabdruck-Rechteck model (0,0)-(2,3), Bounding-Prisma bis 2.6m Rise.
const STAIR: PickStair = {
outline: [
[0, 0],
[2, 0],
[2, 3],
[0, 3],
],
zBottom: 0,
zTop: 2.6,
stairId: "st1",
};
describe("pickNearest — Öffnungen und Treppen", () => {
it("Strahl auf eine Öffnung liefert openingId", () => {
// Wie der Wand-Treffer (Front z=5.1 → t=4.9), aber y=1 liegt in der
// Fenster-Ausdehnung (0.9..1.9) → Öffnung statt Wand.
const hit = pickNearest({ origin: [0, 1, 10], dir: [0, 0, -1] }, [], [], [OPENING]);
expect(hit).not.toBeNull();
expect(hit!.kind).toBe("opening");
expect(hit!.openingId).toBe("op1");
expect(hit!.t).toBeCloseTo(4.9, 6);
});
it("Strahl auf eine Treppe liefert stairId", () => {
// Senkrecht von oben ins Fußabdruck-Rechteck: Deckel bei y=2.6 → t=7.4.
const hit = pickNearest(
{ origin: [1, 10, 1], dir: [0, -1, 0] },
[],
[],
[],
[STAIR],
);
expect(hit).not.toBeNull();
expect(hit!.kind).toBe("stair");
expect(hit!.stairId).toBe("st1");
expect(hit!.t).toBeCloseTo(7.4, 6);
});
it("bei näherer Wand gewinnt die Wand (kleinstes t) trotz konkurrierender Öffnung", () => {
// farOpening liegt bei model y=2 (world z=2) → Front z=2.1 → t=7.9, WEITER
// hinten als die Wand (t=4.9) → die Wand muss trotz der Öffnung-Priorität
// bei Gleichstand gewinnen, weil hier gar kein Gleichstand vorliegt.
const farOpening: PickOpening = { ...OPENING, start: [-0.5, 2], end: [0.5, 2], openingId: "op-far" };
const hit = pickNearest({ origin: [0, 1, 10], dir: [0, 0, -1] }, [WALL], [], [farOpening]);
expect(hit).not.toBeNull();
expect(hit!.kind).toBe("wall");
expect(hit!.id).toBe("w1");
});
});
+271 -50
View File
@@ -8,12 +8,13 @@
// (x, elevation, y); Höhe entlang +Y. Der Grundriss liegt also in der XZ-Ebene,
// vertikal wird über die world-Y-Achse extrudiert.
//
// KAMERA: perspektivischer Pinhole-Strahl aus eye durch das Cursor-Pixel. Für
// die freie Orbit-Kamera (iso/perspektivisch bzw. nach jeder manuellen
// Navigation) ist das exakt. Für die achsparallelen Presets (front/top/side),
// die render3d orthografisch zeichnet, ist es eine perspektivische Näherung —
// der Blickwinkel stimmt (VIEW3D_ORBIT), die Parallaxe nicht ganz. Ausreichend
// für die Bauteil-Auswahl; ein exakter Ortho-Strahl kann später ergänzt werden.
// KAMERA: `cameraRay` deckt BEIDE Projektionen ab — perspektivischer Pinhole-
// Strahl aus `eye` (Default, `perspective` fehlt/true) für die freie Orbit-
// Kamera, ODER (bei `perspective:false`, s. `ProjCamera`/Front-/Top-/Side-/
// Iso-Presets, die render3d orthografisch zeichnet) echte parallele Strahlen:
// alle Strahlen teilen dieselbe Richtung (Blickrichtung `f`), nur der URSPRUNG
// wandert lateral mit dem Pixel — exakte Umkehrung von `worldToScreen`s
// Ortho-Zweig, keine perspektivische Näherung mehr.
export type Vec3 = [number, number, number];
@@ -24,6 +25,11 @@ export interface RayCamera {
up: readonly [number, number, number];
/** Vertikaler Öffnungswinkel in RADIANT. */
fovY: number;
/** false = orthografisch (parallele Strahlen). Fehlt/true = perspektivisch
* (Default, rückwärtskompatibel zu bestehenden Aufrufern ohne dieses Feld). */
perspective?: boolean;
/** Halbe Bildhöhe in Metern — nur bei `perspective:false` relevant. */
orthoHalfHeight?: number;
}
/** Ein Strahl in Weltkoordinaten (dir normalisiert). */
@@ -42,6 +48,21 @@ export interface PickWall {
wallId: string;
}
/**
* Minimal-Form einer Öffnungs-Box (Fenster/Tür) für den Raycast — dieselbe
* orientierte-Quader-Form wie `PickWall` (Achse jambStart→jambEnd in der
* XZ-Ebene, Dicke = Wanddicke am Sitz der Öffnung), nur über ihre EIGENE
* vertikale Ausdehnung (Brüstung..Sturz) statt der vollen Wandhöhe.
*/
export interface PickOpening {
start: readonly [number, number];
end: readonly [number, number];
thickness: number;
height: number;
baseElevation: number;
openingId: string;
}
/** Minimal-Form eines Decken-Prismas für den Raycast (RSlab erfüllt sie strukturell). */
export interface PickSlab {
outline: ReadonlyArray<readonly [number, number]>;
@@ -50,11 +71,28 @@ export interface PickSlab {
ceilingId: string;
}
/**
* Minimal-Form eines Treppen-Bounding-Prismas für den Raycast: ein achsparalleles
* Rechteck über den Grundriss-Fußabdruck (alle Tritte + Podest), extrudiert über
* die vertikale Ausdehnung der Treppe. Pragmatische Näherung statt Tritt-für-
* Tritt-Boxen — reicht für zuverlässiges Anklicken im 3D-Viewport.
*/
export interface PickStair {
outline: ReadonlyArray<readonly [number, number]>;
zBottom: number;
zTop: number;
stairId: string;
}
/** Ergebnis eines Picks: getroffenes Bauteil + Distanz entlang des Strahls. */
export interface PickHit {
kind: "wall" | "ceiling";
kind: "wall" | "ceiling" | "opening" | "stair";
id: string;
t: number;
/** Bei `kind:"opening"` dieselbe Id wie `id` (ausdrucksstärkerer Zugriff). */
openingId?: string;
/** Bei `kind:"stair"` dieselbe Id wie `id` (ausdrucksstärkerer Zugriff). */
stairId?: string;
}
const EPS = 1e-9;
@@ -78,10 +116,16 @@ function norm(a: Vec3): Vec3 {
}
/**
* Perspektivischer Kamerastrahl durch ein NDC-Pixel. `ndcX`/`ndcY` ∈ [-1,1]
* (X links→rechts, Y unten→oben — der Aufrufer invertiert die Pixel-Y-Achse).
* `aspect` = Breite/Höhe des Canvas. Standard-Pinhole: Kamerabasis f/s/u aus
* eye/target/up, Richtung = f + s·(ndcX·aspect·tan(fovY/2)) + u·(ndcY·tan(fovY/2)).
* Kamerastrahl durch ein NDC-Pixel. `ndcX`/`ndcY` ∈ [-1,1] (X links→rechts, Y
* unten→oben — der Aufrufer invertiert die Pixel-Y-Achse). `aspect` = Breite/
* Höhe des Canvas.
*
* `perspective` (Default true): Standard-Pinhole — EIN Ursprung (`eye`),
* Richtung = f + s·(ndcX·aspect·tan(fovY/2)) + u·(ndcY·tan(fovY/2)).
*
* `perspective:false`: orthografisch — alle Strahlen PARALLEL (Richtung
* immer `f`), der Ursprung wandert stattdessen lateral mit dem Pixel (exakte
* Umkehrung von `worldToScreen`s Ortho-Zweig, s. `ProjCamera`).
*/
export function cameraRay(
cam: RayCamera,
@@ -93,6 +137,17 @@ export function cameraRay(
const f = norm(sub(cam.target as Vec3, eye)); // Blickrichtung
const s = norm(cross(f, cam.up as Vec3)); // rechts
const u = cross(s, f); // echtes Kamera-oben
if (cam.perspective === false) {
const hh = cam.orthoHalfHeight || 1;
const origin: Vec3 = [
eye[0] + s[0] * (ndcX * aspect * hh) + u[0] * (ndcY * hh),
eye[1] + s[1] * (ndcX * aspect * hh) + u[1] * (ndcY * hh),
eye[2] + s[2] * (ndcX * aspect * hh) + u[2] * (ndcY * hh),
];
return { origin, dir: f };
}
const th = Math.tan(cam.fovY / 2);
const dir = norm([
f[0] + s[0] * (ndcX * aspect * th) + u[0] * (ndcY * th),
@@ -103,27 +158,36 @@ export function cameraRay(
}
/**
* Schnitt-Distanz des Strahls mit dem orientierten Quader (OBB) einer Wand, oder
* null. Der Quader ist exakt: Achse start→end in der XZ-Ebene (Länge), Normale
* dazu (Dicke), world-Y (Höhe baseElevation..baseElevation+height). Slab-Methode
* im OBB-Rahmen (Real-Time Rendering). Rückgabe = nichtnegative Eintritts-t (bzw.
* Austritts-t, falls der Ursprung im Quader liegt).
* Schnitt-Distanz des Strahls mit einem orientierten Quader (OBB) in der
* world-XZ-Ebene, oder null. Der Quader ist exakt: Achse start→end in der
* XZ-Ebene (Länge), Normale dazu (Dicke), world-Y (Höhe baseElevation..
* baseElevation+height). Slab-Methode im OBB-Rahmen (Real-Time Rendering).
* Rückgabe = nichtnegative Eintritts-t (bzw. Austritts-t, falls der Ursprung
* im Quader liegt). Gemeinsamer Kern für `rayWallBox` (volle Wandhöhe) und
* `rayOpeningBox` (nur die vertikale Ausdehnung der Öffnung).
*/
export function rayWallBox(ray: Ray, wall: PickWall): number | null {
const ax = wall.end[0] - wall.start[0];
const az = wall.end[1] - wall.start[1];
function rayOrientedBoxXZ(
ray: Ray,
start: readonly [number, number],
end: readonly [number, number],
thickness: number,
height: number,
baseElevation: number,
): number | null {
const ax = end[0] - start[0];
const az = end[1] - start[1];
const len = Math.hypot(ax, az);
if (len < EPS || wall.height <= EPS || wall.thickness <= EPS) return null;
if (len < EPS || height <= EPS || thickness <= EPS) return null;
const axis: [Vec3, Vec3, Vec3] = [
[ax / len, 0, az / len], // entlang der Wandachse
[ax / len, 0, az / len], // entlang der Achse
[0, 1, 0], // Höhe
[az / len, 0, -ax / len], // Normale (Dicke), senkrecht in XZ
];
const half = [len / 2, wall.height / 2, wall.thickness / 2];
const half = [len / 2, height / 2, thickness / 2];
const center: Vec3 = [
(wall.start[0] + wall.end[0]) / 2,
wall.baseElevation + wall.height / 2,
(wall.start[1] + wall.end[1]) / 2,
(start[0] + end[0]) / 2,
baseElevation + height / 2,
(start[1] + end[1]) / 2,
];
const p = sub(center, ray.origin);
let tmin = -Infinity;
@@ -152,6 +216,29 @@ export function rayWallBox(ray: Ray, wall: PickWall): number | null {
return tmin > 0 ? tmin : tmax;
}
/**
* Schnitt-Distanz des Strahls mit dem orientierten Quader (OBB) einer Wand,
* oder null — s. {@link rayOrientedBoxXZ}.
*/
export function rayWallBox(ray: Ray, wall: PickWall): number | null {
return rayOrientedBoxXZ(ray, wall.start, wall.end, wall.thickness, wall.height, wall.baseElevation);
}
/**
* Schnitt-Distanz des Strahls mit der orientierten Box einer Öffnung (Fenster/
* Tür) über ihre EIGENE vertikale Ausdehnung, oder null — s. {@link rayOrientedBoxXZ}.
*/
export function rayOpeningBox(ray: Ray, opening: PickOpening): number | null {
return rayOrientedBoxXZ(
ray,
opening.start,
opening.end,
opening.thickness,
opening.height,
opening.baseElevation,
);
}
/** Punkt-in-Polygon (XZ-Ebene), klassisches Ray-Casting (ungerade Kreuzungen). */
function pointInPolyXZ(
x: number,
@@ -191,36 +278,42 @@ function rayTriangle(ray: Ray, a: Vec3, b: Vec3, c: Vec3): number | null {
}
/**
* Schnitt-Distanz des Strahls mit dem extrudierten Umriss-Prisma einer Decke,
* oder null. GENAUIGKEIT: exakt für ein einfaches (nicht selbst-schneidendes)
* Polygon — Deckel/Boden (y=zTop/zBottom) über Punkt-in-Polygon (XZ), die
* Seitenflächen als vertikale Quads (je zwei Ray-Dreiecke). Kleinstes
* nichtnegatives t gewinnt.
* Schnitt-Distanz des Strahls mit einem extrudierten Umriss-Prisma (world-XZ-
* Grundriss, world-Y-Extrusion zBottom..zTop), oder null. GENAUIGKEIT: exakt
* für ein einfaches (nicht selbst-schneidendes) Polygon — Deckel/Boden (y=
* zTop/zBottom) über Punkt-in-Polygon (XZ), die Seitenflächen als vertikale
* Quads (je zwei Ray-Dreiecke). Kleinstes nichtnegatives t gewinnt. Gemeinsamer
* Kern für `raySlabPrism` (Decke) und `rayStairPrism` (Treppen-Bounding-Box).
*/
export function raySlabPrism(ray: Ray, slab: PickSlab): number | null {
const n = slab.outline.length;
if (n < 3 || slab.zTop - slab.zBottom <= EPS) return null;
function rayPrismXZ(
ray: Ray,
outline: ReadonlyArray<readonly [number, number]>,
zBottom: number,
zTop: number,
): number | null {
const n = outline.length;
if (n < 3 || zTop - zBottom <= EPS) return null;
let best = Infinity;
const consider = (t: number | null) => {
if (t !== null && t >= 0 && t < best) best = t;
};
// Deckel + Boden (horizontale Ebenen) via Punkt-in-Polygon.
for (const y of [slab.zBottom, slab.zTop]) {
for (const y of [zBottom, zTop]) {
if (Math.abs(ray.dir[1]) <= EPS) continue;
const t = (y - ray.origin[1]) / ray.dir[1];
if (t < 0) continue;
const px = ray.origin[0] + t * ray.dir[0];
const pz = ray.origin[2] + t * ray.dir[2];
if (pointInPolyXZ(px, pz, slab.outline)) consider(t);
if (pointInPolyXZ(px, pz, outline)) consider(t);
}
// Seitenwände: je Kante ein vertikales Quad (zwei Dreiecke).
for (let i = 0, j = n - 1; i < n; j = i++) {
const a = slab.outline[j];
const b = slab.outline[i];
const A: Vec3 = [a[0], slab.zBottom, a[1]];
const B: Vec3 = [b[0], slab.zBottom, b[1]];
const C: Vec3 = [b[0], slab.zTop, b[1]];
const D: Vec3 = [a[0], slab.zTop, a[1]];
const a = outline[j];
const b = outline[i];
const A: Vec3 = [a[0], zBottom, a[1]];
const B: Vec3 = [b[0], zBottom, b[1]];
const C: Vec3 = [b[0], zTop, b[1]];
const D: Vec3 = [a[0], zTop, a[1]];
consider(rayTriangle(ray, A, B, C));
consider(rayTriangle(ray, A, C, D));
}
@@ -228,27 +321,155 @@ export function raySlabPrism(ray: Ray, slab: PickSlab): number | null {
}
/**
* Nächstes getroffenes Bauteil (kleinstes t) über Wände + Decken, oder null.
* Wände und Decken konkurrieren direkt über die Distanz — was näher an der
* Kamera liegt, gewinnt.
* Schnitt-Distanz des Strahls mit dem extrudierten Umriss-Prisma einer Decke,
* oder null — s. {@link rayPrismXZ}.
*/
export function raySlabPrism(ray: Ray, slab: PickSlab): number | null {
return rayPrismXZ(ray, slab.outline, slab.zBottom, slab.zTop);
}
/**
* Schnitt-Distanz des Strahls mit dem Bounding-Prisma einer Treppe (Grundriss-
* Fußabdruck × vertikale Ausdehnung), oder null — s. {@link rayPrismXZ}.
*/
export function rayStairPrism(ray: Ray, stair: PickStair): number | null {
return rayPrismXZ(ray, stair.outline, stair.zBottom, stair.zTop);
}
// Overload OHNE Öffnungen/Treppen: bleibt exakt die bisherige Signatur, sodass
// der bestehende Aufrufer (Wasm3DViewport.tsx: `pickNearest(ray, walls, slabs)`)
// unverändert kompiliert — inklusive des dort erwarteten engen `kind`-Typs
// ("wall" | "ceiling"), da mit leeren Öffnungen/Treppen ohnehin nur diese beiden
// Arten getroffen werden können.
export function pickNearest(
ray: Ray,
walls: readonly PickWall[],
slabs: readonly PickSlab[],
): (PickHit & { kind: "wall" | "ceiling" }) | null;
// Erweiterter Overload MIT Öffnungen/Treppen: `kind` deckt alle vier Arten ab.
export function pickNearest(
ray: Ray,
walls: readonly PickWall[],
slabs: readonly PickSlab[],
openings: readonly PickOpening[],
stairs?: readonly PickStair[],
): PickHit | null;
/**
* Nächstes getroffenes Bauteil (kleinstes t) über Wände, Decken, Öffnungen und
* Treppen, oder null. Alle Kandidaten konkurrieren direkt über die Distanz —
* was näher an der Kamera liegt, gewinnt. PRIORITÄT bei (nahezu) gleichem t:
* Öffnung/Treppe vor Wand/Decke, weil ihr Symbol (Rahmen, Blatt, Stufenkante)
* auf derselben Fläche wie die Wandaussenkante liegt und sonst vom fast
* gleich-weiten Wandtreffer verdeckt würde — primär entscheidet aber IMMER das
* kleinste t, der Rang bricht nur echte Gleichstände.
*/
export function pickNearest(
ray: Ray,
walls: readonly PickWall[],
slabs: readonly PickSlab[],
openings: readonly PickOpening[] = [],
stairs: readonly PickStair[] = [],
): PickHit | null {
const TIE_EPS = 1e-6;
let hit: PickHit | null = null;
let rank = Infinity;
const consider = (candidate: PickHit, candidateRank: number) => {
if (
hit === null ||
candidate.t < hit.t - TIE_EPS ||
(candidate.t <= hit.t + TIE_EPS && candidateRank < rank)
) {
hit = candidate;
rank = candidateRank;
}
};
for (const w of walls) {
const t = rayWallBox(ray, w);
if (t !== null && (hit === null || t < hit.t)) {
hit = { kind: "wall", id: w.wallId, t };
}
if (t !== null) consider({ kind: "wall", id: w.wallId, t }, 1);
}
for (const s of slabs) {
const t = raySlabPrism(ray, s);
if (t !== null && (hit === null || t < hit.t)) {
hit = { kind: "ceiling", id: s.ceilingId, t };
}
if (t !== null) consider({ kind: "ceiling", id: s.ceilingId, t }, 1);
}
for (const o of openings) {
const t = rayOpeningBox(ray, o);
if (t !== null) consider({ kind: "opening", id: o.openingId, t, openingId: o.openingId }, 0);
}
for (const st of stairs) {
const t = rayStairPrism(ray, st);
if (t !== null) consider({ kind: "stair", id: st.stairId, t, stairId: st.stairId }, 0);
}
return hit;
}
// ── 3D-Editier-Griffe (Wasm-Pfad) ────────────────────────────────────────────
// Ergänzt den Pick-Kern um die Bausteine, die ein Vertex-Griff-Drag im nativen
// Viewport braucht: Schnitt mit einer horizontalen Arbeitsebene (Drag-Ziel-
// punkt, analog `workplaneModel` in der three.js-Sicht) + Nächster-Griff-Test
// (Screen-Pixel-Toleranz, die je Strahl-Distanz in Welt-Meter umgerechnet wird
// — dieselbe perspektivische Näherung wie `cameraRay`/`pickNearest`).
/**
* Schnittpunkt des Strahls mit der horizontalen Ebene world-y = `planeY`, oder
* null (Strahl (nahezu) parallel zur Ebene oder Schnitt liegt hinter dem
* Ursprung). Analog `workplaneModel` in `Viewport3D.tsx`, nur ohne three.js.
*/
export function rayPlaneY(ray: Ray, planeY: number): Vec3 | null {
return rayPlane(ray, [0, planeY, 0], [0, 1, 0]);
}
/**
* Schnittpunkt des Strahls mit einer beliebigen Ebene (Punkt + Normale), oder
* null (Strahl (nahezu) parallel zur Ebene oder Schnitt hinter dem Ursprung).
* Für den 3D-Höhen-Griff (vertikale Ebene entlang der Wandachse) — s.
* `Wasm3DViewport.tsx`.
*/
export function rayPlane(ray: Ray, point: Vec3, normal: Vec3): Vec3 | null {
const denom = dot(ray.dir, normal);
if (Math.abs(denom) < EPS) return null;
const t = dot(sub(point, ray.origin), normal) / denom;
if (t < 0) return null;
return [ray.origin[0] + t * ray.dir[0], ray.origin[1] + t * ray.dir[1], ray.origin[2] + t * ray.dir[2]];
}
/** Kameraparameter für die Weltpunkt→Bildschirm-Projektion (Umkehrung von `cameraRay`). */
export interface ProjCamera extends RayCamera {
/** true = perspektivisch (fovY), false = orthografisch (orthoHalfHeight). */
perspective: boolean;
/** Halbe Sichthöhe in Metern (nur orthografisch). */
orthoHalfHeight: number;
}
/**
* Projiziert einen Weltpunkt auf Bildschirm-Pixel (0,0 = oben links) innerhalb
* eines `width`×`height`-Rechtecks. `null`, wenn der Punkt (nur perspektivisch
* relevant) hinter der Kamera liegt. Für echte GUI-Griffe (DOM-Overlay statt
* In-Szene-Geometrie) — s. `Wasm3DViewport.tsx`.
*/
export function worldToScreen(
cam: ProjCamera,
point: Vec3,
width: number,
height: number,
): { x: number; y: number } | null {
const eye = cam.eye as Vec3;
const f = norm(sub(cam.target as Vec3, eye));
const s = norm(cross(f, cam.up as Vec3));
const u = cross(s, f);
const v = sub(point, eye);
const aspect = width / height;
let ndcX: number;
let ndcY: number;
if (cam.perspective) {
const depth = dot(v, f);
if (depth <= 0.01) return null;
const th = Math.tan(cam.fovY / 2);
ndcX = dot(v, s) / (depth * aspect * th);
ndcY = dot(v, u) / (depth * th);
} else {
const hh = cam.orthoHalfHeight || 1;
ndcX = dot(v, s) / (hh * aspect);
ndcY = dot(v, u) / hh;
}
return { x: ((ndcX + 1) / 2) * width, y: ((1 - ndcY) / 2) * height };
}
+32
View File
@@ -20,6 +20,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { Project } from "../model/types";
import { projectToModel3d } from "../plan/toWalls3d";
import { loadEngine3d } from "../engine/engine3d";
import { decodeMaterialTextures } from "./materialTextures";
/** Fertige Kamera für einen Frame (world-Meter, Y-up — wie render3d::types::Camera). */
export interface Camera3d {
@@ -74,6 +75,12 @@ interface WebModelRendererLike {
* aktuell gewählten Bauteils. Leeres Array löscht das Highlight. Wirkt erst
* beim nächsten `render`. */
set_highlight_lines(vertices: Float32Array): void;
/** Material-Farbkarten (Stil „textured") als Textur-Array hochladen
* (`web::set_material_textures`). `rgba` = gepackte RGBA8-Bytes von
* `layerCount` 256×256-Ebenen; Ebene 0 bleibt intern das Fallback-Schachbrett,
* die Karten belegen Ebenen 1..N (siehe `WallInput.materialIndex`). Optional —
* ältere WASM-Builds ohne diesen Export lassen den Aufrufer still zurückfallen. */
set_material_textures?(rgba: Uint8Array, layerCount: number): void;
set_camera(
eyeX: number,
eyeY: number,
@@ -154,6 +161,11 @@ export function useWasm3dRenderer(
};
}, [enabled, canvasRef]);
// Signatur der zuletzt hochgeladenen Material-Textur-Menge — verhindert ein
// erneutes (teures) Dekodieren/Hochladen, wenn sich die Materialien nicht
// geändert haben (updateModel läuft bei JEDER Projektänderung).
const materialSigRef = useRef<string | null>(null);
/** Modell bei Projektänderung neu setzen (re-extrudiert in Rust). */
const updateModel = useCallback((project: Project) => {
const r = rendererRef.current;
@@ -163,6 +175,26 @@ export function useWasm3dRenderer(
} catch (e) {
console.warn("render3d set_model fehlgeschlagen:", e);
}
// Material-Farbkarten (Stil „textured") ASYNCHRON dekodieren + hochladen.
// Fire-and-forget: bis die Texturen ankommen, zeigt render3d für die
// betroffenen Bänder das Fallback-Schachbrett (graceful). Nur neu laden,
// wenn sich die Material-Menge geändert hat.
if (typeof r.set_material_textures !== "function") return;
void (async () => {
try {
const decoded = await decodeMaterialTextures(project);
const sig = decoded ? decoded.signature : "";
if (sig === materialSigRef.current) return; // unverändert
materialSigRef.current = sig;
// Renderer könnte zwischenzeitlich getauscht worden sein.
const cur = rendererRef.current;
if (!cur || typeof cur.set_material_textures !== "function") return;
if (decoded) cur.set_material_textures(decoded.data, decoded.count);
else cur.set_material_textures(new Uint8Array(0), 0); // zurück auf Schachbrett
} catch (e) {
console.warn("render3d set_material_textures fehlgeschlagen:", e);
}
})();
}, []);
/**