From fabf97a402aaa93ea084254ae37513688b571114 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 06:26:29 +0200 Subject: [PATCH] Nordstern-3D: Auswahl-Highlight (orange Outline, immer sichtbar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/render3d/src/gpu.rs | 137 ++++++++++++++++++++++-------- src-tauri/render3d/src/web.rs | 12 +++ src/App.tsx | 28 ++++++ src/plan/toWalls3d.test.ts | 70 +++++++++++++++ src/plan/toWalls3d.ts | 94 ++++++++++++++++++++ src/viewport/Viewport3D.tsx | 7 +- src/viewport/Wasm3DViewport.tsx | 20 +++++ src/viewport/useWasm3dRenderer.ts | 25 ++++++ 8 files changed, 358 insertions(+), 35 deletions(-) create mode 100644 src/plan/toWalls3d.test.ts diff --git a/src-tauri/render3d/src/gpu.rs b/src-tauri/render3d/src/gpu.rs index 64909e6..83933d4 100644 --- a/src-tauri/render3d/src/gpu.rs +++ b/src-tauri/render3d/src/gpu.rs @@ -146,6 +146,10 @@ pub struct Renderer { /// Teilt sich das `Globals`-Uniform/Bind-Group (nur View-Projektion) mit der /// Mesh-Pipeline und rendert in denselben Pass/Tiefenpuffer. grid_pipeline: wgpu::RenderPipeline, + /// Wie `grid_pipeline` (LineList, GRID_WGSL), aber OHNE Tiefentest + /// (`depth_compare: Always`, kein Depth-Write): die Auswahl-Highlight-Linien + /// liegen immer sichtbar obenauf und scheinen auch hinter Waenden durch. + highlight_pipeline: wgpu::RenderPipeline, bind_group: wgpu::BindGroup, uniform: wgpu::Buffer, mesh: Option, @@ -156,6 +160,11 @@ pub struct Renderer { /// Bodengitter-Vertices (None = nicht erzeugt). Wird nur gezeichnet, wenn /// zusaetzlich `grid_visible` gesetzt ist. grid: Option, + /// Auswahl-Highlight-Linien (Kanten des selektierten Bauteils, LineList) als + /// LineBuffers. None = keine Auswahl -> nichts wird gezeichnet. Ueber + /// `set_highlight_lines` gesetzt/geloescht; im Render-Pass als ALLERLETZTES + /// (ohne Tiefentest) gezeichnet. + highlight: Option, /// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false — /// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter. grid_visible: bool, @@ -296,46 +305,72 @@ impl Renderer { step_mode: wgpu::VertexStepMode::Vertex, attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3], }; - let grid_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("grid.pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &grid_module, - entry_point: Some("vs_main"), - buffers: &[grid_vertex_layout], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &grid_module, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: color_format, - blend: Some(wgpu::BlendState::REPLACE), - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::LineList, - // Linien haben keine Vorder-/Rueckseite -> kein Culling. - cull_mode: None, - ..Default::default() - }, - depth_stencil: Some(wgpu::DepthStencilState { + // Linien-Pipeline-Fabrik: identische Beschreibung (LineList, GRID_WGSL, + // gemeinsame Bind-Group/Farbziel/MSAA) fuer das Bodengitter/die Kanten + // (tiefengetestet) UND die Auswahl-Highlight-Linien (ohne Tiefentest, siehe + // unten). Nur der `depth_stencil`-Zustand unterscheidet sich. + let make_line_pipeline = |label: &str, depth: wgpu::DepthStencilState| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &grid_module, + entry_point: Some("vs_main"), + buffers: &[grid_vertex_layout.clone()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &grid_module, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::LineList, + // Linien haben keine Vorder-/Rueckseite -> kein Culling. + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(depth), + multisample: wgpu::MultisampleState { + count: SAMPLE_COUNT, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) + }; + // Grid/Kanten: normaler Tiefentest (Less), schreibt Tiefe -> das Modell + // verdeckt Gitter/verdeckte Kanten korrekt. + let grid_pipeline = make_line_pipeline( + "grid.pipeline", + wgpu::DepthStencilState { format: DEPTH_FORMAT, depth_write_enabled: Some(true), depth_compare: Some(wgpu::CompareFunction::Less), stencil: wgpu::StencilState::default(), bias: wgpu::DepthBiasState::default(), - }), - multisample: wgpu::MultisampleState { - count: SAMPLE_COUNT, - mask: !0, - alpha_to_coverage_enabled: false, }, - multiview_mask: None, - cache: None, - }); + ); + // Auswahl-Highlight: KEIN Tiefentest (`Always`) und KEIN Tiefen-Write — + // die Auswahl-Umrisslinien werden unabhaengig von der Szenentiefe immer + // gezeichnet und scheinen so auch hinter Waenden durch (liegen optisch oben, + // weil sie als letztes gezeichnet werden). + let highlight_pipeline = make_line_pipeline( + "highlight.pipeline", + wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }, + ); let uniform = device.create_buffer(&wgpu::BufferDescriptor { label: Some("globals.buffer"), @@ -356,11 +391,13 @@ impl Renderer { pipeline, hidden_face_pipeline, grid_pipeline, + highlight_pipeline, bind_group, uniform, mesh: None, edges: None, grid: None, + highlight: None, grid_visible: false, style: RenderStyle::Shaded, depth: None, @@ -498,6 +535,27 @@ impl Renderer { }); } + /// Setzt/loescht die Auswahl-Highlight-Linien (Kanten des selektierten Bauteils). + /// `verts` = interleaved LineList-Vertices `[px,py,pz, r,g,b, ...]` (world, + /// je 2 Vertices = 1 Segment), exakt wie Grid-/Edge-Vertices + /// (`GRID_FLOATS_PER_VERTEX`). Leeres Slice -> Highlight loeschen. Analog + /// `set_ground_grid`, aber ohne Sichtbarkeits-Flag: vorhanden = gezeichnet. + pub fn set_highlight_lines(&mut self, device: &wgpu::Device, verts: &[f32]) { + if verts.is_empty() { + self.highlight = None; + return; + } + let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("highlight.vbo"), + contents: bytemuck::cast_slice(verts), + usage: wgpu::BufferUsages::VERTEX, + }); + self.highlight = Some(LineBuffers { + vbo, + vertex_count: (verts.len() / GRID_FLOATS_PER_VERTEX) as u32, + }); + } + /// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert. fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) { let ok = matches!(&self.depth, Some(d) if d.width == w && d.height == h); @@ -658,6 +716,17 @@ impl Renderer { pass.draw(0..g.vertex_count, 0..1); } } + + // 4) Auswahl-Highlight-Linien als ALLERLETZTES — ohne Tiefentest + // (`highlight_pipeline`: depth_compare Always, kein Depth-Write), + // damit die Auswahl immer sichtbar obenauf liegt und auch hinter + // Waenden durchscheint. Nur wenn per `set_highlight_lines` gesetzt. + if let Some(hl) = &self.highlight { + pass.set_pipeline(&self.highlight_pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.set_vertex_buffer(0, hl.vbo.slice(..)); + pass.draw(0..hl.vertex_count, 0..1); + } } queue.submit(std::iter::once(encoder.finish())); } diff --git a/src-tauri/render3d/src/web.rs b/src-tauri/render3d/src/web.rs index dc7ca74..f44dc67 100644 --- a/src-tauri/render3d/src/web.rs +++ b/src-tauri/render3d/src/web.rs @@ -366,6 +366,18 @@ impl WebModelRenderer { Ok(()) } + /// Setzt/loescht die Auswahl-Highlight-Linien (Umriss/Kanten des selektierten + /// Bauteils). `vertices` = interleaved LineList-Vertices + /// `[px,py,pz, r,g,b, ...]` (world-Meter, je 2 Vertices = 1 Segment) — exakt + /// dasselbe Format wie die Grid-/Edge-Vertices. Ein leeres Slice loescht das + /// Highlight (nichts wird gezeichnet). Die Linien liegen im Render-Pass ohne + /// Tiefentest immer sichtbar obenauf (scheinen auch hinter Waenden durch). + /// Wirkt erst beim naechsten `render`. + pub fn set_highlight_lines(&mut self, vertices: &[f32]) -> Result<(), JsValue> { + self.renderer.set_highlight_lines(&self.device, vertices); + Ok(()) + } + /// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer). pub fn resize(&mut self, width: u32, height: u32) { let (w, h) = (width.max(1), height.max(1)); diff --git a/src/App.tsx b/src/App.tsx index 713a4bc..c5255d2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ import type { CategoryDisplayResolver } from "./plan/generatePlan"; import { computeSection } from "./plan/toSection"; import type { SectionOutput } from "./plan/toSection"; import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync"; +import { selectionHighlightLines } from "./plan/toWalls3d"; import { parseDxf } from "./io/dxfParser"; import type { DxfImportResult } from "./io/dxfParser"; import { setDxfImportTrigger } from "./io/dxfImportHook"; @@ -193,6 +194,13 @@ type EditorState = * getipptes „wall"/„wand" münden in denselben Befehl; der Legacy-Wand-Pfad ist * damit deaktiviert (siehe toolHandlers/onSelectTool). */ +/** + * Akzentfarbe der 3D-Auswahl-Hervorhebung (Umriss-Linien, WASM-Viewport, s. + * `Content`/`selectionHighlightLines`). Kräftiges Orange — hebt sich vom + * grauen Wand-/Deckenton, dem hellen Hidden-Line-Weiss und dem Bodenraster ab. + */ +const HIGHLIGHT_RGB: [number, number, number] = [1.0, 0.5, 0.1]; + const TOOL_COMMAND: Partial> = { wall: "wall", ceiling: "ceiling", @@ -4240,6 +4248,25 @@ function Content({ onEdit3dWallTop: (wallId: string, topZ: number) => void; onEdit3dEnd: () => void; }) { + // Auswahl-Hervorhebung im WASM-3D-Viewport: Umriss der gewählten Wand(e)/ + // Decke(n) in Akzent-Orange, immer obenauf (s. selectionHighlightLines in + // plan/toWalls3d.ts + set_highlight_lines in useWasm3dRenderer). Wirkt NUR + // im WASM-Zweig (Viewport3D reicht sie nur dort an Wasm3DViewport durch); + // die three.js-Sicht hebt die Auswahl weiterhin über ihr eigenes Material + // hervor. Leere Auswahl → null (kein Highlight). + const highlightLines = useMemo( + () => + selectedWallIds.length === 0 && selectedCeilingIds.length === 0 + ? null + : selectionHighlightLines( + project, + selectedWallIds, + selectedCeilingIds, + HIGHLIGHT_RGB, + ), + [project, selectedWallIds, selectedCeilingIds], + ); + if (level.kind === "section" || level.kind === "elevation") { return (
@@ -4367,6 +4394,7 @@ function Content({ onSelectStair={onViewportSelectStair} onContextMenu={onViewportContextMenu} onPick3d={onViewport3dPick} + highlightLines={highlightLines} commandActive={commandActive} draft={draft} onWorkplanePoint={onWorkplanePoint} diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts new file mode 100644 index 0000000..9d851f1 --- /dev/null +++ b/src/plan/toWalls3d.test.ts @@ -0,0 +1,70 @@ +/** + * Unit-Tests für {@link selectionHighlightLines} (Umriss-Linien der 3D- + * Auswahl, s. Datei-Kommentar in toWalls3d.ts): korrekte Vertexanzahl je + * Bauteilart (Wand-Quader = 12 Kanten, Slab-Prisma = 3·n Kanten) sowie die + * Leerfälle (keine Auswahl / unbekannte Id). + */ + +import { describe, it, expect } from "vitest"; +import { sampleProject } from "../model/sampleProject"; +import { selectionHighlightLines } from "./toWalls3d"; + +const RGB: [number, number, number] = [1, 0.5, 0.1]; +const FLOATS_PER_VERTEX = 6; // [px,py,pz, r,g,b] +const VERTICES_PER_EDGE = 2; + +describe("selectionHighlightLines", () => { + it("leere Auswahl -> leeres Array", () => { + const lines = selectionHighlightLines(sampleProject, [], [], RGB); + expect(lines.length).toBe(0); + }); + + it("unbekannte Ids -> leeres Array (keine Übereinstimmung in der Geometrie)", () => { + const lines = selectionHighlightLines(sampleProject, ["does-not-exist"], [], RGB); + expect(lines.length).toBe(0); + }); + + it("eine Wand ohne Öffnungen -> genau 12 Kanten (ein Quader)", () => { + // W2 (EG, Ost) trägt keine Tür/kein Fenster im Sample-Projekt (nur W1/W3 + // haben Öffnungen, s. sampleProject.ts) -> ein einziger Teilquader, also + // exakt 12 Kanten. + const lines = selectionHighlightLines(sampleProject, ["W2"], [], RGB); + expect(lines.length).toBe(12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); + }); + + it("eine Wand mit Farbwerten in jedem Vertex", () => { + const lines = selectionHighlightLines(sampleProject, ["W2"], [], RGB); + // Erstes Vertex: Position (3) + Farbe (3) = [px,py,pz,r,g,b]. + expect(lines[3]).toBeCloseTo(RGB[0], 6); + expect(lines[4]).toBeCloseTo(RGB[1], 6); + expect(lines[5]).toBeCloseTo(RGB[2], 6); + }); + + it("eine Decke (Slab) -> 3·n Kanten (n = Umriss-Eckenzahl)", () => { + // C1 hat einen 4-eckigen Umriss (5x4m Rechteck) -> 3*4 = 12 Kanten. + const lines = selectionHighlightLines(sampleProject, [], ["C1"], RGB); + const n = 4; + expect(lines.length).toBe(3 * n * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); + }); + + it("Mehrfachauswahl (Wand + Decke) -> Summe der Einzelgeometrien", () => { + const wallOnly = selectionHighlightLines(sampleProject, ["W2"], [], RGB); + const ceilingOnly = selectionHighlightLines(sampleProject, [], ["C1"], RGB); + const both = selectionHighlightLines(sampleProject, ["W2"], ["C1"], RGB); + expect(both.length).toBe(wallOnly.length + ceilingOnly.length); + }); + + it("mehrere gewählte Wände -> Kantenzahl skaliert linear", () => { + const one = selectionHighlightLines(sampleProject, ["W2"], [], RGB); + const two = selectionHighlightLines(sampleProject, ["W2", "W4"], [], RGB); + expect(two.length).toBe(one.length * 2); + }); + + it("eine Wand MIT Öffnungen -> mehrere Teilquader (mehr als 12 Kanten)", () => { + // W1 trägt Tür D1 + Fenster O1 -> Pfeiler/Brüstung/Sturz-Segmente, also + // ein Vielfaches von 12 Kanten (mehr als ein einzelner Quader). + const lines = selectionHighlightLines(sampleProject, ["W1"], [], RGB); + expect(lines.length).toBeGreaterThan(12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); + expect(lines.length % (12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX)).toBe(0); + }); +}); diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index cbe1c74..291fb80 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -33,6 +33,8 @@ import { openingInterval, openingVerticalExtent } from "../geometry/opening"; export type RVec2 = [number, number]; export type RRgb = [number, number, number]; +/** Weltpunkt (x, elevation, y) — nur intern für Highlight-Kantengeometrie. */ +type RVec3 = [number, number, number]; /** Eine aufgelöste Wandschicht: Dicke + 3D-Albedo-Farbe (aus dem Component). */ export interface RLayer { @@ -311,3 +313,95 @@ export function projectToModel3d(project: Project): RModel3d { export function pickGeometry(project: Project): { walls: RWall[]; slabs: RSlab[] } { return { walls: projectToWalls3d(project), slabs: emitSlabs(project) }; } + +/** + * Baut die Highlight-Linien der aktuellen 3D-Auswahl (Wand-/Decken-Ids) als + * flaches LineList-Vertex-Array `[px,py,pz,r,g,b, ...]` (world-Meter, je 2 + * Vertices = 1 Kante) — genau das Format, das `set_highlight_lines` erwartet + * (immer obenauf gezeichnete Akzent-Umrisslinie, s. useWasm3dRenderer). Leere + * Auswahl → leeres Array (Highlight löschen). + * + * GEOMETRIE (dieselbe Welt-Konvention wie pickGeometry/raycast3d: model (x,y) + * → world (x, elevation, y), Höhe entlang +Y): + * • Wand: derselbe orientierte Quader wie `rayWallBox` (raycast3d.ts) — Achse + * entlang der Wandlänge, Normale (Dicke) senkrecht dazu in der XZ-Ebene, + * Y = Höhe. Aus den 8 Ecken werden die 12 Kanten des Quaders emittiert. + * Wände MIT Öffnungen bestehen aus mehreren Teilquadern (Pfeiler/Brüstung/ + * Sturz, s. emitWall) — alle tragen dieselbe `wallId` und werden hier ALLE + * umrandet, sodass Öffnungsränder mit hervorgehoben werden. + * • Decke: der Umriss oben (zTop) und unten (zBottom) je als geschlossener + * Kantenzug, plus eine vertikale Kante an jeder Umriss-Ecke. + * + * Kanten werden EINFACH (nicht versetzt/doppelt) ausgegeben — eine zweite, + * leicht verschobene Kopie je Linie (für optisch "breitere" Linien bei + * WebGPU-Linienbreite 1) würde die Vertexzahl verdoppeln und die Achse/Betrag + * des Versatzes müsste in Bildschirm- statt Weltraum berechnet werden (hängt + * von Kamera-Distanz/-Winkel ab) — außerhalb des schmalen TS-Rahmens dieser + * Funktion. Der Renderer kann das bei Bedarf selbst tun (Rust-seitige + * Linienbreite/Shader), ohne dass sich dieses Vertexformat ändert. + */ +export function selectionHighlightLines( + project: Project, + wallIds: string[], + ceilingIds: string[], + rgb: RRgb, +): Float32Array { + if (wallIds.length === 0 && ceilingIds.length === 0) return new Float32Array(0); + const { walls, slabs } = pickGeometry(project); + const wallSet = new Set(wallIds); + const ceilingSet = new Set(ceilingIds); + const out: number[] = []; + const pushEdge = (a: RVec3, b: RVec3) => { + out.push(a[0], a[1], a[2], rgb[0], rgb[1], rgb[2]); + out.push(b[0], b[1], b[2], rgb[0], rgb[1], rgb[2]); + }; + // 12 Kanten eines Quaders aus 8 Ecken (Ecken-Index-Bits: bit0=Achse, + // bit1=Höhe, bit2=Dicke — je zwei Ecken, die sich in genau einem Bit + // unterscheiden, bilden eine Kante). + const BOX_EDGES: Array<[number, number]> = [ + [0, 1], [0, 2], [0, 4], [1, 3], [1, 5], [2, 3], + [2, 6], [3, 7], [4, 5], [4, 6], [5, 7], [6, 7], + ]; + for (const w of walls) { + if (!wallSet.has(w.wallId)) continue; + const ax = w.end[0] - w.start[0]; + const az = w.end[1] - w.start[1]; + const len = Math.hypot(ax, az); + if (len < EPS || w.height <= EPS || w.thickness <= EPS) continue; + const axis: RVec3 = [ax / len, 0, az / len]; + const normal: RVec3 = [az / len, 0, -ax / len]; + const halfLen = len / 2; + const halfHeight = w.height / 2; + const halfThick = w.thickness / 2; + const center: RVec3 = [ + (w.start[0] + w.end[0]) / 2, + w.baseElevation + w.height / 2, + (w.start[1] + w.end[1]) / 2, + ]; + const corners: RVec3[] = []; + for (let i = 0; i < 8; i++) { + const sL = i & 1 ? 1 : -1; + const sH = i & 2 ? 1 : -1; + const sT = i & 4 ? 1 : -1; + corners.push([ + center[0] + sL * halfLen * axis[0] + sT * halfThick * normal[0], + center[1] + sH * halfHeight, + center[2] + sL * halfLen * axis[2] + sT * halfThick * normal[2], + ]); + } + for (const [i, j] of BOX_EDGES) pushEdge(corners[i], corners[j]); + } + for (const s of slabs) { + if (!ceilingSet.has(s.ceilingId)) continue; + const n = s.outline.length; + if (n < 3 || s.zTop - s.zBottom <= EPS) continue; + const bottom: RVec3[] = s.outline.map((p) => [p[0], s.zBottom, p[1]]); + const top: RVec3[] = s.outline.map((p) => [p[0], s.zTop, p[1]]); + for (let i = 0, j = n - 1; i < n; j = i++) { + pushEdge(bottom[j], bottom[i]); + pushEdge(top[j], top[i]); + } + for (let i = 0; i < n; i++) pushEdge(bottom[i], top[i]); + } + return new Float32Array(out); +} diff --git a/src/viewport/Viewport3D.tsx b/src/viewport/Viewport3D.tsx index 5c08e48..e12ffea 100644 --- a/src/viewport/Viewport3D.tsx +++ b/src/viewport/Viewport3D.tsx @@ -128,9 +128,13 @@ export function Viewport3D( /** 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, ...threeProps } = props; + const { gridVisible, onToggleGrid, onPick3d, highlightLines, ...threeProps } = props; if (WASM_ENGINE_ACTIVE) { return ( ); } diff --git a/src/viewport/Wasm3DViewport.tsx b/src/viewport/Wasm3DViewport.tsx index 4764a3d..2f7360b 100644 --- a/src/viewport/Wasm3DViewport.tsx +++ b/src/viewport/Wasm3DViewport.tsx @@ -176,6 +176,7 @@ export function Wasm3DViewport({ groundElevation = 0, onToggleGrid, onPick3d, + highlightLines = null, }: { project: Project; /** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */ @@ -199,6 +200,13 @@ export function Wasm3DViewport({ * Aufrufer (App) setzt daraus die Store-Selektion; ohne Angabe passiert nichts. */ onPick3d?: (hit: Pick3dHit, additive: boolean) => void; + /** + * Umriss-Linien der aktuellen Auswahl (LineList-Vertex-Array, s. + * `selectionHighlightLines` in plan/toWalls3d.ts) — werden ohne Tiefentest + * immer sichtbar obenauf gezeichnet (Akzentfarbe kommt vom Aufrufer/App). + * `null`/leeres Array = kein Highlight (leere Auswahl). + */ + highlightLines?: Float32Array | null; }) { const canvasRef = useRef(null); // Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte) @@ -212,6 +220,7 @@ export function Wasm3DViewport({ setRenderStyle, applyViewPreset, setGroundGrid, + setHighlightLines, ready, failed, } = useWasm3dRenderer(canvasRef, true); @@ -268,6 +277,17 @@ export function Wasm3DViewport({ redrawRef.current(); }, [ready, gridVisible, groundElevation, setGroundGrid]); + // Auswahl-Hervorhebung: bei Bereitschaft oder Änderung der Highlight-Linien + // (Auswahl gewechselt/aufgehoben) neu setzen + zeichnen. Die Engine hält das + // Highlight, bis es erneut gesetzt wird — Kamerabewegungen (Orbit/Pan/Zoom) + // lösen hier KEINEN neuen `setHighlightLines`-Aufruf aus, das Highlight + // bleibt einfach über den nächsten `render()`-Aufruf hinweg sichtbar. + useEffect(() => { + if (!ready) return; + setHighlightLines(highlightLines ?? new Float32Array(0)); + redrawRef.current(); + }, [ready, highlightLines, setHighlightLines]); + // Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu // zeichnen (die Surface-Anpassung übernimmt der Hook im render()). useEffect(() => { diff --git a/src/viewport/useWasm3dRenderer.ts b/src/viewport/useWasm3dRenderer.ts index 5cd1dff..d31099f 100644 --- a/src/viewport/useWasm3dRenderer.ts +++ b/src/viewport/useWasm3dRenderer.ts @@ -53,6 +53,13 @@ interface WebModelRendererLike { extent_m: number, spacing_m: number, ): void; + /** + * Auswahl-Hervorhebung setzen: ein LineList-Vertex-Array (interleaved + * `[px,py,pz, r,g,b, ...]`, world-Meter, je 2 Vertices = 1 Linie), das die + * Engine OHNE Tiefentest zeichnet (immer sichtbar obenauf) — der Umriss des + * aktuell gewählten Bauteils. Leeres Array löscht das Highlight. Wirkt erst + * beim nächsten `render`. */ + set_highlight_lines(vertices: Float32Array): void; set_camera( eyeX: number, eyeY: number, @@ -180,6 +187,23 @@ export function useWasm3dRenderer( [], ); + /** + * Auswahl-Hervorhebung setzen (Umriss-Linien des gewählten Bauteils, immer + * obenauf) — reicht das fertige LineList-Vertex-Array 1:1 an + * `set_highlight_lines` durch (s. WebModelRendererLike). Der Aufrufer + * (Wasm3DViewport) übergibt ein leeres Float32Array, um das Highlight zu + * löschen (leere Auswahl). Wirkt erst beim nächsten `render`. + */ + const setHighlightLines = useCallback((vertices: Float32Array) => { + const r = rendererRef.current; + if (!r) return; + try { + r.set_highlight_lines(vertices); + } catch (e) { + console.warn("render3d set_highlight_lines fehlgeschlagen:", e); + } + }, []); + /** * Kamera-Praeset anwenden (`front`/`top`/`side`/`iso`/`perspective`, wie * `View3d` in TopBar.tsx). Delegiert die formatfuellende Ziel-/Distanz- @@ -250,6 +274,7 @@ export function useWasm3dRenderer( setRenderStyle, applyViewPreset, setGroundGrid, + setHighlightLines, ready, failed, };