Add editable sketch text with embedded Linux font outlines

This commit is contained in:
2026-09-09 23:03:19 +02:00
parent 16ecff8398
commit 44aa92cf93
30 changed files with 2560 additions and 13 deletions
Generated
+101
View File
@@ -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",
]
+4
View File
@@ -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"
+2
View File
@@ -10,6 +10,8 @@ repository.workspace = true
publish.workspace = true
[dependencies]
fontdb.workspace = true
anyhow.workspace = true
egui.workspace = true
egui-wgpu.workspace = true
+29 -1
View File
@@ -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;
+6
View File
@@ -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<u8>, 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.
+1
View File
@@ -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;
@@ -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()
)
@@ -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();
@@ -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"));
}
@@ -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,
+10
View File
@@ -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,
@@ -66,6 +66,61 @@ pub(crate) fn apply(server: &mut DocumentServer, edit: &Edit) -> Result<bool, St
_ => {}
}
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 {
@@ -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(),
+3 -1
View File
@@ -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 { .. }
+35 -6
View File
@@ -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<vernier_ui::shell::SketchTextRow> {
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()
}
+119
View File
@@ -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<String, FontEntry>,
choices: Vec<TextFontChoice>,
}
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<TextFontChoice> {
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<u8>, 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}")
}
+3
View File
@@ -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.
+3
View File
@@ -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};
@@ -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<EntityId>,
/// 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<String>) -> 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<CommandOutput, DocError> {
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<CommandOutput, DocError> {
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<EntityId, [f64; 2]>,
IndexMap<EntityId, SketchCurve>,
);
fn expected_geometry(
text: &SketchText,
shaped: &[TextContour],
) -> Result<GeneratedGeometry, String> {
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<EntityId>, BTreeSet<EntityId>), 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<EntityId>,
curves: &BTreeSet<EntityId>,
) -> 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<EntityId, usize> = 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::<usize>();
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<CommandOutput, DocError> {
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());
}
}
+22 -1
View File
@@ -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<RawDocument> 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<u64
.keys()
.copied()
.chain(data.curves.keys().copied())
.chain(data.texts.keys().copied())
.chain(data.texts.values().flat_map(|text| {
text.contours.iter().flat_map(|c| {
c.segments
.iter()
.flat_map(|s| std::iter::once(s.curve).chain(s.points.iter().copied()))
})
}))
.chain(data.curves.values().flat_map(SketchCurve::points))
.chain(
data.constraints
@@ -1920,6 +1929,8 @@ impl Document {
insertion_before: self.rollback,
};
let output = command.apply(&mut ctx)?;
crate::command::sketch_text::validate_state(&next, Some(&self.state), self.ids.next_raw())
.map_err(DocError::Format)?;
if !command.preserves_parameter_bindings() {
crate::bindings::detach_changed(&self.state, &mut next);
}
@@ -1970,6 +1981,12 @@ impl Document {
insertion_before: self.rollback,
};
outputs.push(command.apply(&mut ctx)?);
crate::command::sketch_text::validate_state(
&next,
Some(&previous),
self.ids.next_raw(),
)
.map_err(DocError::Format)?;
if !command.preserves_parameter_bindings() {
crate::bindings::detach_changed(&previous, &mut next);
}
@@ -2154,6 +2171,10 @@ impl Document {
let header: Header = serde_json::from_str(text)?;
match header.version {
DOCUMENT_FORMAT_VERSION => 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)
+11
View File
@@ -367,6 +367,17 @@ pub(crate) fn read_up_from_37(text: &str) -> Result<String, String> {
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<String, String> {
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)]
+6
View File
@@ -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};
+108 -1
View File
@@ -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<IndexMap<EntityId, SketchText>>,
/// 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<EntityId>,
}
/// 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<SketchTextContour>,
}
/// 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<SketchTextSegment>,
}
/// 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<EntityId>,
}
impl SketchData {
/// Text object owning a point or curve, if it remains editable text.
#[must_use]
pub fn text_owner(&self, entity: EntityId) -> Option<EntityId> {
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<Box<IndexMap<EntityId, SketchText>>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Texts;
impl<'de> serde::de::Visitor<'de> for Texts {
type Value = IndexMap<EntityId, SketchText>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("unique sketch text identities")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<Self::Value, A::Error> {
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<SolverBridge, SketchError> {
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<EntityId, vernier_solver::PointId> = 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<EntityId, vernier_solver::EntityId> = 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<SolverBridge, SketchError>
}
// 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,
+560
View File
@@ -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<u8>,
/// 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<TextSegment>,
}
/// 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<Vec<TextContour>, 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<usize> = 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::<Result<Vec<_>, 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<rustybuzz::Direction, String> {
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<Vec<TextSegment>>,
current: Option<([f64; 2], [f64; 2], Vec<TextSegment>)>,
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<FontOutlineSpec> {
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}"
);
}
}
}
+47 -1
View File
@@ -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<SketchTextRow>,
/// 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<TextFontChoice>,
/// Current text draft.
pub text_editor: TextEditorState,
/// One explicit text mutation from the panel.
pub text_submission: Option<TextSubmission>,
/// 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);
}
+1
View File
@@ -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(),
+343
View File
@@ -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<EntityId>,
/// 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<EntityId>,
/// Existing text being edited, or `None` for a new text entity.
pub target_text: Option<EntityId>,
/// 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<super::BodyToolControlRect>,
}
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<EntityId>,
state: &mut TextEditorState,
) -> Option<TextSubmission> {
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
}
@@ -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.
+3
View File
@@ -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
+318
View File
@@ -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
}
]
}