diff --git a/scripts/dumpNativeScene.ts b/scripts/dumpNativeScene.ts index f6172b7..d61aa3c 100644 --- a/scripts/dumpNativeScene.ts +++ b/scripts/dumpNativeScene.ts @@ -1,6 +1,6 @@ // Erzeugt aus dem echten Modell (sampleProject → generatePlan) die JSON-Dateien, // die die nativen wgpu-Fenster (render2d/render3d) beim Start laden: -// src-tauri/assets/native2d_scene.json (RScene: fills/outlines/lines) +// src-tauri/assets/native2d_scene.json (RScene: fills/outlines/lines/texts) // src-tauri/assets/native3d_walls.json (RWall[]) // Wird via scripts/dump-native-scene.mjs (esbuild-Bundle) unter Node ausgeführt. @@ -30,7 +30,7 @@ writeFileSync(scenePath, JSON.stringify(scene)); writeFileSync(wallsPath, JSON.stringify(walls)); console.log( - `native2d_scene.json: ${scene.fills.length} fills, ${scene.outlines.length} outlines, ${scene.lines.length} lines`, + `native2d_scene.json: ${scene.fills.length} fills, ${scene.outlines.length} outlines, ${scene.lines.length} lines, ${scene.texts.length} texts`, ); console.log(`native3d_walls.json: ${walls.length} walls`); console.log(`→ ${outDir}`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0ee2f64..0daaef7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -702,6 +702,29 @@ dependencies = [ "libc", ] +[[package]] +name = "cosmic-text" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2" +dependencies = [ + "bitflags 2.13.0", + "fontdb", + "log", + "rangemap", + "rayon", + "rustc-hash 1.1.0", + "rustybuzz", + "self_cell", + "swash", + "sys-locale", + "ttf-parser 0.21.1", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -729,6 +752,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -797,7 +839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdbd1f579714e3c809ebd822c81ef148b1ceaeb3d535352afc73fd0c4c6a0017" dependencies = [ "bitflags 2.13.0", - "libloading 0.7.4", + "libloading 0.8.9", "winapi", ] @@ -974,7 +1016,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]] @@ -1081,6 +1123,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "embed-resource" version = "3.0.9" @@ -1151,6 +1199,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1210,6 +1277,38 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3971f9a5ca983419cdc386941ba3b9e1feba01a0ab888adf78739feb2798492" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.20.0", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1595,6 +1694,19 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "glyphon" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11b1afb04c1a1be055989042258499473d0a9447f16450b433aba10bc2a46e7" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.2", + "wgpu", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1740,7 +1852,7 @@ dependencies = [ "bitflags 2.13.0", "com", "libc", - "libloading 0.7.4", + "libloading 0.8.9", "thiserror 1.0.69", "widestring", "winapi", @@ -2313,6 +2425,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.18" @@ -2364,6 +2482,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3009,7 +3133,7 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ - "ttf-parser", + "ttf-parser 0.25.1", ] [[package]] @@ -3390,12 +3514,48 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.22.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69aacb76b5c29acfb7f90155d39759a29496aebb49395830e928a9703d2eec2f" +dependencies = [ + "bytemuck", + "font-types", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -3489,6 +3649,7 @@ version = "0.1.0" dependencies = [ "bytemuck", "env_logger", + "glyphon", "naga", "pollster", "serde", @@ -3551,6 +3712,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3604,6 +3771,23 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "libm", + "smallvec", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3708,6 +3892,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.28" @@ -3921,6 +4111,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "skrifa" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" @@ -4091,6 +4291,23 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -4144,6 +4361,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4805,6 +5031,18 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4864,12 +5102,48 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -5372,7 +5646,7 @@ dependencies = [ "js-sys", "khronos-egl", "libc", - "libloading 0.7.4", + "libloading 0.8.9", "log", "metal", "naga", @@ -6004,6 +6278,12 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "yazi" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" + [[package]] name = "yoke" version = "0.8.3" @@ -6027,6 +6307,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeno" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" + [[package]] name = "zerocopy" version = "0.8.52" diff --git a/src-tauri/render2d/Cargo.toml b/src-tauri/render2d/Cargo.toml index c2f1a4c..24ec84a 100644 --- a/src-tauri/render2d/Cargo.toml +++ b/src-tauri/render2d/Cargo.toml @@ -23,7 +23,9 @@ default = [] # GPU-Pipelines (wgpu). Baut auch headless (wgpu laedt Vulkan/GL erst zur # Laufzeit dynamisch), rendert aber nur mit einer aktiven Grafik-Session. -render = ["dep:wgpu", "dep:bytemuck", "dep:pollster"] +# glyphon = GPU-Glyphen-Atlas fuer ECHTE TrueType-Schrift (cosmic-text) — +# gehoert bewusst in die GPU-Schicht, die serde-only-Default-Schicht bleibt fontfrei. +render = ["dep:wgpu", "dep:bytemuck", "dep:pollster", "dep:glyphon"] # Fenster-Spike (winit) inkl. lauffaehigem Binary. Braucht Wayland/X11 + eine # Display-Session zur visuellen Verifikation. @@ -39,6 +41,9 @@ serde = { version = "1", features = ["derive"] } wgpu = { version = "22", optional = true } bytemuck = { version = "1", optional = true, features = ["derive"] } pollster = { version = "0.3", optional = true } +# Echte TrueType-Glyphen ueber einen GPU-Atlas (cosmic-text + etagere). Version +# an wgpu gekoppelt: die 0.6er-Reihe ist die zu wgpu 22 passende. +glyphon = { version = "0.6", optional = true } # --- Fenster (nur mit Feature "window") -------------------------------------- winit = { version = "0.30", optional = true } diff --git a/src-tauri/render2d/src/demo.rs b/src-tauri/render2d/src/demo.rs index 6095750..532551d 100644 --- a/src-tauri/render2d/src/demo.rs +++ b/src-tauri/render2d/src/demo.rs @@ -6,7 +6,7 @@ // noetig), damit die Szene auch headless testbar bleibt. use crate::tessellate::PX_PER_M; -use crate::types::{FillPolygon, Line, Outline, Scene, ViewBox}; +use crate::types::{FillPolygon, Line, Outline, Scene, Text, TextAlign, ViewBox}; /// Demo-Szene in Modell-Metern: ein L-foermiger Wand-Poche (konkav!), ein Raum /// und ein paar Striche — genug, um Fuellung, Umriss und Papier-mm-Linien zu sehen. @@ -57,6 +57,14 @@ pub fn demo_scene() -> Scene { color: ink, width_mm: 0.25, }], + // Ein Raumstempel-artiger Text (echte Glyphen via Atlas), mittig im Raum. + texts: vec![Text { + pos: [7.0, 2.0], + content: "Raum 16.00 m²".into(), + size_mm: 3.5, + color: ink, + align: TextAlign::Center, + }], } } diff --git a/src-tauri/render2d/src/gpu.rs b/src-tauri/render2d/src/gpu.rs index de72505..ddca31e 100644 --- a/src-tauri/render2d/src/gpu.rs +++ b/src-tauri/render2d/src/gpu.rs @@ -17,10 +17,10 @@ use bytemuck::{Pod, Zeroable}; use wgpu::util::DeviceExt; -use crate::ortho::{compute_ortho_matrix, mm_to_device_px, Mat4}; +use crate::ortho::{compute_ortho_matrix, corrected_view_box, meet_scale, mm_to_device_px, Mat4}; use crate::shaders::{FILL_WGSL, LINE_WGSL}; -use crate::tessellate::{compile_scene, GpuGeometry}; -use crate::types::{Scene, ViewBox}; +use crate::tessellate::{compile_scene, to_screen, GpuGeometry}; +use crate::types::{Scene, Text, TextAlign, ViewBox}; /// MSAA-Faktor: 4x Multisampling fuer glatte Linien-/Fuellkanten (wie im Browser). const SAMPLE_COUNT: u32 = 4; @@ -64,8 +64,28 @@ struct SceneBuffers { line_vbo: Option, line_ibo: Option, geo: GpuGeometry, + /// Textzeilen der Szene (Modell-Anker + Papier-mm-Groesse). Werden nicht + /// tesselliert, sondern pro Frame ueber den Glyphen-Atlas gesetzt (die + /// Pixelgroesse haengt vom Zoom ab). + texts: Vec, } +/// Zustand des Glyphen-Textpasses (glyphon): Systemfonts + Rasterizer-Cache + +/// GPU-Atlas + Renderer. Lazily beim ersten Frame mit Texten erstellt (braucht +/// die Queue, die `Renderer::new` nicht bekommt — Signatur bleibt stabil). +struct TextState { + font_system: glyphon::FontSystem, + swash_cache: glyphon::SwashCache, + viewport: glyphon::Viewport, + atlas: glyphon::TextAtlas, + renderer: glyphon::TextRenderer, +} + +/// Schrift des Plans: dieselbe Familie wie der Browser-Text (`--font` in +/// styles.css beginnt mit "Inter"); cosmic-text laedt die Systemfonts und +/// faellt bei fehlender Familie auf sans-serif zurueck. +const TEXT_FAMILY: &str = "Inter"; + /// Der native 2D-Renderer: haelt beide Pipelines, den (dynamischen) Uniform- /// Puffer und die aktuell hochgeladene Szene. Pan/Zoom aktualisiert nur die /// Ortho-Matrix (kein Re-Tessellieren). @@ -84,6 +104,8 @@ pub struct Renderer { msaa_view: Option, /// Groesse, fuer die `msaa_view` angelegt wurde. msaa_size: (u32, u32), + /// Glyphen-Textpass (lazily beim ersten Frame mit Texten erstellt). + text: Option, /// Loeschfarbe (Zeichenblatt), Default hell wie im WebGL-Pfad (#f0f0f0). pub clear_color: wgpu::Color, /// Papier-Massstab-Nenner (1:N) fuer echte mm-Strichbreiten. @@ -229,6 +251,7 @@ impl Renderer { format: color_format, msaa_view: None, msaa_size: (0, 0), + text: None, // #f0f0f0 (== --sheet). 0.941 entspricht dem WebGL-clearColor. clear_color: wgpu::Color { r: 0.941, @@ -271,6 +294,37 @@ impl Renderer { line_vbo: mk_vbo(&geo.line_verts, "line.vbo"), line_ibo: mk_ibo(&geo.line_idx, "line.ibo"), geo, + texts: scene.texts.clone(), + }); + } + + /// Erstellt den Glyphen-Textpass beim ersten Bedarf (FontSystem laedt die + /// Systemfonts einmalig). Der TextRenderer MUSS mit demselben + /// MultisampleState wie die Geometrie-Pipelines gebaut werden (MSAA 4x), + /// sonst passt er nicht in den gemeinsamen Render-Pass. + fn ensure_text(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) { + if self.text.is_some() { + return; + } + let cache = glyphon::Cache::new(device); + let viewport = glyphon::Viewport::new(device, &cache); + let mut atlas = glyphon::TextAtlas::new(device, queue, &cache, self.format); + let renderer = glyphon::TextRenderer::new( + &mut atlas, + device, + wgpu::MultisampleState { + count: SAMPLE_COUNT, + mask: !0, + alpha_to_coverage_enabled: false, + }, + None, + ); + self.text = Some(TextState { + font_system: glyphon::FontSystem::new(), + swash_cache: glyphon::SwashCache::new(), + viewport, + atlas, + renderer, }); } @@ -374,6 +428,122 @@ impl Renderer { self.ensure_uniform_capacity(device, blocks.len() as u64); + // ── Textpass vorbereiten (echte Glyphen via Atlas) ──────────────────── + // Pixelgroesse = Papier-mm * mm_px (dieselbe Formel wie die Strich- + // breiten) -> Text waechst beim Zoomen exakt wie im Browser. Die Anker + // werden ueber den aspekt-korrigierten viewBox in Fenster-Pixel + // projiziert; die Ausrichtung nutzt die gemessene Zeilenbreite. + let has_texts = self + .scene + .as_ref() + .map_or(false, |s| !s.texts.is_empty()); + if has_texts || self.text.is_some() { + self.ensure_text(device, queue); + let ts = self.text.as_mut().expect("ensure_text"); + let cvb = corrected_view_box(view_box, vw, vh); + let meet = meet_scale(view_box, vw, vh); + + // Je sichtbarer Zeile ein geshapter Buffer + Platzierung. + struct Placed { + buffer: glyphon::Buffer, + left: f32, + top: f32, + color: glyphon::Color, + } + let mut placed: Vec = Vec::new(); + if let Some(scene) = &self.scene { + let attrs = glyphon::Attrs::new() + .family(glyphon::Family::Name(TEXT_FAMILY)); + for t in &scene.texts { + let font_px = t.size_mm * mm_px; + // Unlesbar kleine Glyphen ueberspringen (spart Shaping/Atlas). + if !(font_px >= 1.0) || t.content.is_empty() { + continue; + } + // Anker (Modell-Meter) -> Fenster-Pixel: erst Bildschirm-Raum + // (to_screen), dann viewBox-Projektion (meet-Skala). + let s = to_screen(t.pos); + let ax = (s[0] - cvb.x) * meet; + let ay = (s[1] - cvb.y) * meet; + // Grober Sichtbarkeits-Test VOR dem Shaping (Buffer sparen): + // grosszuegige Marge, da die Zeilenbreite noch unbekannt ist. + let margin = font_px * (t.content.len() as f32 + 2.0); + if ax < -margin || ax > vw + margin || ay < -2.0 * font_px || ay > vh + 2.0 * font_px { + continue; + } + + let mut buffer = glyphon::Buffer::new( + &mut ts.font_system, + glyphon::Metrics::new(font_px, font_px * 1.2), + ); + // Unbegrenzt: eine Zeile, kein Umbruch (Zeilen sind vorgeflacht). + buffer.set_size(&mut ts.font_system, None, None); + buffer.set_text(&mut ts.font_system, &t.content, attrs, glyphon::Shaping::Advanced); + + // Gemessene Breite + Baseline fuer Ausrichtung/Anker. + let (line_w, baseline) = buffer + .layout_runs() + .next() + .map(|r| (r.line_w, r.line_y)) + .unwrap_or((0.0, font_px * 0.8)); + let left = match t.align { + TextAlign::Left => ax, + TextAlign::Center => ax - line_w / 2.0, + TextAlign::Right => ax - line_w, + }; + let top = ay - baseline; // Anker-Y ist die BASELINE der Zeile. + // Praeziser Sichtbarkeits-Test mit gemessener Breite. + if left > vw || left + line_w < 0.0 || top > vh || top + font_px * 1.4 < 0.0 { + continue; + } + let c = t.color; + let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + placed.push(Placed { + buffer, + left, + top, + color: glyphon::Color::rgba(to8(c[0]), to8(c[1]), to8(c[2]), to8(c[3])), + }); + } + } + + ts.viewport.update( + queue, + glyphon::Resolution { + width: viewport.0, + height: viewport.1, + }, + ); + let bounds = glyphon::TextBounds { + left: 0, + top: 0, + right: viewport.0 as i32, + bottom: viewport.1 as i32, + }; + let areas = placed.iter().map(|p| glyphon::TextArea { + buffer: &p.buffer, + left: p.left, + top: p.top, + scale: 1.0, + bounds, + default_color: p.color, + custom_glyphs: &[], + }); + if let Err(e) = ts.renderer.prepare( + device, + queue, + &mut ts.font_system, + &mut ts.atlas, + &ts.viewport, + areas, + &mut ts.swash_cache, + ) { + eprintln!("render2d Text-prepare fehlgeschlagen: {e:?}"); + } + // Nicht mehr benoetigte Glyphen aus dem Atlas werfen (haelt ihn klein). + ts.atlas.trim(); + } + // Alle Bloecke in den (auf uniform_stride gepolsterten) Puffer schreiben. if let Some(u) = &self.uniform { let stride = self.uniform_stride as usize; @@ -444,6 +614,14 @@ impl Renderer { } } } + + // 3) Text ZUOBERST im selben MSAA-Pass (der TextRenderer wurde mit + // identischem MultisampleState erstellt, siehe ensure_text). + if let Some(ts) = &self.text { + if let Err(e) = ts.renderer.render(&ts.atlas, &ts.viewport, &mut pass) { + eprintln!("render2d Text-render fehlgeschlagen: {e:?}"); + } + } } queue.submit(std::iter::once(encoder.finish())); } diff --git a/src-tauri/render2d/src/lib.rs b/src-tauri/render2d/src/lib.rs index e7e4e6d..b3ff512 100644 --- a/src-tauri/render2d/src/lib.rs +++ b/src-tauri/render2d/src/lib.rs @@ -24,7 +24,7 @@ pub mod gpu; pub use demo::{demo_scene, initial_view_box}; pub use ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4}; pub use tessellate::{compile_scene, triangulate, GpuGeometry, PX_PER_M}; -pub use types::{FillPolygon, Line, Outline, Point, Rgba, Scene, ViewBox}; +pub use types::{FillPolygon, Line, Outline, Point, Rgba, Scene, Text, TextAlign, ViewBox}; // --- Tests: Tessellierung (Muster wie glPlanCompile.test.ts) ----------------- diff --git a/src-tauri/render2d/src/ortho.rs b/src-tauri/render2d/src/ortho.rs index fef6ba6..52b2274 100644 --- a/src-tauri/render2d/src/ortho.rs +++ b/src-tauri/render2d/src/ortho.rs @@ -48,6 +48,17 @@ fn ortho(left: f32, right: f32, bottom: f32, top: f32) -> Mat4 { /// zusaetzliche Meter-Skalierung. Bildschirm-Y zeigt nach unten -> top = vb.y, /// bottom = vb.y + vb.h (kleineres Y oben -> Clip +1). pub fn compute_ortho_matrix(view_box: ViewBox, canvas_w: f32, canvas_h: f32) -> Mat4 { + let ViewBox { x, y, w, h } = corrected_view_box(view_box, canvas_w, canvas_h); + // top = y (Bildschirm-Y oben, kleiner), bottom = y + h. + ortho(x, x + w, y + h, y) +} + +/// Der aufs Canvas-Seitenverhaeltnis gedehnte (zentrierte) viewBox — exakt die +/// Korrektur, die auch in der Ortho-Matrix steckt. Mit ihm laesst sich ein +/// Bildschirm-Raum-Punkt direkt in Fenster-Pixel projizieren: +/// `px = (s - corrected.xy) * meet_scale(corrected, ...)` (der Textpass braucht +/// genau das fuer die Glyphen-Anker). +pub fn corrected_view_box(view_box: ViewBox, canvas_w: f32, canvas_h: f32) -> ViewBox { let ViewBox { mut x, mut y, @@ -69,8 +80,7 @@ pub fn compute_ortho_matrix(view_box: ViewBox, canvas_w: f32, canvas_h: f32) -> h = nh; } } - // top = y (Bildschirm-Y oben, kleiner), bottom = y + h. - ortho(x, x + w, y + h, y) + ViewBox::new(x, y, w, h) } /// meet-Skala: Geraete-px je viewBox-Einheit (SVG `xMidYMid meet`). diff --git a/src-tauri/render2d/src/types.rs b/src-tauri/render2d/src/types.rs index 9ddef11..d38f95d 100644 --- a/src-tauri/render2d/src/types.rs +++ b/src-tauri/render2d/src/types.rs @@ -68,9 +68,42 @@ pub struct Polyline { pub width_mm: f32, } +/// Horizontale Ausrichtung eines Textes relativ zu seinem Anker (deckungsgleich +/// zur Absatz-Ausrichtung des Plan-Text-Primitivs: left/center/right). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TextAlign { + #[default] + Left, + Center, + Right, +} + +/// EINE Textzeile mit echter Papier-mm-Schriftgroesse. Der Web-Renderer flacht +/// mehrzeilige Raumstempel (Rich-Text + Zusatzzeilen) zeilenweise auf dieses +/// Format ab; gerendert wird mit ECHTEN TrueType-Glyphen ueber einen GPU-Atlas +/// (glyphon/cosmic-text) — bewusst KEINE Einstrich-Vektorschrift. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Text { + /// Anker in Modell-Metern: X gemaess `align`, Y = BASELINE der Zeile. + pub pos: Point, + /// Zeileninhalt (bereits geflachte Runs, plain). + pub content: String, + /// Schriftgroesse in ECHTEN Papier-Millimetern (skaliert mit dem Zoom wie + /// die Strichbreiten, siehe `ortho::mm_to_device_px`). + #[serde(rename = "sizeMm")] + pub size_mm: f32, + /// Textfarbe (RGBA 0..1). + pub color: Rgba, + /// Ausrichtung relativ zu `pos` (Default: left). + #[serde(default)] + pub align: TextAlign, +} + /// Die vollstaendige Szene: alles, was ein Frame zeichnet. Reihenfolge ist /// signifikant (Z-/Alpha-Ueberlagerung): erst Fuellungen, dann Umrisse, dann /// offene Polylinien, dann freie Linien — analog zur Draw-Reihenfolge im WebGL-Pfad. +/// Texte liegen zuoberst (eigener Glyphen-Pass nach der Geometrie). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Scene { #[serde(default)] @@ -81,6 +114,8 @@ pub struct Scene { pub polylines: Vec, #[serde(default)] pub lines: Vec, + #[serde(default)] + pub texts: Vec, } /// Rechteckiger Ausschnitt in BILDSCHIRM-Einheiten (viewBox), analog `ViewBox` diff --git a/src/plan/nativeSync.ts b/src/plan/nativeSync.ts index 3279d6a..dd9a3ac 100644 --- a/src/plan/nativeSync.ts +++ b/src/plan/nativeSync.ts @@ -2,8 +2,8 @@ // die AKTUELL sichtbare 2D-Szene bzw. die geflachten 3D-Wände debounced per // Tauri-Command an die native Event-Loop. Es wird EXAKT dieselbe Ableitung // benutzt wie für die Browser-Ansicht (planToRenderScene auf dem sichtbaren -// Plan, projectToWalls3d aufs Projekt) — 1:1-Parität inkl. Türschwenk und -// Fenster-Symbolen (Text bleibt bewusst dem Browser-Overlay überlassen). +// Plan, projectToWalls3d aufs Projekt) — 1:1-Parität inkl. Türschwenk, +// Fenster-Symbolen und Raumstempel-Text (echte Glyphen via GPU-Atlas in Rust). // // Im reinen Browser (kein Tauri) sind beide Funktionen No-ops: erkannt wird // Tauri an `window.__TAURI_INTERNALS__`; Fehler beim Invoke werden still diff --git a/src/plan/toRenderScene.ts b/src/plan/toRenderScene.ts index e760c65..87c423e 100644 --- a/src/plan/toRenderScene.ts +++ b/src/plan/toRenderScene.ts @@ -1,14 +1,17 @@ // Flacht einen {@link Plan} (die eine Wahrheit aus generatePlan) auf das // GPU-nahe Szenenformat des nativen render2d-Renderers ab: Füllpolygone, -// Umrisse und Linien. Hatch und Text bleiben (wie im Rust-Renderer, siehe -// render2d/src/types.rs) vorerst außen vor — sie sind Overlay/späteres M3. +// Umrisse, Linien und Textzeilen. Text wird zeilenweise geflacht (Rich-Text-Doc +// + Live-Zusatzzeilen) — die echten Glyphen rastert der Rust-Renderer über +// einen GPU-Glyphen-Atlas (glyphon/cosmic-text), keine Vektor-Ersatzschrift. // // Das erzeugte Objekt matcht 1:1 die serde-Structs render2d::types::Scene -// { fills:[{pts,color}], outlines:[{pts,color,widthMm}], lines:[{a,b,color,widthMm}] } +// { fills:[{pts,color}], outlines:[{pts,color,widthMm}], lines:[{a,b,color,widthMm}], +// texts:[{pos,content,sizeMm,color,align}] } // mit Point = [x,y] (Meter) und Rgba = [r,g,b,a] (0..1). import type { Plan, Primitive } from "./generatePlan"; import { applyDashRuns, buildHatchRuns } from "./glPlan/glPlanHatch"; +import { docToLines } from "../text/renderHtml"; type LinePrim = Extract; @@ -35,15 +38,39 @@ export interface RLine { color: RRgba; widthMm: number; } +export type RTextAlign = "left" | "center" | "right"; +/** EINE Textzeile; serde-kompatibel zu render2d::types::Text. */ +export interface RText { + /** Anker in Modell-Metern: X gemäß align, Y = Baseline der Zeile. */ + pos: RPoint; + content: string; + /** Schriftgröße in echten Papier-mm (skaliert mit Zoom wie Strichbreiten). */ + sizeMm: number; + color: RRgba; + align: RTextAlign; +} export interface RScene { fills: RFill[]; outlines: ROutline[]; polylines: RPolyline[]; lines: RLine[]; + texts: RText[]; } const DEFAULT_LINE = "#1a1a1a"; +/** 1 Punkt (pt) in Papier-Millimetern. */ +const PT_TO_MM = 25.4 / 72; +/** + * Referenzmaßstab des Display-Modus (1:100) — identisch zu PlanView (`nRef`): + * dort belegt 1 pt Schrift (1/72)·0.0254·100 Modell-Meter. Der native Renderer + * rechnet mit `paper_scale_n = 100`, damit ist `sizeMm = pt · PT_TO_MM` exakt + * die Browser-Darstellung. + */ +const STAMP_REF_N = 100; +/** Papier-mm → Modell-Meter beim Referenzmaßstab. */ +const MM_TO_M = STAMP_REF_N / 1000; + /** Wenige benannte Farben, die in Klassen/Defaults vorkommen können. */ const NAMED: Record = { black: "#000000", @@ -208,6 +235,7 @@ export function planToRenderScene(plan: Plan): RScene { const outlines: ROutline[] = []; const polylines: RPolyline[] = []; const lines: RLine[] = []; + const texts: RText[] = []; // Sichtbare Umriss-Läufe werden erst GESAMMELT (nach Stil gruppiert) und am // Ende über Polygon-Grenzen hinweg zusammengenäht — erst dadurch bekommen @@ -303,10 +331,56 @@ export function planToRenderScene(plan: Plan): RScene { const poly = tessellateArc(p.center, p.from, p.to, p.r); if (poly.length >= 2) polylines.push({ pts: poly, color: col, widthMm: p.weightMm }); } else { - // "text" wird bewusst NICHT nativ gerendert — eine Einstrich-Vektorschrift - // sieht neben dem echten Browser-Font schlecht aus. Der Raumstempel-Text - // bleibt dem Browser/Overlay überlassen. + // "text": zeilenweise flachen — die ECHTEN Glyphen rastert der Rust- + // Renderer über einen GPU-Glyphen-Atlas (glyphon, dieselbe Font-Familie + // wie im Browser). Layout identisch zum SVG-Pfad in PlanView (case + // "text"): Rich-Text-Zeilen + Live-Zusatzzeilen, Block vertikal um den + // Anker zentriert, Zeilenvorschub 1.3·Basisgröße. flushRun(); + const defCol = toRgba(p.color, 1) ?? [0.1, 0.1, 0.1, 1]; + // unitPerPt = PT_TO_MM → tspan.fontSize ist direkt die Papier-mm-Größe. + const docLines = docToLines(p.doc, { + x: 0, + y: 0, + lineHeight: 0, + unitPerPt: PT_TO_MM, + basePt: p.basePt, + color: p.color, + }); + const baseMm = p.basePt * PT_TO_MM; + const flat: { content: string; sizeMm: number; color: RRgba; align: RTextAlign }[] = []; + for (const line of docLines) { + flat.push({ + // Runs einer Zeile werden zusammengelegt (ein Buffer je Zeile); + // Größe/Farbe des größten bzw. ersten Runs gelten für die Zeile. + content: line.tspans.map((t) => t.text).join(""), + sizeMm: line.tspans.length + ? Math.max(...line.tspans.map((t) => t.fontSize)) + : baseMm, + color: toRgba(line.tspans[0]?.fill ?? p.color, 1) ?? defCol, + align: line.align, + }); + } + for (const line of p.extraLines) { + flat.push({ content: line, sizeMm: baseMm * 0.8, color: defCol, align: "center" }); + } + if (flat.length > 0) { + const baseH = baseMm * MM_TO_M; // Basisgröße in Modell-Metern + const lineGap = baseH * 1.3; + const totalH = baseH + (flat.length - 1) * lineGap; + flat.forEach((line, k) => { + if (!line.content.trim()) return; // Leerzeile: nur Vorschub + texts.push({ + // Baseline der Zeile k (Modell-Y nach oben, deshalb minus): + // Browser: startY = c.y − H/2 + 0.8·fs (Bildschirm-Y nach unten). + pos: [p.at.x, p.at.y + totalH / 2 - baseH * 0.8 - k * lineGap], + content: line.content, + sizeMm: line.sizeMm, + color: line.color, + align: line.align, + }); + }); + } } } flushRun(); @@ -324,5 +398,5 @@ export function planToRenderScene(plan: Plan): RScene { } } - return { fills, outlines, polylines, lines }; + return { fills, outlines, polylines, lines, texts }; }