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 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..b77ae9e4 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -983,6 +983,431 @@ 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 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..1ac95ab0 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -222,6 +222,75 @@ impl Light { // } // } +#[pyclass] +#[derive(Debug)] +pub struct Font { + pub(crate) entity: Entity, +} + +#[pymethods] +impl Font { + /// Query variable font axes. + /// + /// Returns a list of `(tag, min, max, default)` tuples. + 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 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)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } +} + +/// Convert glyph outline groups into per-group lists of Python command tuples. +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 { @@ -906,6 +975,287 @@ 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>>> { + 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))) + } + + /// 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>>> { + 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))) + } + + /// 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` 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}"))) + } + + 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}"))) + } + + 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}"))) + } + + /// 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}"))) + } + + /// 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, + 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}"))) + } + + /// 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, + 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/lib.rs b/crates/processing_render/src/lib.rs index 90c838c2..53575f3c 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,383 @@ 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. +/// +/// 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}; + + #[cfg(target_arch = "wasm32")] + { + let _ = path; + return Err(error::ProcessingError::FontLoadError( + "loading fonts from a file is not supported on wasm".to_string(), + )); + } + + #[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. +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_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_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_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_glyph_colors( + graphics_entity: Entity, + colors: Vec, +) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::TextGlyphColors(colors)) +} + +/// Snapshot a graphics entity's text state and the shared `TextContext`. +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>> { + app_mut(|app| { + 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, + )) + }) +} + +pub fn graphics_text_to_contours( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, +) -> error::Result>> { + app_mut(|app| { + 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, + )) + }) +} + +pub fn graphics_text_to_points( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + sample_factor: Option, +) -> error::Result> { + app_mut(|app| { + 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(render::primitive::text::DEFAULT_SAMPLE_FACTOR), + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_to_model( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + depth: f32, +) -> error::Result { + app_mut(|app| { + 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_width(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_width( + content, + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_ascent(graphics_entity: Entity) -> error::Result { + app_mut(|app| { + 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 { + app_mut(|app| { + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_descent( + ¶ms.as_params(), + &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]> { + app_mut(|app| { + 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, + )) + }) +} + +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( + content, + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_lines( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, +) -> error::Result> { + app_mut(|app| { + 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, + )) + }) +} + +pub fn graphics_text_glyph_rects( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, +) -> error::Result> { + app_mut(|app| { + 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 2e3bae8d..1d8f3087 100644 --- a/crates/processing_render/src/render/command.rs +++ b/crates/processing_render/src/render/command.rs @@ -2,6 +2,88 @@ 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(), + } + } +} + +#[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 +582,38 @@ 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), + 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..74f921c4 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -12,7 +12,9 @@ use bevy::{ prelude::*, render::render_resource::BlendState, }; -use command::{CommandBuffer, DrawCommand, ShapeMode}; +use command::{ + CommandBuffer, DrawCommand, ShapeMode, TextAlignH, TextAlignV, TextStyle, TextWrapMode, +}; use material::{MaterialKey, ProcessingExtendedMaterial}; use primitive::{ ShapeBuilder, StrokeConfig, TessellationMode, VertexType, arc_fill, arc_stroke, bezier, @@ -31,6 +33,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 +95,17 @@ 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_glyph_colors: Option>, } impl RenderState { @@ -115,6 +129,17 @@ 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_glyph_colors: None, } } @@ -137,6 +162,17 @@ 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_glyph_colors = None; } pub fn begin_frame(&mut self) { @@ -176,6 +212,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 +1187,127 @@ 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::TextGlyphColors(colors) => { + state.text_glyph_colors = Some(colors); + } + DrawCommand::Text { + content, + x, + y, + z, + max_w, + max_h, + } => { + // 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)) + } else { + (x, y, max_w, max_h) + }; + + 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 { + state.transform.translate_3d(0.0, 0.0, z); + } + + add_fill( + &mut res, + &mut batch, + &state, + |mesh, color| { + primitive::text::text( + mesh, + &content, + x, + y, + color, + &text_params.as_params(), + &text_cx, + ); + }, + &p_material_handles, + ); + + add_stroke( + &mut res, + &mut batch, + &state, + |mesh, color, weight| { + // 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( + 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..b5ddc74f --- /dev/null +++ b/crates/processing_render/src/render/primitive/text.rs @@ -0,0 +1,1291 @@ +use std::borrow::Cow; + +use bevy::mesh::{Indices, VertexAttributeValues}; +use bevy::prelude::*; +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::{ + FontRef, MetadataProvider, + instance::{LocationRef, NormalizedCoord, Size}, + outline::{DrawSettings, OutlinePen}, +}; + +use crate::render::{ + RenderState, + 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, +} + +/// Text layout parameters. +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]>, +} + +/// Owned [`TextParams`]: a `RenderState` snapshot that outlives the borrow. +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 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, + 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`]. + 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() { + 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. +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 + }) +} + +/// 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) + }) +} + +/// 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) + }) +} + +/// 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]; + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + + // 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 { + Some(h) => layout.height().min(h), + None => layout.height(), + }; + + [box_x, box_y, width, height] + }) +} + +/// A single laid-out line. +#[derive(Debug, Clone)] +pub struct TextLineInfo { + pub text: String, + /// `[x, y, width, height]`. + pub rect: [f32; 4], +} + +/// A single laid-out glyph. +#[derive(Debug, Clone)] +pub struct TextGlyphInfo { + /// `[x, y, width, height]`. + pub rect: [f32; 4], +} + +/// 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() + }) +} + +/// Per-line text and bounding rects. +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 + }) +} + +/// Per-glyph bounding rects. +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) + }) +} + +/// Default `sample_factor` for [`text_to_points`]. +pub const DEFAULT_SAMPLE_FACTOR: f32 = 0.1; + +/// Sample points along text outlines. Higher `sample_factor` = more points; +/// see [`DEFAULT_SAMPLE_FACTOR`]. +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 + }) +} + +/// 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, + 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 +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 -Z, with winding reversed below + 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); + } + + 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: connect each contour's front vertices to its back ones + 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 } => { + 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, + } => { + 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 { + for i in 0..contour_points.len() { + let j = (i + 1) % contour_points.len(); + let p0 = contour_points[i]; + let p1 = contour_points[j]; + + // 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); + let nx = -dy / len; + let ny = dx / len; + let normal = [nx, ny, 0.0]; + + // 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); + + 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]); + } +} + +/// Places lyon fill output at a fixed Z depth and 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)) + } +} + +/// 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 + .get(0) + .map(|line| line.metrics().baseline) + .unwrap_or(0.0); + + let y_offset = match align_v { + TextAlignV::Baseline => y - first_baseline, + TextAlignV::Top => y, + TextAlignV::Center => y - total_height / 2.0, + TextAlignV::Bottom => y - total_height, + }; + + (x, y_offset) +} + +/// Extract text outlines, one `PathCommand` vec per contour. +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`s, one per glyph. +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); + + 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)); + } + + // 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) { + 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); + + let max_advance = params.max_w.unwrap_or(f32::MAX); + layout.break_all_lines(Some(max_advance)); + + 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; + }; + + // stop once a line falls past max_h + 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(); + + 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); + + // 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); + + 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() { + // 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; + + 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() +} + +/// An `OutlinePen` that builds a lyon `Path` from a skrifa glyph outline. +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..e8f31fbb --- /dev/null +++ b/crates/processing_render/src/text/font.rs @@ -0,0 +1,172 @@ +use std::sync::{Arc, Mutex}; + +use bevy::prelude::*; +use parley::{FontContext, LayoutContext}; + +/// Font component: the resolved family name. +#[derive(Component)] +pub struct Font { + pub family_name: String, +} + +/// Shared parley 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(); + + // embedded NotoSans is the 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 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 { + ref mut font_cx, + ref mut layout_cx, + } = *inner; + f(font_cx, layout_cx) + } + + /// 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); + families.first().and_then(|(fam_id, _)| { + inner + .font_cx + .collection + .family_name(*fam_id) + .map(|s| s.to_string()) + }) + } + + /// All available family names, system and 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() + } + + /// 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() + } +} + +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 { + /// 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) { + 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() + } + + /// Metadata for a family. + 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, + 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/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/examples/text.rs b/examples/text.rs new file mode 100644 index 00000000..bd8e5167 --- /dev/null +++ b/examples/text.rs @@ -0,0 +1,100 @@ +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)?; + + graphics_record_command(graphics, DrawCommand::BackgroundColor(Color::WHITE))?; + graphics_record_command(graphics, DrawCommand::Fill(Color::BLACK))?; + graphics_record_command(graphics, DrawCommand::TextSize(32.0))?; + + 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, + }, + )?; + + 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, + }, + )?; + + 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), + }, + )?; + + 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..c6d01182 --- /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)?; + + 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))?; + + 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))?; + + 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]), + )?; + + 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)?; + + 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/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 3ff8cb6c..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, - custom_blend_state, + TextAlignH, TextAlignV, TextStyle, TextWrapMode, custom_blend_state, }, *, };