Nordstern-3D: Auswahl-Highlight (orange Outline, immer sichtbar)
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.
This commit is contained in:
+103
-34
@@ -146,6 +146,10 @@ pub struct Renderer {
|
|||||||
/// Teilt sich das `Globals`-Uniform/Bind-Group (nur View-Projektion) mit der
|
/// Teilt sich das `Globals`-Uniform/Bind-Group (nur View-Projektion) mit der
|
||||||
/// Mesh-Pipeline und rendert in denselben Pass/Tiefenpuffer.
|
/// Mesh-Pipeline und rendert in denselben Pass/Tiefenpuffer.
|
||||||
grid_pipeline: wgpu::RenderPipeline,
|
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,
|
bind_group: wgpu::BindGroup,
|
||||||
uniform: wgpu::Buffer,
|
uniform: wgpu::Buffer,
|
||||||
mesh: Option<MeshBuffers>,
|
mesh: Option<MeshBuffers>,
|
||||||
@@ -156,6 +160,11 @@ pub struct Renderer {
|
|||||||
/// Bodengitter-Vertices (None = nicht erzeugt). Wird nur gezeichnet, wenn
|
/// Bodengitter-Vertices (None = nicht erzeugt). Wird nur gezeichnet, wenn
|
||||||
/// zusaetzlich `grid_visible` gesetzt ist.
|
/// zusaetzlich `grid_visible` gesetzt ist.
|
||||||
grid: Option<LineBuffers>,
|
grid: Option<LineBuffers>,
|
||||||
|
/// 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<LineBuffers>,
|
||||||
/// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false —
|
/// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false —
|
||||||
/// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter.
|
/// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter.
|
||||||
grid_visible: bool,
|
grid_visible: bool,
|
||||||
@@ -296,46 +305,72 @@ impl Renderer {
|
|||||||
step_mode: wgpu::VertexStepMode::Vertex,
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3],
|
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3],
|
||||||
};
|
};
|
||||||
let grid_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
// Linien-Pipeline-Fabrik: identische Beschreibung (LineList, GRID_WGSL,
|
||||||
label: Some("grid.pipeline"),
|
// gemeinsame Bind-Group/Farbziel/MSAA) fuer das Bodengitter/die Kanten
|
||||||
layout: Some(&pipeline_layout),
|
// (tiefengetestet) UND die Auswahl-Highlight-Linien (ohne Tiefentest, siehe
|
||||||
vertex: wgpu::VertexState {
|
// unten). Nur der `depth_stencil`-Zustand unterscheidet sich.
|
||||||
module: &grid_module,
|
let make_line_pipeline = |label: &str, depth: wgpu::DepthStencilState| {
|
||||||
entry_point: Some("vs_main"),
|
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
buffers: &[grid_vertex_layout],
|
label: Some(label),
|
||||||
compilation_options: Default::default(),
|
layout: Some(&pipeline_layout),
|
||||||
},
|
vertex: wgpu::VertexState {
|
||||||
fragment: Some(wgpu::FragmentState {
|
module: &grid_module,
|
||||||
module: &grid_module,
|
entry_point: Some("vs_main"),
|
||||||
entry_point: Some("fs_main"),
|
buffers: &[grid_vertex_layout.clone()],
|
||||||
targets: &[Some(wgpu::ColorTargetState {
|
compilation_options: Default::default(),
|
||||||
format: color_format,
|
},
|
||||||
blend: Some(wgpu::BlendState::REPLACE),
|
fragment: Some(wgpu::FragmentState {
|
||||||
write_mask: wgpu::ColorWrites::ALL,
|
module: &grid_module,
|
||||||
})],
|
entry_point: Some("fs_main"),
|
||||||
compilation_options: Default::default(),
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
}),
|
format: color_format,
|
||||||
primitive: wgpu::PrimitiveState {
|
blend: Some(wgpu::BlendState::REPLACE),
|
||||||
topology: wgpu::PrimitiveTopology::LineList,
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
// Linien haben keine Vorder-/Rueckseite -> kein Culling.
|
})],
|
||||||
cull_mode: None,
|
compilation_options: Default::default(),
|
||||||
..Default::default()
|
}),
|
||||||
},
|
primitive: wgpu::PrimitiveState {
|
||||||
depth_stencil: Some(wgpu::DepthStencilState {
|
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,
|
format: DEPTH_FORMAT,
|
||||||
depth_write_enabled: Some(true),
|
depth_write_enabled: Some(true),
|
||||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||||
stencil: wgpu::StencilState::default(),
|
stencil: wgpu::StencilState::default(),
|
||||||
bias: wgpu::DepthBiasState::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 {
|
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("globals.buffer"),
|
label: Some("globals.buffer"),
|
||||||
@@ -356,11 +391,13 @@ impl Renderer {
|
|||||||
pipeline,
|
pipeline,
|
||||||
hidden_face_pipeline,
|
hidden_face_pipeline,
|
||||||
grid_pipeline,
|
grid_pipeline,
|
||||||
|
highlight_pipeline,
|
||||||
bind_group,
|
bind_group,
|
||||||
uniform,
|
uniform,
|
||||||
mesh: None,
|
mesh: None,
|
||||||
edges: None,
|
edges: None,
|
||||||
grid: None,
|
grid: None,
|
||||||
|
highlight: None,
|
||||||
grid_visible: false,
|
grid_visible: false,
|
||||||
style: RenderStyle::Shaded,
|
style: RenderStyle::Shaded,
|
||||||
depth: None,
|
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.
|
/// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert.
|
||||||
fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) {
|
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);
|
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);
|
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()));
|
queue.submit(std::iter::once(encoder.finish()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,6 +366,18 @@ impl WebModelRenderer {
|
|||||||
Ok(())
|
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).
|
/// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
|
||||||
pub fn resize(&mut self, width: u32, height: u32) {
|
pub fn resize(&mut self, width: u32, height: u32) {
|
||||||
let (w, h) = (width.max(1), height.max(1));
|
let (w, h) = (width.max(1), height.max(1));
|
||||||
|
|||||||
+28
@@ -42,6 +42,7 @@ import type { CategoryDisplayResolver } from "./plan/generatePlan";
|
|||||||
import { computeSection } from "./plan/toSection";
|
import { computeSection } from "./plan/toSection";
|
||||||
import type { SectionOutput } from "./plan/toSection";
|
import type { SectionOutput } from "./plan/toSection";
|
||||||
import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync";
|
import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync";
|
||||||
|
import { selectionHighlightLines } from "./plan/toWalls3d";
|
||||||
import { parseDxf } from "./io/dxfParser";
|
import { parseDxf } from "./io/dxfParser";
|
||||||
import type { DxfImportResult } from "./io/dxfParser";
|
import type { DxfImportResult } from "./io/dxfParser";
|
||||||
import { setDxfImportTrigger } from "./io/dxfImportHook";
|
import { setDxfImportTrigger } from "./io/dxfImportHook";
|
||||||
@@ -193,6 +194,13 @@ type EditorState =
|
|||||||
* getipptes „wall"/„wand" münden in denselben Befehl; der Legacy-Wand-Pfad ist
|
* getipptes „wall"/„wand" münden in denselben Befehl; der Legacy-Wand-Pfad ist
|
||||||
* damit deaktiviert (siehe toolHandlers/onSelectTool).
|
* 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<Record<ToolId, string>> = {
|
const TOOL_COMMAND: Partial<Record<ToolId, string>> = {
|
||||||
wall: "wall",
|
wall: "wall",
|
||||||
ceiling: "ceiling",
|
ceiling: "ceiling",
|
||||||
@@ -4240,6 +4248,25 @@ function Content({
|
|||||||
onEdit3dWallTop: (wallId: string, topZ: number) => void;
|
onEdit3dWallTop: (wallId: string, topZ: number) => void;
|
||||||
onEdit3dEnd: () => 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") {
|
if (level.kind === "section" || level.kind === "elevation") {
|
||||||
return (
|
return (
|
||||||
<main className="content">
|
<main className="content">
|
||||||
@@ -4367,6 +4394,7 @@ function Content({
|
|||||||
onSelectStair={onViewportSelectStair}
|
onSelectStair={onViewportSelectStair}
|
||||||
onContextMenu={onViewportContextMenu}
|
onContextMenu={onViewportContextMenu}
|
||||||
onPick3d={onViewport3dPick}
|
onPick3d={onViewport3dPick}
|
||||||
|
highlightLines={highlightLines}
|
||||||
commandActive={commandActive}
|
commandActive={commandActive}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
onWorkplanePoint={onWorkplanePoint}
|
onWorkplanePoint={onWorkplanePoint}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,8 @@ import { openingInterval, openingVerticalExtent } from "../geometry/opening";
|
|||||||
|
|
||||||
export type RVec2 = [number, number];
|
export type RVec2 = [number, number];
|
||||||
export type RRgb = [number, 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). */
|
/** Eine aufgelöste Wandschicht: Dicke + 3D-Albedo-Farbe (aus dem Component). */
|
||||||
export interface RLayer {
|
export interface RLayer {
|
||||||
@@ -311,3 +313,95 @@ export function projectToModel3d(project: Project): RModel3d {
|
|||||||
export function pickGeometry(project: Project): { walls: RWall[]; slabs: RSlab[] } {
|
export function pickGeometry(project: Project): { walls: RWall[]; slabs: RSlab[] } {
|
||||||
return { walls: projectToWalls3d(project), slabs: emitSlabs(project) };
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,9 +128,13 @@ export function Viewport3D(
|
|||||||
/** Links-Klick-Auswahl im WASM-Viewport (TS-Raycast) — wirkt NUR im WASM-Pfad;
|
/** Links-Klick-Auswahl im WASM-Viewport (TS-Raycast) — wirkt NUR im WASM-Pfad;
|
||||||
* die three.js-Sicht nutzt weiter `onSelectWall`/`onSelectCeiling`. */
|
* die three.js-Sicht nutzt weiter `onSelectWall`/`onSelectCeiling`. */
|
||||||
onPick3d?: (hit: Pick3dHit, additive: boolean) => void;
|
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) {
|
if (WASM_ENGINE_ACTIVE) {
|
||||||
return (
|
return (
|
||||||
<Wasm3DViewport
|
<Wasm3DViewport
|
||||||
@@ -141,6 +145,7 @@ export function Viewport3D(
|
|||||||
groundElevation={props.gridElevation}
|
groundElevation={props.gridElevation}
|
||||||
onToggleGrid={onToggleGrid}
|
onToggleGrid={onToggleGrid}
|
||||||
onPick3d={onPick3d}
|
onPick3d={onPick3d}
|
||||||
|
highlightLines={highlightLines}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export function Wasm3DViewport({
|
|||||||
groundElevation = 0,
|
groundElevation = 0,
|
||||||
onToggleGrid,
|
onToggleGrid,
|
||||||
onPick3d,
|
onPick3d,
|
||||||
|
highlightLines = null,
|
||||||
}: {
|
}: {
|
||||||
project: Project;
|
project: Project;
|
||||||
/** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */
|
/** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */
|
||||||
@@ -199,6 +200,13 @@ export function Wasm3DViewport({
|
|||||||
* Aufrufer (App) setzt daraus die Store-Selektion; ohne Angabe passiert nichts.
|
* Aufrufer (App) setzt daraus die Store-Selektion; ohne Angabe passiert nichts.
|
||||||
*/
|
*/
|
||||||
onPick3d?: (hit: Pick3dHit, additive: boolean) => void;
|
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<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
// Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte)
|
// Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte)
|
||||||
@@ -212,6 +220,7 @@ export function Wasm3DViewport({
|
|||||||
setRenderStyle,
|
setRenderStyle,
|
||||||
applyViewPreset,
|
applyViewPreset,
|
||||||
setGroundGrid,
|
setGroundGrid,
|
||||||
|
setHighlightLines,
|
||||||
ready,
|
ready,
|
||||||
failed,
|
failed,
|
||||||
} = useWasm3dRenderer(canvasRef, true);
|
} = useWasm3dRenderer(canvasRef, true);
|
||||||
@@ -268,6 +277,17 @@ export function Wasm3DViewport({
|
|||||||
redrawRef.current();
|
redrawRef.current();
|
||||||
}, [ready, gridVisible, groundElevation, setGroundGrid]);
|
}, [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
|
// Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu
|
||||||
// zeichnen (die Surface-Anpassung übernimmt der Hook im render()).
|
// zeichnen (die Surface-Anpassung übernimmt der Hook im render()).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ interface WebModelRendererLike {
|
|||||||
extent_m: number,
|
extent_m: number,
|
||||||
spacing_m: number,
|
spacing_m: number,
|
||||||
): void;
|
): 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(
|
set_camera(
|
||||||
eyeX: number,
|
eyeX: number,
|
||||||
eyeY: 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
|
* Kamera-Praeset anwenden (`front`/`top`/`side`/`iso`/`perspective`, wie
|
||||||
* `View3d` in TopBar.tsx). Delegiert die formatfuellende Ziel-/Distanz-
|
* `View3d` in TopBar.tsx). Delegiert die formatfuellende Ziel-/Distanz-
|
||||||
@@ -250,6 +274,7 @@ export function useWasm3dRenderer(
|
|||||||
setRenderStyle,
|
setRenderStyle,
|
||||||
applyViewPreset,
|
applyViewPreset,
|
||||||
setGroundGrid,
|
setGroundGrid,
|
||||||
|
setHighlightLines,
|
||||||
ready,
|
ready,
|
||||||
failed,
|
failed,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user