From 44aa92cf93a89c4e92a4ff4096fed4b4fee0d10f Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 9 Sep 2026 23:03:19 +0200 Subject: [PATCH] Add editable sketch text with embedded Linux font outlines --- Cargo.lock | 101 +++ Cargo.toml | 4 + crates/vernier-app/Cargo.toml | 2 + crates/vernier-app/src/app.rs | 30 +- crates/vernier-app/src/edit.rs | 6 + crates/vernier-app/src/lib.rs | 1 + .../src/process_codec/request_tests.rs | 17 +- .../src/process_codec/scene_tests.rs | 16 +- .../vernier-app/src/process_codec/validate.rs | 3 + .../src/process_codec/validation_tests.rs | 15 + crates/vernier-app/src/reachability.rs | 10 + .../vernier-app/src/server/apply_features.rs | 55 ++ crates/vernier-app/src/server/checkpoint.rs | 1 + crates/vernier-app/src/server/mod.rs | 4 +- crates/vernier-app/src/server/view.rs | 41 +- crates/vernier-app/src/text_fonts.rs | 119 +++ crates/vernier-doc/Cargo.toml | 3 + crates/vernier-doc/src/command/mod.rs | 3 + crates/vernier-doc/src/command/sketch_text.rs | 695 ++++++++++++++++++ crates/vernier-doc/src/document.rs | 23 +- crates/vernier-doc/src/legacy.rs | 11 + crates/vernier-doc/src/lib.rs | 6 + crates/vernier-doc/src/sketch.rs | 109 ++- crates/vernier-doc/src/text_outline.rs | 560 ++++++++++++++ crates/vernier-ui/src/shell/mod.rs | 48 +- crates/vernier-ui/src/shell/tests.rs | 1 + crates/vernier-ui/src/shell/text_editor.rs | 343 +++++++++ ...ECHANICAL_MODELLING_PROGRESS_2026-09-09.md | 25 + scripts/check.fish | 3 + scripts/drive/sketch-text.json | 318 ++++++++ 30 files changed, 2560 insertions(+), 13 deletions(-) create mode 100644 crates/vernier-app/src/text_fonts.rs create mode 100644 crates/vernier-doc/src/command/sketch_text.rs create mode 100644 crates/vernier-doc/src/text_outline.rs create mode 100644 crates/vernier-ui/src/shell/text_editor.rs create mode 100644 scripts/drive/sketch-text.json diff --git a/Cargo.lock b/Cargo.lock index ed93302..d9c69c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,6 +563,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -947,6 +956,29 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2440,6 +2472,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2493,6 +2531,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2883,6 +2939,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2975,6 +3046,9 @@ name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] [[package]] name = "type-map" @@ -2996,6 +3070,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -3008,6 +3094,18 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -3097,6 +3195,7 @@ dependencies = [ "egui", "egui-wgpu", "egui-winit", + "fontdb", "pollster 1.0.1", "rfd", "serde", @@ -3129,9 +3228,11 @@ name = "vernier-doc" version = "0.0.1" dependencies = [ "indexmap", + "rustybuzz", "serde", "serde_json", "thiserror 2.0.19", + "ttf-parser", "vernier-solver", ] diff --git a/Cargo.toml b/Cargo.toml index 66d35ea..79a3767 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,10 @@ repository = "https://git.briggen.dev/NilsBriggen/vernier" publish = false [workspace.dependencies] +# Approved Linux sketch-text font discovery and exact shaping. +fontdb = "0.23" +rustybuzz = "0.20" +ttf-parser = "0.25" anyhow = "1" bytemuck = { version = "1", features = ["derive"] } cxx = "1" diff --git a/crates/vernier-app/Cargo.toml b/crates/vernier-app/Cargo.toml index 28db50e..81d7fad 100644 --- a/crates/vernier-app/Cargo.toml +++ b/crates/vernier-app/Cargo.toml @@ -10,6 +10,8 @@ repository.workspace = true publish.workspace = true [dependencies] +fontdb.workspace = true + anyhow.workspace = true egui.workspace = true egui-wgpu.workspace = true diff --git a/crates/vernier-app/src/app.rs b/crates/vernier-app/src/app.rs index 5b694cf..05e7545 100644 --- a/crates/vernier-app/src/app.rs +++ b/crates/vernier-app/src/app.rs @@ -50,6 +50,7 @@ pub(crate) enum CloseChoice { } pub(crate) struct VernierApp { + text_fonts: crate::text_fonts::TextFonts, projects: project_library::Projects, close_state: CloseState, retiring_gesture: bool, @@ -331,10 +332,15 @@ impl VernierApp { // never stop the app opening. let egui_ctx = egui::Context::default(); vernier_ui::theme::install_fonts(&egui_ctx); - let shell = ShellState::default(); + let text_fonts = crate::text_fonts::TextFonts::load_system(); + let shell = ShellState { + text_fonts: text_fonts.choices(), + ..ShellState::default() + }; shell.theme.install(&egui_ctx); Self { + text_fonts, projects: project_library::Projects::default(), close_state: CloseState::Idle, retiring_gesture: false, @@ -1066,6 +1072,8 @@ impl VernierApp { self.shell.file_path.clone_from(path); } if scene.document_opened { + self.shell.text_editor = Default::default(); + self.shell.text_submission = None; self.shell.spline_editor_target = None; self.shell.spline_edit = None; self.files_opened(scene.document_path.as_deref()); @@ -1676,6 +1684,26 @@ impl VernierApp { if self.document_barrier != 0 || self.worker_failed { return output; } + if let Some(request) = self.shell.text_submission.take() { + let retain = request.convert_to_curves + || request.text_id.is_some_and(|id| { + self.scene.sketch_texts.iter().any(|row| { + row.id == id + && row.sketch == request.sketch + && row.font_key == request.font_key + }) + }); + let font = if retain { + Ok(None) + } else { + self.text_fonts.resolve(&request.font_key).map(Some) + }; + match font { + Ok(font) => self.submit(Edit::SketchText { request, font }), + Err(why) => self.shell.readout = why, + } + return output; + } if let Some(action) = response.feature_preview { self.submit_feature_preview(action); return output; diff --git a/crates/vernier-app/src/edit.rs b/crates/vernier-app/src/edit.rs index 45ef426..c32a7c4 100644 --- a/crates/vernier-app/src/edit.rs +++ b/crates/vernier-app/src/edit.rs @@ -17,6 +17,12 @@ use crate::sessions::{create_session_edit, slot_is_committable}; /// shell's dimension fields or the push/pull gizmo drag. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) enum Edit { + /// Editable text or explicit outline conversion, applied by the worker. + SketchText { + request: vernier_ui::shell::TextSubmission, + /// New selected font bytes, face index and label; None retains embedded font. + font: Option<(Vec, u32, String)>, + }, /// Create and enter a new empty native document at an app-owned destination. NewDocument { path: String }, /// Undoable display metadata with no native evaluation. diff --git a/crates/vernier-app/src/lib.rs b/crates/vernier-app/src/lib.rs index 196dc16..2e3baf8 100644 --- a/crates/vernier-app/src/lib.rs +++ b/crates/vernier-app/src/lib.rs @@ -58,6 +58,7 @@ mod app; mod app_worker; mod project_catalogue; +mod text_fonts; pub(crate) use app::autosave; mod body_tools; mod camera; diff --git a/crates/vernier-app/src/process_codec/request_tests.rs b/crates/vernier-app/src/process_codec/request_tests.rs index 6ebe342..fe83837 100644 --- a/crates/vernier-app/src/process_codec/request_tests.rs +++ b/crates/vernier-app/src/process_codec/request_tests.rs @@ -26,6 +26,7 @@ fn body(raw: u64) -> vernier_doc::BodyId { /// inventory to be revisited instead of silently falling through a wildcard. fn variant_name(edit: &Edit) -> &'static str { match edit { + Edit::SketchText { .. } => "SketchText", Edit::NewDocument { .. } => "NewDocument", Edit::Organisation { .. } => "Organisation", Edit::Variables { .. } => "Variables", @@ -141,6 +142,20 @@ fn roundtrip(edit: Edit) -> Edit { #[test] fn scalar_and_id_edges_are_exact() { FIXTURES_SEEN.with(|seen| seen.borrow_mut().clear()); + roundtrip(Edit::SketchText { + request: vernier_ui::shell::TextSubmission { + sketch: id(1), + text_id: Some(id(2)), + text: "CAD".into(), + font_key: "embedded".into(), + height_mm: 10.0, + origin: [2.0, 3.0], + rotation_radians: 0.25, + convert_to_curves: false, + }, + font: Some((vec![0, 1, 255], 0, "fixture".into())), + }); + roundtrip(Edit::Recompute); roundtrip(Edit::NewDocument { path: "/tmp/new.vernier".into(), @@ -1119,7 +1134,7 @@ fn scalar_and_id_edges_are_exact() { FIXTURES_SEEN.with(|seen| { assert_eq!( seen.borrow().len(), - 85, + 86, "fixture inventory covered {:?}", seen.borrow() ) diff --git a/crates/vernier-app/src/process_codec/scene_tests.rs b/crates/vernier-app/src/process_codec/scene_tests.rs index 3e0cc63..37add45 100644 --- a/crates/vernier-app/src/process_codec/scene_tests.rs +++ b/crates/vernier-app/src/process_codec/scene_tests.rs @@ -187,6 +187,16 @@ fn view() -> SceneView { }); } SceneView { + sketch_texts: vec![vernier_ui::shell::SketchTextRow { + id: id(50), + sketch: id(1), + text: "CAD".into(), + font_label: "Fixture".into(), + font_key: "fixture".into(), + height_mm: 10.0, + origin: [1.0, 2.0], + rotation_radians: 0.25, + }], extrude_faces: Default::default(), face_attached_sketches: Default::default(), organisation: Default::default(), @@ -522,9 +532,13 @@ fn roundtrip(reply: &ServerReply, saved: &ServerCheckpoint) -> (ServerReply, Ser let wire_reply = match reply { ServerReply::Scene(scene) => { let mut scene = (**scene).clone(); - scene.view.history = canonical_history(saved); + // This scene is intentionally synthetic; its checkpoint is a + // separate real document. Bind document-derived projections to + // the acknowledged document before codec admission. let document = vernier_doc::Document::from_session_json(&saved.document_session_json).unwrap(); + scene.view.sketch_texts = crate::server::view::sketch_texts(&document); + scene.view.history = canonical_history(saved); scene.view.variables = document.variables().clone(); scene.view.parameter_bindings = document.parameter_bindings().to_vec(); scene.view.parameter_fields = document.parameter_fields(); diff --git a/crates/vernier-app/src/process_codec/validate.rs b/crates/vernier-app/src/process_codec/validate.rs index 0f8e612..1292dee 100644 --- a/crates/vernier-app/src/process_codec/validate.rs +++ b/crates/vernier-app/src/process_codec/validate.rs @@ -104,6 +104,9 @@ pub(crate) fn response( { return Err(fail("expressions differ from acknowledged document")); } + if scene.view.sketch_texts != crate::server::view::sketch_texts(&document) { + return Err(fail("text rows differ from acknowledged document")); + } if scene.view.body_operations != crate::server::body_tools::body_operations(&document) { return Err(fail("body operations differ from acknowledged document")); } diff --git a/crates/vernier-app/src/process_codec/validation_tests.rs b/crates/vernier-app/src/process_codec/validation_tests.rs index 8595672..0ff54f9 100644 --- a/crates/vernier-app/src/process_codec/validation_tests.rs +++ b/crates/vernier-app/src/process_codec/validation_tests.rs @@ -127,6 +127,21 @@ fn two_body_scene() -> ( normal: Some([0.0, 0.0, 1.0]), }, ]; + // The fixture replaces the worker's meshes and face metadata with stable + // synthetic ids. Keep the support-face projection in that same ownership + // universe so admission still exercises valid support rows. + scene.view.extrude_faces = vec![ + vernier_ui::shell::ExtrudeFaceRow { + body: first, + face: first_face.raw(), + label: "first support".into(), + }, + vernier_ui::shell::ExtrudeFaceRow { + body: second, + face: second_face.raw(), + label: "second support".into(), + }, + ]; scene.edges = vec![ EdgeMeta { body: first, diff --git a/crates/vernier-app/src/reachability.rs b/crates/vernier-app/src/reachability.rs index 170956e..4cc5b19 100644 --- a/crates/vernier-app/src/reachability.rs +++ b/crates/vernier-app/src/reachability.rs @@ -68,6 +68,11 @@ enum Role { Commands, } const SOURCES: &[(&str, Role, &str)] = &[ + ( + "text_fonts.rs", + Role::Production, + include_str!("text_fonts.rs"), + ), ( "project_library.rs", Role::Production, @@ -513,6 +518,11 @@ const SOURCES: &[(&str, Role, &str)] = &[ /// against one root, and a single list with two roots would need a fourth /// column saying which — a column whose only reader is the walk. const COMMAND_SOURCES: &[(&str, Role, &str)] = &[ + ( + "sketch_text.rs", + Role::Commands, + include_str!("../../vernier-doc/src/command/sketch_text.rs"), + ), ( "variables.rs", Role::Commands, diff --git a/crates/vernier-app/src/server/apply_features.rs b/crates/vernier-app/src/server/apply_features.rs index 30cdeaf..40f46a4 100644 --- a/crates/vernier-app/src/server/apply_features.rs +++ b/crates/vernier-app/src/server/apply_features.rs @@ -66,6 +66,61 @@ pub(crate) fn apply(server: &mut DocumentServer, edit: &Edit) -> Result {} } match edit { + Edit::SketchText { request, font } => { + if request.convert_to_curves { + let text_id = request.text_id.ok_or("Select existing text to convert")?; + return server + .document + .execute(&vernier_doc::ConvertSketchText { + sketch: request.sketch, + text_id, + }) + .map(|_| true) + .map_err(stringy); + } + let (font_data, face_index, font_label) = if let Some(font) = font { + font.clone() + } else { + let Some(FeaturePayload::Sketch(data)) = server + .document + .features() + .get(&request.sketch) + .map(|f| &f.payload) + else { + return Err("Text sketch is unavailable".into()); + }; + let old = request + .text_id + .and_then(|id| data.texts.get(&id)) + .ok_or("Select a font for new text")?; + if old.font_key != request.font_key { + return Err("Selected font was not supplied".into()); + } + ( + old.spec.font_data.clone(), + old.spec.face_index, + old.font_label.clone(), + ) + }; + server + .document + .execute(&vernier_doc::SetSketchText { + sketch: request.sketch, + text_id: request.text_id, + font_key: request.font_key.clone(), + font_label, + spec: vernier_doc::text_outline::FontOutlineSpec { + text: request.text.clone(), + font_data, + face_index, + height_mm: request.height_mm, + origin: request.origin, + rotation_radians: request.rotation_radians, + }, + }) + .map(|_| true) + .map_err(stringy) + } Edit::Organisation { request } => server .document .execute(&vernier_doc::EditOrganisation { diff --git a/crates/vernier-app/src/server/checkpoint.rs b/crates/vernier-app/src/server/checkpoint.rs index 9665ea4..4fa8da3 100644 --- a/crates/vernier-app/src/server/checkpoint.rs +++ b/crates/vernier-app/src/server/checkpoint.rs @@ -88,6 +88,7 @@ impl DocumentServer { bodies: self.body_rows(None), timeline: self.timeline(), sketch_curves: self.sketch_curves(), + sketch_texts: super::view::sketch_texts(&self.document), sketch_points, dimension_sketches: self.dimension_sketches(None), closed_profile: self.closed_profile(), diff --git a/crates/vernier-app/src/server/mod.rs b/crates/vernier-app/src/server/mod.rs index e1ec812..d401349 100644 --- a/crates/vernier-app/src/server/mod.rs +++ b/crates/vernier-app/src/server/mod.rs @@ -997,6 +997,7 @@ impl DocumentServer { bodies: self.body_rows(compiled.as_ref()), timeline: self.timeline(), sketch_curves: self.sketch_curves(), + sketch_texts: view::sketch_texts(&self.document), sketch_points: self.sketch_points(), dimension_sketches: self.dimension_sketches(compiled.as_ref()), closed_profile: self.closed_profile(), @@ -1181,7 +1182,8 @@ impl DocumentServer { | Edit::SetSketchRadius { .. } | Edit::SetSketchDimension { .. } | Edit::RemoveSketchDimension { .. } => apply_constraints::apply(self, edit).map(Applied::from_family), - Edit::DeleteSketchEntities { .. } + Edit::SketchText { .. } + | Edit::DeleteSketchEntities { .. } | Edit::Organisation { .. } | Edit::SetBodyAttributes { .. } | Edit::BooleanBodies { .. } diff --git a/crates/vernier-app/src/server/view.rs b/crates/vernier-app/src/server/view.rs index 7ecc8e7..b200470 100644 --- a/crates/vernier-app/src/server/view.rs +++ b/crates/vernier-app/src/server/view.rs @@ -297,12 +297,13 @@ impl DocumentServer { let at = solved .and_then(|solved| solved.points.get(&id).copied()) .unwrap_or(authored); - let locked = data.constraints.iter().any(|constraint| { - matches!( - constraint, - vernier_doc::SketchConstraint::Lock { point, .. } if *point == id - ) - }); + let locked = data.text_owner(id).is_some() + || data.constraints.iter().any(|constraint| { + matches!( + constraint, + vernier_doc::SketchConstraint::Lock { point, .. } if *point == id + ) + }); // Exactly two LINES sharing this point is a corner // `AddSketchFillet` can round — v1 scope, mirroring the // command's own `FilletCornerError::UnsupportedCurve` @@ -846,3 +847,31 @@ pub(crate) const fn section_plane(plane: vernier_doc::PrincipalPlane) -> ([f64; vernier_doc::PrincipalPlane::Xy => ([0.0, 0.0, 1.0], "z"), } } + +/// Compact authored text rows shared by live and recovery scenes. +pub(crate) fn sketch_texts( + document: &vernier_doc::Document, +) -> Vec { + document + .features() + .iter() + .flat_map(|(&sketch, feature)| { + let FeaturePayload::Sketch(data) = &feature.payload else { + return Vec::new(); + }; + data.texts + .iter() + .map(|(&id, text)| vernier_ui::shell::SketchTextRow { + id, + sketch, + text: text.spec.text.clone(), + font_key: text.font_key.clone(), + font_label: text.font_label.clone(), + height_mm: text.spec.height_mm, + origin: text.spec.origin, + rotation_radians: text.spec.rotation_radians, + }) + .collect() + }) + .collect() +} diff --git a/crates/vernier-app/src/text_fonts.rs b/crates/vernier-app/src/text_fonts.rs new file mode 100644 index 0000000..7905b57 --- /dev/null +++ b/crates/vernier-app/src/text_fonts.rs @@ -0,0 +1,119 @@ +//! System font discovery for the sketch-text editor. +//! +//! This module only discovers and returns font sources. Outline permission +//! checks remain authoritative in `vernier_doc::text_outline` when a text +//! command is applied. + +use std::collections::BTreeMap; + +use vernier_ui::shell::TextFontChoice; + +const MAX_FONT_BYTES: usize = 32 * 1024 * 1024; + +#[derive(Debug)] +struct FontEntry { + id: fontdb::ID, + post_script_name: String, + label: String, +} + +/// The system fonts available to the text editor. +#[derive(Debug)] +pub struct TextFonts { + database: fontdb::Database, + entries: BTreeMap, + choices: Vec, +} + +impl TextFonts { + /// Load the system font database and retain every readable face. + pub fn load_system() -> Self { + let mut database = fontdb::Database::new(); + database.load_system_fonts(); + + let mut entries = BTreeMap::new(); + for face in database.faces() { + let family = face + .families + .first() + .map(|(name, _)| name.as_str()) + .unwrap_or("Unnamed font"); + let label = format!("{family} · {}", face.post_script_name); + let Some((key, _face_index)) = database.with_face_data(face.id, |data, index| { + (font_key(data, index, &face.post_script_name), index) + }) else { + continue; + }; + entries.entry(key).or_insert(FontEntry { + id: face.id, + post_script_name: face.post_script_name.clone(), + label, + }); + } + + let mut choices: Vec<_> = entries + .iter() + .map(|(key, entry)| TextFontChoice { + key: key.clone(), + label: entry.label.clone(), + }) + .collect(); + choices.sort_by(|left, right| { + left.label + .cmp(&right.label) + .then_with(|| left.key.cmp(&right.key)) + }); + + Self { + database, + entries, + choices, + } + } + + /// Return deterministically ordered labels and stable source keys. + pub fn choices(&self) -> Vec { + self.choices.clone() + } + + /// Resolve one selected face into complete font bytes. + /// + /// Unknown keys, unreadable sources, and sources over the document's + /// bounded embedding limit are rejected. Embedding permissions are + /// checked later by the authoritative document outline command. + pub fn resolve(&self, key: &str) -> Result<(Vec, u32, String), String> { + let entry = self + .entries + .get(key) + .ok_or_else(|| format!("unknown system font key: {key}"))?; + let resolved = self.database.with_face_data(entry.id, |data, index| { + if font_key(data, index, &entry.post_script_name) != key { + return Err( + "Font file changed since discovery; restart to reload the font list".into(), + ); + } + if data.len() > MAX_FONT_BYTES { + return Err(format!("font source exceeds {MAX_FONT_BYTES} bytes")); + } + Ok((data.to_vec(), index, entry.label.clone())) + }); + resolved.ok_or_else(|| format!("font source is unavailable: {key}"))? + } +} + +fn font_key(data: &[u8], face_index: u32, post_script_name: &str) -> String { + let mut hash = 0xcbf29ce484222325_u64; + for byte in data { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + for byte in face_index + .to_le_bytes() + .iter() + .chain(post_script_name.as_bytes()) + { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("font:{hash:016x}:{face_index}") +} diff --git a/crates/vernier-doc/Cargo.toml b/crates/vernier-doc/Cargo.toml index 824a839..a5bdc0b 100644 --- a/crates/vernier-doc/Cargo.toml +++ b/crates/vernier-doc/Cargo.toml @@ -9,6 +9,9 @@ repository.workspace = true publish.workspace = true [dependencies] +rustybuzz.workspace = true +ttf-parser.workspace = true + indexmap.workspace = true serde.workspace = true # Session recovery must preserve every finite authored f64 bit across JSON. diff --git a/crates/vernier-doc/src/command/mod.rs b/crates/vernier-doc/src/command/mod.rs index d577f8a..54232cb 100644 --- a/crates/vernier-doc/src/command/mod.rs +++ b/crates/vernier-doc/src/command/mod.rs @@ -528,3 +528,6 @@ mod variable_tests; mod organisation; pub use organisation::{EditOrganisation, OrganisationEdit}; + +pub(crate) mod sketch_text; +pub use sketch_text::{ConvertSketchText, SetSketchText}; diff --git a/crates/vernier-doc/src/command/sketch_text.rs b/crates/vernier-doc/src/command/sketch_text.rs new file mode 100644 index 0000000..c949886 --- /dev/null +++ b/crates/vernier-doc/src/command/sketch_text.rs @@ -0,0 +1,695 @@ +//! Authoritative text ownership and exact generated sketch geometry. + +use super::{Command, CommandContext, CommandOutput}; +use crate::document::DocState; +use crate::{ + DocError, EntityId, FeaturePayload, FontOutlineSpec, SketchCurve, SketchData, SketchText, + SketchTextContour, SketchTextSegment, TextContour, TextSegment, shape_text, +}; +use indexmap::IndexMap; +use std::collections::{BTreeMap, BTreeSet}; + +/// Adds or edits one text object. Empty text deletes an existing object. +#[derive(Debug, Clone)] +pub struct SetSketchText { + /// Owning sketch feature. + pub sketch: EntityId, + /// Existing text object, or None to allocate one. + pub text_id: Option, + /// Complete font, text and canonical placement. + pub spec: FontOutlineSpec, + /// Presentation catalogue key; font bytes remain authoritative. + pub font_key: String, + /// Presentation font label. + pub font_label: String, +} + +/// Releases the text definition, retaining exact geometry and identities. +#[derive(Debug, Clone)] +pub struct ConvertSketchText { + /// Owning sketch feature. + pub sketch: EntityId, + /// Text ownership record to remove. + pub text_id: EntityId, +} + +fn bad(message: impl Into) -> DocError { + DocError::Format(message.into()) +} +fn sketch<'a>(ctx: &'a CommandContext<'_>, id: EntityId) -> Result<&'a SketchData, DocError> { + match ctx.features().get(&id).map(|f| &f.payload) { + Some(FeaturePayload::Sketch(data)) => Ok(data), + _ => Err(DocError::UnknownEntity(id)), + } +} +fn metadata(text: &SketchText) -> Result<(), String> { + if text.font_key.trim().is_empty() + || text.font_key.len() > 4096 + || text.font_label.trim().is_empty() + || text.font_label.len() > 256 + { + return Err("Text requires a bounded font key and label".into()); + } + Ok(()) +} + +impl Command for SetSketchText { + fn apply(&self, ctx: &mut CommandContext<'_>) -> Result { + let current = sketch(ctx, self.sketch)?; + let previous = self + .text_id + .map(|id| { + current + .texts + .get(&id) + .cloned() + .ok_or(DocError::UnknownEntity(id)) + }) + .transpose()?; + if previous.is_none() && current.texts.len() >= 64 { + return Err(bad("At most 64 text objects per sketch")); + } + if self.spec.text.is_empty() { + let old = previous.ok_or_else(|| bad("New text cannot be empty"))?; + let FeaturePayload::Sketch(data) = &mut ctx.feature_mut(self.sketch)?.payload else { + unreachable!() + }; + remove_geometry(data, &old); + data.texts + .shift_remove(&self.text_id.ok_or_else(|| bad("Missing text identity"))?); + return Ok(CommandOutput::default()); + } + let shaped = shape_text(&self.spec).map_err(bad)?; + let mut text = SketchText { + spec: self.spec.clone(), + font_key: self.font_key.clone(), + font_label: self.font_label.clone(), + contours: Vec::new(), + }; + metadata(&text).map_err(bad)?; + let preserve = previous.as_ref().is_some_and(|old| { + old.spec.text == self.spec.text + && old.spec.font_data == self.spec.font_data + && old.spec.face_index == self.spec.face_index + && same_structure(&old.contours, &shaped) + }); + let mut created = Vec::new(); + let id = match self.text_id { + Some(id) => id, + None => { + let id = ctx.ids.allocate(); + created.push(id); + id + } + }; + if preserve { + text.contours = previous + .as_ref() + .ok_or_else(|| bad("Missing prior text"))? + .contours + .clone(); + } else { + for contour in &shaped { + let mut allocate = || { + let id = ctx.ids.allocate(); + created.push(id); + id + }; + let starts: Vec<_> = (0..contour.segments.len()).map(|_| allocate()).collect(); + let mut segments = Vec::new(); + for (index, segment) in contour.segments.iter().enumerate() { + let start = starts[index]; + let end = starts[(index + 1) % starts.len()]; + let points = match segment { + TextSegment::Line { .. } => vec![start, end], + TextSegment::Cubic { .. } if start == end => { + vec![start, allocate(), allocate()] + } + TextSegment::Cubic { .. } => vec![start, allocate(), allocate(), end], + }; + segments.push(SketchTextSegment { + curve: allocate(), + points, + }); + } + text.contours.push(SketchTextContour { + cluster: contour.cluster, + glyph_index: contour.glyph_index, + glyph_id: contour.glyph_id, + contour_index: contour.contour_index, + segments, + }); + } + } + let (points, curves) = expected_geometry(&text, &shaped).map_err(bad)?; + let FeaturePayload::Sketch(data) = &mut ctx.feature_mut(self.sketch)?.payload else { + unreachable!() + }; + if !preserve && let Some(previous) = &previous { + remove_geometry(data, previous); + } + for (id, value) in points { + data.points.insert(id, value); + } + for (id, value) in curves { + data.curves.insert(id, value); + } + data.texts.insert(id, text); + Ok(CommandOutput { + created, + ..CommandOutput::default() + }) + } +} + +impl Command for ConvertSketchText { + fn apply(&self, ctx: &mut CommandContext<'_>) -> Result { + sketch(ctx, self.sketch)?; + let FeaturePayload::Sketch(data) = &mut ctx.feature_mut(self.sketch)?.payload else { + unreachable!() + }; + data.texts + .shift_remove(&self.text_id) + .ok_or(DocError::UnknownEntity(self.text_id))?; + Ok(CommandOutput::default()) + } +} + +fn remove_geometry(data: &mut SketchData, text: &SketchText) { + for segment in text.contours.iter().flat_map(|c| &c.segments) { + data.curves.shift_remove(&segment.curve); + for point in &segment.points { + data.points.shift_remove(point); + } + } +} +fn descriptor(a: &SketchTextContour, b: &TextContour) -> bool { + (a.cluster, a.glyph_index, a.glyph_id, a.contour_index) + == (b.cluster, b.glyph_index, b.glyph_id, b.contour_index) +} +fn same_structure(owned: &[SketchTextContour], shaped: &[TextContour]) -> bool { + owned.len() == shaped.len() + && owned.iter().zip(shaped).all(|(a, b)| { + descriptor(a, b) + && a.segments.len() == b.segments.len() + && a.segments + .iter() + .zip(&b.segments) + .all(|(ids, segment)| match segment { + TextSegment::Line { .. } => ids.points.len() == 2, + TextSegment::Cubic { .. } => { + ids.points.len() == 4 + || (b.segments.len() == 1 && ids.points.len() == 3) + } + }) + }) +} + +type GeneratedGeometry = ( + IndexMap, + IndexMap, +); +fn expected_geometry( + text: &SketchText, + shaped: &[TextContour], +) -> Result { + if !same_structure(&text.contours, shaped) { + return Err("Text contour identity structure does not match the font outline".into()); + } + let mut points = IndexMap::new(); + let mut curves = IndexMap::new(); + let mut descriptors = BTreeSet::new(); + let mut point_roles = BTreeSet::new(); + for (owned, outline) in text.contours.iter().zip(shaped) { + if !descriptors.insert(( + owned.cluster, + owned.glyph_index, + owned.glyph_id, + owned.contour_index, + )) { + return Err("Duplicate text contour descriptor".into()); + } + for (index, (ids, segment)) in owned.segments.iter().zip(&outline.segments).enumerate() { + let exclusive = if ids.points.len() == 2 { + &ids.points[..1] + } else { + &ids.points[..3] + }; + if exclusive.iter().any(|id| !point_roles.insert(*id)) { + return Err( + "Text point identities may be shared only by adjacent segment endpoints".into(), + ); + } + let next = &owned.segments[(index + 1) % owned.segments.len()]; + let end = if ids.points.len() == 3 { + ids.points[0] + } else { + *ids.points.last().ok_or("Empty text segment")? + }; + if end != next.points[0] { + return Err("Text contours must share connected endpoint identities".into()); + } + let unique: BTreeSet<_> = ids.points.iter().collect(); + if unique.len() != ids.points.len() { + return Err("Text segment has aliased point identities".into()); + } + let (positions, curve) = match segment { + TextSegment::Line { start, end } => ( + vec![*start, *end], + SketchCurve::Line { + start: ids.points[0], + end: ids.points[1], + }, + ), + TextSegment::Cubic { controls } => { + let closed = ids.points.len() == 3; + if closed && controls[0] != controls[3] { + return Err("Closed text cubic does not close".into()); + } + ( + controls[..ids.points.len()].to_vec(), + SketchCurve::CubicSpline { + controls: ids.points.clone(), + closed, + tangents: [None, None], + }, + ) + } + }; + for (&id, position) in ids.points.iter().zip(positions) { + if id.raw() == 0 || position.iter().any(|v| !v.is_finite()) { + return Err("Invalid text point".into()); + } + if points + .insert(id, position) + .is_some_and(|old| old != position) + { + return Err("Text point identity aliases distinct coordinates".into()); + } + } + if ids.curve.raw() == 0 || curves.insert(ids.curve, curve).is_some() { + return Err("Duplicate text curve identity".into()); + } + } + } + if curves.keys().any(|id| points.contains_key(id)) { + return Err("Text curve and point identities collide".into()); + } + Ok((points, curves)) +} + +pub(crate) fn owned_entities( + data: &SketchData, +) -> Result<(BTreeSet, BTreeSet), String> { + let mut points = BTreeSet::new(); + let mut curves = BTreeSet::new(); + for text in data.texts.values() { + let mut local = BTreeSet::new(); + for segment in text.contours.iter().flat_map(|c| &c.segments) { + if !curves.insert(segment.curve) { + return Err("Text objects share a curve identity".into()); + } + local.extend(segment.points.iter().copied()); + } + for point in local { + if !points.insert(point) { + return Err("Text objects share a point identity".into()); + } + } + } + if points.iter().any(|id| curves.contains(id)) { + return Err("Text point and curve identities collide".into()); + } + Ok((points, curves)) +} + +pub(crate) fn validate_isolation( + data: &SketchData, + points: &BTreeSet, + curves: &BTreeSet, +) -> Result<(), String> { + if points.iter().any(|id| !data.points.contains_key(id)) + || curves.iter().any(|id| !data.curves.contains_key(id)) + { + return Err( + "Text-owned geometry is missing; convert text before editing its outline".into(), + ); + } + if data.construction.iter().any(|id| curves.contains(id)) + || data.references.keys().any(|id| points.contains(id)) + || data + .constraints + .iter() + .flat_map(|c| c.entities().into_iter().flatten()) + .any(|id| points.contains(&id) || curves.contains(&id)) + || data.curves.iter().any(|(id, curve)| { + !curves.contains(id) && curve.points().iter().any(|p| points.contains(p)) + }) + { + return Err("Convert text to curves before constraining, projecting, sharing or marking its outline construction".into()); + } + Ok(()) +} + +/// Validates at the command/load boundary. Unchanged definitions compare their +/// owned geometry against the already-admitted previous state, without shaping. +pub(crate) fn validate_state( + state: &DocState, + previous: Option<&DocState>, + ceiling: u64, +) -> Result<(), String> { + if !state + .features + .values() + .any(|f| matches!(&f.payload,FeaturePayload::Sketch(data) if !data.texts.is_empty())) + { + return Ok(()); + } + let mut definitions: BTreeMap = BTreeMap::new(); + let mut add = |id: EntityId| { + *definitions.entry(id).or_default() += 1; + }; + for (&id, feature) in &state.features { + add(id); + for body in feature.payload.created_bodies() { + add(body.entity()); + } + if let FeaturePayload::Sketch(data) = &feature.payload { + for &id in data.points.keys().chain(data.curves.keys()) { + add(id); + } + } + } + for id in state + .organisation + .folders + .iter() + .map(|x| x.id) + .chain(state.organisation.groups.iter().map(|x| x.id)) + .chain(state.organisation.views.iter().map(|x| x.id)) + { + add(id); + } + let mut text_ids = BTreeSet::new(); + let mut font_bytes = 0usize; + let mut segment_count = 0usize; + for (&sketch_id, feature) in &state.features { + let FeaturePayload::Sketch(data) = &feature.payload else { + continue; + }; + if data.texts.len() > 64 { + return Err("At most 64 text objects per sketch".into()); + } + let prior = previous + .and_then(|state| state.features.get(&sketch_id)) + .and_then(|f| { + if let FeaturePayload::Sketch(data) = &f.payload { + Some(data) + } else { + None + } + }); + let (points, curves) = owned_entities(data)?; + validate_isolation(data, &points, &curves)?; + for id in points.iter().chain(&curves) { + if id.raw() == 0 || id.raw() >= ceiling || definitions.get(id) != Some(&1) { + return Err( + "Text geometry identity is invalid or collides with another model entity" + .into(), + ); + } + } + for (&id, text) in data.texts.iter() { + if id.raw() == 0 + || id.raw() >= ceiling + || definitions.contains_key(&id) + || !text_ids.insert(id) + || state + .features + .values() + .any(|f| f.payload.face_refs().contains(&id.raw())) + { + return Err( + "Text identity is invalid or collides with another model entity".into(), + ); + } + metadata(text)?; + font_bytes = font_bytes + .checked_add(text.spec.font_data.len()) + .ok_or("Text font size overflow")?; + segment_count += text + .contours + .iter() + .map(|c| c.segments.len()) + .sum::(); + if font_bytes > 64 * 1024 * 1024 || segment_count > 65536 { + return Err("Document exceeds its editable text font/outline budget".into()); + } + let old = prior.and_then(|data| data.texts.get(&id)); + if let Some(old) = old + && (old.spec.text != text.spec.text + || old.spec.font_data != text.spec.font_data + || old.spec.face_index != text.spec.face_index) + { + let old_ids: BTreeSet<_> = old + .contours + .iter() + .flat_map(|c| &c.segments) + .flat_map(|segment| { + std::iter::once(segment.curve).chain(segment.points.iter().copied()) + }) + .collect(); + if text + .contours + .iter() + .flat_map(|c| &c.segments) + .any(|segment| { + old_ids.contains(&segment.curve) + || segment.points.iter().any(|id| old_ids.contains(id)) + }) + { + return Err("Changed text or font must allocate new outline identities".into()); + } + } + if let (Some(old), Some(prior)) = (old, prior) + && old.spec == text.spec + && old.contours == text.contours + { + for segment in text.contours.iter().flat_map(|c| &c.segments) { + if data.curves.get(&segment.curve) != prior.curves.get(&segment.curve) + || segment + .points + .iter() + .any(|id| data.points.get(id) != prior.points.get(id)) + { + return Err( + "Convert text to curves before editing its owned outline".into() + ); + } + } + } else { + let shaped = shape_text(&text.spec)?; + let (expected_points, expected_curves) = expected_geometry(text, &shaped)?; + if expected_points + .iter() + .any(|(id, value)| data.points.get(id) != Some(value)) + || expected_curves + .iter() + .any(|(id, value)| data.curves.get(id) != Some(value)) + { + return Err( + "Text geometry does not match its embedded font and definition".into(), + ); + } + } + } + } + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::{AddSketchFeature, Document, SetCurveConstruction}; + fn fixture() -> Option<(Document, EntityId, EntityId)> { + let Ok(font_data) = std::fs::read("/usr/share/fonts/TTF/DejaVuSans.ttf") else { + eprintln!("system DejaVu font unavailable; text document integration skipped"); + return None; + }; + let mut document = Document::new(); + let sketch = document + .execute(&AddSketchFeature { + name: "lettering".into(), + plane: Default::default(), + points: vec![], + curves: vec![], + constraints: vec![], + }) + .unwrap() + .created[0]; + let text = document + .execute(&SetSketchText { + sketch, + text_id: None, + spec: FontOutlineSpec { + text: "O".into(), + font_data, + face_index: 0, + height_mm: 10., + origin: [0., 0.], + rotation_radians: 0., + }, + font_key: "dejavu-sans".into(), + font_label: "DejaVu Sans".into(), + }) + .unwrap() + .created[0]; + Some((document, sketch, text)) + } + fn data(document: &Document, sketch: EntityId) -> &SketchData { + let FeaturePayload::Sketch(data) = &document.features()[&sketch].payload else { + panic!("sketch") + }; + data + } + fn edit(document: &Document, sketch: EntityId, text: EntityId) -> SetSketchText { + let old = &data(document, sketch).texts[&text]; + SetSketchText { + sketch, + text_id: Some(text), + spec: old.spec.clone(), + font_key: old.font_key.clone(), + font_label: old.font_label.clone(), + } + } + #[test] + fn text_placement_keeps_ids_content_replaces_ids_and_undo_restores() { + let Some((mut document, sketch, text)) = fixture() else { + return; + }; + let original = data(&document, sketch).clone(); + let ids = owned_entities(&original).unwrap(); + let mut change = edit(&document, sketch, text); + change.spec.origin = [11., 17.]; + change.spec.height_mm = 20.; + document.execute(&change).unwrap(); + assert_eq!(owned_entities(data(&document, sketch)).unwrap(), ids); + for (id, point) in &original.points { + let actual = data(&document, sketch).points[id]; + assert!((actual[0] - 11. - 2. * point[0]).abs() < 1e-10); + assert!((actual[1] - 17. - 2. * point[1]).abs() < 1e-10); + } + let positioned = data(&document, sketch).clone(); + change.spec.text = "B".into(); + document.execute(&change).unwrap(); + let replacement = owned_entities(data(&document, sketch)).unwrap(); + assert!(ids.0.is_disjoint(&replacement.0)); + assert!(ids.1.is_disjoint(&replacement.1)); + assert!(data(&document, sketch).texts.contains_key(&text)); + assert!(document.undo()); + assert_eq!(data(&document, sketch), &positioned); + assert!(document.undo()); + assert_eq!(data(&document, sketch), &original); + } + struct MoveOwned { + sketch: EntityId, + point: EntityId, + } + impl Command for MoveOwned { + fn apply(&self, ctx: &mut CommandContext<'_>) -> Result { + let FeaturePayload::Sketch(data) = &mut ctx.feature_mut(self.sketch)?.payload else { + unreachable!() + }; + data.points.get_mut(&self.point).unwrap()[0] += 1.; + Ok(CommandOutput::default()) + } + } + #[test] + fn text_is_solver_fixed_and_command_protected_until_convert() { + let Some((mut document, sketch, text)) = fixture() else { + return; + }; + let original = data(&document, sketch).clone(); + let (points, curves) = owned_entities(&original).unwrap(); + let point = *points.first().unwrap(); + let curve = *curves.first().unwrap(); + let mut solved = original.clone(); + let diagnostics = crate::solve_sketch(&mut solved).unwrap(); + assert_eq!(diagnostics.dof, 0); + assert_eq!(solved.points, original.points); + let before = document.to_json().unwrap(); + assert!(document.execute(&MoveOwned { sketch, point }).is_err()); + assert!( + document + .execute(&SetCurveConstruction { + feature: sketch, + curve, + construction: true + }) + .is_err() + ); + assert!( + document + .execute(&crate::SetSketchLock { + feature: sketch, + point, + x: 0., + y: 0. + }) + .is_err() + ); + assert_eq!(document.to_json().unwrap(), before); + document + .execute(&ConvertSketchText { + sketch, + text_id: text, + }) + .unwrap(); + assert!(data(&document, sketch).texts.is_empty()); + assert_eq!(data(&document, sketch).points, original.points); + assert_eq!(data(&document, sketch).curves, original.curves); + document.execute(&MoveOwned { sketch, point }).unwrap(); + assert!(document.undo()); + assert!(document.undo()); + assert_eq!(data(&document, sketch), &original); + } + #[test] + fn text_load_checks_geometry_identity_and_recovery_restores_undo() { + let Some((mut document, sketch, text)) = fixture() else { + return; + }; + let original = data(&document, sketch).clone(); + let json = document.to_json().unwrap(); + assert_eq!( + data(&Document::from_json(&json).unwrap(), sketch), + &original + ); + let mut forged: serde_json::Value = serde_json::from_str(&json).unwrap(); + let key = sketch.raw().to_string(); + let text_key = text.raw().to_string(); + let point = original.points.first().unwrap().0.raw().to_string(); + forged["state"]["features"][&key]["payload"]["Sketch"]["points"][&point][0] = + serde_json::json!(999.); + assert!(Document::from_json(&forged.to_string()).is_err()); + let mut forged: serde_json::Value = serde_json::from_str(&json).unwrap(); + let text_map = forged["state"]["features"][&key]["payload"]["Sketch"]["texts"] + .as_object_mut() + .unwrap(); + let definition = text_map.remove(&text_key).unwrap(); + text_map.insert(key.clone(), definition); + assert!(Document::from_json(&forged.to_string()).is_err()); + let mut change = edit(&document, sketch, text); + change.spec.origin = [2., 3.]; + document.execute(&change).unwrap(); + let mut restored = + Document::from_session_json(&document.to_session_json().unwrap()).unwrap(); + assert!(restored.undo()); + assert_eq!(data(&restored, sketch), &original); + change.spec.text.clear(); + document.execute(&change).unwrap(); + assert!(data(&document, sketch).texts.is_empty()); + assert!(data(&document, sketch).curves.is_empty()); + assert!(data(&document, sketch).points.is_empty()); + } +} diff --git a/crates/vernier-doc/src/document.rs b/crates/vernier-doc/src/document.rs index 7440e3e..08a3265 100644 --- a/crates/vernier-doc/src/document.rs +++ b/crates/vernier-doc/src/document.rs @@ -317,7 +317,7 @@ use crate::sketch::SketchCurve; /// Version 32 adds authored item organisation and saved views. /// Version 33 adds boolean retention and independent scale axes. /// Version 34 adds native face matching intent. -pub const DOCUMENT_FORMAT_VERSION: u32 = 38; +pub const DOCUMENT_FORMAT_VERSION: u32 = 39; /// The oldest save-format version this build migrates forward from. Three /// steps today — see the version entries above for why 25, 26 and 27 read up @@ -1154,6 +1154,7 @@ impl TryFrom for Document { } crate::bindings::validate(&raw.state).map_err(|error| error.to_string())?; let next = raw.ids.next_raw(); + crate::command::sketch_text::validate_state(&raw.state, None, next)?; raw.state.organisation.validate(next)?; let metadata_ids: std::collections::BTreeSet<_> = raw .state @@ -1649,6 +1650,14 @@ fn payload_ref_at_or_above(payload: &FeaturePayload, ceiling: u64) -> Option Ok(serde_json::from_str(text)?), + 38 => { + let migrated = crate::legacy::read_up_from_38(text).map_err(DocError::Format)?; + Self::from_json(&migrated) + } 35 => { let migrated = crate::legacy::read_up_from_35(text).map_err(DocError::Format)?; Self::from_json(&migrated) diff --git a/crates/vernier-doc/src/legacy.rs b/crates/vernier-doc/src/legacy.rs index 57cb6d6..58abc58 100644 --- a/crates/vernier-doc/src/legacy.rs +++ b/crates/vernier-doc/src/legacy.rs @@ -367,6 +367,17 @@ pub(crate) fn read_up_from_37(text: &str) -> Result { serde_json::to_string(&doc).map_err(|e| e.to_string()) } +/// Version 39 adds editable sketch text. Existing geometry and timeline order +/// are unchanged; the default text map is empty. +pub(crate) fn read_up_from_38(text: &str) -> Result { + let mut doc: OrderedDocument = serde_json::from_str(text).map_err(|error| error.to_string())?; + if doc.version != 38 { + return Err("expected document version 38".into()); + } + doc.version = 39; + serde_json::to_string(&doc).map_err(|error| error.to_string()) +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] diff --git a/crates/vernier-doc/src/lib.rs b/crates/vernier-doc/src/lib.rs index 05c62a8..8c7ed3c 100644 --- a/crates/vernier-doc/src/lib.rs +++ b/crates/vernier-doc/src/lib.rs @@ -123,3 +123,9 @@ pub use organisation::{ mod organisation_tests; pub use command::{EditSpline, SplineEdit}; + +/// Exact, bounded font shaping for authored sketch text. +pub mod text_outline; +pub use command::{ConvertSketchText, SetSketchText}; +pub use sketch::{SketchText, SketchTextContour, SketchTextSegment}; +pub use text_outline::{FontOutlineSpec, TextContour, TextSegment, shape_text}; diff --git a/crates/vernier-doc/src/sketch.rs b/crates/vernier-doc/src/sketch.rs index 718a507..5eb873e 100644 --- a/crates/vernier-doc/src/sketch.rs +++ b/crates/vernier-doc/src/sketch.rs @@ -24,6 +24,13 @@ use crate::command::MIRROR_SOURCES_MAX; #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SketchData { + /// Editable text objects; their owned outline geometry is immutable until conversion. + #[serde( + default, + skip_serializing_if = "IndexMap::is_empty", + deserialize_with = "deserialize_texts" + )] + pub texts: Box>, /// Which plane this sketch's `(u, v)` coordinates are measured in. /// /// **A RULE, NOT A FRAME**, and that is the whole design. See @@ -85,6 +92,93 @@ pub struct SketchData { pub construction: std::collections::BTreeSet, } +/// Editable text definition and the sketch entities it owns. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SketchText { + /// Embedded font, text and canonical placement. + pub spec: crate::text_outline::FontOutlineSpec, + /// Catalogue identity for presentation only; font bytes are authoritative. + pub font_key: String, + /// Human-readable font name. + pub font_label: String, + /// Native contour descriptors and allocated geometry identities. + pub contours: Vec, +} + +/// Identity mapping for one shaped glyph contour. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SketchTextContour { + /// UTF-8 source cluster offset. + pub cluster: u32, + /// Shaped glyph occurrence. + pub glyph_index: u32, + /// Font-local glyph number. + pub glyph_id: u16, + /// Native contour order within the glyph. + pub contour_index: u32, + /// Ordered primitive segments forming this closed contour. + pub segments: Vec, +} + +/// Allocated identities for one exact font-outline segment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SketchTextSegment { + /// Existing sketch line or cubic curve identity. + pub curve: EntityId, + /// Line endpoints or cubic poles; neighboring segments share endpoints. + pub points: Vec, +} + +impl SketchData { + /// Text object owning a point or curve, if it remains editable text. + #[must_use] + pub fn text_owner(&self, entity: EntityId) -> Option { + self.texts.iter().find_map(|(&id, text)| { + text.contours + .iter() + .flat_map(|c| &c.segments) + .any(|segment| segment.curve == entity || segment.points.contains(&entity)) + .then_some(id) + }) + } +} + +fn deserialize_texts<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct Texts; + impl<'de> serde::de::Visitor<'de> for Texts { + type Value = IndexMap; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("unique sketch text identities") + } + fn visit_map>( + self, + mut map: A, + ) -> Result { + let mut output = IndexMap::new(); + while let Some((id, text)) = map.next_entry()? { + if output.insert(id, text).is_some() { + return Err(serde::de::Error::custom("duplicate sketch text identity")); + } + if output.len() > 64 { + return Err(serde::de::Error::custom( + "at most 64 text objects per sketch", + )); + } + } + Ok(output) + } + } + deserializer.deserialize_map(Texts).map(Box::new) +} + /// What a sketch point can be driven by, outside its own sketch. /// /// **AN ENUM FROM DAY ONE WITH ONE VARIANT**, deliberately (card 0482acc1). @@ -2928,14 +3022,24 @@ pub(crate) struct SolverBridge { /// [`SketchError::UnknownEntity`] when a curve or a constraint names an id /// the sketch does not hold. pub(crate) fn to_solver(data: &SketchData) -> Result { + let (text_points, text_curves) = + crate::command::sketch_text::owned_entities(data).map_err(SketchError::Solve)?; + crate::command::sketch_text::validate_isolation(data, &text_points, &text_curves) + .map_err(SketchError::Solve)?; let mut solver_sketch = vernier_solver::Sketch::new(); let mut point_ids: IndexMap = IndexMap::new(); for (&id, &[x, y]) in &data.points { + if text_points.contains(&id) { + continue; + } point_ids.insert(id, solver_sketch.add_point(x, y)); } let mut curve_ids: IndexMap = IndexMap::new(); for (&id, curve) in &data.curves { + if text_curves.contains(&id) { + continue; + } let point = |id: EntityId| { point_ids .get(&id) @@ -2971,7 +3075,10 @@ pub(crate) fn to_solver(data: &SketchData) -> Result } // Cubic endpoint conditions are intrinsic constraints, like the arc's // equal-radius condition. They refer directly to the endpoint handles. - for curve in data.curves.values() { + for (&curve_id, curve) in &data.curves { + if text_curves.contains(&curve_id) { + continue; + } if let SketchCurve::CubicSpline { controls, closed, diff --git a/crates/vernier-doc/src/text_outline.rs b/crates/vernier-doc/src/text_outline.rs new file mode 100644 index 0000000..a686641 --- /dev/null +++ b/crates/vernier-doc/src/text_outline.rs @@ -0,0 +1,560 @@ +//! Bounded, single-run font shaping into exact CAD outline segments. +//! +//! Height is the font's em size, not its cap-height or bounding-box height. +//! Coordinates are millimetres, Y up. Rotation is about the baseline origin. +//! Placement never participates in contour identity. This module does not +//! allocate document identities, classify holes, or approximate curves. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use ttf_parser::OutlineBuilder; + +const MAX_TEXT_BYTES: usize = 16 * 1024; +const MAX_CHARACTERS: usize = 4096; +const MAX_FONT_BYTES: usize = 32 * 1024 * 1024; +const MAX_GLYPHS: usize = 16384; +const MAX_SEGMENTS: usize = 65536; +const MAX_POINTS: usize = 262144; +const MAX_COORDINATE: f64 = 1e9; + +/// Authored font and placement for one horizontal text run. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FontOutlineSpec { + /// UTF-8 text; newlines and bidi control characters are unsupported. + pub text: String, + /// Complete embedded OpenType/TrueType font or font collection bytes. + pub font_data: Vec, + /// Face within a font collection; zero for an ordinary font. + pub face_index: u32, + /// Em height in millimetres, positive and at most one million. + pub height_mm: f64, + /// Baseline origin in sketch coordinates, millimetres. + pub origin: [f64; 2], + /// Counter-clockwise baseline rotation, radians. + pub rotation_radians: f64, +} + +/// Exact outline primitive; quadratics are degree-elevated to cubic Beziers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum TextSegment { + /// Straight boundary segment. + Line { + /// Start point, millimetres. + start: [f64; 2], + /// End point, millimetres. + end: [f64; 2], + }, + /// Cubic Bezier in start/control/control/end order, millimetres. + Cubic { + /// Four exact Bezier poles. + controls: [[f64; 2]; 4], + }, +} + +/// A closed font contour with a placement-independent identity descriptor. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TextContour { + /// UTF-8 byte offset of the shaped glyph's cluster in the authored text. + pub cluster: u32, + /// Occurrence in the shaped visual glyph run, including whitespace glyphs. + pub glyph_index: u32, + /// Glyph number inside the embedded font, for diagnostic correlation. + pub glyph_id: u16, + /// Contour index in that glyph's native outline order. + pub contour_index: u32, + /// Connected, closed exact boundary segments in native traversal order. + pub segments: Vec, +} + +/// Shapes one directional/script run into exact, closed font contours. +/// +/// Neutral punctuation and whitespace inherit the run direction. Multiple +/// non-common scripts and mixed strong directions are refused because this +/// API performs no bidi/script itemization. Numeric characters inside RTL +/// runs also require itemization and are refused. Whitespace retains advance +/// without producing material. Default ignorables are removed by the shaper. +/// The font's default variation instance is used consistently for shaping and +/// outlines; size/placement changes preserve all contour descriptors. +/// +/// # Errors +/// Refuses malformed or excessive input, disallowed outline embedding, +/// missing glyphs, unsupported visible outlines, incomplete contours, and +/// non-finite or excessive output coordinates. Permissions must explicitly +/// permit installable or editable embedding; preview/print is insufficient. +pub fn shape_text(spec: &FontOutlineSpec) -> Result, String> { + if spec.text.is_empty() + || spec.text.len() > MAX_TEXT_BYTES + || spec.text.chars().count() > MAX_CHARACTERS + { + return Err("Text must contain 1–4096 characters within 16 KiB".into()); + } + if spec.font_data.is_empty() || spec.font_data.len() > MAX_FONT_BYTES { + return Err("Font data must be nonempty and no larger than 32 MiB".into()); + } + if !spec.height_mm.is_finite() + || !(0.0..=1e6).contains(&spec.height_mm) + || spec.height_mm == 0.0 + || !spec.rotation_radians.is_finite() + || spec + .origin + .iter() + .any(|v| !v.is_finite() || v.abs() > MAX_COORDINATE) + { + return Err( + "Text requires a finite positive em height and bounded finite placement".into(), + ); + } + let direction = run_direction(&spec.text)?; + let face = ttf_parser::Face::parse(&spec.font_data, spec.face_index) + .map_err(|error| format!("Cannot read font face: {error:?}"))?; + if !matches!( + face.permissions(), + Some(ttf_parser::Permissions::Installable | ttf_parser::Permissions::Editable) + ) || !face.is_outline_embedding_allowed() + { + return Err("Font must permit installable or editable outline embedding".into()); + } + let font = rustybuzz::Face::from_slice(&spec.font_data, spec.face_index) + .ok_or_else(|| "Cannot shape this font face".to_owned())?; + let mut buffer = rustybuzz::UnicodeBuffer::new(); + buffer.push_str(&spec.text); + buffer.guess_segment_properties(); + buffer.set_direction(direction); + buffer.set_flags(rustybuzz::BufferFlags::REMOVE_DEFAULT_IGNORABLES); + let shaped = rustybuzz::shape(&font, &[], buffer); + let infos = shaped.glyph_infos(); + if infos.is_empty() || infos.len() > MAX_GLYPHS || infos.len() != shaped.glyph_positions().len() + { + return Err("Text shaping produced an empty or excessive glyph run".into()); + } + let mut clusters: BTreeSet = infos.iter().map(|info| info.cluster as usize).collect(); + clusters.insert(spec.text.len()); + if infos + .iter() + .any(|info| info.cluster as usize >= spec.text.len()) + || clusters.iter().any(|&at| !spec.text.is_char_boundary(at)) + { + return Err("Font shaping returned an invalid text cluster".into()); + } + let scale = spec.height_mm / f64::from(face.units_per_em()); + let (sin, cos) = spec.rotation_radians.sin_cos(); + let mut pen = [0.0, 0.0]; + let mut budget = Budget::default(); + let mut output = Vec::new(); + for (occurrence, (info, position)) in infos.iter().zip(shaped.glyph_positions()).enumerate() { + let glyph = u16::try_from(info.glyph_id).map_err(|_| "Invalid shaped glyph ID")?; + if glyph == 0 { + return Err(format!( + "Font has no glyph for text cluster {}", + info.cluster + )); + } + let offset = [ + pen[0] + f64::from(position.x_offset), + pen[1] + f64::from(position.y_offset), + ]; + let mut builder = ContourBuilder::new(&mut budget); + let outlined = face.outline_glyph(ttf_parser::GlyphId(glyph), &mut builder); + if builder.failed || builder.current.is_some() { + return Err(format!( + "Glyph {glyph} has an invalid, unclosed or excessive outline" + )); + } + let contours = builder.contours; + if outlined.is_none() || contours.is_empty() { + let begin = info.cluster as usize; + let end = clusters + .range((begin + 1)..) + .next() + .copied() + .unwrap_or(spec.text.len()); + if !spec.text[begin..end].chars().all(char::is_whitespace) || !contours.is_empty() { + return Err(format!("Glyph {glyph} has no supported vector outline")); + } + } + let transform = |point: [f64; 2]| -> Result<[f64; 2], String> { + let x = (point[0] + offset[0]) * scale; + let y = (point[1] + offset[1]) * scale; + let point = [ + spec.origin[0] + cos * x - sin * y, + spec.origin[1] + sin * x + cos * y, + ]; + if point + .iter() + .any(|v| !v.is_finite() || v.abs() > MAX_COORDINATE) + { + return Err("Text outline exceeds the finite coordinate limit".into()); + } + Ok(point) + }; + for (contour_index, segments) in contours.into_iter().enumerate() { + let segments = segments + .into_iter() + .map(|segment| match segment { + TextSegment::Line { start, end } => Ok(TextSegment::Line { + start: transform(start)?, + end: transform(end)?, + }), + TextSegment::Cubic { controls } => Ok(TextSegment::Cubic { + controls: [ + transform(controls[0])?, + transform(controls[1])?, + transform(controls[2])?, + transform(controls[3])?, + ], + }), + }) + .collect::, String>>()?; + output.push(TextContour { + cluster: info.cluster, + glyph_index: u32::try_from(occurrence).map_err(|_| "Too many text glyphs")?, + glyph_id: glyph, + contour_index: u32::try_from(contour_index) + .map_err(|_| "Too many glyph contours")?, + segments, + }); + } + pen[0] += f64::from(position.x_advance); + pen[1] += f64::from(position.y_advance); + if pen + .iter() + .any(|v| !v.is_finite() || (v * scale).abs() > MAX_COORDINATE) + { + return Err("Text advance exceeds the finite coordinate limit".into()); + } + } + if output.is_empty() { + return Err("Text contains no visible vector contours".into()); + } + Ok(output) +} + +fn run_direction(text: &str) -> Result { + let mut script = None; + let mut direction = None; + let mut numeric = false; + for character in text.chars() { + if character.is_control() + || matches!(character, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{200e}' | '\u{200f}' | '\u{061c}') + { + return Err("Text must be one line without directional controls".into()); + } + numeric |= character.is_numeric(); + let mut scalar = rustybuzz::UnicodeBuffer::new(); + scalar.add(character, 0); + scalar.guess_segment_properties(); + let current = scalar.script(); + if matches!( + current, + rustybuzz::script::UNKNOWN | rustybuzz::script::COMMON | rustybuzz::script::INHERITED + ) { + continue; + } + let current_direction = scalar.direction(); + if direction.is_some_and(|previous| previous != current_direction) { + return Err("Mixed-direction text requires separate text features".into()); + } + if script.is_some_and(|previous| previous != current) { + return Err("Multiple-script text requires separate text features".into()); + } + direction = Some(current_direction); + script = Some(current); + } + let direction = direction.unwrap_or(rustybuzz::Direction::LeftToRight); + if direction == rustybuzz::Direction::RightToLeft && numeric { + return Err("Numbers within right-to-left text require separate text features".into()); + } + Ok(direction) +} + +#[derive(Default)] +struct Budget { + segments: usize, + points: usize, +} + +struct ContourBuilder<'a> { + budget: &'a mut Budget, + contours: Vec>, + current: Option<([f64; 2], [f64; 2], Vec)>, + failed: bool, +} + +impl<'a> ContourBuilder<'a> { + fn new(budget: &'a mut Budget) -> Self { + Self { + budget, + contours: Vec::new(), + current: None, + failed: false, + } + } + fn append(&mut self, segment: TextSegment, end: [f64; 2], points: usize) { + if self.failed { + return; + } + self.budget.segments += 1; + self.budget.points += points; + if self.budget.segments > MAX_SEGMENTS || self.budget.points > MAX_POINTS { + self.failed = true; + return; + } + if let Some((_, last, segments)) = &mut self.current { + *last = end; + segments.push(segment); + } else { + self.failed = true; + } + } + fn last(&mut self) -> Option<[f64; 2]> { + if self.current.is_none() { + self.failed = true; + } + self.current.as_ref().map(|(_, last, _)| *last) + } +} + +impl OutlineBuilder for ContourBuilder<'_> { + fn move_to(&mut self, x: f32, y: f32) { + if self.current.is_some() { + self.failed = true; + } + if self.failed { + return; + } + let point = [f64::from(x), f64::from(y)]; + self.current = Some((point, point, Vec::new())); + } + fn line_to(&mut self, x: f32, y: f32) { + let end = [f64::from(x), f64::from(y)]; + if let Some(start) = self.last() + && start != end + { + self.append(TextSegment::Line { start, end }, end, 2); + } + } + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + let end = [f64::from(x), f64::from(y)]; + let middle = [f64::from(x1), f64::from(y1)]; + if let Some(start) = self.last() { + let first = std::array::from_fn(|i| start[i] + (middle[i] - start[i]) * 2.0 / 3.0); + let second = std::array::from_fn(|i| end[i] + (middle[i] - end[i]) * 2.0 / 3.0); + self.append( + TextSegment::Cubic { + controls: [start, first, second, end], + }, + end, + 4, + ); + } + } + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + let end = [f64::from(x), f64::from(y)]; + if let Some(start) = self.last() { + self.append( + TextSegment::Cubic { + controls: [ + start, + [f64::from(x1), f64::from(y1)], + [f64::from(x2), f64::from(y2)], + end, + ], + }, + end, + 4, + ); + } + } + fn close(&mut self) { + if let Some((start, last, _)) = &self.current + && start != last + { + self.append( + TextSegment::Line { + start: *last, + end: *start, + }, + *start, + 2, + ); + } + if let Some((_, _, segments)) = self.current.take() { + if segments.is_empty() { + self.failed = true; + } else { + self.contours.push(segments); + } + } else { + self.failed = true; + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + fn font_spec(text: &str) -> Option { + let path = "/usr/share/fonts/TTF/DejaVuSans.ttf"; + let Ok(font_data) = std::fs::read(path) else { + eprintln!("system-font integration case skipped: {path} unavailable"); + return None; + }; + Some(FontOutlineSpec { + text: text.into(), + font_data, + face_index: 0, + height_mm: 10., + origin: [0., 0.], + rotation_radians: 0., + }) + } + fn points(segment: &TextSegment) -> Vec<[f64; 2]> { + match segment { + TextSegment::Line { start, end } => vec![*start, *end], + TextSegment::Cubic { controls } => controls.to_vec(), + } + } + #[test] + fn quadratic_degree_elevation_is_exact_and_closes() { + let mut budget = Budget::default(); + let mut builder = ContourBuilder::new(&mut budget); + builder.move_to(0., 0.); + builder.quad_to(3., 6., 6., 0.); + builder.close(); + assert!(!builder.failed); + assert_eq!( + builder.contours[0], + vec![ + TextSegment::Cubic { + controls: [[0., 0.], [2., 4.], [4., 4.], [6., 0.]] + }, + TextSegment::Line { + start: [6., 0.], + end: [0., 0.] + }, + ] + ); + let mut budget = Budget { + segments: MAX_SEGMENTS, + points: 0, + }; + let mut limited = ContourBuilder::new(&mut budget); + limited.move_to(0., 0.); + limited.line_to(1., 0.); + assert!(limited.failed); + } + #[test] + fn system_font_holes_ligatures_and_placement_keep_descriptors() { + let Some(mut spec) = font_spec("O ffi") else { + return; + }; + let original = shape_text(&spec).unwrap(); + assert_eq!( + original.iter().filter(|c| c.cluster == 0).count(), + 2, + "O has its outer and inner boundary" + ); + assert!( + original + .iter() + .flat_map(|c| &c.segments) + .any(|s| matches!(s, TextSegment::Cubic { .. })) + ); + let glyphs: BTreeSet<_> = original.iter().map(|c| c.glyph_index).collect(); + assert_eq!( + glyphs.len(), + 2, + "ffi must shape as one ligature, not three independently outlined characters" + ); + spec.height_mm = 20.; + spec.origin = [7., 11.]; + spec.rotation_radians = std::f64::consts::FRAC_PI_2; + let moved = shape_text(&spec).unwrap(); + assert_eq!(original.len(), moved.len()); + for (before, after) in original.iter().zip(&moved) { + assert_eq!( + ( + before.cluster, + before.glyph_index, + before.glyph_id, + before.contour_index + ), + ( + after.cluster, + after.glyph_index, + after.glyph_id, + after.contour_index + ) + ); + assert_eq!(before.segments.len(), after.segments.len()); + for (a, b) in before.segments.iter().zip(&after.segments) { + for (point, actual) in points(a).iter().zip(points(b)) { + assert!((actual[0] - (7. - 2. * point[1])).abs() < 1e-10); + assert!((actual[1] - (11. + 2. * point[0])).abs() < 1e-10); + } + } + } + } + #[test] + fn missing_glyph_mixed_runs_and_bad_size_refuse() { + assert!( + run_direction("Latin א") + .unwrap_err() + .contains("Mixed-direction") + ); + assert!(run_direction("אב12").is_err()); + assert!(run_direction("a\nb").is_err()); + assert_eq!( + run_direction("אב").unwrap(), + rustybuzz::Direction::RightToLeft + ); + let Some(mut spec) = font_spec("\u{10ffff}") else { + return; + }; + assert!(shape_text(&spec).unwrap_err().contains("no glyph")); + spec.text = "O".into(); + spec.height_mm = f64::NAN; + assert!(shape_text(&spec).is_err()); + spec.height_mm = 10.; + spec.text = "אב".into(); + assert!(!shape_text(&spec).unwrap().is_empty()); + spec.text = " ".into(); + assert!(shape_text(&spec).is_err()); + } + #[test] + fn font_embedding_permissions_are_authoritative() { + let Some(spec) = font_spec("O") else { + return; + }; + let count = u16::from_be_bytes([spec.font_data[4], spec.font_data[5]]) as usize; + let record = (0..count) + .map(|index| 12 + 16 * index) + .find(|&at| &spec.font_data[at..at + 4] == b"OS/2") + .unwrap(); + let offset = u32::from_be_bytes(spec.font_data[record + 8..record + 12].try_into().unwrap()) + as usize; + let version = u16::from_be_bytes([spec.font_data[offset], spec.font_data[offset + 1]]); + for (flags, allowed) in [ + (0u16, true), + (8, true), + (2, false), + (4, false), + // The bitmap-only bit was introduced in OS/2 version 2. + (0x200, version <= 1), + ] { + let mut changed = spec.clone(); + changed.font_data[offset + 8..offset + 10].copy_from_slice(&flags.to_be_bytes()); + assert_eq!( + shape_text(&changed).is_ok(), + allowed, + "OS/2 fsType {flags:#x}" + ); + } + } +} diff --git a/crates/vernier-ui/src/shell/mod.rs b/crates/vernier-ui/src/shell/mod.rs index e5dabb1..89fd350 100644 --- a/crates/vernier-ui/src/shell/mod.rs +++ b/crates/vernier-ui/src/shell/mod.rs @@ -18,6 +18,8 @@ use vernier_doc::{EntityId as DocEntityId, LoftSection, MIN_LOFT_SECTIONS}; // crate as `vernier_ui::shell::X` is re-exported here so `vernier-app` (and // anything else naming `shell::X`) sees no change in shape. mod bodies; +mod text_editor; +pub use text_editor::{SketchTextRow, TextEditorState, TextFontChoice, TextSubmission}; /// Body operation drafts, controls and viewport handles. pub mod body_tools; mod import; @@ -264,6 +266,8 @@ pub struct HoleSpecRow { /// What the shell renders from: a snapshot the document server sent. #[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct SceneView { + /// Authored text labels and editable placement, including recovery rows. + pub sketch_texts: Vec, /// Authored organisation and saved working views. pub organisation: vernier_doc::Organisation, /// Typed authored variable definitions. @@ -1586,6 +1590,12 @@ pub enum SketchCurveKind { /// values the toolbar's dimension fields edit. #[derive(Debug)] pub struct ShellState { + /// Discovered Linux font choices, local to this app session. + pub text_fonts: Vec, + /// Current text draft. + pub text_editor: TextEditorState, + /// One explicit text mutation from the panel. + pub text_submission: Option, /// Local project dashboard supplied by the app controller. pub projects: ProjectLibraryState, /// One explicit local project action. @@ -2138,6 +2148,9 @@ impl Default for ShellState { plane: vernier_doc::PrincipalPlane::Xy, sketching: false, tool: SketchTool::default(), + text_fonts: Vec::new(), + text_editor: TextEditorState::default(), + text_submission: None, spline_editor: SplineEditorState::default(), spline_editor_target: None, spline_edit: None, @@ -3319,6 +3332,39 @@ pub fn show(ui: &mut egui::Ui, view: &SceneView, state: &mut ShellState) -> Shel projects::panel(ui, state); view_controls::panel(ui, state); spline_editor_panel(ui, view, state, &mut response); + let text_sketch = view.active_sketch.or_else(|| { + state.selected_feature.filter(|id| { + view.timeline + .iter() + .any(|row| row.id == *id && row.kind == FeatureKind::Sketch) + }) + }); + let text_response = ui + .add_enabled_ui(!state.geometry_needs_rebuild, |ui| { + text_editor::show( + ui, + &state.text_fonts, + &view.sketch_texts, + text_sketch, + &mut state.text_editor, + ) + }) + .inner; + if let Some(request) = text_response { + state.text_submission = Some(request); + } + state + .body_tool_controls + .extend( + state + .text_editor + .controls + .iter() + .map(|hit| BodyToolControlRect { + control: format!("text:{}", hit.control), + rect: hit.rect, + }), + ); let palette_owned_input = state.command_bar.open; ribbon::command_bar(ui, state, theme, selection, view.unsaved, &mut response); // LAST, so it floats over everything: the card is anchored to the geometry @@ -3340,7 +3386,7 @@ pub fn show(ui: &mut egui::Ui, view: &SceneView, state: &mut ShellState) -> Shel // one lane not knowing about each other). `arms_the_card` refuses to // take a NEW aim here; this drops the one that was already standing. parameter_card::cancel(state); - } else if !palette_owned_input && !state.command_bar.open { + } else if !palette_owned_input && !state.command_bar.open && !state.text_editor.open { value_card::value_card(ui, view, state, theme, selection, &mut response); } diff --git a/crates/vernier-ui/src/shell/tests.rs b/crates/vernier-ui/src/shell/tests.rs index db4393a..693a3e8 100644 --- a/crates/vernier-ui/src/shell/tests.rs +++ b/crates/vernier-ui/src/shell/tests.rs @@ -13,6 +13,7 @@ use crate::testing::driven; fn view() -> SceneView { SceneView { + sketch_texts: Vec::new(), extrude_faces: Vec::new(), face_attached_sketches: Vec::new(), organisation: Default::default(), diff --git a/crates/vernier-ui/src/shell/text_editor.rs b/crates/vernier-ui/src/shell/text_editor.rs new file mode 100644 index 0000000..e367ee1 --- /dev/null +++ b/crates/vernier-ui/src/shell/text_editor.rs @@ -0,0 +1,343 @@ +//! Small stateful editor for sketch text. +//! +//! The panel owns only UI draft state. Font discovery, document commands, and +//! conversion are handled by the caller after [`TextSubmission`] is returned. + +use vernier_doc::EntityId; + +/// A font the application discovered from its configured font sources. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextFontChoice { + /// Stable document-facing font key. + pub key: String, + /// Human-readable family label. + pub label: String, +} + +/// A text entity already present in the active sketch. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SketchTextRow { + /// Authored text entity identity. + pub id: EntityId, + /// Owning sketch identity. + pub sketch: EntityId, + /// Text content. + pub text: String, + /// Resolved display family label, retained even when unavailable. + pub font_label: String, + /// Authored stable font key. + pub font_key: String, + /// Text height in millimetres. + pub height_mm: f64, + /// Origin in sketch coordinates. + pub origin: [f64; 2], + /// Rotation in canonical radians. + pub rotation_radians: f64, +} + +/// The UI request emitted by Apply. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct TextSubmission { + /// Owning sketch identity. + pub sketch: EntityId, + /// Existing text identity, or `None` for new text. + pub text_id: Option, + /// Authored content. + pub text: String, + /// Explicit stable font key; no fallback is selected here. + pub font_key: String, + /// Text height in millimetres. + pub height_mm: f64, + /// Origin in sketch coordinates. + pub origin: [f64; 2], + /// Rotation in canonical radians. + pub rotation_radians: f64, + /// Whether an existing text should be converted to sketch curves. + pub convert_to_curves: bool, +} + +/// Persistent draft state for the text panel. +#[derive(Debug, Clone)] +pub struct TextEditorState { + /// Whether the editor window is open. + pub open: bool, + /// Sketch currently being edited. + pub target_sketch: Option, + /// Existing text being edited, or `None` for a new text entity. + pub target_text: Option, + /// Draft content. + pub text: String, + /// Draft font key. + pub font_key: String, + /// Draft display label, retained for unavailable fonts. + pub font_label: String, + /// Search text for the explicit font list. + pub font_search: String, + /// Show the filtered font list inside this editor. + pub font_list_open: bool, + /// Draft height in millimetres. + pub height_mm: f64, + /// Draft origin. + pub origin: [f64; 2], + /// Draft rotation in degrees for the UI boundary. + pub rotation_deg: f64, + /// Existing-text conversion choice. + pub convert_to_curves: bool, + /// Actual widget rectangles for the driven UI harness. + pub controls: Vec, +} + +impl Default for TextEditorState { + fn default() -> Self { + Self { + open: false, + target_sketch: None, + target_text: None, + text: String::new(), + font_key: String::new(), + font_label: String::new(), + font_search: String::new(), + font_list_open: false, + height_mm: 10.0, + origin: [0.0, 0.0], + rotation_deg: 0.0, + convert_to_curves: false, + controls: Vec::new(), + } + } +} + +impl TextEditorState { + fn new_text(&mut self, sketch: EntityId) { + self.open = true; + self.target_sketch = Some(sketch); + self.target_text = None; + self.text.clear(); + self.font_key.clear(); + self.font_label.clear(); + self.font_search.clear(); + self.height_mm = 10.0; + self.origin = [0.0, 0.0]; + self.rotation_deg = 0.0; + self.convert_to_curves = false; + } + + fn edit_existing(&mut self, row: &SketchTextRow) { + self.open = true; + self.target_sketch = Some(row.sketch); + self.target_text = Some(row.id); + self.text.clone_from(&row.text); + self.font_key.clone_from(&row.font_key); + self.font_label.clone_from(&row.font_label); + self.height_mm = row.height_mm; + self.origin = row.origin; + self.rotation_deg = row.rotation_radians.to_degrees(); + self.convert_to_curves = false; + } +} + +/// Paint the sketch text panel and return one explicit request when Apply is pressed. +pub fn show( + ui: &mut egui::Ui, + fonts: &[TextFontChoice], + rows: &[SketchTextRow], + active_sketch: Option, + state: &mut TextEditorState, +) -> Option { + state.controls.clear(); + let mut submission = None; + let can_open = active_sketch.is_some(); + let text_button = ui.add_enabled(can_open, egui::Button::new("Text")); + state.controls.push(super::BodyToolControlRect { + control: "Text".into(), + rect: text_button.rect, + }); + if text_button.clicked() + && let Some(sketch) = active_sketch + { + state.new_text(sketch); + } + if !state.open { + return None; + } + let Some(sketch) = state.target_sketch.or(active_sketch) else { + state.open = false; + return None; + }; + state.target_sketch = Some(sketch); + + let mut window_open = true; + egui::Window::new("Sketch text") + .collapsible(false) + .resizable(false) + .open(&mut window_open) + .show(ui.ctx(), |ui| { + ui.set_min_width(280.0); + ui.label(if state.target_text.is_some() { + "Edit text" + } else { + "New text" + }); + + let candidates: Vec<&SketchTextRow> = + rows.iter().filter(|row| row.sketch == sketch).collect(); + if !candidates.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.label("Existing:"); + for row in candidates { + if ui + .selectable_label(state.target_text == Some(row.id), &row.text) + .clicked() + { + state.edit_existing(row); + } + } + }); + } + + ui.label("Content"); + let content = ui.text_edit_singleline(&mut state.text); + state.controls.push(super::BodyToolControlRect { + control: "content".into(), + rect: content.rect, + }); + ui.label("Font search"); + let search = ui.text_edit_singleline(&mut state.font_search); + state.controls.push(super::BodyToolControlRect { + control: "font search".into(), + rect: search.rect, + }); + let font_picker = ui.button(if state.font_label.is_empty() { + "Choose font" + } else { + &state.font_label + }); + state.controls.push(super::BodyToolControlRect { + control: "font selector".into(), + rect: font_picker.rect, + }); + if font_picker.clicked() { + state.font_list_open = !state.font_list_open; + } + if state.font_list_open { + egui::ScrollArea::vertical() + .id_salt("text-font-list") + .max_height(150.0) + .show(ui, |ui| { + for font in fonts.iter().filter(|font| { + state.font_search.trim().is_empty() + || font + .label + .to_lowercase() + .contains(&state.font_search.trim().to_lowercase()) + }) { + let choice = + ui.selectable_label(state.font_key == font.key, &font.label); + state.controls.push(super::BodyToolControlRect { + control: format!("font:{}", font.key), + rect: choice.rect, + }); + if choice.clicked() { + state.font_key.clone_from(&font.key); + state.font_label.clone_from(&font.label); + state.font_list_open = false; + } + } + }); + } + let height = ui.add( + egui::DragValue::new(&mut state.height_mm) + .prefix("height ") + .suffix(" mm") + .range(0.01..=f64::MAX), + ); + state.controls.push(super::BodyToolControlRect { + control: "height".into(), + rect: height.rect, + }); + ui.horizontal(|ui| { + let x = ui.add(egui::DragValue::new(&mut state.origin[0]).prefix("X ")); + state.controls.push(super::BodyToolControlRect { + control: "X".into(), + rect: x.rect, + }); + let y = ui.add(egui::DragValue::new(&mut state.origin[1]).prefix("Y ")); + state.controls.push(super::BodyToolControlRect { + control: "Y".into(), + rect: y.rect, + }); + }); + let rotation = ui.add( + egui::DragValue::new(&mut state.rotation_deg) + .prefix("rotation ") + .suffix("°"), + ); + state.controls.push(super::BodyToolControlRect { + control: "rotation".into(), + rect: rotation.rect, + }); + if state.target_text.is_some() { + let convert = ui.checkbox(&mut state.convert_to_curves, "Convert to curves"); + state.controls.push(super::BodyToolControlRect { + control: "convert to curves".into(), + rect: convert.rect, + }); + } + let valid = !state.text.trim().is_empty() && !state.font_key.trim().is_empty(); + if !valid { + ui.label("Enter text and choose a font before applying."); + } + ui.horizontal(|ui| { + let apply = ui.add_enabled(valid, egui::Button::new("Apply")); + state.controls.push(super::BodyToolControlRect { + control: "apply".into(), + rect: apply.rect, + }); + if apply.clicked() { + submission = Some(TextSubmission { + sketch, + text_id: state.target_text, + text: state.text.clone(), + font_key: state.font_key.clone(), + height_mm: state.height_mm, + origin: state.origin, + rotation_radians: state.rotation_deg.to_radians(), + convert_to_curves: state.convert_to_curves, + }); + state.open = false; + } + if state.target_text.is_some() && state.text.trim().is_empty() { + let delete = ui.button("Delete text"); + state.controls.push(super::BodyToolControlRect { + control: "delete".into(), + rect: delete.rect, + }); + if delete.clicked() { + submission = Some(TextSubmission { + sketch, + text_id: state.target_text, + text: String::new(), + font_key: state.font_key.clone(), + height_mm: state.height_mm, + origin: state.origin, + rotation_radians: state.rotation_deg.to_radians(), + convert_to_curves: false, + }); + state.open = false; + } + } + let cancel = ui.button("Cancel"); + state.controls.push(super::BodyToolControlRect { + control: "cancel".into(), + rect: cancel.rect, + }); + if cancel.clicked() { + state.open = false; + } + }); + }); + if !window_open { + state.open = false; + } + submission +} diff --git a/docs/MECHANICAL_MODELLING_PROGRESS_2026-09-09.md b/docs/MECHANICAL_MODELLING_PROGRESS_2026-09-09.md index 97af87a..56f963c 100644 --- a/docs/MECHANICAL_MODELLING_PROGRESS_2026-09-09.md +++ b/docs/MECHANICAL_MODELLING_PROGRESS_2026-09-09.md @@ -90,3 +90,28 @@ Reports: /tmp/vernier-thin-closed-final/report.json and /tmp/vernier-thin-open-final2/report.json. Affected-crate clippy passed in 3.69 s; helper/driver builds take approximately 17–20 s on this batch. No broad gate or installation. The discovered same-card re-arm issue now preserves draft buffers. + +## Batch 4 — editable sketch text + +Format 39 embeds the explicitly selected Linux font with text, em height, +placement and outline ownership. Native lines and exact cubic Beziers form the +sketch profiles. Placement/size edits retain curve and point IDs; text/font +changes deliberately allocate new outline IDs. Convert to curves preserves the +existing geometry. Owned outlines are solver-fixed and reject direct geometry +or constraint changes until conversion. Document transitions clear UI drafts; +worker snapshots preserve authored text and validate displayed rows. + +Approved fontdb/rustybuzz/ttf-parser dependencies are now integrated. Fonts must +permit editable or installable outline embedding. Missing glyphs, mixed script/ +direction runs and unsupported outlines refuse explicitly. Font search uses a +visible filtered list; existing documents retain their embedded source. + +Directed evidence: four shaping checks pass; three command/identity/ownership/ +recovery checks pass (0.91 s); 27 codec checks pass (0.07 s), including corrected +synthetic support-face fixtures. Affected-crate clippy passed in 3.42 s. +The real-control text workflow passes cross-process determinism on RADV: +/tmp/vernier-text-drive6/report.json. Its independent TTF oracle for DejaVuSans +capital I has area 301586/2048^2 square ems. Em height 10 extruded 5 gives +35.951852798461914 mm3; doubling text size gives four times that volume. Undo, +save/reopen and conversion preserve the prescribed geometry. Release helper/ +driver build took 19.30 s. No broad gate or installation yet. diff --git a/scripts/check.fish b/scripts/check.fish index 6170e98..c1bd6f0 100755 --- a/scripts/check.fish +++ b/scripts/check.fish @@ -337,3 +337,6 @@ and cargo run -q -p vernier-drive -- scripts/drive/thin-wall-extrusion.json \ --out target/drive/thin-wall-extrusion --require-adapter RADV and cargo run -q -p vernier-drive -- scripts/drive/thin-wall-open-chain.json \ --out target/drive/thin-wall-open-chain --require-adapter RADV + +and cargo run -q -p vernier-drive -- scripts/drive/sketch-text.json \ + --out target/drive/sketch-text --require-adapter RADV diff --git a/scripts/drive/sketch-text.json b/scripts/drive/sketch-text.json new file mode 100644 index 0000000..b318424 --- /dev/null +++ b/scripts/drive/sketch-text.json @@ -0,0 +1,318 @@ +{ + "name": "sketch-text-outline-history", + "document": "empty", + "size": [ + 1600, + 1000 + ], + "camera": { + "target": [ + 0.0, + 0.0, + 0.0 + ], + "distance": 60.0 + }, + "notes": [ + "The authored glyph is capital I from the selected DejaVu Sans face. Its independent TTF oracle uses unitsPerEm=2048 and outline points [(201,1493),(403,1493),(403,0),(201,0)], giving 301586 font-unit squared; at em height 10 and extrusion 5 the expected volume is 35.951852798461914 mm3.", + "Changing only the authored em height from 10 to 20 preserves the text and sketch identities and multiplies the outline volume by four: 143.80741119384766 mm3. The text editor keeps the selected font key instead of silently substituting an unavailable face.", + "The final conversion to curves is applied through the existing text editor control and is checked by the unchanged STEP volume oracle.", + "The font chooser key pins the exact locally installed DejaVuSans font bytes used by the independent glyph oracle; font substitution is not accepted." + ], + "steps": [ + { + "step": "wait_idle" + }, + { + "step": "click", + "at": "ribbon:sketch" + }, + { + "step": "wait_idle" + }, + { + "step": "click", + "at": "text:Text" + }, + { + "step": "frames", + "count": 2 + }, + { + "step": "click", + "at": "body-tool:text:content" + }, + { + "step": "key", + "key": "ctrl+a" + }, + { + "step": "type", + "text": "I" + }, + { + "step": "click", + "at": "body-tool:text:font search" + }, + { + "step": "type", + "text": "DejaVu Sans \u00b7 DejaVuSans" + }, + { + "step": "frames", + "count": 2 + }, + { + "step": "click", + "at": "body-tool:text:font selector" + }, + { + "step": "frames", + "count": 2 + }, + { + "step": "click", + "at": "body-tool:text:font:font:c7f4d457028661d3:0" + }, + { + "step": "click", + "at": "body-tool:text:apply" + }, + { + "step": "wait_idle" + }, + { + "step": "expect_no_error" + }, + { + "step": "click", + "at": "timeline:0" + }, + { + "step": "frames", + "count": 2 + }, + { + "step": "click", + "at": "region:Region 1" + }, + { + "step": "click", + "at": "tab:solid" + }, + { + "step": "frames", + "count": 2 + }, + { + "step": "click", + "at": "chip:New body" + }, + { + "step": "frames", + "count": 2 + }, + { + "step": "click", + "at": "card:height" + }, + { + "step": "key", + "key": "ctrl+a" + }, + { + "step": "type", + "text": "5" + }, + { + "step": "key", + "key": "Enter" + }, + { + "step": "wait_idle" + }, + { + "step": "expect_no_error" + }, + { + "step": "export_step", + "path": "{out}/text-height10.step" + }, + { + "step": "wait_idle" + }, + { + "step": "expect_step", + "path": "{out}/text-height10.step", + "volume": 35.951852798461914, + "solids": 1, + "tol": 1e-09 + }, + { + "step": "expect_selection", + "is": "feature:sketch" + }, + { + "step": "click", + "at": "text:Text" + }, + { + "step": "frames", + "count": 2 + }, + { + "step": "click", + "at": "text:I" + }, + { + "step": "click", + "at": "body-tool:text:height" + }, + { + "step": "key", + "key": "ctrl+a" + }, + { + "step": "type", + "text": "20" + }, + { + "step": "key", + "key": "Enter" + }, + { + "step": "click", + "at": "body-tool:text:apply" + }, + { + "step": "wait_idle" + }, + { + "step": "export_step", + "path": "{out}/text-height20.step" + }, + { + "step": "wait_idle" + }, + { + "step": "expect_step", + "path": "{out}/text-height20.step", + "volume": 143.80741119384766, + "solids": 1, + "tol": 1e-09 + }, + { + "step": "key", + "key": "ctrl+z" + }, + { + "step": "wait_idle" + }, + { + "step": "expect_no_error" + }, + { + "step": "export_step", + "path": "{out}/text-size-undo.step" + }, + { + "step": "wait_idle" + }, + { + "step": "expect_step", + "path": "{out}/text-size-undo.step", + "volume": 35.951852798461914, + "solids": 1, + "tol": 1e-09 + }, + { + "step": "save", + "path": "{out}/text.vernier" + }, + { + "step": "wait_idle" + }, + { + "step": "click", + "at": "text:$" + }, + { + "step": "frames", + "count": 2 + }, + { + "step": "type", + "text": "open" + }, + { + "step": "click", + "at": "text:open / export path" + }, + { + "step": "key", + "key": "ctrl+a" + }, + { + "step": "type_path", + "path": "{out}/text.vernier" + }, + { + "step": "key", + "key": "Enter" + }, + { + "step": "wait_idle" + }, + { + "step": "expect_no_error" + }, + { + "step": "click", + "at": "timeline:0" + }, + { + "step": "frames", + "count": 2 + }, + { + "step": "click", + "at": "text:Text" + }, + { + "step": "frames", + "count": 2 + }, + { + "step": "click", + "at": "text:I" + }, + { + "step": "click", + "at": "body-tool:text:convert to curves" + }, + { + "step": "click", + "at": "body-tool:text:apply" + }, + { + "step": "wait_idle" + }, + { + "step": "expect_no_error" + }, + { + "step": "export_step", + "path": "{out}/text-curves.step" + }, + { + "step": "wait_idle" + }, + { + "step": "expect_step", + "path": "{out}/text-curves.step", + "volume": 35.951852798461914, + "solids": 1, + "tol": 1e-09 + } + ] +}