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
+173 -3
View File
@@ -384,6 +384,7 @@ dependencies = [
name = "cad-tauri"
version = "0.1.0"
dependencies = [
"fs4",
"geometry",
"pollster",
"render2d",
@@ -392,6 +393,8 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"wgpu",
"winit",
]
@@ -982,7 +985,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading 0.7.4",
"libloading 0.8.9",
]
[[package]]
@@ -1304,6 +1307,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs4"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8c6b3bd49c37d2aa3f3f2220233b29a7cd23f79d1fe70e5337d25fb390793de"
dependencies = [
"rustix 0.38.44",
"windows-sys 0.52.0",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -1690,7 +1703,7 @@ dependencies = [
"log",
"presser",
"thiserror 2.0.18",
"windows 0.61.3",
"windows 0.62.2",
]
[[package]]
@@ -2892,6 +2905,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2-core-foundation",
]
@@ -3705,6 +3719,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2 0.6.2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
@@ -4538,6 +4576,64 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation 0.3.2",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -5946,6 +6042,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -5979,13 +6084,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -6025,6 +6147,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -6037,6 +6165,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -6049,12 +6183,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -6067,6 +6213,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -6079,6 +6231,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -6091,6 +6249,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -6103,6 +6267,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
+7 -1
View File
@@ -8,7 +8,7 @@ members = ["."]
# wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat).
# kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts),
# per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des cad-tauri-Workspace.
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport"]
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"]
[package]
name = "cad-tauri"
@@ -37,9 +37,15 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
geometry = { path = "geometry" }
# OS-Advisory-Lock (gepflegter fs2-Nachfolger) fuer die Projektdatei-Lock-Datei
# (src/lock.rs): exklusiver Lock auf einer Sidecar-Datei, faellt automatisch
# beim Prozessende/Absturz weg (kein manuelles Aufraeumen noetig).
fs4 = { version = "0.9", default-features = false, features = ["sync"] }
# --- M2-Spike (nur mit Feature "native2d") -----------------------------------
# render2d ist eine eigenstaendige Crate (eigener leerer [workspace]); wir binden
+3 -1
View File
@@ -11,6 +11,8 @@
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-close",
"core:window:allow-start-dragging"
"core:window:allow-start-dragging",
"dialog:allow-save",
"fs:allow-write-text-file"
]
}
+394
View File
@@ -2470,6 +2470,223 @@ pub fn room_from_point_inside_faces(
best
}
// --- Oeffnungs-Geometrie (Port von src/geometry/opening.ts, Slice 4) --------
// `Wall`/`Opening`/`Project`-Kopplung durch Vec2/number-Parameter ersetzt
// (PORT_PLAN §1 "Teilweise im Scope"): `thickness`/`refOffset` werden hier als
// bereits aufgeloeste Zahlen uebergeben (TS: `wallTypeThickness(getWallType(...))`
// bzw. `wallReferenceOffset(wall, total)` gegen ein `Project`). Nur
// `openingVerticalExtent` bleibt TS (haengt an `Project.drawingLevels` via
// `wallVerticalExtent`) und wird hier NICHT portiert.
//
// `DEG` (Grad→Radiant, Port des lokalen `DEG` in opening.ts) ist bereits als
// modul-weite Konstante aus dem stair-Slice vorhanden (identischer Wert).
/// Punkt entlang der Strecke p1→p2 im Abstand d vom Start (Port von `along`
/// aus src/model/geometry.ts).
pub fn along(p1: Vec2, p2: Vec2, d: f64) -> Vec2 {
add(p1, scale(normalize(sub(p2, p1)), d))
}
/// Achslaenge einer Wand (Port von `wallAxisLength`; Wand → start/end).
pub fn wall_axis_length(start: Vec2, end: Vec2) -> f64 {
(end.x - start.x).hypot(end.y - start.y)
}
/// Oeffnungs-Intervall entlang der Achse (Port von `openingInterval`).
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Interval {
pub from: f64,
pub to: f64,
}
pub fn opening_interval(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Interval> {
let axis = wall_axis_length(start, end);
let from = position.min(axis).max(0.0);
let to = (position + width).min(axis).max(from);
if to - from < 1e-4 {
return None; // entartet: Breite <= 0 oder vollstaendig ausserhalb der Achse
}
Some(Interval { from, to })
}
/// Einheits-Laufrichtung (u) und linke Normale (n) der Wandachse (Port von
/// `wallAxisFrame`).
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
pub struct AxisFrame {
pub u: Vec2,
pub n: Vec2,
}
pub fn wall_axis_frame(start: Vec2, end: Vec2) -> AxisFrame {
let u = normalize(sub(end, start));
AxisFrame { u, n: left_normal(u) }
}
/// Die beiden Pfosten-Punkte einer Oeffnung im Grundriss (Port von `openingJambs`).
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Jambs {
pub jamb_start: Vec2,
pub jamb_end: Vec2,
}
pub fn opening_jambs(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Jambs> {
let iv = opening_interval(start, end, position, width)?;
Some(Jambs {
jamb_start: along(start, end, iv.from),
jamb_end: along(start, end, iv.to),
})
}
/// Luecken-Rechteck-Ecken ueber die volle Wanddicke (Port von `openingGapQuad`).
/// Reihenfolge: jambStart(+n/2) → jambEnd(+n/2) → jambEnd(n/2) → jambStart(n/2).
pub fn opening_gap_quad(
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
) -> Option<Vec<Vec2>> {
let jambs = opening_jambs(start, end, position, width)?;
let n = wall_axis_frame(start, end).n;
let outer = ref_offset + thickness / 2.0;
let inner = ref_offset - thickness / 2.0;
Some(vec![
add(jambs.jamb_start, scale(n, outer)),
add(jambs.jamb_end, scale(n, outer)),
add(jambs.jamb_end, scale(n, inner)),
add(jambs.jamb_start, scale(n, inner)),
])
}
/// Mittelpunkt einer Oeffnung im Grundriss (Port von `openingCenter`).
pub fn opening_center(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Vec2> {
let iv = opening_interval(start, end, position, width)?;
Some(along(start, end, (iv.from + iv.to) / 2.0))
}
/// Aufgeloeste Tuer-Symbol-Geometrie im Grundriss (Port von `DoorSymbol`).
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DoorSymbol {
pub hinge: Vec2,
pub open_end: Vec2,
pub closed_end: Vec2,
pub radius: f64,
pub jamb_start: Vec2,
pub jamb_end: Vec2,
pub normal: Vec2,
}
/// Port von `doorSymbol`. `swing`/`opening_dir`/`hinge_side` folgen der TS-
/// `??`-Fallback-Semantik (`None` → TS-Default); `swing_angle` `None` → 90°.
pub fn door_symbol(
start: Vec2,
end: Vec2,
position: f64,
width: f64,
swing: Option<&str>,
opening_dir: Option<&str>,
hinge_side: Option<&str>,
swing_angle: Option<f64>,
) -> Option<DoorSymbol> {
let jambs = opening_jambs(start, end, position, width)?;
let frame = wall_axis_frame(start, end);
let (jamb_start, jamb_end) = (jambs.jamb_start, jambs.jamb_end);
// TS berechnet die Breite NEU aus den geklemmten Pfosten (nicht das rohe
// `o.width`) — bewusst repliziert.
let w = (jamb_end.x - jamb_start.x).hypot(jamb_end.y - jamb_start.y);
let swing_sign = if swing.unwrap_or("left") == "left" { 1.0 } else { -1.0 };
let dir_sign = if opening_dir.unwrap_or("in") == "in" { 1.0 } else { -1.0 };
let swing_dir = scale(frame.n, swing_sign * dir_sign);
let hinge_at_start = hinge_side.unwrap_or("start") == "start";
let hinge = if hinge_at_start { jamb_start } else { jamb_end };
let closed_dir = if hinge_at_start { frame.u } else { scale(frame.u, -1.0) };
let angle = swing_angle.unwrap_or(90.0) * DEG;
let open_dir = Vec2 {
x: closed_dir.x * angle.cos() + swing_dir.x * angle.sin(),
y: closed_dir.y * angle.cos() + swing_dir.y * angle.sin(),
};
Some(DoorSymbol {
hinge,
open_end: add(hinge, scale(open_dir, w)),
closed_end: add(hinge, scale(closed_dir, w)),
radius: w,
jamb_start,
jamb_end,
normal: frame.n,
})
}
/// Aufgeloeste Fenster-Linien im Grundriss (Port von `WindowSymbol`).
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct WindowSymbol {
pub frame: Vec<Vec2>,
pub glass_lines: Vec<(Vec2, Vec2)>,
pub mullion_lines: Vec<(Vec2, Vec2)>,
}
/// Port von `windowSymbol`. `thickness`/`ref_offset` sind bereits aufgeloeste
/// Werte (siehe Modul-Kommentar); `wing_count` `None` → TS-Default `1`.
pub fn window_symbol(
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
glass_count: f64,
wing_count: Option<f64>,
) -> Option<WindowSymbol> {
let jambs = opening_jambs(start, end, position, width)?;
let (jamb_start, jamb_end) = (jambs.jamb_start, jambs.jamb_end);
let n = wall_axis_frame(start, end).n;
let outer = ref_offset + thickness / 2.0;
let inner = ref_offset - thickness / 2.0;
let frame_quad = vec![
add(jamb_start, scale(n, outer)),
add(jamb_end, scale(n, outer)),
add(jamb_end, scale(n, inner)),
add(jamb_start, scale(n, inner)),
];
let mut glass_lines: Vec<(Vec2, Vec2)> = Vec::new();
let count = glass_count.max(1.0);
let mut i: i64 = 0;
while (i as f64) < count {
let spread = thickness / 8.0;
let off = if count == 1.0 {
ref_offset
} else {
ref_offset + ((i as f64) - (count - 1.0) / 2.0) * spread * 2.0
};
glass_lines.push((add(jamb_start, scale(n, off)), add(jamb_end, scale(n, off))));
i += 1;
}
let mut mullion_lines: Vec<(Vec2, Vec2)> = Vec::new();
let wings = wing_count.unwrap_or(1.0).round().min(4.0).max(1.0);
if wings > 1.0 {
let post_depth = thickness * 0.6;
let p_outer = ref_offset + post_depth / 2.0;
let p_inner = ref_offset - post_depth / 2.0;
let wings_n = wings as i64;
for k in 1..wings_n {
let t = k as f64 / wings;
let on_axis = add(jamb_start, scale(sub(jamb_end, jamb_start), t));
mullion_lines.push((add(on_axis, scale(n, p_outer)), add(on_axis, scale(n, p_inner))));
}
}
Some(WindowSymbol { frame: frame_quad, glass_lines, mullion_lines })
}
// --- Batch-Fassaden: roomArea / ceiling (Slice 1) ----------------------------
#[cfg(feature = "web")]
@@ -2741,6 +2958,183 @@ pub fn rb_point_in_polygon_batch_json(input_json: &str) -> Result<String, wasm_b
to_js(&out)
}
// --- Batch-Fassaden: opening (Slice 4) ---------------------------------------
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WallAxisLengthQuery {
start: Vec2,
end: Vec2,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn wall_axis_length_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<WallAxisLengthQuery> = from_js(input_json)?;
let out: Vec<f64> = qs.iter().map(|q| wall_axis_length(q.start, q.end)).collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpeningQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_interval_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningQuery> = from_js(input_json)?;
let out: Vec<Option<Interval>> = qs
.iter()
.map(|q| opening_interval(q.start, q.end, q.position, q.width))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn wall_axis_frame_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<WallAxisLengthQuery> = from_js(input_json)?;
let out: Vec<AxisFrame> = qs.iter().map(|q| wall_axis_frame(q.start, q.end)).collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_jambs_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningQuery> = from_js(input_json)?;
let out: Vec<Option<Jambs>> = qs
.iter()
.map(|q| opening_jambs(q.start, q.end, q.position, q.width))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpeningGapQuadQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_gap_quad_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningGapQuadQuery> = from_js(input_json)?;
let out: Vec<Option<Vec<Vec2>>> = qs
.iter()
.map(|q| opening_gap_quad(q.start, q.end, q.position, q.width, q.thickness, q.ref_offset))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_center_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningQuery> = from_js(input_json)?;
let out: Vec<Option<Vec2>> = qs
.iter()
.map(|q| opening_center(q.start, q.end, q.position, q.width))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DoorSymbolQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
#[serde(default)]
swing: Option<String>,
#[serde(default)]
opening_dir: Option<String>,
#[serde(default)]
hinge: Option<String>,
#[serde(default)]
swing_angle: Option<f64>,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn door_symbol_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<DoorSymbolQuery> = from_js(input_json)?;
let out: Vec<Option<DoorSymbol>> = qs
.iter()
.map(|q| {
door_symbol(
q.start,
q.end,
q.position,
q.width,
q.swing.as_deref(),
q.opening_dir.as_deref(),
q.hinge.as_deref(),
q.swing_angle,
)
})
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WindowSymbolQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
glass_count: f64,
#[serde(default)]
wing_count: Option<f64>,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn window_symbol_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<WindowSymbolQuery> = from_js(input_json)?;
let out: Vec<Option<WindowSymbol>> = qs
.iter()
.map(|q| {
window_symbol(
q.start,
q.end,
q.position,
q.width,
q.thickness,
q.ref_offset,
q.glass_count,
q.wing_count,
)
})
.collect();
to_js(&out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -33,6 +33,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
}],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
WallInput {
start: [0.0, 3.0],
@@ -44,6 +47,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
// also groessere Tiefe von der Betrachter-Ebene aus), genau im
@@ -60,6 +66,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
];
let slabs = vec![SlabInput {
@@ -67,6 +76,8 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
z_bottom: -0.2,
z_top: 0.0,
color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
}];
(walls, slabs)
}
+3
View File
@@ -40,6 +40,9 @@ fn demo_walls() -> Vec<WallInput> {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
// Raum 6 x 4 m.
vec![
+278 -27
View File
@@ -15,12 +15,17 @@ use wgpu::util::DeviceExt;
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
use crate::math::{view_projection, Mat4};
use crate::mesh::{build_scene_mesh, build_walls_mesh, textured_from_mesh};
use crate::mesh::{
build_scene_mesh, build_scene_mesh_textured, build_walls_mesh, build_walls_mesh_textured,
};
use crate::section::SectionPlane;
use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX};
use crate::section_fill::{
build_cut_cap_lines, build_cut_caps, CAP_FLOATS_PER_VERTEX, CUT_LINE_FLOATS_PER_VERTEX,
};
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL};
use crate::types::{
Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
Camera, Mesh, MeshInput, SlabInput, TexturedMesh, WallInput, FLOATS_PER_VERTEX,
TEXTURED_FLOATS_PER_VERTEX,
};
/// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/
@@ -142,6 +147,10 @@ struct MeshBuffers {
/// Puffer neben `MeshBuffers` — der Alt-Pfad bleibt bitgleich unangetastet.
struct TexturedMeshBuffers {
vbo: wgpu::Buffer,
/// Zweiter Vertex-Buffer: EIN f32 (Material-Textur-Ebene) je Vertex,
/// @location(3) im `MESH_TEXTURED_WGSL`. Immer gesetzt (Fallback: `-1` je
/// Vertex), damit das Pipeline-Layout stets zwei Vertex-Buffer erwartet.
layer_vbo: wgpu::Buffer,
ibo: wgpu::Buffer,
index_count: u32,
}
@@ -225,21 +234,39 @@ pub struct Renderer {
/// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene
/// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None).
cap_pipeline: wgpu::RenderPipeline,
/// Schnitt-Umrisslinien-Pipeline (Cut-Cap-Kanten, P2): TriangleList von
/// Ribbon-Quads (`section_fill::build_cut_cap_lines`), Vertex-Layout [pos vec3,
/// color vec3] wie die Grid/Highlight-LineList — teilt darum das `grid_module`
/// (GRID_WGSL). KEIN Tiefentest (`depth_compare: Always`, kein Write) wie das
/// Highlight: die Fugenlinien liegen (leicht zur Kamera versetzt) sichtbar auf
/// den Cut-Caps. Sie werden NICHT an der Schnittebene weggeclippt (sie SIND die
/// Schnittflaeche) — der Kamera-Lift in `build_cut_cap_lines` haelt sie vor der
/// `depth > 0`-Kappung von GRID_WGSL.
cut_line_pipeline: wgpu::RenderPipeline,
/// Texturierte Wand-Pipeline (`RenderStyle::Textured`, TriangleList,
/// MESH_TEXTURED_WGSL, Vertex-Layout [pos vec3, normal vec3, uv vec2]). Bindet
/// group 0 (Globals) UND group 1 (Bild-Textur). Depth-/MSAA-/Farbziel
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
/// Shader/Layout unterscheiden sich.
textured_pipeline: wgpu::RenderPipeline,
/// Bind-Group der prozeduralen Test-Textur (group 1): Texture-View + Sampler.
/// Konstant ueber die Lebensdauer des Renderers (eine Textur fuer alle Waende).
/// Bind-Group des Material-Textur-Arrays (group 1): Array-View + Sampler.
/// Wird bei `set_material_textures` neu gebaut (mehr Ebenen); bis dahin haelt
/// sie das 1-Ebenen-Array mit dem Fallback-Schachbrett.
texture_bind_group: wgpu::BindGroup,
/// Die (prozedurale) Textur selbst — nur festgehalten, um ihre Texel beim
/// ersten `render` einmalig hochzuladen (`texture_uploaded`), da `new` keine
/// Queue bekommt.
/// Das Textur-Array selbst. Start: 1 Ebene (Schachbrett). `set_material_textures`
/// ersetzt es durch ein Array `[Schachbrett, Material0, Material1, …]`.
texture: wgpu::Texture,
/// Ob die Textur-Texel schon per `queue.write_texture` geladen wurden. Beim
/// ersten `render` einmalig gesetzt (lazy Upload).
/// Bind-Group-Layout (group 1), festgehalten fuer den Neubau der Bind-Group
/// beim Hochladen von Material-Texturen.
texture_bind_group_layout: wgpu::BindGroupLayout,
/// Sampler (Repeat/Linear), festgehalten fuer den Bind-Group-Neubau.
texture_sampler: wgpu::Sampler,
/// Anzahl gueltiger Ebenen im Textur-Array (>= 1). 1 = nur Schachbrett; nach
/// `set_material_textures` = 1 + Anzahl Material-Karten. Wandert je Frame nach
/// `mode.z` (Klemmung der Ebenen-Auswahl im Shader).
material_layer_count: u32,
/// Ob die Schachbrett-Texel (Ebene 0) schon hochgeladen wurden. Beim ersten
/// `render` einmalig gesetzt (lazy Upload, weil `new` keine Queue bekommt).
texture_uploaded: bool,
bind_group: wgpu::BindGroup,
uniform: wgpu::Buffer,
@@ -264,6 +291,10 @@ pub struct Renderer {
/// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht,
/// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight.
caps: Option<CapBuffers>,
/// Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) als Ribbon-TriangleList
/// (`[pos, color]`). None = kein Schnitt aktiv / keine Kanten. Ueber
/// `set_cut_lines` gesetzt/geloescht, gezeichnet NACH den Caps.
cut_lines: Option<LineBuffers>,
/// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false —
/// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter.
grid_visible: bool,
@@ -471,6 +502,52 @@ impl Renderer {
},
);
// Schnitt-Umrisslinien-Pipeline (Cut-Cap-Kanten, P2): dasselbe schlanke
// LineList-Vertex-Layout [pos vec3, color vec3] und `grid_module` (GRID_WGSL)
// wie Grid/Highlight, aber als TriangleList (die Kanten sind Ribbon-Quads,
// damit die Strichstaerke als Welt-Breite durchkommt). Depth wie Highlight
// (Always, kein Write) -> immer sichtbar auf den Caps.
let cut_line_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("cut_line.pipeline"),
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::TriangleList,
// Ribbons sind flach in der Schnittebene, aus beiden Seiten sichtbar.
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
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,
});
// Schnittflaechen-Kappen-Pipeline: eigener Shader (CAP_WGSL), eigenes
// schlankes Vertex-Layout [pos vec3, uv vec2], TriangleList. Dieselbe
// Bind-Group (Globals) und dasselbe Depth-/MSAA-/Farbziel wie oben.
@@ -478,10 +555,14 @@ impl Renderer {
label: Some("cap.wgsl"),
source: wgpu::ShaderSource::Wgsl(CAP_WGSL.into()),
});
// Vertex-Layout: [pos vec3, uv vec2, hatch vec4(pattern, angle_rad, scale,
// line_weight_mm)], stride 9*4 (`CAP_FLOATS_PER_VERTEX`). Das dritte Attribut
// treibt die musterspezifische Schraffur im Cap-Fragment-Shader (CAP_WGSL);
// `line_weight_mm` setzt die Musterlinien-Breite je Schraffur.
let cap_vertex_layout = wgpu::VertexBufferLayout {
array_stride: (CAP_FLOATS_PER_VERTEX * 4) as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2],
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4],
};
let cap_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("cap.pipeline"),
@@ -547,7 +628,7 @@ impl Renderer {
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
@@ -579,6 +660,14 @@ impl Renderer {
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2],
};
// ZWEITER Vertex-Buffer: die Material-Textur-Ebene je Vertex (@location(3),
// 1 f32). Separat gehalten, damit das Haupt-`[pos,normal,uv]`-Layout (und
// die Geometrie-Paritaet zum Shaded-Pfad) unveraendert bleibt.
let textured_layer_layout = wgpu::VertexBufferLayout {
array_stride: 4,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![3 => Float32],
};
// WICHTIG: Depth-Format, MSAA (`SAMPLE_COUNT`), Color-Target-Format,
// Topologie und Culling BITIDENTISCH zur Haupt-`pipeline` (make_mesh_pipeline)
// — die texturierten Waende laufen im GLEICHEN Render-Pass, deshalb muss der
@@ -590,7 +679,7 @@ impl Renderer {
vertex: wgpu::VertexState {
module: &textured_module,
entry_point: Some("vs_main"),
buffers: &[textured_vertex_layout],
buffers: &[textured_vertex_layout, textured_layer_layout],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
@@ -632,8 +721,10 @@ impl Renderer {
// `render` (siehe `texture_uploaded`), weil `new` bewusst KEINE Queue
// bekommt — die Signatur bleibt fuer die bestehenden Aufrufer (web.rs,
// spike3d) unveraendert.
// Textur-ARRAY (D2Array). Start: EINE Ebene (Fallback-Schachbrett).
// `set_material_textures` ersetzt das Array spaeter durch mehrere Ebenen.
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("checker.texture"),
label: Some("materials.texture_array"),
size: wgpu::Extent3d {
width: TEXTURE_SIZE,
height: TEXTURE_SIZE,
@@ -646,10 +737,13 @@ impl Renderer {
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
// Repeat (weltmassstaebliches UV kachelt) + Linear (weiche Filterung).
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("checker.sampler"),
let texture_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("materials.sampler"),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
address_mode_w: wgpu::AddressMode::Repeat,
@@ -659,7 +753,7 @@ impl Renderer {
..Default::default()
});
let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("texture.bind"),
label: Some("materials.bind"),
layout: &texture_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
@@ -668,7 +762,7 @@ impl Renderer {
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
resource: wgpu::BindingResource::Sampler(&texture_sampler),
},
],
});
@@ -694,9 +788,13 @@ impl Renderer {
grid_pipeline,
highlight_pipeline,
cap_pipeline,
cut_line_pipeline,
textured_pipeline,
texture_bind_group,
texture,
texture_bind_group_layout,
texture_sampler,
material_layer_count: 1,
texture_uploaded: false,
bind_group,
uniform,
@@ -706,6 +804,7 @@ impl Renderer {
grid: None,
highlight: None,
caps: None,
cut_lines: None,
grid_visible: false,
style: RenderStyle::Shaded,
depth: None,
@@ -724,7 +823,7 @@ impl Renderer {
/// Erzeugt das Mesh aus geflachten Waenden und laedt die Puffer hoch.
pub fn upload_walls(&mut self, device: &wgpu::Device, walls: &[WallInput]) {
self.upload_mesh(device, build_walls_mesh(walls));
self.upload_mesh(device, build_walls_mesh(walls), build_walls_mesh_textured(walls));
}
/// Erzeugt das Mesh aus Waenden, Deckenplatten UND rohen Kontext-Meshes
@@ -736,13 +835,17 @@ impl Renderer {
slabs: &[SlabInput],
meshes: &[MeshInput],
) {
self.upload_mesh(device, build_scene_mesh(walls, slabs, meshes));
self.upload_mesh(
device,
build_scene_mesh(walls, slabs, meshes),
build_scene_mesh_textured(walls, slabs, meshes),
);
}
/// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und
/// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer
/// die Stile wireframe/hidden — einmalig hier, nicht je Frame.
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) {
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh, tex_mesh: TexturedMesh) {
if mesh.indices.is_empty() {
self.mesh = None;
self.textured_mesh = None;
@@ -782,11 +885,10 @@ impl Renderer {
index_count: mesh.indices.len() as u32,
});
// Texturiertes Pendant AUS DEMSELBEN `Mesh` ableiten (planare Welt-UV je
// Vertex statt Farbe, siehe `textured_from_mesh`) und in einen zweiten
// Vertex-Puffer hochladen. So bleibt die Geometrie bitgleich zum Alt-Pfad,
// und der Stil `Textured` hat ohne Re-Meshing seine Puffer bereit.
let tex_mesh = textured_from_mesh(&mesh);
// Texturiertes Pendant (planare Welt-UV je Vertex + Material-Ebenen-Puffer,
// siehe `build_scene_mesh_textured`) in zwei Vertex-Puffer hochladen. Die
// Geometrie bleibt bitgleich zum Alt-Pfad; der Stil `Textured` hat ohne
// Re-Meshing seine Puffer bereit.
let tvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.textured.vbo"),
contents: bytemuck::cast_slice(&tex_mesh.verts),
@@ -797,8 +899,22 @@ impl Renderer {
contents: bytemuck::cast_slice(&tex_mesh.indices),
usage: wgpu::BufferUsages::INDEX,
});
// Material-Ebenen-Puffer (1 f32 je Vertex). Fehlt er (kein Material-Bau),
// mit `-1` je Vertex fuellen -> alle Vertices auf das Fallback-Schachbrett.
let vertex_count = tex_mesh.verts.len() / TEXTURED_FLOATS_PER_VERTEX;
let layer_data: Vec<f32> = if tex_mesh.layers.len() == vertex_count {
tex_mesh.layers
} else {
vec![-1.0; vertex_count]
};
let tlvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.textured.layer.vbo"),
contents: bytemuck::cast_slice(&layer_data),
usage: wgpu::BufferUsages::VERTEX,
});
self.textured_mesh = Some(TexturedMeshBuffers {
vbo: tvbo,
layer_vbo: tlvbo,
ibo: tibo,
index_count: tex_mesh.indices.len() as u32,
});
@@ -918,6 +1034,27 @@ impl Renderer {
});
}
/// Setzt/loescht die Schnitt-Umrisslinien (Cut-Cap-Kanten, P2). `verts` =
/// interleaved `[px,py,pz, r,g,b, ...]` (world-Meter + Farbe, TriangleList von
/// Ribbon-Quads, `CUT_LINE_FLOATS_PER_VERTEX`), wie `section_fill::
/// build_cut_cap_lines` liefert. Leeres Slice loescht die Linien. Wirkt erst
/// beim naechsten `render`.
pub fn set_cut_lines(&mut self, device: &wgpu::Device, verts: &[f32]) {
if verts.is_empty() {
self.cut_lines = None;
return;
}
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("cut_lines.vbo"),
contents: bytemuck::cast_slice(verts),
usage: wgpu::BufferUsages::VERTEX,
});
self.cut_lines = Some(LineBuffers {
vbo,
vertex_count: (verts.len() / CUT_LINE_FLOATS_PER_VERTEX) as u32,
});
}
/// Komfort: Schnittebene setzen UND die Cap-Geometrie aus den (gecachten)
/// Waenden/Decken neu bauen — bei `active == false` werden Uniform-Flag und
/// Caps geloescht. Buendelt `set_section_plane` + `build_cut_caps` +
@@ -936,11 +1073,110 @@ impl Renderer {
let plane = SectionPlane::new(point, normal);
let caps = build_cut_caps(&plane, walls, slabs);
self.set_cut_caps(device, &caps);
// P2: die Schnitt-Umrisslinien (Fugen-/Materiallinien) je ueberlebendem
// Cut-Cap-Rechteck aus DERSELBEN, dominanz-verschnittenen Rechteck-Menge.
let lines = build_cut_cap_lines(&plane, walls, slabs);
self.set_cut_lines(device, &lines);
} else {
self.set_cut_caps(device, &[]);
self.set_cut_lines(device, &[]);
}
}
/// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-ARRAY hoch.
/// `rgba` = dicht gepackte RGBA8-Bytes von `layer_count` Ebenen, jede exakt
/// `TEXTURE_SIZE × TEXTURE_SIZE` (der Aufrufer skaliert/dekodiert die Bilder
/// JS-seitig, siehe `web::set_material_textures`/`Wasm3DViewport`). Baut ein
/// neues Array `[Schachbrett (Ebene 0), Material0 (Ebene 1), …]`, laedt es hoch
/// und ersetzt die group-1-Bind-Group. Ein Wand-Band verweist per
/// `WallInput::material_index` (1-basiert) auf seine Ebene; Baender ohne
/// Material (`-1`) bleiben beim Schachbrett (Ebene 0). `layer_count == 0` (oder
/// leere/zu kleine Daten) setzt auf das reine Schachbrett-Array zurueck.
pub fn set_material_textures(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
rgba: &[u8],
layer_count: u32,
) {
let bytes_per_layer = (TEXTURE_SIZE * TEXTURE_SIZE * 4) as usize;
// Ungueltige/leere Eingabe -> reines Schachbrett-Array (1 Ebene).
let valid = layer_count > 0 && rgba.len() >= layer_count as usize * bytes_per_layer;
let material_layers = if valid { layer_count } else { 0 };
let total_layers = material_layers + 1; // Ebene 0 = Schachbrett
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("materials.texture_array"),
size: wgpu::Extent3d {
width: TEXTURE_SIZE,
height: TEXTURE_SIZE,
depth_or_array_layers: total_layers,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
// Ebene 0: Schachbrett-Fallback.
let checker = build_checker_texture();
let write_layer = |layer: u32, data: &[u8]| {
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: 0,
y: 0,
z: layer,
},
aspect: wgpu::TextureAspect::All,
},
data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * TEXTURE_SIZE),
rows_per_image: Some(TEXTURE_SIZE),
},
wgpu::Extent3d {
width: TEXTURE_SIZE,
height: TEXTURE_SIZE,
depth_or_array_layers: 1,
},
);
};
write_layer(0, &checker);
for i in 0..material_layers {
let start = i as usize * bytes_per_layer;
write_layer(i + 1, &rgba[start..start + bytes_per_layer]);
}
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
self.texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("materials.bind"),
layout: &self.texture_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.texture_sampler),
},
],
});
self.texture = texture;
self.material_layer_count = total_layers;
// Ebene 0 (Schachbrett) ist bereits geladen -> den Lazy-Upload ueberspringen.
self.texture_uploaded = true;
}
/// 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);
@@ -1019,6 +1255,8 @@ impl Renderer {
// `mode.x` fuer den Mesh-Shader aus dem aktuellen Stil ableiten (Material/
// Clay/Hidden-Flaechen). Wireframe zeichnet keine Flaechen -> Wert egal.
self.globals.mode[0] = self.style.mesh_mode();
// `mode.z` = Anzahl gueltiger Textur-Array-Ebenen (Klemmung im Textur-Shader).
self.globals.mode[2] = self.material_layer_count as f32;
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
// Prozedurale Schachbrett-Textur einmalig hochladen (lazy, weil `new` keine
@@ -1100,6 +1338,7 @@ impl Renderer {
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_bind_group(1, &self.texture_bind_group, &[]);
pass.set_vertex_buffer(0, t.vbo.slice(..));
pass.set_vertex_buffer(1, t.layer_vbo.slice(..));
pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..t.index_count, 0, 0..1);
}
@@ -1128,6 +1367,18 @@ impl Renderer {
pass.draw(0..c.vertex_count, 0..1);
}
// 1c) Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) NACH den Caps: die
// Material-/Fugenlinien je ueberlebendem Cut-Rechteck (Ribbon-
// TriangleList, eigener Stil je Schicht). Ohne Tiefentest (Always)
// liegen sie sichtbar auf den Caps; der Kamera-Lift in
// `build_cut_cap_lines` haelt sie vor der Ebenen-Kappung.
if let Some(cl) = &self.cut_lines {
pass.set_pipeline(&self.cut_line_pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_vertex_buffer(0, cl.vbo.slice(..));
pass.draw(0..cl.vertex_count, 0..1);
}
// 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid).
// Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten)
// Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne
+78 -1
View File
@@ -20,6 +20,7 @@ pub mod math;
pub mod mesh;
pub(crate) mod openings;
pub mod section;
pub mod section_boolean;
pub mod section_fill;
pub mod shaders;
pub mod types;
@@ -72,6 +73,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
}
}
@@ -171,6 +175,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
@@ -250,6 +257,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mut mesh = Mesh::default();
extrude_wall(&mut mesh, &w);
@@ -272,6 +282,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mesh = build_walls_mesh(&[w]);
assert_eq!(mesh.vertex_count(), 0);
@@ -486,6 +499,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let b = WallInput {
start: [0.0, 0.0],
@@ -497,6 +513,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mesh = build_walls_mesh(&[a, b]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
@@ -557,6 +576,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let walls = vec![
mk([0.0, 0.0], [3.0, 0.0]),
@@ -586,6 +608,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let walls = vec![
mk([0.0, 0.0], [3.0, 0.0], 0.0),
@@ -675,6 +700,9 @@ mod tests {
openings: vec![],
layers: layers(),
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mut b = a.clone();
b.end = [0.0, 3.0];
@@ -705,6 +733,8 @@ mod tests {
z_bottom,
z_top,
color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
}
}
@@ -761,6 +791,8 @@ mod tests {
z_bottom: 0.0,
z_top: 0.3,
color: [0.8, 0.8, 0.8],
hatch: None,
cut: None,
};
let mut mesh = Mesh::default();
extrude_slab(&mut mesh, &slab);
@@ -789,7 +821,7 @@ mod tests {
fn entarteter_slab_erzeugt_nichts() {
let mut mesh = Mesh::default();
// <3 Ecken.
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8] });
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8], hatch: None, cut: None });
assert_eq!(mesh.vertex_count(), 0);
// Nullhoehe.
extrude_slab(&mut mesh, &square_slab(4.0, 2.6, 2.6));
@@ -962,6 +994,51 @@ mod tests {
assert!(top.eye[1] > t[1], "Top-Kamera ueber dem Ziel");
}
#[test]
fn presets_kardinal_back_left_sind_orthografisch_und_gegenrichtung() {
use super::math::preset_camera;
use super::types::{CameraPreset, Projection};
let t = [0.0, 0.0, 0.0];
let front = preset_camera(CameraPreset::Front, t, 10.0);
let back = preset_camera(CameraPreset::Back, t, 10.0);
let side = preset_camera(CameraPreset::Side, t, 10.0);
let left = preset_camera(CameraPreset::Left, t, 10.0);
assert_eq!(back.projection, Projection::Orthographic);
assert_eq!(left.projection, Projection::Orthographic);
// Back/Left sind exakt die Gegenrichtung von Front/Side (gespiegelte Achse).
assert!((back.eye[2] - (-front.eye[2])).abs() < 1e-4, "Back == -Front (Z)");
assert!((left.eye[0] - (-side.eye[0])).abs() < 1e-4, "Left == -Side (X)");
}
#[test]
fn presets_iso_oktanten_sind_orthografisch_gleich_gross() {
use super::math::preset_camera;
use super::types::{CameraPreset, Projection};
let t = [0.0, 0.0, 0.0];
let dist = 10.0;
let iso = preset_camera(CameraPreset::Iso, t, dist);
let fl = preset_camera(CameraPreset::IsoFrontLeft, t, dist);
let br = preset_camera(CameraPreset::IsoBackRight, t, dist);
let bl = preset_camera(CameraPreset::IsoBackLeft, t, dist);
for cam in [&iso, &fl, &br, &bl] {
assert_eq!(cam.projection, Projection::Orthographic);
// Alle vier oberen Iso-Oktanten haben denselben eye-target-Abstand
// (Modell erscheint in jedem Oktanten gleich gross).
let d = ((cam.eye[0] - t[0]).powi(2)
+ (cam.eye[1] - t[1]).powi(2)
+ (cam.eye[2] - t[2]).powi(2))
.sqrt();
assert!((d - dist).abs() < 1e-3, "Iso-Oktant-Abstand == dist");
// Alle vier blicken von OBEN (Y > 0) — untere Oktanten bleiben ungenutzt.
assert!(cam.eye[1] > t[1], "Iso-Oktant ueber dem Ziel");
}
// Vier unterschiedliche horizontale Vorzeichen-Kombinationen (X,Z).
assert!(iso.eye[0] > t[0] && iso.eye[2] > t[2]); // vorne-rechts
assert!(fl.eye[0] < t[0] && fl.eye[2] > t[2]); // vorne-links
assert!(br.eye[0] > t[0] && br.eye[2] < t[2]); // hinten-rechts
assert!(bl.eye[0] < t[0] && bl.eye[2] < t[2]); // hinten-links
}
/// Validiert die WGSL-Quelle headless ueber naga (Parser + Validator) — faengt
/// Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render", weil naga
/// sonst nicht mitgebaut wird (Muster: render2d).
+53 -11
View File
@@ -185,15 +185,26 @@ pub fn orbit_eye(target: [f32; 3], yaw: f32, pitch: f32, dist: f32) -> [f32; 3]
]
}
/// Baut eine `Camera` fuer eines der fuenf Presets der three.js-Sicht. `target`
/// ist das Blickziel (Modell-Mitte), `dist` der Abstand. Front/Top/Side/Iso
/// sind orthografisch (Front/Top/Side achsparallel, Iso diagonal); nur Persp
/// ist perspektivisch.
/// Baut eine `Camera` fuer eines der Kardinal-/Iso-Presets (ROADMAP §11).
/// `target` ist das Blickziel (Modell-Mitte), `dist` der Abstand. Alle Presets
/// ausser `Persp` sind orthografisch (Front/Back/Top/Side/Left achsparallel,
/// die Iso-Varianten diagonal); nur Persp ist perspektivisch.
///
/// Achs-Konvention (wie die three.js-Sicht, s. `applyView3d` in Viewport3D.tsx):
/// +Z = vorne (Front blickt von +Z entlang -Z), +X = rechts (Side blickt von
/// +X entlang -X). Back/Left sind exakt die Gegenrichtungen (-Z/-X); es gibt
/// noch KEINEN Nordwinkel — die Kardinalrichtungen sind rein modellrelativ
/// (Vorne/Rechts/Hinten/Links im UI, keine Himmelsrichtungen).
pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camera {
let mut cam = Camera {
target,
..Camera::default()
};
// Iso-Distanz-Helfer: Augenabstand-Komponente je Achse, sodass der
// tatsaechliche eye-target-Abstand exakt `dist` bleibt (d*sqrt(3) == dist,
// wie bei den bisherigen Front/Top/Side-Presets), damit alle Iso-Varianten
// das Modell gleich gross zeigen.
let iso_d = dist / 3.0_f32.sqrt();
match preset {
// Blick entlang -Z (von vorne), orthografisch.
CameraPreset::Front => {
@@ -202,6 +213,13 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Blick entlang +Z (von hinten) — Gegenrichtung von Front.
CameraPreset::Back => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0], target[1], target[2] - dist];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Blick von oben entlang -Y (Grundriss), orthografisch. Up = -Z, damit
// Modell-Y (world +Z) im Bild nach unten zeigt (wie die 2D-Plan-Sicht).
CameraPreset::Top => {
@@ -210,24 +228,48 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer
cam.up = [0.0, 0.0, -1.0];
cam.ortho_half_height = dist * 0.5;
}
// Blick entlang -X (von der Seite), orthografisch.
// Blick entlang -X (von der rechten Seite), orthografisch.
CameraPreset::Side => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] + dist, target[1], target[2]];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick (orthografisch, Blick von schraeg oben).
// Blick entlang +X (von der linken Seite) — Gegenrichtung von Side.
CameraPreset::Left => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] - dist, target[1], target[2]];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von vorne-oben-rechts (Default-Iso).
// Echte Isometrie ist per Definition parallelprojiziert (keine
// perspektivische Verzerrung, Kantenlaengen bleiben massstabsgetreu).
CameraPreset::Iso => {
cam.projection = Projection::Orthographic;
let d = dist / 3.0_f32.sqrt();
cam.eye = [target[0] + d, target[1] + d, target[2] + d];
cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] + iso_d];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von vorne-oben-links.
CameraPreset::IsoFrontLeft => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] + iso_d];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von hinten-oben-rechts.
CameraPreset::IsoBackRight => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] - iso_d];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von hinten-oben-links.
CameraPreset::IsoBackLeft => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] - iso_d];
cam.up = [0.0, 1.0, 0.0];
// eye-target-Abstand ist hier (wie bei Front/Top/Side) exakt
// `dist` (d*sqrt(3) == dist), darum dieselbe Skalierung wie dort,
// damit das Modell im Bild aehnlich gross erscheint.
cam.ortho_half_height = dist * 0.5;
}
// Freie perspektivische Standard-Ansicht (leicht von vorn-oben-rechts).
+57 -1
View File
@@ -940,7 +940,10 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
/// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie
/// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst.
pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh {
textured_from_mesh(&build_walls_mesh(walls))
// Ueber den Szenen-Builder (ohne Decken/Kontext) — so traegt der Puffer auch
// die Material-Ebenen je Wand (`layers`), waehrend Geometrie/UV bitgleich zu
// `textured_from_mesh(&build_walls_mesh(walls))` bleiben.
build_scene_mesh_textured(walls, &[], &[])
}
/// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh`
@@ -980,9 +983,62 @@ pub fn textured_from_mesh(mesh: &Mesh) -> TexturedMesh {
TexturedMesh {
verts,
indices: mesh.indices.clone(),
// Ohne Material-Zuordnung: leer -> die GPU-Schicht bindet einen konstanten
// `-1`-Ebenenpuffer (Fallback-Schachbrett fuer alle Vertices).
layers: Vec::new(),
}
}
/// TEXTURIERTES Szenen-Mesh MIT Material-Ebenen: baut dieselbe Szene wie
/// `build_scene_mesh` (Waende -> Decken -> Kontext-Meshes, gleiche Reihenfolge/
/// Geometrie) und fuellt zusaetzlich den parallelen `layers`-Puffer (ein Eintrag
/// je Vertex): fuer jede Wand ihre `material_index`-Textur-Ebene (`None` -> `-1`),
/// fuer Decken und Kontext-Meshes `-1` (Fallback-Schachbrett). Die Vertex-Ranges
/// werden waehrend des Baus je Bauteil aus `Mesh::vertex_count()` abgegriffen —
/// dieselben `extrude_*`/`append_*`-Funktionen wie der Shaded-Pfad bleiben die
/// EINE Geometrie-Quelle (Paritaet garantiert), es kommt nur die Ebenen-Zuordnung
/// dazu. Genutzt vom `Textured`-Stil (`gpu::upload_*`).
pub fn build_scene_mesh_textured(
walls: &[WallInput],
slabs: &[SlabInput],
meshes: &[MeshInput],
) -> TexturedMesh {
let mut mesh = Mesh::default();
let mut layers: Vec<f32> = Vec::new();
let fill = |mesh: &Mesh, layers: &mut Vec<f32>, before: usize, layer: f32| {
let added = mesh.vertex_count().saturating_sub(before);
layers.extend(std::iter::repeat(layer).take(added));
};
let miters = compute_wall_miters(walls);
for (w, m) in walls.iter().zip(miters.iter()) {
let before = mesh.vertex_count();
extrude_wall_with_miters(&mut mesh, w, *m);
// Material-Ebene der Wand (1-basiert; 0 ist das Fallback-Schachbrett).
let layer = w.material_index.map(|i| i as f32).unwrap_or(-1.0);
fill(&mesh, &mut layers, before, layer);
}
for s in slabs {
let before = mesh.vertex_count();
extrude_slab(&mut mesh, s);
fill(&mesh, &mut layers, before, -1.0);
}
for m in meshes {
let before = mesh.vertex_count();
append_context_mesh(&mut mesh, m);
fill(&mesh, &mut layers, before, -1.0);
}
let mut tex = textured_from_mesh(&mesh);
debug_assert_eq!(
layers.len(),
tex.vertex_count(),
"ein Ebenen-Eintrag je Vertex"
);
tex.layers = layers;
tex
}
/// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
/// vorne (deterministische Zaehlung fuer die Wand-Tests).
+39 -1
View File
@@ -133,7 +133,7 @@
use serde::{Deserialize, Serialize};
use crate::math;
use crate::types::{Opening, Point2, Rgb, SlabInput, WallInput};
use crate::types::{CutBandMeta, Hatch, Opening, Point2, Rgb, SlabInput, WallInput};
/// Toleranz fuer "naeher an der Ebene" / Grenzwert-Vergleiche (Meter, Tiefe/Hoehe).
const EPS: f32 = 1e-4;
@@ -268,8 +268,19 @@ pub struct ComponentRef {
pub struct CutPolygon {
pub component: ComponentRef,
pub color: Rgb,
/// Schnitt-Schraffur des Ursprungs-Bauteils (siehe `WallInput::hatch`). `None`
/// -> der Cap-Shader zeichnet das Fallback-Diagonalmuster (Alt-Verhalten).
#[serde(default)]
pub hatch: Option<Hatch>,
/// Ring-Ecken (u, v); erste != letzte (Ring implizit geschlossen).
pub pts: Vec<[f32; 2]>,
/// Schnitt-Verschneidungs-/Linienstil-Metadaten des Ursprungs-Bandes (siehe
/// `WallInput::cut`). Treibt die Boolean-Dominanz in `section_boolean::
/// subtract_dominant_bands` (die staerkere Schicht schneidet die schwaechere)
/// und die Schnitt-Umrisslinien (`section_fill::build_cut_cap_lines`). `None`
/// -> das Band nimmt an keiner Subtraktion teil (Alt-Verhalten).
#[serde(default)]
pub cut: Option<CutBandMeta>,
}
/// Ein Liniensegment in Schnittkoordinaten (u, v), Meter.
@@ -314,9 +325,16 @@ struct Prism {
z0: f32,
z1: f32,
color: Rgb,
/// Schnitt-Schraffur des Bauteils (siehe `WallInput::hatch`). Wandert in die
/// Cut-Polygone, damit `section_fill`/`CAP_WGSL` das richtige Muster zeichnen.
hatch: Option<Hatch>,
/// Nur fuer Waende: Achse + Oeffnungen (siehe `WallAxis`). `None` fuer
/// Decken (Slabs kennen keine Oeffnungen).
wall_axis: Option<WallAxis>,
/// Schnitt-Metadaten (Prioritaet/Identitaet/Linienstil), 1:1 aus der
/// `WallInput`/`SlabInput`-Eingabe (`cut`) uebernommen und an jedes erzeugte
/// `CutPolygon` weitergereicht.
cut: Option<CutBandMeta>,
}
/// Wandachse + Oeffnungen, wie sie fuer die Oeffnungs-Zuordnung bei Cut-
@@ -399,12 +417,14 @@ fn wall_prism(index: usize, wall: &WallInput) -> Option<Prism> {
z0: wall.base_elevation,
z1: wall.base_elevation + wall.height,
color: wall.color,
hatch: wall.hatch,
wall_axis: Some(WallAxis {
start: wall.start,
dir,
length,
openings: wall.openings.clone(),
}),
cut: wall.cut.clone(),
})
}
@@ -425,7 +445,9 @@ fn slab_prism(index: usize, slab: &SlabInput) -> Option<Prism> {
z0,
z1,
color: slab.color,
hatch: slab.hatch,
wall_axis: None,
cut: slab.cut.clone(),
})
}
@@ -974,7 +996,9 @@ pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput
index: prism.index,
},
color: prism.color,
hatch: prism.hatch,
pts: vec![[rlo, rz0], [rhi, rz0], [rhi, rz1], [rlo, rz1]],
cut: prism.cut.clone(),
});
}
}
@@ -1017,6 +1041,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
WallInput {
start: [0.0, 3.0],
@@ -1028,6 +1055,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
];
let slabs = vec![SlabInput {
@@ -1035,6 +1065,8 @@ mod tests {
z_bottom: -0.2,
z_top: 0.0,
color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
}];
(walls, slabs)
}
@@ -1285,6 +1317,9 @@ mod tests {
}],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
}
}
@@ -1371,6 +1406,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
let plane = SectionPlane::looking_plus_y(-2.0);
+516
View File
@@ -0,0 +1,516 @@
//! Schnitt-Boolean-Dominanz fuer den 3D-Live-Schnitt — die 1:1-Portierung von
//! `src/plan/toSection.ts::subtractDominantBands`/`subtractRect` nach Rust.
//!
//! GRUND: der 2D-Schnitt (`toSection.ts`) ist die Referenz-Implementierung. Er
//! sammelt alle Cut-Baender (Wand- und Deckenschichten) als achsparallele
//! (u,v)-Rechtecke, taggt jedes mit seiner `Component.joinPriority` und laesst
//! dann die STAERKERE Schicht die schwaechere per Rechteck-Subtraktion wegschneiden
//! (elementuebergreifend). Der 3D-Rust-Cut (`section.rs`) produziert bereits genau
//! dasselbe Format — achsparallele (u,v)-Rechteck-`CutPolygon`s — also laesst sich
//! die Dominanz-Zerlegung hier identisch nachbauen, damit der 3D-Live-Schnitt fuer
//! Wand+Decken-Schichten EXAKT wie der 2D-Schnitt aussieht.
//!
//! Die Prioritaet/Identitaet je Band kommt aus `CutPolygon::cut` (`CutBandMeta`,
//! aus der TS-Emission `toWalls3d`). Baender ohne `join_priority` (unaufloesbares
//! Bauteil) nehmen weder als Basis noch als Cutter teil und bleiben unveraendert.
use crate::section::CutPolygon;
/// Toleranz fuer Ueberlappungs-/Rest-Rechtecke (Meter): Null-/Negativflaechen und
/// Rest-Streifen duenner als EPS werden verworfen (Float-Rauschen der (u,v)-Meter).
/// Identisch zu `RECT_EPS` in `toSection.ts`.
const RECT_EPS: f32 = 1e-6;
/// Achsparalleles Rechteck in (u, v)-Metern — das Rust-Gegenstueck zu `Rect` in
/// `toSection.ts`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub u_min: f32,
pub u_max: f32,
pub v_min: f32,
pub v_max: f32,
}
/// Bounding-Box eines (achsparallelen) Cut-Bands als `Rect`, oder `None` bei <3
/// Ecken (analog `rectOfBand`).
pub fn rect_of_poly(poly: &CutPolygon) -> Option<Rect> {
if poly.pts.len() < 3 {
return None;
}
let mut u_min = f32::INFINITY;
let mut u_max = f32::NEG_INFINITY;
let mut v_min = f32::INFINITY;
let mut v_max = f32::NEG_INFINITY;
for p in &poly.pts {
u_min = u_min.min(p[0]);
u_max = u_max.max(p[0]);
v_min = v_min.min(p[1]);
v_max = v_max.max(p[1]);
}
Some(Rect {
u_min,
u_max,
v_min,
v_max,
})
}
/// Rechteck-minus-Rechteck (beide achsparallel): `base` ohne die Ueberlappung mit
/// `cutter`, zerlegt in 04 disjunkte Rest-Rechtecke (links/rechts ueber die volle
/// Hoehe, dann oben/unten im mittleren u-Streifen — ueberlappungsfrei). Kein echter
/// Ueberlapp (Flaeche ≤ EPS in u oder v) ⇒ `base` bleibt ganz. Vollstaendige
/// Ueberdeckung ⇒ leere Liste. Rest-Streifen duenner als EPS werden verworfen.
/// 1:1 aus `toSection.ts::subtractRect`.
pub fn subtract_rect(base: Rect, cutter: Rect) -> Vec<Rect> {
let o_min_u = base.u_min.max(cutter.u_min);
let o_max_u = base.u_max.min(cutter.u_max);
let o_min_v = base.v_min.max(cutter.v_min);
let o_max_v = base.v_max.min(cutter.v_max);
// Kein (nennenswerter) Ueberlapp — `base` unveraendert.
if o_max_u - o_min_u <= RECT_EPS || o_max_v - o_min_v <= RECT_EPS {
return vec![base];
}
let mut out = Vec::new();
let mut push = |r: Rect| {
if r.u_max - r.u_min > RECT_EPS && r.v_max - r.v_min > RECT_EPS {
out.push(r);
}
};
// Links / rechts der Ueberlappung, jeweils ueber die volle Basis-Hoehe.
push(Rect {
u_min: base.u_min,
u_max: o_min_u,
v_min: base.v_min,
v_max: base.v_max,
});
push(Rect {
u_min: o_max_u,
u_max: base.u_max,
v_min: base.v_min,
v_max: base.v_max,
});
// Unten / oben im mittleren u-Streifen (nur der von der Ueberlappung befreite Rest).
push(Rect {
u_min: o_min_u,
u_max: o_max_u,
v_min: base.v_min,
v_max: o_min_v,
});
push(Rect {
u_min: o_min_u,
u_max: o_max_u,
v_min: o_max_v,
v_max: base.v_max,
});
out
}
/// `join_priority` eines Bands (aus `CutBandMeta`), oder `None`.
fn priority_of(poly: &CutPolygon) -> Option<i32> {
poly.cut.as_ref().and_then(|c| c.join_priority)
}
/// `component_id` eines Bands (aus `CutBandMeta`), oder `None`.
fn component_of(poly: &CutPolygon) -> Option<&str> {
poly.cut.as_ref().and_then(|c| c.component_id.as_deref())
}
/// Erzeugt aus einem Rest-`Rect` ein Cut-Band, das Stil/Referenz/Metadaten von
/// `src` erbt (analog `bandFromRect`). Ecken-Reihenfolge wie in `section.rs`
/// (CCW-analog, hier v_max oben zuerst — fuer die Fan-Triangulierung irrelevant).
fn poly_from_rect(src: &CutPolygon, r: Rect) -> CutPolygon {
CutPolygon {
component: src.component,
color: src.color,
hatch: src.hatch,
cut: src.cut.clone(),
pts: vec![
[r.u_min, r.v_max],
[r.u_max, r.v_max],
[r.u_max, r.v_min],
[r.u_min, r.v_min],
],
}
}
/// Globale Schnitt-Dominanz: fuer jedes Band wird die Rechteck-Ueberlappung ALLER
/// Baender mit STRIKT hoeherer `join_priority` subtrahiert (elementuebergreifend —
/// z. B. schneidet ein Decken-Beton-Band 100 ein ueberlappendes Wand-Putz-Band 10
/// weg). Gleiche Prioritaet schneidet NICHT (koexistiert → durchgehende Flaeche).
/// Baender ohne `join_priority` nehmen weder als Basis noch als Cutter teil und
/// bleiben unveraendert erhalten. 1:1 aus `toSection.ts::subtractDominantBands`
/// (inklusive der abschliessenden MERGE-Phase).
pub fn subtract_dominant_bands(bands: Vec<CutPolygon>) -> Vec<CutPolygon> {
let rects: Vec<Option<Rect>> = bands.iter().map(rect_of_poly).collect();
let prios: Vec<Option<i32>> = bands.iter().map(priority_of).collect();
let mut out: Vec<CutPolygon> = Vec::new();
for i in 0..bands.len() {
// Nicht-rechteckig oder ohne Prioritaet: unveraendert durchreichen.
let (base, prio) = match (rects[i], prios[i]) {
(Some(b), Some(p)) => (b, p),
_ => {
out.push(bands[i].clone());
continue;
}
};
let mut pieces = vec![base];
for j in 0..bands.len() {
if pieces.is_empty() {
break;
}
if j == i {
continue;
}
let cutter = match (rects[j], prios[j]) {
(Some(c), Some(op)) if op > prio => c,
_ => continue,
};
let mut next = Vec::new();
for p in &pieces {
next.extend(subtract_rect(*p, cutter));
}
pieces = next;
}
for p in pieces {
out.push(poly_from_rect(&bands[i], p));
}
}
merge_same_component_bands(out)
}
/// Union zweier achsparalleler Rechtecke, ABER NUR wenn das Ergebnis EXAKT wieder
/// ein Rechteck ist — sonst `None`. Enthaltensein oder Achs-Deckung + Beruehrung
/// liefern eine rechteckige Union; Teil-/Eck-Ueberlappungen (L-Form) und Baender
/// mit Spalt liefern `None`. 1:1 aus `toSection.ts::rectUnionIfRect`.
fn rect_union_if_rect(a: Rect, b: Rect) -> Option<Rect> {
const E: f32 = RECT_EPS;
// Enthaltensein (inkl. Deckungsgleichheit): Union = umschliessendes Rechteck.
let a_in_b = b.u_min - a.u_min <= E
&& a.u_max - b.u_max <= E
&& b.v_min - a.v_min <= E
&& a.v_max - b.v_max <= E;
if a_in_b {
return Some(b);
}
let b_in_a = a.u_min - b.u_min <= E
&& b.u_max - a.u_max <= E
&& a.v_min - b.v_min <= E
&& b.v_max - a.v_max <= E;
if b_in_a {
return Some(a);
}
let u_aligned = (a.u_min - b.u_min).abs() <= E && (a.u_max - b.u_max).abs() <= E;
let v_aligned = (a.v_min - b.v_min).abs() <= E && (a.v_max - b.v_max).abs() <= E;
// Beruehrung/Ueberlappung in der jeweils ANDEREN Achse (kein Spalt groesser EPS).
let v_touch = a.v_max >= b.v_min - E && b.v_max >= a.v_min - E;
let u_touch = a.u_max >= b.u_min - E && b.u_max >= a.u_min - E;
if (u_aligned && v_touch) || (v_aligned && u_touch) {
return Some(Rect {
u_min: a.u_min.min(b.u_min),
u_max: a.u_max.max(b.u_max),
v_min: a.v_min.min(b.v_min),
v_max: a.v_max.max(b.v_max),
});
}
None
}
/// MERGE-Phase der Schnitt-Dominanz: Baender mit GLEICHER `component_id` UND
/// gleicher `join_priority`, deren achsparallele Rechtecke sich beruehren/ueberlappen
/// und eine rechteckige Union bilden (`rect_union_if_rect`), werden zu EINEM Band
/// zusammengefasst — so entsteht an der Beruehrkante keine innere Trennlinie durch
/// gleichartiges Material. Baender ohne `component_id`/`join_priority` oder ohne
/// Rechteck-Form nehmen nicht teil. Iterativ bis zum Fixpunkt. 1:1 aus
/// `toSection.ts::mergeSameComponentBands`.
fn merge_same_component_bands(bands: Vec<CutPolygon>) -> Vec<CutPolygon> {
let mut out = bands;
let mut merged = true;
while merged {
merged = false;
'outer: for i in 0..out.len() {
let (ci, pi) = match (component_of(&out[i]), priority_of(&out[i])) {
(Some(c), Some(p)) => (c.to_string(), p),
_ => continue,
};
let ri = match rect_of_poly(&out[i]) {
Some(r) => r,
None => continue,
};
for j in (i + 1)..out.len() {
if component_of(&out[j]) != Some(ci.as_str()) || priority_of(&out[j]) != Some(pi) {
continue;
}
let rj = match rect_of_poly(&out[j]) {
Some(r) => r,
None => continue,
};
if let Some(u) = rect_union_if_rect(ri, rj) {
out[i] = poly_from_rect(&out[i], u);
out.remove(j);
merged = true; // Fixpunkt-Neustart
break 'outer;
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::section::{ComponentKind, ComponentRef, CutPolygon};
use crate::types::CutBandMeta;
/// Achsparalleles Cut-Band mit Prioritaet (und optionaler Komponenten-Id) —
/// Ecken wie `section.rs` (Rechteck).
fn band(
u_min: f32,
u_max: f32,
v_min: f32,
v_max: f32,
join_priority: i32,
component_id: Option<&str>,
) -> CutPolygon {
CutPolygon {
component: ComponentRef {
kind: ComponentKind::Wall,
index: 0,
},
color: [0.5, 0.5, 0.5],
hatch: None,
pts: vec![
[u_min, v_max],
[u_max, v_max],
[u_max, v_min],
[u_min, v_min],
],
cut: Some(CutBandMeta {
join_priority: Some(join_priority),
component_id: component_id.map(|s| s.to_string()),
line_color: None,
line_weight: None,
}),
}
}
fn rect_of(p: &CutPolygon) -> Rect {
rect_of_poly(p).unwrap()
}
fn area(bands: &[CutPolygon]) -> f32 {
bands
.iter()
.map(|b| {
let r = rect_of(b);
(r.u_max - r.u_min) * (r.v_max - r.v_min)
})
.sum()
}
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-5
}
// --- subtractRect (Referenz: toSection.boolean.test.ts) ------------------
#[test]
fn subtract_rect_kein_overlap_base_unveraendert() {
let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 };
let cut = Rect { u_min: 2.0, u_max: 3.0, v_min: 2.0, v_max: 3.0 };
assert_eq!(subtract_rect(base, cut), vec![base]);
}
#[test]
fn subtract_rect_kantenberuehrung_base_unveraendert() {
let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 };
let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 0.0, v_max: 1.0 };
assert_eq!(subtract_rect(base, cut), vec![base]);
}
#[test]
fn subtract_rect_cutter_enthaelt_base_leer() {
let base = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 };
let cut = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 };
assert!(subtract_rect(base, cut).is_empty());
}
#[test]
fn subtract_rect_cutter_mittig_vier_reste() {
let base = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 };
let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 };
let rest = subtract_rect(base, cut);
assert_eq!(rest.len(), 4);
let a: f32 = rest
.iter()
.map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min))
.sum();
assert!(approx(a, 8.0), "9 (base) - 1 (overlap) = 8, ist {a}");
}
#[test]
fn subtract_rect_eck_ueberlappung_zwei_reste() {
// cutter deckt die obere-rechte Ecke von base: overlap [1,2]x[1,2].
let base = Rect { u_min: 0.0, u_max: 2.0, v_min: 0.0, v_max: 2.0 };
let cut = Rect { u_min: 1.0, u_max: 3.0, v_min: 1.0, v_max: 3.0 };
let rest = subtract_rect(base, cut);
let a: f32 = rest
.iter()
.map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min))
.sum();
assert!(approx(a, 3.0), "4 - 1 = 3, ist {a}");
}
// --- subtractDominantBands: die geometrische Kernaussage ------------------
#[test]
fn decke_100_schneidet_wand_50_im_ueberlappungs_z_intervall_weg() {
// Nutzer-Sample-analog: eine Wandschicht (Backstein, Prio 50, u in [0,0.2],
// volle Hoehe v in [0,3]) und ein Decken-Beton-Band (Prio 100, u in [0,0.2],
// v in [2.6,2.8]) ueberlappen in (u,v). Nach der Subtraktion darf die Wand
// im Decken-z-Intervall KEINE Flaeche mehr haben; Beton bleibt voll.
let brick = band(0.0, 0.2, 0.0, 3.0, 50, Some("brick"));
let concrete = band(0.0, 0.2, 2.6, 2.8, 100, Some("concrete"));
let out = subtract_dominant_bands(vec![brick, concrete]);
// Beton unveraendert (kein staerkeres Band).
let concrete_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("concrete"))
.collect();
assert_eq!(concrete_out.len(), 1);
assert!(approx(
(rect_of(concrete_out[0]).v_max - rect_of(concrete_out[0]).v_min) as f32,
0.2
));
// Backstein: in v in [2.6,2.8] KEINE Flaeche mehr -> zwei Reste [0,2.6] und
// [2.8,3.0]. Kein Rest-Rechteck ragt ins Beton-Intervall.
let brick_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("brick"))
.collect();
assert!(!brick_out.is_empty());
for b in &brick_out {
let r = rect_of(b);
// Kein Backstein-Rest ueberlappt (2.6, 2.8) echt.
let overlaps = r.v_min < 2.8 - 1e-4 && r.v_max > 2.6 + 1e-4;
assert!(!overlaps, "Backstein-Rest {r:?} ragt ins Beton-z-Intervall");
}
// Flaechenbilanz: Backstein 0.2*3 = 0.6, minus Overlap 0.2*0.2 = 0.04 -> 0.56.
assert!(
approx(area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()), 0.56),
"Backstein-Restflaeche"
);
}
#[test]
fn wand_50_schneidet_estrich_30() {
// Estrich (Decke, Prio 30) verliert die Ueberlappung mit der Wand
// (Backstein, Prio 50). Referenz-Zahlen wie im 2D-Test.
let screed = band(0.0, 2.0, 0.0, 3.0, 30, Some("screed"));
let brick = band(0.0, 2.0, 2.0, 4.0, 50, Some("brick")); // Overlap v in [2,3]
let out = subtract_dominant_bands(vec![screed, brick]);
// Brick voll.
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()),
4.0
));
// Estrich: [0,3] minus Overlap [2,3] = [0,2] -> Rest genau v in [0,2].
let screed_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("screed"))
.collect();
assert_eq!(screed_out.len(), 1);
let r = rect_of(screed_out[0]);
assert!(approx(r.v_min, 0.0) && approx(r.v_max, 2.0), "Estrich-Rest {r:?}");
}
/// SAMPLE-ANALOG (Nutzer-Bug): der Schnitt durch Aussenwand + Geschossdecke.
/// Deckenschichten (u-breit, gestapelt in v nahe OK): Estrich (30, [2.54,2.6]),
/// Dämmung (20, [2.50,2.54]), Beton (100, [2.30,2.50]). Wandschichten (schmal in
/// u, an ihrer Dicken-Position): Backstein (50) läuft als ZWEI Boxen [0,2.3] +
/// [2.5,2.6] durch (Beton kappt das Mittelstück — genau die per-Schicht-Decken-
/// Dominanz aus emitWall). ERWARTUNG: nach der Verschneidung haben Estrich UND
/// Dämmung im u-Bereich des Backsteins KEINE Fläche mehr (Backstein 50 > 30, 20);
/// der Beton bleibt voll und läuft durch. Das ist die Richtung, die der Nutzer als
/// kaputt meldete ("dämmung und zementboden laufen raus").
#[test]
fn sample_analog_backstein_schneidet_estrich_und_daemmung() {
let brick_u = (0.0075_f32, 0.1575_f32); // Backstein-Dickenposition (W4-analog)
let ceil_u = (0.0_f32, 5.0_f32); // Deckenschichten über die volle Raumbreite
let bands = vec![
band(ceil_u.0, ceil_u.1, 2.54, 2.6, 30, Some("screed")),
band(ceil_u.0, ceil_u.1, 2.50, 2.54, 20, Some("insulation-ceiling")),
band(ceil_u.0, ceil_u.1, 2.30, 2.50, 100, Some("concrete")),
band(brick_u.0, brick_u.1, 0.0, 2.3, 50, Some("brick")),
band(brick_u.0, brick_u.1, 2.5, 2.6, 50, Some("brick")),
];
let out = subtract_dominant_bands(bands);
// Kein Estrich-/Dämmung-Rest überlappt den Backstein-u-Bereich ECHT.
for name in ["screed", "insulation-ceiling"] {
for b in out.iter().filter(|b| component_of(b) == Some(name)) {
let r = rect_of(b);
let u_overlap = r.u_min < brick_u.1 - 1e-4 && r.u_max > brick_u.0 + 1e-4;
assert!(
!u_overlap,
"{name}-Rest {r:?} ragt noch in den Backstein-u-Bereich (müsste weg sein)"
);
}
}
// Beton bleibt voll (kein stärkeres Band): Fläche 5*0.2 = 1.0.
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("concrete")).cloned().collect::<Vec<_>>()),
1.0
));
// Backstein bleibt in beiden Boxen erhalten (nichts Stärkeres überlappt).
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()),
0.15 * 2.3 + 0.15 * 0.1
));
}
#[test]
fn gleiche_prioritaet_koexistiert() {
// Beton-Wand + Beton-Decke, gleiche Prio 100, VERSCHIEDENE Komponenten:
// schneiden sich NICHT und verschmelzen NICHT (bleiben beide voll).
let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton-wand"));
let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton-decke")); // Overlap v[2,3]
let out = subtract_dominant_bands(vec![wand, decke]);
assert_eq!(out.len(), 2);
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("beton-wand")).cloned().collect::<Vec<_>>()),
3.0
));
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("beton-decke")).cloned().collect::<Vec<_>>()),
2.0
));
}
#[test]
fn ohne_prioritaet_keine_teilnahme() {
let stark = band(0.0, 2.0, 0.0, 2.0, 100, None);
let mut ohne = band(0.0, 2.0, 0.0, 2.0, 0, None);
ohne.cut = None; // keine Prioritaet
let out = subtract_dominant_bands(vec![stark, ohne]);
// Beide unveraendert (4 + 4).
assert_eq!(out.len(), 2);
assert!(approx(area(&out), 8.0));
}
#[test]
fn merge_gleiche_komponente_und_prioritaet() {
// Beton-Wand trifft Beton-Decke (u-deckungsgleich), gleiche componentId +
// Prio -> verschmelzen zu EINEM Rechteck v in [0,4] (keine innere Kante).
let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton"));
let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton"));
let out = subtract_dominant_bands(vec![wand, decke]);
assert_eq!(out.len(), 1);
let r = rect_of(&out[0]);
assert!(approx(r.v_min, 0.0) && approx(r.v_max, 4.0), "verschmolzen {r:?}");
}
}
+513 -13
View File
@@ -21,19 +21,68 @@
//!
//! ## Ausgabeformat
//!
//! Interleaved `[world_x, world_y, world_z, u, v]` je Vertex (5 f32,
//! `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern wandern als
//! Attribut mit, damit die Schraffur im Fragment-Shader ein durchgaengiges,
//! blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet (nicht im
//! Bildschirmraum). Ein Cut-Polygon ist stets ein konvexes Rechteck (4 Ecken,
//! siehe `section.rs`) — Fan-Triangulierung genuegt.
//! Interleaved `[world_x, world_y, world_z, u, v, pattern, angle_rad, scale]` je
//! Vertex (8 f32, `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern
//! wandern als Attribut mit, damit die Schraffur im Fragment-Shader ein
//! durchgaengiges, blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet
//! (nicht im Bildschirmraum). `pattern`/`angle_rad`/`scale` kommen aus der
//! Bauteil-Schraffur (`CutPolygon::hatch` -> `types::Hatch`) und waehlen im Shader
//! das MUSTER je geschnittener Schicht (statt eines fixen 45°-Musters). Sie sind je
//! Cut-Polygon konstant, wandern aber als (redundantes) Vertex-Attribut mit — das
//! haelt die Cap-Pipeline ohne zusaetzliche per-Draw-Uniform-Verwaltung (die Caps
//! werden in EINEM `draw` gezeichnet). Ein Cut-Polygon ist stets ein konvexes
//! Rechteck (4 Ecken, siehe `section.rs`) — Fan-Triangulierung genuegt.
use crate::section::{cut_section, SectionPlane};
use crate::section::{cut_section, CutPolygon, SectionPlane};
use crate::section_boolean::{rect_of_poly, subtract_dominant_bands};
use crate::types::{SlabInput, WallInput};
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v]. Spiegel des Vertex-Layouts
/// der `cap_pipeline` (gpu.rs) und des `VsIn` in `CAP_WGSL` (shaders.rs).
pub const CAP_FLOATS_PER_VERTEX: usize = 5;
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v, pattern, angle_rad, scale,
/// line_weight_mm]. Spiegel des Vertex-Layouts der `cap_pipeline` (gpu.rs) und des
/// `VsIn` in `CAP_WGSL` (shaders.rs). `line_weight_mm` steuert die Musterlinien-
/// Breite (mm-Papier -> Welt) je Schraffur.
pub const CAP_FLOATS_PER_VERTEX: usize = 9;
/// Default-Musterlinien-Staerke (mm-Papier), wenn ein Cut-Polygon keine `hatch`
/// traegt (Fallback-Diagonale) — Haarlinie, wie `HatchRender.lineWeight`-Default.
const FALLBACK_HATCH_LINE_WEIGHT_MM: f32 = 0.13;
/// f32 pro Cut-Linien-Vertex: [pos.x, pos.y, pos.z, r, g, b] — dasselbe Layout wie
/// die Grid-/Highlight-LineList (`GRID_WGSL`), damit die Schnitt-Umrisslinien ohne
/// eigenes Vertex-Format ueber eine (Dreiecks-)Ribbon-Pipeline laufen.
pub const CUT_LINE_FLOATS_PER_VERTEX: usize = 6;
/// Default-Schnittkanten-Farbe (dunkle Tinte), wenn ein Band keinen eigenen
/// `line_color` traegt — passend zur 2D-Schnittkonvention (dunkle Fugenlinie).
const DEFAULT_CUT_LINE_COLOR: [f32; 3] = [0.10, 0.10, 0.10];
/// Default-Strichstaerke der Schnitt-Umrisslinie in mm-Papier (Haarlinie), wenn
/// ein Band keinen eigenen `line_weight` traegt.
const DEFAULT_CUT_LINE_WEIGHT_MM: f32 = 0.35;
/// mm-Papier -> Welt-Meter fuer die Ribbon-BREITE der Schnitt-Umrisslinien.
///
/// EHRLICHE EINSCHRAENKUNG: die 2D-Strichstaerke ist in mm-Papier (masstabsabhaengig).
/// Der 3D-Schnitt kennt keinen Papiermasstab; die Linie wird hier als flaches Ribbon
/// mit KONSTANTER Welt-Breite in die Schnittebene gelegt (blickwinkel-unabhaengig,
/// skaliert also mit der Kamera-Distanz wie gemalte Geometrie — NICHT bildschirmfest).
/// Der Faktor ist bewusst grob gewaehlt (0.35 mm -> ~3.5 mm Welt), damit die Fuge
/// ueberhaupt lesbar bleibt; eine bildschirmfeste Strichstaerke braeuchte eine
/// Screen-Space-Expansion in einer eigenen Pipeline (bekannte Luecke, siehe
/// Schlussbericht/Moduldoc).
const CUT_LINE_MM_TO_WORLD: f32 = 0.01;
/// Kleiner Versatz (Meter) der Umrisslinien-Ribbons ZUR KAMERA (entgegen der
/// Ebenennormale), damit sie (a) nicht durch die Schnittebenen-Kappung von
/// `GRID_WGSL` (`depth > 0` = hinter der Ebene) weggeschnitten werden und (b)
/// sichtbar VOR den Cut-Caps liegen (die exakt auf der Ebene sitzen).
const CUT_LINE_LIFT: f32 = 0.002;
/// Fallback-Muster-Id, wenn ein Cut-Polygon keine `hatch` traegt: 2 = diagonal
/// (45°) — reproduziert mit `angle_rad = 0`, `scale = 1` exakt das fruehere fest
/// verdrahtete 45°-Muster (Rueckwaertskompatibilitaet fuer Waende/Decken ohne
/// aufgeloeste Bauteil-Schraffur).
const FALLBACK_PATTERN: f32 = 2.0;
/// Erzeugt die Schnittflaechen-Geometrie (Cut-Caps) fuer eine vertikale
/// Schnittebene: ruft `cut_section` und trianguliert jedes (u,v)-Rechteck in die
@@ -44,12 +93,23 @@ pub const CAP_FLOATS_PER_VERTEX: usize = 5;
/// zeichnet ohne Backface-Culling (`CullMode::None`, siehe gpu.rs), sodass die
/// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt
/// nicht von der Normale ab.
/// Berechnet die (u,v)-Cut-Polygone der Schnittebene MIT angewandter Boolean-
/// Dominanz (`section_boolean::subtract_dominant_bands`): die staerkere Schicht
/// schneidet die schwaechere weg — elementuebergreifend, exakt wie der 2D-Schnitt
/// (`toSection.ts::attachCutStyles` Phase 2). Gemeinsame Basis der Cut-Cap-Flaechen
/// (`build_cut_caps`) UND der Cut-Umrisslinien (`build_cut_cap_lines`), damit beide
/// auf DERSELBEN, ueberlappungsfreien Rechteck-Menge arbeiten.
fn cut_cap_polys(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> Vec<CutPolygon> {
let out = cut_section(plane, walls, slabs);
subtract_dominant_bands(out.cut_polygons)
}
pub fn build_cut_caps(
plane: &SectionPlane,
walls: &[WallInput],
slabs: &[SlabInput],
) -> Vec<f32> {
let out = cut_section(plane, walls, slabs);
let polys = cut_cap_polys(plane, walls, slabs);
let point = plane.point;
let u_axis = plane.u_axis();
// (u, v) -> Welt: horizontale Lage aus point + u*u_axis, Hoehe = v.
@@ -62,14 +122,28 @@ pub fn build_cut_caps(
};
let mut verts: Vec<f32> = Vec::new();
for poly in &out.cut_polygons {
for poly in &polys {
let pts = &poly.pts;
if pts.len() < 3 {
continue;
}
// Muster/Winkel/Skalierung aus der Bauteil-Schraffur (oder Fallback-
// Diagonale). Winkel von Grad in Radiant, damit der Shader direkt
// cos/sin rechnen kann.
let (pattern, angle_rad, scale, line_weight) = match poly.hatch {
Some(h) => (
h.pattern as f32,
h.angle.to_radians(),
h.scale.max(0.05),
h.line_weight.max(0.0),
),
None => (FALLBACK_PATTERN, 0.0, 1.0, FALLBACK_HATCH_LINE_WEIGHT_MM),
};
let mut push_vertex = |uv: [f32; 2]| {
let w = to_world(uv);
verts.extend_from_slice(&[w[0], w[1], w[2], uv[0], uv[1]]);
verts.extend_from_slice(&[
w[0], w[1], w[2], uv[0], uv[1], pattern, angle_rad, scale, line_weight,
]);
};
// Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1).
for i in 1..pts.len() - 1 {
@@ -80,3 +154,429 @@ pub fn build_cut_caps(
}
verts
}
/// Erzeugt die Schnitt-UMRISSLINIEN (P2): fuer JEDES ueberlebende Cut-Cap-Rechteck
/// seine vier Kanten als flache Ribbon-Dreiecke auf der Schnittebene, in Welt-
/// Koordinaten (dieselbe (u,v)->Welt-Ruecktransformation wie `build_cut_caps`).
/// Rueckgabe = interleaved `[px,py,pz, r,g,b]` (`CUT_LINE_FLOATS_PER_VERTEX`,
/// TriangleList), leer wenn die Ebene das Modell nicht schneidet.
///
/// STIL je Kante: Farbe/Strichstaerke aus dem `line_color`/`line_weight` des Bandes
/// (aus dem `jointLineStyleId`-LineStyle, TS-seitig aufgeloest), sonst der Default-
/// Schnittkanten-Stil. Die Strichstaerke (mm-Papier) wird ueber `CUT_LINE_MM_TO_WORLD`
/// in eine konstante Welt-Breite umgesetzt (siehe dortige Doku — bewusste
/// Vereinfachung, keine bildschirmfeste Strichstaerke). Jede Kante wird als Quad
/// (2 Dreiecke) um ihre Mittellinie gelegt, mit der Ribbon-Breite senkrecht zur
/// Kante IN der Ebene; alle Ribbons um `CUT_LINE_LIFT` zur Kamera versetzt, damit
/// sie nicht an der Ebene weggeclippt werden und vor den Caps liegen.
pub fn build_cut_cap_lines(
plane: &SectionPlane,
walls: &[WallInput],
slabs: &[SlabInput],
) -> Vec<f32> {
let polys = cut_cap_polys(plane, walls, slabs);
let point = plane.point;
let u_axis = plane.u_axis();
let normal = plane.normal;
// Kamera-waertiger Lift entlang -normal (normal zeigt vom Betrachter ins Modell).
let lift = [
-normal[0] * CUT_LINE_LIFT,
-normal[1] * CUT_LINE_LIFT,
-normal[2] * CUT_LINE_LIFT,
];
let to_world = |uv: [f32; 2]| -> [f32; 3] {
[
point[0] + uv[0] * u_axis[0] + lift[0],
uv[1] + lift[1],
point[2] + uv[0] * u_axis[2] + lift[2],
]
};
let mut verts: Vec<f32> = Vec::new();
for poly in &polys {
let Some(r) = rect_of_poly(poly) else {
continue;
};
// Stil aus den Band-Metadaten (oder Default).
let (color, weight_mm) = match &poly.cut {
Some(meta) => (
meta.line_color.unwrap_or(DEFAULT_CUT_LINE_COLOR),
meta.line_weight.unwrap_or(DEFAULT_CUT_LINE_WEIGHT_MM),
),
None => (DEFAULT_CUT_LINE_COLOR, DEFAULT_CUT_LINE_WEIGHT_MM),
};
let half_w = (weight_mm.max(0.0) * CUT_LINE_MM_TO_WORLD * 0.5).max(1e-4);
// Die vier Rechteck-Ecken (u,v) im Umlauf; je Kante ein Ribbon.
let corners = [
[r.u_min, r.v_min],
[r.u_max, r.v_min],
[r.u_max, r.v_max],
[r.u_min, r.v_max],
];
for k in 0..4 {
let a = to_world(corners[k]);
let b = to_world(corners[(k + 1) % 4]);
push_line_ribbon(&mut verts, a, b, normal, half_w, color);
}
}
verts
}
/// Legt ein flaches Ribbon-Quad (2 Dreiecke) der halben Breite `half_w` um die
/// Kante `a`->`b`, verbreitert IN der Schnittebene (senkrecht zur Kante ueber
/// `cross(normal, dir)`), und schreibt es als 6 Vertices `[pos, color]` in `out`.
fn push_line_ribbon(
out: &mut Vec<f32>,
a: [f32; 3],
b: [f32; 3],
normal: [f32; 3],
half_w: f32,
color: [f32; 3],
) {
let dir = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
if len < 1e-9 {
return;
}
let d = [dir[0] / len, dir[1] / len, dir[2] / len];
// In-Ebene-Senkrechte zur Kante: cross(normal, dir) liegt in der Ebene und
// steht senkrecht auf der Kante.
let mut perp = [
normal[1] * d[2] - normal[2] * d[1],
normal[2] * d[0] - normal[0] * d[2],
normal[0] * d[1] - normal[1] * d[0],
];
let plen = (perp[0] * perp[0] + perp[1] * perp[1] + perp[2] * perp[2]).sqrt();
if plen < 1e-9 {
return;
}
perp = [perp[0] / plen * half_w, perp[1] / plen * half_w, perp[2] / plen * half_w];
let p0 = [a[0] - perp[0], a[1] - perp[1], a[2] - perp[2]];
let p1 = [a[0] + perp[0], a[1] + perp[1], a[2] + perp[2]];
let p2 = [b[0] + perp[0], b[1] + perp[1], b[2] + perp[2]];
let p3 = [b[0] - perp[0], b[1] - perp[1], b[2] - perp[2]];
let mut push = |p: [f32; 3]| {
out.extend_from_slice(&[p[0], p[1], p[2], color[0], color[1], color[2]]);
};
// Zwei Dreiecke: p0,p1,p2 und p0,p2,p3.
push(p0);
push(p1);
push(p2);
push(p0);
push(p2);
push(p3);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Hatch;
/// Eine Wand entlang der X-Achse, senkrecht von einer +X-Ebene geschnitten:
/// die Muster-Id/Winkel/Skalierung der Bauteil-Schraffur muessen in JEDEM
/// Cap-Vertex an den Positionen 5/6/7 landen (Vertex-Stride = 8).
#[test]
fn hatch_muster_id_landet_in_den_cap_vertices() {
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.3,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch {
pattern: 3, // crosshatch
angle: 90.0,
scale: 2.0,
line_weight: 0.5,
}),
cut: None,
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[]);
assert!(!verts.is_empty(), "Schnitt liefert Cap-Geometrie");
assert_eq!(
verts.len() % CAP_FLOATS_PER_VERTEX,
0,
"Vertexpuffer ist ein Vielfaches des Strides ({CAP_FLOATS_PER_VERTEX})"
);
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
assert_eq!(verts[base + 5], 3.0, "pattern-Id je Vertex");
assert!(
(verts[base + 6] - 90.0_f32.to_radians()).abs() < 1e-5,
"angle in Radiant"
);
assert_eq!(verts[base + 7], 2.0, "scale je Vertex");
assert_eq!(verts[base + 8], 0.5, "line_weight (mm) je Vertex");
}
}
/// Ohne `hatch` faellt jedes Cap-Polygon auf das Diagonalmuster (Id 2,
/// angle 0, scale 1) zurueck — das reproduziert das fruehere fixe 45°-Muster.
#[test]
fn ohne_hatch_fallback_auf_diagonale() {
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.3,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[]);
assert!(!verts.is_empty());
assert_eq!(verts[5], FALLBACK_PATTERN, "Fallback = Diagonale (2)");
assert_eq!(verts[6], 0.0, "Fallback-Winkel 0");
assert_eq!(verts[7], 1.0, "Fallback-Skalierung 1");
assert_eq!(verts[8], FALLBACK_HATCH_LINE_WEIGHT_MM, "Fallback-Musterlinienstaerke 0.13 mm");
}
use crate::types::CutBandMeta;
/// Ein Wand-Band (Prio 50, volle Hoehe) und ein u-deckungsgleiches Decken-Band
/// (Prio 100) ueberlappen in einem z-Intervall. Nach `build_cut_caps` (das die
/// Boolean-Dominanz anwendet) darf im Ueberlappungs-z-Intervall KEINE Wand-
/// Cap-Geometrie mehr liegen — die geometrische Kernaussage von P1, direkt auf
/// den erzeugten Welt-Vertices geprueft (nicht bloss „laeuft").
#[test]
fn build_cut_caps_wendet_dominanz_an_wand_verliert_ueberlappung() {
// Wand entlang +X bei x in [0,3], Dicke 0.4, volle Hoehe [0,2.5]; Prio 50.
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: None,
line_weight: None,
}),
};
// Decken-Slab, deren Umriss die Wand an der Schnittstelle (x=1.5) ueberdeckt,
// z in [2.0, 2.4]; Prio 100 (Beton laeuft durch, schneidet die Wand).
let slab = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 2.0,
z_top: 2.4,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 1, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(100),
component_id: Some("concrete".into()),
line_color: None,
line_weight: None,
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[slab]);
assert!(!verts.is_empty());
// Die Wand-Caps tragen pattern==2 (diagonal), die Beton-Caps pattern==1
// (solid). Fuer JEDES Wand-Vertex (pattern 2) muss die Hoehe (v = pos.y,
// Index 1) AUSSERHALB des Beton-Intervalls [2.0,2.4] liegen.
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
let mut wall_verts = 0;
let mut concrete_verts = 0;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
let y = verts[base + 1];
let pattern = verts[base + 5];
if (pattern - 2.0).abs() < 0.5 {
wall_verts += 1;
assert!(
y <= 2.0 + 1e-3 || y >= 2.4 - 1e-3,
"Wand-Cap-Vertex bei v={y} liegt im Beton-z-Intervall (muesste weggeschnitten sein)"
);
} else if (pattern - 1.0).abs() < 0.5 {
concrete_verts += 1;
}
}
assert!(wall_verts > 0, "Wand-Caps vorhanden (ober-/unterhalb des Betons)");
assert!(concrete_verts > 0, "Beton-Caps vorhanden (voll durchlaufend)");
}
/// Reziprok zum vorigen Test (Nutzer-Report 2026-07-08): eine Backstein-WAND
/// (Prio 50) muss die SCHWAECHEREN Decken-Schichten Estrich (30) und Daemmung
/// (20) in der u/v-Ueberlappung wegschneiden — wie im 2D-Schnitt. Wir bauen die
/// Wand als EIN Backstein-Band (Prio 50) und den Boden als zwei duenne Slabs
/// (Estrich oben, Daemmung darunter), deren z-Intervall die Wand ueberlappt.
/// Nach der Dominanz darf im Wand-u-Intervall KEINE Estrich-/Daemmung-Cap mehr
/// liegen (die Wand-Cap dagegen bleibt — Backstein ist dort dominant).
#[test]
fn wand_schneidet_schwaechere_decken_schichten() {
// Backstein-Wand entlang +X, x in [0,3], Dicke 0.4 → u (=y) in [-0.2,0.2],
// volle Hoehe [0,2.5]; Prio 50, Muster 2 (diagonal).
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: None,
line_weight: None,
}),
};
// Estrich-Slab (Prio 30, Muster 3 crosshatch), z in [1.0,1.06], deckt die
// Wand in u ab (Umriss ueber y in [-1,1]).
let screed = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 1.0,
z_top: 1.06,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 3, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(30),
component_id: Some("screed".into()),
line_color: None,
line_weight: None,
}),
};
// Daemmung-Slab (Prio 20, Muster 4), z in [0.96,1.0], gleiche u-Deckung.
let insulation = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 0.96,
z_top: 1.0,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 4, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(20),
component_id: Some("insulation".into()),
line_color: None,
line_weight: None,
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[screed, insulation]);
assert!(!verts.is_empty());
// Wand-u-Intervall (=y) ist [-0.2,0.2]. Fuer JEDES Estrich- (Muster 3) oder
// Daemmung-Vertex (Muster 4) muss u AUSSERHALB [-0.2,0.2] liegen — sonst hat
// der Backstein die schwaechere Schicht dort NICHT weggeschnitten.
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
let mut weak_verts = 0;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
let u = verts[base]; // pos.x = y-Welt (Schnitt entlang +X)
let pattern = verts[base + 5];
if (pattern - 3.0).abs() < 0.5 || (pattern - 4.0).abs() < 0.5 {
weak_verts += 1;
assert!(
u <= -0.2 + 1e-3 || u >= 0.2 - 1e-3,
"Estrich/Daemmung-Vertex bei u={u} liegt im Backstein-u-Intervall \
[-0.2,0.2] (muesste weggeschnitten sein)"
);
}
}
assert!(weak_verts > 0, "Estrich/Daemmung-Caps existieren (ausserhalb der Wand)");
}
/// P2: je ueberlebendem Cut-Cap-Rechteck entstehen 4 Umriss-Kanten als Ribbon-
/// Dreiecke (4 Kanten * 6 Vertices = 24 Vertices je Rechteck), und Farbe +
/// Strichstaerke kommen aus dem Band-Stil durch (Farbe je Vertex, Staerke ueber
/// die Ribbon-Breite messbar).
#[test]
fn build_cut_cap_lines_kantenzahl_und_stil() {
let color = [0.2, 0.4, 0.9];
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: Some(color),
line_weight: Some(2.0),
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_cap_lines(&plane, &[wall.clone()], &[]);
assert!(!verts.is_empty());
assert_eq!(
verts.len() % CUT_LINE_FLOATS_PER_VERTEX,
0,
"Vielfaches des Cut-Linien-Strides"
);
// EIN Rechteck (keine Dominanz-Subtraktion, lone band) -> 4 Kanten * 6 = 24 Vertices.
assert_eq!(
verts.len() / CUT_LINE_FLOATS_PER_VERTEX,
24,
"4 Kanten * 6 Vertices je Ribbon"
);
// Farbe je Vertex aus dem Stil (Index 3/4/5).
let n = verts.len() / CUT_LINE_FLOATS_PER_VERTEX;
for i in 0..n {
let base = i * CUT_LINE_FLOATS_PER_VERTEX;
assert!((verts[base + 3] - color[0]).abs() < 1e-6);
assert!((verts[base + 4] - color[1]).abs() < 1e-6);
assert!((verts[base + 5] - color[2]).abs() < 1e-6);
}
// Strichstaerke kommt durch: eine DICKERE Linie erzeugt ein breiteres Ribbon.
// Messbar an der (u,v)-Ausdehnung senkrecht zur Kante — hier ueber die
// world-Bounding-Box-Hoehe der beiden horizontalen Kanten (v-Ausdehnung der
// gesamten Geometrie) im Vergleich duenn vs. dick.
let bbox_y = |vs: &[f32]| -> f32 {
let n = vs.len() / CUT_LINE_FLOATS_PER_VERTEX;
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for i in 0..n {
let y = vs[i * CUT_LINE_FLOATS_PER_VERTEX + 1];
lo = lo.min(y);
hi = hi.max(y);
}
hi - lo
};
let mut thin = wall.clone();
thin.cut = Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: Some(color),
line_weight: Some(0.2),
});
let thin_verts = build_cut_cap_lines(&plane, &[thin], &[]);
assert!(
bbox_y(&verts) > bbox_y(&thin_verts) + 1e-4,
"dickere Strichstaerke -> groessere v-Ausdehnung der Ribbons"
);
}
}
+104 -22
View File
@@ -144,15 +144,20 @@ struct Globals {
};
@group(0) @binding(0) var<uniform> globals : Globals;
// Bild-Textur + Sampler (group 1). Sampler mit Repeat/Linear (siehe gpu.rs) ->
// das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
@group(1) @binding(0) var tex : texture_2d<f32>;
// Material-Textur-ARRAY + Sampler (group 1). Sampler mit Repeat/Linear (siehe
// gpu.rs) -> das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
// Ebene 0 = Fallback-Schachbrett; Ebenen 1..N = per `set_material_textures`
// hochgeladene Material-Farbkarten. Die Ebene je Vertex kommt aus einem ZWEITEN
// Vertex-Buffer (@location(3)); `mode.z` traegt die Anzahl gueltiger Ebenen.
@group(1) @binding(0) var tex : texture_2d_array<f32>;
@group(1) @binding(1) var samp : sampler;
struct VsIn {
@location(0) position : vec3<f32>,
@location(1) normal : vec3<f32>,
@location(2) uv : vec2<f32>,
// Material-Textur-Ebene dieses Vertex (`-1` = kein Material -> Schachbrett).
@location(3) layer : f32,
};
struct VsOut {
@@ -160,6 +165,7 @@ struct VsOut {
@location(0) world_normal : vec3<f32>,
@location(1) uv : vec2<f32>,
@location(2) world_pos : vec3<f32>,
@location(3) @interpolate(flat) layer : f32,
};
@vertex
@@ -169,6 +175,7 @@ fn vs_main(in : VsIn) -> VsOut {
out.world_normal = in.normal;
out.uv = in.uv;
out.world_pos = in.position;
out.layer = in.layer;
return out;
}
@@ -195,8 +202,13 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb
* vec3<f32>(0.24, 0.27, 0.30);
// EINZIGER Unterschied zu MESH_WGSL: Albedo aus der Bild-Textur statt Vertexfarbe.
let albedo = textureSample(tex, samp, in.uv).rgb;
// Albedo aus dem Material-Textur-Array statt Vertexfarbe. Ebene je Vertex:
// `layer < 0` (kein Material) -> Ebene 0 (Schachbrett). Sonst auf den
// gueltigen Bereich [0, mode.z-1] geklemmt (Sicherheit gegen ueberzaehlige
// Indizes, falls die Material-Menge kleiner ist als erwartet).
let max_layer = max(i32(globals.mode.z) - 1, 0);
let li = clamp(i32(round(in.layer)), 0, max_layer);
let albedo = textureSample(tex, samp, in.uv, li).rgb;
var shaded = albedo * (ambient + diffuse + fill);
@@ -261,11 +273,16 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
"#;
/// WGSL der Schnittflaechen-Kappen (Cut-Caps, section_fill.rs / gpu::cap_pipeline).
/// Eigene TriangleList-Pipeline: schlankes Vertex-Layout [pos vec3, uv vec2],
/// Eigene TriangleList-Pipeline: Vertex-Layout [pos vec3, uv vec2, hatch vec3],
/// dieselbe `Globals`-Bind-Group (group(0) binding(0)) wie Mesh/Grid. Die Caps
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: prozedurale
/// 45-Grad-Diagonalschraffur aus den Ebenen-Koordinaten (u,v) in Modell-Metern —
/// dunkle Tinte auf hellem Papier, passend zur 2D-Schnittkonvention.
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: PROZEDURALE
/// Bauteil-Schraffur je geschnittener Schicht — Muster/Winkel/Skalierung kommen aus
/// dem `hatch`-Vertex-Attribut (pattern, angle_rad, scale; siehe
/// `section_fill.rs`/`types::Hatch`). MONOCHROM (dunkle Tinte auf hellem Papier,
/// passend zur 2D-Schnittkonvention `HATCH_INK`): es variiert das MUSTER, nicht die
/// Farbe. Muster-Ids: 0 = none (leer/weiss), 1 = solid (Vollton),
/// 2 = diagonal (Linienschar, `angle` steuert die Neigung — 0° = horizontal, wie
/// im 2D), 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung).
pub const CAP_WGSL: &str = r#"
struct Globals {
view_proj : mat4x4<f32>,
@@ -281,11 +298,18 @@ struct Globals {
struct VsIn {
@location(0) position : vec3<f32>,
@location(1) uv : vec2<f32>,
// hatch: x = Muster-Id, y = Winkel (Radiant), z = Skalierung, w = Musterlinien-
// Staerke (mm-Papier). Je Cut-Polygon konstant (redundant je Vertex, siehe
// section_fill.rs).
@location(2) hatch : vec4<f32>,
};
struct VsOut {
@builtin(position) clip_pos : vec4<f32>,
@location(0) uv : vec2<f32>,
// flat: die Muster-Parameter sind je Flaeche konstant und duerfen nicht
// interpoliert werden (Muster-Id ist eine Ganzzahl-Auswahl).
@location(1) @interpolate(flat) hatch : vec4<f32>,
};
@vertex
@@ -293,27 +317,85 @@ fn vs_main(in : VsIn) -> VsOut {
var out : VsOut;
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
out.uv = in.uv;
out.hatch = in.hatch;
return out;
}
// Ein AA-gekantetes Linienband entlang der Koordinate `s` (in Einheiten von
// `spacing`): 1 auf der Linie, 0 dazwischen. `fwidth` glaettet blickwinkelrichtig.
fn line_band(s : f32, half_s : f32) -> f32 {
let frac = fract(s);
let dist = min(frac, 1.0 - frac);
let aa = max(fwidth(s), 1e-5);
return 1.0 - smoothstep(half_s, half_s + aa, dist);
}
@fragment
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
// 45-Grad-Diagonalschraffur in Modell-Metern: Linien bei ganzzahligen
// Vielfachen von `spacing` entlang (u + v). `dist` = Abstand (in Einheiten
// von `spacing`) zur naechsten Linie; `fwidth` liefert eine bildschirm-
// groessenrichtige Kantenglaettung (kein Aliasing bei flachem Blickwinkel).
let spacing = 0.08; // Linienabstand ~8 cm
let half = 0.004; // halbe Linienbreite ~4 mm (Strich ~8 mm)
let ink = vec3<f32>(0.06, 0.06, 0.06);
let paper = vec3<f32>(0.94, 0.94, 0.94);
let s = (in.uv.x + in.uv.y) / spacing;
let frac = fract(s);
let dist = min(frac, 1.0 - frac); // 0 auf der Linie
let half_s = half / spacing;
let aa = max(fwidth(s), 1e-5);
let line = 1.0 - smoothstep(half_s, half_s + aa, dist);
let color = mix(paper, ink, line);
let pattern = i32(round(in.hatch.x));
let angle = in.hatch.y;
let scale = max(in.hatch.z, 0.05);
// none (0): leere (weisse) Schnittflaeche — wie „keine Schraffur" im 2D.
if (pattern == 0) {
return vec4<f32>(paper, 1.0);
}
// solid (1): Vollton (Poché) — z. B. Beton.
if (pattern == 1) {
return vec4<f32>(ink, 1.0);
}
// Muster-Koordinaten: (u,v) in Modell-Metern, um `angle` gedreht. Konvention
// wie die 2D-Schraffur (`hatchPreview.parallelLines`): `angle == 0` ergibt
// HORIZONTALE Linien (Phasen-Achse = dv), `+angle` dreht die Schar gegen den
// Uhrzeigersinn wie im 2D-Swatch. `du` laeuft ENTLANG der Linienrichtung, `dv`
// ist die Quer-/Phasen-Achse. (Frueher lag hier ein fixer 45°-Versatz, weil das
// Diagonalmuster ueber `du+dv` lief — dadurch war alles 45° verdreht.)
let ca = cos(angle);
let sa = sin(angle);
let du = in.uv.x * ca + in.uv.y * sa; // entlang der Linienrichtung
let dv = -in.uv.x * sa + in.uv.y * ca; // quer (Phase)
let spacing = 0.08 * scale; // Grund-Linienabstand ~8 cm * Skalierung
// Musterlinien-Staerke PRO Schraffur aus `hatch.w` (mm-Papier, aus dem LineStyle
// des Hatch — wie die 2D-Schnitt-Schraffur: SIA `hatch-hair` 0.02 mm vs. `thin`
// 0.13 mm). Dieselbe mm→Welt-Kalibrierung wie die Schicht-Trennlinien
// (`section_fill::CUT_LINE_MM_TO_WORLD` = 0.01, halbe Breite ×0.5 → Faktor 0.005),
// damit Schraffur- und Trennlinien-Staerke konsistent skalieren. Kleiner Floor,
// damit eine Haarlinie nicht ganz verschwindet.
let half = max(in.hatch.w * 0.005, 0.0001);
let half_s = half / spacing;
var line = 0.0;
if (pattern == 2) {
// diagonal: EINE Linienschar quer zur Phasen-Achse (dv). angle==0 →
// horizontal; `hatch.angle` (z. B. 45° bei Backstein) dreht sie wie im 2D.
line = line_band(dv / spacing, half_s);
} else if (pattern == 3) {
// crosshatch: zwei Scharen ueber Kreuz (Phase dv + Phase du = +90°). Bei
// angle==0 horizontal+vertikal, bei 45° (Beton) das gekippte Kreuz — wie 2D.
line = max(
line_band(dv / spacing, half_s),
line_band(du / spacing, half_s)
);
} else {
// insulation (4, Daemmung): Zickzack. Die Quer-Position (dv) folgt einer
// Dreieckswelle entlang der Lauf-Achse (du) — ein durchgehender Zickzack.
let cell = spacing * 2.0;
let a = du / cell;
let b = dv / cell;
let tri = abs(fract(a) - 0.5) * 2.0; // 0..1 Dreieckswelle
let fb = fract(b);
let d = min(abs(fb - tri), min(abs(fb - tri + 1.0), abs(fb - tri - 1.0)));
let aa = max(fwidth(fb) + fwidth(tri), 1e-5);
let hw = half / cell;
line = 1.0 - smoothstep(hw, hw + aa, d);
}
let color = mix(paper, ink, clamp(line, 0.0, 1.0));
return vec4<f32>(color, 1.0);
}
"#;
+129 -2
View File
@@ -20,6 +20,79 @@ pub type Point2 = [f32; 2];
/// RGB-Farbe/Albedo, Komponenten in [0,1]. Alpha wird (noch) nicht gebraucht.
pub type Rgb = [f32; 3];
/// Schnitt-Schraffur eines Bauteils (fuer den 3D-Live-Schnitt). Rust-seitiges
/// Minimal-Aequivalent zu `HatchStyle` (src/model/types.ts): nur die drei Felder,
/// die der prozedurale Cap-Shader (`shaders::CAP_WGSL`) auswertet — Muster-Id,
/// Winkel und Skalierung. Die Muster-Farbe bleibt MONOCHROM (Tinte auf Papier,
/// siehe 2D-Direktive `HATCH_INK`) und wandert deshalb bewusst NICHT mit.
///
/// `pattern`: 0 = none (leer/weiss), 1 = solid (Vollton), 2 = diagonal (45°),
/// 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung). Die Zuordnung ist
/// mit der TS-Seite (`toWalls3d::hatchPatternId`) und dem Shader synchron zu halten.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Hatch {
/// Muster-Id (siehe oben). Default 0 (none).
#[serde(default)]
pub pattern: u32,
/// Musterdrehung in Grad (Bildschirm-/Schnittebenen-Konvention). Default 0.
#[serde(default)]
pub angle: f32,
/// Grundmassstab (1 = Standardteilung). Default 1.
#[serde(default = "default_hatch_scale")]
pub scale: f32,
/// Strichstaerke der Musterlinien in mm-Papier (aus dem LineStyle des Hatch,
/// `HatchRender.lineWeight`). Der Cap-Shader (`CAP_WGSL`) setzt die Muster-
/// linienbreite daraus (mm->Welt wie die Trennlinien), sodass die Schraffur-
/// Staerke pro Schraffur einstellbar ist (z. B. SIA `hatch-hair` 0.02 mm vs.
/// `thin` 0.13 mm). Default 0.13 (Alt-Verhalten/Haarlinie).
#[serde(default = "default_hatch_line_weight", rename = "lineWeight")]
pub line_weight: f32,
}
fn default_hatch_scale() -> f32 {
1.0
}
fn default_hatch_line_weight() -> f32 {
0.13
}
/// Verschneidungs-/Stil-Metadaten EINES geschnittenen Bauteil-Bandes fuer den
/// 3D-Live-Schnitt — das Rust-Gegenstueck zu den Feldern, mit denen `toSection.ts`
/// die Schnitt-Boolean-Dominanz (`subtractDominantBands`) rechnet. Rein additiv
/// (`#[serde(default)]` an jedem Feld); fehlt der ganze Block (`WallInput::cut ==
/// None`), verhaelt sich der Schnitt wie zuvor (Band nimmt an keiner Subtraktion
/// teil).
///
/// Da der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`/`SlabInput`-Box
/// emittiert (`toWalls3d`, layered), traegt jede geschnittene Schicht hier IHRE
/// eigene Prioritaet/Identitaet/Linienstil — die Dominanz-Verschneidung
/// (`section_boolean.rs`) laeuft dann elementuebergreifend auf den erzeugten
/// (u,v)-Rechtecken, exakt wie im 2D-Schnitt.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CutBandMeta {
/// `Component.joinPriority` der Schicht: ein Band mit STRIKT hoeherer
/// Prioritaet schneidet ein ueberlappendes schwaecheres per Rechteck-
/// Subtraktion weg (elementuebergreifend). `None` -> keine Teilnahme.
#[serde(default)]
pub join_priority: Option<i32>,
/// `Component.id` der Schicht — steuert die MERGE-Regel (gleiche Komponente +
/// Prioritaet verschmelzen zu einem Koerper, keine innere Trennlinie). `None`
/// -> keine Verschmelzung.
#[serde(default)]
pub component_id: Option<String>,
/// Farbe (RGB 0..1) der Schnitt-Umrisslinie dieses Bandes (P2, aus dem
/// `jointLineStyleId`-LineStyle). `None` -> Default-Schnittkanten-Stil.
#[serde(default)]
pub line_color: Option<Rgb>,
/// Strichstaerke der Schnitt-Umrisslinie in mm-Papier (aus dem LineStyle).
/// `None` -> Default. Siehe `section_fill::build_cut_cap_lines` fuer die (ehrlich
/// dokumentierte) mm->Welt-Umsetzung.
#[serde(default)]
pub line_weight: Option<f32>,
}
/// Eine geflachte Wand: Achse (Mittellinie) Start->Ende im Grundriss, plus Dicke,
/// Hoehe und Basis-Hoehe. Aus diesen Feldern wird ein extrudiertes Quader-Mesh
/// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`).
@@ -82,6 +155,30 @@ pub struct WallInput {
/// extrudiert den vollen Achsen-Abschnitt mit Loechern.
#[serde(default)]
pub holes: Vec<Hole>,
/// Optionaler Material-Textur-Index fuer den Stil `Textured`: verweist auf eine
/// per `Renderer::set_material_textures` hochgeladene Material-Farbkarte
/// (Textur-Array-Ebene, 1-basiert; Ebene 0 ist das Fallback-Schachbrett). Da
/// der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert
/// (`toWalls3d`, `layeredWalls:true`), traegt jede Schicht so IHRE Material-
/// Textur. `None` -> Fallback-Schachbrett (bisheriges Verhalten). Vom
/// Schnitt/Serde-Pfad (`section.rs`) ignoriert.
#[serde(default, rename = "materialIndex")]
pub material_index: Option<u32>,
/// Optionale Schnitt-Schraffur dieses Wand-Bandes (siehe `Hatch`). Nur der
/// 3D-Live-Schnitt (`section.rs`/`section_fill.rs`) wertet sie aus, um die
/// Schnittflaeche im MUSTER des Bauteils zu schraffieren (statt eines fixen
/// 45°-Musters). `None` -> Fallback-Diagonalmuster (Alt-Verhalten). Da der
/// 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert
/// (`toWalls3d`, `layeredWalls:true`), traegt jede geschnittene Schicht so
/// ihre eigene Schraffur.
#[serde(default)]
pub hatch: Option<Hatch>,
/// Schnitt-Verschneidungs-/Linienstil-Metadaten dieses Wand-Bandes (siehe
/// `CutBandMeta`). Nur der 3D-Live-Schnitt (`section_fill`/`section_boolean`)
/// wertet sie aus; fehlt sie, bleibt das Alt-Verhalten (keine Dominanz-
/// Subtraktion, Default-Schnittkante).
#[serde(default)]
pub cut: Option<CutBandMeta>,
}
/// Ein rechteckiges Loch (Fenster/Tuer) in EINEM Wandkoerper. Siehe
@@ -162,6 +259,13 @@ pub struct SlabInput {
/// Albedo-Farbe (RGB 0..1). Default heller Deckenton.
#[serde(default = "default_slab_color")]
pub color: Rgb,
/// Optionale Schnitt-Schraffur der Decke (siehe `Hatch`/`WallInput::hatch`).
#[serde(default)]
pub hatch: Option<Hatch>,
/// Schnitt-Verschneidungs-/Linienstil-Metadaten dieser Decken-Schicht (siehe
/// `CutBandMeta`/`WallInput::cut`).
#[serde(default)]
pub cut: Option<CutBandMeta>,
}
fn default_slab_color() -> Rgb {
@@ -179,6 +283,9 @@ pub enum MeshKind {
/// Importiertes Volumen (Gebaeude/DXF) — neutrales Hellgrau.
#[default]
Imported,
/// Per truck (Profil-Extrusion, `src-tauri/trucksolid`) erzeugter Koerper —
/// warmes Orange, damit er sich klar von Wand/Decke/Kontext abhebt.
Extrusion,
}
impl MeshKind {
@@ -191,6 +298,8 @@ impl MeshKind {
MeshKind::Terrain => [0.663, 0.690, 0.635],
// 0x9fa6ae — neutrales Hellgrau fuer importierte Meshes.
MeshKind::Imported => [0.624, 0.651, 0.682],
// Warmes Orange fuer truck-Profil-Extrusionen (Phase-1-Viewer-Beweis).
MeshKind::Extrusion => [0.85, 0.55, 0.25],
}
}
}
@@ -308,14 +417,24 @@ impl Default for Camera {
}
}
/// Die fuenf Kamera-Presets der three.js-Sicht. `Iso` und `Persp` sind
/// perspektivisch/frei; `Front`/`Top`/`Side` sind achsparallel (orthografisch).
/// Die Kamera-Presets der Kardinal-/Iso-Sicht (ROADMAP §11). `Persp` ist frei
/// perspektivisch; alle anderen sind achsparallel bzw. diagonal orthografisch.
/// `Front`/`Back`/`Side`/`Left` sind die vier Kardinalrichtungen (Vorne/Hinten/
/// Rechts/Links im UI, s. `View3d` in TopBar.tsx — keine Himmelsrichtungen, da
/// es noch keinen Nordwinkel gibt); `Iso`/`IsoFrontLeft`/`IsoBackRight`/
/// `IsoBackLeft` sind die vier OBEREN Iso-Oktanten (untere vier bleiben im
/// Hochbau ungenutzt) — `Iso` (vorne-oben-rechts) ist der bestehende Default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CameraPreset {
Front,
Back,
Top,
Side,
Left,
Iso,
IsoFrontLeft,
IsoBackRight,
IsoBackLeft,
Persp,
}
@@ -345,6 +464,14 @@ pub struct TexturedMesh {
pub verts: Vec<f32>,
/// Dreiecks-Indizes (0-basiert auf die Vertices).
pub indices: Vec<u32>,
/// Optionaler PARALLELER Puffer: EIN f32 je Vertex = die Material-Textur-
/// Ebene (Textur-Array-Index) dieses Vertex, oder `-1` fuer „kein Material"
/// (Fallback-Schachbrett, Ebene 0). Leer, wenn ohne Material-Zuordnung gebaut
/// (`textured_from_mesh`) — dann bindet die GPU-Schicht einen konstanten
/// `-1`-Puffer. Als ZWEITER Vertex-Buffer gehalten (nicht in `verts`
/// interleaved), damit das bestehende `[pos,normal,uv]`-Layout (und die
/// Geometrie-Paritaets-Tests) bitgleich bleiben.
pub layers: Vec<f32>,
}
/// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV.
+33 -12
View File
@@ -15,12 +15,13 @@
// (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion
// (Yaw/Pitch/Distanz) rechnet weiterhin die TS-Seite (`math::orbit_eye`-Konvention),
// damit die Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgt.
// `set_view_preset` wendet stattdessen eines der fuenf `View3d`-Praesete
// (front/top/side/iso/perspective) direkt ueber `math::preset_camera` an — Front/
// Top/Side/Iso werden dabei ECHT orthografisch (`preset_camera`; Iso = echte
// Isometrie ohne perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung
// nicht leisten kann. Nach einem Praeset-Sprung bleibt die
// weitere Navigation frei ueber `set_camera` (kein Lock).
// `set_view_preset` wendet stattdessen eines der `View3d`-Praesete (front/back/
// top/side/left/iso/isoFrontLeft/isoBackRight/isoBackLeft/perspective) direkt
// ueber `math::preset_camera` an — alle ausser Perspective werden dabei ECHT
// orthografisch (`preset_camera`; die Iso-Varianten = echte Isometrie ohne
// perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung nicht leisten
// kann. Nach einem Praeset-Sprung bleibt die weitere Navigation frei ueber
// `set_camera` (kein Lock).
//
// MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch
// zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts).
@@ -330,19 +331,25 @@ impl WebModelRenderer {
};
}
/// Wendet eines der fuenf Kamera-Praesets der three.js-Sicht
/// (`front`/`top`/`side`/`iso`/`perspective`, s. `View3d` in TopBar.tsx) auf
/// das aktuelle Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend
/// (mirror der TS-`fitTargetDist`); Front/Top/Side/Iso werden orthografisch,
/// Wendet eines der Kardinal-/Iso-Kamera-Praesets der three.js-Sicht
/// (`front`/`back`/`top`/`side`/`left`/`iso`/`isoFrontLeft`/`isoBackRight`/
/// `isoBackLeft`/`perspective`, s. `View3d` in TopBar.tsx) auf das aktuelle
/// Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend (mirror der
/// TS-`fitTargetDist`); alle Presets ausser Perspective werden orthografisch,
/// nur Perspective ist perspektivisch (`math::preset_camera`). Wirkt erst beim
/// naechsten `render`. „Praeset = hinspringen, kein Lock" — manuelles
/// Orbit/Pan/Zoom bleibt danach frei ueber `set_camera` (s. Wasm3DViewport.tsx).
pub fn set_view_preset(&mut self, preset: &str) -> Result<(), JsValue> {
let preset = match preset {
"front" => CameraPreset::Front,
"back" => CameraPreset::Back,
"top" => CameraPreset::Top,
"side" => CameraPreset::Side,
"left" => CameraPreset::Left,
"iso" => CameraPreset::Iso,
"isoFrontLeft" => CameraPreset::IsoFrontLeft,
"isoBackRight" => CameraPreset::IsoBackRight,
"isoBackLeft" => CameraPreset::IsoBackLeft,
"perspective" => CameraPreset::Persp,
other => {
return Err(JsValue::from_str(&format!(
@@ -377,8 +384,8 @@ impl WebModelRenderer {
/// - `"shaded"` — beleuchtete Bauteilfarben (Default).
/// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet
/// (identisch zum bisherigen `set_render_mode_white(true)`).
/// - `"textured"` — mangels Textur-Pipeline VORERST identisch zu `"shaded"`
/// behandelt (KEIN Fake-Stub); Platzhalter fuer eine spaetere Textur-Schicht.
/// - `"textured"` — Material-Farbkarten je Wand-Band aus dem Textur-Array
/// (`set_material_textures`), Fallback-Schachbrett fuer Baender ohne Material.
/// - `"wireframe"`— nur Modell-Kanten (Feature-Edges), keine Flaechen.
/// - `"hidden"` — Hidden-Line: flach-weisse Flaechen fuellen den Depth-
/// Buffer, dunkle Kanten liegen tiefengetestet obenauf (nur sichtbare).
@@ -431,6 +438,20 @@ impl WebModelRenderer {
Ok(())
}
/// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-Array hoch
/// (`Renderer::set_material_textures`). `rgba` = dicht gepackte RGBA8-Bytes von
/// `layer_count` Ebenen, JEDE exakt 256×256 (der JS-Aufrufer dekodiert/skaliert
/// die Material-Bilder, siehe `viewport/materialTextures.ts`). Ebene 0 bleibt
/// intern das Fallback-Schachbrett; die hochgeladenen Karten belegen die Ebenen
/// 1..N, auf die `WallInput::materialIndex` (1-basiert) zeigt. `layer_count == 0`
/// oder zu kleine Daten setzen auf das reine Schachbrett zurueck. Wirkt erst beim
/// naechsten `render`.
pub fn set_material_textures(&mut self, rgba: &[u8], layer_count: u32) -> Result<(), JsValue> {
self.renderer
.set_material_textures(&self.device, &self.queue, rgba, layer_count);
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));
+43 -3
View File
@@ -6,6 +6,32 @@
#[cfg(any(feature = "native2d", feature = "native3d"))]
mod native;
// Lock-Datei gegen gleichzeitiges Oeffnen derselben Projektdatei aus zwei
// App-Instanzen (OS-Advisory-Lock via `fs4`, siehe lock.rs).
mod lock;
use std::path::PathBuf;
use tauri::State;
/// Fordert den exklusiven Lock fuer eine Projektdatei an. `Ok(())` wenn frei
/// (oder schon von uns gehalten), sonst `Err(Some(LockInfo))` mit Angaben zur
/// haltenden Instanz (pid/hostname/timestamp), oder `Err(None)` falls die
/// Sidecar-Datei gesperrt, aber ihr Info-Block nicht lesbar war.
#[tauri::command]
fn acquire_project_lock(
path: String,
state: State<lock::LockState>,
) -> Result<(), Option<lock::LockInfo>> {
lock::acquire(&state, &PathBuf::from(path))
}
/// Gibt einen von dieser Instanz gehaltenen Lock frei. No-op, wenn der Pfad
/// nicht gelockt war.
#[tauri::command]
fn release_project_lock(path: String, state: State<lock::LockState>) {
lock::release(&state, &PathBuf::from(path));
}
/// Berechnet die Wand-Gehrungen im Rust-Kern und liefert sie ans Frontend.
#[tauri::command]
async fn compute_joins(
@@ -40,7 +66,15 @@ fn push_native_walls(walls: serde_json::Value) {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default().setup(|_app| {
let builder = tauri::Builder::default()
// Native „Speichern unter"-Dialog + Datei-Schreiben aus der Webview.
// Immer aktiv (unabhaengig von native2d/native3d), damit Datei-Exporte
// im Tauri-Fenster einen echten Save-Dialog zeigen statt still zu laden.
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
// Haelt die pro Instanz offen gelockten Projektdatei-Handles (lock.rs).
.manage(lock::LockState::default())
.setup(|_app| {
// Native GPU-Fenster (2D und/oder 3D je nach Feature) auf einem eigenen
// Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt.
#[cfg(any(feature = "native2d", feature = "native3d"))]
@@ -52,10 +86,16 @@ pub fn run() {
let builder = builder.invoke_handler(tauri::generate_handler![
compute_joins,
push_native_scene,
push_native_walls
push_native_walls,
acquire_project_lock,
release_project_lock
]);
#[cfg(not(any(feature = "native2d", feature = "native3d")))]
let builder = builder.invoke_handler(tauri::generate_handler![compute_joins]);
let builder = builder.invoke_handler(tauri::generate_handler![
compute_joins,
acquire_project_lock,
release_project_lock
]);
builder
.run(tauri::generate_context!())
+145
View File
@@ -0,0 +1,145 @@
// Lock-Datei gegen gleichzeitiges Öffnen derselben Projektdatei aus zwei
// App-Instanzen. Prinzip: ein OS-Advisory-Lock (Crate `fs4`, gepflegter
// `fs2`-Nachfolger) auf einer Sidecar-Datei `<projektdatei>.lock`, gehalten
// über ein offenes `File`-Handle im Tauri-`State`. Der OS-Lock fällt beim
// Prozessende AUTOMATISCH weg (auch bei Absturz) — kein manuelles Aufräumen
// nötig, keine verwaiste Sperre blockiert dauerhaft: ohne aktiven OS-Lock kann
// die nächste Instanz dieselbe Sidecar-Datei problemlos wieder exklusiv locken,
// auch wenn die Datei selbst liegen bleibt.
//
// Zusätzlich schreiben wir einen kleinen JSON-Info-Block (pid/hostname/
// timestamp) in die Sidecar-Datei, damit eine zweite Instanz bei einem
// Konflikt eine freundliche Meldung zeigen kann ("bereits geöffnet auf …").
use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use fs4::fs_std::FileExt;
use serde::{Deserialize, Serialize};
/// Info-Block in der Lock-Sidecar-Datei — wer hält den Lock gerade.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockInfo {
pub pid: u32,
pub hostname: String,
/// Unix-Zeitstempel (Sekunden) als String — reicht für eine Anzeige,
/// keine Notwendigkeit für eine Datumsbibliothek.
pub timestamp: String,
}
impl LockInfo {
fn current() -> Self {
LockInfo {
pid: std::process::id(),
hostname: hostname(),
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".to_string()),
}
}
}
/// Bester-Versuch-Hostname ohne zusätzliche Abhängigkeit: erst Umgebungs-
/// variablen (Windows/Unix), sonst das `hostname`-Kommando, sonst "unbekannt".
fn hostname() -> String {
for var in ["COMPUTERNAME", "HOSTNAME"] {
if let Ok(h) = std::env::var(var) {
if !h.is_empty() {
return h;
}
}
}
if let Ok(out) = std::process::Command::new("hostname").output() {
if out.status.success() {
if let Ok(s) = String::from_utf8(out.stdout) {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
}
}
"unbekannt".to_string()
}
/// Alle von DIESER Instanz gehaltenen Locks: Projektdatei-Pfad → offenes
/// File-Handle auf die Sidecar-Lock-Datei. Das Handle hält den OS-Lock, solange
/// es lebt (Drop/Prozessende gibt ihn automatisch frei).
#[derive(Default)]
pub struct LockState(pub Mutex<HashMap<PathBuf, File>>);
fn sidecar_path(project_path: &Path) -> PathBuf {
let mut s = project_path.as_os_str().to_os_string();
s.push(".lock");
PathBuf::from(s)
}
/// Fordert den exklusiven Lock für `project_path` an.
///
/// - Bereits von UNS gehalten (dieselbe Datei nochmal geöffnet) → `Ok(())`.
/// - Frei → OS-Lock holen, eigenen Info-Block reinschreiben, Handle im State
/// behalten, `Ok(())`.
/// - Von einer ANDEREN Instanz gehalten → `Err(Some(info))` mit deren
/// Info-Block (falls lesbar), sonst `Err(None)`.
pub fn acquire(state: &LockState, project_path: &Path) -> Result<(), Option<LockInfo>> {
let mut held = state.0.lock().expect("lock state poisoned");
if held.contains_key(project_path) {
return Ok(());
}
let sidecar = sidecar_path(project_path);
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(&sidecar)
.map_err(|_| None)?;
match FileExt::try_lock_exclusive(&file) {
Ok(()) => {
write_info(&file);
held.insert(project_path.to_path_buf(), file);
Ok(())
}
Err(_) => {
// Gesperrt von einer anderen Instanz -> deren Info-Block lesen (best effort).
Err(read_info(&sidecar))
}
}
}
/// Gibt den von uns gehaltenen Lock für `project_path` frei (schliesst das
/// File-Handle → OS-Lock fällt weg). No-op, wenn wir diesen Pfad nicht halten.
pub fn release(state: &LockState, project_path: &Path) {
let mut held = state.0.lock().expect("lock state poisoned");
held.remove(project_path);
}
/// Schreibt den aktuellen Info-Block (pid/hostname/timestamp dieser Instanz)
/// in die bereits gelockte Sidecar-Datei. Fehler hier sind nicht fatal — der
/// Lock selbst (das OS-Lock) steht unabhängig davon schon.
fn write_info(file: &File) {
let info = LockInfo::current();
let Ok(json) = serde_json::to_string(&info) else {
return;
};
let mut f = file;
let _ = f.set_len(0);
let _ = f.seek(SeekFrom::Start(0));
let _ = f.write_all(json.as_bytes());
let _ = f.flush();
}
/// Liest den Info-Block aus einer (von einer anderen Instanz gesperrten)
/// Sidecar-Datei. `None` wenn die Datei fehlt/kein gültiges JSON enthält.
fn read_info(sidecar: &Path) -> Option<LockInfo> {
let mut contents = String::new();
File::open(sidecar).ok()?.read_to_string(&mut contents).ok()?;
serde_json::from_str(&contents).ok()
}
+6
View File
@@ -344,6 +344,12 @@ fn demo_walls() -> Vec<WallInput> {
color: grey,
openings: vec![],
layers: None,
// Additive Felder (Loecher/Material/Schnitt) — die Demo-Waende nutzen sie
// nicht; Defaults wie im Serde-Pfad.
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
vec![
mk([0.0, 0.0], [6.0, 0.0]),
+1488
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
[workspace]
[package]
name = "trucksolid"
version = "0.1.0"
edition = "2021"
description = "Profil-Extrusion via truck B-Rep → tesselliertes Mesh für render3d"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = []
web = [
"dep:wasm-bindgen",
"dep:serde_json",
"dep:console_error_panic_hook",
]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
# truck-modeling bringt truck-geometry/topology/polymesh transitiv mit (alle
# auf derselben kompatiblen Version). Nur truck-modeling explizit pinnen —
# keine separaten geometry/topology-Einträge, die einen Versions-Konflikt erzeugen.
truck-modeling = "0.3"
# Mesh-Ebenen-CSG (Boolesche Operationen) — bewusst NICHT truck-modeling-Booleans
# (instabil bei koinzidenten Flächen, siehe PENDENZEN.md truck-Integration Phase 4).
# csgrs arbeitet auf Dreiecks-Ebene (BSP-Baum), robust genau in diesem Fall.
# Gepinnt auf einen Git-Commit (crates.io-Veröffentlichungen 0.160.20.1 sind alle
# wegen einer harten, zurückgezogenen core2-Abhängigkeit nicht installierbar; auf
# dem unveröffentlichten main-Branch bereits behoben). Nutzerautorisiert (Spike).
csgrs = { git = "https://github.com/timschmidt/csgrs", rev = "5e7a37a8803d4e56617734687edc9b98f4ebeed7", default-features = false, features = ["f64", "bmesh", "mesh", "earcut"] }
nalgebra = "0.34"
[dev-dependencies]
serde_json = "1"
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
+212
View File
@@ -0,0 +1,212 @@
// Boolesche Operationen (Union/Differenz/Schnitt) zwischen zwei Dreiecks-Meshes.
// Mesh-Ebenen-CSG via csgrs (BSP-Baum) statt truck-modeling-Booleans — letztere
// sind bei koinzidenten/tangentialen Flächen instabil (siehe PENDENZEN.md,
// truck-Integration Phase 4, monstertruck-solid-Spike). csgrs erwies sich im
// Spike an genau diesem Fall (Extrusion bündig/eingebunden in eine Wand,
// deckungsgleicher Querschnitt) als exakt korrekt.
use csgrs::csg::CSG;
use csgrs::mesh::Mesh as CsgMesh;
use csgrs::polygon::Polygon;
use csgrs::triangulated::Triangulated3D;
use csgrs::vertex::Vertex;
use nalgebra::Point3;
use serde::{Deserialize, Serialize};
/// Eingabe-Mesh für eine boolesche Operation: flache f64-Positionen (Modell-
/// Meter) + Dreiecks-Indizes. Getrennt von `MeshOutput` (dort f32, da Render-
/// Ausgabe) — hier f64, da Eingabe aus Modelldaten und Präzision für die BSP-
/// Klassifikation an koinzidenten Flächen zählt.
#[derive(Deserialize)]
pub struct BooleanMeshInput {
pub a_positions: Vec<f64>,
pub a_indices: Vec<u32>,
pub b_positions: Vec<f64>,
pub b_indices: Vec<u32>,
/// "union" | "difference" | "intersection"
pub op: String,
}
#[derive(Serialize)]
pub struct BooleanMeshOutput {
pub positions: Vec<f32>,
pub indices: Vec<u32>,
/// Ob das Ergebnis leer ist (z. B. Schnitt zweier sich nur berührender
/// Körper) — csgrs liefert das als Trimesh-Fehler statt eines leeren
/// Meshes zurück, hier auf einen sauberen Fall normalisiert.
pub empty: bool,
}
fn mesh_from_triangles(positions: &[f64], indices: &[u32]) -> Result<CsgMesh<()>, String> {
if indices.len() % 3 != 0 {
return Err("indices müssen Dreiecke sein (Vielfaches von 3)".into());
}
let n_verts = positions.len() / 3;
let mut polygons = Vec::with_capacity(indices.len() / 3);
for tri in indices.chunks(3) {
let mut pts = [Point3::origin(); 3];
for (k, &i) in tri.iter().enumerate() {
let idx = i as usize;
if idx >= n_verts {
return Err(format!("Index {idx} außerhalb der Positions-Liste"));
}
let o = idx * 3;
pts[k] = Point3::new(positions[o], positions[o + 1], positions[o + 2]);
}
let normal = (pts[1] - pts[0]).cross(&(pts[2] - pts[0]));
let verts = vec![
Vertex::new(pts[0], normal),
Vertex::new(pts[1], normal),
Vertex::new(pts[2], normal),
];
polygons.push(Polygon::new(verts, ()));
}
Ok(CsgMesh::from_polygons(&polygons, ()))
}
fn triangles_from_mesh(m: &CsgMesh<()>) -> (Vec<f32>, Vec<u32>) {
let mut positions: Vec<f32> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
let mut next = 0u32;
m.visit_triangles(|tri| {
for v in &tri {
positions.push(v.position.x as f32);
positions.push(v.position.y as f32);
positions.push(v.position.z as f32);
}
indices.push(next);
indices.push(next + 1);
indices.push(next + 2);
next += 3;
});
(positions, indices)
}
pub fn boolean_mesh_core(
a_positions: &[f64],
a_indices: &[u32],
b_positions: &[f64],
b_indices: &[u32],
op: &str,
) -> Result<BooleanMeshOutput, String> {
let a = mesh_from_triangles(a_positions, a_indices)?;
let b = mesh_from_triangles(b_positions, b_indices)?;
let result = match op {
"union" => a.union(&b),
"difference" => a.difference(&b),
"intersection" => a.intersection(&b),
other => return Err(format!("unbekannte boolesche Operation: {other}")),
};
let (positions, indices) = triangles_from_mesh(&result);
Ok(BooleanMeshOutput {
empty: indices.is_empty(),
positions,
indices,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn cuboid(x: f64, y: f64, z: f64, w: f64, l: f64, h: f64) -> (Vec<f64>, Vec<u32>) {
let corners = [
(x, y, z),
(x + w, y, z),
(x + w, y + l, z),
(x, y + l, z),
(x, y, z + h),
(x + w, y, z + h),
(x + w, y + l, z + h),
(x, y + l, z + h),
];
let mut positions = Vec::with_capacity(24);
for (cx, cy, cz) in corners {
positions.push(cx);
positions.push(cy);
positions.push(cz);
}
// 12 Dreiecke, konsistent nach außen orientiert (Rechte-Hand-Regel).
let indices: Vec<u32> = vec![
0, 2, 1, 0, 3, 2, // unten (-z)
4, 5, 6, 4, 6, 7, // oben (+z)
0, 5, 4, 0, 1, 5, // -y
1, 6, 5, 1, 2, 6, // +x
2, 7, 6, 2, 3, 7, // +y
3, 4, 7, 3, 0, 4, // -x
];
(positions, indices)
}
fn volume_of(positions: &[f32], indices: &[u32]) -> f64 {
// Divergenztheorem (Tetraeder vom Ursprung), robust für beliebige geschlossene Dreiecksmeshes.
let mut vol = 0.0f64;
for tri in indices.chunks(3) {
let mut p = [[0.0f64; 3]; 3];
for (k, &i) in tri.iter().enumerate() {
let o = i as usize * 3;
p[k] = [
positions[o] as f64,
positions[o + 1] as f64,
positions[o + 2] as f64,
];
}
vol += (p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2])
- p[0][1] * (p[1][0] * p[2][2] - p[2][0] * p[1][2])
+ p[0][2] * (p[1][0] * p[2][1] - p[2][0] * p[1][1]))
/ 6.0;
}
vol.abs()
}
#[test]
fn union_of_overlapping_cuboids() {
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let (bp, bi) = cuboid(0.5, 0.5, 0.5, 1.0, 1.0, 1.0);
let r = boolean_mesh_core(&ap, &ai, &bp, &bi, "union").unwrap();
assert!(!r.empty);
assert!((volume_of(&r.positions, &r.indices) - 1.875).abs() < 1e-6);
}
#[test]
fn intersection_of_overlapping_cuboids() {
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let (bp, bi) = cuboid(0.5, 0.5, 0.5, 1.0, 1.0, 1.0);
let r = boolean_mesh_core(&ap, &ai, &bp, &bi, "intersection").unwrap();
assert!(!r.empty);
assert!((volume_of(&r.positions, &r.indices) - 0.125).abs() < 1e-6);
}
/// Praxisfall: Extrusion 0.1m in eine Wand eingebunden, deckungsgleicher
/// Querschnitt — genau der Fall, an dem monstertruck-solid scheiterte.
#[test]
fn wall_minus_embedded_extrusion_matching_cross_section() {
let wall = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let embedded = cuboid(0.9, 0.0, 0.0, 1.0, 1.0, 1.0);
let r = boolean_mesh_core(&wall.0, &wall.1, &embedded.0, &embedded.1, "difference").unwrap();
assert!(!r.empty);
assert!((volume_of(&r.positions, &r.indices) - 0.9).abs() < 1e-6);
}
#[test]
fn flush_touching_union_is_exact() {
let a = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let b = cuboid(1.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let r = boolean_mesh_core(&a.0, &a.1, &b.0, &b.1, "union").unwrap();
assert!(!r.empty);
assert!((volume_of(&r.positions, &r.indices) - 2.0).abs() < 1e-6);
}
#[test]
fn rejects_non_triangle_indices() {
let (ap, _) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let bad_indices = vec![0u32, 1, 2, 3];
assert!(boolean_mesh_core(&ap, &bad_indices, &ap, &bad_indices, "union").is_err());
}
#[test]
fn rejects_unknown_op() {
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
assert!(boolean_mesh_core(&ap, &ai, &ap, &ai, "xor").is_err());
}
}
+546
View File
@@ -0,0 +1,546 @@
// Profil-Extrusion via truck B-Rep. Erzeugt ein tesselliertes Mesh aus einem
// 2D-Polygon-Querschnitt (XY-Ebene, Modell-Meter) durch lineare Extrusion
// entlang +Z. Ausgabe ist kompatibel mit render3d::types::MeshInput.
//
// truck-Architektur:
// truck_modeling::builder — Vertex/Edge/Wire/Face bauen + tsweep (Validierung)
// Tessellierung — direkt aus den Eingangskoordinaten (Prismen-Geometrie)
// Bewusst NICHT genutzt: truck-modeling Booleans (instabil upstream).
// truck-polymesh wird nicht benötigt (keine Tess.-API für Solids in 0.3).
use serde::{Deserialize, Serialize};
use truck_modeling::*;
mod boolean;
pub use boolean::{boolean_mesh_core, BooleanMeshInput, BooleanMeshOutput};
/// Flaches [x0,y0, x1,y1, …] Array + Höhe → Extrusion.
#[derive(Deserialize)]
pub struct ExtrudePolyInput {
pub points: Vec<f64>,
pub height: f64,
/// Verjüngung 0.0 (Prisma, Default) … 1.0 (Spitze/Kegel-Pyramide). Fehlt
/// das Feld (ältere Aufrufer), greift serde-Default 0.0 — unverändertes
/// Prisma-Verhalten.
#[serde(default)]
pub taper: f64,
}
/// Tesselliertes Mesh, kompatibel mit render3d::types::MeshInput.
/// positions: flat [x,y,z, …] in Modell-Metern; indices: Dreiecks-Indizes.
#[derive(Serialize)]
pub struct MeshOutput {
pub positions: Vec<f32>,
pub indices: Vec<u32>,
}
/// Extrudiert ein geschlossenes Polygon (≥3 Punkte) um `height` Meter nach +Z,
/// optional verjüngt (`taper` 0.0 Prisma … 1.0 Spitze/Kegel-Pyramide — linear
/// zum Schwerpunkt skaliert). Nutzt truck::builder zur Validierung der
/// Grundfläche; tesselliert dann direkt aus den Koordinaten.
pub fn extrude_polygon_core(
pts: &[(f64, f64)],
height: f64,
taper: f64,
) -> std::result::Result<MeshOutput, String> {
if pts.len() < 3 {
return Err("min 3 Punkte".into());
}
if height <= 0.0 {
return Err("height muss > 0 sein".into());
}
if !(0.0..=1.0).contains(&taper) {
return Err("taper muss zwischen 0 und 1 liegen".into());
}
// Letzten Punkt entfernen falls er den ersten wiederholt (geschlossener Ring).
let ring: Vec<(f64, f64)> = {
let mut r = pts.to_vec();
if r.len() >= 2 {
let first = r[0];
let last = *r.last().unwrap();
if (first.0 - last.0).abs() < 1e-10 && (first.1 - last.1).abs() < 1e-10 {
r.pop();
}
}
r
};
if ring.len() < 3 {
return Err("min 3 eindeutige Punkte".into());
}
let n = ring.len();
// ── truck B-Rep: try_attach_plane validiert Planarität und Degenerierung ──
let verts: Vec<Vertex> = ring
.iter()
.map(|&(x, y)| builder::vertex(Point3::new(x, y, 0.0)))
.collect();
let edges: Vec<Edge> = (0..n)
.map(|i| builder::line(&verts[i], &verts[(i + 1) % n]))
.collect();
let wire = Wire::from_iter(edges);
// Gibt Err zurück bei degeneriertem / nicht-ebenem Profil.
let face = builder::try_attach_plane(&[wire]).map_err(|e| format!("{e}"))?;
// tsweep erzeugt den Solid — wir nutzen ihn zur Vollständigkeit, auch wenn
// wir ihn für die Tessellierung nicht direkt traversieren (nur fürs
// ungetaperte Prisma sinnvoll validiert; Verjüngung ist reine Tessellierung).
let _solid: Solid = builder::tsweep(&face, Vector3::new(0.0, 0.0, height));
let centroid = polygon_centroid(&ring);
// Deckel-/Boden-Triangulierung fuer BELIEBIGE (auch konkave) Profile per
// Ohr-Clipping — NICHT per Fächer-ab-Vertex-0 (der nur fuer konvexe bzw.
// von Vertex 0 aus sternfoermige Polygone korrekt ist; bei einem T-Traeger
// o.ae. erzeugt ein Fächer Phantom-Dreiecke quer durch die konkave Kerbe).
// `cap_tris` ist bereits in der Konvention orientiert, die der bisherige
// "Fächer ab Vertex 0" fuer KONVEXE Ringe erzeugt hat (a,b,c mit
// aufsteigendem Index), sodass die Deck-/Boden-Zuordnung unten unveraendert
// bleibt.
let cap_tris = ear_triangulate(&ring);
// Volle Verjüngung (taper ≈ 1): Spitze statt entartetem Deck-Ring — Kegel/
// Pyramide als n Boden-Vertices + 1 Spitzen-Vertex, sonst gäbe es
// Nulldreiecke (Deckfläche + halbe Seitenflächen) mit Kreuzprodukt-Normale
// (0,0,0) am Deck.
if taper >= 1.0 - 1e-9 {
let mut positions: Vec<f32> = Vec::with_capacity((n + 1) * 3);
for &(x, y) in &ring {
positions.extend_from_slice(&[x as f32, y as f32, 0.0_f32]);
}
positions.extend_from_slice(&[centroid.0 as f32, centroid.1 as f32, height as f32]);
let apex = n as u32;
let mut indices: Vec<u32> = Vec::with_capacity((cap_tris.len() + n) * 3);
// Bodenfläche: wie im Prisma-Fall die zu "oben" umgekehrte Wicklung
// (CCW von unten).
for &(a, b, c) in &cap_tris {
indices.extend_from_slice(&[a as u32, c as u32, b as u32]);
}
// Seitenflächen: ein Dreieck je Kante zur Spitze.
for i in 0..n as u32 {
let j = (i + 1) % n as u32;
indices.extend_from_slice(&[i, j, apex]);
}
return Ok(MeshOutput { positions, indices });
}
// ── Tessellierung (Prisma bei taper=0, sonst Pyramidenstumpf) ──
// Für ein n-Eck ergeben sich:
// Bodenfläche: len(cap_tris) Dreiecke (Ohr-Clipping, konkav-sicher)
// Deckfläche: len(cap_tris) Dreiecke
// Seitenflächen: n * 2 Dreiecke (je Kante ein Rechteck → 2 Dreiecke)
let mut positions: Vec<f32> = Vec::with_capacity(2 * n * 3);
let mut indices: Vec<u32> = Vec::with_capacity((cap_tris.len() * 2 + n * 2) * 3);
// Vertices: Boden (0..n-1) gefolgt von Dach (n..2n-1) — Dach zum
// Schwerpunkt hin um (1-taper) skaliert (taper=0 ⇒ Prisma, unveränderte
// Kontur; 0<taper<1 ⇒ Pyramidenstumpf).
let scale = 1.0 - taper;
for &(x, y) in &ring {
positions.extend_from_slice(&[x as f32, y as f32, 0.0_f32]);
}
for &(x, y) in &ring {
let tx = centroid.0 + (x - centroid.0) * scale;
let ty = centroid.1 + (y - centroid.1) * scale;
positions.extend_from_slice(&[tx as f32, ty as f32, height as f32]);
}
let base = 0u32;
let top = n as u32;
// Bodenfläche: umgekehrte Wicklung relativ zur Deckfläche (CCW von unten).
for &(a, b, c) in &cap_tris {
indices.extend_from_slice(&[base + a as u32, base + c as u32, base + b as u32]);
}
// Deckfläche: gleiche Wicklung wie `cap_tris` (CCW von oben).
for &(a, b, c) in &cap_tris {
indices.extend_from_slice(&[top + a as u32, top + b as u32, top + c as u32]);
}
// Seitenflächen: Rechteck pro Kante → 2 Dreiecke
for i in 0..n as u32 {
let j = (i + 1) % n as u32;
// Unteres Dreieck
indices.extend_from_slice(&[base + i, base + j, top + i]);
// Oberes Dreieck
indices.extend_from_slice(&[base + j, top + j, top + i]);
}
Ok(MeshOutput { positions, indices })
}
/// Signierte Fläche (Shoelace) — positiv bei CCW-Umlauf.
fn signed_area_f64(ring: &[(f64, f64)]) -> f64 {
let n = ring.len();
let mut a = 0.0;
let mut j = n - 1;
for i in 0..n {
a += ring[j].0 * ring[i].1 - ring[i].0 * ring[j].1;
j = i;
}
a * 0.5
}
/// Flächen-gewichteter Schwerpunkt eines einfachen Polygons (nicht der reine
/// Vertex-Mittelwert, der bei asymmetrischen/konkaven Profilen verzerrt ist).
/// Fallback auf Vertex-Mittelwert bei (nahezu) entarteter Fläche.
fn polygon_centroid(ring: &[(f64, f64)]) -> (f64, f64) {
let n = ring.len();
let a = signed_area_f64(ring);
if a.abs() < 1e-12 {
let (sx, sy) = ring.iter().fold((0.0, 0.0), |(sx, sy), &(x, y)| (sx + x, sy + y));
return (sx / n as f64, sy / n as f64);
}
let mut cx = 0.0;
let mut cy = 0.0;
for i in 0..n {
let (x0, y0) = ring[i];
let (x1, y1) = ring[(i + 1) % n];
let cross = x0 * y1 - x1 * y0;
cx += (x0 + x1) * cross;
cy += (y0 + y1) * cross;
}
(cx / (6.0 * a), cy / (6.0 * a))
}
/// Kreuzprodukt (b-a) x (c-a) im Grundriss.
#[inline]
fn cross2_f64(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 {
(b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
}
/// Liegt p im (a,b,c)-Dreieck? (CCW-orientiert).
fn point_in_tri_f64(a: (f64, f64), b: (f64, f64), c: (f64, f64), p: (f64, f64)) -> bool {
let d1 = cross2_f64(a, b, p);
let d2 = cross2_f64(b, c, p);
let d3 = cross2_f64(c, a, p);
let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
!(has_neg && has_pos)
}
/// Ear-Clipping-Triangulierung eines einfachen (lochfreien) Rings — robust fuer
/// konvexe UND konkave Profile (z. B. T-Traeger, L-Profil, Freiform). Portiert
/// von `render3d::mesh::triangulate` (dort fuer Decken/Slabs genutzt) auf f64.
/// O(n^2), fuer Extrusionsprofile mit wenigen Ecken voellig ausreichend.
/// Liefert Dreiecke als (a,b,c)-Indextripel (0-basiert auf `ring`), in der
/// Wicklung des (intern auf CCW normalisierten) Rings.
fn ear_triangulate(ring: &[(f64, f64)]) -> Vec<(usize, usize, usize)> {
let n = ring.len();
if n < 3 {
return Vec::new();
}
let mut idx: Vec<usize> = (0..n).collect();
if signed_area_f64(ring) < 0.0 {
idx.reverse();
}
let mut tris: Vec<(usize, usize, usize)> = Vec::new();
let mut guard = 0usize;
let max_guard = n * n + 16;
while idx.len() > 3 && guard < max_guard {
guard += 1;
let mut clipped = false;
let m = idx.len();
for i in 0..m {
let i_prev = idx[(i + m - 1) % m];
let i_cur = idx[i];
let i_next = idx[(i + 1) % m];
let a = ring[i_prev];
let b = ring[i_cur];
let c = ring[i_next];
if cross2_f64(a, b, c) <= 0.0 {
continue; // konkav/kollinear -> kein Ohr
}
let mut contains = false;
for &vi in &idx {
if vi == i_prev || vi == i_cur || vi == i_next {
continue;
}
if point_in_tri_f64(a, b, c, ring[vi]) {
contains = true;
break;
}
}
if contains {
continue;
}
tris.push((i_prev, i_cur, i_next));
idx.remove(i);
clipped = true;
break;
}
if !clipped {
break;
}
}
if idx.len() == 3 {
tris.push((idx[0], idx[1], idx[2]));
}
tris
}
/// Zylinder-Extrusion (Kreis-Querschnitt), optional verjüngt (Kegel bei
/// `taper=1.0`). Tesselliert den Kreis in N Segmente und ruft
/// `extrude_polygon_core` auf.
pub fn extrude_circle_core(
cx: f64,
cy: f64,
r: f64,
height: f64,
taper: f64,
) -> std::result::Result<MeshOutput, String> {
if r <= 0.0 {
return Err("Radius muss > 0 sein".into());
}
let n = ((2.0 * std::f64::consts::PI * r / 0.02).ceil() as usize).max(16);
let pts: Vec<(f64, f64)> = (0..n)
.map(|i| {
let a = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
(cx + r * a.cos(), cy + r * a.sin())
})
.collect();
extrude_polygon_core(&pts, height, taper)
}
// ── WASM-Bindings (nur mit Feature "web") ────────────────────────────────────
#[cfg(feature = "web")]
mod web {
use super::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn init() {
console_error_panic_hook::set_once();
}
/// Extrudiert ein Polygon-Profil.
/// Input JSON: `{ "points": [x0,y0,…], "height": 2.5 }`
/// Output JSON: `{ "positions": […], "indices": […] }` oder JsError.
#[wasm_bindgen]
pub fn extrude_polygon(input_json: &str) -> std::result::Result<String, JsError> {
let input: ExtrudePolyInput =
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
if input.points.len() % 2 != 0 {
return Err(JsError::new("points muss x/y-Paare enthalten"));
}
let pts: Vec<(f64, f64)> = input
.points
.chunks(2)
.map(|c| (c[0], c[1]))
.collect();
let mesh = extrude_polygon_core(&pts, input.height, input.taper)
.map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
/// Extrudiert einen Kreis-Querschnitt (Zylinder, oder Kegel bei `taper=1`).
/// Input JSON: `{ "cx": 0, "cy": 0, "r": 0.15, "height": 3.0, "taper": 0.0 }`
#[wasm_bindgen]
pub fn extrude_circle(input_json: &str) -> std::result::Result<String, JsError> {
#[derive(serde::Deserialize)]
struct In {
cx: f64,
cy: f64,
r: f64,
height: f64,
#[serde(default)]
taper: f64,
}
let i: In =
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
let mesh = extrude_circle_core(i.cx, i.cy, i.r, i.height, i.taper)
.map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
/// Boolesche Operation zwischen zwei Dreiecks-Meshes (Mesh-Ebenen-CSG via
/// csgrs). Input JSON: `{ "a_positions": […], "a_indices": […],
/// "b_positions": […], "b_indices": […], "op": "union"|"difference"|"intersection" }`
/// Output JSON: `{ "positions": […], "indices": […], "empty": bool }`
#[wasm_bindgen]
pub fn boolean_mesh(input_json: &str) -> std::result::Result<String, JsError> {
let input: BooleanMeshInput =
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
let mesh = boolean_mesh_core(
&input.a_positions,
&input.a_indices,
&input.b_positions,
&input.b_indices,
&input.op,
)
.map_err(|e| JsError::new(&e))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quad_extrusion_vertex_count() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
let m = extrude_polygon_core(&pts, 2.0, 0.0).unwrap();
// 2 * 4 = 8 Vertices → 24 Floats
assert_eq!(m.positions.len(), 8 * 3, "Vertex-Anzahl");
assert_eq!(m.indices.len() % 3, 0, "unvollständige Dreiecke");
// Quader: 2*(4-2) + 4*2 = 4 + 8 = 12 Dreiecke
assert_eq!(m.indices.len(), 12 * 3, "Dreieck-Anzahl für Quader");
}
#[test]
fn l_profile_extrusion() {
let pts = vec![
(0.0, 0.0),
(0.3, 0.0),
(0.3, 0.1),
(0.1, 0.1),
(0.1, 0.3),
(0.0, 0.3),
];
let m = extrude_polygon_core(&pts, 3.0, 0.0).unwrap();
assert_eq!(m.indices.len() % 3, 0);
assert!(!m.positions.is_empty());
}
#[test]
fn cylinder_extrusion() {
let m = extrude_circle_core(0.0, 0.0, 0.15, 3.0, 0.0).unwrap();
assert_eq!(m.indices.len() % 3, 0);
assert!(!m.positions.is_empty());
}
#[test]
fn rejects_too_few_points() {
assert!(extrude_polygon_core(&[(0.0, 0.0), (1.0, 0.0)], 1.0, 0.0).is_err());
}
#[test]
fn rejects_zero_height() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)];
assert!(extrude_polygon_core(&pts, 0.0, 0.0).is_err());
}
#[test]
fn rejects_taper_out_of_range() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)];
assert!(extrude_polygon_core(&pts, 1.0, 1.5).is_err());
assert!(extrude_polygon_core(&pts, 1.0, -0.1).is_err());
}
/// Kegel-Volumen (taper=1, Kreis-Querschnitt) via Divergenzsatz gegen die
/// analytische Formel V = π·r²·h/3 geprüft.
#[test]
fn cone_taper_one_has_correct_volume() {
let r = 0.5;
let h = 2.0;
let m = extrude_circle_core(0.0, 0.0, r, h, 1.0).unwrap();
// Spitze ist der letzte Vertex — Boden-Vertices sind alle bei z=0, kein
// separater Deck-Ring mehr (kein Nulldreieck-Risiko).
assert_eq!(m.positions.len() % 3, 0);
let n_verts = m.positions.len() / 3;
// Boden-N-Eck + 1 Spitze.
let apex_z = m.positions[(n_verts - 1) * 3 + 2];
assert!((apex_z as f64 - h).abs() < 1e-6, "Spitze muss bei z=height liegen");
let mut vol = 0.0f64;
for tri in m.indices.chunks(3) {
let mut p = [[0.0f64; 3]; 3];
for (k, &i) in tri.iter().enumerate() {
let o = i as usize * 3;
p[k] = [m.positions[o] as f64, m.positions[o + 1] as f64, m.positions[o + 2] as f64];
}
vol += (p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2])
- p[0][1] * (p[1][0] * p[2][2] - p[2][0] * p[1][2])
+ p[0][2] * (p[1][0] * p[2][1] - p[2][0] * p[1][1]))
/ 6.0;
}
let expected = std::f64::consts::PI * r * r * h / 3.0;
// Kreis ist n-eck-tesselliert (nicht analytisch exakt) → grobe Toleranz.
assert!((vol.abs() - expected).abs() / expected < 0.01, "Kegel-Volumen: {} vs {}", vol.abs(), expected);
}
/// Pyramidenstumpf (taper=0.5, Quadrat-Querschnitt): Deckfläche muss auf
/// die Hälfte der Kantenlänge geschrumpft sein, zentriert um den
/// Schwerpunkt der Grundfläche.
#[test]
fn frustum_taper_half_shrinks_top_toward_centroid() {
let pts = vec![(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)];
let m = extrude_polygon_core(&pts, 1.0, 0.5).unwrap();
// Prisma-Topologie bleibt erhalten (2n Vertices, kein Spitzen-Sonderfall).
assert_eq!(m.positions.len(), 8 * 3);
// Deck-Ring: Vertices 4..7. Schwerpunkt der Grundfläche ist (1,1).
// scale=0.5 ⇒ Ecke (0,0)→(1,1)+0.5*((0,0)-(1,1))=(0.5,0.5).
let top0 = (m.positions[4 * 3] as f64, m.positions[4 * 3 + 1] as f64);
assert!((top0.0 - 0.5).abs() < 1e-6 && (top0.1 - 0.5).abs() < 1e-6, "Deck-Ecke: {:?}", top0);
}
/// Punkt-in-Polygon (Ray-Casting) für die Korrektheits-Prüfung unten.
fn point_in_polygon(p: (f64, f64), poly: &[(f64, f64)]) -> bool {
let n = poly.len();
let mut inside = false;
let mut j = n - 1;
for i in 0..n {
let (xi, yi) = poly[i];
let (xj, yj) = poly[j];
if ((yi > p.1) != (yj > p.1)) && (p.0 < (xj - xi) * (p.1 - yi) / (yj - yi) + xi) {
inside = !inside;
}
j = i;
}
inside
}
/// T-Träger-Querschnitt (konkav, NICHT von Vertex 0 aus sternförmig) —
/// genau das Zielprofil aus PENDENZEN/truck-plan.md. Eine Fächer-Triangu-
/// lierung ab Vertex 0 erzeugt hier Phantom-Dreiecke quer durch die
/// konkave Kerbe (nachgerechnet: 2 von 6 Fächer-Dreiecken liegen mit ihrem
/// Schwerpunkt ausserhalb des Profils). Regressionstest fürs Ohr-Clipping.
#[test]
fn t_beam_caps_stay_inside_polygon() {
let pts = vec![
(1.0, 0.0),
(2.0, 0.0),
(2.0, 1.0),
(3.0, 1.0),
(3.0, 2.0),
(0.0, 2.0),
(0.0, 1.0),
(1.0, 1.0),
];
let m = extrude_polygon_core(&pts, 1.0, 0.0).unwrap();
let mut checked = 0;
for tri in m.indices.chunks(3) {
let p: Vec<[f32; 3]> = tri
.iter()
.map(|&i| {
let o = i as usize * 3;
[m.positions[o], m.positions[o + 1], m.positions[o + 2]]
})
.collect();
// Nur Deckel/Boden (alle 3 Ecken auf derselben Höhe) prüfen, nicht
// die Seitenwand-Quads (die spannen z zwischen 0 und height).
if (p[0][2] - p[1][2]).abs() > 1e-6 || (p[1][2] - p[2][2]).abs() > 1e-6 {
continue;
}
let centroid = (
(p[0][0] + p[1][0] + p[2][0]) as f64 / 3.0,
(p[0][1] + p[1][1] + p[2][1]) as f64 / 3.0,
);
assert!(
point_in_polygon(centroid, &pts),
"Deckel-/Boden-Dreieck-Schwerpunkt {:?} liegt ausserhalb des T-Profils",
centroid
);
checked += 1;
}
assert!(checked > 0, "kein Deckel-/Boden-Dreieck gefunden");
}
}