From 52a451bfa7b3b267b492c6f1169c9a58dcb97d64 Mon Sep 17 00:00:00 2001 From: charlotte Date: Tue, 24 Mar 2026 12:39:40 -0700 Subject: [PATCH 1/5] Text. --- Cargo.lock | 123 +- Cargo.toml | 8 + crates/processing_core/src/error.rs | 4 + crates/processing_ffi/src/lib.rs | 445 +++++++ crates/processing_pyo3/src/graphics.rs | 367 +++++ crates/processing_pyo3/src/lib.rs | 4 +- crates/processing_render/Cargo.toml | 3 + crates/processing_render/src/geometry/mod.rs | 20 + crates/processing_render/src/graphics.rs | 9 +- crates/processing_render/src/lib.rs | 686 ++++++++++ .../processing_render/src/render/command.rs | 139 ++ crates/processing_render/src/render/mod.rs | 191 ++- .../src/render/primitive/mod.rs | 1 + .../src/render/primitive/text.rs | 1175 +++++++++++++++++ crates/processing_render/src/text/font.rs | 168 +++ crates/processing_render/src/text/mod.rs | 1 + examples/text.rs | 109 ++ examples/text_3d.rs | 76 ++ src/prelude.rs | 2 +- 19 files changed, 3517 insertions(+), 14 deletions(-) create mode 100644 crates/processing_render/src/render/primitive/text.rs create mode 100644 crates/processing_render/src/text/font.rs create mode 100644 crates/processing_render/src/text/mod.rs create mode 100644 examples/text.rs create mode 100644 examples/text_3d.rs diff --git a/Cargo.lock b/Cargo.lock index 7d354ed4..cff85865 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1714,7 +1714,7 @@ dependencies = [ "bevy_platform", "bevy_reflect", "bevy_utils", - "parley", + "parley 0.9.0", "serde", "smallvec", "smol_str", @@ -1783,7 +1783,7 @@ dependencies = [ "bevy_utils", "bevy_window", "derive_more", - "parley", + "parley 0.9.0", "smallvec", "swash", "taffy", @@ -1843,7 +1843,7 @@ dependencies = [ "bevy_text", "bevy_ui", "bevy_window", - "parley", + "parley 0.9.0", "smol_str", ] @@ -3054,6 +3054,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + [[package]] name = "font-types" version = "0.11.3" @@ -3063,6 +3072,29 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fontique" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bbc252c93499b6d3635d692f892a637db0dbb130ce9b32bf20b28e0dcc470b" +dependencies = [ + "bytemuck", + "hashbrown 0.16.1", + "icu_locale_core", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "read-fonts 0.35.0", + "roxmltree", + "smallvec", + "windows 0.58.0", + "windows-core 0.58.0", + "yeslogic-fontconfig-sys", +] + [[package]] name = "fontique" version = "0.9.0" @@ -3496,6 +3528,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + [[package]] name = "harfrust" version = "0.6.0" @@ -4766,6 +4811,12 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "notosans" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "004d578bbfc8a6bdd4690576a8381af234ef051dd4cc358604e1784821e8205c" + [[package]] name = "ntapi" version = "0.4.3" @@ -5394,14 +5445,28 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" +[[package]] +name = "parley" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada5338c3a9794af7342e6f765b6e78740db37378aced034d7bf72c96b94ed94" +dependencies = [ + "fontique 0.7.0", + "harfrust 0.3.2", + "hashbrown 0.16.1", + "linebender_resource_handle", + "skrifa 0.37.0", + "swash", +] + [[package]] name = "parley" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fad031076f48f0d4d85ce1aea9b94b4e715a4d636a030a123038f8f5b5e4343" dependencies = [ - "fontique", - "harfrust", + "fontique 0.9.0", + "harfrust 0.6.0", "hashbrown 0.17.1", "icu_normalizer", "icu_properties", @@ -5741,10 +5806,13 @@ dependencies = [ "js-sys", "lyon", "naga", + "notosans", "objc2 0.6.4", "objc2-app-kit 0.3.2", + "parley 0.7.0", "processing_core", "raw-window-handle", + "skrifa 0.37.0", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -6065,6 +6133,17 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + [[package]] name = "read-fonts" version = "0.37.0" @@ -6072,7 +6151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" dependencies = [ "bytemuck", - "font-types", + "font-types 0.11.3", ] [[package]] @@ -6083,7 +6162,7 @@ checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" dependencies = [ "bytemuck", "core_maths", - "font-types", + "font-types 0.11.3", ] [[package]] @@ -6191,6 +6270,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -6427,6 +6515,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + [[package]] name = "skrifa" version = "0.40.0" @@ -8338,6 +8436,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index b4a0139e..6bb4d494 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -209,6 +209,14 @@ path = "examples/particles_emit_gpu.rs" name = "particles_stress" path = "examples/particles_stress.rs" +[[example]] +name = "text" +path = "examples/text.rs" + +[[example]] +name = "text_3d" +path = "examples/text_3d.rs" + [profile.wasm-release] inherits = "release" opt-level = "z" diff --git a/crates/processing_core/src/error.rs b/crates/processing_core/src/error.rs index 254b1f59..32b1e394 100644 --- a/crates/processing_core/src/error.rs +++ b/crates/processing_core/src/error.rs @@ -58,4 +58,8 @@ pub enum ProcessingError { PipelineNotReady(u32), #[error("Particles not found")] ParticlesNotFound, + #[error("Font not found")] + FontNotFound, + #[error("Font load error: {0}")] + FontLoadError(String), } diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index 524572f7..cd610bab 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -983,6 +983,451 @@ pub extern "C" fn processing_end_contour(graphics_id: u64) { error::check(|| graphics_record_command(graphics_entity, DrawCommand::EndContour)); } +// --- Font --- + +/// Load a font file and return a font entity ID. +/// Returns 0 on error. +/// +/// SAFETY: +/// - path_ptr is a valid pointer to a null-terminated UTF-8 string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_load_font(path_ptr: *const std::ffi::c_char) -> u64 { + error::clear_error(); + let path = unsafe { std::ffi::CStr::from_ptr(path_ptr) } + .to_string_lossy(); + error::check(|| font_load(&path).map(|e| e.to_bits())) + .unwrap_or(0) +} + +/// Create a font handle from an existing font family name. +/// Returns 0 on error. +/// +/// SAFETY: +/// - name_ptr is a valid pointer to a null-terminated UTF-8 string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_create_font(name_ptr: *const std::ffi::c_char) -> u64 { + error::clear_error(); + let name = unsafe { std::ffi::CStr::from_ptr(name_ptr) } + .to_string_lossy(); + error::check(|| font_create(&name).map(|e| e.to_bits())) + .unwrap_or(0) +} + +/// Query the number of variable font axes for a font. +/// Returns 0 if the font is not variable or not found. +#[unsafe(no_mangle)] +pub extern "C" fn processing_font_variation_count(font_id: u64) -> u32 { + error::clear_error(); + let font_entity = Entity::from_bits(font_id); + error::check(|| font_variations(font_entity).map(|v| v.len() as u32)) + .unwrap_or(0) +} + +/// Query variable font axis info. +/// Writes tag (4 bytes), min, max, default to out buffer at the given index. +/// +/// SAFETY: +/// - out_tag is a valid pointer to at least 4 bytes. +/// - out_min, out_max, out_default are valid pointers. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_font_variation( + font_id: u64, + index: u32, + out_tag: *mut u8, + out_min: *mut f32, + out_max: *mut f32, + out_default: *mut f32, +) -> bool { + error::clear_error(); + let font_entity = Entity::from_bits(font_id); + let axes = error::check(|| font_variations(font_entity)); + if let Some(axes) = axes { + if let Some(axis) = axes.get(index as usize) { + let tag_bytes = axis.tag.as_bytes(); + let len = tag_bytes.len().min(4); + unsafe { + std::ptr::copy_nonoverlapping(tag_bytes.as_ptr(), out_tag, len); + for i in len..4 { + *out_tag.add(i) = b' '; + } + *out_min = axis.min; + *out_max = axis.max; + *out_default = axis.default; + } + return true; + } + } + false +} + +/// Set the current text font. +/// Pass 0 to reset to the default font. +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_font(graphics_id: u64, font_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let font_entity = if font_id == 0 { + None + } else { + Some(Entity::from_bits(font_id)) + }; + error::check(|| graphics_text_font(graphics_entity, font_entity)); +} + +// --- Text --- + +/// Draw text at a position. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - str_ptr is a valid pointer to a null-terminated UTF-8 string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_text( + graphics_id: u64, + str_ptr: *const std::ffi::c_char, + x: f32, + y: f32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) } + .to_string_lossy() + .into_owned(); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Text { + content, + x, + y, + z: 0.0, + max_w: None, + max_h: None, + }, + ) + }); +} + +/// Draw text at a 3D position. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - str_ptr is a valid pointer to a null-terminated UTF-8 string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_text_3d( + graphics_id: u64, + str_ptr: *const std::ffi::c_char, + x: f32, + y: f32, + z: f32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) } + .to_string_lossy() + .into_owned(); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Text { + content, + x, + y, + z, + max_w: None, + max_h: None, + }, + ) + }); +} + +/// Draw an integer as text at a position. +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_int(graphics_id: u64, value: i32, x: f32, y: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let content = value.to_string(); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Text { + content, + x, + y, + z: 0.0, + max_w: None, + max_h: None, + }, + ) + }); +} + +/// Draw a float as text at a position (formatted to 3 decimal places). +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_float(graphics_id: u64, value: f32, x: f32, y: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let content = format!("{:.3}", value); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Text { + content, + x, + y, + z: 0.0, + max_w: None, + max_h: None, + }, + ) + }); +} + +/// Draw text within a bounding box (with word wrapping). +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - str_ptr is a valid pointer to a null-terminated UTF-8 string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_text_box( + graphics_id: u64, + str_ptr: *const std::ffi::c_char, + x: f32, + y: f32, + w: f32, + h: f32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) } + .to_string_lossy() + .into_owned(); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Text { + content, + x, + y, + z: 0.0, + max_w: Some(w), + max_h: Some(h), + }, + ) + }); +} + +/// Set the text style. 0=NORMAL, 1=ITALIC, 2=BOLD, 3=BOLDITALIC +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_style(graphics_id: u64, style: u8) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_text_style(graphics_entity, style)); +} + +/// Compute the bounding box of text. Writes [x, y, w, h] to out_bounds. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - str_ptr is a valid pointer to a null-terminated UTF-8 string. +/// - out_bounds is a valid pointer to a float array of at least 4 elements. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_text_bounds( + graphics_id: u64, + str_ptr: *const std::ffi::c_char, + x: f32, + y: f32, + out_bounds: *mut f32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) } + .to_string_lossy(); + if let Some(bounds) = + error::check(|| graphics_text_bounds(graphics_entity, &content, x, y, None, None)) + { + unsafe { + *out_bounds = bounds[0]; + *out_bounds.add(1) = bounds[1]; + *out_bounds.add(2) = bounds[2]; + *out_bounds.add(3) = bounds[3]; + } + } +} + +/// Set a font variation axis value (e.g. "wdth", 75.0). +/// +/// SAFETY: +/// - tag_ptr is a valid pointer to a null-terminated UTF-8 string of exactly 4 characters. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_text_variation( + graphics_id: u64, + tag_ptr: *const std::ffi::c_char, + value: f32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let tag = unsafe { std::ffi::CStr::from_ptr(tag_ptr) }.to_string_lossy(); + error::check(|| graphics_text_variation(graphics_entity, &tag, value)); +} + +/// Clear all font variation axis overrides. +#[unsafe(no_mangle)] +pub extern "C" fn processing_clear_text_variations(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_clear_text_variations(graphics_entity)); +} + +/// Enable/configure an OpenType font feature (e.g. "smcp", 1). +/// +/// SAFETY: +/// - tag_ptr is a valid pointer to a null-terminated UTF-8 string of exactly 4 characters. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_text_feature( + graphics_id: u64, + tag_ptr: *const std::ffi::c_char, + value: u16, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let tag = unsafe { std::ffi::CStr::from_ptr(tag_ptr) }.to_string_lossy(); + error::check(|| graphics_text_feature(graphics_entity, &tag, value)); +} + +/// Disable an OpenType font feature. +/// +/// SAFETY: +/// - tag_ptr is a valid pointer to a null-terminated UTF-8 string of exactly 4 characters. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_no_text_feature( + graphics_id: u64, + tag_ptr: *const std::ffi::c_char, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let tag = unsafe { std::ffi::CStr::from_ptr(tag_ptr) }.to_string_lossy(); + error::check(|| graphics_no_text_feature(graphics_entity, &tag)); +} + +/// Clear all OpenType font feature overrides. +#[unsafe(no_mangle)] +pub extern "C" fn processing_clear_text_features(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_clear_text_features(graphics_entity)); +} + +/// Set per-glyph colors for the next text() call. +/// colors_ptr points to an array of (r, g, b, a) float tuples. +/// +/// SAFETY: +/// - colors_ptr is a valid pointer to count * 4 floats. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_text_glyph_colors( + graphics_id: u64, + colors_ptr: *const f32, + count: u32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let colors: Vec = (0..count as usize) + .map(|i| unsafe { + let base = colors_ptr.add(i * 4); + bevy::color::Color::srgba(*base, *base.add(1), *base.add(2), *base.add(3)) + }) + .collect(); + error::check(|| graphics_text_glyph_colors(graphics_entity, colors)); +} + +/// Set the font weight for variable fonts (e.g. 100-900). +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_weight(graphics_id: u64, weight: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_text_weight(graphics_entity, weight)); +} + +/// Set the text size. +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_size(graphics_id: u64, size: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_record_command(graphics_entity, DrawCommand::TextSize(size))); +} + +/// Set the text alignment. +/// h: 0=LEFT, 1=CENTER, 2=RIGHT +/// v: 0=BASELINE, 1=TOP, 2=CENTER, 3=BOTTOM +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_align(graphics_id: u64, h: u8, v: u8) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + graphics_text_align(graphics_entity, h, v) + }); +} + +/// Set the text leading (line spacing). +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_leading(graphics_id: u64, leading: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_record_command(graphics_entity, DrawCommand::TextLeading(leading))); +} + +/// Set the text direction. 0=AUTO, 1=LTR, 2=RTL +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_direction(graphics_id: u64, dir: u8) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_text_direction(graphics_entity, dir)); +} + +/// Set the text wrap mode. 0=WORD, 1=CHAR +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_wrap(graphics_id: u64, mode: u8) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_text_wrap(graphics_entity, mode)); +} + +/// Measure the width of text. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - str_ptr is a valid pointer to a null-terminated UTF-8 string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_text_width( + graphics_id: u64, + str_ptr: *const std::ffi::c_char, +) -> f32 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) } + .to_string_lossy(); + error::check(|| graphics_text_width(graphics_entity, &content)) + .unwrap_or(0.0) +} + +/// Get the text ascent for the current font size. +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_ascent(graphics_id: u64) -> f32 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_text_ascent(graphics_entity)) + .unwrap_or(0.0) +} + +/// Get the text descent for the current font size. +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_descent(graphics_id: u64) -> f32 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_text_descent(graphics_entity)) + .unwrap_or(0.0) +} + /// Create an image from raw pixel data. /// /// # Safety diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index 65d5ce2f..9bc2bcf5 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -222,6 +222,33 @@ impl Light { // } // } +#[pyclass] +#[derive(Debug)] +pub struct Font { + pub(crate) entity: Entity, +} + +#[pymethods] +impl Font { + /// Query variable font axes. Returns list of dicts: {tag, min, max, default}. + pub fn variations(&self) -> PyResult> { + font_variations(self.entity) + .map(|axes| { + axes.into_iter() + .map(|a| (a.tag, a.min, a.max, a.default)) + .collect() + }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Query font metadata. Returns dict with family, style, weight, width, is_variable. + pub fn metadata(&self) -> PyResult<(String, String, f32, f32, bool)> { + font_metadata(self.entity) + .map(|m| (m.family, m.style, m.weight, m.width, m.is_variable)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } +} + #[pyclass] #[derive(Debug)] pub struct Image { @@ -906,6 +933,346 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + // --- Font --- + + pub fn load_font(&self, path: &str) -> PyResult { + font_load(path) + .map(|entity| Font { entity }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn create_font(&self, name: &str) -> PyResult { + font_create(name) + .map(|entity| Font { entity }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn list_fonts(&self) -> PyResult> { + font_list().map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[pyo3(signature = (font=None))] + pub fn text_font(&self, font: Option<&Font>) -> PyResult<()> { + graphics_text_font(self.entity, font.map(|f| f.entity)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + // --- Text --- + + #[pyo3(signature = (content, x, y, *args, max_w=None, max_h=None))] + pub fn text( + &self, + content: &str, + x: f32, + y: f32, + args: &Bound<'_, pyo3::types::PyTuple>, + max_w: Option, + max_h: Option, + ) -> PyResult<()> { + // text(content, x, y) or text(content, x, y, z) or text(content, x, y, max_w, max_h) + let (z, mw, mh) = match args.len() { + 0 => (0.0, max_w, max_h), + 1 => { + let z: f32 = args.get_item(0)?.extract()?; + (z, max_w, max_h) + } + 2 => { + let w: f32 = args.get_item(0)?.extract()?; + let h: f32 = args.get_item(1)?.extract()?; + (0.0, Some(w), Some(h)) + } + 3 => { + let z: f32 = args.get_item(0)?.extract()?; + let w: f32 = args.get_item(1)?.extract()?; + let h: f32 = args.get_item(2)?.extract()?; + (z, Some(w), Some(h)) + } + _ => return Err(PyRuntimeError::new_err("text() takes 3-6 positional arguments")), + }; + graphics_record_command( + self.entity, + DrawCommand::Text { + content: content.to_string(), + x, + y, + z, + max_w: mw, + max_h: mh, + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_style(&self, style: u8) -> PyResult<()> { + graphics_text_style(self.entity, style) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[pyo3(signature = (content, x, y, max_w=None, max_h=None))] + pub fn text_bounds( + &self, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, + ) -> PyResult<(f32, f32, f32, f32)> { + graphics_text_bounds(self.entity, content, x, y, max_w, max_h) + .map(|b| (b[0], b[1], b[2], b[3])) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_weight(&self, weight: f32) -> PyResult<()> { + graphics_text_weight(self.entity, weight) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_variation(&self, tag: &str, value: f32) -> PyResult<()> { + graphics_text_variation(self.entity, tag, value) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn clear_text_variations(&self) -> PyResult<()> { + graphics_clear_text_variations(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Enable/configure an OpenType font feature. + /// text_feature("smcp") -> enable (value=1) + /// text_feature("smcp", True) -> enable (value=1) + /// text_feature("smcp", False) -> disable (value=0) + /// text_feature("salt", 3) -> select alternate 3 + #[pyo3(signature = (tag, value=None))] + pub fn text_feature(&self, tag: &str, value: Option<&Bound<'_, PyAny>>) -> PyResult<()> { + let v: u16 = match value { + None => 1, + Some(val) => { + if let Ok(b) = val.extract::() { + if b { 1 } else { 0 } + } else if let Ok(i) = val.extract::() { + i + } else { + return Err(PyRuntimeError::new_err( + "text_feature value must be bool or int", + )); + } + } + }; + graphics_text_feature(self.entity, tag, v) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn no_text_feature(&self, tag: &str) -> PyResult<()> { + graphics_no_text_feature(self.entity, tag) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn clear_text_features(&self) -> PyResult<()> { + graphics_clear_text_features(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Extract glyph outlines as path commands (one list per glyph). + /// Each command is a tuple: ("M", x, y), ("L", x, y), ("Q", cx, cy, x, y), + /// ("C", cx1, cy1, cx2, cy2, x, y), or ("Z",). + pub fn text_to_paths( + &self, + content: &str, + x: f32, + y: f32, + ) -> PyResult>>> { + use processing_render::render::primitive::text::PathCommand; + + let paths = graphics_text_to_paths(self.entity, content, x, y) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + + Python::attach(|py| { + Ok(paths + .into_iter() + .map(|glyph| { + glyph + .into_iter() + .map(|cmd| match cmd { + PathCommand::MoveTo(x, y) => ("M", x, y, 0.0, 0.0, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), + PathCommand::LineTo(x, y) => ("L", x, y, 0.0, 0.0, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), + PathCommand::QuadTo { cx, cy, x, y } => ("Q", cx, cy, x, y, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), + PathCommand::CubicTo { cx1, cy1, cx2, cy2, x, y } => ("C", cx1, cy1, cx2, cy2, x, y).into_pyobject(py).unwrap().into_any().unbind(), + PathCommand::Close => ("Z", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), + }) + .collect() + }) + .collect()) + }) + } + + /// Extract glyph outlines as per-contour path commands. + /// Each contour (MoveTo...Close sequence) is a separate list. + pub fn text_to_contours( + &self, + content: &str, + x: f32, + y: f32, + ) -> PyResult>>> { + use processing_render::render::primitive::text::PathCommand; + + let contours = graphics_text_to_contours(self.entity, content, x, y) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + + Python::attach(|py| { + Ok(contours + .into_iter() + .map(|contour| { + contour + .into_iter() + .map(|cmd| match cmd { + PathCommand::MoveTo(x, y) => ("M", x, y, 0.0, 0.0, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), + PathCommand::LineTo(x, y) => ("L", x, y, 0.0, 0.0, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), + PathCommand::QuadTo { cx, cy, x, y } => ("Q", cx, cy, x, y, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), + PathCommand::CubicTo { cx1, cy1, cx2, cy2, x, y } => ("C", cx1, cy1, cx2, cy2, x, y).into_pyobject(py).unwrap().into_any().unbind(), + PathCommand::Close => ("Z", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), + }) + .collect() + }) + .collect()) + }) + } + + /// Sample points along text outlines. + /// Returns list of [x, y] points. + #[pyo3(signature = (content, x, y, sample_factor=None))] + pub fn text_to_points( + &self, + content: &str, + x: f32, + y: f32, + sample_factor: Option, + ) -> PyResult> { + graphics_text_to_points(self.entity, content, x, y, sample_factor) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Generate a 3D extruded mesh from text outlines. + pub fn text_to_model( + &self, + content: &str, + x: f32, + y: f32, + depth: f32, + ) -> PyResult { + let mesh = graphics_text_to_model(self.entity, content, x, y, depth) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let entity = geometry_create_from_mesh(mesh) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Geometry { entity }) + } + + /// Set per-glyph colors for the next text() call. + /// colors: list of (r, g, b) or (r, g, b, a) tuples with values 0-255. + pub fn text_glyph_colors(&self, colors: Vec<(f32, f32, f32, f32)>) -> PyResult<()> { + let colors: Vec = colors + .into_iter() + .map(|(r, g, b, a)| bevy::color::Color::srgba(r, g, b, a)) + .collect(); + graphics_text_glyph_colors(self.entity, colors) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_size(&self, size: f32) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::TextSize(size)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[pyo3(signature = (h, v=None))] + pub fn text_align(&self, h: u8, v: Option) -> PyResult<()> { + use processing::prelude::{TextAlignH, TextAlignV}; + graphics_record_command( + self.entity, + DrawCommand::TextAlign { + h: TextAlignH::from(h), + v: TextAlignV::from(v.unwrap_or(0)), + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_leading(&self, leading: f32) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::TextLeading(leading)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Set text direction. 0=AUTO, 1=LTR, 2=RTL + pub fn text_direction(&self, dir: u8) -> PyResult<()> { + graphics_text_direction(self.entity, dir) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_wrap(&self, mode: u8) -> PyResult<()> { + use processing::prelude::TextWrapMode; + graphics_record_command(self.entity, DrawCommand::TextWrap(TextWrapMode::from(mode))) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Get the number of lines after text layout. + pub fn text_line_count(&self, content: &str) -> PyResult { + graphics_text_line_count(self.entity, content) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Get per-line info: list of dicts with "text" and "rect" (x, y, w, h). + #[pyo3(signature = (content, x, y, max_w=None, max_h=None))] + pub fn text_lines( + &self, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, + ) -> PyResult> { + graphics_text_lines(self.entity, content, x, y, max_w, max_h) + .map(|lines| { + lines + .into_iter() + .map(|li| (li.text, (li.rect[0], li.rect[1], li.rect[2], li.rect[3]))) + .collect() + }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Get per-glyph bounding rects: list of (x, y, w, h). + #[pyo3(signature = (content, x, y, max_w=None, max_h=None))] + pub fn text_glyph_rects( + &self, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, + ) -> PyResult> { + graphics_text_glyph_rects(self.entity, content, x, y, max_w, max_h) + .map(|glyphs| { + glyphs + .into_iter() + .map(|g| (g.rect[0], g.rect[1], g.rect[2], g.rect[3])) + .collect() + }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_width(&self, content: &str) -> PyResult { + graphics_text_width(self.entity, content) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_ascent(&self) -> PyResult { + graphics_text_ascent(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_descent(&self) -> PyResult { + graphics_text_descent(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + /// Loads an image from a file and returns an Image object. /// /// The path is relative to the sketch's assets directory. diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 59024d4f..d28e67a2 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -29,7 +29,7 @@ mod webcam; use compute::{Buffer, Compute}; use graphics::{ - Geometry, Graphics, Image, Light, PyBlendMode, Sampler, Topology, get_graphics, + Font, Geometry, Graphics, Image, Light, PyBlendMode, Sampler, Topology, get_graphics, get_graphics_mut, }; use material::Material; @@ -334,6 +334,8 @@ mod mewnala { #[pymodule_export] use super::Compute; #[pymodule_export] + use super::Font; + #[pymodule_export] use super::Geometry; #[pymodule_export] use super::Gltf; diff --git a/crates/processing_render/Cargo.toml b/crates/processing_render/Cargo.toml index 87417343..5c728d11 100644 --- a/crates/processing_render/Cargo.toml +++ b/crates/processing_render/Cargo.toml @@ -17,6 +17,9 @@ bevy_naga_reflect = { workspace = true } naga = { workspace = true } wesl = { workspace = true } lyon = "1.0" +parley = { version = "0.7", features = ["system"] } +skrifa = "0.37" +notosans = "0.1" raw-window-handle = "0.6" half = "2.7" crossbeam-channel = "0.5" diff --git a/crates/processing_render/src/geometry/mod.rs b/crates/processing_render/src/geometry/mod.rs index 87aaee10..1424f221 100644 --- a/crates/processing_render/src/geometry/mod.rs +++ b/crates/processing_render/src/geometry/mod.rs @@ -212,6 +212,26 @@ pub fn create_grid( commands.spawn(Geometry::new(handle, layout_entity)).id() } +pub fn create_from_mesh( + In(mesh): In, + mut commands: Commands, + mut meshes: ResMut>, + builtins: Res, +) -> Entity { + let handle = meshes.add(mesh); + + let layout_entity = commands + .spawn(VertexLayout::with_attributes(vec![ + builtins.position, + builtins.normal, + builtins.color, + builtins.uv, + ])) + .id(); + + commands.spawn(Geometry::new(handle, layout_entity)).id() +} + pub fn normal(world: &mut World, entity: Entity, normal: Vec3) -> Result<()> { let mut geometry = world .get_mut::(entity) diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index 001f9f5d..d7f5f7e3 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -9,7 +9,8 @@ use bevy::{ ImageRenderTarget, MsaaWriteback, Projection, RenderTarget, visibility::RenderLayers, }, core_pipeline::tonemapping::Tonemapping, - ecs::query::QueryEntityError, + post_process::bloom::Bloom, + ecs::{entity::EntityHashMap, query::QueryEntityError}, math::{Mat4, Vec3A}, prelude::*, render::{ @@ -207,7 +208,7 @@ pub fn create( ..default() }, target, - // tonemapping prevents color accurate readback, so we disable it + // tonemapping is conditionally set below based on HDR mode Tonemapping::None, // we need to be able to write to the texture CameraMainTextureUsages::default().with(TextureUsages::COPY_DST), @@ -225,9 +226,9 @@ pub fn create( }, )); - // only enable Hdr for floating-point texture formats + // enable Hdr, Bloom, and tonemapping for floating-point texture formats if is_hdr { - entity_commands.insert(Hdr); + entity_commands.insert((Hdr, Bloom::NATURAL, Tonemapping::TonyMcMapface)); } let entity = entity_commands.id(); diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 90c838c2..42121aab 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -15,6 +15,7 @@ pub mod render; pub mod shader_value; pub mod sketch; pub mod surface; +pub mod text; pub mod time; pub mod transform; @@ -69,6 +70,7 @@ impl Plugin for ProcessingRenderPlugin { camera::OrbitCameraPlugin, bevy::camera_controller::free_camera::FreeCameraPlugin, bevy::camera_controller::pan_camera::PanCameraPlugin, + text::font::TextPlugin, )); app.add_systems(First, (clear_transient_meshes, activate_cameras)) @@ -1386,6 +1388,15 @@ pub fn geometry_set_attribute( }) } +pub fn geometry_create_from_mesh(mesh: Mesh) -> error::Result { + app_mut(|app| { + Ok(app + .world_mut() + .run_system_cached_with(geometry::create_from_mesh, mesh) + .unwrap()) + }) +} + pub fn geometry_box(width: f32, height: f32, depth: f32) -> error::Result { app_mut(|app| { Ok(app @@ -2299,3 +2310,678 @@ pub fn particles_apply(particles_entity: Entity, compute_entity: Entity) -> erro let workgroup_count = capacity.div_ceil(WORKGROUP_SIZE); compute_dispatch(compute_entity, workgroup_count, 1, 1) } + +// --- Font API --- + +/// Load a font file and return a font entity handle. +#[cfg(not(target_arch = "wasm32"))] +pub fn font_load(path: &str) -> error::Result { + use text::font::{Font, TextContext}; + + let data = std::fs::read(path) + .map_err(|e| error::ProcessingError::FontLoadError(format!("{}: {}", path, e)))?; + + app_mut(|app| { + let text_cx = app.world().resource::().clone(); + let family_name = text_cx + .load_font(data) + .ok_or(error::ProcessingError::FontLoadError( + "Could not determine font family name".to_string(), + ))?; + let entity = app.world_mut().spawn(Font { family_name }).id(); + Ok(entity) + }) +} + +/// Create a font handle from an existing font family name. +pub fn font_create(name: &str) -> error::Result { + use text::font::{Font, TextContext}; + + app_mut(|app| { + let text_cx = app.world().resource::().clone(); + if !text_cx.has_font(name) { + return Err(error::ProcessingError::FontNotFound); + } + let entity = app + .world_mut() + .spawn(Font { + family_name: name.to_string(), + }) + .id(); + Ok(entity) + }) +} + +/// List all available font family names (system + registered). +pub fn font_list() -> error::Result> { + use text::font::TextContext; + + app_mut(|app| { + let text_cx = app.world().resource::().clone(); + Ok(text_cx.list_fonts()) + }) +} + +/// Query variable font axes for a loaded font. +pub fn font_variations(font_entity: Entity) -> error::Result> { + use text::font::TextContext; + + app_mut(|app| { + let font = app + .world() + .get::(font_entity) + .ok_or(error::ProcessingError::InvalidArgument( + "Invalid font entity".to_string(), + ))?; + let family = font.family_name.clone(); + let text_cx = app.world().resource::().clone(); + Ok(text_cx.font_variations(&family)) + }) +} + +/// Query font metadata for a loaded font. +pub fn font_metadata(font_entity: Entity) -> error::Result { + use text::font::TextContext; + + app_mut(|app| { + let font = app + .world() + .get::(font_entity) + .ok_or(error::ProcessingError::InvalidArgument( + "Invalid font entity".to_string(), + ))?; + let family = font.family_name.clone(); + let text_cx = app.world().resource::().clone(); + text_cx + .font_metadata(&family) + .ok_or(error::ProcessingError::InvalidArgument( + format!("Font family '{}' not found", family), + )) + }) +} + +// --- Text API --- + +pub fn graphics_text_font( + graphics_entity: Entity, + font_entity: Option, +) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::TextFont(font_entity)) +} + +pub fn graphics_text_style(graphics_entity: Entity, style: u8) -> error::Result<()> { + use render::command::TextStyle; + graphics_record_command(graphics_entity, DrawCommand::TextStyle(TextStyle::from(style))) +} + +pub fn graphics_text( + graphics_entity: Entity, + content: String, + x: f32, + y: f32, + z: f32, + max_w: Option, + max_h: Option, +) -> error::Result<()> { + graphics_record_command( + graphics_entity, + DrawCommand::Text { + content, + x, + y, + z, + max_w, + max_h, + }, + ) +} + +pub fn graphics_text_size(graphics_entity: Entity, size: f32) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::TextSize(size)) +} + +pub fn graphics_text_align(graphics_entity: Entity, h: u8, v: u8) -> error::Result<()> { + use render::command::{TextAlignH, TextAlignV}; + graphics_record_command( + graphics_entity, + DrawCommand::TextAlign { + h: TextAlignH::from(h), + v: TextAlignV::from(v), + }, + ) +} + +pub fn graphics_text_leading(graphics_entity: Entity, leading: f32) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::TextLeading(leading)) +} + +pub fn graphics_text_wrap(graphics_entity: Entity, mode: u8) -> error::Result<()> { + use render::command::TextWrapMode; + graphics_record_command(graphics_entity, DrawCommand::TextWrap(TextWrapMode::from(mode))) +} + +pub fn graphics_text_direction(graphics_entity: Entity, dir: u8) -> error::Result<()> { + use render::command::TextDirection; + graphics_record_command( + graphics_entity, + DrawCommand::TextDirection(TextDirection::from(dir)), + ) +} + +pub fn graphics_text_weight(graphics_entity: Entity, weight: f32) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::TextWeight(weight)) +} + +fn parse_tag(tag: &str) -> error::Result<[u8; 4]> { + let bytes = tag.as_bytes(); + if bytes.len() != 4 { + return Err(error::ProcessingError::InvalidArgument( + format!("Font tag must be exactly 4 characters, got '{}'", tag), + )); + } + Ok([bytes[0], bytes[1], bytes[2], bytes[3]]) +} + +pub fn graphics_text_variation( + graphics_entity: Entity, + tag: &str, + value: f32, +) -> error::Result<()> { + let tag = parse_tag(tag)?; + graphics_record_command(graphics_entity, DrawCommand::TextVariation { tag, value }) +} + +pub fn graphics_clear_text_variations(graphics_entity: Entity) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::ClearTextVariations) +} + +pub fn graphics_text_feature( + graphics_entity: Entity, + tag: &str, + value: u16, +) -> error::Result<()> { + let tag = parse_tag(tag)?; + graphics_record_command(graphics_entity, DrawCommand::TextFeature { tag, value }) +} + +pub fn graphics_no_text_feature(graphics_entity: Entity, tag: &str) -> error::Result<()> { + let tag = parse_tag(tag)?; + graphics_record_command(graphics_entity, DrawCommand::NoTextFeature { tag }) +} + +pub fn graphics_clear_text_features(graphics_entity: Entity) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::ClearTextFeatures) +} + +pub fn graphics_text_to_paths( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, +) -> error::Result>> { + use render::primitive::text::TextParams; + use text::font::TextContext; + + app_mut(|app| { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let font_family = state.text_font_family.clone(); + let text_variations = state.text_variations.clone(); + let text_features = state.text_features.clone(); + let params = TextParams { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: state.text_leading, + max_w: None, + max_h: None, + wrap: state.text_wrap, + font_family: None, + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: &[], + text_features: &[], + glyph_colors: None, + }; + let text_cx = app.world().resource::().clone(); + let params = TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + ..params + }; + Ok(render::primitive::text::text_to_paths(content, x, y, ¶ms, &text_cx)) + }) +} + +pub fn graphics_text_to_contours( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, +) -> error::Result>> { + use render::primitive::text::TextParams; + use text::font::TextContext; + + app_mut(|app| { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let font_family = state.text_font_family.clone(); + let text_variations = state.text_variations.clone(); + let text_features = state.text_features.clone(); + let params = TextParams { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: state.text_leading, + max_w: None, + max_h: None, + wrap: state.text_wrap, + font_family: None, + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: &[], + text_features: &[], + glyph_colors: None, + }; + let text_cx = app.world().resource::().clone(); + let params = TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + ..params + }; + Ok(render::primitive::text::text_to_contours(content, x, y, ¶ms, &text_cx)) + }) +} + +pub fn graphics_text_to_points( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + sample_factor: Option, +) -> error::Result> { + use render::primitive::text::TextParams; + use text::font::TextContext; + + app_mut(|app| { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let font_family = state.text_font_family.clone(); + let text_variations = state.text_variations.clone(); + let text_features = state.text_features.clone(); + let params = TextParams { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: state.text_leading, + max_w: None, + max_h: None, + wrap: state.text_wrap, + font_family: None, + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: &[], + text_features: &[], + glyph_colors: None, + }; + let text_cx = app.world().resource::().clone(); + let params = TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + ..params + }; + Ok(render::primitive::text::text_to_points( + content, x, y, sample_factor.unwrap_or(0.1), ¶ms, &text_cx, + )) + }) +} + +pub fn graphics_text_to_model( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + depth: f32, +) -> error::Result { + use render::primitive::text::TextParams; + use text::font::TextContext; + + app_mut(|app| { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let font_family = state.text_font_family.clone(); + let text_variations = state.text_variations.clone(); + let text_features = state.text_features.clone(); + let params = TextParams { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: state.text_leading, + max_w: None, + max_h: None, + wrap: state.text_wrap, + font_family: None, + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: &[], + text_features: &[], + glyph_colors: None, + }; + let text_cx = app.world().resource::().clone(); + let params = TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + ..params + }; + Ok(render::primitive::text::text_to_model(content, x, y, depth, ¶ms, &text_cx)) + }) +} + +pub fn graphics_text_glyph_colors( + graphics_entity: Entity, + colors: Vec, +) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::TextGlyphColors(colors)) +} + +pub fn graphics_text_width(graphics_entity: Entity, content: &str) -> error::Result { + use render::primitive::text::TextParams; + use text::font::TextContext; + + app_mut(|app| { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let font_family = state.text_font_family.clone(); + let params = TextParams { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: state.text_leading, + max_w: None, + max_h: None, + wrap: state.text_wrap, + font_family: None, + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: &[], + text_features: &[], + glyph_colors: None, + }; + let text_cx = app.world().resource::().clone(); + let text_variations = state.text_variations.clone(); + let text_features = state.text_features.clone(); + let params = TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + ..params + }; + Ok(render::primitive::text::text_width(content, ¶ms, &text_cx)) + }) +} + +pub fn graphics_text_ascent(graphics_entity: Entity) -> error::Result { + use render::primitive::text::TextParams; + use text::font::TextContext; + + app_mut(|app| { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let font_family = state.text_font_family.clone(); + let params = TextParams { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: None, + max_w: None, + max_h: None, + wrap: render::command::TextWrapMode::Word, + font_family: None, + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: &[], + text_features: &[], + glyph_colors: None, + }; + let text_cx = app.world().resource::().clone(); + let text_variations = state.text_variations.clone(); + let text_features = state.text_features.clone(); + let params = TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + ..params + }; + Ok(render::primitive::text::text_ascent(¶ms, &text_cx)) + }) +} + +pub fn graphics_text_descent(graphics_entity: Entity) -> error::Result { + use render::primitive::text::TextParams; + use text::font::TextContext; + + app_mut(|app| { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let font_family = state.text_font_family.clone(); + let params = TextParams { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: None, + max_w: None, + max_h: None, + wrap: render::command::TextWrapMode::Word, + font_family: None, + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: &[], + text_features: &[], + glyph_colors: None, + }; + let text_cx = app.world().resource::().clone(); + let text_variations = state.text_variations.clone(); + let text_features = state.text_features.clone(); + let params = TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + ..params + }; + Ok(render::primitive::text::text_descent(¶ms, &text_cx)) + }) +} + +pub fn graphics_text_bounds( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, +) -> error::Result<[f32; 4]> { + use render::primitive::text::TextParams; + use text::font::TextContext; + + app_mut(|app| { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let font_family = state.text_font_family.clone(); + let params = TextParams { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: state.text_leading, + max_w, + max_h, + wrap: state.text_wrap, + font_family: None, + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: &[], + text_features: &[], + glyph_colors: None, + }; + let text_cx = app.world().resource::().clone(); + let text_variations = state.text_variations.clone(); + let text_features = state.text_features.clone(); + let params = TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + ..params + }; + Ok(render::primitive::text::text_bounds(content, x, y, ¶ms, &text_cx)) + }) +} + +pub fn graphics_text_line_count( + graphics_entity: Entity, + content: &str, +) -> error::Result { + use render::primitive::text::TextParams; + use text::font::TextContext; + + app_mut(|app| { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let font_family = state.text_font_family.clone(); + let text_variations = state.text_variations.clone(); + let text_features = state.text_features.clone(); + let params = TextParams { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: state.text_leading, + max_w: None, + max_h: None, + wrap: state.text_wrap, + font_family: None, + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: &[], + text_features: &[], + glyph_colors: None, + }; + let text_cx = app.world().resource::().clone(); + let params = TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + ..params + }; + Ok(render::primitive::text::text_line_count(content, ¶ms, &text_cx)) + }) +} + +pub fn graphics_text_lines( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, +) -> error::Result> { + use render::primitive::text::TextParams; + use text::font::TextContext; + + app_mut(|app| { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let font_family = state.text_font_family.clone(); + let text_variations = state.text_variations.clone(); + let text_features = state.text_features.clone(); + let params = TextParams { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: state.text_leading, + max_w, + max_h, + wrap: state.text_wrap, + font_family: None, + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: &[], + text_features: &[], + glyph_colors: None, + }; + let text_cx = app.world().resource::().clone(); + let params = TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + ..params + }; + Ok(render::primitive::text::text_lines(content, x, y, ¶ms, &text_cx)) + }) +} + +pub fn graphics_text_glyph_rects( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, +) -> error::Result> { + use render::primitive::text::TextParams; + use text::font::TextContext; + + app_mut(|app| { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let font_family = state.text_font_family.clone(); + let text_variations = state.text_variations.clone(); + let text_features = state.text_features.clone(); + let params = TextParams { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: state.text_leading, + max_w, + max_h, + wrap: state.text_wrap, + font_family: None, + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: &[], + text_features: &[], + glyph_colors: None, + }; + let text_cx = app.world().resource::().clone(); + let params = TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + ..params + }; + Ok(render::primitive::text::text_glyph_rects(content, x, y, ¶ms, &text_cx)) + }) +} diff --git a/crates/processing_render/src/render/command.rs b/crates/processing_render/src/render/command.rs index 2e3bae8d..893cec01 100644 --- a/crates/processing_render/src/render/command.rs +++ b/crates/processing_render/src/render/command.rs @@ -2,6 +2,112 @@ use bevy::prelude::*; use bevy::render::render_resource::{BlendComponent, BlendFactor, BlendOperation, BlendState}; use processing_core::error::{self, ProcessingError}; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum TextAlignH { + #[default] + Left = 0, + Center = 1, + Right = 2, +} + +impl From for TextAlignH { + fn from(v: u8) -> Self { + match v { + 0 => Self::Left, + 1 => Self::Center, + 2 => Self::Right, + _ => Self::default(), + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum TextAlignV { + #[default] + Baseline = 0, + Top = 1, + Center = 2, + Bottom = 3, +} + +impl From for TextAlignV { + fn from(v: u8) -> Self { + match v { + 0 => Self::Baseline, + 1 => Self::Top, + 2 => Self::Center, + 3 => Self::Bottom, + _ => Self::default(), + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum TextWrapMode { + #[default] + Word = 0, + Char = 1, +} + +impl From for TextWrapMode { + fn from(v: u8) -> Self { + match v { + 0 => Self::Word, + 1 => Self::Char, + _ => Self::default(), + } + } +} + +/// Text direction for BiDi layout. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum TextDirection { + /// Auto-detect from Unicode character properties. + #[default] + Auto = 0, + /// Left-to-right. + Ltr = 1, + /// Right-to-left. + Rtl = 2, +} + +impl From for TextDirection { + fn from(v: u8) -> Self { + match v { + 0 => Self::Auto, + 1 => Self::Ltr, + 2 => Self::Rtl, + _ => Self::default(), + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum TextStyle { + #[default] + Normal = 0, + Italic = 1, + Bold = 2, + BoldItalic = 3, +} + +impl From for TextStyle { + fn from(v: u8) -> Self { + match v { + 0 => Self::Normal, + 1 => Self::Italic, + 2 => Self::Bold, + 3 => Self::BoldItalic, + _ => Self::default(), + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[repr(u8)] pub enum StrokeCapMode { @@ -500,6 +606,39 @@ pub enum DrawCommand { Tetrahedron { radius: f32, }, + TextFont(Option), + TextStyle(TextStyle), + TextWeight(f32), + TextVariation { + tag: [u8; 4], + value: f32, + }, + ClearTextVariations, + TextFeature { + tag: [u8; 4], + value: u16, + }, + NoTextFeature { + tag: [u8; 4], + }, + ClearTextFeatures, + TextSize(f32), + TextAlign { + h: TextAlignH, + v: TextAlignV, + }, + TextLeading(f32), + TextWrap(TextWrapMode), + TextDirection(TextDirection), + TextGlyphColors(Vec), + Text { + content: String, + x: f32, + y: f32, + z: f32, + max_w: Option, + max_h: Option, + }, } #[derive(Debug, Default, Component)] diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index c0d21106..ad6b07f4 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -12,7 +12,10 @@ use bevy::{ prelude::*, render::render_resource::BlendState, }; -use command::{CommandBuffer, DrawCommand, ShapeMode}; +use command::{ + CommandBuffer, DrawCommand, ShapeMode, TextAlignH, TextAlignV, TextDirection, TextStyle, + TextWrapMode, +}; use material::{MaterialKey, ProcessingExtendedMaterial}; use primitive::{ ShapeBuilder, StrokeConfig, TessellationMode, VertexType, arc_fill, arc_stroke, bezier, @@ -31,6 +34,7 @@ use crate::{ material::custom::CustomMaterial, particles::{Particles, ParticlesDraw}, render::{material::UntypedMaterial, primitive::rect}, + text::font::TextContext, }; pub(crate) const BATCH_INDEX_STEP: f32 = 0.001; @@ -92,6 +96,18 @@ pub struct RenderState { pub rect_mode: ShapeMode, pub ellipse_mode: ShapeMode, pub shape_builder: Option, + pub text_font_family: Option, + pub text_style: TextStyle, + pub text_weight: Option, + pub text_variations: Vec<([u8; 4], f32)>, + pub text_features: Vec<([u8; 4], u16)>, + pub text_size: f32, + pub text_align_h: TextAlignH, + pub text_align_v: TextAlignV, + pub text_leading: Option, + pub text_wrap: TextWrapMode, + pub text_direction: TextDirection, + pub text_glyph_colors: Option>, } impl RenderState { @@ -115,6 +131,18 @@ impl RenderState { rect_mode: ShapeMode::Corner, ellipse_mode: ShapeMode::Center, shape_builder: None, + text_font_family: None, + text_style: TextStyle::Normal, + text_weight: None, + text_variations: Vec::new(), + text_features: Vec::new(), + text_size: 12.0, + text_align_h: TextAlignH::Left, + text_align_v: TextAlignV::Baseline, + text_leading: None, + text_wrap: TextWrapMode::Word, + text_direction: TextDirection::Auto, + text_glyph_colors: None, } } @@ -137,6 +165,18 @@ impl RenderState { self.rect_mode = ShapeMode::Corner; self.ellipse_mode = ShapeMode::Center; self.shape_builder = None; + self.text_font_family = None; + self.text_style = TextStyle::Normal; + self.text_weight = None; + self.text_variations.clear(); + self.text_features.clear(); + self.text_size = 12.0; + self.text_align_h = TextAlignH::Left; + self.text_align_v = TextAlignV::Baseline; + self.text_leading = None; + self.text_wrap = TextWrapMode::Word; + self.text_direction = TextDirection::Auto; + self.text_glyph_colors = None; } pub fn begin_frame(&mut self) { @@ -176,6 +216,8 @@ pub fn flush_draw_commands( p_geometries: Query<(&Geometry, Option<&GltfNodeTransform>)>, p_material_handles: Query<&UntypedMaterial>, mut p_particles: Query<&mut Particles>, + p_fonts: Query<&crate::text::font::Font>, + text_cx: Res, ) { for (graphics_entity, mut cmd_buffer, mut state, render_layers, projection, camera_transform) in graphics.iter_mut() @@ -1149,6 +1191,153 @@ pub fn flush_draw_commands( &p_material_handles, ); } + DrawCommand::TextFont(font_entity) => { + if let Some(entity) = font_entity { + if let Ok(font) = p_fonts.get(entity) { + state.text_font_family = Some(font.family_name.clone()); + } + } else { + state.text_font_family = None; + } + } + DrawCommand::TextStyle(style) => { + state.text_style = style; + } + DrawCommand::TextWeight(weight) => { + state.text_weight = Some(weight); + } + DrawCommand::TextVariation { tag, value } => { + if let Some(existing) = state.text_variations.iter_mut().find(|(t, _)| *t == tag) { + existing.1 = value; + } else { + state.text_variations.push((tag, value)); + } + } + DrawCommand::ClearTextVariations => { + state.text_variations.clear(); + } + DrawCommand::TextFeature { tag, value } => { + if let Some(existing) = state.text_features.iter_mut().find(|(t, _)| *t == tag) { + existing.1 = value; + } else { + state.text_features.push((tag, value)); + } + } + DrawCommand::NoTextFeature { tag } => { + state.text_features.retain(|(t, _)| *t != tag); + } + DrawCommand::ClearTextFeatures => { + state.text_features.clear(); + } + DrawCommand::TextSize(size) => { + state.text_size = size; + state.text_leading = None; + } + DrawCommand::TextAlign { h, v } => { + state.text_align_h = h; + state.text_align_v = v; + } + DrawCommand::TextLeading(leading) => { + state.text_leading = Some(leading); + } + DrawCommand::TextWrap(mode) => { + state.text_wrap = mode; + } + DrawCommand::TextDirection(dir) => { + state.text_direction = dir; + } + DrawCommand::TextGlyphColors(colors) => { + state.text_glyph_colors = Some(colors); + } + DrawCommand::Text { + content, + x, + y, + z, + max_w, + max_h, + } => { + // Apply rectMode to bounding box form + let (x, y, max_w, max_h) = if let (Some(w), Some(h)) = (max_w, max_h) { + let (bx, by, bw, bh) = apply_shape_mode(state.rect_mode, x, y, w, h); + (bx, by, Some(bw), Some(bh)) + } else { + (x, y, max_w, max_h) + }; + + let font_family = state.text_font_family.clone(); + let text_variations = state.text_variations.clone(); + let text_features = state.text_features.clone(); + let glyph_colors = state.text_glyph_colors.take(); + let params = primitive::text::TextParams { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: state.text_leading, + max_w, + max_h, + wrap: state.text_wrap, + font_family: None, + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: &[], + text_features: &[], + glyph_colors: None, + }; + let text_cx = text_cx.clone(); + + if z != 0.0 { + state.transform.translate_3d(0.0, 0.0, z); + } + + add_fill( + &mut res, + &mut batch, + &state, + |mesh, color| { + let params = primitive::text::TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + glyph_colors: glyph_colors.as_deref(), + ..params + }; + primitive::text::text( + mesh, &content, x, y, color, ¶ms, &text_cx, + ); + }, + &p_material_handles, + ); + + { + let text_cx = text_cx.clone(); + let font_family = font_family.clone(); + let text_variations = text_variations.clone(); + let text_features = text_features.clone(); + add_stroke( + &mut res, + &mut batch, + &state, + |mesh, color, weight| { + let params = primitive::text::TextParams { + font_family: font_family.as_deref(), + text_variations: &text_variations, + text_features: &text_features, + glyph_colors: None, + ..params + }; + primitive::text::text_stroke( + mesh, &content, x, y, color, weight, ¶ms, &text_cx, + ); + }, + &p_material_handles, + ); + } + + if z != 0.0 { + state.transform.translate_3d(0.0, 0.0, -z); + } + } } } diff --git a/crates/processing_render/src/render/primitive/mod.rs b/crates/processing_render/src/render/primitive/mod.rs index 2d956f50..c4f20d63 100644 --- a/crates/processing_render/src/render/primitive/mod.rs +++ b/crates/processing_render/src/render/primitive/mod.rs @@ -6,6 +6,7 @@ mod quad; mod rect; mod shape; mod shape3d; +pub mod text; mod triangle; pub use arc::{arc_fill, arc_stroke}; diff --git a/crates/processing_render/src/render/primitive/text.rs b/crates/processing_render/src/render/primitive/text.rs new file mode 100644 index 00000000..37e03202 --- /dev/null +++ b/crates/processing_render/src/render/primitive/text.rs @@ -0,0 +1,1175 @@ +use std::borrow::Cow; + +use bevy::prelude::*; +use bevy::mesh::{Indices, VertexAttributeValues}; +use lyon::{ + geom::Point, + path::Path, + tessellation::{ + FillOptions, FillTessellator, FillVertex, StrokeOptions, StrokeTessellator, VertexId, + geometry_builder::{FillGeometryBuilder, GeometryBuilder, GeometryBuilderError}, + }, +}; +use parley::{ + Alignment, AlignmentOptions, FontContext, Layout, LayoutContext, PositionedLayoutItem, + StyleProperty, + style::{ + FontFamily, FontFeature, FontSettings, FontStack, FontStyle as ParleyFontStyle, + FontVariation, FontWeight as ParleyFontWeight, LineHeight, WordBreakStrength, + }, +}; +use skrifa::{ + instance::{LocationRef, NormalizedCoord, Size}, + outline::{DrawSettings, OutlinePen}, + FontRef, MetadataProvider, +}; + +use crate::render::{ + command::{TextAlignH, TextAlignV, TextStyle, TextWrapMode}, + mesh_builder::MeshBuilder, +}; +use crate::text::font::{DEFAULT_FONT_FAMILY, TextContext}; + +/// A path command for text outline data. +#[derive(Debug, Clone)] +pub enum PathCommand { + MoveTo(f32, f32), + LineTo(f32, f32), + QuadTo { cx: f32, cy: f32, x: f32, y: f32 }, + CubicTo { cx1: f32, cy1: f32, cx2: f32, cy2: f32, x: f32, y: f32 }, + Close, +} + +/// Bundled text layout parameters to avoid long parameter lists. +pub struct TextParams<'a> { + pub text_size: f32, + pub align_h: TextAlignH, + pub align_v: TextAlignV, + pub leading: Option, + pub max_w: Option, + pub max_h: Option, + pub wrap: TextWrapMode, + pub font_family: Option<&'a str>, + pub text_style: TextStyle, + pub text_weight: Option, + pub text_variations: &'a [([u8; 4], f32)], + pub text_features: &'a [([u8; 4], u16)], + pub glyph_colors: Option<&'a [Color]>, +} + +/// Tessellate text into a mesh (fill). +pub fn text(mesh: &mut Mesh, content: &str, x: f32, y: f32, color: Color, params: &TextParams, text_cx: &TextContext) { + if content.is_empty() { + return; + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, color, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + tessellate_layout(mesh, &layout, base_x, base_y, params.max_h, params.glyph_colors); + }); +} + +/// Tessellate text outlines as strokes into a mesh. +pub fn text_stroke(mesh: &mut Mesh, content: &str, x: f32, y: f32, color: Color, stroke_weight: f32, params: &TextParams, text_cx: &TextContext) { + if content.is_empty() { + return; + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, color, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + stroke_layout(mesh, &layout, base_x, base_y, color, stroke_weight, params.max_h); + }); +} + +/// Measure the width of text without rendering. +pub fn text_width(content: &str, params: &TextParams, text_cx: &TextContext) -> f32 { + if content.is_empty() { + return 0.0; + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + + let mut max_width: f32 = 0.0; + for line_idx in 0..layout.len() { + if let Some(line) = layout.get(line_idx) { + max_width = max_width.max(line.metrics().advance); + } + } + max_width + }) +} + +/// Get font ascent for the current text size. +pub fn text_ascent(params: &TextParams, text_cx: &TextContext) -> f32 { + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, "X", Color::BLACK, params); + layout + .get(0) + .map(|line| line.metrics().ascent) + .unwrap_or(0.0) + }) +} + +/// Get font descent for the current text size. +pub fn text_descent(params: &TextParams, text_cx: &TextContext) -> f32 { + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, "X", Color::BLACK, params); + layout + .get(0) + .map(|line| line.metrics().descent) + .unwrap_or(0.0) + }) +} + +/// Compute the bounding box of text without rendering. +/// Returns [x, y, width, height]. +pub fn text_bounds(content: &str, x: f32, y: f32, params: &TextParams, text_cx: &TextContext) -> [f32; 4] { + if content.is_empty() { + return [x, y, 0.0, 0.0]; + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + + let width = layout.width(); + let total_height = layout.height(); + let ascent = layout + .get(0) + .map(|line| line.metrics().ascent) + .unwrap_or(0.0); + + // Clamp height if max_h is set + let height = match params.max_h { + Some(h) => total_height.min(h), + None => total_height, + }; + + // Compute vertical offset based on alignment (same logic as text()) + let by = match params.align_v { + TextAlignV::Baseline => y - ascent, + TextAlignV::Top => y, + TextAlignV::Center => y - total_height / 2.0, + TextAlignV::Bottom => y - total_height, + }; + + [x, by, width, height] + }) +} + +/// A line info entry from layout introspection. +#[derive(Debug, Clone)] +pub struct TextLineInfo { + /// The text content of this line. + pub text: String, + /// Bounding rect: [x, y, width, height]. + pub rect: [f32; 4], +} + +/// A glyph info entry from layout introspection. +#[derive(Debug, Clone)] +pub struct TextGlyphInfo { + /// Bounding rect: [x, y, width, height]. + pub rect: [f32; 4], +} + +/// Get the number of lines after layout. +pub fn text_line_count(content: &str, params: &TextParams, text_cx: &TextContext) -> usize { + if content.is_empty() { + return 0; + } + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + layout.len() + }) +} + +/// Get per-line info (text content and bounding rect) after layout. +pub fn text_lines( + content: &str, + x: f32, + y: f32, + params: &TextParams, + text_cx: &TextContext, +) -> Vec { + if content.is_empty() { + return Vec::new(); + } + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + let mut result = Vec::new(); + + for line_idx in 0..layout.len() { + let Some(line) = layout.get(line_idx) else { + continue; + }; + let metrics = line.metrics(); + + if let Some(h) = params.max_h { + if metrics.baseline + metrics.descent > h { + break; + } + } + + let line_y = base_y + metrics.baseline - metrics.ascent; + let line_text = &content[line.text_range()]; + + result.push(TextLineInfo { + text: line_text.to_string(), + rect: [base_x, line_y, metrics.advance, metrics.ascent + metrics.descent], + }); + } + result + }) +} + +/// Get per-glyph bounding rects after layout. +pub fn text_glyph_rects( + content: &str, + x: f32, + y: f32, + params: &TextParams, + text_cx: &TextContext, +) -> Vec { + if content.is_empty() { + return Vec::new(); + } + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + let mut result = Vec::new(); + + for line_idx in 0..layout.len() { + let Some(line) = layout.get(line_idx) else { + continue; + }; + let metrics = line.metrics(); + + if let Some(h) = params.max_h { + if metrics.baseline + metrics.descent > h { + break; + } + } + + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { + continue; + }; + let run = glyph_run.run(); + let font_size = run.font_size(); + for glyph in glyph_run.positioned_glyphs() { + let gx = base_x + glyph.x; + let gy = base_y + glyph.y - metrics.ascent; + result.push(TextGlyphInfo { + rect: [gx, gy, glyph.advance, font_size], + }); + } + } + } + result + }) +} + +/// Extract glyph outlines as path commands (one vec per glyph). +pub fn text_to_paths( + content: &str, + x: f32, + y: f32, + params: &TextParams, + text_cx: &TextContext, +) -> Vec> { + if content.is_empty() { + return Vec::new(); + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + extract_glyph_path_commands(&layout, base_x, base_y, params.max_h) + }) +} + +/// Sample points along text outlines. +/// `sample_factor` controls point density (default 0.1 — lower = more points). +pub fn text_to_points( + content: &str, + x: f32, + y: f32, + sample_factor: f32, + params: &TextParams, + text_cx: &TextContext, +) -> Vec<[f32; 2]> { + if content.is_empty() { + return Vec::new(); + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + let glyph_paths = extract_glyph_lyon_paths(&layout, base_x, base_y, params.max_h); + + let step = sample_factor.max(0.001); + let mut points = Vec::new(); + + for path in &glyph_paths { + for event in path.iter() { + use lyon::path::Event; + match event { + Event::Begin { at } => { + points.push([at.x, at.y]); + } + Event::Line { from, to } => { + let dx = to.x - from.x; + let dy = to.y - from.y; + let len = (dx * dx + dy * dy).sqrt(); + let steps = (len * step).max(1.0) as usize; + for i in 1..=steps { + let t = i as f32 / steps as f32; + points.push([from.x + dx * t, from.y + dy * t]); + } + } + Event::Quadratic { from, ctrl, to } => { + let steps = (20.0 * step).max(2.0) as usize; + for i in 1..=steps { + let t = i as f32 / steps as f32; + let inv = 1.0 - t; + let px = inv * inv * from.x + 2.0 * inv * t * ctrl.x + t * t * to.x; + let py = inv * inv * from.y + 2.0 * inv * t * ctrl.y + t * t * to.y; + points.push([px, py]); + } + } + Event::Cubic { from, ctrl1, ctrl2, to } => { + let steps = (30.0 * step).max(2.0) as usize; + for i in 1..=steps { + let t = i as f32 / steps as f32; + let inv = 1.0 - t; + let px = inv * inv * inv * from.x + + 3.0 * inv * inv * t * ctrl1.x + + 3.0 * inv * t * t * ctrl2.x + + t * t * t * to.x; + let py = inv * inv * inv * from.y + + 3.0 * inv * inv * t * ctrl1.y + + 3.0 * inv * t * t * ctrl2.y + + t * t * t * to.y; + points.push([px, py]); + } + } + Event::End { .. } => {} + } + } + } + + points + }) +} + +/// Generate a 3D extruded mesh from text outlines. +/// Returns a Mesh with front face, back face, and side walls. +/// Uses Y-up convention matching Bevy's 3D coordinate system. +pub fn text_to_model( + content: &str, + x: f32, + y: f32, + depth: f32, + params: &TextParams, + text_cx: &TextContext, +) -> Mesh { + let mut mesh = empty_mesh(); + + if content.is_empty() || depth <= 0.0 { + return mesh; + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::WHITE, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + let glyph_paths = extract_glyph_lyon_paths_yup(&layout, base_x, base_y, params.max_h); + + let half_depth = depth / 2.0; + let mut fill_tess = FillTessellator::new(); + + for path in &glyph_paths { + // --- Front face (z = +half_depth, normal = [0,0,1]) --- + { + let mut builder = Extrusion3DBuilder::new(&mut mesh, half_depth, [0.0, 0.0, 1.0]); + let _ = fill_tess.tessellate_path(path, &FillOptions::default(), &mut builder); + } + + // --- Back face (z = -half_depth, normal = [0,0,-1], reversed winding) --- + let back_indices_start = mesh + .indices() + .map(|i| match i { + Indices::U32(v) => v.len(), + _ => 0, + }) + .unwrap_or(0); + { + let mut builder = + Extrusion3DBuilder::new(&mut mesh, -half_depth, [0.0, 0.0, -1.0]); + let _ = fill_tess.tessellate_path(path, &FillOptions::default(), &mut builder); + } + + // Reverse winding order for back face + if let Some(Indices::U32(indices)) = mesh.indices_mut() { + let mut i = back_indices_start; + while i + 2 < indices.len() { + indices.swap(i + 1, i + 2); + i += 3; + } + } + + // --- Side walls --- + // Walk the path outline and connect front vertices to back vertices + let mut contour_points: Vec> = Vec::new(); + + for event in path.iter() { + use lyon::path::Event; + match event { + Event::Begin { at } => { + contour_points.clear(); + contour_points.push(at); + } + Event::Line { from: _, to } => { + contour_points.push(to); + } + Event::Quadratic { from, ctrl, to } => { + // Flatten quadratic curve + let steps = 8; + for s in 1..=steps { + let t = s as f32 / steps as f32; + let inv = 1.0 - t; + let px = inv * inv * from.x + 2.0 * inv * t * ctrl.x + t * t * to.x; + let py = inv * inv * from.y + 2.0 * inv * t * ctrl.y + t * t * to.y; + contour_points.push(Point::new(px, py)); + } + } + Event::Cubic { from, ctrl1, ctrl2, to } => { + // Flatten cubic curve + let steps = 12; + for s in 1..=steps { + let t = s as f32 / steps as f32; + let inv = 1.0 - t; + let px = inv * inv * inv * from.x + + 3.0 * inv * inv * t * ctrl1.x + + 3.0 * inv * t * t * ctrl2.x + + t * t * t * to.x; + let py = inv * inv * inv * from.y + + 3.0 * inv * inv * t * ctrl1.y + + 3.0 * inv * t * t * ctrl2.y + + t * t * t * to.y; + contour_points.push(Point::new(px, py)); + } + } + Event::End { close, .. } => { + if close && contour_points.len() >= 2 { + // Generate side wall quads for this contour + for i in 0..contour_points.len() { + let j = (i + 1) % contour_points.len(); + let p0 = contour_points[i]; + let p1 = contour_points[j]; + + // Compute outward normal for this edge + let dx = p1.x - p0.x; + let dy = p1.y - p0.y; + let len = (dx * dx + dy * dy).sqrt().max(1e-6); + let nx = -dy / len; + let ny = dx / len; + let normal = [nx, ny, 0.0]; + + // Four vertices: front-p0, front-p1, back-p1, back-p0 + let base = vertex_count(&mesh) as u32; + push_vertex_3d(&mut mesh, [p0.x, p0.y, half_depth], normal); + push_vertex_3d(&mut mesh, [p1.x, p1.y, half_depth], normal); + push_vertex_3d(&mut mesh, [p1.x, p1.y, -half_depth], normal); + push_vertex_3d(&mut mesh, [p0.x, p0.y, -half_depth], normal); + + // Two triangles + if let Some(Indices::U32(indices)) = mesh.indices_mut() { + indices.extend_from_slice(&[ + base, + base + 1, + base + 2, + base, + base + 2, + base + 3, + ]); + } + } + } + contour_points.clear(); + } + } + } + } + }); + + mesh +} + +fn empty_mesh() -> Mesh { + use bevy::render::mesh::PrimitiveTopology; + let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, default()); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new()); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, Vec::<[f32; 3]>::new()); + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, Vec::<[f32; 4]>::new()); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, Vec::<[f32; 2]>::new()); + mesh.insert_indices(Indices::U32(Vec::new())); + mesh +} + +fn vertex_count(mesh: &Mesh) -> usize { + mesh.attribute(Mesh::ATTRIBUTE_POSITION) + .map(|a| match a { + VertexAttributeValues::Float32x3(v) => v.len(), + _ => 0, + }) + .unwrap_or(0) +} + +fn push_vertex_3d(mesh: &mut Mesh, position: [f32; 3], normal: [f32; 3]) { + if let Some(VertexAttributeValues::Float32x3(positions)) = + mesh.attribute_mut(Mesh::ATTRIBUTE_POSITION) + { + positions.push(position); + } + if let Some(VertexAttributeValues::Float32x3(normals)) = + mesh.attribute_mut(Mesh::ATTRIBUTE_NORMAL) + { + normals.push(normal); + } + if let Some(VertexAttributeValues::Float32x4(colors)) = + mesh.attribute_mut(Mesh::ATTRIBUTE_COLOR) + { + colors.push([1.0, 1.0, 1.0, 1.0]); + } + if let Some(VertexAttributeValues::Float32x2(uvs)) = + mesh.attribute_mut(Mesh::ATTRIBUTE_UV_0) + { + uvs.push([0.0, 0.0]); + } +} + +/// A geometry builder that places lyon tessellation output at a given Z depth with a given normal. +struct Extrusion3DBuilder<'a> { + mesh: &'a mut Mesh, + z: f32, + normal: [f32; 3], + begin_vertex_count: u32, +} + +impl<'a> Extrusion3DBuilder<'a> { + fn new(mesh: &'a mut Mesh, z: f32, normal: [f32; 3]) -> Self { + Self { + mesh, + z, + normal, + begin_vertex_count: 0, + } + } +} + +impl<'a> GeometryBuilder for Extrusion3DBuilder<'a> { + fn begin_geometry(&mut self) { + self.begin_vertex_count = vertex_count(self.mesh) as u32; + } + fn add_triangle(&mut self, a: VertexId, b: VertexId, c: VertexId) { + if let Some(Indices::U32(indices)) = self.mesh.indices_mut() { + indices.push(a.to_usize() as u32); + indices.push(b.to_usize() as u32); + indices.push(c.to_usize() as u32); + } + } + fn abort_geometry(&mut self) {} +} + +impl<'a> FillGeometryBuilder for Extrusion3DBuilder<'a> { + fn add_fill_vertex(&mut self, vertex: FillVertex) -> Result { + let pos = vertex.position(); + let count = vertex_count(self.mesh); + push_vertex_3d(self.mesh, [pos.x, pos.y, self.z], self.normal); + Ok(VertexId::from_usize(count)) + } +} + +fn compute_text_origin(layout: &Layout, x: f32, y: f32, align_v: TextAlignV) -> (f32, f32) { + let total_height = layout.height(); + let ascent = layout + .get(0) + .map(|line| line.metrics().ascent) + .unwrap_or(0.0); + + let y_offset = match align_v { + TextAlignV::Baseline => y, + TextAlignV::Top => y + ascent, + TextAlignV::Center => y + ascent - total_height / 2.0, + TextAlignV::Bottom => y + ascent - total_height, + }; + + (x, y_offset) +} + +/// Extract text outlines as per-contour PathCommand vecs. +/// Each contour (each MoveTo...Close sequence) is a separate vec. +pub fn text_to_contours( + content: &str, + x: f32, + y: f32, + params: &TextParams, + text_cx: &TextContext, +) -> Vec> { + if content.is_empty() { + return Vec::new(); + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + let lyon_paths = extract_glyph_lyon_paths(&layout, base_x, base_y, params.max_h); + + let mut contours = Vec::new(); + for path in &lyon_paths { + let mut current_contour = Vec::new(); + for event in path.iter() { + use lyon::path::Event; + match event { + Event::Begin { at } => { + current_contour.push(PathCommand::MoveTo(at.x, at.y)); + } + Event::Line { from: _, to } => { + current_contour.push(PathCommand::LineTo(to.x, to.y)); + } + Event::Quadratic { from: _, ctrl, to } => { + current_contour.push(PathCommand::QuadTo { + cx: ctrl.x, cy: ctrl.y, x: to.x, y: to.y, + }); + } + Event::Cubic { from: _, ctrl1, ctrl2, to } => { + current_contour.push(PathCommand::CubicTo { + cx1: ctrl1.x, cy1: ctrl1.y, + cx2: ctrl2.x, cy2: ctrl2.y, + x: to.x, y: to.y, + }); + } + Event::End { close, .. } => { + if close { + current_contour.push(PathCommand::Close); + } + if !current_contour.is_empty() { + contours.push(std::mem::take(&mut current_contour)); + } + } + } + } + if !current_contour.is_empty() { + contours.push(current_contour); + } + } + contours + }) +} + +/// Extract glyph outlines as PathCommand vecs. +fn extract_glyph_path_commands( + layout: &Layout, + base_x: f32, + base_y: f32, + max_h: Option, +) -> Vec> { + let lyon_paths = extract_glyph_lyon_paths(layout, base_x, base_y, max_h); + lyon_paths + .into_iter() + .map(|path| { + let mut cmds = Vec::new(); + for event in path.iter() { + use lyon::path::Event; + match event { + Event::Begin { at } => cmds.push(PathCommand::MoveTo(at.x, at.y)), + Event::Line { from: _, to } => cmds.push(PathCommand::LineTo(to.x, to.y)), + Event::Quadratic { from: _, ctrl, to } => { + cmds.push(PathCommand::QuadTo { + cx: ctrl.x, cy: ctrl.y, x: to.x, y: to.y, + }); + } + Event::Cubic { from: _, ctrl1, ctrl2, to } => { + cmds.push(PathCommand::CubicTo { + cx1: ctrl1.x, cy1: ctrl1.y, + cx2: ctrl2.x, cy2: ctrl2.y, + x: to.x, y: to.y, + }); + } + Event::End { close, .. } => { + if close { cmds.push(PathCommand::Close); } + } + } + } + cmds + }) + .collect() +} + +/// Extract glyph outlines as lyon Path objects (internal helper). +fn extract_glyph_lyon_paths( + layout: &Layout, + base_x: f32, + base_y: f32, + max_h: Option, +) -> Vec { + let mut paths = Vec::new(); + + for line_idx in 0..layout.len() { + let Some(line) = layout.get(line_idx) else { + continue; + }; + + if let Some(h) = max_h { + let metrics = line.metrics(); + if metrics.baseline + metrics.descent > h { + break; + } + } + + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { + continue; + }; + + let run = glyph_run.run(); + let font_data = run.font(); + let font_size = run.font_size(); + let normalized_coords = run.normalized_coords(); + + let Ok(font_ref) = FontRef::from_index(font_data.data.as_ref(), font_data.index) + else { + continue; + }; + + let outlines = font_ref.outline_glyphs(); + let skrifa_size = Size::new(font_size); + let coords: Vec = normalized_coords + .iter() + .map(|&c| NormalizedCoord::from_bits(c)) + .collect(); + let location = LocationRef::new(&coords); + + for glyph in glyph_run.positioned_glyphs() { + let glyph_id = skrifa::GlyphId::new(glyph.id); + + if let Some(outline_glyph) = outlines.get(glyph_id) { + let mut pen = LyonOutlinePen::new(); + let settings = DrawSettings::unhinted(skrifa_size, location); + let _ = outline_glyph.draw(settings, &mut pen); + + if let Some(path) = pen.build() { + let tx = base_x + glyph.x; + let ty = base_y + glyph.y; + paths.push(translate_path_flip_y(&path, tx, ty)); + } + } + } + } + } + + paths +} + +fn build_layout( + font_cx: &mut FontContext, + layout_cx: &mut LayoutContext, + content: &str, + color: Color, + params: &TextParams, +) -> Layout { + let mut builder = layout_cx.ranged_builder(font_cx, content, 1.0, false); + + // Set default styles + let family = params.font_family.unwrap_or(DEFAULT_FONT_FAMILY); + builder.push_default(StyleProperty::FontSize(params.text_size)); + builder.push_default(StyleProperty::FontStack(FontStack::Single( + FontFamily::Named(Cow::Owned(family.to_string())), + ))); + builder.push_default(StyleProperty::Brush(color)); + + if let Some(line_height) = params.leading { + builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(line_height))); + } + + if matches!(params.wrap, TextWrapMode::Char) { + builder.push_default(StyleProperty::WordBreak(WordBreakStrength::BreakAll)); + } + + // Apply text style (bold/italic). text_weight overrides bold from text_style. + if let Some(weight) = params.text_weight { + builder.push_default(StyleProperty::FontWeight(ParleyFontWeight::new(weight))); + if matches!(params.text_style, TextStyle::Italic | TextStyle::BoldItalic) { + builder.push_default(StyleProperty::FontStyle(ParleyFontStyle::Italic)); + } + } else { + match params.text_style { + TextStyle::Normal => {} + TextStyle::Italic => { + builder.push_default(StyleProperty::FontStyle(ParleyFontStyle::Italic)); + } + TextStyle::Bold => { + builder.push_default(StyleProperty::FontWeight(ParleyFontWeight::BOLD)); + } + TextStyle::BoldItalic => { + builder.push_default(StyleProperty::FontStyle(ParleyFontStyle::Italic)); + builder.push_default(StyleProperty::FontWeight(ParleyFontWeight::BOLD)); + } + } + } + + if !params.text_variations.is_empty() { + let vars: Vec = params + .text_variations + .iter() + .map(|&(tag, value)| FontVariation { + tag: u32::from_be_bytes(tag), + value, + }) + .collect(); + builder.push_default(StyleProperty::FontVariations(FontSettings::List( + Cow::Owned(vars), + ))); + } + + if !params.text_features.is_empty() { + let feats: Vec = params + .text_features + .iter() + .map(|&(tag, value)| FontFeature { + tag: u32::from_be_bytes(tag), + value, + }) + .collect(); + builder.push_default(StyleProperty::FontFeatures(FontSettings::List( + Cow::Owned(feats), + ))); + } + + let mut layout = builder.build(content); + + // Apply line breaking + let max_advance = params.max_w.unwrap_or(f32::MAX); + layout.break_all_lines(Some(max_advance)); + + // Apply alignment + let alignment = match params.align_h { + TextAlignH::Left => Alignment::Start, + TextAlignH::Center => Alignment::Center, + TextAlignH::Right => Alignment::End, + }; + layout.align(params.max_w, alignment, AlignmentOptions::default()); + + layout +} + +fn tessellate_layout( + mesh: &mut Mesh, + layout: &Layout, + base_x: f32, + base_y: f32, + max_h: Option, + glyph_colors: Option<&[Color]>, +) { + let mut fill_tess = FillTessellator::new(); + let mut glyph_index: usize = 0; + + for line_idx in 0..layout.len() { + let Some(line) = layout.get(line_idx) else { + continue; + }; + + // Clip lines that exceed the maximum height + if let Some(h) = max_h { + let metrics = line.metrics(); + if metrics.baseline + metrics.descent > h { + break; + } + } + + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { + continue; + }; + + let run = glyph_run.run(); + let font_data = run.font(); + let font_size = run.font_size(); + let normalized_coords = run.normalized_coords(); + let color = glyph_run.style().brush.clone(); + + // Get the raw font bytes for skrifa + let Ok(font_ref) = FontRef::from_index(font_data.data.as_ref(), font_data.index) else { + continue; + }; + + let outlines = font_ref.outline_glyphs(); + let skrifa_size = Size::new(font_size); + + // Convert normalized coordinates (i16) to skrifa NormalizedCoord (F2Dot14) + let coords: Vec = normalized_coords + .iter() + .map(|&c| NormalizedCoord::from_bits(c)) + .collect(); + let location = LocationRef::new(&coords); + + // Process each glyph in the run + for glyph in glyph_run.positioned_glyphs() { + let glyph_color = glyph_colors + .filter(|colors| !colors.is_empty()) + .map(|colors| colors[glyph_index % colors.len()]) + .unwrap_or(color.clone()); + glyph_index += 1; + + let glyph_id = skrifa::GlyphId::new(glyph.id); + + if let Some(outline_glyph) = outlines.get(glyph_id) { + let mut pen = LyonOutlinePen::new(); + let settings = DrawSettings::unhinted(skrifa_size, location); + let _ = outline_glyph.draw(settings, &mut pen); + + if let Some(path) = pen.build() { + // glyph.x and glyph.y are the fully positioned coordinates + // Font outlines have Y-up, but our coordinate system is Y-down, + // so we negate the Y from the outline pen + let tx = base_x + glyph.x; + let ty = base_y + glyph.y; + + let translated = translate_path_flip_y(&path, tx, ty); + + let mut builder = MeshBuilder::new(mesh, glyph_color.clone()); + let _ = fill_tess.tessellate_path( + &translated, + &FillOptions::default(), + &mut builder, + ); + } + } + } + } + } +} + +fn stroke_layout( + mesh: &mut Mesh, + layout: &Layout, + base_x: f32, + base_y: f32, + color: Color, + stroke_weight: f32, + max_h: Option, +) { + let mut stroke_tess = StrokeTessellator::new(); + let stroke_opts = StrokeOptions::default().with_line_width(stroke_weight); + + let glyph_paths = extract_glyph_lyon_paths(layout, base_x, base_y, max_h); + for path in &glyph_paths { + let mut builder = MeshBuilder::new(mesh, color.clone()); + let _ = stroke_tess.tessellate_path(path, &stroke_opts, &mut builder); + } +} + +/// Translate a lyon path keeping Y-up convention (for 3D geometry). +/// Font outline Y is up; layout ty is Y-down, so we compute: (x + tx, y - ty). +fn translate_path_yup(path: &Path, tx: f32, ty: f32) -> Path { + let mut builder = Path::builder(); + for event in path.iter() { + use lyon::path::Event; + match event { + Event::Begin { at } => { + builder.begin(Point::new(at.x + tx, at.y - ty)); + } + Event::Line { from: _, to } => { + builder.line_to(Point::new(to.x + tx, to.y - ty)); + } + Event::Quadratic { from: _, ctrl, to } => { + builder.quadratic_bezier_to( + Point::new(ctrl.x + tx, ctrl.y - ty), + Point::new(to.x + tx, to.y - ty), + ); + } + Event::Cubic { + from: _, + ctrl1, + ctrl2, + to, + } => { + builder.cubic_bezier_to( + Point::new(ctrl1.x + tx, ctrl1.y - ty), + Point::new(ctrl2.x + tx, ctrl2.y - ty), + Point::new(to.x + tx, to.y - ty), + ); + } + Event::End { + last: _, + first: _, + close, + } => { + builder.end(close); + } + } + } + builder.build() +} + +/// Extract glyph outlines as lyon Path objects in Y-up convention (for 3D). +fn extract_glyph_lyon_paths_yup( + layout: &Layout, + base_x: f32, + base_y: f32, + max_h: Option, +) -> Vec { + let mut paths = Vec::new(); + + for line_idx in 0..layout.len() { + let Some(line) = layout.get(line_idx) else { + continue; + }; + + if let Some(h) = max_h { + let metrics = line.metrics(); + if metrics.baseline + metrics.descent > h { + break; + } + } + + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { + continue; + }; + + let run = glyph_run.run(); + let font_data = run.font(); + let font_size = run.font_size(); + let normalized_coords = run.normalized_coords(); + + let Ok(font_ref) = FontRef::from_index(font_data.data.as_ref(), font_data.index) + else { + continue; + }; + + let outlines = font_ref.outline_glyphs(); + let skrifa_size = Size::new(font_size); + let coords: Vec = normalized_coords + .iter() + .map(|&c| NormalizedCoord::from_bits(c)) + .collect(); + let location = LocationRef::new(&coords); + + for glyph in glyph_run.positioned_glyphs() { + let glyph_id = skrifa::GlyphId::new(glyph.id); + + if let Some(outline_glyph) = outlines.get(glyph_id) { + let mut pen = LyonOutlinePen::new(); + let settings = DrawSettings::unhinted(skrifa_size, location); + let _ = outline_glyph.draw(settings, &mut pen); + + if let Some(path) = pen.build() { + let tx = base_x + glyph.x; + let ty = base_y + glyph.y; + paths.push(translate_path_yup(&path, tx, ty)); + } + } + } + } + } + + paths +} + +/// Translate a lyon path by (tx, ty) and flip Y coordinates (font Y-up to screen Y-down). +fn translate_path_flip_y(path: &Path, tx: f32, ty: f32) -> Path { + let mut builder = Path::builder(); + for event in path.iter() { + use lyon::path::Event; + match event { + Event::Begin { at } => { + builder.begin(Point::new(at.x + tx, -at.y + ty)); + } + Event::Line { from: _, to } => { + builder.line_to(Point::new(to.x + tx, -to.y + ty)); + } + Event::Quadratic { from: _, ctrl, to } => { + builder.quadratic_bezier_to( + Point::new(ctrl.x + tx, -ctrl.y + ty), + Point::new(to.x + tx, -to.y + ty), + ); + } + Event::Cubic { + from: _, + ctrl1, + ctrl2, + to, + } => { + builder.cubic_bezier_to( + Point::new(ctrl1.x + tx, -ctrl1.y + ty), + Point::new(ctrl2.x + tx, -ctrl2.y + ty), + Point::new(to.x + tx, -to.y + ty), + ); + } + Event::End { + last: _, + first: _, + close, + } => { + builder.end(close); + } + } + } + builder.build() +} + +/// OutlinePen implementation that converts skrifa glyph outlines to lyon Path. +struct LyonOutlinePen { + builder: lyon::path::path::Builder, + has_content: bool, +} + +impl LyonOutlinePen { + fn new() -> Self { + Self { + builder: Path::builder(), + has_content: false, + } + } + + fn build(self) -> Option { + if self.has_content { + Some(self.builder.build()) + } else { + None + } + } +} + +impl OutlinePen for LyonOutlinePen { + fn move_to(&mut self, x: f32, y: f32) { + self.builder.begin(Point::new(x, y)); + self.has_content = true; + } + + fn line_to(&mut self, x: f32, y: f32) { + self.builder.line_to(Point::new(x, y)); + } + + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.builder + .quadratic_bezier_to(Point::new(cx0, cy0), Point::new(x, y)); + } + + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.builder.cubic_bezier_to( + Point::new(cx0, cy0), + Point::new(cx1, cy1), + Point::new(x, y), + ); + } + + fn close(&mut self) { + self.builder.end(true); + } +} diff --git a/crates/processing_render/src/text/font.rs b/crates/processing_render/src/text/font.rs new file mode 100644 index 00000000..59e83ad9 --- /dev/null +++ b/crates/processing_render/src/text/font.rs @@ -0,0 +1,168 @@ +use std::sync::{Arc, Mutex}; + +use bevy::prelude::*; +use parley::{FontContext, LayoutContext}; + +/// A font entity component storing the font's family name. +#[derive(Component)] +pub struct Font { + pub family_name: String, +} + +/// Shared text context resource containing parley's font and layout contexts. +#[derive(Resource, Clone)] +pub struct TextContext { + inner: Arc>, +} + +struct TextContextInner { + pub font_cx: FontContext, + pub layout_cx: LayoutContext, +} + +impl TextContext { + pub fn new() -> Self { + let mut font_cx = FontContext::default(); + + // Register the embedded NotoSans as default font + font_cx + .collection + .register_fonts(notosans::REGULAR_TTF.to_vec().into(), None); + + Self { + inner: Arc::new(Mutex::new(TextContextInner { + font_cx, + layout_cx: LayoutContext::new(), + })), + } + } + + /// Access the font and layout contexts together via a closure. + /// We split the struct to avoid double-mutable-borrow issues. + pub fn with(&self, f: impl FnOnce(&mut FontContext, &mut LayoutContext) -> R) -> R { + let mut inner = self.inner.lock().unwrap(); + let TextContextInner { + ref mut font_cx, + ref mut layout_cx, + } = *inner; + f(font_cx, layout_cx) + } + + /// Load a font file and register it with the font context. + /// Returns the primary family name of the loaded font, if available. + pub fn load_font(&self, data: Vec) -> Option { + let mut inner = self.inner.lock().unwrap(); + let families = inner + .font_cx + .collection + .register_fonts(data.into(), None); + families.first().and_then(|(fam_id, _)| { + inner + .font_cx + .collection + .family_name(*fam_id) + .map(|s| s.to_string()) + }) + } + + /// List all available font family names (system + registered). + pub fn list_fonts(&self) -> Vec { + let mut inner = self.inner.lock().unwrap(); + inner + .font_cx + .collection + .family_names() + .map(|s| s.to_string()) + .collect() + } + + /// Check if a font family name is available. + pub fn has_font(&self, name: &str) -> bool { + let mut inner = self.inner.lock().unwrap(); + inner.font_cx.collection.family_id(name).is_some() + } +} + +impl Default for TextContext { + fn default() -> Self { + Self::new() + } +} + +/// Info about a variable font axis. +#[derive(Debug, Clone)] +pub struct FontAxisInfo { + /// Four-character tag (e.g. "wght", "wdth"). + pub tag: String, + /// Minimum axis value. + pub min: f32, + /// Maximum axis value. + pub max: f32, + /// Default axis value. + pub default: f32, +} + +/// Font metadata. +#[derive(Debug, Clone, Default)] +pub struct FontMetadata { + pub family: String, + pub style: String, + pub weight: f32, + pub width: f32, + pub is_variable: bool, +} + +impl TextContext { + /// Query variable font axes for a given family name. + pub fn font_variations(&self, family: &str) -> Vec { + let mut inner = self.inner.lock().unwrap(); + let family_info = match inner.font_cx.collection.family_by_name(family) { + Some(f) => f, + None => return Vec::new(), + }; + let font_info = match family_info.default_font() { + Some(f) => f, + None => return Vec::new(), + }; + + font_info + .axes() + .iter() + .map(|axis| { + let tag_bytes = axis.tag.to_be_bytes(); + let tag = String::from_utf8_lossy(&tag_bytes).to_string(); + FontAxisInfo { + tag, + min: axis.min, + max: axis.max, + default: axis.default, + } + }) + .collect() + } + + /// Query font metadata for a given family name. + pub fn font_metadata(&self, family: &str) -> Option { + let mut inner = self.inner.lock().unwrap(); + let family_info = inner.font_cx.collection.family_by_name(family)?; + let font_info = family_info.default_font()?; + + Some(FontMetadata { + family: family_info.name().to_string(), + style: format!("{:?}", font_info.style()), + weight: font_info.weight().value(), + width: font_info.width().ratio(), + is_variable: !font_info.axes().is_empty(), + }) + } +} + +pub const DEFAULT_FONT_FAMILY: &str = "Noto Sans"; + +pub struct TextPlugin; + +impl Plugin for TextPlugin { + fn build(&self, app: &mut App) { + app.insert_resource(TextContext::new()); + } +} diff --git a/crates/processing_render/src/text/mod.rs b/crates/processing_render/src/text/mod.rs new file mode 100644 index 00000000..8123d3bf --- /dev/null +++ b/crates/processing_render/src/text/mod.rs @@ -0,0 +1 @@ +pub mod font; diff --git a/examples/text.rs b/examples/text.rs new file mode 100644 index 00000000..498d5776 --- /dev/null +++ b/examples/text.rs @@ -0,0 +1,109 @@ +use bevy::prelude::Color; +use processing_glfw::GlfwContext; + +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + match sketch() { + Ok(_) => { + eprintln!("Sketch completed successfully"); + exit(0).unwrap(); + } + Err(e) => { + eprintln!("Sketch error: {:?}", e); + exit(1).unwrap(); + } + }; +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(600, 400)?; + init(Config::default())?; + + let width = 600; + let height = 400; + let surface = glfw_ctx.create_surface(width, height)?; + let graphics = graphics_create(surface, width, height, TextureFormat::Rgba16Float)?; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + + // White background + graphics_record_command(graphics, DrawCommand::BackgroundColor(Color::WHITE))?; + + // Set fill to black for text + graphics_record_command(graphics, DrawCommand::Fill(Color::BLACK))?; + + // Set text size + graphics_record_command(graphics, DrawCommand::TextSize(32.0))?; + + // Draw text + graphics_record_command( + graphics, + DrawCommand::Text { + content: "Hello, Processing!".to_string(), + x: 50.0, + y: 100.0, + z: 0.0, + max_w: None, + max_h: None, + }, + )?; + + // Smaller text + graphics_record_command(graphics, DrawCommand::TextSize(18.0))?; + + graphics_record_command( + graphics, + DrawCommand::Text { + content: "Text rendering with parley + skrifa + lyon".to_string(), + x: 50.0, + y: 160.0, + z: 0.0, + max_w: None, + max_h: None, + }, + )?; + + // Text with bounding box (word wrap) + graphics_record_command(graphics, DrawCommand::TextSize(16.0))?; + + graphics_record_command( + graphics, + DrawCommand::Text { + content: "This is a longer paragraph of text that should wrap within its bounding box. The text uses parley for layout, skrifa for glyph outlines, and lyon for tessellation.".to_string(), + x: 50.0, + y: 220.0, + z: 0.0, + max_w: Some(300.0), + max_h: Some(200.0), + }, + )?; + + // Center-aligned text + graphics_record_command( + graphics, + DrawCommand::TextAlign { + h: TextAlignH::Center, + v: TextAlignV::Top, + }, + )?; + graphics_record_command(graphics, DrawCommand::TextSize(24.0))?; + + graphics_record_command( + graphics, + DrawCommand::Text { + content: "Centered".to_string(), + x: 450.0, + y: 100.0, + z: 0.0, + max_w: Some(200.0), + max_h: None, + }, + )?; + + graphics_end_draw(graphics)?; + } + Ok(()) +} diff --git a/examples/text_3d.rs b/examples/text_3d.rs new file mode 100644 index 00000000..9fa1646d --- /dev/null +++ b/examples/text_3d.rs @@ -0,0 +1,76 @@ +use processing_glfw::GlfwContext; + +use bevy::math::{Vec2, Vec3}; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let width = 1200; + let height = 700; + let mut glfw_ctx = GlfwContext::new(width, height)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(width, height)?; + let graphics = graphics_create(surface, width, height, TextureFormat::Rgba16Float)?; + + // 3D camera + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 0.0, 800.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + // Directional light to reveal 3D shape + let dir_light = + light_create_directional(graphics, bevy::color::Color::srgb(0.3, 0.3, 0.4), 2000.0)?; + transform_set_position(dir_light, Vec3::new(200.0, 300.0, 500.0))?; + transform_look_at(dir_light, Vec3::new(0.0, 0.0, 0.0))?; + + // Emissive glowing material + let glow = material_create_pbr()?; + material_set(glow, "roughness", shader_value::ShaderValue::Float(0.3))?; + material_set(glow, "metallic", shader_value::ShaderValue::Float(0.5))?; + // HDR emissive — values > 1.0 produce bloom/glow + material_set(glow, "emissive", shader_value::ShaderValue::Float4([2.0, 0.5, 3.0, 1.0]))?; + + // Generate 3D text geometry — measure width to center the mesh + graphics_record_command(graphics, DrawCommand::TextSize(120.0))?; + graphics_record_command(graphics, DrawCommand::TextStyle(TextStyle::Bold))?; + + let w = graphics_text_width(graphics, "Processing")?; + let mesh = graphics_text_to_model(graphics, "Processing", -w / 2.0, 0.0, 40.0)?; + let geom = geometry_create_from_mesh(mesh)?; + + let mut t: f32 = 0.0; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.02, 0.02, 0.03)), + )?; + + graphics_record_command( + graphics, + DrawCommand::Fill(bevy::color::Color::srgb(0.8, 0.3, 1.0)), + )?; + graphics_record_command(graphics, DrawCommand::Material(glow))?; + + graphics_record_command(graphics, DrawCommand::PushMatrix)?; + graphics_record_command(graphics, DrawCommand::Scale(Vec2::new(15.0, 15.0)))?; + graphics_record_command(graphics, DrawCommand::Rotate { angle: t * 0.3 })?; + graphics_record_command(graphics, DrawCommand::Geometry(geom))?; + graphics_record_command(graphics, DrawCommand::PopMatrix)?; + + graphics_end_draw(graphics)?; + t += 0.016; + } + + material_destroy(glow)?; + + Ok(()) +} diff --git a/src/prelude.rs b/src/prelude.rs index 3ff8cb6c..ac582eab 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -11,7 +11,7 @@ pub use processing_midi::{ pub use processing_render::{ render::command::{ ArcMode, BlendMode, DrawCommand, ShapeKind, ShapeMode, StrokeCapMode, StrokeJoinMode, - custom_blend_state, + TextAlignH, TextAlignV, TextDirection, TextStyle, TextWrapMode, custom_blend_state, }, *, }; From 7f59de4c592f79c90be2506463d56b610cc0c676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Wed, 13 May 2026 22:38:12 -0700 Subject: [PATCH 2/5] Cleanup. --- crates/processing_ffi/src/lib.rs | 8 - crates/processing_pyo3/src/graphics.rs | 98 ++-- crates/processing_render/src/graphics.rs | 9 +- crates/processing_render/src/lib.rs | 551 +++++------------- .../processing_render/src/render/command.rs | 25 - crates/processing_render/src/render/mod.rs | 80 +-- .../src/render/primitive/text.rs | 116 +++- crates/processing_render/src/text/font.rs | 10 +- docs/gen_ref_pages.py | 2 +- mkdocs.yml | 1 + src/prelude.rs | 2 +- 11 files changed, 302 insertions(+), 600 deletions(-) diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index cd610bab..085ad250 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -1376,14 +1376,6 @@ pub extern "C" fn processing_text_leading(graphics_id: u64, leading: f32) { error::check(|| graphics_record_command(graphics_entity, DrawCommand::TextLeading(leading))); } -/// Set the text direction. 0=AUTO, 1=LTR, 2=RTL -#[unsafe(no_mangle)] -pub extern "C" fn processing_text_direction(graphics_id: u64, dir: u8) { - error::clear_error(); - let graphics_entity = Entity::from_bits(graphics_id); - error::check(|| graphics_text_direction(graphics_entity, dir)); -} - /// Set the text wrap mode. 0=WORD, 1=CHAR #[unsafe(no_mangle)] pub extern "C" fn processing_text_wrap(graphics_id: u64, mode: u8) { diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index 9bc2bcf5..8414db4f 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -230,7 +230,9 @@ pub struct Font { #[pymethods] impl Font { - /// Query variable font axes. Returns list of dicts: {tag, min, max, default}. + /// Query variable font axes. + /// + /// Returns a list of `(tag, min, max, default)` tuples. pub fn variations(&self) -> PyResult> { font_variations(self.entity) .map(|axes| { @@ -241,7 +243,9 @@ impl Font { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - /// Query font metadata. Returns dict with family, style, weight, width, is_variable. + /// Query font metadata. + /// + /// Returns a `(family, style, weight, width, is_variable)` tuple. pub fn metadata(&self) -> PyResult<(String, String, f32, f32, bool)> { font_metadata(self.entity) .map(|m| (m.family, m.style, m.weight, m.width, m.is_variable)) @@ -249,6 +253,36 @@ impl Font { } } +/// Convert glyph outline data into per-glyph (or per-contour) lists of Python +/// tuples. Each command is a variable-length tuple tagged by a single letter: +/// `("M", x, y)`, `("L", x, y)`, `("Q", cx, cy, x, y)`, +/// `("C", cx1, cy1, cx2, cy2, x, y)`, or `("Z",)`. +fn path_commands_to_py( + py: Python<'_>, + groups: Vec>, +) -> Vec>> { + use processing_render::render::primitive::text::PathCommand; + + let to_py = |cmd: PathCommand| -> Py { + match cmd { + PathCommand::MoveTo(x, y) => ("M", x, y).into_pyobject(py).unwrap().into_any().unbind(), + PathCommand::LineTo(x, y) => ("L", x, y).into_pyobject(py).unwrap().into_any().unbind(), + PathCommand::QuadTo { cx, cy, x, y } => { + ("Q", cx, cy, x, y).into_pyobject(py).unwrap().into_any().unbind() + } + PathCommand::CubicTo { cx1, cy1, cx2, cy2, x, y } => { + ("C", cx1, cy1, cx2, cy2, x, y).into_pyobject(py).unwrap().into_any().unbind() + } + PathCommand::Close => ("Z",).into_pyobject(py).unwrap().into_any().unbind(), + } + }; + + groups + .into_iter() + .map(|group| group.into_iter().map(to_py).collect()) + .collect() +} + #[pyclass] #[derive(Debug)] pub struct Image { @@ -1081,60 +1115,23 @@ impl Graphics { x: f32, y: f32, ) -> PyResult>>> { - use processing_render::render::primitive::text::PathCommand; - let paths = graphics_text_to_paths(self.entity, content, x, y) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - - Python::attach(|py| { - Ok(paths - .into_iter() - .map(|glyph| { - glyph - .into_iter() - .map(|cmd| match cmd { - PathCommand::MoveTo(x, y) => ("M", x, y, 0.0, 0.0, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), - PathCommand::LineTo(x, y) => ("L", x, y, 0.0, 0.0, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), - PathCommand::QuadTo { cx, cy, x, y } => ("Q", cx, cy, x, y, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), - PathCommand::CubicTo { cx1, cy1, cx2, cy2, x, y } => ("C", cx1, cy1, cx2, cy2, x, y).into_pyobject(py).unwrap().into_any().unbind(), - PathCommand::Close => ("Z", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), - }) - .collect() - }) - .collect()) - }) + Python::attach(|py| Ok(path_commands_to_py(py, paths))) } /// Extract glyph outlines as per-contour path commands. /// Each contour (MoveTo...Close sequence) is a separate list. + /// Commands use the same tuple shapes as `text_to_paths`. pub fn text_to_contours( &self, content: &str, x: f32, y: f32, ) -> PyResult>>> { - use processing_render::render::primitive::text::PathCommand; - let contours = graphics_text_to_contours(self.entity, content, x, y) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - - Python::attach(|py| { - Ok(contours - .into_iter() - .map(|contour| { - contour - .into_iter() - .map(|cmd| match cmd { - PathCommand::MoveTo(x, y) => ("M", x, y, 0.0, 0.0, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), - PathCommand::LineTo(x, y) => ("L", x, y, 0.0, 0.0, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), - PathCommand::QuadTo { cx, cy, x, y } => ("Q", cx, cy, x, y, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), - PathCommand::CubicTo { cx1, cy1, cx2, cy2, x, y } => ("C", cx1, cy1, cx2, cy2, x, y).into_pyobject(py).unwrap().into_any().unbind(), - PathCommand::Close => ("Z", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0).into_pyobject(py).unwrap().into_any().unbind(), - }) - .collect() - }) - .collect()) - }) + Python::attach(|py| Ok(path_commands_to_py(py, contours))) } /// Sample points along text outlines. @@ -1167,12 +1164,11 @@ impl Graphics { } /// Set per-glyph colors for the next text() call. - /// colors: list of (r, g, b) or (r, g, b, a) tuples with values 0-255. - pub fn text_glyph_colors(&self, colors: Vec<(f32, f32, f32, f32)>) -> PyResult<()> { - let colors: Vec = colors - .into_iter() - .map(|(r, g, b, a)| bevy::color::Color::srgba(r, g, b, a)) - .collect(); + /// + /// `colors` is a list of color objects (as built by `color(...)`); they are + /// cycled across the glyphs of the next `text()` call. + pub fn text_glyph_colors(&self, colors: Vec>) -> PyResult<()> { + let colors: Vec = colors.iter().map(|c| c.0).collect(); graphics_text_glyph_colors(self.entity, colors) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } @@ -1200,12 +1196,6 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - /// Set text direction. 0=AUTO, 1=LTR, 2=RTL - pub fn text_direction(&self, dir: u8) -> PyResult<()> { - graphics_text_direction(self.entity, dir) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) - } - pub fn text_wrap(&self, mode: u8) -> PyResult<()> { use processing::prelude::TextWrapMode; graphics_record_command(self.entity, DrawCommand::TextWrap(TextWrapMode::from(mode))) diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index d7f5f7e3..001f9f5d 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -9,8 +9,7 @@ use bevy::{ ImageRenderTarget, MsaaWriteback, Projection, RenderTarget, visibility::RenderLayers, }, core_pipeline::tonemapping::Tonemapping, - post_process::bloom::Bloom, - ecs::{entity::EntityHashMap, query::QueryEntityError}, + ecs::query::QueryEntityError, math::{Mat4, Vec3A}, prelude::*, render::{ @@ -208,7 +207,7 @@ pub fn create( ..default() }, target, - // tonemapping is conditionally set below based on HDR mode + // tonemapping prevents color accurate readback, so we disable it Tonemapping::None, // we need to be able to write to the texture CameraMainTextureUsages::default().with(TextureUsages::COPY_DST), @@ -226,9 +225,9 @@ pub fn create( }, )); - // enable Hdr, Bloom, and tonemapping for floating-point texture formats + // only enable Hdr for floating-point texture formats if is_hdr { - entity_commands.insert((Hdr, Bloom::NATURAL, Tonemapping::TonyMcMapface)); + entity_commands.insert(Hdr); } let entity = entity_commands.id(); diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 42121aab..7f2e9ccd 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -2314,23 +2314,36 @@ pub fn particles_apply(particles_entity: Entity, compute_entity: Entity) -> erro // --- Font API --- /// Load a font file and return a font entity handle. -#[cfg(not(target_arch = "wasm32"))] +/// +/// Reading fonts from the filesystem is not available on wasm; callers should +/// register font bytes through another path there. pub fn font_load(path: &str) -> error::Result { use text::font::{Font, TextContext}; - let data = std::fs::read(path) - .map_err(|e| error::ProcessingError::FontLoadError(format!("{}: {}", path, e)))?; + #[cfg(target_arch = "wasm32")] + { + let _ = path; + return Err(error::ProcessingError::FontLoadError( + "loading fonts from a file is not supported on wasm".to_string(), + )); + } - app_mut(|app| { - let text_cx = app.world().resource::().clone(); - let family_name = text_cx - .load_font(data) - .ok_or(error::ProcessingError::FontLoadError( - "Could not determine font family name".to_string(), - ))?; - let entity = app.world_mut().spawn(Font { family_name }).id(); - Ok(entity) - }) + #[cfg(not(target_arch = "wasm32"))] + { + let data = std::fs::read(path) + .map_err(|e| error::ProcessingError::FontLoadError(format!("{}: {}", path, e)))?; + + app_mut(|app| { + let text_cx = app.world().resource::().clone(); + let family_name = text_cx + .load_font(data) + .ok_or(error::ProcessingError::FontLoadError( + "Could not determine font family name".to_string(), + ))?; + let entity = app.world_mut().spawn(Font { family_name }).id(); + Ok(entity) + }) + } } /// Create a font handle from an existing font family name. @@ -2414,32 +2427,6 @@ pub fn graphics_text_style(graphics_entity: Entity, style: u8) -> error::Result< graphics_record_command(graphics_entity, DrawCommand::TextStyle(TextStyle::from(style))) } -pub fn graphics_text( - graphics_entity: Entity, - content: String, - x: f32, - y: f32, - z: f32, - max_w: Option, - max_h: Option, -) -> error::Result<()> { - graphics_record_command( - graphics_entity, - DrawCommand::Text { - content, - x, - y, - z, - max_w, - max_h, - }, - ) -} - -pub fn graphics_text_size(graphics_entity: Entity, size: f32) -> error::Result<()> { - graphics_record_command(graphics_entity, DrawCommand::TextSize(size)) -} - pub fn graphics_text_align(graphics_entity: Entity, h: u8, v: u8) -> error::Result<()> { use render::command::{TextAlignH, TextAlignV}; graphics_record_command( @@ -2451,23 +2438,11 @@ pub fn graphics_text_align(graphics_entity: Entity, h: u8, v: u8) -> error::Resu ) } -pub fn graphics_text_leading(graphics_entity: Entity, leading: f32) -> error::Result<()> { - graphics_record_command(graphics_entity, DrawCommand::TextLeading(leading)) -} - pub fn graphics_text_wrap(graphics_entity: Entity, mode: u8) -> error::Result<()> { use render::command::TextWrapMode; graphics_record_command(graphics_entity, DrawCommand::TextWrap(TextWrapMode::from(mode))) } -pub fn graphics_text_direction(graphics_entity: Entity, dir: u8) -> error::Result<()> { - use render::command::TextDirection; - graphics_record_command( - graphics_entity, - DrawCommand::TextDirection(TextDirection::from(dir)), - ) -} - pub fn graphics_text_weight(graphics_entity: Entity, weight: f32) -> error::Result<()> { graphics_record_command(graphics_entity, DrawCommand::TextWeight(weight)) } @@ -2513,46 +2488,47 @@ pub fn graphics_clear_text_features(graphics_entity: Entity) -> error::Result<() graphics_record_command(graphics_entity, DrawCommand::ClearTextFeatures) } +pub fn graphics_text_glyph_colors( + graphics_entity: Entity, + colors: Vec, +) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::TextGlyphColors(colors)) +} + +/// Snapshot a graphics entity's text state plus the shared `TextContext`. +/// +/// Every text measurement/extraction query needs the same two things, so this +/// keeps each query down to its one interesting line. +fn text_query_state( + app: &App, + graphics_entity: Entity, + max_w: Option, + max_h: Option, +) -> error::Result<(render::primitive::text::OwnedTextParams, text::font::TextContext)> { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let params = render::primitive::text::OwnedTextParams::from_render_state(state, max_w, max_h); + let text_cx = app.world().resource::().clone(); + Ok((params, text_cx)) +} + pub fn graphics_text_to_paths( graphics_entity: Entity, content: &str, x: f32, y: f32, ) -> error::Result>> { - use render::primitive::text::TextParams; - use text::font::TextContext; - app_mut(|app| { - let state = app - .world() - .get::(graphics_entity) - .ok_or(error::ProcessingError::GraphicsNotFound)?; - let font_family = state.text_font_family.clone(); - let text_variations = state.text_variations.clone(); - let text_features = state.text_features.clone(); - let params = TextParams { - text_size: state.text_size, - align_h: state.text_align_h, - align_v: state.text_align_v, - leading: state.text_leading, - max_w: None, - max_h: None, - wrap: state.text_wrap, - font_family: None, - text_style: state.text_style, - text_weight: state.text_weight, - text_variations: &[], - text_features: &[], - glyph_colors: None, - }; - let text_cx = app.world().resource::().clone(); - let params = TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - ..params - }; - Ok(render::primitive::text::text_to_paths(content, x, y, ¶ms, &text_cx)) + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_to_paths( + content, + x, + y, + ¶ms.as_params(), + &text_cx, + )) }) } @@ -2562,40 +2538,15 @@ pub fn graphics_text_to_contours( x: f32, y: f32, ) -> error::Result>> { - use render::primitive::text::TextParams; - use text::font::TextContext; - app_mut(|app| { - let state = app - .world() - .get::(graphics_entity) - .ok_or(error::ProcessingError::GraphicsNotFound)?; - let font_family = state.text_font_family.clone(); - let text_variations = state.text_variations.clone(); - let text_features = state.text_features.clone(); - let params = TextParams { - text_size: state.text_size, - align_h: state.text_align_h, - align_v: state.text_align_v, - leading: state.text_leading, - max_w: None, - max_h: None, - wrap: state.text_wrap, - font_family: None, - text_style: state.text_style, - text_weight: state.text_weight, - text_variations: &[], - text_features: &[], - glyph_colors: None, - }; - let text_cx = app.world().resource::().clone(); - let params = TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - ..params - }; - Ok(render::primitive::text::text_to_contours(content, x, y, ¶ms, &text_cx)) + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_to_contours( + content, + x, + y, + ¶ms.as_params(), + &text_cx, + )) }) } @@ -2606,41 +2557,15 @@ pub fn graphics_text_to_points( y: f32, sample_factor: Option, ) -> error::Result> { - use render::primitive::text::TextParams; - use text::font::TextContext; - app_mut(|app| { - let state = app - .world() - .get::(graphics_entity) - .ok_or(error::ProcessingError::GraphicsNotFound)?; - let font_family = state.text_font_family.clone(); - let text_variations = state.text_variations.clone(); - let text_features = state.text_features.clone(); - let params = TextParams { - text_size: state.text_size, - align_h: state.text_align_h, - align_v: state.text_align_v, - leading: state.text_leading, - max_w: None, - max_h: None, - wrap: state.text_wrap, - font_family: None, - text_style: state.text_style, - text_weight: state.text_weight, - text_variations: &[], - text_features: &[], - glyph_colors: None, - }; - let text_cx = app.world().resource::().clone(); - let params = TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - ..params - }; + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; Ok(render::primitive::text::text_to_points( - content, x, y, sample_factor.unwrap_or(0.1), ¶ms, &text_cx, + content, + x, + y, + sample_factor.unwrap_or(render::primitive::text::DEFAULT_SAMPLE_FACTOR), + ¶ms.as_params(), + &text_cx, )) }) } @@ -2652,161 +2577,47 @@ pub fn graphics_text_to_model( y: f32, depth: f32, ) -> error::Result { - use render::primitive::text::TextParams; - use text::font::TextContext; - app_mut(|app| { - let state = app - .world() - .get::(graphics_entity) - .ok_or(error::ProcessingError::GraphicsNotFound)?; - let font_family = state.text_font_family.clone(); - let text_variations = state.text_variations.clone(); - let text_features = state.text_features.clone(); - let params = TextParams { - text_size: state.text_size, - align_h: state.text_align_h, - align_v: state.text_align_v, - leading: state.text_leading, - max_w: None, - max_h: None, - wrap: state.text_wrap, - font_family: None, - text_style: state.text_style, - text_weight: state.text_weight, - text_variations: &[], - text_features: &[], - glyph_colors: None, - }; - let text_cx = app.world().resource::().clone(); - let params = TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - ..params - }; - Ok(render::primitive::text::text_to_model(content, x, y, depth, ¶ms, &text_cx)) + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_to_model( + content, + x, + y, + depth, + ¶ms.as_params(), + &text_cx, + )) }) } -pub fn graphics_text_glyph_colors( - graphics_entity: Entity, - colors: Vec, -) -> error::Result<()> { - graphics_record_command(graphics_entity, DrawCommand::TextGlyphColors(colors)) -} - pub fn graphics_text_width(graphics_entity: Entity, content: &str) -> error::Result { - use render::primitive::text::TextParams; - use text::font::TextContext; - app_mut(|app| { - let state = app - .world() - .get::(graphics_entity) - .ok_or(error::ProcessingError::GraphicsNotFound)?; - let font_family = state.text_font_family.clone(); - let params = TextParams { - text_size: state.text_size, - align_h: state.text_align_h, - align_v: state.text_align_v, - leading: state.text_leading, - max_w: None, - max_h: None, - wrap: state.text_wrap, - font_family: None, - text_style: state.text_style, - text_weight: state.text_weight, - text_variations: &[], - text_features: &[], - glyph_colors: None, - }; - let text_cx = app.world().resource::().clone(); - let text_variations = state.text_variations.clone(); - let text_features = state.text_features.clone(); - let params = TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - ..params - }; - Ok(render::primitive::text::text_width(content, ¶ms, &text_cx)) + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_width( + content, + ¶ms.as_params(), + &text_cx, + )) }) } pub fn graphics_text_ascent(graphics_entity: Entity) -> error::Result { - use render::primitive::text::TextParams; - use text::font::TextContext; - app_mut(|app| { - let state = app - .world() - .get::(graphics_entity) - .ok_or(error::ProcessingError::GraphicsNotFound)?; - let font_family = state.text_font_family.clone(); - let params = TextParams { - text_size: state.text_size, - align_h: state.text_align_h, - align_v: state.text_align_v, - leading: None, - max_w: None, - max_h: None, - wrap: render::command::TextWrapMode::Word, - font_family: None, - text_style: state.text_style, - text_weight: state.text_weight, - text_variations: &[], - text_features: &[], - glyph_colors: None, - }; - let text_cx = app.world().resource::().clone(); - let text_variations = state.text_variations.clone(); - let text_features = state.text_features.clone(); - let params = TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - ..params - }; - Ok(render::primitive::text::text_ascent(¶ms, &text_cx)) + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_ascent( + ¶ms.as_params(), + &text_cx, + )) }) } pub fn graphics_text_descent(graphics_entity: Entity) -> error::Result { - use render::primitive::text::TextParams; - use text::font::TextContext; - app_mut(|app| { - let state = app - .world() - .get::(graphics_entity) - .ok_or(error::ProcessingError::GraphicsNotFound)?; - let font_family = state.text_font_family.clone(); - let params = TextParams { - text_size: state.text_size, - align_h: state.text_align_h, - align_v: state.text_align_v, - leading: None, - max_w: None, - max_h: None, - wrap: render::command::TextWrapMode::Word, - font_family: None, - text_style: state.text_style, - text_weight: state.text_weight, - text_variations: &[], - text_features: &[], - glyph_colors: None, - }; - let text_cx = app.world().resource::().clone(); - let text_variations = state.text_variations.clone(); - let text_features = state.text_features.clone(); - let params = TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - ..params - }; - Ok(render::primitive::text::text_descent(¶ms, &text_cx)) + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_descent( + ¶ms.as_params(), + &text_cx, + )) }) } @@ -2818,40 +2629,15 @@ pub fn graphics_text_bounds( max_w: Option, max_h: Option, ) -> error::Result<[f32; 4]> { - use render::primitive::text::TextParams; - use text::font::TextContext; - app_mut(|app| { - let state = app - .world() - .get::(graphics_entity) - .ok_or(error::ProcessingError::GraphicsNotFound)?; - let font_family = state.text_font_family.clone(); - let params = TextParams { - text_size: state.text_size, - align_h: state.text_align_h, - align_v: state.text_align_v, - leading: state.text_leading, - max_w, - max_h, - wrap: state.text_wrap, - font_family: None, - text_style: state.text_style, - text_weight: state.text_weight, - text_variations: &[], - text_features: &[], - glyph_colors: None, - }; - let text_cx = app.world().resource::().clone(); - let text_variations = state.text_variations.clone(); - let text_features = state.text_features.clone(); - let params = TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - ..params - }; - Ok(render::primitive::text::text_bounds(content, x, y, ¶ms, &text_cx)) + let (params, text_cx) = text_query_state(app, graphics_entity, max_w, max_h)?; + Ok(render::primitive::text::text_bounds( + content, + x, + y, + ¶ms.as_params(), + &text_cx, + )) }) } @@ -2859,40 +2645,13 @@ pub fn graphics_text_line_count( graphics_entity: Entity, content: &str, ) -> error::Result { - use render::primitive::text::TextParams; - use text::font::TextContext; - app_mut(|app| { - let state = app - .world() - .get::(graphics_entity) - .ok_or(error::ProcessingError::GraphicsNotFound)?; - let font_family = state.text_font_family.clone(); - let text_variations = state.text_variations.clone(); - let text_features = state.text_features.clone(); - let params = TextParams { - text_size: state.text_size, - align_h: state.text_align_h, - align_v: state.text_align_v, - leading: state.text_leading, - max_w: None, - max_h: None, - wrap: state.text_wrap, - font_family: None, - text_style: state.text_style, - text_weight: state.text_weight, - text_variations: &[], - text_features: &[], - glyph_colors: None, - }; - let text_cx = app.world().resource::().clone(); - let params = TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - ..params - }; - Ok(render::primitive::text::text_line_count(content, ¶ms, &text_cx)) + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_line_count( + content, + ¶ms.as_params(), + &text_cx, + )) }) } @@ -2904,40 +2663,15 @@ pub fn graphics_text_lines( max_w: Option, max_h: Option, ) -> error::Result> { - use render::primitive::text::TextParams; - use text::font::TextContext; - app_mut(|app| { - let state = app - .world() - .get::(graphics_entity) - .ok_or(error::ProcessingError::GraphicsNotFound)?; - let font_family = state.text_font_family.clone(); - let text_variations = state.text_variations.clone(); - let text_features = state.text_features.clone(); - let params = TextParams { - text_size: state.text_size, - align_h: state.text_align_h, - align_v: state.text_align_v, - leading: state.text_leading, - max_w, - max_h, - wrap: state.text_wrap, - font_family: None, - text_style: state.text_style, - text_weight: state.text_weight, - text_variations: &[], - text_features: &[], - glyph_colors: None, - }; - let text_cx = app.world().resource::().clone(); - let params = TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - ..params - }; - Ok(render::primitive::text::text_lines(content, x, y, ¶ms, &text_cx)) + let (params, text_cx) = text_query_state(app, graphics_entity, max_w, max_h)?; + Ok(render::primitive::text::text_lines( + content, + x, + y, + ¶ms.as_params(), + &text_cx, + )) }) } @@ -2949,39 +2683,14 @@ pub fn graphics_text_glyph_rects( max_w: Option, max_h: Option, ) -> error::Result> { - use render::primitive::text::TextParams; - use text::font::TextContext; - app_mut(|app| { - let state = app - .world() - .get::(graphics_entity) - .ok_or(error::ProcessingError::GraphicsNotFound)?; - let font_family = state.text_font_family.clone(); - let text_variations = state.text_variations.clone(); - let text_features = state.text_features.clone(); - let params = TextParams { - text_size: state.text_size, - align_h: state.text_align_h, - align_v: state.text_align_v, - leading: state.text_leading, - max_w, - max_h, - wrap: state.text_wrap, - font_family: None, - text_style: state.text_style, - text_weight: state.text_weight, - text_variations: &[], - text_features: &[], - glyph_colors: None, - }; - let text_cx = app.world().resource::().clone(); - let params = TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - ..params - }; - Ok(render::primitive::text::text_glyph_rects(content, x, y, ¶ms, &text_cx)) + let (params, text_cx) = text_query_state(app, graphics_entity, max_w, max_h)?; + Ok(render::primitive::text::text_glyph_rects( + content, + x, + y, + ¶ms.as_params(), + &text_cx, + )) }) } diff --git a/crates/processing_render/src/render/command.rs b/crates/processing_render/src/render/command.rs index 893cec01..1d8f3087 100644 --- a/crates/processing_render/src/render/command.rs +++ b/crates/processing_render/src/render/command.rs @@ -62,30 +62,6 @@ impl From for TextWrapMode { } } -/// Text direction for BiDi layout. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -#[repr(u8)] -pub enum TextDirection { - /// Auto-detect from Unicode character properties. - #[default] - Auto = 0, - /// Left-to-right. - Ltr = 1, - /// Right-to-left. - Rtl = 2, -} - -impl From for TextDirection { - fn from(v: u8) -> Self { - match v { - 0 => Self::Auto, - 1 => Self::Ltr, - 2 => Self::Rtl, - _ => Self::default(), - } - } -} - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[repr(u8)] pub enum TextStyle { @@ -629,7 +605,6 @@ pub enum DrawCommand { }, TextLeading(f32), TextWrap(TextWrapMode), - TextDirection(TextDirection), TextGlyphColors(Vec), Text { content: String, diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index ad6b07f4..86ff4998 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -13,8 +13,7 @@ use bevy::{ render::render_resource::BlendState, }; use command::{ - CommandBuffer, DrawCommand, ShapeMode, TextAlignH, TextAlignV, TextDirection, TextStyle, - TextWrapMode, + CommandBuffer, DrawCommand, ShapeMode, TextAlignH, TextAlignV, TextStyle, TextWrapMode, }; use material::{MaterialKey, ProcessingExtendedMaterial}; use primitive::{ @@ -106,7 +105,6 @@ pub struct RenderState { pub text_align_v: TextAlignV, pub text_leading: Option, pub text_wrap: TextWrapMode, - pub text_direction: TextDirection, pub text_glyph_colors: Option>, } @@ -141,7 +139,6 @@ impl RenderState { text_align_v: TextAlignV::Baseline, text_leading: None, text_wrap: TextWrapMode::Word, - text_direction: TextDirection::Auto, text_glyph_colors: None, } } @@ -175,7 +172,6 @@ impl RenderState { self.text_align_v = TextAlignV::Baseline; self.text_leading = None; self.text_wrap = TextWrapMode::Word; - self.text_direction = TextDirection::Auto; self.text_glyph_colors = None; } @@ -1243,9 +1239,6 @@ pub fn flush_draw_commands( DrawCommand::TextWrap(mode) => { state.text_wrap = mode; } - DrawCommand::TextDirection(dir) => { - state.text_direction = dir; - } DrawCommand::TextGlyphColors(colors) => { state.text_glyph_colors = Some(colors); } @@ -1265,25 +1258,10 @@ pub fn flush_draw_commands( (x, y, max_w, max_h) }; - let font_family = state.text_font_family.clone(); - let text_variations = state.text_variations.clone(); - let text_features = state.text_features.clone(); - let glyph_colors = state.text_glyph_colors.take(); - let params = primitive::text::TextParams { - text_size: state.text_size, - align_h: state.text_align_h, - align_v: state.text_align_v, - leading: state.text_leading, - max_w, - max_h, - wrap: state.text_wrap, - font_family: None, - text_style: state.text_style, - text_weight: state.text_weight, - text_variations: &[], - text_features: &[], - glyph_colors: None, - }; + let mut text_params = + primitive::text::OwnedTextParams::from_render_state(&state, max_w, max_h); + // Per-glyph colors apply to this one text() call only. + text_params.glyph_colors = state.text_glyph_colors.take(); let text_cx = text_cx.clone(); if z != 0.0 { @@ -1295,44 +1273,28 @@ pub fn flush_draw_commands( &mut batch, &state, |mesh, color| { - let params = primitive::text::TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - glyph_colors: glyph_colors.as_deref(), - ..params - }; primitive::text::text( - mesh, &content, x, y, color, ¶ms, &text_cx, + mesh, &content, x, y, color, &text_params.as_params(), &text_cx, ); }, &p_material_handles, ); - { - let text_cx = text_cx.clone(); - let font_family = font_family.clone(); - let text_variations = text_variations.clone(); - let text_features = text_features.clone(); - add_stroke( - &mut res, - &mut batch, - &state, - |mesh, color, weight| { - let params = primitive::text::TextParams { - font_family: font_family.as_deref(), - text_variations: &text_variations, - text_features: &text_features, - glyph_colors: None, - ..params - }; - primitive::text::text_stroke( - mesh, &content, x, y, color, weight, ¶ms, &text_cx, - ); - }, - &p_material_handles, - ); - } + add_stroke( + &mut res, + &mut batch, + &state, + |mesh, color, weight| { + // The stroke outlines every glyph in the stroke color; + // per-glyph fill colors don't apply. + let mut params = text_params.as_params(); + params.glyph_colors = None; + primitive::text::text_stroke( + mesh, &content, x, y, color, weight, ¶ms, &text_cx, + ); + }, + &p_material_handles, + ); if z != 0.0 { state.transform.translate_3d(0.0, 0.0, -z); diff --git a/crates/processing_render/src/render/primitive/text.rs b/crates/processing_render/src/render/primitive/text.rs index 37e03202..59763f37 100644 --- a/crates/processing_render/src/render/primitive/text.rs +++ b/crates/processing_render/src/render/primitive/text.rs @@ -25,6 +25,7 @@ use skrifa::{ }; use crate::render::{ + RenderState, command::{TextAlignH, TextAlignV, TextStyle, TextWrapMode}, mesh_builder::MeshBuilder, }; @@ -57,6 +58,70 @@ pub struct TextParams<'a> { pub glyph_colors: Option<&'a [Color]>, } +/// Owned counterpart of [`TextParams`], decoupled from `RenderState`'s borrow. +/// +/// `TextParams` borrows its string/slice fields, which makes it awkward to +/// build directly from a `RenderState`. Snapshot into this struct, then call +/// [`OwnedTextParams::as_params`] at each use site. +pub struct OwnedTextParams { + pub text_size: f32, + pub align_h: TextAlignH, + pub align_v: TextAlignV, + pub leading: Option, + pub max_w: Option, + pub max_h: Option, + pub wrap: TextWrapMode, + pub font_family: Option, + pub text_style: TextStyle, + pub text_weight: Option, + pub text_variations: Vec<([u8; 4], f32)>, + pub text_features: Vec<([u8; 4], u16)>, + pub glyph_colors: Option>, +} + +impl OwnedTextParams { + /// Snapshot the text state from a `RenderState` with explicit layout bounds. + /// + /// `glyph_colors` is left empty: it applies only to an actual `text()` draw + /// and is set explicitly by the draw path, not by measurement queries. + pub fn from_render_state(state: &RenderState, max_w: Option, max_h: Option) -> Self { + Self { + text_size: state.text_size, + align_h: state.text_align_h, + align_v: state.text_align_v, + leading: state.text_leading, + max_w, + max_h, + wrap: state.text_wrap, + font_family: state.text_font_family.clone(), + text_style: state.text_style, + text_weight: state.text_weight, + text_variations: state.text_variations.clone(), + text_features: state.text_features.clone(), + glyph_colors: None, + } + } + + /// Borrow as a [`TextParams`] for a layout/rendering call. + pub fn as_params(&self) -> TextParams<'_> { + TextParams { + text_size: self.text_size, + align_h: self.align_h, + align_v: self.align_v, + leading: self.leading, + max_w: self.max_w, + max_h: self.max_h, + wrap: self.wrap, + font_family: self.font_family.as_deref(), + text_style: self.text_style, + text_weight: self.text_weight, + text_variations: &self.text_variations, + text_features: &self.text_features, + glyph_colors: self.glyph_colors.as_deref(), + } + } +} + /// Tessellate text into a mesh (fill). pub fn text(mesh: &mut Mesh, content: &str, x: f32, y: f32, color: Color, params: &TextParams, text_cx: &TextContext) { if content.is_empty() { @@ -134,28 +199,16 @@ pub fn text_bounds(content: &str, x: f32, y: f32, params: &TextParams, text_cx: text_cx.with(|font_cx, layout_cx| { let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + // `compute_text_origin` returns the absolute position of the layout's + // top-left corner, so the box top-left is exactly the text origin. + let (box_x, box_y) = compute_text_origin(&layout, x, y, params.align_v); let width = layout.width(); - let total_height = layout.height(); - let ascent = layout - .get(0) - .map(|line| line.metrics().ascent) - .unwrap_or(0.0); - - // Clamp height if max_h is set let height = match params.max_h { - Some(h) => total_height.min(h), - None => total_height, + Some(h) => layout.height().min(h), + None => layout.height(), }; - // Compute vertical offset based on alignment (same logic as text()) - let by = match params.align_v { - TextAlignV::Baseline => y - ascent, - TextAlignV::Top => y, - TextAlignV::Center => y - total_height / 2.0, - TextAlignV::Bottom => y - total_height, - }; - - [x, by, width, height] + [box_x, box_y, width, height] }) } @@ -292,8 +345,13 @@ pub fn text_to_paths( }) } +/// Default `sample_factor` for [`text_to_points`]. +pub const DEFAULT_SAMPLE_FACTOR: f32 = 0.1; + /// Sample points along text outlines. -/// `sample_factor` controls point density (default 0.1 — lower = more points). +/// +/// `sample_factor` controls point density: higher values place more points +/// along each segment. See [`DEFAULT_SAMPLE_FACTOR`]. pub fn text_to_points( content: &str, x: f32, @@ -594,18 +652,26 @@ impl<'a> FillGeometryBuilder for Extrusion3DBuilder<'a> { } } +/// Resolve the absolute position of the layout's top-left corner. +/// +/// parley's positioned glyphs are expressed relative to the layout's top-left, +/// so callers add this origin to `glyph.y` to place glyphs. The vertical +/// reference point of the user-supplied `y` depends on `align_v`: +/// - `Baseline`: `y` is the first line's baseline (Processing's default). +/// - `Top` / `Center` / `Bottom`: `y` is the top / center / bottom of the +/// whole text block. fn compute_text_origin(layout: &Layout, x: f32, y: f32, align_v: TextAlignV) -> (f32, f32) { let total_height = layout.height(); - let ascent = layout + let first_baseline = layout .get(0) - .map(|line| line.metrics().ascent) + .map(|line| line.metrics().baseline) .unwrap_or(0.0); let y_offset = match align_v { - TextAlignV::Baseline => y, - TextAlignV::Top => y + ascent, - TextAlignV::Center => y + ascent - total_height / 2.0, - TextAlignV::Bottom => y + ascent - total_height, + TextAlignV::Baseline => y - first_baseline, + TextAlignV::Top => y, + TextAlignV::Center => y - total_height / 2.0, + TextAlignV::Bottom => y - total_height, }; (x, y_offset) diff --git a/crates/processing_render/src/text/font.rs b/crates/processing_render/src/text/font.rs index 59e83ad9..441f6cf8 100644 --- a/crates/processing_render/src/text/font.rs +++ b/crates/processing_render/src/text/font.rs @@ -143,13 +143,21 @@ impl TextContext { /// Query font metadata for a given family name. pub fn font_metadata(&self, family: &str) -> Option { + use parley::FontStyle; + let mut inner = self.inner.lock().unwrap(); let family_info = inner.font_cx.collection.family_by_name(family)?; let font_info = family_info.default_font()?; + let style = match font_info.style() { + FontStyle::Normal => "normal".to_string(), + FontStyle::Italic => "italic".to_string(), + FontStyle::Oblique(_) => "oblique".to_string(), + }; + Some(FontMetadata { family: family_info.name().to_string(), - style: format!("{:?}", font_info.style()), + style, weight: font_info.weight().value(), width: font_info.width().ratio(), is_variable: !font_info.axes().is_empty(), diff --git a/docs/gen_ref_pages.py b/docs/gen_ref_pages.py index ff582e0e..2f45b796 100644 --- a/docs/gen_ref_pages.py +++ b/docs/gen_ref_pages.py @@ -9,7 +9,7 @@ modules = { "reference/index.md": ("API Reference", "mewnala", {"show_submodules": False}), "reference/math.md": ("mewnala.math", "mewnala.math", {}), - # "reference/color.md": ("mewnala.color", "mewnala.color", {}), + "reference/color.md": ("mewnala.color", "mewnala.color", {}), } for path, (title, module, options) in modules.items(): diff --git a/mkdocs.yml b/mkdocs.yml index 38330f27..c0e22261 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,6 +51,7 @@ nav: - API Reference: - Overview: reference/index.md - Math: reference/math.md + - Color: reference/color.md - Design: - Principles: principles.md - Internal API: api.md diff --git a/src/prelude.rs b/src/prelude.rs index ac582eab..e4ef86ed 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -11,7 +11,7 @@ pub use processing_midi::{ pub use processing_render::{ render::command::{ ArcMode, BlendMode, DrawCommand, ShapeKind, ShapeMode, StrokeCapMode, StrokeJoinMode, - TextAlignH, TextAlignV, TextDirection, TextStyle, TextWrapMode, custom_blend_state, + TextAlignH, TextAlignV, TextStyle, TextWrapMode, custom_blend_state, }, *, }; From e4de15de82c8fd416bfd80319ad8c82ced305b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Thu, 14 May 2026 11:19:53 -0700 Subject: [PATCH 3/5] Docs. --- crates/processing_pyo3/src/graphics.rs | 11 +- crates/processing_render/src/lib.rs | 5 +- crates/processing_render/src/render/mod.rs | 7 +- .../src/render/primitive/text.rs | 105 ++++++------------ crates/processing_render/src/text/font.rs | 21 ++-- examples/text.rs | 9 -- examples/text_3d.rs | 6 +- 7 files changed, 56 insertions(+), 108 deletions(-) diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index 8414db4f..322ebf2c 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -253,10 +253,7 @@ impl Font { } } -/// Convert glyph outline data into per-glyph (or per-contour) lists of Python -/// tuples. Each command is a variable-length tuple tagged by a single letter: -/// `("M", x, y)`, `("L", x, y)`, `("Q", cx, cy, x, y)`, -/// `("C", cx1, cy1, cx2, cy2, x, y)`, or `("Z",)`. +/// Convert glyph outline groups into per-group lists of Python command tuples. fn path_commands_to_py( py: Python<'_>, groups: Vec>, @@ -1202,13 +1199,13 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - /// Get the number of lines after text layout. + /// Number of lines `content` wraps to. pub fn text_line_count(&self, content: &str) -> PyResult { graphics_text_line_count(self.entity, content) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - /// Get per-line info: list of dicts with "text" and "rect" (x, y, w, h). + /// Per-line info as a list of `(text, (x, y, w, h))` tuples. #[pyo3(signature = (content, x, y, max_w=None, max_h=None))] pub fn text_lines( &self, @@ -1228,7 +1225,7 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - /// Get per-glyph bounding rects: list of (x, y, w, h). + /// Per-glyph bounding rects as a list of `(x, y, w, h)` tuples. #[pyo3(signature = (content, x, y, max_w=None, max_h=None))] pub fn text_glyph_rects( &self, diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 7f2e9ccd..8157548e 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -2495,10 +2495,7 @@ pub fn graphics_text_glyph_colors( graphics_record_command(graphics_entity, DrawCommand::TextGlyphColors(colors)) } -/// Snapshot a graphics entity's text state plus the shared `TextContext`. -/// -/// Every text measurement/extraction query needs the same two things, so this -/// keeps each query down to its one interesting line. +/// Snapshot a graphics entity's text state and the shared `TextContext`. fn text_query_state( app: &App, graphics_entity: Entity, diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 86ff4998..54f629b7 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -1250,7 +1250,7 @@ pub fn flush_draw_commands( max_w, max_h, } => { - // Apply rectMode to bounding box form + // rectMode applies to the bounding-box form let (x, y, max_w, max_h) = if let (Some(w), Some(h)) = (max_w, max_h) { let (bx, by, bw, bh) = apply_shape_mode(state.rect_mode, x, y, w, h); (bx, by, Some(bw), Some(bh)) @@ -1260,7 +1260,7 @@ pub fn flush_draw_commands( let mut text_params = primitive::text::OwnedTextParams::from_render_state(&state, max_w, max_h); - // Per-glyph colors apply to this one text() call only. + // per-glyph colors apply to this one text() call only text_params.glyph_colors = state.text_glyph_colors.take(); let text_cx = text_cx.clone(); @@ -1285,8 +1285,7 @@ pub fn flush_draw_commands( &mut batch, &state, |mesh, color, weight| { - // The stroke outlines every glyph in the stroke color; - // per-glyph fill colors don't apply. + // per-glyph fill colors don't apply to the stroke let mut params = text_params.as_params(); params.glyph_colors = None; primitive::text::text_stroke( diff --git a/crates/processing_render/src/render/primitive/text.rs b/crates/processing_render/src/render/primitive/text.rs index 59763f37..654b7d09 100644 --- a/crates/processing_render/src/render/primitive/text.rs +++ b/crates/processing_render/src/render/primitive/text.rs @@ -41,7 +41,7 @@ pub enum PathCommand { Close, } -/// Bundled text layout parameters to avoid long parameter lists. +/// Text layout parameters. pub struct TextParams<'a> { pub text_size: f32, pub align_h: TextAlignH, @@ -58,11 +58,7 @@ pub struct TextParams<'a> { pub glyph_colors: Option<&'a [Color]>, } -/// Owned counterpart of [`TextParams`], decoupled from `RenderState`'s borrow. -/// -/// `TextParams` borrows its string/slice fields, which makes it awkward to -/// build directly from a `RenderState`. Snapshot into this struct, then call -/// [`OwnedTextParams::as_params`] at each use site. +/// Owned [`TextParams`]: a `RenderState` snapshot that outlives the borrow. pub struct OwnedTextParams { pub text_size: f32, pub align_h: TextAlignH, @@ -80,10 +76,8 @@ pub struct OwnedTextParams { } impl OwnedTextParams { - /// Snapshot the text state from a `RenderState` with explicit layout bounds. - /// - /// `glyph_colors` is left empty: it applies only to an actual `text()` draw - /// and is set explicitly by the draw path, not by measurement queries. + /// Snapshot a `RenderState`'s text state. `glyph_colors` is left unset; the + /// draw path fills it in, measurement queries don't need it. pub fn from_render_state(state: &RenderState, max_w: Option, max_h: Option) -> Self { Self { text_size: state.text_size, @@ -102,7 +96,7 @@ impl OwnedTextParams { } } - /// Borrow as a [`TextParams`] for a layout/rendering call. + /// Borrow as a [`TextParams`]. pub fn as_params(&self) -> TextParams<'_> { TextParams { text_size: self.text_size, @@ -148,7 +142,7 @@ pub fn text_stroke(mesh: &mut Mesh, content: &str, x: f32, y: f32, color: Color, }); } -/// Measure the width of text without rendering. +/// Measure the width of text. pub fn text_width(content: &str, params: &TextParams, text_cx: &TextContext) -> f32 { if content.is_empty() { return 0.0; @@ -167,7 +161,7 @@ pub fn text_width(content: &str, params: &TextParams, text_cx: &TextContext) -> }) } -/// Get font ascent for the current text size. +/// Font ascent for the current text size. pub fn text_ascent(params: &TextParams, text_cx: &TextContext) -> f32 { text_cx.with(|font_cx, layout_cx| { let layout = build_layout(font_cx, layout_cx, "X", Color::BLACK, params); @@ -178,7 +172,7 @@ pub fn text_ascent(params: &TextParams, text_cx: &TextContext) -> f32 { }) } -/// Get font descent for the current text size. +/// Font descent for the current text size. pub fn text_descent(params: &TextParams, text_cx: &TextContext) -> f32 { text_cx.with(|font_cx, layout_cx| { let layout = build_layout(font_cx, layout_cx, "X", Color::BLACK, params); @@ -189,8 +183,7 @@ pub fn text_descent(params: &TextParams, text_cx: &TextContext) -> f32 { }) } -/// Compute the bounding box of text without rendering. -/// Returns [x, y, width, height]. +/// Bounding box of text as `[x, y, width, height]`. pub fn text_bounds(content: &str, x: f32, y: f32, params: &TextParams, text_cx: &TextContext) -> [f32; 4] { if content.is_empty() { return [x, y, 0.0, 0.0]; @@ -199,8 +192,7 @@ pub fn text_bounds(content: &str, x: f32, y: f32, params: &TextParams, text_cx: text_cx.with(|font_cx, layout_cx| { let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); - // `compute_text_origin` returns the absolute position of the layout's - // top-left corner, so the box top-left is exactly the text origin. + // the text origin is the layout's top-left corner let (box_x, box_y) = compute_text_origin(&layout, x, y, params.align_v); let width = layout.width(); let height = match params.max_h { @@ -212,23 +204,22 @@ pub fn text_bounds(content: &str, x: f32, y: f32, params: &TextParams, text_cx: }) } -/// A line info entry from layout introspection. +/// A single laid-out line. #[derive(Debug, Clone)] pub struct TextLineInfo { - /// The text content of this line. pub text: String, - /// Bounding rect: [x, y, width, height]. + /// `[x, y, width, height]`. pub rect: [f32; 4], } -/// A glyph info entry from layout introspection. +/// A single laid-out glyph. #[derive(Debug, Clone)] pub struct TextGlyphInfo { - /// Bounding rect: [x, y, width, height]. + /// `[x, y, width, height]`. pub rect: [f32; 4], } -/// Get the number of lines after layout. +/// Number of lines after layout. pub fn text_line_count(content: &str, params: &TextParams, text_cx: &TextContext) -> usize { if content.is_empty() { return 0; @@ -239,7 +230,7 @@ pub fn text_line_count(content: &str, params: &TextParams, text_cx: &TextContext }) } -/// Get per-line info (text content and bounding rect) after layout. +/// Per-line text and bounding rects. pub fn text_lines( content: &str, x: f32, @@ -279,7 +270,7 @@ pub fn text_lines( }) } -/// Get per-glyph bounding rects after layout. +/// Per-glyph bounding rects. pub fn text_glyph_rects( content: &str, x: f32, @@ -348,10 +339,8 @@ pub fn text_to_paths( /// Default `sample_factor` for [`text_to_points`]. pub const DEFAULT_SAMPLE_FACTOR: f32 = 0.1; -/// Sample points along text outlines. -/// -/// `sample_factor` controls point density: higher values place more points -/// along each segment. See [`DEFAULT_SAMPLE_FACTOR`]. +/// Sample points along text outlines. Higher `sample_factor` = more points; +/// see [`DEFAULT_SAMPLE_FACTOR`]. pub fn text_to_points( content: &str, x: f32, @@ -424,9 +413,8 @@ pub fn text_to_points( }) } -/// Generate a 3D extruded mesh from text outlines. -/// Returns a Mesh with front face, back face, and side walls. -/// Uses Y-up convention matching Bevy's 3D coordinate system. +/// 3D extruded mesh from text outlines: front and back faces plus side walls, +/// in Bevy's Y-up convention. pub fn text_to_model( content: &str, x: f32, @@ -450,13 +438,13 @@ pub fn text_to_model( let mut fill_tess = FillTessellator::new(); for path in &glyph_paths { - // --- Front face (z = +half_depth, normal = [0,0,1]) --- + // front face: z = +half_depth, normal +Z { let mut builder = Extrusion3DBuilder::new(&mut mesh, half_depth, [0.0, 0.0, 1.0]); let _ = fill_tess.tessellate_path(path, &FillOptions::default(), &mut builder); } - // --- Back face (z = -half_depth, normal = [0,0,-1], reversed winding) --- + // back face: z = -half_depth, normal -Z, with winding reversed below let back_indices_start = mesh .indices() .map(|i| match i { @@ -470,7 +458,6 @@ pub fn text_to_model( let _ = fill_tess.tessellate_path(path, &FillOptions::default(), &mut builder); } - // Reverse winding order for back face if let Some(Indices::U32(indices)) = mesh.indices_mut() { let mut i = back_indices_start; while i + 2 < indices.len() { @@ -479,8 +466,7 @@ pub fn text_to_model( } } - // --- Side walls --- - // Walk the path outline and connect front vertices to back vertices + // side walls: connect each contour's front vertices to its back ones let mut contour_points: Vec> = Vec::new(); for event in path.iter() { @@ -494,7 +480,6 @@ pub fn text_to_model( contour_points.push(to); } Event::Quadratic { from, ctrl, to } => { - // Flatten quadratic curve let steps = 8; for s in 1..=steps { let t = s as f32 / steps as f32; @@ -505,7 +490,6 @@ pub fn text_to_model( } } Event::Cubic { from, ctrl1, ctrl2, to } => { - // Flatten cubic curve let steps = 12; for s in 1..=steps { let t = s as f32 / steps as f32; @@ -523,13 +507,12 @@ pub fn text_to_model( } Event::End { close, .. } => { if close && contour_points.len() >= 2 { - // Generate side wall quads for this contour for i in 0..contour_points.len() { let j = (i + 1) % contour_points.len(); let p0 = contour_points[i]; let p1 = contour_points[j]; - // Compute outward normal for this edge + // outward normal of this edge let dx = p1.x - p0.x; let dy = p1.y - p0.y; let len = (dx * dx + dy * dy).sqrt().max(1e-6); @@ -537,14 +520,13 @@ pub fn text_to_model( let ny = dx / len; let normal = [nx, ny, 0.0]; - // Four vertices: front-p0, front-p1, back-p1, back-p0 + // quad vertices: front-p0, front-p1, back-p1, back-p0 let base = vertex_count(&mesh) as u32; push_vertex_3d(&mut mesh, [p0.x, p0.y, half_depth], normal); push_vertex_3d(&mut mesh, [p1.x, p1.y, half_depth], normal); push_vertex_3d(&mut mesh, [p1.x, p1.y, -half_depth], normal); push_vertex_3d(&mut mesh, [p0.x, p0.y, -half_depth], normal); - // Two triangles if let Some(Indices::U32(indices)) = mesh.indices_mut() { indices.extend_from_slice(&[ base, @@ -610,7 +592,7 @@ fn push_vertex_3d(mesh: &mut Mesh, position: [f32; 3], normal: [f32; 3]) { } } -/// A geometry builder that places lyon tessellation output at a given Z depth with a given normal. +/// Places lyon fill output at a fixed Z depth and normal. struct Extrusion3DBuilder<'a> { mesh: &'a mut Mesh, z: f32, @@ -652,14 +634,9 @@ impl<'a> FillGeometryBuilder for Extrusion3DBuilder<'a> { } } -/// Resolve the absolute position of the layout's top-left corner. -/// -/// parley's positioned glyphs are expressed relative to the layout's top-left, -/// so callers add this origin to `glyph.y` to place glyphs. The vertical -/// reference point of the user-supplied `y` depends on `align_v`: -/// - `Baseline`: `y` is the first line's baseline (Processing's default). -/// - `Top` / `Center` / `Bottom`: `y` is the top / center / bottom of the -/// whole text block. +/// Absolute position of the layout's top-left corner — parley's glyph +/// positions are measured against it. `y` is the first line's baseline for +/// `Baseline` align, otherwise the top/center/bottom of the text block. fn compute_text_origin(layout: &Layout, x: f32, y: f32, align_v: TextAlignV) -> (f32, f32) { let total_height = layout.height(); let first_baseline = layout @@ -677,8 +654,7 @@ fn compute_text_origin(layout: &Layout, x: f32, y: f32, align_v: TextAlig (x, y_offset) } -/// Extract text outlines as per-contour PathCommand vecs. -/// Each contour (each MoveTo...Close sequence) is a separate vec. +/// Extract text outlines, one `PathCommand` vec per contour. pub fn text_to_contours( content: &str, x: f32, @@ -776,7 +752,7 @@ fn extract_glyph_path_commands( .collect() } -/// Extract glyph outlines as lyon Path objects (internal helper). +/// Extract glyph outlines as lyon `Path`s, one per glyph. fn extract_glyph_lyon_paths( layout: &Layout, base_x: f32, @@ -850,7 +826,6 @@ fn build_layout( ) -> Layout { let mut builder = layout_cx.ranged_builder(font_cx, content, 1.0, false); - // Set default styles let family = params.font_family.unwrap_or(DEFAULT_FONT_FAMILY); builder.push_default(StyleProperty::FontSize(params.text_size)); builder.push_default(StyleProperty::FontStack(FontStack::Single( @@ -866,7 +841,7 @@ fn build_layout( builder.push_default(StyleProperty::WordBreak(WordBreakStrength::BreakAll)); } - // Apply text style (bold/italic). text_weight overrides bold from text_style. + // text_weight overrides the bold implied by text_style if let Some(weight) = params.text_weight { builder.push_default(StyleProperty::FontWeight(ParleyFontWeight::new(weight))); if matches!(params.text_style, TextStyle::Italic | TextStyle::BoldItalic) { @@ -918,11 +893,9 @@ fn build_layout( let mut layout = builder.build(content); - // Apply line breaking let max_advance = params.max_w.unwrap_or(f32::MAX); layout.break_all_lines(Some(max_advance)); - // Apply alignment let alignment = match params.align_h { TextAlignH::Left => Alignment::Start, TextAlignH::Center => Alignment::Center, @@ -949,7 +922,7 @@ fn tessellate_layout( continue; }; - // Clip lines that exceed the maximum height + // stop once a line falls past max_h if let Some(h) = max_h { let metrics = line.metrics(); if metrics.baseline + metrics.descent > h { @@ -968,7 +941,6 @@ fn tessellate_layout( let normalized_coords = run.normalized_coords(); let color = glyph_run.style().brush.clone(); - // Get the raw font bytes for skrifa let Ok(font_ref) = FontRef::from_index(font_data.data.as_ref(), font_data.index) else { continue; }; @@ -976,14 +948,13 @@ fn tessellate_layout( let outlines = font_ref.outline_glyphs(); let skrifa_size = Size::new(font_size); - // Convert normalized coordinates (i16) to skrifa NormalizedCoord (F2Dot14) + // parley's i16 normalized coords -> skrifa's F2Dot14 NormalizedCoord let coords: Vec = normalized_coords .iter() .map(|&c| NormalizedCoord::from_bits(c)) .collect(); let location = LocationRef::new(&coords); - // Process each glyph in the run for glyph in glyph_run.positioned_glyphs() { let glyph_color = glyph_colors .filter(|colors| !colors.is_empty()) @@ -999,9 +970,7 @@ fn tessellate_layout( let _ = outline_glyph.draw(settings, &mut pen); if let Some(path) = pen.build() { - // glyph.x and glyph.y are the fully positioned coordinates - // Font outlines have Y-up, but our coordinate system is Y-down, - // so we negate the Y from the outline pen + // font outlines are Y-up; translate_path_flip_y flips to Y-down let tx = base_x + glyph.x; let ty = base_y + glyph.y; @@ -1189,7 +1158,7 @@ fn translate_path_flip_y(path: &Path, tx: f32, ty: f32) -> Path { builder.build() } -/// OutlinePen implementation that converts skrifa glyph outlines to lyon Path. +/// An `OutlinePen` that builds a lyon `Path` from a skrifa glyph outline. struct LyonOutlinePen { builder: lyon::path::path::Builder, has_content: bool, diff --git a/crates/processing_render/src/text/font.rs b/crates/processing_render/src/text/font.rs index 441f6cf8..70e0633a 100644 --- a/crates/processing_render/src/text/font.rs +++ b/crates/processing_render/src/text/font.rs @@ -3,13 +3,13 @@ use std::sync::{Arc, Mutex}; use bevy::prelude::*; use parley::{FontContext, LayoutContext}; -/// A font entity component storing the font's family name. +/// Font component: the resolved family name. #[derive(Component)] pub struct Font { pub family_name: String, } -/// Shared text context resource containing parley's font and layout contexts. +/// Shared parley font and layout contexts. #[derive(Resource, Clone)] pub struct TextContext { inner: Arc>, @@ -24,7 +24,7 @@ impl TextContext { pub fn new() -> Self { let mut font_cx = FontContext::default(); - // Register the embedded NotoSans as default font + // embedded NotoSans is the default font font_cx .collection .register_fonts(notosans::REGULAR_TTF.to_vec().into(), None); @@ -37,8 +37,8 @@ impl TextContext { } } - /// Access the font and layout contexts together via a closure. - /// We split the struct to avoid double-mutable-borrow issues. + /// Access both contexts at once; they're split so the closure can borrow + /// each mutably. pub fn with(&self, f: impl FnOnce(&mut FontContext, &mut LayoutContext) -> R) -> R { let mut inner = self.inner.lock().unwrap(); let TextContextInner { @@ -48,8 +48,7 @@ impl TextContext { f(font_cx, layout_cx) } - /// Load a font file and register it with the font context. - /// Returns the primary family name of the loaded font, if available. + /// Register font bytes; returns the primary family name if one is found. pub fn load_font(&self, data: Vec) -> Option { let mut inner = self.inner.lock().unwrap(); let families = inner @@ -65,7 +64,7 @@ impl TextContext { }) } - /// List all available font family names (system + registered). + /// All available family names, system and registered. pub fn list_fonts(&self) -> Vec { let mut inner = self.inner.lock().unwrap(); inner @@ -76,7 +75,7 @@ impl TextContext { .collect() } - /// Check if a font family name is available. + /// Whether a family name is available. pub fn has_font(&self, name: &str) -> bool { let mut inner = self.inner.lock().unwrap(); inner.font_cx.collection.family_id(name).is_some() @@ -113,7 +112,7 @@ pub struct FontMetadata { } impl TextContext { - /// Query variable font axes for a given family name. + /// Variable font axes for a family. pub fn font_variations(&self, family: &str) -> Vec { let mut inner = self.inner.lock().unwrap(); let family_info = match inner.font_cx.collection.family_by_name(family) { @@ -141,7 +140,7 @@ impl TextContext { .collect() } - /// Query font metadata for a given family name. + /// Metadata for a family. pub fn font_metadata(&self, family: &str) -> Option { use parley::FontStyle; diff --git a/examples/text.rs b/examples/text.rs index 498d5776..bd8e5167 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -29,16 +29,10 @@ fn sketch() -> error::Result<()> { while glfw_ctx.poll_events() { graphics_begin_draw(graphics)?; - // White background graphics_record_command(graphics, DrawCommand::BackgroundColor(Color::WHITE))?; - - // Set fill to black for text graphics_record_command(graphics, DrawCommand::Fill(Color::BLACK))?; - - // Set text size graphics_record_command(graphics, DrawCommand::TextSize(32.0))?; - // Draw text graphics_record_command( graphics, DrawCommand::Text { @@ -51,7 +45,6 @@ fn sketch() -> error::Result<()> { }, )?; - // Smaller text graphics_record_command(graphics, DrawCommand::TextSize(18.0))?; graphics_record_command( @@ -66,7 +59,6 @@ fn sketch() -> error::Result<()> { }, )?; - // Text with bounding box (word wrap) graphics_record_command(graphics, DrawCommand::TextSize(16.0))?; graphics_record_command( @@ -81,7 +73,6 @@ fn sketch() -> error::Result<()> { }, )?; - // Center-aligned text graphics_record_command( graphics, DrawCommand::TextAlign { diff --git a/examples/text_3d.rs b/examples/text_3d.rs index 9fa1646d..6753f84a 100644 --- a/examples/text_3d.rs +++ b/examples/text_3d.rs @@ -18,28 +18,24 @@ fn sketch() -> error::Result<()> { let surface = glfw_ctx.create_surface(width, height)?; let graphics = graphics_create(surface, width, height, TextureFormat::Rgba16Float)?; - // 3D camera graphics_mode_3d(graphics)?; transform_set_position(graphics, Vec3::new(0.0, 0.0, 800.0))?; transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; - // Directional light to reveal 3D shape let dir_light = light_create_directional(graphics, bevy::color::Color::srgb(0.3, 0.3, 0.4), 2000.0)?; transform_set_position(dir_light, Vec3::new(200.0, 300.0, 500.0))?; transform_look_at(dir_light, Vec3::new(0.0, 0.0, 0.0))?; - // Emissive glowing material let glow = material_create_pbr()?; material_set(glow, "roughness", shader_value::ShaderValue::Float(0.3))?; material_set(glow, "metallic", shader_value::ShaderValue::Float(0.5))?; - // HDR emissive — values > 1.0 produce bloom/glow material_set(glow, "emissive", shader_value::ShaderValue::Float4([2.0, 0.5, 3.0, 1.0]))?; - // Generate 3D text geometry — measure width to center the mesh graphics_record_command(graphics, DrawCommand::TextSize(120.0))?; graphics_record_command(graphics, DrawCommand::TextStyle(TextStyle::Bold))?; + // measure width to center the mesh on the origin let w = graphics_text_width(graphics, "Processing")?; let mesh = graphics_text_to_model(graphics, "Processing", -w / 2.0, 0.0, 40.0)?; let geom = geometry_create_from_mesh(mesh)?; From 264acbf39c6132c17eff7ba46345ff0ead33ad1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Thu, 14 May 2026 13:32:20 -0700 Subject: [PATCH 4/5] Fmt. --- crates/processing_ffi/src/lib.rs | 34 ++-- crates/processing_pyo3/src/graphics.rs | 64 ++++--- crates/processing_render/src/lib.rs | 69 ++++---- crates/processing_render/src/render/mod.rs | 15 +- .../src/render/primitive/text.rs | 161 +++++++++++++----- crates/processing_render/src/text/font.rs | 5 +- examples/text_3d.rs | 6 +- 7 files changed, 214 insertions(+), 140 deletions(-) diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index 085ad250..b77ae9e4 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -993,10 +993,8 @@ pub extern "C" fn processing_end_contour(graphics_id: u64) { #[unsafe(no_mangle)] pub unsafe extern "C" fn processing_load_font(path_ptr: *const std::ffi::c_char) -> u64 { error::clear_error(); - let path = unsafe { std::ffi::CStr::from_ptr(path_ptr) } - .to_string_lossy(); - error::check(|| font_load(&path).map(|e| e.to_bits())) - .unwrap_or(0) + let path = unsafe { std::ffi::CStr::from_ptr(path_ptr) }.to_string_lossy(); + error::check(|| font_load(&path).map(|e| e.to_bits())).unwrap_or(0) } /// Create a font handle from an existing font family name. @@ -1007,10 +1005,8 @@ pub unsafe extern "C" fn processing_load_font(path_ptr: *const std::ffi::c_char) #[unsafe(no_mangle)] pub unsafe extern "C" fn processing_create_font(name_ptr: *const std::ffi::c_char) -> u64 { error::clear_error(); - let name = unsafe { std::ffi::CStr::from_ptr(name_ptr) } - .to_string_lossy(); - error::check(|| font_create(&name).map(|e| e.to_bits())) - .unwrap_or(0) + let name = unsafe { std::ffi::CStr::from_ptr(name_ptr) }.to_string_lossy(); + error::check(|| font_create(&name).map(|e| e.to_bits())).unwrap_or(0) } /// Query the number of variable font axes for a font. @@ -1019,8 +1015,7 @@ pub unsafe extern "C" fn processing_create_font(name_ptr: *const std::ffi::c_cha pub extern "C" fn processing_font_variation_count(font_id: u64) -> u32 { error::clear_error(); let font_entity = Entity::from_bits(font_id); - error::check(|| font_variations(font_entity).map(|v| v.len() as u32)) - .unwrap_or(0) + error::check(|| font_variations(font_entity).map(|v| v.len() as u32)).unwrap_or(0) } /// Query variable font axis info. @@ -1241,8 +1236,7 @@ pub unsafe extern "C" fn processing_text_bounds( ) { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) } - .to_string_lossy(); + let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) }.to_string_lossy(); if let Some(bounds) = error::check(|| graphics_text_bounds(graphics_entity, &content, x, y, None, None)) { @@ -1363,9 +1357,7 @@ pub extern "C" fn processing_text_size(graphics_id: u64, size: f32) { pub extern "C" fn processing_text_align(graphics_id: u64, h: u8, v: u8) { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - error::check(|| { - graphics_text_align(graphics_entity, h, v) - }); + error::check(|| graphics_text_align(graphics_entity, h, v)); } /// Set the text leading (line spacing). @@ -1396,10 +1388,8 @@ pub unsafe extern "C" fn processing_text_width( ) -> f32 { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) } - .to_string_lossy(); - error::check(|| graphics_text_width(graphics_entity, &content)) - .unwrap_or(0.0) + let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) }.to_string_lossy(); + error::check(|| graphics_text_width(graphics_entity, &content)).unwrap_or(0.0) } /// Get the text ascent for the current font size. @@ -1407,8 +1397,7 @@ pub unsafe extern "C" fn processing_text_width( pub extern "C" fn processing_text_ascent(graphics_id: u64) -> f32 { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - error::check(|| graphics_text_ascent(graphics_entity)) - .unwrap_or(0.0) + error::check(|| graphics_text_ascent(graphics_entity)).unwrap_or(0.0) } /// Get the text descent for the current font size. @@ -1416,8 +1405,7 @@ pub extern "C" fn processing_text_ascent(graphics_id: u64) -> f32 { pub extern "C" fn processing_text_descent(graphics_id: u64) -> f32 { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - error::check(|| graphics_text_descent(graphics_entity)) - .unwrap_or(0.0) + error::check(|| graphics_text_descent(graphics_entity)).unwrap_or(0.0) } /// Create an image from raw pixel data. diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index 322ebf2c..1ac95ab0 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -264,12 +264,23 @@ fn path_commands_to_py( match cmd { PathCommand::MoveTo(x, y) => ("M", x, y).into_pyobject(py).unwrap().into_any().unbind(), PathCommand::LineTo(x, y) => ("L", x, y).into_pyobject(py).unwrap().into_any().unbind(), - PathCommand::QuadTo { cx, cy, x, y } => { - ("Q", cx, cy, x, y).into_pyobject(py).unwrap().into_any().unbind() - } - PathCommand::CubicTo { cx1, cy1, cx2, cy2, x, y } => { - ("C", cx1, cy1, cx2, cy2, x, y).into_pyobject(py).unwrap().into_any().unbind() - } + PathCommand::QuadTo { cx, cy, x, y } => ("Q", cx, cy, x, y) + .into_pyobject(py) + .unwrap() + .into_any() + .unbind(), + PathCommand::CubicTo { + cx1, + cy1, + cx2, + cy2, + x, + y, + } => ("C", cx1, cy1, cx2, cy2, x, y) + .into_pyobject(py) + .unwrap() + .into_any() + .unbind(), PathCommand::Close => ("Z",).into_pyobject(py).unwrap().into_any().unbind(), } }; @@ -1018,7 +1029,11 @@ impl Graphics { let h: f32 = args.get_item(2)?.extract()?; (z, Some(w), Some(h)) } - _ => return Err(PyRuntimeError::new_err("text() takes 3-6 positional arguments")), + _ => { + return Err(PyRuntimeError::new_err( + "text() takes 3-6 positional arguments", + )); + } }; graphics_record_command( self.entity, @@ -1035,8 +1050,7 @@ impl Graphics { } pub fn text_style(&self, style: u8) -> PyResult<()> { - graphics_text_style(self.entity, style) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + graphics_text_style(self.entity, style).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } #[pyo3(signature = (content, x, y, max_w=None, max_h=None))] @@ -1106,12 +1120,7 @@ impl Graphics { /// Extract glyph outlines as path commands (one list per glyph). /// Each command is a tuple: ("M", x, y), ("L", x, y), ("Q", cx, cy, x, y), /// ("C", cx1, cy1, cx2, cy2, x, y), or ("Z",). - pub fn text_to_paths( - &self, - content: &str, - x: f32, - y: f32, - ) -> PyResult>>> { + pub fn text_to_paths(&self, content: &str, x: f32, y: f32) -> PyResult>>> { let paths = graphics_text_to_paths(self.entity, content, x, y) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Python::attach(|py| Ok(path_commands_to_py(py, paths))) @@ -1120,12 +1129,7 @@ impl Graphics { /// Extract glyph outlines as per-contour path commands. /// Each contour (MoveTo...Close sequence) is a separate list. /// Commands use the same tuple shapes as `text_to_paths`. - pub fn text_to_contours( - &self, - content: &str, - x: f32, - y: f32, - ) -> PyResult>>> { + pub fn text_to_contours(&self, content: &str, x: f32, y: f32) -> PyResult>>> { let contours = graphics_text_to_contours(self.entity, content, x, y) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Python::attach(|py| Ok(path_commands_to_py(py, contours))) @@ -1146,17 +1150,11 @@ impl Graphics { } /// Generate a 3D extruded mesh from text outlines. - pub fn text_to_model( - &self, - content: &str, - x: f32, - y: f32, - depth: f32, - ) -> PyResult { + pub fn text_to_model(&self, content: &str, x: f32, y: f32, depth: f32) -> PyResult { let mesh = graphics_text_to_model(self.entity, content, x, y, depth) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - let entity = geometry_create_from_mesh(mesh) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let entity = + geometry_create_from_mesh(mesh).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Geometry { entity }) } @@ -1251,13 +1249,11 @@ impl Graphics { } pub fn text_ascent(&self) -> PyResult { - graphics_text_ascent(self.entity) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + graphics_text_ascent(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } pub fn text_descent(&self) -> PyResult { - graphics_text_descent(self.entity) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + graphics_text_descent(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } /// Loads an image from a file and returns an Image object. diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 8157548e..53575f3c 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -2335,11 +2335,12 @@ pub fn font_load(path: &str) -> error::Result { app_mut(|app| { let text_cx = app.world().resource::().clone(); - let family_name = text_cx - .load_font(data) - .ok_or(error::ProcessingError::FontLoadError( - "Could not determine font family name".to_string(), - ))?; + let family_name = + text_cx + .load_font(data) + .ok_or(error::ProcessingError::FontLoadError( + "Could not determine font family name".to_string(), + ))?; let entity = app.world_mut().spawn(Font { family_name }).id(); Ok(entity) }) @@ -2380,12 +2381,9 @@ pub fn font_variations(font_entity: Entity) -> error::Result(font_entity) - .ok_or(error::ProcessingError::InvalidArgument( - "Invalid font entity".to_string(), - ))?; + let font = app.world().get::(font_entity).ok_or( + error::ProcessingError::InvalidArgument("Invalid font entity".to_string()), + )?; let family = font.family_name.clone(); let text_cx = app.world().resource::().clone(); Ok(text_cx.font_variations(&family)) @@ -2397,19 +2395,17 @@ pub fn font_metadata(font_entity: Entity) -> error::Result(font_entity) - .ok_or(error::ProcessingError::InvalidArgument( - "Invalid font entity".to_string(), - ))?; + let font = app.world().get::(font_entity).ok_or( + error::ProcessingError::InvalidArgument("Invalid font entity".to_string()), + )?; let family = font.family_name.clone(); let text_cx = app.world().resource::().clone(); text_cx .font_metadata(&family) - .ok_or(error::ProcessingError::InvalidArgument( - format!("Font family '{}' not found", family), - )) + .ok_or(error::ProcessingError::InvalidArgument(format!( + "Font family '{}' not found", + family + ))) }) } @@ -2424,7 +2420,10 @@ pub fn graphics_text_font( pub fn graphics_text_style(graphics_entity: Entity, style: u8) -> error::Result<()> { use render::command::TextStyle; - graphics_record_command(graphics_entity, DrawCommand::TextStyle(TextStyle::from(style))) + graphics_record_command( + graphics_entity, + DrawCommand::TextStyle(TextStyle::from(style)), + ) } pub fn graphics_text_align(graphics_entity: Entity, h: u8, v: u8) -> error::Result<()> { @@ -2440,7 +2439,10 @@ pub fn graphics_text_align(graphics_entity: Entity, h: u8, v: u8) -> error::Resu pub fn graphics_text_wrap(graphics_entity: Entity, mode: u8) -> error::Result<()> { use render::command::TextWrapMode; - graphics_record_command(graphics_entity, DrawCommand::TextWrap(TextWrapMode::from(mode))) + graphics_record_command( + graphics_entity, + DrawCommand::TextWrap(TextWrapMode::from(mode)), + ) } pub fn graphics_text_weight(graphics_entity: Entity, weight: f32) -> error::Result<()> { @@ -2450,9 +2452,10 @@ pub fn graphics_text_weight(graphics_entity: Entity, weight: f32) -> error::Resu fn parse_tag(tag: &str) -> error::Result<[u8; 4]> { let bytes = tag.as_bytes(); if bytes.len() != 4 { - return Err(error::ProcessingError::InvalidArgument( - format!("Font tag must be exactly 4 characters, got '{}'", tag), - )); + return Err(error::ProcessingError::InvalidArgument(format!( + "Font tag must be exactly 4 characters, got '{}'", + tag + ))); } Ok([bytes[0], bytes[1], bytes[2], bytes[3]]) } @@ -2470,11 +2473,7 @@ pub fn graphics_clear_text_variations(graphics_entity: Entity) -> error::Result< graphics_record_command(graphics_entity, DrawCommand::ClearTextVariations) } -pub fn graphics_text_feature( - graphics_entity: Entity, - tag: &str, - value: u16, -) -> error::Result<()> { +pub fn graphics_text_feature(graphics_entity: Entity, tag: &str, value: u16) -> error::Result<()> { let tag = parse_tag(tag)?; graphics_record_command(graphics_entity, DrawCommand::TextFeature { tag, value }) } @@ -2501,7 +2500,10 @@ fn text_query_state( graphics_entity: Entity, max_w: Option, max_h: Option, -) -> error::Result<(render::primitive::text::OwnedTextParams, text::font::TextContext)> { +) -> error::Result<( + render::primitive::text::OwnedTextParams, + text::font::TextContext, +)> { let state = app .world() .get::(graphics_entity) @@ -2638,10 +2640,7 @@ pub fn graphics_text_bounds( }) } -pub fn graphics_text_line_count( - graphics_entity: Entity, - content: &str, -) -> error::Result { +pub fn graphics_text_line_count(graphics_entity: Entity, content: &str) -> error::Result { app_mut(|app| { let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; Ok(render::primitive::text::text_line_count( diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 54f629b7..74f921c4 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -1203,7 +1203,9 @@ pub fn flush_draw_commands( state.text_weight = Some(weight); } DrawCommand::TextVariation { tag, value } => { - if let Some(existing) = state.text_variations.iter_mut().find(|(t, _)| *t == tag) { + if let Some(existing) = + state.text_variations.iter_mut().find(|(t, _)| *t == tag) + { existing.1 = value; } else { state.text_variations.push((tag, value)); @@ -1213,7 +1215,8 @@ pub fn flush_draw_commands( state.text_variations.clear(); } DrawCommand::TextFeature { tag, value } => { - if let Some(existing) = state.text_features.iter_mut().find(|(t, _)| *t == tag) { + if let Some(existing) = state.text_features.iter_mut().find(|(t, _)| *t == tag) + { existing.1 = value; } else { state.text_features.push((tag, value)); @@ -1274,7 +1277,13 @@ pub fn flush_draw_commands( &state, |mesh, color| { primitive::text::text( - mesh, &content, x, y, color, &text_params.as_params(), &text_cx, + mesh, + &content, + x, + y, + color, + &text_params.as_params(), + &text_cx, ); }, &p_material_handles, diff --git a/crates/processing_render/src/render/primitive/text.rs b/crates/processing_render/src/render/primitive/text.rs index 654b7d09..b5ddc74f 100644 --- a/crates/processing_render/src/render/primitive/text.rs +++ b/crates/processing_render/src/render/primitive/text.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; -use bevy::prelude::*; use bevy::mesh::{Indices, VertexAttributeValues}; +use bevy::prelude::*; use lyon::{ geom::Point, path::Path, @@ -19,9 +19,9 @@ use parley::{ }, }; use skrifa::{ + FontRef, MetadataProvider, instance::{LocationRef, NormalizedCoord, Size}, outline::{DrawSettings, OutlinePen}, - FontRef, MetadataProvider, }; use crate::render::{ @@ -36,8 +36,20 @@ use crate::text::font::{DEFAULT_FONT_FAMILY, TextContext}; pub enum PathCommand { MoveTo(f32, f32), LineTo(f32, f32), - QuadTo { cx: f32, cy: f32, x: f32, y: f32 }, - CubicTo { cx1: f32, cy1: f32, cx2: f32, cy2: f32, x: f32, y: f32 }, + QuadTo { + cx: f32, + cy: f32, + x: f32, + y: f32, + }, + CubicTo { + cx1: f32, + cy1: f32, + cx2: f32, + cy2: f32, + x: f32, + y: f32, + }, Close, } @@ -117,7 +129,15 @@ impl OwnedTextParams { } /// Tessellate text into a mesh (fill). -pub fn text(mesh: &mut Mesh, content: &str, x: f32, y: f32, color: Color, params: &TextParams, text_cx: &TextContext) { +pub fn text( + mesh: &mut Mesh, + content: &str, + x: f32, + y: f32, + color: Color, + params: &TextParams, + text_cx: &TextContext, +) { if content.is_empty() { return; } @@ -125,12 +145,28 @@ pub fn text(mesh: &mut Mesh, content: &str, x: f32, y: f32, color: Color, params text_cx.with(|font_cx, layout_cx| { let layout = build_layout(font_cx, layout_cx, content, color, params); let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); - tessellate_layout(mesh, &layout, base_x, base_y, params.max_h, params.glyph_colors); + tessellate_layout( + mesh, + &layout, + base_x, + base_y, + params.max_h, + params.glyph_colors, + ); }); } /// Tessellate text outlines as strokes into a mesh. -pub fn text_stroke(mesh: &mut Mesh, content: &str, x: f32, y: f32, color: Color, stroke_weight: f32, params: &TextParams, text_cx: &TextContext) { +pub fn text_stroke( + mesh: &mut Mesh, + content: &str, + x: f32, + y: f32, + color: Color, + stroke_weight: f32, + params: &TextParams, + text_cx: &TextContext, +) { if content.is_empty() { return; } @@ -138,7 +174,15 @@ pub fn text_stroke(mesh: &mut Mesh, content: &str, x: f32, y: f32, color: Color, text_cx.with(|font_cx, layout_cx| { let layout = build_layout(font_cx, layout_cx, content, color, params); let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); - stroke_layout(mesh, &layout, base_x, base_y, color, stroke_weight, params.max_h); + stroke_layout( + mesh, + &layout, + base_x, + base_y, + color, + stroke_weight, + params.max_h, + ); }); } @@ -184,7 +228,13 @@ pub fn text_descent(params: &TextParams, text_cx: &TextContext) -> f32 { } /// Bounding box of text as `[x, y, width, height]`. -pub fn text_bounds(content: &str, x: f32, y: f32, params: &TextParams, text_cx: &TextContext) -> [f32; 4] { +pub fn text_bounds( + content: &str, + x: f32, + y: f32, + params: &TextParams, + text_cx: &TextContext, +) -> [f32; 4] { if content.is_empty() { return [x, y, 0.0, 0.0]; } @@ -263,7 +313,12 @@ pub fn text_lines( result.push(TextLineInfo { text: line_text.to_string(), - rect: [base_x, line_y, metrics.advance, metrics.ascent + metrics.descent], + rect: [ + base_x, + line_y, + metrics.advance, + metrics.ascent + metrics.descent, + ], }); } result @@ -388,7 +443,12 @@ pub fn text_to_points( points.push([px, py]); } } - Event::Cubic { from, ctrl1, ctrl2, to } => { + Event::Cubic { + from, + ctrl1, + ctrl2, + to, + } => { let steps = (30.0 * step).max(2.0) as usize; for i in 1..=steps { let t = i as f32 / steps as f32; @@ -453,8 +513,7 @@ pub fn text_to_model( }) .unwrap_or(0); { - let mut builder = - Extrusion3DBuilder::new(&mut mesh, -half_depth, [0.0, 0.0, -1.0]); + let mut builder = Extrusion3DBuilder::new(&mut mesh, -half_depth, [0.0, 0.0, -1.0]); let _ = fill_tess.tessellate_path(path, &FillOptions::default(), &mut builder); } @@ -489,7 +548,12 @@ pub fn text_to_model( contour_points.push(Point::new(px, py)); } } - Event::Cubic { from, ctrl1, ctrl2, to } => { + Event::Cubic { + from, + ctrl1, + ctrl2, + to, + } => { let steps = 12; for s in 1..=steps { let t = s as f32 / steps as f32; @@ -585,9 +649,7 @@ fn push_vertex_3d(mesh: &mut Mesh, position: [f32; 3], normal: [f32; 3]) { { colors.push([1.0, 1.0, 1.0, 1.0]); } - if let Some(VertexAttributeValues::Float32x2(uvs)) = - mesh.attribute_mut(Mesh::ATTRIBUTE_UV_0) - { + if let Some(VertexAttributeValues::Float32x2(uvs)) = mesh.attribute_mut(Mesh::ATTRIBUTE_UV_0) { uvs.push([0.0, 0.0]); } } @@ -685,14 +747,25 @@ pub fn text_to_contours( } Event::Quadratic { from: _, ctrl, to } => { current_contour.push(PathCommand::QuadTo { - cx: ctrl.x, cy: ctrl.y, x: to.x, y: to.y, + cx: ctrl.x, + cy: ctrl.y, + x: to.x, + y: to.y, }); } - Event::Cubic { from: _, ctrl1, ctrl2, to } => { + Event::Cubic { + from: _, + ctrl1, + ctrl2, + to, + } => { current_contour.push(PathCommand::CubicTo { - cx1: ctrl1.x, cy1: ctrl1.y, - cx2: ctrl2.x, cy2: ctrl2.y, - x: to.x, y: to.y, + cx1: ctrl1.x, + cy1: ctrl1.y, + cx2: ctrl2.x, + cy2: ctrl2.y, + x: to.x, + y: to.y, }); } Event::End { close, .. } => { @@ -732,18 +805,31 @@ fn extract_glyph_path_commands( Event::Line { from: _, to } => cmds.push(PathCommand::LineTo(to.x, to.y)), Event::Quadratic { from: _, ctrl, to } => { cmds.push(PathCommand::QuadTo { - cx: ctrl.x, cy: ctrl.y, x: to.x, y: to.y, + cx: ctrl.x, + cy: ctrl.y, + x: to.x, + y: to.y, }); } - Event::Cubic { from: _, ctrl1, ctrl2, to } => { + Event::Cubic { + from: _, + ctrl1, + ctrl2, + to, + } => { cmds.push(PathCommand::CubicTo { - cx1: ctrl1.x, cy1: ctrl1.y, - cx2: ctrl2.x, cy2: ctrl2.y, - x: to.x, y: to.y, + cx1: ctrl1.x, + cy1: ctrl1.y, + cx2: ctrl2.x, + cy2: ctrl2.y, + x: to.x, + y: to.y, }); } Event::End { close, .. } => { - if close { cmds.push(PathCommand::Close); } + if close { + cmds.push(PathCommand::Close); + } } } } @@ -783,8 +869,7 @@ fn extract_glyph_lyon_paths( let font_size = run.font_size(); let normalized_coords = run.normalized_coords(); - let Ok(font_ref) = FontRef::from_index(font_data.data.as_ref(), font_data.index) - else { + let Ok(font_ref) = FontRef::from_index(font_data.data.as_ref(), font_data.index) else { continue; }; @@ -886,9 +971,9 @@ fn build_layout( value, }) .collect(); - builder.push_default(StyleProperty::FontFeatures(FontSettings::List( - Cow::Owned(feats), - ))); + builder.push_default(StyleProperty::FontFeatures(FontSettings::List(Cow::Owned( + feats, + )))); } let mut layout = builder.build(content); @@ -1082,8 +1167,7 @@ fn extract_glyph_lyon_paths_yup( let font_size = run.font_size(); let normalized_coords = run.normalized_coords(); - let Ok(font_ref) = FontRef::from_index(font_data.data.as_ref(), font_data.index) - else { + let Ok(font_ref) = FontRef::from_index(font_data.data.as_ref(), font_data.index) else { continue; }; @@ -1197,11 +1281,8 @@ impl OutlinePen for LyonOutlinePen { } fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { - self.builder.cubic_bezier_to( - Point::new(cx0, cy0), - Point::new(cx1, cy1), - Point::new(x, y), - ); + self.builder + .cubic_bezier_to(Point::new(cx0, cy0), Point::new(cx1, cy1), Point::new(x, y)); } fn close(&mut self) { diff --git a/crates/processing_render/src/text/font.rs b/crates/processing_render/src/text/font.rs index 70e0633a..e8f31fbb 100644 --- a/crates/processing_render/src/text/font.rs +++ b/crates/processing_render/src/text/font.rs @@ -51,10 +51,7 @@ impl TextContext { /// Register font bytes; returns the primary family name if one is found. pub fn load_font(&self, data: Vec) -> Option { let mut inner = self.inner.lock().unwrap(); - let families = inner - .font_cx - .collection - .register_fonts(data.into(), None); + let families = inner.font_cx.collection.register_fonts(data.into(), None); families.first().and_then(|(fam_id, _)| { inner .font_cx diff --git a/examples/text_3d.rs b/examples/text_3d.rs index 6753f84a..c6d01182 100644 --- a/examples/text_3d.rs +++ b/examples/text_3d.rs @@ -30,7 +30,11 @@ fn sketch() -> error::Result<()> { let glow = material_create_pbr()?; material_set(glow, "roughness", shader_value::ShaderValue::Float(0.3))?; material_set(glow, "metallic", shader_value::ShaderValue::Float(0.5))?; - material_set(glow, "emissive", shader_value::ShaderValue::Float4([2.0, 0.5, 3.0, 1.0]))?; + material_set( + glow, + "emissive", + shader_value::ShaderValue::Float4([2.0, 0.5, 3.0, 1.0]), + )?; graphics_record_command(graphics, DrawCommand::TextSize(120.0))?; graphics_record_command(graphics, DrawCommand::TextStyle(TextStyle::Bold))?; From b42ac6c638062017c27ab5458e3840243dabdc96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charlotte=20=F0=9F=8C=B8?= Date: Thu, 14 May 2026 17:21:11 -0700 Subject: [PATCH 5/5] Ci. --- .github/actions/setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 9052ef27..ae33a94c 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -9,7 +9,7 @@ runs: using: 'composite' steps: - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y g++ cmake pkg-config libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libasound2-dev libudev-dev libxkbcommon-x11-0 libwayland-dev libwayland-bin libxkbcommon-dev wayland-protocols libdecor-0-dev libegl1-mesa-dev libwayland-egl1-mesa + run: sudo apt-get update && sudo apt-get install -y g++ cmake pkg-config libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libasound2-dev libudev-dev libxkbcommon-x11-0 libwayland-dev libwayland-bin libxkbcommon-dev wayland-protocols libdecor-0-dev libegl1-mesa-dev libwayland-egl1-mesa libfontconfig1-dev shell: bash - name: Build and install GLFW 3.4