Add editable sketch text with embedded Linux font outlines
This commit is contained in:
Generated
+101
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -10,6 +10,8 @@ repository.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fontdb.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
egui.workspace = true
|
||||
egui-wgpu.workspace = true
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 { .. }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user