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:
Generated
+173
-3
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 0–4 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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!())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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]),
|
||||
|
||||
Generated
+1488
File diff suppressed because it is too large
Load Diff
@@ -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.16–0.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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user