mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 02:55:54 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cbf328bab | |||
| 3559964792 | |||
| dfb0b3e113 | |||
| cd39f96d33 | |||
| 67cbfe941e | |||
| 45286e0489 | |||
| 5f127aceb0 | |||
| e717739735 | |||
| 28d90fae50 | |||
| 7b18fd5c0f | |||
| 992466e76a | |||
| c46fd3acd4 | |||
| 7d66a4d0ab | |||
| 0b3bc6e968 | |||
| d8e7a8690a | |||
| 6b39ee3686 | |||
| a7f3c78b07 | |||
| 471e17f50e | |||
| 1891344e07 | |||
| 1e176d8503 | |||
| fac0043543 | |||
| 119c40c814 |
@@ -40,6 +40,8 @@ pub enum Intent {
|
||||
Open,
|
||||
/// Add its bodies to the open design (STEP, 3MF, STL).
|
||||
Insert,
|
||||
/// Import it as a theme: it was dropped on Preferences' Import tile (#8226).
|
||||
Theme,
|
||||
}
|
||||
|
||||
/// A stored design, as the file panel lists it.
|
||||
@@ -615,13 +617,15 @@ fn choose_file(accept: &str) -> Result<js_sys::Promise, String> {
|
||||
}
|
||||
|
||||
/// Read files dropped on the page.
|
||||
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
|
||||
/// `theme`: the pointer is over Preferences' Import tile, so the files are themes.
|
||||
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput, theme: bool) {
|
||||
let intent = if theme { Intent::Theme } else { Intent::Insert };
|
||||
for f in std::mem::take(&mut raw.dropped_files) {
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(b) => files.arrive(name, b, Intent::Insert),
|
||||
Ok(b) => files.arrive(name, b, intent),
|
||||
Err(e) => files.say(format!("{name}: {e}"), true),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -271,6 +271,10 @@ mod web {
|
||||
!companion
|
||||
});
|
||||
for (name, bytes, intent) in incoming {
|
||||
if intent == Intent::Theme {
|
||||
solidcraft_ui_egui::prefs::import_theme_bytes(&mut self.app, bytes);
|
||||
continue;
|
||||
}
|
||||
let design = solidcraft_engine::io::is_design_path(&name);
|
||||
let path = if design && intent == Intent::Open {
|
||||
// A design from the computer is kept in the browser (a copy), so Save works.
|
||||
@@ -287,7 +291,7 @@ mod web {
|
||||
};
|
||||
match intent {
|
||||
Intent::Open => self.app.open_path(&path),
|
||||
Intent::Insert => self.app.drop_path(&path),
|
||||
Intent::Insert | Intent::Theme => self.app.drop_path(&path),
|
||||
}
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
@@ -632,7 +636,8 @@ mod web {
|
||||
ctx.request_repaint_after(delay);
|
||||
}
|
||||
if self.launch.persists() {
|
||||
files::take_drops(&self.files, raw);
|
||||
let theme = solidcraft_ui_egui::prefs::theme_drop_target(&self.app, ctx);
|
||||
files::take_drops(&self.files, raw, theme);
|
||||
} else {
|
||||
raw.dropped_files.clear();
|
||||
}
|
||||
|
||||
+2597
-4
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
||||
//! Nine-alignment TEXT adapter over the real parsed Ent. No parser or glyph layout.
|
||||
//! Registration and the production call remain with the parent dxf module owner.
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use super::text_value::decode_text_value;
|
||||
use super::{Ent, MAX_WORK, layered_array_budget, layered_number, layered_required, layered_text};
|
||||
use crate::dxf_out::{DxfTextHorizontalAlignment, DxfTextVerticalAlignment};
|
||||
use crate::{DxfShape, IoError, MAX_DRAWING_BYTES, Result};
|
||||
|
||||
const MAX_TEXT_ENCODED_BYTES: usize = 250 * 7;
|
||||
const MAX_TEXT_DECODED_BYTES: usize = 250 * 3;
|
||||
|
||||
fn invalid(reason: &str) -> IoError {
|
||||
IoError::Invalid(format!("layered DXF TEXT: {reason}"))
|
||||
}
|
||||
|
||||
fn number(entity: &Ent, code: i32) -> Result<Option<f64>> {
|
||||
let value = layered_number(entity, code)?;
|
||||
if value.is_some_and(|value| !value.is_finite()) {
|
||||
return Err(invalid("nonfinite numeric field"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn required(entity: &Ent, code: i32) -> Result<f64> {
|
||||
let value = layered_required(entity, code)?;
|
||||
if !value.is_finite() {
|
||||
return Err(invalid("nonfinite required field"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Read nine-alignment model-space TEXT; return an error for MTEXT and any
|
||||
/// unrepresented alignment/style/OCS. Input is raw Ent.s, decoded exactly once.
|
||||
/// Both counters belong to the whole file; callers must not reset them per TEXT.
|
||||
/// Decoded text is restricted to the current writer's faithful plain BMP subset.
|
||||
pub(super) fn read_text_entity(
|
||||
entity: &Ent,
|
||||
k: f64,
|
||||
max_encoded_bytes: usize,
|
||||
max_decoded_bytes: usize,
|
||||
bytes: &mut usize,
|
||||
work: &mut usize,
|
||||
) -> Result<DxfShape> {
|
||||
if entity.kind != "TEXT" {
|
||||
return Err(invalid("not supported yet: MTEXT attachment/wrapping or a non-TEXT entity"));
|
||||
}
|
||||
if !k.is_finite() || k <= 0.0 {
|
||||
return Err(invalid("unit scale must be finite and positive"));
|
||||
}
|
||||
if max_encoded_bytes > MAX_DRAWING_BYTES || max_decoded_bytes > MAX_DRAWING_BYTES {
|
||||
return Err(invalid("decoder byte caps exceed the file ceiling"));
|
||||
}
|
||||
let remaining_bytes = MAX_DRAWING_BYTES.checked_sub(*bytes).ok_or_else(|| invalid("invalid shared byte counter"))?;
|
||||
let remaining_work = MAX_WORK.checked_sub(*work).ok_or_else(|| invalid("invalid shared work counter"))?;
|
||||
|
||||
// Known envelope fields retain the parent's layer/weight/space semantics.
|
||||
// Unknown geometry, explicit style7, MTEXT chunks3,
|
||||
// entity color/visibility/linetype and extension data cannot silently vanish.
|
||||
for code in entity.f.iter().map(|(code, _)| *code).chain(entity.s.iter().map(|(code, _)| *code)) {
|
||||
if !matches!(code, 1 | 5 | 8 | 10 | 11 | 20 | 21 | 30 | 31 | 39 | 40 | 41 | 50 | 51 | 67 | 71 | 72 | 73 | 100 | 210 | 220 | 230 | 330 | 370) {
|
||||
return Err(invalid("not supported yet: unrepresented field or style"));
|
||||
}
|
||||
}
|
||||
for code in [5, 8, 330] {
|
||||
let _ = layered_text(entity, code)?;
|
||||
}
|
||||
// Repeated subclass markers100 are ordinary TEXT framing, not scalar fields.
|
||||
let _ = super::layered_entity_weight(entity)?;
|
||||
if number(entity, 67)?.unwrap_or(0.0) != 0.0 {
|
||||
return Err(invalid("expected model-space TEXT after caller space filtering"));
|
||||
}
|
||||
for (code, neutral) in [(30, 0.0), (31, 0.0), (39, 0.0), (41, 1.0), (51, 0.0), (71, 0.0), (210, 0.0), (220, 0.0), (230, 1.0)] {
|
||||
if number(entity, code)?.is_some_and(|value| value != neutral) {
|
||||
return Err(invalid("not supported yet: nondefault alignment, width, oblique angle, generation flags or OCS"));
|
||||
}
|
||||
}
|
||||
// Parent lexical preflight must validate72/73 before section converts them
|
||||
// through f64. These finite local enum checks also protect direct Ent callers.
|
||||
let horizontal = match number(entity, 72)?.unwrap_or(0.0) {
|
||||
0.0 => DxfTextHorizontalAlignment::Left,
|
||||
1.0 => DxfTextHorizontalAlignment::Centre,
|
||||
2.0 => DxfTextHorizontalAlignment::Right,
|
||||
_ => return Err(invalid("not supported yet: horizontal TEXT alignment")),
|
||||
};
|
||||
let vertical = match number(entity, 73)?.unwrap_or(0.0) {
|
||||
0.0 => DxfTextVerticalAlignment::Baseline,
|
||||
2.0 => DxfTextVerticalAlignment::Middle,
|
||||
3.0 => DxfTextVerticalAlignment::Top,
|
||||
_ => return Err(invalid("not supported yet: vertical TEXT alignment")),
|
||||
};
|
||||
let insertion = Vec2::new(required(entity, 10)?, required(entity, 20)?);
|
||||
let source = if horizontal == DxfTextHorizontalAlignment::Left && vertical == DxfTextVerticalAlignment::Baseline {
|
||||
// Optional second point is irrelevant for left/baseline, but malformed
|
||||
// or duplicate fields still refuse. No silent fallback for other modes.
|
||||
let _ = number(entity, 11)?;
|
||||
let _ = number(entity, 21)?;
|
||||
insertion
|
||||
} else {
|
||||
Vec2::new(required(entity, 11)?, required(entity, 21)?)
|
||||
};
|
||||
let at = source * k;
|
||||
if !at.is_finite() || (source.x != 0.0 && at.x == 0.0) || (source.y != 0.0 && at.y == 0.0) {
|
||||
return Err(invalid("nonfinite or collapsed scaled insertion point"));
|
||||
}
|
||||
let raw_height = required(entity, 40)?;
|
||||
let height = raw_height * k;
|
||||
if raw_height <= 0.0 || !height.is_finite() || height <= 0.0 {
|
||||
return Err(invalid("height must remain finite and positive after scaling"));
|
||||
}
|
||||
let angle_deg = number(entity, 50)?.unwrap_or(0.0);
|
||||
if !angle_deg.to_radians().is_finite() {
|
||||
return Err(invalid("invalid rotation angle"));
|
||||
}
|
||||
let value = layered_text(entity, 1)?.ok_or_else(|| invalid("missing raw group1 value"))?;
|
||||
// Check input before allocation/scanning. Decoded output cannot allocate past
|
||||
// remaining shared payload/work even when its UTF8 representation expands.
|
||||
let encoded_limit = max_encoded_bytes.min(MAX_TEXT_ENCODED_BYTES);
|
||||
if value.len() > encoded_limit || value.len() > remaining_work {
|
||||
return Err(invalid("encoded byte or shared work budget exceeded"));
|
||||
}
|
||||
*work = work.checked_add(value.len()).ok_or_else(|| invalid("work counter overflow"))?;
|
||||
// Current owned writer accepts at most250 BMP scalars: at most750 UTF8 bytes.
|
||||
let decoded_limit = max_decoded_bytes.min(remaining_bytes).min(remaining_work - value.len()).min(MAX_TEXT_DECODED_BYTES);
|
||||
let text = decode_text_value(value, encoded_limit, decoded_limit)?;
|
||||
if text.chars().count() > 250 || text.chars().any(|ch| u32::from(ch) > 0xffff || ch.is_control() || matches!(ch, '%' | '\\')) {
|
||||
return Err(invalid("not supported yet: decoded text exceeds the current writer's plain BMP subset"));
|
||||
}
|
||||
layered_array_budget::<u8>(text.len(), bytes, work)?;
|
||||
if horizontal == DxfTextHorizontalAlignment::Centre && vertical == DxfTextVerticalAlignment::Baseline {
|
||||
Ok(DxfShape::Text { at, height, angle_deg, text })
|
||||
} else {
|
||||
Ok(DxfShape::AlignedText { at, height, angle_deg, text, horizontal, vertical })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FIELDS: &str = "10\n99\n20\n88\n11\n1\n21\n2\n40\n3\n72\n1\n1\nA\n";
|
||||
|
||||
fn parsed(fields: &str) -> Ent {
|
||||
let input = format!("0\nSECTION\n2\nENTITIES\n0\nTEXT\n{fields}0\nENDSEC\n0\nEOF\n");
|
||||
super::super::section(&super::super::pairs(&input), 1).unwrap().remove(0)
|
||||
}
|
||||
|
||||
fn basic(value: &str) -> Ent {
|
||||
parsed(&FIELDS.replacen("1\nA\n", &format!("1\n{value}\n"), 1))
|
||||
}
|
||||
|
||||
fn read(entity: &Ent) -> Result<DxfShape> {
|
||||
read_text_entity(entity, 1.0, 4096, 4096, &mut 0, &mut 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_text_uses_real_decoder_and_second_alignment_point_without_trimming() {
|
||||
assert_eq!(
|
||||
read(&basic(" %%d %%p %%c \\U+4E2D ")).unwrap(),
|
||||
DxfShape::Text { at: Vec2::new(1.0, 2.0), height: 3.0, angle_deg: 0.0, text: " ° ± \u{2205} 中 ".into() }
|
||||
);
|
||||
for value in ["", " ", "ASCII braces {}"] {
|
||||
assert!(matches!(read(&basic(value)).unwrap(), DxfShape::Text { text, .. } if text == value));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_alignment_point_and_height_once_keep_rotation_in_degrees() {
|
||||
let fields = FIELDS.replacen("40\n3\n", "40\n0.5\n50\n-90\n", 1);
|
||||
assert_eq!(
|
||||
read_text_entity(&parsed(&fields), 25.4, 1, 1, &mut 0, &mut 0).unwrap(),
|
||||
DxfShape::Text { at: Vec2::new(25.4, 50.8), height: 12.7, angle_deg: -90.0, text: "A".into() }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_fields_and_repeated_real_subclasses_are_admitted() {
|
||||
let entity = parsed(&format!(
|
||||
"{FIELDS}100\nAcDbEntity\n100\nAcDbText\n100\nAcDbText\n30\n0\n31\n0\n39\n0\n41\n1\n50\n0\n51\n0\n71\n0\n73\n0\n210\n0\n220\n0\n230\n1\n"
|
||||
));
|
||||
assert!(read(&entity).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_malformed_and_duplicate_fields_refuse() {
|
||||
for removed in ["10\n99\n", "20\n88\n", "11\n1\n", "21\n2\n", "40\n3\n", "1\nA\n"] {
|
||||
assert!(read(&parsed(&FIELDS.replacen(removed, "", 1))).is_err(), "{removed}");
|
||||
}
|
||||
for extra in ["10\n99\n", "11\n1\n", "21\n2\n", "40\n3\n", "72\n1\n", "1\nA\n", "50\n0\n50\n0\n", "73\n0\n73\n0\n", "230\n1\n230\n1\n"] {
|
||||
assert!(read(&parsed(&format!("{FIELDS}{extra}"))).is_err(), "{extra}");
|
||||
}
|
||||
for (from, to) in [("11\n1", "11\nNaN"), ("21\n2", "21\nbad"), ("40\n3", "40\ninf"), ("72\n1", "72\nwrong")] {
|
||||
assert!(read(&parsed(&FIELDS.replacen(from, to, 1))).is_err());
|
||||
}
|
||||
let mut entity = basic("A");
|
||||
entity.f.push((1, 1.0));
|
||||
assert!(read(&entity).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_alignment_style_ocs_and_mtext_remain_explicit_errors() {
|
||||
for mode in ["-1", "3", "4", "5", "0.5"] {
|
||||
assert!(read(&parsed(&FIELDS.replacen("72\n1\n", &format!("72\n{mode}\n"), 1))).is_err());
|
||||
}
|
||||
for extra in [
|
||||
"7\nSTANDARD\n",
|
||||
"7\nCustom\n",
|
||||
"41\n2\n",
|
||||
"51\n15\n",
|
||||
"71\n2\n",
|
||||
"73\n1\n",
|
||||
"30\n1\n",
|
||||
"31\n1\n",
|
||||
"39\n1\n",
|
||||
"210\n1\n",
|
||||
"220\n1\n",
|
||||
"230\n-1\n",
|
||||
"3\nextra\n",
|
||||
"6\nDASHED\n",
|
||||
"62\n1\n",
|
||||
"60\n1\n",
|
||||
"67\n1\n",
|
||||
"999\ncomment\n",
|
||||
] {
|
||||
assert!(read(&parsed(&format!("{FIELDS}{extra}"))).is_err(), "{extra}");
|
||||
}
|
||||
let mut entity = basic("A");
|
||||
entity.kind = "MTEXT".into();
|
||||
assert!(read(&entity).unwrap_err().to_string().contains("MTEXT attachment/wrapping"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonrepresentable_literals_are_refused_after_exactly_one_decode() {
|
||||
for value in ["%", "%%%", "\\U+0025\\U+0025d", "\\U+005CU+0041", "\\P", "a\tb", "中𠀋", "\\U+D800", "\\U+0000", "%%q"] {
|
||||
assert!(read(&basic(value)).is_err(), "{value}");
|
||||
}
|
||||
assert!(read(&basic(&"中".repeat(250))).is_ok());
|
||||
assert!(read(&basic(&"A".repeat(251))).is_err());
|
||||
assert!(read(&basic(&"中".repeat(251))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_geometry_scale_and_underflow_refuse() {
|
||||
for k in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::MAX] {
|
||||
assert!(read_text_entity(&basic("A"), k, 1, 1, &mut 0, &mut 0).is_err());
|
||||
}
|
||||
for (code, value) in [(10, f64::NAN), (11, f64::INFINITY), (21, f64::NAN), (40, 0.0), (40, -1.0), (40, f64::NAN), (50, f64::NAN)] {
|
||||
let mut entity = basic("A");
|
||||
entity.f.retain(|(field, _)| *field != code);
|
||||
entity.f.push((code, value));
|
||||
assert!(read(&entity).is_err());
|
||||
}
|
||||
for fields in [FIELDS.replacen("11\n1\n", "11\n1e-300\n", 1), FIELDS.replacen("40\n3\n", "40\n1e-300\n", 1)] {
|
||||
assert!(read_text_entity(&parsed(&fields), 1e-100, 1, 1, &mut 0, &mut 0).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inclusive_decoder_caps_measure_utf8_bytes() {
|
||||
let entity = basic("\\U+FFFF");
|
||||
assert!(read_text_entity(&entity, 1.0, 7, 3, &mut 0, &mut 0).is_ok());
|
||||
assert!(read_text_entity(&entity, 1.0, 6, 3, &mut 0, &mut 0).is_err());
|
||||
assert!(read_text_entity(&entity, 1.0, 7, 2, &mut 0, &mut 0).is_err());
|
||||
assert!(read_text_entity(&basic("中"), 1.0, 2, 3, &mut 0, &mut 0).is_err());
|
||||
assert!(read_text_entity(&basic(""), 1.0, 0, 0, &mut 0, &mut 0).is_ok());
|
||||
assert!(read_text_entity(&basic("A"), 1.0, MAX_DRAWING_BYTES + 1, 1, &mut 0, &mut 0).is_err());
|
||||
assert!(read_text_entity(&basic("A"), 1.0, 1, MAX_DRAWING_BYTES + 1, &mut 0, &mut 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_counters_remain_cumulative_and_limit_before_decode_allocation() {
|
||||
let entity = basic("A");
|
||||
let mut bytes = MAX_DRAWING_BYTES - 2;
|
||||
let mut work = MAX_WORK - 4;
|
||||
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut bytes, &mut work).is_ok());
|
||||
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut bytes, &mut work).is_ok());
|
||||
assert_eq!((bytes, work), (MAX_DRAWING_BYTES, MAX_WORK));
|
||||
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut bytes, &mut work).is_err());
|
||||
let mut full_bytes = MAX_DRAWING_BYTES;
|
||||
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut full_bytes, &mut 0).is_err());
|
||||
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut 0, &mut (MAX_WORK - 1)).is_err());
|
||||
let mut invalid_bytes = usize::MAX;
|
||||
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut invalid_bytes, &mut 0).is_err());
|
||||
let mut invalid_work = usize::MAX;
|
||||
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut 0, &mut invalid_work).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_lexical_preflight_must_precede_float_field_conversion() {
|
||||
for extra in ["71\n1e-400\n", "73\n0.0\n"] {
|
||||
let input = format!("0\nSECTION\n2\nENTITIES\n0\nTEXT\n{FIELDS}{extra}0\nENDSEC\n0\nEOF\n");
|
||||
assert!(super::super::layered_array_integer_fields(&super::super::pairs(&input), 1).is_err());
|
||||
}
|
||||
let fields = FIELDS.replacen("72\n1\n", "72\n1.0000000000000000001\n", 1);
|
||||
let input = format!("0\nSECTION\n2\nENTITIES\n0\nTEXT\n{fields}0\nENDSEC\n0\nEOF\n");
|
||||
assert!(super::super::layered_array_integer_fields(&super::super::pairs(&input), 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nine_literal_mode_pairs_select_the_actual_anchor_and_preserve_legacy_text() {
|
||||
use DxfTextHorizontalAlignment::{Centre, Left, Right};
|
||||
use DxfTextVerticalAlignment::{Baseline, Middle, Top};
|
||||
for (h, v, horizontal, vertical) in [
|
||||
(0, 0, Left, Baseline),
|
||||
(1, 0, Centre, Baseline),
|
||||
(2, 0, Right, Baseline),
|
||||
(0, 2, Left, Middle),
|
||||
(1, 2, Centre, Middle),
|
||||
(2, 2, Right, Middle),
|
||||
(0, 3, Left, Top),
|
||||
(1, 3, Centre, Top),
|
||||
(2, 3, Right, Top),
|
||||
] {
|
||||
let fields = FIELDS.replacen("72\n1\n", &format!("72\n{h}\n73\n{v}\n50\n-90\n"), 1);
|
||||
let shape = read_text_entity(&parsed(&fields), 2.0, 1, 1, &mut 0, &mut 0).unwrap();
|
||||
let at = if h == 0 && v == 0 { Vec2::new(198.0, 176.0) } else { Vec2::new(2.0, 4.0) };
|
||||
let expected = if h == 1 && v == 0 {
|
||||
DxfShape::Text { at, height: 6.0, angle_deg: -90.0, text: "A".into() }
|
||||
} else {
|
||||
DxfShape::AlignedText { at, height: 6.0, angle_deg: -90.0, text: "A".into(), horizontal, vertical }
|
||||
};
|
||||
assert_eq!(shape, expected, "72={h},73={v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_left_baseline_uses_first_point_without_requiring_second_point() {
|
||||
let fields = FIELDS.replacen("72\n1\n", "", 1).replacen("11\n1\n", "", 1).replacen("21\n2\n", "", 1);
|
||||
assert_eq!(
|
||||
read(&parsed(&fields)).unwrap(),
|
||||
DxfShape::AlignedText {
|
||||
at: Vec2::new(99.0, 88.0),
|
||||
height: 3.0,
|
||||
angle_deg: 0.0,
|
||||
text: "A".into(),
|
||||
horizontal: DxfTextHorizontalAlignment::Left,
|
||||
vertical: DxfTextVerticalAlignment::Baseline,
|
||||
}
|
||||
);
|
||||
// Though irrelevant for placement, a present malformed/duplicate point
|
||||
// cannot be used to hide corrupted input in the default mode.
|
||||
for extra in ["11\nbad\n", "21\nNaN\n", "11\n1\n11\n1\n"] {
|
||||
assert!(read(&parsed(&format!("{fields}{extra}"))).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_other_mode_requires_second_anchor_and_never_falls_back_to_first() {
|
||||
for (h, v) in [(1, 0), (2, 0), (0, 2), (1, 2), (2, 2), (0, 3), (1, 3), (2, 3)] {
|
||||
let fields = FIELDS.replacen("72\n1\n", &format!("72\n{h}\n73\n{v}\n"), 1);
|
||||
for removed in ["11\n1\n", "21\n2\n"] {
|
||||
assert!(read(&parsed(&fields.replacen(removed, "", 1))).is_err());
|
||||
}
|
||||
}
|
||||
for invalid in ["73\n-1\n", "73\n1\n", "73\n4\n", "73\n0.5\n", "73\n2\n73\n2\n"] {
|
||||
assert!(read(&parsed(&format!("{FIELDS}{invalid}"))).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_hundred_fifty_cif_bmp_chars_need_1750_encoded_and_750_decoded_bytes() {
|
||||
let encoded = "\\U+4E2D".repeat(250);
|
||||
let expected = "中".repeat(250);
|
||||
assert_eq!(encoded.len(), 1750);
|
||||
assert_eq!(expected.len(), 750);
|
||||
let entity = basic(&encoded);
|
||||
let (mut bytes, mut work) = (0, 0);
|
||||
assert_eq!(
|
||||
read_text_entity(&entity, 1.0, 1750, 750, &mut bytes, &mut work).unwrap(),
|
||||
DxfShape::Text { at: Vec2::new(1.0, 2.0), height: 3.0, angle_deg: 0.0, text: expected }
|
||||
);
|
||||
assert_eq!((bytes, work), (750, 2500));
|
||||
let (mut bytes, mut work) = (0, 0);
|
||||
assert!(read_text_entity(&entity, 1.0, 1749, 750, &mut bytes, &mut work).is_err());
|
||||
assert_eq!((bytes, work), (0, 0), "encoded admission precedes decoding");
|
||||
assert!(read_text_entity(&entity, 1.0, 1750, 749, &mut 0, &mut 0).is_err());
|
||||
assert!(read_text_entity(&entity, 1.0, 1750, 750, &mut (MAX_DRAWING_BYTES - 749), &mut 0).is_err());
|
||||
assert!(read_text_entity(&entity, 1.0, 1750, 750, &mut 0, &mut (MAX_WORK - 2499)).is_err());
|
||||
assert!(read(&basic(&"\\U+4E2D".repeat(251))).is_err());
|
||||
assert!(read(&basic(&"A".repeat(251))).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! TEXT-only value decoding; no MTEXT helper or registration substitute.
|
||||
//! Caller supplies raw group1 and encoded/decoded byte caps. Output is not rescanned.
|
||||
|
||||
use crate::{IoError, Result};
|
||||
|
||||
fn invalid(reason: impl std::fmt::Display) -> IoError {
|
||||
IoError::Invalid(format!("DXF text value: {reason}"))
|
||||
}
|
||||
|
||||
fn push(out: &mut String, ch: char, max_bytes: usize) -> Result<()> {
|
||||
let needed = out.len().checked_add(ch.len_utf8()).ok_or_else(|| invalid("decoded size overflow"))?;
|
||||
if needed > max_bytes {
|
||||
return Err(invalid("decoded byte budget exceeded"));
|
||||
}
|
||||
out.try_reserve(ch.len_utf8()).map_err(|error| invalid(format!("cannot reserve decoded text: {error}")))?;
|
||||
out.push(ch);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cif(chars: &mut impl Iterator<Item = char>) -> Result<char> {
|
||||
if chars.next() != Some('+') {
|
||||
return Err(invalid("malformed CIF Unicode prefix"));
|
||||
}
|
||||
let mut code = 0_u32;
|
||||
for _ in 0..4 {
|
||||
let digit = chars.next().and_then(|ch| ch.to_digit(16)).ok_or_else(|| invalid("CIF Unicode escape requires four hexadecimal digits"))?;
|
||||
code = code * 16 + digit;
|
||||
}
|
||||
let ch = char::from_u32(code).ok_or_else(|| invalid("CIF Unicode escape is not a scalar value; surrogate pairs are unsupported"))?;
|
||||
if ch.is_control() {
|
||||
return Err(invalid("not supported yet: a control character encoded as CIF"));
|
||||
}
|
||||
Ok(ch)
|
||||
}
|
||||
|
||||
fn percent(chars: &mut impl Iterator<Item = char>) -> Result<char> {
|
||||
match chars.next() {
|
||||
Some('%') => Ok('%'),
|
||||
Some('d') => Ok('°'),
|
||||
Some('p') => Ok('±'),
|
||||
Some('c') => Ok('\u{2205}'),
|
||||
_ => Err(invalid("not supported yet: percent formatting code")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn decode_text_value(value: &str, max_encoded_bytes: usize, max_decoded_bytes: usize) -> Result<String> {
|
||||
if value.len() > max_encoded_bytes {
|
||||
return Err(invalid("encoded byte budget exceeded"));
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut chars = value.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
let decoded = match ch {
|
||||
c if c.is_control() => return Err(invalid("not supported yet: raw text control character")),
|
||||
'\\' if chars.peek() == Some(&'U') => {
|
||||
chars.next();
|
||||
cif(&mut chars)?
|
||||
}
|
||||
'%' if chars.peek() == Some(&'%') => {
|
||||
chars.next();
|
||||
percent(&mut chars)?
|
||||
}
|
||||
c => c,
|
||||
};
|
||||
push(&mut out, decoded, max_decoded_bytes)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::decode_text_value;
|
||||
|
||||
#[test]
|
||||
fn literals_percent_cif_and_decode_once_retain_text_semantics() {
|
||||
assert_eq!(decode_text_value("%%% %%d %%p %%c \\U+FFFF", 4096, 4096).unwrap(), "% ° ± \u{2205} \u{ffff}");
|
||||
assert_eq!(decode_text_value(" \\U+0025\\U+0025d \\U+005CU+0041 \\P ", 4096, 4096).unwrap(), " %%d \\U+0041 \\P ");
|
||||
assert_eq!(decode_text_value("trailing\\", 4096, 4096).unwrap(), "trailing\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_styles_surrogates_and_controls_still_refuse() {
|
||||
for value in [
|
||||
"\\U+", "\\U+XYZ0", "\\U+123", "\\U0041", "\\U+D800", "\\U+DC00", "%%", "%%q", "%%u", "%%123", "a\rb", "a\nb", "a\tb", "\\U+0000",
|
||||
"\\U+000A", "\\U+0085",
|
||||
] {
|
||||
assert!(decode_text_value(value, 4096, 4096).is_err(), "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_and_decoded_budgets_are_separate_utf8_byte_bounds() {
|
||||
assert_eq!(decode_text_value("\\U+FFFF", 7, 3).unwrap(), "\u{ffff}");
|
||||
assert!(decode_text_value("\\U+FFFF", 6, 3).is_err());
|
||||
assert!(decode_text_value("\\U+FFFF", 7, 2).is_err());
|
||||
assert_eq!(decode_text_value("", 0, 0).unwrap(), "");
|
||||
// The general TEXT value decoder still preserves newer Unicode; the
|
||||
// current writer-compatible entity adapter separately refuses non-BMP.
|
||||
assert_eq!(decode_text_value("中𠀋", 7, 7).unwrap(), "中𠀋");
|
||||
}
|
||||
}
|
||||
+1490
-33
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,902 @@
|
||||
//! Owned layers and entity metadata for partially built DXF files.
|
||||
//!
|
||||
//! Generic geometry is carried unchanged and is not validated or serialized by
|
||||
//! insertion. Concrete DxfShape methods provide conservative bounds and positive
|
||||
//! transforms in declared supported subsets; text bounds need a layout provider.
|
||||
//! Legacy drawing commands keep their existing APIs until their adapters are qualified.
|
||||
//!
|
||||
//! Layer declarations currently admit ASCII only. They trim ASCII whitespace,
|
||||
//! replace restricted characters, and refuse normalized/case collisions.
|
||||
//! Names admit at most 4096 raw bytes and 255 normalized characters; files hold
|
||||
//! at most 1024 layers and one million entities. These are resource limits,
|
||||
//! not a promise of complete DXF import support. Geometry owns its own budget.
|
||||
//! Standard physical lineweights use the 24 public DXF codes, with exact
|
||||
//! midpoint ties choosing the heavier weight. Absent and symbolic entity
|
||||
//! weights stay distinct. Layer zero is initialized by new() or with_layer_zero()
|
||||
//! and stays immutable after construction. Unicode names remain unsupported.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use super::DxfLinetype;
|
||||
use crate::{IoError, Result};
|
||||
|
||||
mod bulges;
|
||||
mod geometry;
|
||||
pub use geometry::DxfGeometryBounds;
|
||||
|
||||
const STANDARD_WEIGHTS: [u16; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
|
||||
const MAX_NAME_CHARS: usize = 255;
|
||||
// A resource bound on the RAW input, independent of the format-name length
|
||||
// after trimming. It does not change the normalized format-name bound.
|
||||
const MAX_RAW_NAME_BYTES: usize = 4096;
|
||||
const MAX_LAYERS: usize = 1024;
|
||||
const MAX_ENTITIES: usize = 1_000_000;
|
||||
static NEXT_OWNER: AtomicUsize = AtomicUsize::new(1);
|
||||
|
||||
fn invalid(message: &str) -> IoError {
|
||||
IoError::Invalid(message.into())
|
||||
}
|
||||
|
||||
/// A standard physical weight, in hundredths of a millimetre.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct StandardWeight(u16);
|
||||
|
||||
impl StandardWeight {
|
||||
pub fn from_code(code: u16) -> Result<Self> {
|
||||
if STANDARD_WEIGHTS.contains(&code) { Ok(Self(code)) } else { Err(invalid("not a standard DXF lineweight")) }
|
||||
}
|
||||
|
||||
/// Nearest rational standard weight; exact midpoint ties choose the heavier.
|
||||
/// Existing drawing writers are unchanged until their adapters use this model.
|
||||
pub fn from_mm(mm: f64) -> Result<Self> {
|
||||
if !mm.is_finite() || !(0.0..=2.11).contains(&mm) {
|
||||
return Err(invalid("DXF lineweight must be finite and between 0 and 2.11 mm"));
|
||||
}
|
||||
for pair in STANDARD_WEIGHTS.windows(2) {
|
||||
let [low, high] = pair else { return Err(invalid("invalid standard lineweight table")) };
|
||||
let midpoint_sum = f64::from(*low + *high);
|
||||
// Compare exact 200*mm to the integer midpoint_sum. When its
|
||||
// rounded product equals that integer, FMA preserves the sign of
|
||||
// the residual. At these nonzero integer midpoints the residual
|
||||
// is far above subnormal underflow. No tolerance or mm clamping.
|
||||
let scaled = mm * 200.0;
|
||||
if scaled < midpoint_sum || (scaled == midpoint_sum && mm.mul_add(200.0, -scaled) < 0.0) {
|
||||
return Ok(Self(*low));
|
||||
}
|
||||
}
|
||||
Ok(Self(211))
|
||||
}
|
||||
|
||||
pub fn code(self) -> u16 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbols are not negative physical weights. None on Entity means the group
|
||||
/// was omitted; Some(ByLayer) is an explicitly authored -1.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EntityWeight {
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Default,
|
||||
Explicit(StandardWeight),
|
||||
}
|
||||
|
||||
impl EntityWeight {
|
||||
pub fn group370(self) -> i16 {
|
||||
match self {
|
||||
Self::ByLayer => -1,
|
||||
Self::ByBlock => -2,
|
||||
Self::Default => -3,
|
||||
Self::Explicit(value) => value.code() as i16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// These are in-memory ownership handles, NOT serialized DXF group-5 handles.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LayerId {
|
||||
owner: usize,
|
||||
index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Layer {
|
||||
original: String,
|
||||
name: String,
|
||||
color: i16,
|
||||
linetype: DxfLinetype,
|
||||
/// None preserves legacy omission of group370; its semantic default is
|
||||
/// unresolved here, never silently replaced with a numeric weight.
|
||||
weight: Option<StandardWeight>,
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
pub fn color(&self) -> i16 {
|
||||
self.color
|
||||
}
|
||||
pub fn linetype(&self) -> DxfLinetype {
|
||||
self.linetype
|
||||
}
|
||||
pub fn weight(&self) -> Option<StandardWeight> {
|
||||
self.weight
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Entity<G> {
|
||||
layer: LayerId,
|
||||
weight: Option<EntityWeight>,
|
||||
geometry: G,
|
||||
}
|
||||
|
||||
impl<G> Entity<G> {
|
||||
pub fn layer(&self) -> LayerId {
|
||||
self.layer
|
||||
}
|
||||
pub fn weight(&self) -> Option<EntityWeight> {
|
||||
self.weight
|
||||
}
|
||||
pub fn geometry(&self) -> &G {
|
||||
&self.geometry
|
||||
}
|
||||
}
|
||||
|
||||
/// No Clone: cloning must not accidentally duplicate ownership. A future copy
|
||||
/// operation must mint a new owner and remap its handles explicitly.
|
||||
#[derive(Debug)]
|
||||
pub struct DxfFile<G> {
|
||||
owner: usize,
|
||||
layers: Vec<Layer>,
|
||||
entities: Vec<Entity<G>>,
|
||||
max_layers: usize,
|
||||
max_entities: usize,
|
||||
}
|
||||
|
||||
fn owner_from(counter: &AtomicUsize) -> Result<usize> {
|
||||
counter.try_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1)).map_err(|_| invalid("DXF file ownership identities exhausted"))
|
||||
}
|
||||
|
||||
fn owned(text: &str) -> Result<String> {
|
||||
let mut out = String::new();
|
||||
out.try_reserve(text.len()).map_err(|_| invalid("DXF name allocation failed"))?;
|
||||
out.push_str(text);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn ascii_name_space(c: char) -> bool {
|
||||
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
|
||||
}
|
||||
|
||||
fn name_of(input: &str) -> Result<String> {
|
||||
if input.len() > MAX_RAW_NAME_BYTES {
|
||||
return Err(invalid("DXF raw layer-name input exceeds resource byte limit"));
|
||||
}
|
||||
let input = input.trim_matches(ascii_name_space);
|
||||
// Inspect at most one scalar beyond the bound before any allocation.
|
||||
// At most255 Unicode scalars imply at most1020 UTF-8 bytes. The length
|
||||
// rule alone does not establish Unicode symbol-equivalence semantics.
|
||||
if input.chars().take(MAX_NAME_CHARS + 1).count() > MAX_NAME_CHARS {
|
||||
return Err(invalid("DXF layer name exceeds 255 Unicode scalar values"));
|
||||
}
|
||||
if input.is_empty() {
|
||||
return owned("0");
|
||||
}
|
||||
let mut name = String::new();
|
||||
name.try_reserve(input.len()).map_err(|_| invalid("DXF name allocation failed"))?;
|
||||
for c in input.chars() {
|
||||
let forbidden = c.is_control() || matches!(c, '<' | '>' | '/' | '\\' | '"' | '\'' | ':' | ';' | '?' | '*' | '|' | '=');
|
||||
name.push(if forbidden { '_' } else { c });
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
impl<G> DxfFile<G> {
|
||||
pub fn new() -> Result<Self> {
|
||||
Self::with_limits(MAX_LAYERS, MAX_ENTITIES)
|
||||
}
|
||||
|
||||
/// Construct a new file with the imported style of its first layer, `0`.
|
||||
/// This is initialization, not a setter: the style is immutable after construction.
|
||||
/// Colors use the same indexed range as [`Self::declare_layer`]; a missing
|
||||
/// physical weight remains absent rather than becoming an explicit zero.
|
||||
/// Every successful construction mints fresh file-scoped ownership handles.
|
||||
pub fn with_layer_zero(color: i16, linetype: DxfLinetype, weight: Option<StandardWeight>) -> Result<Self> {
|
||||
Self::with_layer_zero_limits(color, linetype, weight, MAX_LAYERS, MAX_ENTITIES)
|
||||
}
|
||||
|
||||
fn with_limits(max_layers: usize, max_entities: usize) -> Result<Self> {
|
||||
Self::with_layer_zero_limits(7, DxfLinetype::Continuous, None, max_layers, max_entities)
|
||||
}
|
||||
|
||||
fn with_layer_zero_limits(
|
||||
color: i16,
|
||||
linetype: DxfLinetype,
|
||||
weight: Option<StandardWeight>,
|
||||
max_layers: usize,
|
||||
max_entities: usize,
|
||||
) -> Result<Self> {
|
||||
if !(1..=255).contains(&color) {
|
||||
return Err(invalid("DXF layer color must be an index from 1 to 255"));
|
||||
}
|
||||
if max_layers == 0 {
|
||||
return Err(invalid("DXF file requires layer 0"));
|
||||
}
|
||||
let owner = owner_from(&NEXT_OWNER)?;
|
||||
let mut layers = Vec::new();
|
||||
layers.try_reserve(1).map_err(|_| invalid("DXF layer allocation failed"))?;
|
||||
layers.push(Layer { original: owned("0")?, name: owned("0")?, color, linetype, weight });
|
||||
Ok(Self { owner, layers, entities: Vec::new(), max_layers: max_layers.min(MAX_LAYERS), max_entities: max_entities.min(MAX_ENTITIES) })
|
||||
}
|
||||
|
||||
pub fn default_layer(&self) -> LayerId {
|
||||
LayerId { owner: self.owner, index: 0 }
|
||||
}
|
||||
pub fn layers(&self) -> &[Layer] {
|
||||
&self.layers
|
||||
}
|
||||
pub fn entities(&self) -> &[Entity<G>] {
|
||||
&self.entities
|
||||
}
|
||||
|
||||
pub fn layer(&self, id: LayerId) -> Result<&Layer> {
|
||||
if id.owner != self.owner {
|
||||
return Err(invalid("layer belongs to a different DXF file"));
|
||||
}
|
||||
self.layers.get(id.index).ok_or_else(|| invalid("unknown DXF layer handle"))
|
||||
}
|
||||
|
||||
pub fn declare_layer(&mut self, original: &str, color: i16, linetype: DxfLinetype, weight: Option<StandardWeight>) -> Result<LayerId> {
|
||||
if original.len() > MAX_RAW_NAME_BYTES {
|
||||
return Err(invalid("DXF raw layer-name input exceeds resource byte limit"));
|
||||
}
|
||||
// Provisional ASCII-only container: do not claim general Unicode
|
||||
// collision safety from eq_ignore_ascii_case. The character-bound
|
||||
// helper is Unicode-aware, but a reader adapter needs an agreed
|
||||
// canonical symbol comparison before it may admit Unicode layers.
|
||||
if !original.is_ascii() {
|
||||
return Err(invalid("not supported yet: non-ASCII DXF layer names"));
|
||||
}
|
||||
let name = name_of(original)?;
|
||||
if !(1..=255).contains(&color) {
|
||||
return Err(invalid("DXF layer color must be an index from 1 to 255"));
|
||||
}
|
||||
if let Some((index, existing)) = self.layers.iter().enumerate().find(|(_, layer)| layer.name.eq_ignore_ascii_case(&name)) {
|
||||
// Blank and whitespace-surrounded0 explicitly alias built-in0.
|
||||
// Other distinct raw declarations that normalize to one symbol
|
||||
// refuse rather than adding a second or silently merging styles.
|
||||
let same_input = original == existing.original || name == "0";
|
||||
if same_input && existing.color == color && existing.linetype == linetype && existing.weight == weight {
|
||||
return Ok(LayerId { owner: self.owner, index });
|
||||
}
|
||||
return Err(invalid("DXF layer declaration collides with an existing symbol"));
|
||||
}
|
||||
if self.layers.len() >= self.max_layers {
|
||||
return Err(invalid("DXF layer count limit exceeded"));
|
||||
}
|
||||
let original = owned(original)?;
|
||||
self.layers.try_reserve(1).map_err(|_| invalid("DXF layer allocation failed"))?;
|
||||
let index = self.layers.len();
|
||||
self.layers.push(Layer { original, name, color, linetype, weight });
|
||||
Ok(LayerId { owner: self.owner, index })
|
||||
}
|
||||
|
||||
/// G is carried unchanged. A future adapter validates geometric values.
|
||||
pub fn push(&mut self, layer: LayerId, geometry: G, weight: Option<EntityWeight>) -> Result<usize> {
|
||||
self.layer(layer)?;
|
||||
if self.entities.len() >= self.max_entities {
|
||||
return Err(invalid("DXF entity count limit exceeded"));
|
||||
}
|
||||
self.entities.try_reserve(1).map_err(|_| invalid("DXF entity allocation failed"))?;
|
||||
let index = self.entities.len();
|
||||
self.entities.push(Entity { layer, weight, geometry });
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// ByBlock and Default deliberately remain symbolic, rather than inventing
|
||||
/// a parent block or the consuming application's default numeric thickness.
|
||||
pub fn effective_weight(&self, index: usize) -> Result<EntityWeight> {
|
||||
let entity = self.entities.get(index).ok_or_else(|| invalid("unknown DXF entity index"))?;
|
||||
match entity.weight {
|
||||
None | Some(EntityWeight::ByLayer) => Ok(self.layer(entity.layer)?.weight.map_or(EntityWeight::Default, EntityWeight::Explicit)),
|
||||
Some(weight) => Ok(weight),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_linetype(&self, index: usize) -> Result<DxfLinetype> {
|
||||
let entity = self.entities.get(index).ok_or_else(|| invalid("unknown DXF entity index"))?;
|
||||
Ok(self.layer(entity.layer)?.linetype)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layers_own_ordered_geometry_without_inventing_a_weight() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
let cut = file.declare_layer("CUT", 1, DxfLinetype::Continuous, None).unwrap();
|
||||
let hidden = file.declare_layer("HIDDEN", 8, DxfLinetype::Hidden, None).unwrap();
|
||||
file.push(cut, ((0, 0), (10, 5)), None).unwrap();
|
||||
file.push(hidden, ((3, 4), (5, 4)), None).unwrap();
|
||||
assert_eq!(file.layers().iter().map(Layer::name).collect::<Vec<_>>(), ["0", "CUT", "HIDDEN"]);
|
||||
assert_eq!(file.entities()[0].geometry(), &((0, 0), (10, 5)));
|
||||
assert_eq!(file.entities()[0].layer(), cut);
|
||||
assert_eq!(file.entities()[0].weight(), None);
|
||||
assert_eq!(file.effective_linetype(1).unwrap(), DxfLinetype::Hidden);
|
||||
assert_eq!(file.effective_weight(0).unwrap(), EntityWeight::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_from_another_file_never_select_its_same_numbered_layer() {
|
||||
let first = DxfFile::<()>::new().unwrap();
|
||||
let mut second = DxfFile::new().unwrap();
|
||||
assert!(second.push(first.default_layer(), (), None).is_err());
|
||||
assert!(second.entities().is_empty());
|
||||
second.push(second.default_layer(), (), None).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_cleaned_once_and_collisions_refuse_without_mutation() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
let id = file.declare_layer("CUT\nA", 3, DxfLinetype::Continuous, None).unwrap();
|
||||
assert_eq!(file.layer(id).unwrap().name(), "CUT_A");
|
||||
assert_eq!(file.declare_layer("CUT\nA", 3, DxfLinetype::Continuous, None).unwrap(), id);
|
||||
for name in ["CUT?A", "cut_a", "CUT_A"] {
|
||||
assert!(file.declare_layer(name, 3, DxfLinetype::Continuous, None).is_err());
|
||||
}
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
assert_eq!(file.declare_layer("", 7, DxfLinetype::Continuous, None).unwrap(), file.default_layer());
|
||||
assert!(file.declare_layer(&"x".repeat(256), 7, DxfLinetype::Continuous, None).is_err());
|
||||
assert!(file.declare_layer("bad color", 256, DxfLinetype::Continuous, None).is_err());
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_cannot_be_redeclared_with_different_style() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
file.declare_layer("ETCH", 4, DxfLinetype::Dashed, None).unwrap();
|
||||
assert!(file.declare_layer("ETCH", 4, DxfLinetype::Continuous, None).is_err());
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
assert_eq!(file.layers()[1].color(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_length_counts_scalars_with_a_bounded_utf8_allocation() {
|
||||
assert_eq!(name_of(&"漢".repeat(86)).unwrap().chars().count(), 86);
|
||||
assert_eq!(name_of(&"漢".repeat(255)).unwrap().chars().count(), 255);
|
||||
assert_eq!(name_of(&"😀".repeat(255)).unwrap().len(), 1020);
|
||||
assert!(name_of(&"漢".repeat(256)).is_err());
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
file.declare_layer(&"A".repeat(255), 7, DxfLinetype::Continuous, None).unwrap();
|
||||
assert!(file.declare_layer(&"A".repeat(256), 7, DxfLinetype::Continuous, None).is_err());
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_symbol_equivalence_is_refused_instead_of_assumed() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
for name in ["É", "é", "漢", "Aé", "E\u{301}"] {
|
||||
let error = file.declare_layer(name, 7, DxfLinetype::Continuous, None).unwrap_err();
|
||||
assert!(error.to_string().contains("not supported yet: non-ASCII"));
|
||||
}
|
||||
assert_eq!(file.layers().len(), 1);
|
||||
assert!(file.entities().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_a_different_layer_zero_style_remains_explicitly_pending() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
assert!(file.declare_layer("0", 3, DxfLinetype::Dashed, None).is_err());
|
||||
let zero = file.layer(file.default_layer()).unwrap();
|
||||
assert_eq!(zero.color(), 7);
|
||||
assert_eq!(zero.linetype(), DxfLinetype::Continuous);
|
||||
assert_eq!(zero.weight(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimming_precedes_ascii_symbol_collision_detection() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
file.declare_layer("CUT", 7, DxfLinetype::Continuous, None).unwrap();
|
||||
for (name, expected) in [(" CUT ", "CUT"), ("\tCUT\r\n", "CUT"), ("\x0bCUT\x0c", "CUT"), (" cut ", "cut")] {
|
||||
assert_eq!(name_of(name).unwrap(), expected);
|
||||
assert!(file.declare_layer(name, 7, DxfLinetype::Continuous, None).is_err());
|
||||
}
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blanks_and_trimmed_layer_zero_use_the_existing_zero_handle() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
for name in ["", " \t\r\n\x0b\x0c ", " 0 ", "\t0\r\n"] {
|
||||
assert_eq!(file.declare_layer(name, 7, DxfLinetype::Continuous, None).unwrap(), file.default_layer());
|
||||
}
|
||||
assert_eq!(file.layers().len(), 1);
|
||||
assert!(file.declare_layer(" 0 ", 3, DxfLinetype::Continuous, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_255_character_limit_is_independent_of_raw_padding() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
let name = "A".repeat(255);
|
||||
let padded = format!(" \t{name}\r\n ");
|
||||
let id = file.declare_layer(&padded, 7, DxfLinetype::Continuous, None).unwrap();
|
||||
assert_eq!(file.layer(id).unwrap().name(), name);
|
||||
assert!(name_of(&format!(" {} ", "A".repeat(256))).is_err());
|
||||
assert_eq!(name_of(&format!(" {} ", "漢".repeat(255))).unwrap().chars().count(), 255);
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_resource_limit_is_separate_from_normalized_format_length() {
|
||||
let boundary = format!("{}{}", " ".repeat(3841), "A".repeat(255));
|
||||
assert_eq!(boundary.len(), 4096);
|
||||
assert_eq!(name_of(&boundary).unwrap().len(), 255);
|
||||
assert!(name_of(&format!(" {boundary}")).unwrap_err().to_string().contains("resource byte limit"));
|
||||
assert!(name_of(&" ".repeat(4097)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_weights_keep_standard_values_and_nearest_boundaries() {
|
||||
let table = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
|
||||
for code in table {
|
||||
let w = StandardWeight::from_code(code).unwrap();
|
||||
assert_eq!(w.code(), code);
|
||||
assert_eq!(StandardWeight::from_mm(f64::from(code) / 100.0).unwrap(), w);
|
||||
}
|
||||
// 0.375 is the EXACT binary midpoint between 0.35 and 0.40.
|
||||
assert_eq!(StandardWeight::from_mm(0.375_f64.next_down()).unwrap().code(), 35);
|
||||
assert_eq!(StandardWeight::from_mm(0.375).unwrap().code(), 40);
|
||||
assert_eq!(StandardWeight::from_mm(0.375_f64.next_up()).unwrap().code(), 40);
|
||||
// Stored 0.025 is slightly ABOVE the rational first midpoint.
|
||||
assert_eq!(StandardWeight::from_mm(0.025_f64.next_down()).unwrap().code(), 0);
|
||||
assert_eq!(StandardWeight::from_mm(0.025).unwrap().code(), 5);
|
||||
assert_eq!(StandardWeight::from_mm(0.01).unwrap().code(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_physical_weights_are_refused_not_clamped_or_symbols() {
|
||||
for value in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 2.11_f64.next_up()] {
|
||||
assert!(StandardWeight::from_mm(value).is_err());
|
||||
}
|
||||
assert!(StandardWeight::from_code(6).is_err());
|
||||
assert_eq!(StandardWeight::from_mm(-0.0).unwrap().code(), 0);
|
||||
assert_eq!(StandardWeight::from_mm(2.11).unwrap().code(), 211);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_and_symbolic_entity_weights_remain_distinct() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
let fifty = StandardWeight::from_code(50).unwrap();
|
||||
let cut = file.declare_layer("CUT", 1, DxfLinetype::Continuous, Some(fifty)).unwrap();
|
||||
for weight in [
|
||||
None,
|
||||
Some(EntityWeight::ByLayer),
|
||||
Some(EntityWeight::ByBlock),
|
||||
Some(EntityWeight::Default),
|
||||
Some(EntityWeight::Explicit(StandardWeight::from_code(0).unwrap())),
|
||||
] {
|
||||
file.push(cut, (), weight).unwrap();
|
||||
}
|
||||
assert_eq!(file.entities()[0].weight(), None);
|
||||
assert_eq!(file.entities()[1].weight(), Some(EntityWeight::ByLayer));
|
||||
assert_eq!(file.effective_weight(0).unwrap(), EntityWeight::Explicit(fifty));
|
||||
assert_eq!(file.effective_weight(1).unwrap(), EntityWeight::Explicit(fifty));
|
||||
assert_eq!(file.effective_weight(2).unwrap(), EntityWeight::ByBlock);
|
||||
assert_eq!(file.effective_weight(3).unwrap(), EntityWeight::Default);
|
||||
assert_eq!(file.effective_weight(4).unwrap(), EntityWeight::Explicit(StandardWeight::from_code(0).unwrap()));
|
||||
assert_eq!(EntityWeight::ByLayer.group370(), -1);
|
||||
assert_eq!(EntityWeight::ByBlock.group370(), -2);
|
||||
assert_eq!(EntityWeight::Default.group370(), -3);
|
||||
assert_eq!(EntityWeight::Explicit(fifty).group370(), 50);
|
||||
assert!(file.effective_weight(5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cumulative_limits_refuse_the_next_insert_without_a_partial_entity() {
|
||||
let mut file = DxfFile::with_limits(2, 1).unwrap();
|
||||
file.declare_layer("A", 7, DxfLinetype::Continuous, None).unwrap();
|
||||
assert!(file.declare_layer("B", 7, DxfLinetype::Continuous, None).is_err());
|
||||
file.push(file.default_layer(), (), None).unwrap();
|
||||
assert!(file.push(file.default_layer(), (), None).is_err());
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
assert_eq!(file.entities().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_counter_never_wraps_or_reuses_an_identity() {
|
||||
let counter = AtomicUsize::new(usize::MAX - 1);
|
||||
assert_eq!(owner_from(&counter).unwrap(), usize::MAX - 1);
|
||||
assert!(owner_from(&counter).is_err());
|
||||
assert!(owner_from(&counter).is_err());
|
||||
}
|
||||
|
||||
// Round trips exercise the real writer and layered reader, not group-code test parsers.
|
||||
use crate::DxfShape;
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
fn read_written_file(file: &DxfFile<DxfShape>) -> DxfFile<DxfShape> {
|
||||
crate::read_dxf_file(&crate::write_dxf_file(file).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_reader_keep_mixed_layer_declarations_entity_order_and_file_scoped_handles() {
|
||||
let mut source = DxfFile::new().unwrap();
|
||||
let cut = source.declare_layer("CUT", 1, DxfLinetype::Continuous, Some(StandardWeight::from_code(50).unwrap())).unwrap();
|
||||
let hidden = source.declare_layer("HIDDEN", 8, DxfLinetype::Hidden, Some(StandardWeight::from_code(0).unwrap())).unwrap();
|
||||
source.declare_layer("UNUSED", 3, DxfLinetype::Dashed, None).unwrap();
|
||||
source.push(cut, DxfShape::Line(Vec2::new(-2.0, 1.5), Vec2::new(3.0, 1.5)), None).unwrap();
|
||||
source.push(source.default_layer(), DxfShape::Circle { c: Vec2::new(4.0, -1.0), r: 0.5 }, Some(EntityWeight::ByBlock)).unwrap();
|
||||
source.push(hidden, DxfShape::Arc { c: Vec2::new(-3.0, 2.0), r: 2.0, a0: 350.0, a1: 10.0 }, Some(EntityWeight::ByLayer)).unwrap();
|
||||
source.push(cut, DxfShape::Line(Vec2::new(7.0, 0.0), Vec2::new(7.0, 4.0)), Some(EntityWeight::Default)).unwrap();
|
||||
|
||||
let recovered = read_written_file(&source);
|
||||
let layers: Vec<_> = recovered
|
||||
.layers()
|
||||
.iter()
|
||||
.map(|layer| (layer.name(), layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
layers,
|
||||
[
|
||||
("0", 7, DxfLinetype::Continuous, None),
|
||||
("CUT", 1, DxfLinetype::Continuous, Some(50)),
|
||||
("HIDDEN", 8, DxfLinetype::Hidden, Some(0)),
|
||||
("UNUSED", 3, DxfLinetype::Dashed, None),
|
||||
]
|
||||
);
|
||||
let names: Vec<_> = recovered.entities().iter().map(|entity| recovered.layer(entity.layer()).unwrap().name()).collect();
|
||||
assert_eq!(names, ["CUT", "0", "HIDDEN", "CUT"]);
|
||||
assert_eq!(recovered.entities().len(), 4);
|
||||
assert_eq!(recovered.entities()[0].geometry(), &DxfShape::Line(Vec2::new(-2.0, 1.5), Vec2::new(3.0, 1.5)));
|
||||
assert_eq!(recovered.entities()[1].geometry(), &DxfShape::Circle { c: Vec2::new(4.0, -1.0), r: 0.5 });
|
||||
assert_eq!(recovered.entities()[2].geometry(), &DxfShape::Arc { c: Vec2::new(-3.0, 2.0), r: 2.0, a0: 350.0, a1: 10.0 });
|
||||
assert_eq!(recovered.entities()[3].geometry(), &DxfShape::Line(Vec2::new(7.0, 0.0), Vec2::new(7.0, 4.0)));
|
||||
assert_eq!(
|
||||
recovered.entities().iter().map(Entity::weight).collect::<Vec<_>>(),
|
||||
[None, Some(EntityWeight::ByBlock), Some(EntityWeight::ByLayer), Some(EntityWeight::Default)]
|
||||
);
|
||||
// Imported handles belong to the new file; compare resolved metadata, not owner IDs.
|
||||
assert!(recovered.layer(cut).is_err());
|
||||
assert!(recovered.layer(source.default_layer()).is_err());
|
||||
assert!(source.layer(recovered.default_layer()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_reader_keep_omission_symbols_and_all_physical_weights_in_each_linetype() {
|
||||
let mut weights = vec![None, Some(EntityWeight::ByLayer), Some(EntityWeight::ByBlock), Some(EntityWeight::Default)];
|
||||
// Independent explicit public code list: not the model's internal lookup table.
|
||||
for code in [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211] {
|
||||
weights.push(Some(EntityWeight::Explicit(StandardWeight::from_code(code).unwrap())));
|
||||
}
|
||||
let styles = [
|
||||
("MEASURED", 1, DxfLinetype::Continuous, Some(50)),
|
||||
("OMITTED", 3, DxfLinetype::Dashed, None),
|
||||
("ZERO_WEIGHT", 8, DxfLinetype::Hidden, Some(0)),
|
||||
];
|
||||
let mut source = DxfFile::new().unwrap();
|
||||
for (name, color, linetype, weight) in styles {
|
||||
let layer = source.declare_layer(name, color, linetype, weight.map(|code| StandardWeight::from_code(code).unwrap())).unwrap();
|
||||
for weight in &weights {
|
||||
source.push(layer, DxfShape::Line(Vec2::new(0.5, 0.75), Vec2::new(1.25, -0.5)), *weight).unwrap();
|
||||
}
|
||||
}
|
||||
let recovered = read_written_file(&source);
|
||||
assert_eq!(recovered.layers().len(), 4);
|
||||
assert_eq!(recovered.entities().len(), 84);
|
||||
for (style_index, (name, color, linetype, layer_weight)) in styles.iter().enumerate() {
|
||||
for (weight_index, expected_weight) in weights.iter().enumerate() {
|
||||
let index = style_index * 28 + weight_index;
|
||||
let entity = &recovered.entities()[index];
|
||||
let layer = recovered.layer(entity.layer()).unwrap();
|
||||
assert_eq!(
|
||||
(layer.name(), layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)),
|
||||
(*name, *color, *linetype, *layer_weight)
|
||||
);
|
||||
assert_eq!(entity.weight(), *expected_weight, "style={name} weight ordinal={weight_index}");
|
||||
assert_eq!(entity.geometry(), &DxfShape::Line(Vec2::new(0.5, 0.75), Vec2::new(1.25, -0.5)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_reader_keep_exact_supported_curves_on_all_three_layer_styles() {
|
||||
for (name, linetype) in [("SOLID", DxfLinetype::Continuous), ("DASH", DxfLinetype::Dashed), ("HIDE", DxfLinetype::Hidden)] {
|
||||
let mut source = DxfFile::new().unwrap();
|
||||
let layer = source.declare_layer(name, 5, linetype, None).unwrap();
|
||||
for shape in [
|
||||
DxfShape::Line(Vec2::new(-1.25, 2.5), Vec2::new(3.75, -4.0)),
|
||||
DxfShape::Circle { c: Vec2::new(8.0, -2.0), r: 1.25 },
|
||||
DxfShape::Arc { c: Vec2::new(2.0, 3.0), r: 0.75, a0: 45.0, a1: 135.0 },
|
||||
// Open straight chain, with an intentional repeated vertex retained.
|
||||
DxfShape::Polyline { points: vec![Vec2::new(-2.0, 0.0), Vec2::new(0.0, 1.5), Vec2::new(0.0, 1.5), Vec2::new(4.0, -0.5)] },
|
||||
] {
|
||||
source.push(layer, shape, Some(EntityWeight::ByLayer)).unwrap();
|
||||
}
|
||||
let recovered = read_written_file(&source);
|
||||
assert_eq!(recovered.entities().len(), 4);
|
||||
assert_eq!(recovered.entities()[0].geometry(), &DxfShape::Line(Vec2::new(-1.25, 2.5), Vec2::new(3.75, -4.0)));
|
||||
assert_eq!(recovered.entities()[1].geometry(), &DxfShape::Circle { c: Vec2::new(8.0, -2.0), r: 1.25 });
|
||||
assert_eq!(recovered.entities()[2].geometry(), &DxfShape::Arc { c: Vec2::new(2.0, 3.0), r: 0.75, a0: 45.0, a1: 135.0 });
|
||||
assert_eq!(
|
||||
recovered.entities()[3].geometry(),
|
||||
&DxfShape::Polyline { points: vec![Vec2::new(-2.0, 0.0), Vec2::new(0.0, 1.5), Vec2::new(0.0, 1.5), Vec2::new(4.0, -0.5)] }
|
||||
);
|
||||
for entity in recovered.entities() {
|
||||
let layer = recovered.layer(entity.layer()).unwrap();
|
||||
assert_eq!((layer.name(), layer.linetype()), (name, linetype));
|
||||
assert_eq!(entity.weight(), Some(EntityWeight::ByLayer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_reader_keep_degree_nine_nonunit_knots_controls_and_optional_rational_weights() {
|
||||
for linetype in [DxfLinetype::Continuous, DxfLinetype::Dashed, DxfLinetype::Hidden] {
|
||||
let mut source = DxfFile::new().unwrap();
|
||||
let layer = source.declare_layer("CURVE", 2, linetype, Some(StandardWeight::from_code(35).unwrap())).unwrap();
|
||||
for weights in [None, Some(vec![1.0; 10]), Some(vec![0.5, 1.0, 2.0, 4.0, 0.25, 0.5, 1.0, 2.0, 4.0, 1.0])] {
|
||||
source
|
||||
.push(
|
||||
layer,
|
||||
DxfShape::Spline {
|
||||
degree: 9,
|
||||
knots: vec![-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0],
|
||||
ctrl: vec![
|
||||
Vec2::new(0.0, 0.0),
|
||||
Vec2::new(1.0, 2.0),
|
||||
Vec2::new(2.0, 4.0),
|
||||
Vec2::new(3.0, 8.0),
|
||||
Vec2::new(4.0, 4.0),
|
||||
Vec2::new(5.0, 2.0),
|
||||
Vec2::new(6.0, 1.0),
|
||||
Vec2::new(7.0, 0.0),
|
||||
Vec2::new(8.0, -1.0),
|
||||
Vec2::new(9.0, -2.0),
|
||||
],
|
||||
weights,
|
||||
},
|
||||
Some(EntityWeight::Explicit(StandardWeight::from_code(0).unwrap())),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let recovered = read_written_file(&source);
|
||||
assert_eq!(recovered.entities().len(), 3);
|
||||
for (index, entity) in recovered.entities().iter().enumerate() {
|
||||
let DxfShape::Spline { degree, knots, ctrl, weights } = entity.geometry() else { panic!("expected degree-nine spline") };
|
||||
assert_eq!(*degree, 9);
|
||||
assert_eq!(
|
||||
ctrl,
|
||||
&[
|
||||
Vec2::new(0.0, 0.0),
|
||||
Vec2::new(1.0, 2.0),
|
||||
Vec2::new(2.0, 4.0),
|
||||
Vec2::new(3.0, 8.0),
|
||||
Vec2::new(4.0, 4.0),
|
||||
Vec2::new(5.0, 2.0),
|
||||
Vec2::new(6.0, 1.0),
|
||||
Vec2::new(7.0, 0.0),
|
||||
Vec2::new(8.0, -1.0),
|
||||
Vec2::new(9.0, -2.0)
|
||||
]
|
||||
);
|
||||
assert_eq!(knots, &[-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0]);
|
||||
match index {
|
||||
0 => assert_eq!(weights, &None),
|
||||
1 => assert_eq!(weights.as_deref(), Some([1.0; 10].as_slice())),
|
||||
2 => assert_eq!(weights.as_deref(), Some([0.5, 1.0, 2.0, 4.0, 0.25, 0.5, 1.0, 2.0, 4.0, 1.0].as_slice())),
|
||||
_ => panic!("unexpected extra spline"),
|
||||
}
|
||||
let layer = recovered.layer(entity.layer()).unwrap();
|
||||
assert_eq!((layer.name(), layer.linetype(), layer.weight().map(StandardWeight::code)), ("CURVE", linetype, Some(35)));
|
||||
assert_eq!(entity.weight(), Some(EntityWeight::Explicit(StandardWeight::from_code(0).unwrap())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_reader_keep_degree_nine_interior_knots_without_normalizing_the_domain() {
|
||||
let mut source = DxfFile::new().unwrap();
|
||||
source
|
||||
.push(
|
||||
source.default_layer(),
|
||||
DxfShape::Spline {
|
||||
degree: 9,
|
||||
knots: vec![
|
||||
-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -0.5, 2.5, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0,
|
||||
],
|
||||
ctrl: vec![
|
||||
Vec2::new(0.0, 0.0),
|
||||
Vec2::new(1.0, 1.0),
|
||||
Vec2::new(2.0, 4.0),
|
||||
Vec2::new(3.0, 1.0),
|
||||
Vec2::new(4.0, 0.0),
|
||||
Vec2::new(5.0, -1.0),
|
||||
Vec2::new(6.0, -2.0),
|
||||
Vec2::new(7.0, -1.0),
|
||||
Vec2::new(8.0, 0.0),
|
||||
Vec2::new(9.0, 1.0),
|
||||
Vec2::new(10.0, 2.0),
|
||||
Vec2::new(11.0, 0.0),
|
||||
],
|
||||
weights: Some(vec![1.0, 0.5, 2.0, 1.0, 0.25, 4.0, 1.0, 2.0, 0.5, 1.0, 4.0, 1.0]),
|
||||
},
|
||||
Some(EntityWeight::Default),
|
||||
)
|
||||
.unwrap();
|
||||
let recovered = read_written_file(&source);
|
||||
assert_eq!(recovered.entities().len(), 1);
|
||||
let entity = &recovered.entities()[0];
|
||||
let DxfShape::Spline { degree, knots, ctrl, weights } = entity.geometry() else { panic!("expected degree-nine spline") };
|
||||
assert_eq!(*degree, 9);
|
||||
assert_eq!(knots, &[-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -0.5, 2.5, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0]);
|
||||
assert_eq!(
|
||||
ctrl,
|
||||
&[
|
||||
Vec2::new(0.0, 0.0),
|
||||
Vec2::new(1.0, 1.0),
|
||||
Vec2::new(2.0, 4.0),
|
||||
Vec2::new(3.0, 1.0),
|
||||
Vec2::new(4.0, 0.0),
|
||||
Vec2::new(5.0, -1.0),
|
||||
Vec2::new(6.0, -2.0),
|
||||
Vec2::new(7.0, -1.0),
|
||||
Vec2::new(8.0, 0.0),
|
||||
Vec2::new(9.0, 1.0),
|
||||
Vec2::new(10.0, 2.0),
|
||||
Vec2::new(11.0, 0.0)
|
||||
]
|
||||
);
|
||||
assert_eq!(weights.as_deref(), Some([1.0, 0.5, 2.0, 1.0, 0.25, 4.0, 1.0, 2.0, 0.5, 1.0, 4.0, 1.0].as_slice()));
|
||||
assert_eq!(recovered.layer(entity.layer()).unwrap().name(), "0");
|
||||
assert_eq!(entity.weight(), Some(EntityWeight::Default));
|
||||
}
|
||||
|
||||
// DEPENDENT CASE ONLY: apply after W1's real DxfShape::Point(Vec2) and writer
|
||||
// support plus Kernel's real POINT read_dxf_file branch are integrated.
|
||||
// No fake variant/API, ignore attribute or active-source compile claim.
|
||||
#[test]
|
||||
fn writer_reader_keep_point_geometry_layer_and_stored_weight() {
|
||||
let mut source = DxfFile::new().unwrap();
|
||||
let points = source.declare_layer("DATUM", 4, DxfLinetype::Hidden, Some(StandardWeight::from_code(50).unwrap())).unwrap();
|
||||
source.push(points, DxfShape::Point(Vec2::new(-0.5, 1.25)), Some(EntityWeight::ByBlock)).unwrap();
|
||||
source.push(source.default_layer(), DxfShape::Point(Vec2::new(8.0, -2.0)), None).unwrap();
|
||||
let recovered = read_written_file(&source);
|
||||
assert_eq!(recovered.entities().len(), 2);
|
||||
assert_eq!(recovered.entities()[0].geometry(), &DxfShape::Point(Vec2::new(-0.5, 1.25)));
|
||||
assert_eq!(recovered.entities()[1].geometry(), &DxfShape::Point(Vec2::new(8.0, -2.0)));
|
||||
let layer = recovered.layer(recovered.entities()[0].layer()).unwrap();
|
||||
assert_eq!(
|
||||
(layer.name(), layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)),
|
||||
("DATUM", 4, DxfLinetype::Hidden, Some(50))
|
||||
);
|
||||
assert_eq!(recovered.entities()[0].weight(), Some(EntityWeight::ByBlock));
|
||||
assert_eq!(recovered.layer(recovered.entities()[1].layer()).unwrap().name(), "0");
|
||||
assert_eq!(recovered.entities()[1].weight(), None);
|
||||
assert!(recovered.layer(points).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_zero_default_constructor_keeps_new_metadata_and_writer_bytes() {
|
||||
let mut original = DxfFile::new().unwrap();
|
||||
let mut imported = DxfFile::with_layer_zero(7, DxfLinetype::Continuous, None).unwrap();
|
||||
for file in [&original, &imported] {
|
||||
assert_eq!(file.layers().len(), 1);
|
||||
assert!(file.entities().is_empty());
|
||||
let zero = file.layer(file.default_layer()).unwrap();
|
||||
assert_eq!((zero.name(), zero.color(), zero.linetype(), zero.weight()), ("0", 7, DxfLinetype::Continuous, None));
|
||||
}
|
||||
assert_eq!(crate::write_dxf_file(&original).unwrap(), crate::write_dxf_file(&imported).unwrap());
|
||||
original.push(original.default_layer(), DxfShape::Line(Vec2::new(-1.0, 0.5), Vec2::new(2.0, 0.5)), None).unwrap();
|
||||
imported.push(imported.default_layer(), DxfShape::Line(Vec2::new(-1.0, 0.5), Vec2::new(2.0, 0.5)), None).unwrap();
|
||||
assert_eq!(original.effective_weight(0).unwrap(), EntityWeight::Default);
|
||||
assert_eq!(imported.effective_weight(0).unwrap(), EntityWeight::Default);
|
||||
assert_eq!(crate::write_dxf_file(&original).unwrap(), crate::write_dxf_file(&imported).unwrap());
|
||||
assert!(original.layer(imported.default_layer()).is_err());
|
||||
assert!(imported.layer(original.default_layer()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_layer_zero_is_first_and_immutable_through_every_zero_alias() {
|
||||
let zero_weight = Some(StandardWeight::from_code(0).unwrap());
|
||||
let mut file = DxfFile::with_layer_zero(3, DxfLinetype::Hidden, zero_weight).unwrap();
|
||||
let zero = file.default_layer();
|
||||
assert!(file.entities().is_empty());
|
||||
let cut = file.declare_layer("CUT", 1, DxfLinetype::Dashed, None).unwrap();
|
||||
file.push(zero, (), None).unwrap();
|
||||
for alias in ["0", " 0 ", "\t0\r\n", "", " \t\r\n\x0b\x0c "] {
|
||||
assert_eq!(file.declare_layer(alias, 3, DxfLinetype::Hidden, zero_weight).unwrap(), zero);
|
||||
assert!(file.declare_layer(alias, 7, DxfLinetype::Hidden, zero_weight).is_err());
|
||||
assert!(file.declare_layer(alias, 3, DxfLinetype::Continuous, zero_weight).is_err());
|
||||
assert!(file.declare_layer(alias, 3, DxfLinetype::Hidden, None).is_err());
|
||||
}
|
||||
assert_eq!(file.layers().iter().map(Layer::name).collect::<Vec<_>>(), ["0", "CUT"]);
|
||||
let layer = file.layer(zero).unwrap();
|
||||
assert_eq!((layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)), (3, DxfLinetype::Hidden, Some(0)));
|
||||
assert_eq!(file.layer(cut).unwrap().name(), "CUT");
|
||||
assert_eq!(file.entities().len(), 1);
|
||||
assert_eq!(file.entities()[0].layer(), zero);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_zero_inheritance_keeps_omission_symbols_and_physical_zero_distinct() {
|
||||
for code in [None, Some(0), Some(50)] {
|
||||
let layer_weight = code.map(|value| StandardWeight::from_code(value).unwrap());
|
||||
let mut file = DxfFile::with_layer_zero(5, DxfLinetype::Dashed, layer_weight).unwrap();
|
||||
for weight in [
|
||||
None,
|
||||
Some(EntityWeight::ByLayer),
|
||||
Some(EntityWeight::ByBlock),
|
||||
Some(EntityWeight::Default),
|
||||
Some(EntityWeight::Explicit(StandardWeight::from_code(13).unwrap())),
|
||||
] {
|
||||
file.push(file.default_layer(), (), weight).unwrap();
|
||||
}
|
||||
let inherited = match code {
|
||||
None => EntityWeight::Default,
|
||||
Some(0) => EntityWeight::Explicit(StandardWeight::from_code(0).unwrap()),
|
||||
Some(50) => EntityWeight::Explicit(StandardWeight::from_code(50).unwrap()),
|
||||
_ => panic!("unexpected fixture weight"),
|
||||
};
|
||||
assert_eq!(file.layer(file.default_layer()).unwrap().weight().map(StandardWeight::code), code);
|
||||
assert_eq!(file.entities()[0].weight(), None);
|
||||
assert_eq!(file.entities()[1].weight(), Some(EntityWeight::ByLayer));
|
||||
assert_eq!(file.effective_weight(0).unwrap(), inherited);
|
||||
assert_eq!(file.effective_weight(1).unwrap(), inherited);
|
||||
assert_eq!(file.effective_weight(2).unwrap(), EntityWeight::ByBlock);
|
||||
assert_eq!(file.effective_weight(3).unwrap(), EntityWeight::Default);
|
||||
assert_eq!(file.effective_weight(4).unwrap(), EntityWeight::Explicit(StandardWeight::from_code(13).unwrap()));
|
||||
assert_eq!(file.effective_linetype(0).unwrap(), DxfLinetype::Dashed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_layer_zero_uses_validated_colors_and_standard_weight_types() {
|
||||
for color in [i16::MIN, -1, 0, 256, i16::MAX] {
|
||||
let error = DxfFile::<()>::with_layer_zero(color, DxfLinetype::Continuous, None).unwrap_err();
|
||||
assert!(error.to_string().contains("color must be an index from 1 to 255"));
|
||||
}
|
||||
for color in [1, 255] {
|
||||
let file = DxfFile::<()>::with_layer_zero(color, DxfLinetype::Hidden, Some(StandardWeight::from_code(211).unwrap())).unwrap();
|
||||
let zero = file.layer(file.default_layer()).unwrap();
|
||||
assert_eq!((zero.color(), zero.linetype(), zero.weight().map(StandardWeight::code)), (color, DxfLinetype::Hidden, Some(211)));
|
||||
}
|
||||
assert!(StandardWeight::from_code(6).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_zero_handles_are_fresh_and_foreign_insertion_is_atomic() {
|
||||
let first = DxfFile::<()>::with_layer_zero(1, DxfLinetype::Dashed, None).unwrap();
|
||||
let mut second = DxfFile::with_layer_zero(1, DxfLinetype::Dashed, None).unwrap();
|
||||
let mut ordinary = DxfFile::new().unwrap();
|
||||
assert!(second.push(first.default_layer(), (), None).is_err());
|
||||
assert!(ordinary.push(second.default_layer(), (), None).is_err());
|
||||
assert!(second.push(ordinary.default_layer(), (), None).is_err());
|
||||
assert!(second.entities().is_empty());
|
||||
assert!(ordinary.entities().is_empty());
|
||||
second.push(second.default_layer(), (), None).unwrap();
|
||||
assert_eq!(second.entities().len(), 1);
|
||||
assert_eq!(second.layers().len(), 1);
|
||||
assert_eq!(second.layer(second.entities()[0].layer()).unwrap().name(), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_zero_constructor_keeps_fallible_resource_limits() {
|
||||
assert!(DxfFile::<()>::with_layer_zero_limits(1, DxfLinetype::Dashed, None, 0, 1).is_err());
|
||||
let mut empty = DxfFile::with_layer_zero_limits(1, DxfLinetype::Dashed, None, 1, 0).unwrap();
|
||||
assert!(empty.push(empty.default_layer(), (), None).is_err());
|
||||
assert!(empty.entities().is_empty());
|
||||
let mut file = DxfFile::with_layer_zero_limits(2, DxfLinetype::Hidden, None, 2, 1).unwrap();
|
||||
file.declare_layer("CUT", 1, DxfLinetype::Continuous, None).unwrap();
|
||||
assert!(file.declare_layer("EXCESS", 1, DxfLinetype::Continuous, None).is_err());
|
||||
file.push(file.default_layer(), (), None).unwrap();
|
||||
assert!(file.push(file.default_layer(), (), None).is_err());
|
||||
assert_eq!((file.layers().len(), file.entities().len()), (2, 1));
|
||||
let bounded = DxfFile::<()>::with_layer_zero_limits(7, DxfLinetype::Continuous, None, usize::MAX, usize::MAX).unwrap();
|
||||
assert_eq!((bounded.max_layers, bounded.max_entities), (1024, 1_000_000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Bounded admission and conservative bounds for the real X1 BulgedPolyline.
|
||||
//! Bounds contain each whole supporting circle, not only the directed arc.
|
||||
//! No trig/hypot, sampling, output array, topology repair or physical stroke width.
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use super::super::{DxfVertex, path::MAX_PATH_ITEMS};
|
||||
use super::DxfGeometryBounds;
|
||||
use crate::{IoError, Result};
|
||||
|
||||
const MAX_WORK: usize = 64 << 20;
|
||||
const EDGE_WORK: usize = 512;
|
||||
|
||||
fn invalid(message: &str) -> IoError {
|
||||
IoError::Invalid(format!("DXF bulged polyline bounds: {message}"))
|
||||
}
|
||||
|
||||
// Intervals enclose REAL arithmetic on the supplied binary64 endpoints/bulge.
|
||||
// Nonzero subnormal inputs/intermediates and infinite outward endpoints refuse.
|
||||
// This deliberately conservative admission is not a tolerance or ULP-accuracy
|
||||
// promise for any transcendental function or the old reconstructed Arc sampler.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Interval {
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
}
|
||||
|
||||
impl Interval {
|
||||
fn checked(lo: f64, hi: f64) -> Result<Self> {
|
||||
if lo > hi || !lo.is_finite() || !hi.is_finite() || (lo != 0.0 && !lo.is_normal()) || (hi != 0.0 && !hi.is_normal()) {
|
||||
return Err(invalid("nonfinite or subnormal support interval is not representable"));
|
||||
}
|
||||
Ok(Self { lo, hi })
|
||||
}
|
||||
|
||||
fn exact(value: f64) -> Result<Self> {
|
||||
Self::checked(value, value)
|
||||
}
|
||||
|
||||
fn rounded(value: f64) -> Result<Self> {
|
||||
if value == 0.0 || !value.is_normal() {
|
||||
return Err(invalid("support arithmetic overflow or underflow"));
|
||||
}
|
||||
Self::checked(value.next_down(), value.next_up())
|
||||
}
|
||||
|
||||
fn neg(self) -> Self {
|
||||
Self { lo: -self.hi, hi: -self.lo }
|
||||
}
|
||||
|
||||
fn sum(a: f64, b: f64) -> Result<Self> {
|
||||
if a == 0.0 {
|
||||
return Self::exact(b);
|
||||
}
|
||||
if b == 0.0 {
|
||||
return Self::exact(a);
|
||||
}
|
||||
if a == -b {
|
||||
return Self::exact(0.0);
|
||||
}
|
||||
Self::rounded(a + b)
|
||||
}
|
||||
|
||||
fn product(a: f64, b: f64) -> Result<Self> {
|
||||
if a == 0.0 || b == 0.0 {
|
||||
return Self::exact(0.0);
|
||||
}
|
||||
if a.abs() == 1.0 {
|
||||
return Self::exact(if a < 0.0 { -b } else { b });
|
||||
}
|
||||
if b.abs() == 1.0 {
|
||||
return Self::exact(if b < 0.0 { -a } else { a });
|
||||
}
|
||||
Self::rounded(a * b)
|
||||
}
|
||||
|
||||
fn quotient(a: f64, b: f64) -> Result<Self> {
|
||||
if b == 0.0 {
|
||||
return Err(invalid("support divisor contains zero"));
|
||||
}
|
||||
if a == 0.0 {
|
||||
return Self::exact(0.0);
|
||||
}
|
||||
if b.abs() == 1.0 {
|
||||
return Self::exact(if b < 0.0 { -a } else { a });
|
||||
}
|
||||
Self::rounded(a / b)
|
||||
}
|
||||
|
||||
fn hull(self, other: Self) -> Result<Self> {
|
||||
Self::checked(self.lo.min(other.lo), self.hi.max(other.hi))
|
||||
}
|
||||
|
||||
fn add(self, rhs: Self) -> Result<Self> {
|
||||
let lo = Self::sum(self.lo, rhs.lo)?.lo;
|
||||
let hi = Self::sum(self.hi, rhs.hi)?.hi;
|
||||
Self::checked(lo, hi)
|
||||
}
|
||||
|
||||
fn sub(self, rhs: Self) -> Result<Self> {
|
||||
self.add(rhs.neg())
|
||||
}
|
||||
|
||||
fn mul(self, rhs: Self) -> Result<Self> {
|
||||
Self::product(self.lo, rhs.lo)?
|
||||
.hull(Self::product(self.lo, rhs.hi)?)?
|
||||
.hull(Self::product(self.hi, rhs.lo)?)?
|
||||
.hull(Self::product(self.hi, rhs.hi)?)
|
||||
}
|
||||
|
||||
fn div(self, rhs: Self) -> Result<Self> {
|
||||
if rhs.lo <= 0.0 && rhs.hi >= 0.0 {
|
||||
return Err(invalid("support divisor contains zero"));
|
||||
}
|
||||
Self::quotient(self.lo, rhs.lo)?
|
||||
.hull(Self::quotient(self.lo, rhs.hi)?)?
|
||||
.hull(Self::quotient(self.hi, rhs.lo)?)?
|
||||
.hull(Self::quotient(self.hi, rhs.hi)?)
|
||||
}
|
||||
|
||||
fn abs(self) -> Result<Self> {
|
||||
let hi = self.lo.abs().max(self.hi.abs());
|
||||
let lo = if self.lo <= 0.0 && self.hi >= 0.0 { 0.0 } else { self.lo.abs().min(self.hi.abs()) };
|
||||
Self::checked(lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
fn supporting_arc(from: DxfVertex, to: Vec2) -> Result<DxfGeometryBounds> {
|
||||
let (x0, y0) = (Interval::exact(from.at.x)?, Interval::exact(from.at.y)?);
|
||||
let (x1, y1) = (Interval::exact(to.x)?, Interval::exact(to.y)?);
|
||||
let b = Interval::exact(from.bulge)?;
|
||||
let b2 = b.mul(b)?;
|
||||
let one = Interval::exact(1.0)?;
|
||||
let two = Interval::exact(2.0)?;
|
||||
let four = Interval::exact(4.0)?;
|
||||
let (dx, dy) = (x1.sub(x0)?, y1.sub(y0)?);
|
||||
// Centre = midpoint + perp(chord/(4b)) * (1-b²).
|
||||
// Dividing the chord components first avoids an unbounded standalone1/b.
|
||||
let divisor = four.mul(b)?;
|
||||
let (qx, qy) = (dx.div(divisor)?, dy.div(divisor)?);
|
||||
let factor = one.sub(b2)?;
|
||||
let cx = x0.div(two)?.add(x1.div(two)?)?.sub(qy.mul(factor)?)?;
|
||||
let cy = y0.div(two)?.add(y1.div(two)?)?.add(qx.mul(factor)?)?;
|
||||
// r = ||chord/(4b)||2 * (1+b²) <= its L1 analogue.
|
||||
// All terms are enclosed before selecting the upper radius, so no assumed
|
||||
// libm rounding bound or single-final-ULP shortcut is hidden here.
|
||||
let radius = qx.abs()?.add(qy.abs()?)?.mul(one.add(b2)?)?.hi;
|
||||
if radius <= 0.0 {
|
||||
return Err(invalid("support radius has collapsed"));
|
||||
}
|
||||
let radius = Interval::exact(radius)?;
|
||||
let x = cx.sub(radius)?.hull(cx.add(radius)?)?;
|
||||
let y = cy.sub(radius)?.hull(cy.add(radius)?)?;
|
||||
DxfGeometryBounds::new(Vec2::new(x.lo, y.lo), Vec2::new(x.hi, y.hi))
|
||||
}
|
||||
|
||||
fn edge(from: DxfVertex, to: Vec2) -> Result<DxfGeometryBounds> {
|
||||
if from.at == to && from.bulge != 0.0 {
|
||||
return Err(invalid("zero chord cannot carry a nonzero bulge"));
|
||||
}
|
||||
let endpoints = DxfGeometryBounds::new(Vec2::new(from.at.x.min(to.x), from.at.y.min(to.y)), Vec2::new(from.at.x.max(to.x), from.at.y.max(to.y)))?;
|
||||
if from.bulge == 0.0 {
|
||||
return Ok(endpoints);
|
||||
}
|
||||
let support = supporting_arc(from, to)?;
|
||||
// Include stored endpoints explicitly even for an extremely wide support
|
||||
// interval; neither a support reconstruction nor a hull may discard them.
|
||||
Ok(union(endpoints, support))
|
||||
}
|
||||
|
||||
fn union(a: DxfGeometryBounds, b: DxfGeometryBounds) -> DxfGeometryBounds {
|
||||
DxfGeometryBounds { min: Vec2::new(a.min.x.min(b.min.x), a.min.y.min(b.min.y)), max: Vec2::new(a.max.x.max(b.max.x), a.max.y.max(b.max.y)) }
|
||||
}
|
||||
|
||||
/// New internal proposal: validate/bound the real X1 vertices, without copying.
|
||||
/// max_vertices/max_work may tighten hard limits only. Work is len +512*edges,
|
||||
/// checked BEFORE scanning values. Caller must charge that same deterministic
|
||||
/// cost cumulatively across the file; passing a fresh per-entity cap is not a
|
||||
/// whole-file budget. No hidden caller or model/variant adoption is assumed.
|
||||
pub(super) fn bounds(vertices: &[DxfVertex], closed: bool, max_vertices: usize, max_work: usize) -> Result<DxfGeometryBounds> {
|
||||
let count = vertices.len();
|
||||
if !(2..=max_vertices.min(MAX_PATH_ITEMS)).contains(&count) {
|
||||
return Err(invalid("vertex count exceeds admission or needs at least two vertices"));
|
||||
}
|
||||
let edges = if closed { count } else { count - 1 };
|
||||
let work = edges.checked_mul(EDGE_WORK).and_then(|cost| cost.checked_add(count)).ok_or_else(|| invalid("work count overflow"))?;
|
||||
if work > max_work.min(MAX_WORK) {
|
||||
return Err(invalid("work budget exceeded before vertex scan"));
|
||||
}
|
||||
if vertices.iter().any(|v| !v.at.is_finite() || !v.bulge.is_finite() || v.bulge.abs() > 1.0) {
|
||||
return Err(invalid("nonfinite vertex/bulge or more than a half turn"));
|
||||
}
|
||||
if !closed && vertices.last().is_some_and(|v| v.bulge != 0.0) {
|
||||
return Err(invalid("open terminal bulge must be zero"));
|
||||
}
|
||||
// Zero-bulge coincident points are valid authored LINE geometry, but must
|
||||
// not silently admit the duplicated terminal vertex of a closed polyline.
|
||||
if closed && vertices.first().zip(vertices.last()).is_some_and(|(first, last)| first.at == last.at) {
|
||||
return Err(invalid("closed polyline must not duplicate its first vertex at the seam"));
|
||||
}
|
||||
let mut pairs = vertices.iter().zip(vertices.iter().skip(1));
|
||||
let (first, second) = pairs.next().ok_or_else(|| invalid("missing first segment"))?;
|
||||
let mut total = edge(*first, second.at)?;
|
||||
for (from, to) in pairs {
|
||||
total = union(total, edge(*from, to.at)?);
|
||||
}
|
||||
if closed {
|
||||
let (last, first) = vertices.last().zip(vertices.first()).ok_or_else(|| invalid("missing closed seam"))?;
|
||||
total = union(total, edge(*last, first.at)?);
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn v(x: f64, y: f64, bulge: f64) -> DxfVertex {
|
||||
DxfVertex { at: Vec2::new(x, y), bulge }
|
||||
}
|
||||
fn read(vertices: &[DxfVertex], closed: bool) -> Result<DxfGeometryBounds> {
|
||||
bounds(vertices, closed, MAX_PATH_ITEMS, MAX_WORK)
|
||||
}
|
||||
fn contains(b: DxfGeometryBounds, x: f64, y: f64) -> bool {
|
||||
b.min.x <= x && x <= b.max.x && b.min.y <= y && y <= b.max.y
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_use_exact_endpoint_hull_without_arc_arithmetic() {
|
||||
let b = read(&[v(-2.0, 3.0, 0.0), v(4.0, -1.0, 0.0), v(0.0, 8.0, 0.0)], false).unwrap();
|
||||
assert_eq!(b, DxfGeometryBounds { min: Vec2::new(-2.0, -1.0), max: Vec2::new(4.0, 8.0) });
|
||||
assert!(read(&[v(-f64::MAX, 0.0, 0.0), v(f64::MAX, 0.0, 0.0)], false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_semicircles_include_all_four_exact_support_cardinals() {
|
||||
for bulge in [-1.0, 1.0] {
|
||||
let b = read(&[v(-1.0, 0.0, bulge), v(1.0, 0.0, 0.0)], false).unwrap();
|
||||
for (x, y) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)] {
|
||||
assert!(contains(b, x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asymmetric_signed_bulges_use_analytic_centre_and_radius_not_writer_output() {
|
||||
// Chord(0,0)->(4,0), b=1/2: centre(2,3/2), radius5/2.
|
||||
for (bulge, cy) in [(0.5, 1.5), (-0.5, -1.5)] {
|
||||
let b = read(&[v(0.0, 0.0, bulge), v(4.0, 0.0, 0.0)], false).unwrap();
|
||||
for (x, y) in [(-0.5, cy), (4.5, cy), (2.0, cy - 2.5), (2.0, cy + 2.5)] {
|
||||
assert!(contains(b, x, y));
|
||||
}
|
||||
}
|
||||
// Oblique chord(0,0)->(4,3), b=1/2: centre(7/8,3), r=25/8.
|
||||
let b = read(&[v(0.0, 0.0, 0.5), v(4.0, 3.0, 0.0)], false).unwrap();
|
||||
for (x, y) in [(-2.25, 3.0), (4.0, 3.0), (0.875, -0.125), (0.875, 6.125)] {
|
||||
assert!(contains(b, x, y));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_last_bulge_is_used_and_duplicate_seam_is_refused() {
|
||||
let b = read(&[v(-1.0, 0.0, 0.0), v(1.0, 0.0, 1.0)], true).unwrap();
|
||||
assert!(contains(b, 0.0, 1.0) && contains(b, 0.0, -1.0));
|
||||
assert!(read(&[v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0)], true).is_err());
|
||||
assert!(read(&[v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.5)], false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonfinite_or_out_of_domain_values_refuse_without_mutating_inputs() {
|
||||
for input in [
|
||||
[v(f64::NAN, 0.0, 0.0), v(1.0, 0.0, 0.0)],
|
||||
[v(0.0, 0.0, f64::INFINITY), v(1.0, 0.0, 0.0)],
|
||||
[v(0.0, 0.0, 1.0000000000000002), v(1.0, 0.0, 0.0)],
|
||||
[v(0.0, 0.0, -1.0000000000000002), v(1.0, 0.0, 0.0)],
|
||||
[v(0.0, 0.0, 0.0), v(1.0, f64::NEG_INFINITY, 0.0)],
|
||||
] {
|
||||
let before = input.map(|v| (v.at.x.to_bits(), v.at.y.to_bits(), v.bulge.to_bits()));
|
||||
assert!(read(&input, false).is_err());
|
||||
assert_eq!(input.map(|v| (v.at.x.to_bits(), v.at.y.to_bits(), v.bulge.to_bits())), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_representable_support_survives_without_squaring_chord() {
|
||||
let b = read(&[v(0.0, 0.0, 1.0), v(1e-200, 0.0, 0.0)], false).unwrap();
|
||||
assert!(contains(b, 0.0, 0.0) && contains(b, 1e-200, 0.0));
|
||||
assert!(b.min.y < 0.0 && b.max.y > 0.0 && b.min.is_finite() && b.max.is_finite());
|
||||
assert!(read(&[v(0.0, 0.0, 1.0), v(f64::from_bits(1), 0.0, 0.0)], false).is_err());
|
||||
assert!(read(&[v(0.0, 0.0, 1e-300), v(1.0, 0.0, 0.0)], false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_or_outward_infinite_support_is_explicitly_refused() {
|
||||
assert!(read(&[v(-f64::MAX, 0.0, 1.0), v(f64::MAX, 0.0, 0.0)], false).is_err());
|
||||
assert!(read(&[v(f64::MAX, 0.0, 0.5), v(f64::MAX, 1e308, 0.0)], false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_and_work_admission_precede_payload_scan_and_are_inclusive() {
|
||||
let input = [v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0)];
|
||||
assert!(bounds(&input, false, 2, 514).is_ok());
|
||||
assert!(bounds(&input, false, 1, 514).is_err());
|
||||
assert!(bounds(&input, false, 2, 513).is_err());
|
||||
assert!(bounds(&input, true, 2, 1026).is_ok());
|
||||
assert!(bounds(&input, true, 2, 1025).is_err());
|
||||
let hostile = [v(f64::NAN, 0.0, 0.0), v(1.0, 0.0, 0.0)];
|
||||
assert!(bounds(&hostile, false, 2, 513).unwrap_err().to_string().contains("work budget"));
|
||||
assert!(bounds(&hostile, false, 1, usize::MAX).unwrap_err().to_string().contains("vertex count"));
|
||||
assert!(bounds(&[], false, usize::MAX, usize::MAX).is_err());
|
||||
assert!(bounds(&input[..1], false, usize::MAX, usize::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_zero_length_line_keeps_exact_point_bounds_and_input_bits() {
|
||||
// Exact migrated former Err fixture, now SUPPORTED to match X1's
|
||||
// original polylines() zero-length LINE -> open two-vertex entity.
|
||||
let original = [v(0.0, 0.0, 0.0), v(0.0, 0.0, 0.0)];
|
||||
assert_eq!(read(&original, false).unwrap(), DxfGeometryBounds { min: Vec2::ZERO, max: Vec2::ZERO });
|
||||
for point in [Vec2::new(3.0, -4.0), Vec2::new(f64::from_bits(1), 0.0), Vec2::new(f64::MAX, -f64::MAX)] {
|
||||
let input = [DxfVertex { at: point, bulge: 0.0 }, DxfVertex { at: point, bulge: 0.0 }];
|
||||
let before = input.map(|v| (v.at.x.to_bits(), v.at.y.to_bits(), v.bulge.to_bits()));
|
||||
assert_eq!(read(&input, false).unwrap(), DxfGeometryBounds { min: point, max: point });
|
||||
assert_eq!(input.map(|v| (v.at.x.to_bits(), v.at.y.to_bits(), v.bulge.to_bits())), before);
|
||||
}
|
||||
let mixed = [v(2.0, 3.0, 0.0), v(2.0, 3.0, 0.0), v(5.0, 7.0, 0.0)];
|
||||
assert_eq!(read(&mixed, false).unwrap(), DxfGeometryBounds { min: Vec2::new(2.0, 3.0), max: Vec2::new(5.0, 7.0) });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_chord_arc_and_explicit_closed_duplicate_seam_still_refuse() {
|
||||
for bulge in [-1.0, -0.5, 0.5, 1.0] {
|
||||
assert!(read(&[v(2.0, 3.0, bulge), v(2.0, 3.0, 0.0)], false).is_err());
|
||||
}
|
||||
for input in [
|
||||
vec![v(0.0, 0.0, 0.0), v(0.0, 0.0, 0.0)],
|
||||
vec![v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0)],
|
||||
vec![v(0.0, 0.0, 1.0), v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0)],
|
||||
] {
|
||||
assert!(read(&input, true).unwrap_err().to_string().contains("closed polyline must not duplicate"));
|
||||
}
|
||||
let nonfinite = [v(f64::INFINITY, 0.0, 0.0), v(f64::INFINITY, 0.0, 0.0)];
|
||||
assert!(read(&nonfinite, false).is_err());
|
||||
assert!(bounds(&[v(0.0, 0.0, 0.0), v(0.0, 0.0, 0.0)], false, 2, 513).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
//! Minimal X1 bulged-polyline support; path conversion/fitting is not ported.
|
||||
|
||||
use solidcraft_geom::{Seg2, Vec2};
|
||||
|
||||
/// A polyline vertex. `bulge` describes the edge leaving `at`: `tan(sweep / 4)`.
|
||||
/// The terminal vertex of an open polyline has zero bulge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DxfVertex {
|
||||
pub at: Vec2,
|
||||
pub bulge: f64,
|
||||
}
|
||||
|
||||
pub(super) const MAX_PATH_ITEMS: usize = 200_000;
|
||||
|
||||
fn circle_bounds_finite(c: Vec2, r: f64) -> bool {
|
||||
c.is_finite() && r.is_finite() && r > 0.0 && (c - Vec2::new(r, r)).is_finite() && (c + Vec2::new(r, r)).is_finite()
|
||||
}
|
||||
|
||||
/// Reconstruct a non-straight polyline edge with a signed sweep for bounds and validation.
|
||||
/// Avoid squaring the chord length or first forming 1/bulge: those can overflow even when
|
||||
/// the resulting radius is finite. A zero chord with a nonzero bulge is not an arc.
|
||||
pub(super) fn bulge_arc(from: DxfVertex, to: Vec2) -> Option<Seg2> {
|
||||
let b = from.bulge;
|
||||
if !(from.at.is_finite() && to.is_finite() && b.is_finite()) || b == 0.0 || b.abs() > 1.0 {
|
||||
return None;
|
||||
}
|
||||
let chord = to - from.at;
|
||||
let length = chord.len();
|
||||
if !chord.is_finite() || !length.is_finite() || length == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let scaled = (length * 0.25) / b;
|
||||
let radius = scaled.abs() * (1.0 + b * b);
|
||||
let center = from.at * 0.5 + to * 0.5 + (chord / length).perp() * (scaled * (1.0 - b * b));
|
||||
if !circle_bounds_finite(center, radius) {
|
||||
return None;
|
||||
}
|
||||
let radial = from.at - center;
|
||||
if !radial.is_finite() || radial == Vec2::ZERO {
|
||||
return None;
|
||||
}
|
||||
let start = radial.angle();
|
||||
let sweep = 4.0 * b.atan();
|
||||
(start.is_finite() && sweep.is_finite() && sweep != 0.0).then_some(Seg2::Arc { center, radius, start, sweep })
|
||||
}
|
||||
@@ -32,8 +32,12 @@ mod threemf;
|
||||
pub mod urdf;
|
||||
pub mod vfs;
|
||||
|
||||
pub use dxf::{read_dxf, write_dxf};
|
||||
pub use dxf_out::{DxfEntity, DxfLayer, DxfLinetype, DxfShape, loop_shapes, seg_shape, write_dxf_drawing};
|
||||
pub use dxf::{read_dxf, read_dxf_file, write_dxf};
|
||||
pub use dxf_out::{
|
||||
DxfEntity, DxfEntityWeight, DxfFile, DxfFileEntity, DxfFileLayer, DxfGeometryBounds, DxfLayer, DxfLayerId, DxfLinetype, DxfShape,
|
||||
DxfStandardWeight, DxfTextHorizontalAlignment, DxfTextVerticalAlignment, DxfVertex, loop_shapes, seg_shape, write_dxf_drawing, write_dxf_file,
|
||||
write_dxf_file_with_text_bounds,
|
||||
};
|
||||
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, PREVIOUS_APP_IDS, adopt_legacy_dir, legacy_flatpak_paths};
|
||||
pub use safe_write::{backup_path, write_atomic};
|
||||
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
|
||||
|
||||
@@ -2728,20 +2728,43 @@ pub fn take_theme_paste(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
|
||||
/// Files dropped on the theme Import tile are themes to import, not designs to open (#8226).
|
||||
pub fn take_theme_drops(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(rect) = app.prefs_window.import_tile.filter(|_| app.prefs_window.open) else { return };
|
||||
if !ctx.input(|i| !i.raw.dropped_files.is_empty() && i.pointer.latest_pos().is_some_and(|p| rect.contains(p))) {
|
||||
if !theme_drop_target(app, ctx) || !ctx.input(|i| !i.raw.dropped_files.is_empty()) {
|
||||
return;
|
||||
}
|
||||
let files = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in files.iter().take(8) {
|
||||
let text = f
|
||||
.bytes()
|
||||
.and_then(|b| if b.len() > crate::theme_files::MAX_BYTES { Err("the file is too large for a theme".into()) } else { Ok(b) })
|
||||
.and_then(|b| String::from_utf8(b).map_err(|_| "not a theme file".to_string()));
|
||||
import_theme_text(app, text);
|
||||
// The browser reads dropped files only asynchronously: the web shell takes them first and
|
||||
// hands a theme's bytes to `import_theme_bytes` (#8226), so none reach this on wasm.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
match f.bytes() {
|
||||
Ok(bytes) => import_theme_bytes(app, bytes),
|
||||
Err(e) => import_theme_text(app, Err(e.to_string())),
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = f;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether files dropped now land on the Import tile: the Preferences window is open and the
|
||||
/// pointer was last seen over the tile.
|
||||
pub fn theme_drop_target(app: &SolveApp, ctx: &egui::Context) -> bool {
|
||||
app.prefs_window
|
||||
.import_tile
|
||||
.filter(|_| app.prefs_window.open)
|
||||
.is_some_and(|rect| ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| rect.contains(p)))
|
||||
}
|
||||
|
||||
/// A file dropped on the Import tile, as bytes: imported when it is a theme, refused with the reason
|
||||
/// otherwise.
|
||||
pub fn import_theme_bytes(app: &mut SolveApp, bytes: Vec<u8>) {
|
||||
let text = if bytes.len() > crate::theme_files::MAX_BYTES {
|
||||
Err("the file is too large for a theme".to_string())
|
||||
} else {
|
||||
String::from_utf8(bytes).map_err(|_| "not a theme file".to_string())
|
||||
};
|
||||
import_theme_text(app, text);
|
||||
}
|
||||
|
||||
pub fn import_theme(app: &mut SolveApp, v: &serde_json::Value) -> Result<String, String> {
|
||||
let theme = match v {
|
||||
serde_json::Value::String(text) => crate::theme_files::parse(text)?,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Clarify Drawings preservation manifest and published index hash
|
||||
|
||||
Source preservation only. The manifest now records the actual published index README rather than the initial selection note, and states its pre-Git-normalization hash scope. Original product proposals are unchanged. The canonical index links all branch checkpoints; validation and owner handoffs remain pending.
|
||||
|
||||
This branch preserves unfinished work at Judit's request. It does not replace the existing contributor claims or PRs, and is not ready for merge. Original parent history and authorship are retained. No tests were run to create this preservation branch.
|
||||
@@ -0,0 +1,96 @@
|
||||
# P2 F0 explicit widths — source-only v4 successor
|
||||
|
||||
**UNFORMATTED, UNCOMPILED, UNRUN, UNREGISTERED.**
|
||||
This implements Cam's resolved width policy in our owned font-resource helper
|
||||
only. It is not a completed PDF/text pipeline, registered integration, runtime
|
||||
result or ready PR. The [v3 source archive](../F0-textplan-fontobjects/README.md)
|
||||
at `45286e048983246ae1c7debf4e01e102762b2783` remains unchanged.
|
||||
|
||||
## Resolved policy and actual change
|
||||
|
||||
[Cam's decision on #7626](https://github.com/tangency-labs/solidcraft/pull/7626#issuecomment-6534292604)
|
||||
requires explicit `/W` for every emitted CID, without relying on `/DW` or
|
||||
inventing a B1 `.notdef` width. The embedded program's glyph zero remains a
|
||||
separate embedding obligation. This follows the existing answer; review and
|
||||
validation remain required.
|
||||
|
||||
Only `crates/io/src/paper/pdf/font_objects.rs` changes:
|
||||
`393ed32f…` to `2c1aaa6f…`. It removes the default-width lookup/scalar,
|
||||
propagation and explicit `default_width(...)` writer call. Its existing checked
|
||||
per-code original-GID join and `/W` loop remain. The companion `text_plan.rs`
|
||||
is byte-identical `eddb5120…`; no parser, source admission or public B1 field
|
||||
is invented.
|
||||
|
||||
Every code in the **immutable opaque declared-code table** requires a real
|
||||
checked B1 `FontGlyph.advance` and original `unitsPerEm`; the existing finite
|
||||
width conversion is 1000*advance/unitsPerEm. Placed glyph offsets/advances remain
|
||||
separate. Each declared code emits one explicit `/W` row, including a real zero
|
||||
width. Missing original widths refuse. Sparse holes, an unused CID0 map placeholder
|
||||
or an empty declared set do not invent widths.
|
||||
|
||||
Original0→subset0 and embedded FontFile2 program bytes remain unchanged. An
|
||||
explicitly declared synthetic CID0 must still map to subset0 **and** have its own
|
||||
actual source width; merely having a program `.notdef` cannot supply it.
|
||||
Omitting `/DW` does not authorize undeclared codes or treat a reader's implicit
|
||||
fallback as coverage. Future real page content must emit only planned codes and
|
||||
separately prove every emitted CID is in the declared table.
|
||||
|
||||
## Exact source and routes
|
||||
|
||||
[Portable bindings](source-bindings.json) pin actual B1a
|
||||
`16c9e9119214a3595b1303e2f1116cd067986c38`, B1b
|
||||
`a46d46e95a7f5c7680ae7440d3c9989846947128`, actual source blobs/byte hashes and
|
||||
the owned helper endpoints. These are exact source pins, not an assertion that
|
||||
all upstream PRs are merged or that this is one qualified Git head.
|
||||
|
||||
- [Product delta from exact v3](deltas/product-from-v3.patch) replays against
|
||||
[before helper](before/crates/io/src/paper/pdf/font_objects.rs) to the
|
||||
[candidate helper](candidate/crates/io/src/paper/pdf/font_objects.rs).
|
||||
- [Unchanged companion](candidate/crates/io/src/paper/pdf/text_plan.rs) is included
|
||||
for source continuity; its parent/producer boundary is not newly changed.
|
||||
- [Acceptance delta](deltas/acceptance-owner-policy-from-v3.patch) replays the
|
||||
[old five-case appendix](before/acceptance/new-production-boundary-cases.rs)
|
||||
to the [new appendix](acceptance/new-production-boundary-cases.rs). Its logical
|
||||
path is an **owned appendix**, not Cam's complete `pdf_tests.rs`.
|
||||
- Retained B1, F0 wrapper and negative fragments are unchanged copies; do not append
|
||||
them again if already composed. Normal integration belongs in the area's
|
||||
existing test file, with negative snippets kept separate. Earlier v3 alternative
|
||||
product patches are not additional layers on top of this v4 increment.
|
||||
|
||||
The opaque FontPlanView/getters, exact trusted builtin bytes/face checks,
|
||||
original B1 identity/intrinsic-hmtx validation and single subset/closure/RAW
|
||||
admission stay. Finite/metadata/descriptor/code/order/map/UTF/Ref/tag/budget
|
||||
guards remain. `Font.embed`/F2, font substitution, assets, dependencies and
|
||||
embedding rights are not supplied or broadened.
|
||||
|
||||
## Original acceptance and scope of review
|
||||
|
||||
[Acceptance inventory](acceptance-NOT-RUN.md): **49 ordinary source declarations
|
||||
and four separate negative compile obligations, all UNRUN**.
|
||||
Nineteen old helper test bodies are byte-identical; four DW-related helper bodies
|
||||
are explicitly superseded by the owner decision, and the existing all-u16 case
|
||||
is strengthened to inspect all 65,536 rows while retaining one-extra refusal.
|
||||
Three new helper cases cover sparse extremes, missing explicit CID0 width and
|
||||
the real pinned F0 producer's AV resource path. The five-case boundary appendix
|
||||
keeps four old bodies and updates only the owner-authorized missing-GID0 case.
|
||||
The 13 original B1, four F0 wrapper and four negative fragments remain unchanged.
|
||||
|
||||
Independent focal source review found no actionable defect in this owner-authorized
|
||||
delta. Separate text/hash replay reconstructed both declared endpoints exactly.
|
||||
This does not establish formatting, Rust typechecking, compilation, negative-test
|
||||
diagnostics, produced PDF pages, actual reader/native results, full CI or readiness.
|
||||
|
||||
## Remaining integration
|
||||
|
||||
Cam's #9647 parent/content/Refs/global allocator window is untouched. Normal
|
||||
modules/resources/text emission, every real emitted-code coverage, all-runs
|
||||
grouping and aggregate bounds remain pending. Multi-glyph ActualText/extraction,
|
||||
real RTL/reader evidence, coherent dependency/Cargo.lock integration, current
|
||||
compiler/native/wasm/strict/full gates, actual PDF rendering/readback and old-byte
|
||||
acceptance remain unrun. Future F2 and embedding/package licence compliance are
|
||||
separate. The earlier owner decision is resolved; these pipeline gates are not.
|
||||
|
||||
Changes a default: no. Original Worker #1/Engineer-team proposal and analytic
|
||||
cases preserve existing Cam/P2/font contributor authorship and notices under
|
||||
MIT OR Apache-2.0. No font binary, asset, private path, full private proof,
|
||||
controller, launcher, receipt or machine log is included.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Acceptance source inventory — NOT RUN
|
||||
|
||||
All 49 ordinary declarations and four separate negative compile obligations are
|
||||
unregistered/uncompiled/unrun. These are proposed source cases, not an executable
|
||||
test inventory or observed PDF result.
|
||||
|
||||
## Owner-authorized changes from v3
|
||||
|
||||
Nineteen of the 24 old helper test bodies remain literal byte matches.
|
||||
Four old helper bodies intentionally follow Cam's resolved explicit-W policy:
|
||||
prepared default-width scalar requirements disappear, serialized DW750 becomes
|
||||
absence of DW with unchanged explicit rows, and the old missing-GID0 refusal
|
||||
becomes explicit-row success. The existing full-u16 case is strengthened:
|
||||
parse all 65,536 rows, verify each CID exactly once and every finite literal
|
||||
width (CID0=750, others277.83203125 in that synthetic component fixture), and
|
||||
retain one-extra refusal. The component fixture's synthetic explicit GID0 is
|
||||
not a claim that the real B1 producer emits it.
|
||||
|
||||
Three new helper declarations:
|
||||
- `explicit_widths_cover_sparse_extreme_cids_without_a_notdef_record`: exact
|
||||
CID1/65535 rows, 131072 map bytes/endpoints, ToUnicode, original program bytes.
|
||||
- `an_explicit_zero_cid_cannot_borrow_a_width_from_the_program_notdef`:
|
||||
distinct missing intrinsic-width and wrong CID0 mapping refusals survive.
|
||||
- `actual_f0_producer_plan_emits_complete_explicit_width_resources`: real
|
||||
pinned paper_text -> Scene.add_fonts/prepare -> TextPlan -> opaque production
|
||||
font_objects for AV; absent B1 GID0 versus present program0 mapping, exact
|
||||
original-derived A/V widths, six refs, program bytes and ToUnicode. This is
|
||||
proposed source, not an executed producer or native result.
|
||||
|
||||
The five-case owned production-boundary fragment keeps its first four bodies
|
||||
unchanged; only the prior unresolved-DW refusal is replaced by owner-authorized
|
||||
successful resource emission. Thirteen retained B1 and four F0 wrapper cases,
|
||||
plus all four negative obligations, are byte-identical to v3. The negative
|
||||
snippets are intentional invalid boundary examples and must remain separate
|
||||
from ordinary module code; their diagnostic classes are unobserved.
|
||||
|
||||
## Current ordinary declarations
|
||||
|
||||
### Inline helper: 27
|
||||
|
||||
- `six_real_refs_link_exact_objects_and_preserve_raw_font_bytes`
|
||||
- `distinct_codes_share_a_gid_without_losing_literal_logical_clusters`
|
||||
- `sparse_maximum_cid_has_bounded_map_and_zero_stays_notdef`
|
||||
- `widths_use_the_mapped_subset_gid_but_are_indexed_by_cid`
|
||||
- `invalid_code_order_gid_and_logical_clusters_refuse`
|
||||
- `essential_descriptor_fields_refuse_missing_invalid_and_underflow_values`
|
||||
- `fixed_name_tag_and_real_ref_constraints_are_explicit`
|
||||
- `preflight_byte_allowance_is_inclusive_and_actual_serialization_is_below_it`
|
||||
- `repeated_complete_inputs_are_deterministic_and_leave_source_unchanged`
|
||||
- `full_u16_code_space_is_inclusive_and_one_extra_record_refuses`
|
||||
- `source_derived_p3_offset_arithmetic_is_a_caller_reservation_not_a_registry`
|
||||
- `b1_widths_use_original_gid_join_and_cid_index_with_distinct_real_advances`
|
||||
- `b1_widths_keep_real_zero_advance_and_exact_intrinsic_units`
|
||||
- `b1_widths_arithmetic_has_literal_zero_fraction_and_u16_envelope_oracles`
|
||||
- `b1_widths_arithmetic_refuses_zero_and_out_of_contract_units_without_dividing`
|
||||
- `b1_widths_arithmetic_preserves_inclusive_serialized_metric_bound`
|
||||
- `b1_widths_missing_original_advance_refuses_without_subset_metric_fallback`
|
||||
- `b1_widths_unemitted_closure_glyphs_do_not_require_invented_b1_entries`
|
||||
- `b1_widths_omitted_notdef_uses_complete_explicit_rows_without_a_default`
|
||||
- `b1_widths_raw_digest_cannot_replace_b1_bytes_plus_face_identity`
|
||||
- `b1_widths_byte_envelope_refuses_before_identity_work`
|
||||
- `b1_widths_fixed_face_upem_glyph_count_and_used_id_caps_stay_closed`
|
||||
- `b1_widths_missing_subset_ids_refuse_even_when_b1_has_other_widths`
|
||||
- `b1_widths_binding_and_emission_leave_source_records_program_and_mapping_unchanged`
|
||||
- `explicit_widths_cover_sparse_extreme_cids_without_a_notdef_record`
|
||||
- `an_explicit_zero_cid_cannot_borrow_a_width_from_the_program_notdef`
|
||||
- `actual_f0_producer_plan_emits_complete_explicit_width_resources`
|
||||
|
||||
### Retained B1: 13
|
||||
|
||||
- `b1_text_plan_keeps_intrinsic_width_separate_from_placed_advance`
|
||||
- `b1_text_plan_same_gid_with_distinct_logical_text_has_distinct_codes`
|
||||
- `b1_text_plan_preserves_quarter_turn_slant_and_mark_rise_once`
|
||||
- `b1_text_plan_single_glyph_literal_cluster_is_not_split_or_normalized`
|
||||
- `b1_text_plan_rtl_keeps_positions_but_uses_logical_cluster_order`
|
||||
- `b1_text_plan_multi_glyph_cluster_refuses_instead_of_duplicating_text`
|
||||
- `b1_text_plan_tounicode_destination_limit_is_inclusive_and_never_split`
|
||||
- `b1_text_plan_space_keeps_advance_and_text_despite_empty_outline`
|
||||
- `b1_text_plan_refuses_wrong_identity_descriptor_face_and_intrinsic_width`
|
||||
- `b1_text_plan_refuses_invalid_partitions_unmatched_font_and_nonfinite_positions`
|
||||
- `b1_text_plan_bounds_storage_subsets_run_count_and_exact_used_font_table`
|
||||
- `b1_text_plan_repeats_real_subset_mapping_and_resource_inputs`
|
||||
- `b1_text_plan_font_byte_envelope_refuses_before_identity_or_table_work`
|
||||
|
||||
### F0 wrappers: 4
|
||||
|
||||
- `f0_text_plan_resolves_the_real_id_through_prepared_without_moving_glyphs`
|
||||
- `f0_text_plan_refuses_empty_or_unknown_id_without_a_numeric_font_fallback`
|
||||
- `f0_prepared_boundary_refuses_missing_duplicate_and_unsorted_font_tables`
|
||||
- `f0_serialized_scene_cannot_supply_prepared_fonts_without_trusted_bytes`
|
||||
|
||||
### Production boundary: 5
|
||||
|
||||
- `f0_builtin_identity_rejects_changed_bytes_even_with_a_matching_recomputed_id`
|
||||
- `f0_prepared_public_advance_is_rechecked_before_any_production_view_exists`
|
||||
- `f0_structural_preparation_does_not_attest_changed_descriptor_metrics`
|
||||
- `f0_production_view_keeps_the_same_borrowed_source_codes_and_subset`
|
||||
- `f0_opaque_production_input_emits_explicit_widths_without_a_notdef_record`
|
||||
|
||||
## Preservation and limits
|
||||
|
||||
No positive case is reclassified as compile-fail. Numeric/zero/finite/missing-width/code/order/map/descriptor/budget/ref/tag negative contracts remain. No fixture font binary or generated PDF is bundled. Complete declared-resource coverage is not real parent-content coverage, extraction, rendering, old-byte or full-CI acceptance.
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// NEGATIVE COMPILE OBLIGATIONS, NOT RUN.
|
||||
// Compile each function as a separate sibling module of text_plan/font_objects
|
||||
// under paper::pdf in a coherent future acceptance assembly. Do not include
|
||||
// these intentional errors in the normal test module or infer a pass here.
|
||||
// No trybuild dependency or test-only proof constructor is introduced.
|
||||
|
||||
// Expected: E0451, FontPlanView fields private to text_plan.
|
||||
fn cannot_pair_arbitrary_raw_font_and_subset<'a>(
|
||||
font: &'a solidcraft_geom::paper::text::Font,
|
||||
subset: &'a super::subset_font::SubsetFont,
|
||||
descriptor: &'a super::font_objects::DescriptorMetrics,
|
||||
codes: &'a [super::font_objects::CodeToGlyph<'a>],
|
||||
) -> super::text_plan::FontPlanView<'a> {
|
||||
super::text_plan::FontPlanView { font, subset, descriptor, codes }
|
||||
}
|
||||
|
||||
// Expected: E0560: no raw source_font/subset/descriptor/codes fields on the
|
||||
// production input. A validated view cannot be replaced by serde assertions.
|
||||
fn cannot_restore_the_raw_production_input<'a>(
|
||||
font: &'a solidcraft_geom::paper::text::Font,
|
||||
subset: &'a super::subset_font::SubsetFont,
|
||||
) -> super::font_objects::FontObjectInput<'a> {
|
||||
super::font_objects::FontObjectInput {
|
||||
source_font: font,
|
||||
subset,
|
||||
codes: &[],
|
||||
descriptor: None,
|
||||
postscript_name: b"LiberationSans",
|
||||
subset_tag: *b"ABCDEF",
|
||||
max_chunk_bytes: 1 << 20,
|
||||
}
|
||||
}
|
||||
|
||||
// Expected: Deserialize is not implemented; there is no token rehydration API.
|
||||
fn cannot_deserialize_a_production_view() {
|
||||
let _: super::text_plan::FontPlanView<'_> = serde_json::from_str("{}").unwrap();
|
||||
}
|
||||
|
||||
// Expected: E0603: the raw preparation entry is private to font_objects.
|
||||
fn cannot_call_the_component_emitter_from_a_sibling() {
|
||||
let _ = super::font_objects::font_objects_parts;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Proposed own acceptance appendix. NOT REGISTERED, UNCOMPILED, UNRUN.
|
||||
// Append after the preserved own13 cases only when Engineer selects a coherent
|
||||
// F0 base and resolves the existing Font.bundled policy seam. No Cam test edit.
|
||||
fn f0_plan_scene(
|
||||
runs: Vec<solidcraft_geom::paper::text::TextRun>,
|
||||
fonts: Vec<solidcraft_geom::paper::text::Font>,
|
||||
) -> solidcraft_geom::paper::Scene {
|
||||
use solidcraft_geom::paper::{Group, Item, ObjectKind, Scene};
|
||||
Scene {
|
||||
fonts,
|
||||
..Scene::new(
|
||||
solidcraft_geom::Vec2::new(210.0, 297.0),
|
||||
vec![Group { object: 1, kind: ObjectKind::TitleBlock, items: runs.into_iter().map(Item::Text).collect() }],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_text_plan_resolves_the_real_id_through_prepared_without_moving_glyphs() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
assert_eq!(run.font, font.id);
|
||||
let scene = f0_plan_scene(vec![run.clone()], vec![font]);
|
||||
let prepared = scene.prepare().unwrap();
|
||||
let plan = super::text_plan::plan_prepared_text(&prepared, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].original_gid, 36);
|
||||
assert_eq!(plan.glyphs()[0].intrinsic_advance_fu, 1366);
|
||||
assert_eq!(plan.glyphs()[0].placed_advance_mm, 0.5);
|
||||
assert_eq!(plan.glyphs()[0].matrix_mm, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
|
||||
assert_eq!(plan.runs()[0].text, "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_text_plan_refuses_empty_or_unknown_id_without_a_numeric_font_fallback() {
|
||||
let empty = f0_plan_scene(vec![], vec![]);
|
||||
let prepared = empty.prepare().unwrap();
|
||||
assert!(super::text_plan::plan_prepared_text(&prepared, &[], 1 << 20, 410_820).is_err());
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let scene = f0_plan_scene(vec![run.clone()], vec![font]);
|
||||
let prepared = scene.prepare().unwrap();
|
||||
let mut wrong = run.clone();
|
||||
wrong.font[0] ^= 1;
|
||||
assert!(prepared.font(&wrong).is_none());
|
||||
let error = super::text_plan::plan_prepared_text(&prepared, &[&wrong], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("run font ID is absent from the prepared scene"));
|
||||
assert!(super::text_plan::plan_prepared_text(&prepared, &[&run, &wrong], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_prepared_boundary_refuses_missing_duplicate_and_unsorted_font_tables() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
assert!(f0_plan_scene(vec![run.clone()], vec![]).prepare().is_err());
|
||||
assert!(f0_plan_scene(vec![run.clone()], vec![font.clone(), font.clone()]).prepare().is_err());
|
||||
// Synthetic second identity tests only the Scene ordering refusal. It is not
|
||||
// a claim that another face is admitted for embedding by this P2 adapter.
|
||||
let mut other_font = font.clone();
|
||||
other_font.id[0] ^= 1;
|
||||
let mut other_run = run.clone();
|
||||
other_run.font = other_font.id;
|
||||
let mut fonts = vec![font, other_font];
|
||||
fonts.sort_by_key(|font| font.id);
|
||||
fonts.reverse();
|
||||
assert!(f0_plan_scene(vec![run, other_run], fonts).prepare().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_serialized_scene_cannot_supply_prepared_fonts_without_trusted_bytes() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let scene = f0_plan_scene(vec![run], vec![font]);
|
||||
let value = serde_json::to_value(&scene).unwrap();
|
||||
assert!(value["fonts"][0].get("bytes").is_none());
|
||||
let read: solidcraft_geom::paper::Scene = serde_json::from_value(value).unwrap();
|
||||
assert!(read.fonts[0].bytes.is_empty());
|
||||
assert!(read.prepare().is_err(), "no Prepared value reaches the consumer after untrusted JSON");
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
// Own production-boundary acceptance. UNREGISTERED, UNCOMPILED, UNRUN.
|
||||
// Depends on the retained13 and unchanged four F0 wrapper cases/helpers.
|
||||
|
||||
#[test]
|
||||
fn f0_builtin_identity_rejects_changed_bytes_even_with_a_matching_recomputed_id() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut bytes = font.bytes.to_vec();
|
||||
bytes[4096] ^= 1;
|
||||
font.bytes = std::sync::Arc::from(bytes);
|
||||
font.id = Sha256::new().chain_update(font.bytes.as_ref())
|
||||
.chain_update(font.face.to_le_bytes()).finalize().into();
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.font = font.id;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("builtin drawing font"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_prepared_public_advance_is_rechecked_before_any_production_view_exists() {
|
||||
let mut font = b1_plan_font(&[(36, 1366)]);
|
||||
font.glyphs.get_mut(&36).unwrap().advance = 1367;
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let scene = f0_plan_scene(vec![run.clone()], vec![font]);
|
||||
let prepared = scene.prepare().unwrap();
|
||||
let error = super::text_plan::plan_prepared_text(&prepared, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("intrinsic advance does not match the admitted hmtx"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_structural_preparation_does_not_attest_changed_descriptor_metrics() {
|
||||
let mut font = b1_plan_font(&[(36, 1366)]);
|
||||
font.metrics.ascent = 1855;
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let scene = f0_plan_scene(vec![run.clone()], vec![font]);
|
||||
let prepared = scene.prepare().unwrap();
|
||||
assert!(super::text_plan::plan_prepared_text(&prepared, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_production_view_keeps_the_same_borrowed_source_codes_and_subset() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert!(std::ptr::eq(input.source.font(), &font));
|
||||
assert_eq!(input.source.subset().mapping(), [(0, 0), (36, 1), (57, 2)]);
|
||||
assert_eq!(input.source.codes().iter().map(|row| (row.code, row.subset_gid, row.logical)).collect::<Vec<_>>(),
|
||||
[(1, 1, "A"), (2, 2, "V")]);
|
||||
assert_eq!(input.source.font().glyphs[&36].advance, 1366);
|
||||
assert_eq!(plan.glyphs()[0].placed_advance_mm, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_opaque_production_input_emits_explicit_widths_without_a_notdef_record() {
|
||||
use pdf_writer::Ref;
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let before = font.clone();
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(input.source.subset().mapping().first(), Some(&(0, 0)));
|
||||
let refs = super::font_objects::FontObjectRefs {
|
||||
type0: Ref::new(6), cid_font: Ref::new(7), descriptor: Ref::new(8),
|
||||
font_file2: Ref::new(9), to_unicode: Ref::new(10), cid_to_gid: Ref::new(11),
|
||||
};
|
||||
let chunk = super::font_objects::font_objects(refs, input).unwrap();
|
||||
assert_eq!(chunk.refs().map(Ref::get).collect::<Vec<_>>(), [6, 7, 8, 9, 10, 11]);
|
||||
assert!(chunk.len() <= 1 << 20);
|
||||
// Strict serialized W/No-DW and stream checks live in the helper's
|
||||
// resource cases; this case exercises the opaque production entry.
|
||||
assert_eq!(font, before);
|
||||
assert!(!font.glyphs.contains_key(&0));
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// P2 B1 text-plan acceptance: original records, not a replacement layout engine.
|
||||
// Font constants are literal fields of the pinned original Liberation resource;
|
||||
// synthetic logical aliases below test the record contract, not real shaping.
|
||||
fn b1_plan_font(ids: &[(u16, u16)]) -> solidcraft_geom::paper::text::Font {
|
||||
use sha2::{Digest, Sha256};
|
||||
use solidcraft_geom::paper::text::{Font, FontFlags, FontGlyph, FontMetrics};
|
||||
let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(&include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/liberation-sans/LiberationSans-Regular.ttf"))[..]);
|
||||
let id = Sha256::new().chain_update(bytes.as_ref()).chain_update(0u32.to_le_bytes()).finalize().into();
|
||||
Font {
|
||||
id, bytes, face: 0, postscript_name: "LiberationSans".into(), num_glyphs: 2620, units_per_em: 2048,
|
||||
metrics: FontMetrics {
|
||||
ascent: 1854, descent: -434, cap_height: 1409, bbox: [-1114, -621, 2666, 2007],
|
||||
italic_angle: 0.0, flags: FontFlags::default(), stem_v: 180,
|
||||
},
|
||||
glyphs: ids.iter().map(|&(id, advance)| (id, FontGlyph { advance, outline: vec![] })).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn b1_plan_run(text: &str, ids: &[u16]) -> solidcraft_geom::paper::text::TextRun {
|
||||
use solidcraft_geom::paper::text::{Cluster, PlacedGlyph, TextRun};
|
||||
assert_eq!(text.chars().count(), ids.len());
|
||||
let chars: Vec<_> = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect();
|
||||
TextRun {
|
||||
font: b1_plan_font(&[]).id, scale: 1.0 / 2048.0, origin: solidcraft_geom::Vec2::new(0.0, 0.0), angle: 0.0, slant: 0.0, rtl: false,
|
||||
glyphs: ids.iter().enumerate().map(|(i, &id)| PlacedGlyph { id, x: i as f64 * 0.375, rise: 0.0, advance: 0.5 }).collect(),
|
||||
text: text.into(),
|
||||
clusters: ids.iter().enumerate().map(|(i, _)| Cluster { glyphs: i as u32..i as u32 + 1, text: chars[i] as u32..chars[i + 1] as u32 }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_keeps_intrinsic_width_separate_from_placed_advance() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.runs()[0].font_size_mm, 1.0);
|
||||
assert_eq!(plan.runs()[0].text, "AV");
|
||||
assert_eq!(plan.runs()[0].glyphs, 0..2);
|
||||
let g = &plan.glyphs()[1];
|
||||
assert_eq!((g.original_gid, g.intrinsic_advance_fu, g.placed_advance_mm), (57, 1366, 0.5));
|
||||
assert_eq!(g.matrix_mm, [1.0, 0.0, 0.0, 1.0, 0.375, 0.0]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(input.source.font().postscript_name.as_bytes(), b"LiberationSans");
|
||||
assert_eq!(input.source.descriptor().ascent_1000, 905.2734375);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_same_gid_with_distinct_logical_text_has_distinct_codes() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let first = b1_plan_run("A", &[36]);
|
||||
let second = b1_plan_run("B", &[36]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&second, &first, &first], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs().iter().map(|g| (g.code, g.logical)).collect::<Vec<_>>(), [(2, "B"), (1, "A"), (1, "A")]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(input.source.codes().len(), 2);
|
||||
assert_eq!(input.source.codes()[0].subset_gid, input.source.codes()[1].subset_gid);
|
||||
assert_eq!((input.source.codes()[0].logical, input.source.codes()[1].logical), ("A", "B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_preserves_quarter_turn_slant_and_mark_rise_once() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.origin = solidcraft_geom::Vec2::new(100.0, 50.0);
|
||||
run.angle = std::f64::consts::FRAC_PI_2;
|
||||
run.slant = 0.25;
|
||||
run.glyphs[0].x = 3.0;
|
||||
run.glyphs[0].rise = 2.0;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
for (actual, expected) in plan.glyphs()[0].matrix_mm.into_iter().zip([0.0, 1.0, -1.0, 0.25, 98.0, 53.0]) {
|
||||
assert!((actual - expected).abs() < 1e-12, "fixed quarter-turn f64 trig allowance: {actual} != {expected}");
|
||||
}
|
||||
assert_eq!(run.origin, solidcraft_geom::Vec2::new(100.0, 50.0));
|
||||
assert_eq!((run.glyphs[0].x, run.glyphs[0].rise), (3.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_single_glyph_literal_cluster_is_not_split_or_normalized() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.text = "fi\u{301}".into();
|
||||
run.clusters[0].text = 0..4;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].logical, "fi\u{301}");
|
||||
assert_eq!(plan.font_input(*b"ABCDEF", 1 << 20).source.codes()[0].logical, "fi\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_rtl_keeps_positions_but_uses_logical_cluster_order() {
|
||||
let font = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
let mut run = b1_plan_run("AB", &[37, 36]);
|
||||
run.rtl = true;
|
||||
run.clusters[0].text = 1..2;
|
||||
run.clusters[1].text = 0..1;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert!(plan.runs()[0].rtl);
|
||||
assert_eq!(plan.glyphs().iter().map(|g| g.logical).collect::<String>(), "AB");
|
||||
assert_eq!(plan.glyphs().iter().map(|g| g.source_glyph_index).collect::<Vec<_>>(), [1, 0]);
|
||||
assert_eq!((plan.glyphs()[0].matrix_mm[4], plan.glyphs()[1].matrix_mm[4]), (0.375, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_multi_glyph_cluster_refuses_instead_of_duplicating_text() {
|
||||
let font = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
let mut run = b1_plan_run("AB", &[36, 37]);
|
||||
run.text = "A".into();
|
||||
run.clusters.truncate(1);
|
||||
run.clusters[0].glyphs = 0..2;
|
||||
run.clusters[0].text = 0..1;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("multi-glyph cluster extraction is not supported"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_tounicode_destination_limit_is_inclusive_and_never_split() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.text = "A".repeat(256);
|
||||
run.clusters[0].text = 0..256;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].logical.len(), 256);
|
||||
drop(plan);
|
||||
run.text.push('A');
|
||||
run.clusters[0].text.end += 1;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("512-byte destination"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_space_keeps_advance_and_text_despite_empty_outline() {
|
||||
let font = b1_plan_font(&[(3, 569)]);
|
||||
let run = b1_plan_run(" ", &[3]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!((plan.glyphs()[0].original_gid, plan.glyphs()[0].intrinsic_advance_fu, plan.glyphs()[0].logical), (3, 569, " "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_refuses_wrong_identity_descriptor_face_and_intrinsic_width() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for kind in 0..5 {
|
||||
let mut wrong = font.clone();
|
||||
match kind {
|
||||
0 => wrong.id[0] ^= 1,
|
||||
1 => wrong.face = 1,
|
||||
2 => wrong.metrics.stem_v = 181,
|
||||
3 => wrong.metrics.flags.italic = true,
|
||||
_ => wrong.glyphs.get_mut(&36).unwrap().advance = 1024,
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&wrong, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_refuses_invalid_partitions_unmatched_font_and_nonfinite_positions() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for kind in 0..6 {
|
||||
let mut wrong = run.clone();
|
||||
match kind {
|
||||
0 => wrong.clusters[0].glyphs.end = 2,
|
||||
1 => wrong.clusters[0].text.end = 2,
|
||||
2 => wrong.clusters.clear(),
|
||||
3 => wrong.font[0] ^= 1,
|
||||
4 => wrong.glyphs[0].x = f64::NAN,
|
||||
_ => { wrong.text = "\u{e9}".into(); wrong.clusters[0].text = 0..1; }
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&font, &[&wrong], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_bounds_storage_subsets_run_count_and_exact_used_font_table() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for (plan_bytes, subset_bytes) in [(0, 410_820), (1, 410_820), (1 << 20, 0), (1 << 20, 1)] {
|
||||
assert!(super::text_plan::plan_text(&font, &[&run], plan_bytes, subset_bytes).is_err());
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&font, &vec![&run; 1025], 1 << 20, 410_820).is_err());
|
||||
let extra = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
assert!(super::text_plan::plan_text(&extra, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_repeats_real_subset_mapping_and_resource_inputs() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let first = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let second = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let a = first.font_input(*b"ABCDEF", 1 << 20);
|
||||
let b = second.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(a.source.subset().bytes(), b.source.subset().bytes());
|
||||
assert_eq!(a.source.subset().mapping(), [(0, 0), (36, 1), (57, 2)]);
|
||||
assert_eq!(a.source.subset().mapping(), b.source.subset().mapping());
|
||||
assert_eq!(a.source.codes().iter().map(|g| (g.code, g.subset_gid, g.logical)).collect::<Vec<_>>(), [(1, 1, "A"), (2, 2, "V")]);
|
||||
assert_eq!(a.source.descriptor().flags, pdf_writer::types::FontFlags::NON_SYMBOLIC.bits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_font_byte_envelope_refuses_before_identity_or_table_work() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for len in [0, 410_819, 410_821] {
|
||||
let mut wrong = font.clone();
|
||||
// Identity is deliberately stale too: byte-envelope refusal has priority.
|
||||
wrong.bytes = std::sync::Arc::from(vec![0; len]);
|
||||
let error = super::text_plan::plan_text(&wrong, &[&run], 1, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("font byte length is outside the reviewed resource envelope"), "{error}");
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
// Own production-boundary acceptance. UNREGISTERED, UNCOMPILED, UNRUN.
|
||||
// Depends on the retained13 and unchanged four F0 wrapper cases/helpers.
|
||||
|
||||
#[test]
|
||||
fn f0_builtin_identity_rejects_changed_bytes_even_with_a_matching_recomputed_id() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut bytes = font.bytes.to_vec();
|
||||
bytes[4096] ^= 1;
|
||||
font.bytes = std::sync::Arc::from(bytes);
|
||||
font.id = Sha256::new().chain_update(font.bytes.as_ref())
|
||||
.chain_update(font.face.to_le_bytes()).finalize().into();
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.font = font.id;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("builtin drawing font"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_prepared_public_advance_is_rechecked_before_any_production_view_exists() {
|
||||
let mut font = b1_plan_font(&[(36, 1366)]);
|
||||
font.glyphs.get_mut(&36).unwrap().advance = 1367;
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let scene = f0_plan_scene(vec![run.clone()], vec![font]);
|
||||
let prepared = scene.prepare().unwrap();
|
||||
let error = super::text_plan::plan_prepared_text(&prepared, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("intrinsic advance does not match the admitted hmtx"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_structural_preparation_does_not_attest_changed_descriptor_metrics() {
|
||||
let mut font = b1_plan_font(&[(36, 1366)]);
|
||||
font.metrics.ascent = 1855;
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let scene = f0_plan_scene(vec![run.clone()], vec![font]);
|
||||
let prepared = scene.prepare().unwrap();
|
||||
assert!(super::text_plan::plan_prepared_text(&prepared, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_production_view_keeps_the_same_borrowed_source_codes_and_subset() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert!(std::ptr::eq(input.source.font(), &font));
|
||||
assert_eq!(input.source.subset().mapping(), [(0, 0), (36, 1), (57, 2)]);
|
||||
assert_eq!(input.source.codes().iter().map(|row| (row.code, row.subset_gid, row.logical)).collect::<Vec<_>>(),
|
||||
[(1, 1, "A"), (2, 2, "V")]);
|
||||
assert_eq!(input.source.font().glyphs[&36].advance, 1366);
|
||||
assert_eq!(plan.glyphs()[0].placed_advance_mm, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_opaque_production_input_does_not_bypass_the_unresolved_default_width_gate() {
|
||||
use pdf_writer::Ref;
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let before = font.clone();
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
let refs = super::font_objects::FontObjectRefs {
|
||||
type0: Ref::new(6), cid_font: Ref::new(7), descriptor: Ref::new(8),
|
||||
font_file2: Ref::new(9), to_unicode: Ref::new(10), cid_to_gid: Ref::new(11),
|
||||
};
|
||||
let error = super::font_objects::font_objects(refs, input).err().unwrap().to_string();
|
||||
assert!(error.contains("PDF default-width policy is unresolved"), "{error}");
|
||||
assert_eq!(font, before);
|
||||
assert!(!font.glyphs.contains_key(&0));
|
||||
}
|
||||
+1219
File diff suppressed because it is too large
Load Diff
+1338
File diff suppressed because it is too large
Load Diff
+387
@@ -0,0 +1,387 @@
|
||||
//! Bounded PDF planning from the actual B1a Font/TextRun records (#4890 P2).
|
||||
//!
|
||||
//! This neither shapes text nor writes a page. It keeps intrinsic hmtx widths
|
||||
//! separate from the producer's placed glyph x/rise/advance. One glyph per logical
|
||||
//! cluster is supported, including a glyph carrying several characters; a
|
||||
//! multi-glyph cluster is refused until its extraction/content policy is supplied.
|
||||
//! The ToUnicode destination limit stays 512 UTF-16BE bytes, not a silent split.
|
||||
//!
|
||||
//! Plans retain no new font/document Arc: strings/font records are borrowed.
|
||||
//! Local vector storage and returned subset bytes have separate allowances. The
|
||||
//! existing subsetter/pdf-writer internal allocation limitations still apply.
|
||||
//! No parent registration, global PDF Ref allocator, page resource or text arm is
|
||||
//! introduced here. The parent must preserve the planned placements and reading
|
||||
//! order and qualify real PDF rendering/extraction; these records prove neither.
|
||||
|
||||
use std::mem::size_of;
|
||||
use std::ops::Range;
|
||||
|
||||
use pdf_writer::types::FontFlags;
|
||||
use sha2::{Digest, Sha256};
|
||||
use solidcraft_geom::paper::text::{Font, TextRun};
|
||||
use solidcraft_geom::paper::MAX_COORDINATE_MM;
|
||||
|
||||
use super::font_objects::{CodeToGlyph, DescriptorMetrics, FontObjectInput};
|
||||
use super::subset_font::{SubsetFont, subset_font};
|
||||
use crate::{IoError, Result};
|
||||
|
||||
const MAX_RUNS: usize = 1024;
|
||||
const MAX_GLYPHS: usize = 16_384;
|
||||
const MAX_PLAN_BYTES: usize = 16 << 20;
|
||||
const MAX_LOGICAL_BYTES: usize = 4 << 20;
|
||||
const MAX_CLUSTER_BYTES: usize = 768;
|
||||
const MAX_CLUSTER_UNITS: usize = 256;
|
||||
const APPROVED_FONT_BYTES: usize = 410_820;
|
||||
|
||||
/// One actual glyph, with a code for its original GID AND literal logical text.
|
||||
/// The matrix has unit basis vectors and a translation in sheet millimetres;
|
||||
/// font_size_mm is on its run. Parent applies its mm-to-point scale only once.
|
||||
pub(super) struct PlannedGlyph<'a> {
|
||||
pub(super) code: u16,
|
||||
pub(super) original_gid: u16,
|
||||
pub(super) source_glyph_index: usize,
|
||||
pub(super) intrinsic_advance_fu: u16,
|
||||
pub(super) placed_advance_mm: f64,
|
||||
pub(super) matrix_mm: [f64; 6],
|
||||
pub(super) logical: &'a str,
|
||||
}
|
||||
|
||||
/// Glyphs occupy one contiguous range in the plan, in logical reading order.
|
||||
/// For an RTL run their SOURCE indices descend; positions are never recomputed
|
||||
/// from font widths. Input run order remains unchanged.
|
||||
pub(super) struct PlannedRun<'a> {
|
||||
pub(super) glyphs: Range<usize>,
|
||||
pub(super) font_size_mm: f64,
|
||||
pub(super) rtl: bool,
|
||||
pub(super) text: &'a str,
|
||||
}
|
||||
|
||||
pub(super) struct TextPlan<'a> {
|
||||
font: &'a Font,
|
||||
subset: SubsetFont,
|
||||
descriptor: DescriptorMetrics,
|
||||
codes: Vec<CodeToGlyph<'a>>,
|
||||
runs: Vec<PlannedRun<'a>>,
|
||||
glyphs: Vec<PlannedGlyph<'a>>,
|
||||
}
|
||||
|
||||
/// Borrowed only from a successfully constructed TextPlan. Private fields
|
||||
/// bind checked source records, the factory-produced subset, descriptor and
|
||||
/// codes for one immutable plan; no raw-record or serde constructor exists.
|
||||
pub(super) struct FontPlanView<'a> {
|
||||
font: &'a Font,
|
||||
subset: &'a SubsetFont,
|
||||
descriptor: &'a DescriptorMetrics,
|
||||
codes: &'a [CodeToGlyph<'a>],
|
||||
}
|
||||
|
||||
impl<'a> FontPlanView<'a> {
|
||||
pub(super) fn font(&self) -> &'a Font { self.font }
|
||||
pub(super) fn subset(&self) -> &'a SubsetFont { self.subset }
|
||||
pub(super) fn descriptor(&self) -> &'a DescriptorMetrics { self.descriptor }
|
||||
pub(super) fn codes(&self) -> &'a [CodeToGlyph<'a>] { self.codes }
|
||||
}
|
||||
|
||||
impl TextPlan<'_> {
|
||||
pub(super) fn runs(&self) -> &[PlannedRun<'_>] {
|
||||
&self.runs
|
||||
}
|
||||
|
||||
pub(super) fn glyphs(&self) -> &[PlannedGlyph<'_>] {
|
||||
&self.glyphs
|
||||
}
|
||||
|
||||
/// Caller still supplies six globally reserved refs to font_objects, a
|
||||
/// globally unique subset tag, page resources and a cumulative output budget.
|
||||
pub(super) fn font_input(&self, subset_tag: [u8; 6], max_chunk_bytes: usize) -> FontObjectInput<'_> {
|
||||
FontObjectInput {
|
||||
source: FontPlanView {
|
||||
font: self.font,
|
||||
subset: &self.subset,
|
||||
descriptor: &self.descriptor,
|
||||
codes: &self.codes,
|
||||
},
|
||||
subset_tag,
|
||||
max_chunk_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(message: &'static str) -> IoError {
|
||||
IoError::Invalid(format!("PDF text plan: {message}"))
|
||||
}
|
||||
|
||||
fn reserve<T>(count: usize) -> Result<Vec<T>> {
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(count).map_err(|_| invalid("cannot allocate bounded plan storage"))?;
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn bytes<T>(count: usize) -> Result<usize> {
|
||||
count.checked_mul(size_of::<T>()).ok_or_else(|| invalid("plan storage overflow"))
|
||||
}
|
||||
|
||||
fn total(parts: &[usize], limit: usize) -> Result<usize> {
|
||||
parts.iter().try_fold(0usize, |n, part| {
|
||||
n.checked_add(*part).filter(|n| *n <= limit).ok_or_else(|| invalid("plan storage allowance exceeded"))
|
||||
})
|
||||
}
|
||||
|
||||
fn u16_at(bytes: &[u8], at: usize) -> Result<u16> {
|
||||
let end = at.checked_add(2).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
let value = bytes.get(at..end).and_then(|b| b.try_into().ok()).ok_or_else(|| invalid("truncated admitted font table"))?;
|
||||
Ok(u16::from_be_bytes(value))
|
||||
}
|
||||
|
||||
fn u32_at(bytes: &[u8], at: usize) -> Result<u32> {
|
||||
let end = at.checked_add(4).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
let value = bytes.get(at..end).and_then(|b| b.try_into().ok()).ok_or_else(|| invalid("truncated admitted font table"))?;
|
||||
Ok(u32::from_be_bytes(value))
|
||||
}
|
||||
|
||||
/// A bounded original hmtx view, not a general font parser. The subset adapter
|
||||
/// performs the existing exact-resource/embedding admission before plan return.
|
||||
fn table<'a>(bytes: &'a [u8], tag: &[u8; 4]) -> Result<&'a [u8]> {
|
||||
let count = usize::from(u16_at(bytes, 4)?);
|
||||
if count == 0 || count > 19 {
|
||||
return Err(invalid("font directory exceeds the reviewed resource"));
|
||||
}
|
||||
let mut found = None;
|
||||
for i in 0..count {
|
||||
let at = 12 + 16 * i;
|
||||
if bytes.get(at..at + 4) != Some(tag) {
|
||||
continue;
|
||||
}
|
||||
if found.is_some() {
|
||||
return Err(invalid("duplicate font table"));
|
||||
}
|
||||
let start = usize::try_from(u32_at(bytes, at + 8)?).map_err(|_| invalid("font offset exceeds the platform"))?;
|
||||
let length = usize::try_from(u32_at(bytes, at + 12)?).map_err(|_| invalid("font length exceeds the platform"))?;
|
||||
let end = start.checked_add(length).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
found = bytes.get(start..end);
|
||||
if found.is_none() {
|
||||
return Err(invalid("font table is outside its bytes"));
|
||||
}
|
||||
}
|
||||
found.ok_or_else(|| invalid("required font table is absent"))
|
||||
}
|
||||
|
||||
fn intrinsic_advance(font: &Font, gid: u16) -> Result<u16> {
|
||||
if gid >= font.num_glyphs {
|
||||
return Err(invalid("glyph is outside the admitted font"));
|
||||
}
|
||||
let count = usize::from(u16_at(table(&font.bytes, b"hhea")?, 34)?);
|
||||
if count == 0 || count > usize::from(font.num_glyphs) {
|
||||
return Err(invalid("invalid hmtx metric count"));
|
||||
}
|
||||
u16_at(table(&font.bytes, b"hmtx")?, usize::from(gid).min(count - 1) * 4)
|
||||
}
|
||||
|
||||
/// Current builtin bytes/face only, obtained by the existing None catalog path
|
||||
/// before any installed-font, filesystem or cache lookup. This does not attest
|
||||
/// mutable metrics/advances or waive the existing single subset admission.
|
||||
pub(super) fn check_builtin_source(font: &Font) -> Result<()> {
|
||||
if font.bytes.len() != APPROVED_FONT_BYTES {
|
||||
return Err(invalid("font byte length is outside the reviewed resource envelope"));
|
||||
}
|
||||
let builtin = solidcraft_doc::sketch::font_data(None)
|
||||
.map_err(|_| invalid("the builtin drawing font is unavailable"))?;
|
||||
if builtin.index != 0 || builtin.weight.is_some() || font.face != builtin.index
|
||||
|| font.bytes.as_ref() != builtin.bytes()
|
||||
{
|
||||
return Err(invalid("font bytes/face differ from the current builtin drawing font"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn descriptor(font: &Font) -> Result<DescriptorMetrics> {
|
||||
// Public Font is hostile input too. Refuse its cheap byte envelope before
|
||||
// hashing or looking through tables, even when the caller's plan budget is tiny.
|
||||
if font.bytes.len() != APPROVED_FONT_BYTES {
|
||||
return Err(invalid("font byte length is outside the reviewed resource envelope"));
|
||||
}
|
||||
check_builtin_source(font)?;
|
||||
// B1 hashes bytes + faceLE. The existing subset admission hashes RAW bytes;
|
||||
// their digests are different identities and are never compared to each other.
|
||||
let m = &font.metrics;
|
||||
// Original fixed-resource metadata, not fitted/default descriptor values.
|
||||
// Values come from this exact Liberation Sans head/hhea/OS2/post and l glyph.
|
||||
if font.face != 0 || font.postscript_name != "LiberationSans"
|
||||
|| font.num_glyphs != 2620 || font.units_per_em != 2048
|
||||
|| m.bbox != [-1114, -621, 2666, 2007] || m.ascent != 1854 || m.descent != -434
|
||||
|| m.cap_height != 1409 || m.stem_v != 180 || m.italic_angle != 0.0
|
||||
|| m.flags.fixed_pitch || m.flags.serif || m.flags.italic || m.flags.symbolic
|
||||
{
|
||||
return Err(invalid("B1 font identity or metrics differ from the reviewed regular bundled face"));
|
||||
}
|
||||
let identity: [u8; 32] = Sha256::new().chain_update(font.bytes.as_ref()).chain_update(font.face.to_le_bytes()).finalize().into();
|
||||
if font.id != identity {
|
||||
return Err(invalid("B1 bytes-and-face identity does not match its supplied bytes"));
|
||||
}
|
||||
let k = 1000.0 / f32::from(font.units_per_em);
|
||||
Ok(DescriptorMetrics {
|
||||
bbox_1000: m.bbox.map(|n| f32::from(n) * k),
|
||||
ascent_1000: f32::from(m.ascent) * k,
|
||||
descent_1000: f32::from(m.descent) * k,
|
||||
cap_height_1000: f32::from(m.cap_height) * k,
|
||||
italic_angle_degrees: m.italic_angle as f32,
|
||||
stem_v_1000: f32::from(m.stem_v) * k,
|
||||
flags: FontFlags::NON_SYMBOLIC.bits(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check the actual B1 cluster partition and the narrower first P2 contract
|
||||
/// BEFORE allocating or subsetting. Returning an error exposes no partial plan.
|
||||
fn check_run(font: &Font, run: &TextRun) -> Result<usize> {
|
||||
if run.font != font.id || !(1..=4000).contains(&run.glyphs.len()) || run.text.len() > 4000 {
|
||||
return Err(invalid("run font, glyph count or text length is outside B1"));
|
||||
}
|
||||
let cap = run.scale * f64::from(font.metrics.cap_height);
|
||||
let within = |x: f64| x.is_finite() && x.abs() <= MAX_COORDINATE_MM;
|
||||
if !run.scale.is_finite() || !(1e-6..1e6).contains(&cap) || cap == 1e-6
|
||||
|| !within(run.origin.x) || !within(run.origin.y)
|
||||
|| !run.angle.is_finite() || run.angle.abs() > std::f64::consts::TAU
|
||||
|| !run.slant.is_finite() || run.slant.abs() > 1.0
|
||||
{
|
||||
return Err(invalid("run transform is outside B1"));
|
||||
}
|
||||
let mut glyph_end = 0usize;
|
||||
let mut text_end = if run.rtl { run.text.len() } else { 0 };
|
||||
for cluster in &run.clusters {
|
||||
let (g0, g1, t0, t1) = (
|
||||
cluster.glyphs.start as usize, cluster.glyphs.end as usize,
|
||||
cluster.text.start as usize, cluster.text.end as usize,
|
||||
);
|
||||
if g0 != glyph_end || g0 >= g1 || t0 >= t1 || (if run.rtl { t1 != text_end } else { t0 != text_end }) {
|
||||
return Err(invalid("clusters do not partition glyphs and logical text in B1 order"));
|
||||
}
|
||||
if g1 - g0 != 1 {
|
||||
return Err(invalid("multi-glyph cluster extraction is not supported by this text-plan increment"));
|
||||
}
|
||||
let logical = run.text.get(t0..t1).ok_or_else(|| invalid("cluster text is not whole UTF-8 characters"))?;
|
||||
if logical.len() > MAX_CLUSTER_BYTES || logical.encode_utf16().take(MAX_CLUSTER_UNITS + 1).count() > MAX_CLUSTER_UNITS {
|
||||
return Err(invalid("cluster exceeds the existing ToUnicode 512-byte destination contract"));
|
||||
}
|
||||
let glyph = run.glyphs.get(g0).ok_or_else(|| invalid("cluster glyph is outside the run"))?;
|
||||
if ![glyph.x, glyph.rise, glyph.advance].into_iter().all(within) {
|
||||
return Err(invalid("placed glyph is outside B1 coordinates"));
|
||||
}
|
||||
let intrinsic = font.glyphs.get(&glyph.id).ok_or_else(|| invalid("glyph is absent from the B1 font table"))?;
|
||||
if intrinsic.advance != intrinsic_advance(font, glyph.id)? {
|
||||
return Err(invalid("B1 intrinsic advance does not match the admitted hmtx"));
|
||||
}
|
||||
glyph_end = g1;
|
||||
text_end = if run.rtl { t0 } else { t1 };
|
||||
}
|
||||
if glyph_end != run.glyphs.len() || text_end != if run.rtl { 0 } else { run.text.len() } {
|
||||
return Err(invalid("clusters do not cover the whole run exactly once"));
|
||||
}
|
||||
Ok(run.glyphs.len())
|
||||
}
|
||||
|
||||
/// Resolve an F0 run through the already validated Scene table, never by a
|
||||
/// bundled number or Vec index. This increment still plans ONE font and all
|
||||
/// its runs; mixed-font input is rejected by the existing plan_text checks.
|
||||
/// Preparation is not proof of PDF embedding permission or trusted advances;
|
||||
/// the existing owned admission/provenance boundary remains mandatory.
|
||||
pub(super) fn plan_prepared_text<'a>(
|
||||
prepared: &solidcraft_geom::paper::Prepared<'a>,
|
||||
runs: &[&'a TextRun],
|
||||
max_plan_bytes: usize,
|
||||
max_subset_bytes: usize,
|
||||
) -> Result<TextPlan<'a>> {
|
||||
let first = runs.first().copied().ok_or_else(|| invalid("empty allowance or unsupported run count"))?;
|
||||
let font = prepared.font(first).ok_or_else(|| invalid("run font ID is absent from the prepared scene"))?;
|
||||
plan_text(font, runs, max_plan_bytes, max_subset_bytes)
|
||||
}
|
||||
|
||||
/// One bundled font and ALL its runs, in their existing paint order. Code keys
|
||||
/// are sorted (original GID, literal text), so iteration/export repetition cannot
|
||||
/// change their allocation. Code zero stays reserved for .notdef, not user text.
|
||||
pub(super) fn plan_text<'a>(font: &'a Font, runs: &[&'a TextRun], max_plan_bytes: usize, max_subset_bytes: usize) -> Result<TextPlan<'a>> {
|
||||
let limit = max_plan_bytes.min(MAX_PLAN_BYTES);
|
||||
if limit == 0 || max_subset_bytes == 0 || runs.is_empty() || runs.len() > MAX_RUNS {
|
||||
return Err(invalid("empty allowance or unsupported run count"));
|
||||
}
|
||||
let descriptor = descriptor(font)?;
|
||||
let mut count = 0usize;
|
||||
let mut logical_bytes = 0usize;
|
||||
for run in runs {
|
||||
count = count.checked_add(check_run(font, run)?).filter(|n| *n <= MAX_GLYPHS)
|
||||
.ok_or_else(|| invalid("combined glyph count exceeds 16384"))?;
|
||||
logical_bytes = logical_bytes.checked_add(run.text.len()).filter(|n| *n <= MAX_LOGICAL_BYTES)
|
||||
.ok_or_else(|| invalid("combined logical text exceeds its allowance"))?;
|
||||
}
|
||||
total(&[
|
||||
bytes::<(u16, &str)>(count)?, bytes::<u16>(count)?, bytes::<CodeToGlyph<'_>>(count)?,
|
||||
bytes::<PlannedGlyph<'_>>(count)?, bytes::<PlannedRun<'_>>(runs.len())?,
|
||||
], limit)?;
|
||||
let mut keys = reserve::<(u16, &str)>(count)?;
|
||||
let mut used = reserve::<u16>(count)?;
|
||||
let mut codes = reserve::<CodeToGlyph<'_>>(count)?;
|
||||
let mut planned_runs = reserve::<PlannedRun<'_>>(runs.len())?;
|
||||
let mut glyphs = reserve::<PlannedGlyph<'_>>(count)?;
|
||||
total(&[
|
||||
bytes::<(u16, &str)>(keys.capacity())?, bytes::<u16>(used.capacity())?, bytes::<CodeToGlyph<'_>>(codes.capacity())?,
|
||||
bytes::<PlannedGlyph<'_>>(glyphs.capacity())?, bytes::<PlannedRun<'_>>(planned_runs.capacity())?,
|
||||
], limit)?;
|
||||
for run in runs {
|
||||
for cluster in &run.clusters {
|
||||
let glyph = run.glyphs.get(cluster.glyphs.start as usize).ok_or_else(|| invalid("checked glyph disappeared"))?;
|
||||
if glyph.id == 0 {
|
||||
return Err(invalid(".notdef cannot replace a missing text glyph"));
|
||||
}
|
||||
let logical = run.text.get(cluster.text.start as usize..cluster.text.end as usize).ok_or_else(|| invalid("checked text disappeared"))?;
|
||||
keys.push((glyph.id, logical));
|
||||
}
|
||||
}
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
for (gid, _) in &keys {
|
||||
if used.last() != Some(gid) {
|
||||
used.push(*gid);
|
||||
}
|
||||
}
|
||||
if !font.glyphs.keys().copied().eq(used.iter().copied()) {
|
||||
return Err(invalid("the font table is not exactly the glyphs of the supplied runs"));
|
||||
}
|
||||
// Reuse the real existing admission, composite closure and subset library.
|
||||
// There is no duplicate raw-font admission hash or copied font implementation.
|
||||
let subset = subset_font(&font.bytes, font.face, &used, max_subset_bytes)?;
|
||||
for (index, (gid, logical)) in keys.iter().enumerate() {
|
||||
let code = u16::try_from(index + 1).map_err(|_| invalid("text codes exceed u16"))?;
|
||||
let at = subset.mapping().binary_search_by_key(gid, |(old, _)| *old).map_err(|_| invalid("subset lost an original glyph"))?;
|
||||
let (_, subset_gid) = subset.mapping().get(at).ok_or_else(|| invalid("subset mapping is incomplete"))?;
|
||||
codes.push(CodeToGlyph { code, subset_gid: *subset_gid, logical });
|
||||
}
|
||||
for run in runs {
|
||||
let first = glyphs.len();
|
||||
let (sin, cos) = run.angle.sin_cos();
|
||||
for logical_index in 0..run.clusters.len() {
|
||||
let at = if run.rtl { run.clusters.len() - 1 - logical_index } else { logical_index };
|
||||
let cluster = run.clusters.get(at).ok_or_else(|| invalid("checked cluster disappeared"))?;
|
||||
let source = cluster.glyphs.start as usize;
|
||||
let glyph = run.glyphs.get(source).ok_or_else(|| invalid("checked glyph disappeared"))?;
|
||||
let logical = run.text.get(cluster.text.start as usize..cluster.text.end as usize).ok_or_else(|| invalid("checked text disappeared"))?;
|
||||
let key = keys.binary_search(&(glyph.id, logical)).map_err(|_| invalid("text code is absent"))?;
|
||||
let code = codes.get(key).ok_or_else(|| invalid("text code is outside its table"))?.code;
|
||||
let matrix_mm = [
|
||||
cos, sin, cos * run.slant - sin, sin * run.slant + cos,
|
||||
run.origin.x + cos * glyph.x - sin * glyph.rise,
|
||||
run.origin.y + sin * glyph.x + cos * glyph.rise,
|
||||
];
|
||||
if !matrix_mm.iter().all(|n| n.is_finite()) {
|
||||
return Err(invalid("glyph transform is not finite"));
|
||||
}
|
||||
glyphs.push(PlannedGlyph {
|
||||
code, original_gid: glyph.id, source_glyph_index: source,
|
||||
intrinsic_advance_fu: intrinsic_advance(font, glyph.id)?, placed_advance_mm: glyph.advance,
|
||||
matrix_mm, logical,
|
||||
});
|
||||
}
|
||||
planned_runs.push(PlannedRun {
|
||||
glyphs: first..glyphs.len(), font_size_mm: run.scale * f64::from(font.units_per_em), rtl: run.rtl, text: &run.text,
|
||||
});
|
||||
}
|
||||
Ok(TextPlan { font, subset, descriptor, codes, runs: planned_runs, glyphs })
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
diff --git a/acceptance/new-production-boundary-cases.rs b/acceptance/new-production-boundary-cases.rs
|
||||
--- a/acceptance/new-production-boundary-cases.rs
|
||||
+++ b/acceptance/new-production-boundary-cases.rs
|
||||
@@ -52,19 +52,23 @@
|
||||
}
|
||||
|
||||
#[test]
|
||||
-fn f0_opaque_production_input_does_not_bypass_the_unresolved_default_width_gate() {
|
||||
+fn f0_opaque_production_input_emits_explicit_widths_without_a_notdef_record() {
|
||||
use pdf_writer::Ref;
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let before = font.clone();
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
+ assert_eq!(input.source.subset().mapping().first(), Some(&(0, 0)));
|
||||
let refs = super::font_objects::FontObjectRefs {
|
||||
type0: Ref::new(6), cid_font: Ref::new(7), descriptor: Ref::new(8),
|
||||
font_file2: Ref::new(9), to_unicode: Ref::new(10), cid_to_gid: Ref::new(11),
|
||||
};
|
||||
- let error = super::font_objects::font_objects(refs, input).err().unwrap().to_string();
|
||||
- assert!(error.contains("PDF default-width policy is unresolved"), "{error}");
|
||||
+ let chunk = super::font_objects::font_objects(refs, input).unwrap();
|
||||
+ assert_eq!(chunk.refs().map(Ref::get).collect::<Vec<_>>(), [6, 7, 8, 9, 10, 11]);
|
||||
+ assert!(chunk.len() <= 1 << 20);
|
||||
+ // Strict serialized W/No-DW and stream checks live in the helper's
|
||||
+ // resource cases; this case exercises the opaque production entry.
|
||||
assert_eq!(font, before);
|
||||
assert!(!font.glyphs.contains_key(&0));
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
diff --git a/crates/io/src/paper/pdf/font_objects.rs b/crates/io/src/paper/pdf/font_objects.rs
|
||||
--- a/crates/io/src/paper/pdf/font_objects.rs
|
||||
+++ b/crates/io/src/paper/pdf/font_objects.rs
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Identity-H explicitly maps two-byte codes to CIDs. A separate stream maps
|
||||
//! CIDs to subset GIDs; logical text remains keyed by code in real ToUnicode.
|
||||
//! W widths come from actual B1 original-GID advances, never subset hmtx.
|
||||
-//! Missing B1 glyph-zero metrics explicitly block the old DW path.
|
||||
+//! Every declared emitted CID has an explicit W width; no DW fallback is used.
|
||||
//! Descriptor metrics must come from the SAME admitted source font. Numeric
|
||||
//! validation does not prove that provenance, and no metrics are estimated.
|
||||
//!
|
||||
@@ -255,15 +255,7 @@
|
||||
width_1000(glyph.advance, self.font.units_per_em)
|
||||
}
|
||||
|
||||
- fn default_width(&self) -> Result<f32> {
|
||||
- // No extra .notdef record is invented and no width is parsed from the
|
||||
- // subset. Preserve the old explicit DW path ONLY if actual B1 owns GID0.
|
||||
- // Most exactly-used B1 tables omit it: those callers refuse until an
|
||||
- // owner-agreed DW/full-emitted-CID-coverage policy is implemented.
|
||||
- let glyph = self.font.glyphs.get(&0)
|
||||
- .ok_or_else(|| invalid("B1 glyph zero is absent; PDF default-width policy is unresolved"))?;
|
||||
- width_1000(glyph.advance, self.font.units_per_em)
|
||||
- }
|
||||
+
|
||||
}
|
||||
|
||||
fn fixed_font_name(name: &[u8], tag: [u8; 6]) -> Result<[u8; 21]> {
|
||||
@@ -296,7 +288,6 @@
|
||||
unicode: Vec<u8>,
|
||||
cid_map: Vec<u8>,
|
||||
widths: Vec<(u16, f32)>,
|
||||
- default_width: f32,
|
||||
name: [u8; 21],
|
||||
descriptor: &'a DescriptorMetrics,
|
||||
flags: FontFlags,
|
||||
@@ -312,7 +303,6 @@
|
||||
return Err(invalid("PDF font code count exceeds 65536"));
|
||||
}
|
||||
let metrics = B1Widths::read(input.source_font, input.subset)?;
|
||||
- let default_width = metrics.default_width()?;
|
||||
let limit = input.max_chunk_bytes.min(MAX_CHUNK_BYTES).min(MAX_DRAWING_BYTES);
|
||||
let max_cid = input.codes.last().map_or(0, |row| usize::from(row.code));
|
||||
let map_len = max_cid.checked_add(1).and_then(|n| n.checked_mul(2))
|
||||
@@ -364,7 +354,7 @@
|
||||
}
|
||||
Ok(Prepared {
|
||||
font: input.subset.bytes(), unicode, cid_map, widths,
|
||||
- default_width, name, descriptor, flags, limit, bound,
|
||||
+ name, descriptor, flags, limit, bound,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -396,7 +386,7 @@
|
||||
fn font_objects_parts(refs: FontObjectRefs, input: FontObjectParts<'_>) -> Result<Chunk> {
|
||||
refs.validate()?;
|
||||
let prepared = prepare(input)?;
|
||||
- let Prepared { font, unicode, cid_map, widths, default_width, name, descriptor, flags, limit, bound } = prepared;
|
||||
+ let Prepared { font, unicode, cid_map, widths, name, descriptor, flags, limit, bound } = prepared;
|
||||
let mut chunk = Chunk::new();
|
||||
{
|
||||
let mut writer = chunk.type0_font(refs.type0);
|
||||
@@ -408,7 +398,7 @@
|
||||
let mut writer = chunk.cid_font(refs.cid_font);
|
||||
writer.subtype(CidFontType::Type2).base_font(Name(&name))
|
||||
.system_info(SystemInfo { registry: Str(b"Adobe"), ordering: Str(b"Identity"), supplement: 0 })
|
||||
- .font_descriptor(refs.descriptor).default_width(default_width)
|
||||
+ .font_descriptor(refs.descriptor)
|
||||
.cid_to_gid_map_stream(refs.cid_to_gid);
|
||||
let mut array = writer.widths();
|
||||
for &(cid, width) in &widths {
|
||||
@@ -806,6 +796,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
+ // Test-only structural oracle: keep both W array levels and reject trailing
|
||||
+ // fields. Literal limits are independent of the emitter's constants.
|
||||
+ fn parse_all_width_rows(field: &str) -> Result<Vec<(u16, [f32; 1])>> {
|
||||
+ if field.len() > (16 << 20) {
|
||||
+ return Err(invalid("W coverage oracle field exceeds 16 MiB"));
|
||||
+ }
|
||||
+ let mut reader = WidthField { bytes: field.as_bytes(), at: 0 };
|
||||
+ reader.literal(b"/W")?;
|
||||
+ reader.literal(b"[")?;
|
||||
+ let mut rows = Vec::new();
|
||||
+ loop {
|
||||
+ skip_space(reader.bytes, &mut reader.at);
|
||||
+ if reader.bytes.get(reader.at) == Some(&b']') {
|
||||
+ reader.at += 1;
|
||||
+ break;
|
||||
+ }
|
||||
+ if rows.len() == 65_536 {
|
||||
+ return Err(invalid("W coverage oracle exceeds 65536 rows"));
|
||||
+ }
|
||||
+ rows.push(reader.row()?);
|
||||
+ }
|
||||
+ skip_space(reader.bytes, &mut reader.at);
|
||||
+ if reader.at != reader.bytes.len() {
|
||||
+ return Err(invalid("W coverage oracle has trailing data"));
|
||||
+ }
|
||||
+ Ok(rows)
|
||||
+ }
|
||||
+
|
||||
fn parse_two_width_rows(field: &str) -> Result<[(u16, [f32; 1]); 2]> {
|
||||
if field.len() > 512 {
|
||||
return Err(invalid("W oracle field exceeds its byte limit"));
|
||||
@@ -834,7 +852,6 @@
|
||||
let prepared = prepare(input(&subset, &codes, &descriptor)).unwrap();
|
||||
// Independent original-font integer metadata, not emitter output.
|
||||
let empty_width = (569_000.0_f64 / 2048.0) as f32;
|
||||
- assert_eq!(prepared.default_width, 750.0); //1536*1000/2048.
|
||||
assert_eq!(prepared.widths, [(42, empty_width), (300, 750.0)]);
|
||||
// Subsetter compresses the repeated569 width of the two empty glyphs.
|
||||
let view = MetricView::read(&subset).unwrap();
|
||||
@@ -843,7 +860,7 @@
|
||||
let chunk = font_objects(refs(6), input(&subset, &codes, &descriptor)).unwrap();
|
||||
let parsed = objects(chunk.as_bytes());
|
||||
let cid = object(&parsed, 7).dict;
|
||||
- assert!(cid.contains("/DW 750"));
|
||||
+ assert!(!cid.contains("/DW"), "all emitted CIDs use explicit W rows");
|
||||
// W is the last entry of this emitted dictionary; remove ONLY its
|
||||
// dictionary terminator, then parse all of W including both array levels.
|
||||
let field = cid.get(cid.find("/W ").unwrap()..).unwrap()
|
||||
@@ -985,6 +1002,17 @@
|
||||
let chunk = font_objects(refs(6), input(&subset, &codes, &descriptor)).unwrap();
|
||||
assert_eq!(chunk.refs().count(), 6);
|
||||
assert!(chunk.len() <= MAX_CHUNK_BYTES);
|
||||
+ let parsed = objects(chunk.as_bytes());
|
||||
+ let cid = object(&parsed, 7).dict;
|
||||
+ assert!(!cid.contains("/DW"));
|
||||
+ let field = cid.get(cid.find("/W ").unwrap()..).unwrap().strip_suffix(">>").unwrap().trim_end();
|
||||
+ let rows = parse_all_width_rows(field).unwrap();
|
||||
+ assert_eq!(rows.len(), 65_536);
|
||||
+ for (index, &(cid, [width])) in rows.iter().enumerate() {
|
||||
+ assert_eq!(usize::from(cid), index, "every declared CID occurs once, including both extremes");
|
||||
+ assert!(width.is_finite());
|
||||
+ assert_eq!(width, if index == 0 { 750.0 } else { 277.832_031_25_f64 as f32 });
|
||||
+ }
|
||||
codes.push(CodeToGlyph { code: 0, subset_gid: 0, logical: "x" });
|
||||
assert!(font_objects(refs(6), input(&subset, &codes, &descriptor)).is_err());
|
||||
}
|
||||
@@ -1033,11 +1061,11 @@
|
||||
];
|
||||
let prepared = prepare(input_from_b1(&subset, &codes, &descriptor, &font)).unwrap();
|
||||
assert_eq!(prepared.widths, [(42, (666.992_187_5_f64 as f32)), (300, (277.832_031_25_f64 as f32))]);
|
||||
- assert_eq!(prepared.default_width, 750.0);
|
||||
let chunk = font_objects(refs(16), input_from_b1(&subset, &codes, &descriptor, &font)).unwrap();
|
||||
let parsed = objects(chunk.as_bytes());
|
||||
assert_eq!(object(&parsed, 19).stream.unwrap(), subset.bytes());
|
||||
let field = object(&parsed, 17).dict;
|
||||
+ assert!(!field.contains("/DW"));
|
||||
let field = field.get(field.find("/W ").unwrap()..).unwrap().strip_suffix(">>").unwrap().trim_end();
|
||||
assert_eq!(parse_two_width_rows(field).unwrap(), [(42, [(666.992_187_5_f64 as f32)]), (300, [(277.832_031_25_f64 as f32)])]);
|
||||
let cmap = std::str::from_utf8(object(&parsed, 20).stream.unwrap()).unwrap();
|
||||
@@ -1054,7 +1082,12 @@
|
||||
let codes = [CodeToGlyph { code: 9, subset_gid: 1, logical: "zero" }];
|
||||
let result = prepare(input_from_b1(&subset, &codes, &descriptor, &font)).unwrap();
|
||||
assert_eq!(result.widths, [(9, 0.0)]);
|
||||
- assert_eq!(result.default_width, 750.0);
|
||||
+ let chunk = font_objects(refs(6), input_from_b1(&subset, &codes, &descriptor, &font)).unwrap();
|
||||
+ let parsed = objects(chunk.as_bytes());
|
||||
+ let cid = object(&parsed, 7).dict;
|
||||
+ assert!(!cid.contains("/DW"));
|
||||
+ let field = cid.get(cid.find("/W ").unwrap()..).unwrap().strip_suffix(">>").unwrap().trim_end();
|
||||
+ assert_eq!(parse_all_width_rows(field).unwrap(), [(9, [0.0])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1111,22 +1144,28 @@
|
||||
}
|
||||
|
||||
#[test]
|
||||
- fn b1_widths_omitted_notdef_still_plans_used_width_but_blocks_default_policy() {
|
||||
+ fn b1_widths_omitted_notdef_uses_complete_explicit_rows_without_a_default() {
|
||||
let subset = subset_font(BUNDLED, 0, &[36], MAX_FONT_BYTES).unwrap();
|
||||
let font = b1_width_font(&[(36, 1366)]);
|
||||
let provider = B1Widths::read(&font, &subset).unwrap();
|
||||
assert_eq!(provider.advance(1).unwrap(), (666.992_187_5_f64 as f32));
|
||||
- let e = provider.default_width().unwrap_err().to_string();
|
||||
- assert!(e.contains("glyph zero is absent; PDF default-width policy is unresolved"), "{e}");
|
||||
let descriptor = synthetic_descriptor();
|
||||
let codes = [CodeToGlyph { code: 1, subset_gid: 1, logical: "A" }];
|
||||
- let e = match font_objects(refs(6), input_from_b1(&subset, &codes, &descriptor, &font)) {
|
||||
- Ok(_) => panic!("emitted font objects without an agreed default-width source"),
|
||||
- Err(e) => e.to_string(),
|
||||
- };
|
||||
- assert!(e.contains("default-width policy is unresolved"), "{e}");
|
||||
- // A caller cannot bypass the unresolved policy by emitting no codes.
|
||||
- assert!(font_objects(refs(6), input_from_b1(&subset, &[], &descriptor, &font)).is_err());
|
||||
+ let chunk = font_objects(refs(6), input_from_b1(&subset, &codes, &descriptor, &font)).unwrap();
|
||||
+ let parsed = objects(chunk.as_bytes());
|
||||
+ let cid = object(&parsed, 7).dict;
|
||||
+ assert!(!cid.contains("/DW"));
|
||||
+ let field = cid.get(cid.find("/W ").unwrap()..).unwrap().strip_suffix(">>").unwrap().trim_end();
|
||||
+ assert_eq!(parse_all_width_rows(field).unwrap(), [(1, [666.992_187_5_f64 as f32])]);
|
||||
+ assert_eq!(object(&parsed, 9).stream.unwrap(), subset.bytes());
|
||||
+ assert_eq!(subset.mapping().first(), Some(&(0, 0)), ".notdef remains in the embedded program");
|
||||
+ // No declared emitted CIDs means no W rows, not an invented CID0 width.
|
||||
+ let empty = font_objects(refs(6), input_from_b1(&subset, &[], &descriptor, &font)).unwrap();
|
||||
+ let parsed = objects(empty.as_bytes());
|
||||
+ let cid = object(&parsed, 7).dict;
|
||||
+ assert!(!cid.contains("/DW"));
|
||||
+ let field = cid.get(cid.find("/W ").unwrap()..).unwrap().strip_suffix(">>").unwrap().trim_end();
|
||||
+ assert!(parse_all_width_rows(field).unwrap().is_empty());
|
||||
assert!(!font.glyphs.contains_key(&0));
|
||||
}
|
||||
|
||||
@@ -1212,6 +1251,86 @@
|
||||
assert_eq!(subset.mapping(), before_mapping);
|
||||
let prepared = prepare(input_from_b1(&subset, &codes, &descriptor, &font)).unwrap();
|
||||
assert_eq!(prepared.widths, [(42, (277.832_031_25_f64 as f32)), (300, (277.832_031_25_f64 as f32))]);
|
||||
+ }
|
||||
+
|
||||
+ // Owner DW decision: #7626 comment6534292604. These cover resource rows,
|
||||
+ // not a parent page-content emission or PDF-reader/native qualification.
|
||||
+ #[test]
|
||||
+ fn explicit_widths_cover_sparse_extreme_cids_without_a_notdef_record() {
|
||||
+ let subset = subset_font(BUNDLED, 0, &[3, 36], MAX_FONT_BYTES).unwrap();
|
||||
+ let font = b1_width_font(&[(3, 569), (36, 1366)]);
|
||||
+ let descriptor = synthetic_descriptor();
|
||||
+ let codes = [
|
||||
+ CodeToGlyph { code: 1, subset_gid: 2, logical: "A" },
|
||||
+ CodeToGlyph { code: u16::MAX, subset_gid: 1, logical: " " },
|
||||
+ ];
|
||||
+ let chunk = font_objects(refs(6), input_from_b1(&subset, &codes, &descriptor, &font)).unwrap();
|
||||
+ let parsed = objects(chunk.as_bytes());
|
||||
+ assert_eq!(parsed.iter().map(|object| object.id).collect::<Vec<_>>(), [6, 7, 8, 9, 10, 11]);
|
||||
+ let cid = object(&parsed, 7).dict;
|
||||
+ assert!(!cid.contains("/DW"));
|
||||
+ let field = cid.get(cid.find("/W ").unwrap()..).unwrap().strip_suffix(">>").unwrap().trim_end();
|
||||
+ assert_eq!(parse_two_width_rows(field).unwrap(),
|
||||
+ [(1, [666.992_187_5_f64 as f32]), (65_535, [277.832_031_25_f64 as f32])]);
|
||||
+ let map = object(&parsed, 11).stream.unwrap();
|
||||
+ assert_eq!(map.len(), 131_072);
|
||||
+ assert_eq!(&map[..2], &[0, 0], "unemitted CID0 stays a map placeholder, not an emitted width");
|
||||
+ assert_eq!(&map[2..4], &[0, 2]);
|
||||
+ assert_eq!(&map[131_070..], &[0, 1]);
|
||||
+ assert_eq!(object(&parsed, 9).stream.unwrap(), subset.bytes());
|
||||
+ assert_eq!(subset.mapping().first(), Some(&(0, 0)));
|
||||
+ let cmap = std::str::from_utf8(object(&parsed, 10).stream.unwrap()).unwrap();
|
||||
+ assert!(cmap.contains("<0001> <0041>") && cmap.contains("<FFFF> <0020>"));
|
||||
+ assert!(!font.glyphs.contains_key(&0));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn an_explicit_zero_cid_cannot_borrow_a_width_from_the_program_notdef() {
|
||||
+ let subset = subset_font(BUNDLED, 0, &[36], MAX_FONT_BYTES).unwrap();
|
||||
+ let font = b1_width_font(&[(36, 1366)]);
|
||||
+ let descriptor = synthetic_descriptor();
|
||||
+ assert_eq!(subset.mapping().first(), Some(&(0, 0)));
|
||||
+ let codes = [CodeToGlyph { code: 0, subset_gid: 0, logical: "synthetic" }];
|
||||
+ let error = font_objects(refs(6), input_from_b1(&subset, &codes, &descriptor, &font)).err().unwrap().to_string();
|
||||
+ assert!(error.contains("lacks an intrinsic width for an emitted original glyph"), "{error}");
|
||||
+ let wrong_map = [CodeToGlyph { code: 0, subset_gid: 1, logical: "synthetic" }];
|
||||
+ let error = font_objects(refs(6), input_from_b1(&subset, &wrong_map, &descriptor, &font)).err().unwrap().to_string();
|
||||
+ assert!(error.contains("CID zero must map to subset glyph zero"), "{error}");
|
||||
+ assert!(!font.glyphs.contains_key(&0));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn actual_f0_producer_plan_emits_complete_explicit_width_resources() {
|
||||
+ use solidcraft_geom::paper::{Group, Item, ObjectKind, Scene};
|
||||
+ let Some((runs, fonts)) = solidcraft_doc::sketch::paper_text(
|
||||
+ "AV", None, solidcraft_geom::Vec2::new(20.0, 30.0), 5.0, 0.0,
|
||||
+ &solidcraft_doc::sketch::TextStyle::default(),
|
||||
+ ).unwrap() else { panic!("nonempty supported text must produce records") };
|
||||
+ let mut scene = Scene::new(solidcraft_geom::Vec2::new(210.0, 297.0),
|
||||
+ vec![Group { object: 1, kind: ObjectKind::TitleBlock, items: runs.iter().cloned().map(Item::Text).collect() }]);
|
||||
+ scene.add_fonts(fonts);
|
||||
+ let prepared = scene.prepare().unwrap();
|
||||
+ let borrowed: Vec<_> = runs.iter().collect();
|
||||
+ let plan = super::super::text_plan::plan_prepared_text(&prepared, &borrowed, 1 << 20, MAX_FONT_BYTES).unwrap();
|
||||
+ let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
+ assert!(!input.source.font().glyphs.contains_key(&0));
|
||||
+ assert_eq!(input.source.subset().mapping(), [(0, 0), (36, 1), (57, 2)]);
|
||||
+ let program = input.source.subset().bytes().to_vec();
|
||||
+ // Explicit super calls the opaque production boundary, not this test
|
||||
+ // module's historical raw-component alias.
|
||||
+ let chunk = super::font_objects(refs(6), input).unwrap();
|
||||
+ let parsed = objects(chunk.as_bytes());
|
||||
+ assert_eq!(parsed.iter().map(|object| object.id).collect::<Vec<_>>(), [6, 7, 8, 9, 10, 11]);
|
||||
+ let cid = object(&parsed, 7).dict;
|
||||
+ assert!(!cid.contains("/DW"));
|
||||
+ let field = cid.get(cid.find("/W ").unwrap()..).unwrap().strip_suffix(">>").unwrap().trim_end();
|
||||
+ assert_eq!(parse_two_width_rows(field).unwrap(),
|
||||
+ [(1, [666.992_187_5_f64 as f32]), (2, [666.992_187_5_f64 as f32])]);
|
||||
+ assert_eq!(object(&parsed, 9).stream.unwrap(), program.as_slice());
|
||||
+ assert_eq!(object(&parsed, 11).stream.unwrap(), [0, 0, 0, 1, 0, 2]);
|
||||
+ let cmap = std::str::from_utf8(object(&parsed, 10).stream.unwrap()).unwrap();
|
||||
+ assert!(cmap.contains("<0001> <0041>") && cmap.contains("<0002> <0056>"));
|
||||
+ assert!(!prepared.font(&runs[0]).unwrap().glyphs.contains_key(&0));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"repository": "tangency-labs/solidcraft",
|
||||
"status": "SOURCE_ONLY_UNFORMATTED_UNCOMPILED_UNRUN_UNREGISTERED_HELPER_SUCCESSOR",
|
||||
"producer": {
|
||||
"B1a_PR": 9512,
|
||||
"B1a_head": "16c9e9119214a3595b1303e2f1116cd067986c38",
|
||||
"B1b_PR": 9513,
|
||||
"B1b_head": "a46d46e95a7f5c7680ae7440d3c9989846947128",
|
||||
"drawings_base": "562bb379a5b426506d7fc75cc069bc0936044671",
|
||||
"owner_reply": "https://github.com/tangency-labs/solidcraft/issues/4890#issuecomment-6532962216",
|
||||
"contract": "TextRun.font names Font.id; paper_text returns unique used Vec<Font>; no public Font.bundled or invented Font.embed.",
|
||||
"Font_embed": "F2 pending; not present in this proposal"
|
||||
},
|
||||
"lineage": {
|
||||
"predecessor_public_commit": "45286e048983246ae1c7debf4e01e102762b2783",
|
||||
"predecessor_packet": "../F0-textplan-fontobjects/README.md",
|
||||
"v4_is_incremental_on_exact_v3_helper": true,
|
||||
"text_plan_byte_unchanged": true,
|
||||
"no_single_composed_or_qualified_Git_head": true,
|
||||
"historical_v3_DW_refusal_preserved_in_its_archive": true
|
||||
},
|
||||
"owner_decision": {
|
||||
"author": "CamWight",
|
||||
"url": "https://github.com/tangency-labs/solidcraft/pull/7626#issuecomment-6534292604",
|
||||
"created_at": "2026-10-30T21:53:12Z",
|
||||
"contract": "Complete explicit W for every emitted CID, no reliance on DW and no added B1 GID0 width. Program notdef remains a separate embedding obligation.",
|
||||
"status": "RESOLVED_AND_IMPLEMENTED_IN_THIS_SOURCE_HELPER_ONLY_NOT_RUNTIME"
|
||||
},
|
||||
"product_change": {
|
||||
"path": "crates/io/src/paper/pdf/font_objects.rs",
|
||||
"before_sha256": "393ed32fb7a7633fa949ad6758fe9989cab4633ea3771752886c3404558d8a33",
|
||||
"candidate_sha256": "2c1aaa6f83f2e2d3631d02b41d3c2e6989f331be83b2a2dc62723244e4f0c709",
|
||||
"before_bytes": 58307,
|
||||
"candidate_bytes": 65175,
|
||||
"before": "before/crates/io/src/paper/pdf/font_objects.rs",
|
||||
"candidate": "candidate/crates/io/src/paper/pdf/font_objects.rs",
|
||||
"delta": "deltas/product-from-v3.patch",
|
||||
"delta_sha256": "c02105abbee968b2a4a994097c364c50cd2acf4fe1d693657392f4b5c5f25ef8",
|
||||
"semantic_change": "Remove default-width lookup/scalar/propagation/explicit DW writer call; preserve original per-declared-code width validation and W emission."
|
||||
},
|
||||
"unchanged_companion": {
|
||||
"path": "candidate/crates/io/src/paper/pdf/text_plan.rs",
|
||||
"sha256": "eddb5120912b934c41fd58cea345c9cc8e74f067995336da8401737a9a2fc027",
|
||||
"bytes": 18699
|
||||
},
|
||||
"acceptance_delta": {
|
||||
"baseline": "before/acceptance/new-production-boundary-cases.rs",
|
||||
"candidate": "acceptance/new-production-boundary-cases.rs",
|
||||
"delta": "deltas/acceptance-owner-policy-from-v3.patch",
|
||||
"before_sha256": "017f5876b575a78c5a54a2d75fcfacc6440b718ae06933991a33d33d014a9131",
|
||||
"candidate_sha256": "169b5b1abdc9e84d1488f9b14080b59079d6da4c187127c5fd5ec2f4c6de5795",
|
||||
"delta_sha256": "3fbf8094ddbf09cdb0e17f99e3e0deb9fe8000c50bf9b71fe0a2b48b23ac2b22",
|
||||
"logical_path_is_owned_appendix_not_Cam_pdf_tests": true
|
||||
},
|
||||
"acceptance": {
|
||||
"ordinary_declarations": 49,
|
||||
"inline_helper": 27,
|
||||
"retained_B1": 13,
|
||||
"F0_wrapper": 4,
|
||||
"production_boundary": 5,
|
||||
"negative_compile_obligations": 4,
|
||||
"literal_old_helper_bodies_unchanged": 19,
|
||||
"old_helper_DW_bodies_owner_superseded": 4,
|
||||
"old_full_u16_case_strengthened": 1,
|
||||
"new_helper_declarations": 3,
|
||||
"first_four_boundary_bodies_unchanged": true,
|
||||
"fifth_boundary_body_owner_policy_changed": true,
|
||||
"unchanged_fragments": [
|
||||
{
|
||||
"path": "acceptance/retained-b1-cases.rs",
|
||||
"sha256": "4658fc295fac96d2d1c66cb22622b757b531ac4db982b2368125351b321a11f9",
|
||||
"bytes": 10324
|
||||
},
|
||||
{
|
||||
"path": "acceptance/new-f0-wrapper-cases.rs",
|
||||
"sha256": "d9eca9a001f865a7eb14b7e1b9017bcd9cbe85a58cc133510d239c9ed1fb7a19",
|
||||
"bytes": 3674
|
||||
},
|
||||
{
|
||||
"path": "acceptance/negative-compile-obligations.rs",
|
||||
"sha256": "3ed8ade260983df4960016defaa6c607a454b77452ea6970a933ae755a66c353",
|
||||
"bytes": 1786
|
||||
}
|
||||
],
|
||||
"all_ordinary_and_negative_UNRUN": true,
|
||||
"negative_snippets_must_not_be_included_as_ordinary_module_code": true
|
||||
},
|
||||
"checked_boundary": {
|
||||
"mint": "TextPlan::font_input only constructs FontPlanView after plan_text returns successfully.",
|
||||
"view_private_fields": [
|
||||
"font",
|
||||
"subset",
|
||||
"descriptor",
|
||||
"codes"
|
||||
],
|
||||
"public_production_input": [
|
||||
"source:FontPlanView",
|
||||
"subset_tag",
|
||||
"max_chunk_bytes"
|
||||
],
|
||||
"raw_parts": "FontObjectParts and font_objects_parts are module-private, no safe sibling/public/serde entry; descendant component tests use explicit private aliases, never mint the view.",
|
||||
"same_immutable_source_subset": true
|
||||
},
|
||||
"preserved_checks": [
|
||||
"opaque plan/getters and exact builtin byte/face identity",
|
||||
"single source subset/closure/RAW admission",
|
||||
"B1 original-GID intrinsic advances and actual original hmtx consistency",
|
||||
"finite/descriptor/metadata/code order/duplicates/UTF/mapping/tag/Ref/output/budget guards",
|
||||
"CID0 declared mapping and missing actual emitted-original-width refusal"
|
||||
],
|
||||
"width_policy": {
|
||||
"authority": "checked original B1 FontGlyph.advance / unitsPerEm, joined through original GID, separate from placed advances",
|
||||
"formula": "1000 * original_advance / original_unitsPerEm, using existing finite/range-checked width_1000",
|
||||
"resource_coverage": "Exactly one explicit W row for every code in the immutable opaque declared-code slice",
|
||||
"parent_coverage_pending": "Actual page content must emit only planned codes and independently prove every emitted CID is in that complete declared table",
|
||||
"DW": "Omitted; no reliance on a DW fallback. Reader behavior for undeclared CIDs is not coverage.",
|
||||
"GID0": "Original0-to-subset0 mapping/font program stays intact; no B1 width is invented for it. Declared synthetic CID0 still requires subset0 mapping and real source width.",
|
||||
"empty_or_sparse_codes": "No invented widths for empty declared sets, sparse holes or unused map placeholders"
|
||||
},
|
||||
"builtin_source_identity": {
|
||||
"api": "solidcraft_doc::sketch::font_data(None)",
|
||||
"fields": "fresh index0/weightNone; bounded original-byte slice equality and face equality",
|
||||
"before_filesystem_or_cache": true,
|
||||
"substitution_or_fallback": false,
|
||||
"new_parser": false,
|
||||
"new_admission": false,
|
||||
"duplicate_hash": false,
|
||||
"B1_hash_in_textplan": "existing bytes||faceLE check retained",
|
||||
"B1_hash_in_fontobjects": "removed redundant recomputation; fixed reviewedB1ID and exact builtin source comparison retained",
|
||||
"RAW_SHA256": "075e71be4c58beda172ecb574a799308f04c3d054c6a5b7434954d3b0f1b28d8",
|
||||
"B1_ID": "af02dbc8839b7cadae15e528aa3d9ff85c545b715b551d218bf38a937ee7e2b0",
|
||||
"licence_or_F2_waiver": false
|
||||
},
|
||||
"unmodified_source_admission": {
|
||||
"font_admission": {
|
||||
"repository_path": "crates/io/src/paper/pdf/font_admission.rs",
|
||||
"sha256": "a8abc3b4aaef9325694ec404512c228f9973b82006b5718eb304ef57d5c30b23",
|
||||
"bytes": 4170
|
||||
},
|
||||
"glyph_closure": {
|
||||
"repository_path": "crates/io/src/paper/pdf/glyph_closure.rs",
|
||||
"sha256": "a8d94c9996a5c718346e66d1983e6d2f2edb224286d3f45e92d731ab0013f1d8",
|
||||
"bytes": 38397
|
||||
},
|
||||
"subset_font": {
|
||||
"repository_path": "crates/io/src/paper/pdf/subset_font.rs",
|
||||
"sha256": "a9808063f2223fd9e86e208d55092b6b9e9a5ae7ba0eaba2dce5d557a7fd5f26",
|
||||
"bytes": 8941
|
||||
}
|
||||
},
|
||||
"upstream_source_pins": [
|
||||
{
|
||||
"path": "b1a-text",
|
||||
"repository_path": "crates/geom/src/paper/text.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/16c9e9119214a3595b1303e2f1116cd067986c38/crates/geom/src/paper/text.rs",
|
||||
"blob_sha1": "f16f1e703323a11a0dfbd17de680278006aca997",
|
||||
"sha256": "8b813342836802bdd6f7955ae26bcc3a605c2e9e6e358e4a2097c330d860eb65",
|
||||
"bytes": 14124
|
||||
},
|
||||
{
|
||||
"path": "f0-text",
|
||||
"repository_path": "crates/geom/src/paper/text.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/geom/src/paper/text.rs",
|
||||
"blob_sha1": "f16f1e703323a11a0dfbd17de680278006aca997",
|
||||
"sha256": "8b813342836802bdd6f7955ae26bcc3a605c2e9e6e358e4a2097c330d860eb65",
|
||||
"bytes": 14124
|
||||
},
|
||||
{
|
||||
"path": "f0-paper",
|
||||
"repository_path": "crates/geom/src/paper.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/geom/src/paper.rs",
|
||||
"blob_sha1": "f8f924b0529255efed81b5d2eaa00ae6e8692cb6",
|
||||
"sha256": "5b9fb55ddcfb1d1fa488622dfc69b6d2ee5545943e0d453645aaf8b421bf75d5",
|
||||
"bytes": 10593
|
||||
},
|
||||
{
|
||||
"path": "f0-layout",
|
||||
"repository_path": "crates/sketch/src/paper_text.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/sketch/src/paper_text.rs",
|
||||
"blob_sha1": "63a73ef4c34fe9a1a206e60dd9527b90744b078f",
|
||||
"sha256": "6b3dcb84ba8cdac8b50ee264443b0ac5e27296a1cd02f48ac74a5b2e80908553",
|
||||
"bytes": 10871
|
||||
},
|
||||
{
|
||||
"path": "f0-layout-tests",
|
||||
"repository_path": "crates/sketch/src/paper_text_tests.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/sketch/src/paper_text_tests.rs",
|
||||
"blob_sha1": "bab262b09dd31d93df6e50fe7cc7ca5385a28c60",
|
||||
"sha256": "6c06017f6afbeae37856e7298135a03efdad85b4b1ab89b928deb425828b88a4",
|
||||
"bytes": 9807
|
||||
},
|
||||
{
|
||||
"path": "f0-record-tests",
|
||||
"repository_path": "crates/geom/src/paper/text_tests.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/geom/src/paper/text_tests.rs",
|
||||
"blob_sha1": "16ffc7677f85ff0aff8a6ca67bc30e21cfffdfbc",
|
||||
"sha256": "0799c62e97576c1483437a15d72c58fe7dda1441e21019405b4cb65d90bd2ce3",
|
||||
"bytes": 12753
|
||||
},
|
||||
{
|
||||
"path": "font_admission.rs",
|
||||
"repository_path": "crates/io/src/paper/pdf/font_admission.rs",
|
||||
"url": "https://github.com/tangency-labs/solidcraft/blob/bf2a6fd567b03e1d25dcb66624e719a0cec109fc/crates/io/src/paper/pdf/font_admission.rs",
|
||||
"blob_sha1": "632d21b81d25626dd5fe6e3b28d274a96a640382",
|
||||
"sha256": "a8abc3b4aaef9325694ec404512c228f9973b82006b5718eb304ef57d5c30b23",
|
||||
"bytes": 4170
|
||||
},
|
||||
{
|
||||
"path": "subset_font.rs",
|
||||
"repository_path": "crates/io/src/paper/pdf/subset_font.rs",
|
||||
"url": "https://github.com/tangency-labs/solidcraft/blob/bf2a6fd567b03e1d25dcb66624e719a0cec109fc/crates/io/src/paper/pdf/subset_font.rs",
|
||||
"blob_sha1": "cef7bd532639faf95f5ec5f9ea7451b79b03c688",
|
||||
"sha256": "a9808063f2223fd9e86e208d55092b6b9e9a5ae7ba0eaba2dce5d557a7fd5f26",
|
||||
"bytes": 8941
|
||||
},
|
||||
{
|
||||
"path": "public-sources/9513-a46d/crates/sketch/src/fonts.rs",
|
||||
"repository_path": "crates/sketch/src/fonts.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/sketch/src/fonts.rs",
|
||||
"blob_sha1": "4b104ec49eac9516dc2f69aa56fff779eccdb7f2",
|
||||
"sha256": "f497592266ef11a4071d550277cf61c75ce4d2e6e40e4e0e3b8da6e06969c3d0",
|
||||
"bytes": 22385
|
||||
},
|
||||
{
|
||||
"path": "public-sources/9513-a46d/crates/doc/src/lib.rs",
|
||||
"repository_path": "crates/doc/src/lib.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/doc/src/lib.rs",
|
||||
"blob_sha1": "0123c4bac25559c19a9f3e9c0043dfbc46dd3b3d",
|
||||
"sha256": "e67a61f9ef907a36105d0689a7808cad3e44175a14607d3f78a246af7e81a882",
|
||||
"bytes": 4091
|
||||
},
|
||||
{
|
||||
"path": "public-sources/9513-a46d/crates/io/Cargo.toml",
|
||||
"repository_path": "crates/io/Cargo.toml",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/io/Cargo.toml",
|
||||
"blob_sha1": "e9a701fe1a60be6b30e083a911554a78a2e15b2e",
|
||||
"sha256": "e19c93f548baf29442657ec74e5307bccceaccf202491d80f192ed234bb55ce2",
|
||||
"bytes": 1467
|
||||
}
|
||||
],
|
||||
"dependency_source_inspection": {
|
||||
"name": "pdf-writer",
|
||||
"version": "0.15.0",
|
||||
"path": "src/font.rs",
|
||||
"sha256": "2ce15a3574d4e1fc3ba31ae72442b75534a0a96921f7d7ac8bcc8f7947a9193f",
|
||||
"scope": "Source inspection only; DW inserted by explicit default_width method; widths/consecutive emits W rows; no dependency execution or API qualification"
|
||||
},
|
||||
"Cam_parent_and_allocator": "Actual #9647 ce505658 parent/Refs/global allocator/content untouched. No parent registration/font resources or real page-content acceptance supplied.",
|
||||
"source_review": {
|
||||
"scope": "Independent focal source review found no actionable defect in this owner-authorized delta; byte replay only, not compiler/runtime/PDF acceptance"
|
||||
},
|
||||
"pending": [
|
||||
"normal module/test registration",
|
||||
"actual coherent dependencies/Cargo.lock",
|
||||
"format/compiler/native/wasm/strict/full CI",
|
||||
"Cam parent content/resources and all-runs grouping/aggregate budgets",
|
||||
"every real emitted CID coverage",
|
||||
"multi-glyph ActualText/extraction",
|
||||
"real PDF rendering/readback/old-byte comparison",
|
||||
"embedding/package licensing and future F2"
|
||||
],
|
||||
"authorship": "Worker #1 and Engineer team original proposal/acceptance; referenced Cam/P2/font code retains existing original authorship and licences. No binary font/asset or new dependency included."
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# P2 F0 font-plan boundary — source preservation, v3
|
||||
|
||||
**UNFORMATTED, UNCOMPILED, UNRUN, UNREGISTERED.** This packet preserves two owned product proposals and original acceptance fragments. It is not an integrated branch, production font support, a completed PDF, a passing gate or a merge claim. Earlier archives remain available; none is overwritten.
|
||||
|
||||
## Actual source and lineage
|
||||
|
||||
The current F0 contract is pinned to Cam's B1a [#9512 at 16c9e911](https://github.com/tangency-labs/solidcraft/pull/9512) and B1b [#9513 at a46d46e9](https://github.com/tangency-labs/solidcraft/pull/9513): `TextRun.font` names `Font.id`, and `paper_text` returns the unique used `Vec<Font>`. The numeric `Font.bundled` field is gone. `Font.embed` belongs to F2 and is **not** invented here. Full immutable heads, blobs, byte hashes and per-file baselines are in [source-bindings.json](source-bindings.json).
|
||||
|
||||
The predecessor [width/Refs composition](https://github.com/tangency-labs/solidcraft/tree/5f127aceb09f04dcfe6e4dc1d0253a292787dffa/proposals/drawings/2026-10-30/P2-width-Refs-composition) remains bound to its older records. This successor carries the F0 and checked-source boundary work. The v3 source manifest is `1b6f64a24513cb069efd76708fd8788dd29ff09da802ea662c1a60da2bbc24ed`; its product candidates remain byte-identical to v2 (`6795b37c`).
|
||||
|
||||
## DW decision since this archived source
|
||||
|
||||
Cam has now [resolved the DW contract](https://github.com/tangency-labs/solidcraft/pull/7626#issuecomment-6534292604): explicit `/W` coverage for **every emitted CID**, without relying on `/DW`; B1 adds no GID-0 width. The embedded font program's `.notdef` glyph is a separate embedding responsibility.
|
||||
|
||||
The exact v3 product below predates that decision and **still contains its old absent-GID-0 refusal**. It is not the implementation of the now-resolved contract. A separate v4 successor is being prepared; no product bytes are changed in this archival payload and no new public question is asked. The old refusal is preserved as history, not presented as an unanswered owner decision.
|
||||
|
||||
## What the proposed boundary actually checks
|
||||
|
||||
`TextPlan::font_input` can expose a borrowed `FontPlanView` only after successful plan construction. Its font, immutable factory-produced subset, descriptor and codes are private fields with existing read-only getters. The production font input accepts that view plus the subset tag and local output allowance. Raw parts and component emission remain module-private; descendant component tests use explicit private aliases rather than a new proof constructor. This prevents a safe sibling caller from simply pairing arbitrary Font and Subset records at that entry. It does not certify facts the plan never checks.
|
||||
|
||||
The existing exact regular-font policy remains. The proposed path compares the actual bytes and face with the fresh builtin `font_data(None)` result, using the existing catalog path before installed-font/filesystem/cache lookup. The original B1 bytes-plus-face identity, original-GID intrinsic-advance check and single subset→closure→RAW admission chain remain. The font-object helper removes its redundant identity rehash; this is **not** removal of the original identity/admission checks or permission to add another parser/RAW admission. `/W` still uses checked B1 original-GID advances, distinct from placed advances. No font lookup fallback, new asset, new dependency or broadened embedding policy is supplied.
|
||||
|
||||
The view attests only those performed checks. It does not attest arbitrary outlines, historic layout provenance, licensing/distribution rights, all runs in a document, global budgets or successful future PDF emission. The mutable-advance poison fixture edits the Font **before** `Scene.prepare`; structural preparation succeeds and planning must refuse it. No mutation of an already-live immutable Prepared borrow or executed success is claimed.
|
||||
|
||||
## Acceptance and the narrow v3 correction
|
||||
|
||||
There are **46 ordinary written declarations** in this proposed subset: 24 inline helper cases, 13 retained B1 cases, 4 F0 wrapper cases and 5 production-boundary cases. The [four negative compile obligations](acceptance/negative-compile-obligations.rs) are separate intentional invalid snippets, not ordinary module code. Their expected diagnostic classes are not observed compiler results. No positive case has been relabelled as compile-fail.
|
||||
|
||||
The sole v2 source finding was six positive-test lines still reading removed raw input fields. V3 changes exactly eight reads on those six lines to the existing `source.font()`, `source.descriptor()` and `source.codes()` getters. The descriptor getter borrows a successful plan's nonoptional descriptor; this does not remove the missing-descriptor refusal from the raw component boundary. Test names, order, classification, assertions, literal text/numbers, seeds, tolerances, budgets and fixtures are preserved.
|
||||
|
||||
The independent focal closure (`335195903fca30866b1dbef23417b191439bee063e6bf35487858151c40c1fcf`) closes **only that source accessor finding**. Product bytes and product patch alternatives are unchanged from v2. No formatting, compiler, ordinary/negative-test execution, PDF generation/readback or full-feature qualification follows from this closure. The worker's sealed-before-review manifest remains historical; no private report is republished here.
|
||||
|
||||
Earlier Cam/P1/many-page, Refs and other P2 acceptance remain in the predecessor archives and must survive a future assembly. The count 46 is this proposal's components, not the whole project or an executable inventory.
|
||||
|
||||
## Choose one route; do not overlay alternatives
|
||||
|
||||
- `candidate/` contains the final two proposed files.
|
||||
- [Product canonical delta](deltas/product-from-canonical.patch) starts from `before/canonical/`, the owned predecessor's per-file bytes.
|
||||
- [Product alternative delta](deltas/product-from-alternative-partial.patch) starts from the earlier partial text plan in `before/alternative-partial/` and the same canonical font-object baseline. These routes produce the same two candidates; they are alternatives, not sequential patches.
|
||||
- [Retained-case cumulative delta](deltas/acceptance-from-owned-appendix.patch) starts from `before/acceptance/owned-b1-cases.rs`. Its logical filename deliberately identifies an **owned appendix**, not Cam's complete `pdf_tests.rs`.
|
||||
- [Accessor-only delta](deltas/acceptance-accessor-fix-from-v2.patch) starts from `before/acceptance/v2-retained-b1-cases.rs`. It is the alternative route to the same final retained fragment. The wrapper and boundary fragments append unchanged when normally integrating into the area's existing test file. Keep negative obligations separate.
|
||||
|
||||
All paths are portable source artifacts. These mixed component baselines are not one validated Git head, and no parent registration or application is supplied. Preserve earlier test suffixes and contributor authorship when composing the eventual source.
|
||||
|
||||
## Still pending
|
||||
|
||||
- **Implementing the resolved DW contract** remains a separate successor task. This exact v3 still reaches its historical missing-GID-0 refusal; full emitted-CID `/W` coverage without a `/DW` fallback is not implemented or qualified by these files. No default width, unused glyph or new B1 field is invented, and no text-emission success is claimed.
|
||||
- Cam's actual #9647 parent/page/content/resource builder is untouched. Normal modules, all-runs-per-font grouping, aggregate budgets, font/page resources and actual text emission remain open.
|
||||
- Multi-glyph ActualText and real RTL/text extraction remain unsupported/unqualified. Source-only positioning is not reader evidence.
|
||||
- F2 embedding policy/rights, coherent dependency and Cargo.lock integration, current-source compiler/native/wasm/strict/full gate, actual old-versus-new PDF byte comparison and PDF rendering/readback remain unrun.
|
||||
|
||||
Changes a default: no. Original Worker #1/Engineer-team source and cases retain the existing Cam, P2 and font contributors' authorship/notices. No font binary, private machine data, launcher, log, grant or control file is included.
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// NEGATIVE COMPILE OBLIGATIONS, NOT RUN.
|
||||
// Compile each function as a separate sibling module of text_plan/font_objects
|
||||
// under paper::pdf in a coherent future acceptance assembly. Do not include
|
||||
// these intentional errors in the normal test module or infer a pass here.
|
||||
// No trybuild dependency or test-only proof constructor is introduced.
|
||||
|
||||
// Expected: E0451, FontPlanView fields private to text_plan.
|
||||
fn cannot_pair_arbitrary_raw_font_and_subset<'a>(
|
||||
font: &'a solidcraft_geom::paper::text::Font,
|
||||
subset: &'a super::subset_font::SubsetFont,
|
||||
descriptor: &'a super::font_objects::DescriptorMetrics,
|
||||
codes: &'a [super::font_objects::CodeToGlyph<'a>],
|
||||
) -> super::text_plan::FontPlanView<'a> {
|
||||
super::text_plan::FontPlanView { font, subset, descriptor, codes }
|
||||
}
|
||||
|
||||
// Expected: E0560: no raw source_font/subset/descriptor/codes fields on the
|
||||
// production input. A validated view cannot be replaced by serde assertions.
|
||||
fn cannot_restore_the_raw_production_input<'a>(
|
||||
font: &'a solidcraft_geom::paper::text::Font,
|
||||
subset: &'a super::subset_font::SubsetFont,
|
||||
) -> super::font_objects::FontObjectInput<'a> {
|
||||
super::font_objects::FontObjectInput {
|
||||
source_font: font,
|
||||
subset,
|
||||
codes: &[],
|
||||
descriptor: None,
|
||||
postscript_name: b"LiberationSans",
|
||||
subset_tag: *b"ABCDEF",
|
||||
max_chunk_bytes: 1 << 20,
|
||||
}
|
||||
}
|
||||
|
||||
// Expected: Deserialize is not implemented; there is no token rehydration API.
|
||||
fn cannot_deserialize_a_production_view() {
|
||||
let _: super::text_plan::FontPlanView<'_> = serde_json::from_str("{}").unwrap();
|
||||
}
|
||||
|
||||
// Expected: E0603: the raw preparation entry is private to font_objects.
|
||||
fn cannot_call_the_component_emitter_from_a_sibling() {
|
||||
let _ = super::font_objects::font_objects_parts;
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
// Proposed own acceptance appendix. NOT REGISTERED, UNCOMPILED, UNRUN.
|
||||
// Append after the preserved own13 cases only when Engineer selects a coherent
|
||||
// F0 base and resolves the existing Font.bundled policy seam. No Cam test edit.
|
||||
fn f0_plan_scene(
|
||||
runs: Vec<solidcraft_geom::paper::text::TextRun>,
|
||||
fonts: Vec<solidcraft_geom::paper::text::Font>,
|
||||
) -> solidcraft_geom::paper::Scene {
|
||||
use solidcraft_geom::paper::{Group, Item, ObjectKind, Scene};
|
||||
Scene {
|
||||
fonts,
|
||||
..Scene::new(
|
||||
solidcraft_geom::Vec2::new(210.0, 297.0),
|
||||
vec![Group { object: 1, kind: ObjectKind::TitleBlock, items: runs.into_iter().map(Item::Text).collect() }],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_text_plan_resolves_the_real_id_through_prepared_without_moving_glyphs() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
assert_eq!(run.font, font.id);
|
||||
let scene = f0_plan_scene(vec![run.clone()], vec![font]);
|
||||
let prepared = scene.prepare().unwrap();
|
||||
let plan = super::text_plan::plan_prepared_text(&prepared, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].original_gid, 36);
|
||||
assert_eq!(plan.glyphs()[0].intrinsic_advance_fu, 1366);
|
||||
assert_eq!(plan.glyphs()[0].placed_advance_mm, 0.5);
|
||||
assert_eq!(plan.glyphs()[0].matrix_mm, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
|
||||
assert_eq!(plan.runs()[0].text, "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_text_plan_refuses_empty_or_unknown_id_without_a_numeric_font_fallback() {
|
||||
let empty = f0_plan_scene(vec![], vec![]);
|
||||
let prepared = empty.prepare().unwrap();
|
||||
assert!(super::text_plan::plan_prepared_text(&prepared, &[], 1 << 20, 410_820).is_err());
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let scene = f0_plan_scene(vec![run.clone()], vec![font]);
|
||||
let prepared = scene.prepare().unwrap();
|
||||
let mut wrong = run.clone();
|
||||
wrong.font[0] ^= 1;
|
||||
assert!(prepared.font(&wrong).is_none());
|
||||
let error = super::text_plan::plan_prepared_text(&prepared, &[&wrong], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("run font ID is absent from the prepared scene"));
|
||||
assert!(super::text_plan::plan_prepared_text(&prepared, &[&run, &wrong], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_prepared_boundary_refuses_missing_duplicate_and_unsorted_font_tables() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
assert!(f0_plan_scene(vec![run.clone()], vec![]).prepare().is_err());
|
||||
assert!(f0_plan_scene(vec![run.clone()], vec![font.clone(), font.clone()]).prepare().is_err());
|
||||
// Synthetic second identity tests only the Scene ordering refusal. It is not
|
||||
// a claim that another face is admitted for embedding by this P2 adapter.
|
||||
let mut other_font = font.clone();
|
||||
other_font.id[0] ^= 1;
|
||||
let mut other_run = run.clone();
|
||||
other_run.font = other_font.id;
|
||||
let mut fonts = vec![font, other_font];
|
||||
fonts.sort_by_key(|font| font.id);
|
||||
fonts.reverse();
|
||||
assert!(f0_plan_scene(vec![run, other_run], fonts).prepare().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_serialized_scene_cannot_supply_prepared_fonts_without_trusted_bytes() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let scene = f0_plan_scene(vec![run], vec![font]);
|
||||
let value = serde_json::to_value(&scene).unwrap();
|
||||
assert!(value["fonts"][0].get("bytes").is_none());
|
||||
let read: solidcraft_geom::paper::Scene = serde_json::from_value(value).unwrap();
|
||||
assert!(read.fonts[0].bytes.is_empty());
|
||||
assert!(read.prepare().is_err(), "no Prepared value reaches the consumer after untrusted JSON");
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
// Own production-boundary acceptance. UNREGISTERED, UNCOMPILED, UNRUN.
|
||||
// Depends on the retained13 and unchanged four F0 wrapper cases/helpers.
|
||||
|
||||
#[test]
|
||||
fn f0_builtin_identity_rejects_changed_bytes_even_with_a_matching_recomputed_id() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut bytes = font.bytes.to_vec();
|
||||
bytes[4096] ^= 1;
|
||||
font.bytes = std::sync::Arc::from(bytes);
|
||||
font.id = Sha256::new().chain_update(font.bytes.as_ref())
|
||||
.chain_update(font.face.to_le_bytes()).finalize().into();
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.font = font.id;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("builtin drawing font"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_prepared_public_advance_is_rechecked_before_any_production_view_exists() {
|
||||
let mut font = b1_plan_font(&[(36, 1366)]);
|
||||
font.glyphs.get_mut(&36).unwrap().advance = 1367;
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let scene = f0_plan_scene(vec![run.clone()], vec![font]);
|
||||
let prepared = scene.prepare().unwrap();
|
||||
let error = super::text_plan::plan_prepared_text(&prepared, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("intrinsic advance does not match the admitted hmtx"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_structural_preparation_does_not_attest_changed_descriptor_metrics() {
|
||||
let mut font = b1_plan_font(&[(36, 1366)]);
|
||||
font.metrics.ascent = 1855;
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let scene = f0_plan_scene(vec![run.clone()], vec![font]);
|
||||
let prepared = scene.prepare().unwrap();
|
||||
assert!(super::text_plan::plan_prepared_text(&prepared, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_production_view_keeps_the_same_borrowed_source_codes_and_subset() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert!(std::ptr::eq(input.source.font(), &font));
|
||||
assert_eq!(input.source.subset().mapping(), [(0, 0), (36, 1), (57, 2)]);
|
||||
assert_eq!(input.source.codes().iter().map(|row| (row.code, row.subset_gid, row.logical)).collect::<Vec<_>>(),
|
||||
[(1, 1, "A"), (2, 2, "V")]);
|
||||
assert_eq!(input.source.font().glyphs[&36].advance, 1366);
|
||||
assert_eq!(plan.glyphs()[0].placed_advance_mm, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f0_opaque_production_input_does_not_bypass_the_unresolved_default_width_gate() {
|
||||
use pdf_writer::Ref;
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let before = font.clone();
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
let refs = super::font_objects::FontObjectRefs {
|
||||
type0: Ref::new(6), cid_font: Ref::new(7), descriptor: Ref::new(8),
|
||||
font_file2: Ref::new(9), to_unicode: Ref::new(10), cid_to_gid: Ref::new(11),
|
||||
};
|
||||
let error = super::font_objects::font_objects(refs, input).err().unwrap().to_string();
|
||||
assert!(error.contains("PDF default-width policy is unresolved"), "{error}");
|
||||
assert_eq!(font, before);
|
||||
assert!(!font.glyphs.contains_key(&0));
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
// P2 B1 text-plan acceptance: original records, not a replacement layout engine.
|
||||
// Font constants are literal fields of the pinned original Liberation resource;
|
||||
// synthetic logical aliases below test the record contract, not real shaping.
|
||||
fn b1_plan_font(ids: &[(u16, u16)]) -> solidcraft_geom::paper::text::Font {
|
||||
use sha2::{Digest, Sha256};
|
||||
use solidcraft_geom::paper::text::{Font, FontFlags, FontGlyph, FontMetrics};
|
||||
let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(&include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/liberation-sans/LiberationSans-Regular.ttf"))[..]);
|
||||
let id = Sha256::new().chain_update(bytes.as_ref()).chain_update(0u32.to_le_bytes()).finalize().into();
|
||||
Font {
|
||||
id, bytes, face: 0, postscript_name: "LiberationSans".into(), num_glyphs: 2620, units_per_em: 2048,
|
||||
metrics: FontMetrics {
|
||||
ascent: 1854, descent: -434, cap_height: 1409, bbox: [-1114, -621, 2666, 2007],
|
||||
italic_angle: 0.0, flags: FontFlags::default(), stem_v: 180,
|
||||
},
|
||||
glyphs: ids.iter().map(|&(id, advance)| (id, FontGlyph { advance, outline: vec![] })).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn b1_plan_run(text: &str, ids: &[u16]) -> solidcraft_geom::paper::text::TextRun {
|
||||
use solidcraft_geom::paper::text::{Cluster, PlacedGlyph, TextRun};
|
||||
assert_eq!(text.chars().count(), ids.len());
|
||||
let chars: Vec<_> = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect();
|
||||
TextRun {
|
||||
font: b1_plan_font(&[]).id, scale: 1.0 / 2048.0, origin: solidcraft_geom::Vec2::new(0.0, 0.0), angle: 0.0, slant: 0.0, rtl: false,
|
||||
glyphs: ids.iter().enumerate().map(|(i, &id)| PlacedGlyph { id, x: i as f64 * 0.375, rise: 0.0, advance: 0.5 }).collect(),
|
||||
text: text.into(),
|
||||
clusters: ids.iter().enumerate().map(|(i, _)| Cluster { glyphs: i as u32..i as u32 + 1, text: chars[i] as u32..chars[i + 1] as u32 }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_keeps_intrinsic_width_separate_from_placed_advance() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.runs()[0].font_size_mm, 1.0);
|
||||
assert_eq!(plan.runs()[0].text, "AV");
|
||||
assert_eq!(plan.runs()[0].glyphs, 0..2);
|
||||
let g = &plan.glyphs()[1];
|
||||
assert_eq!((g.original_gid, g.intrinsic_advance_fu, g.placed_advance_mm), (57, 1366, 0.5));
|
||||
assert_eq!(g.matrix_mm, [1.0, 0.0, 0.0, 1.0, 0.375, 0.0]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(input.source.font().postscript_name.as_bytes(), b"LiberationSans");
|
||||
assert_eq!(input.source.descriptor().ascent_1000, 905.2734375);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_same_gid_with_distinct_logical_text_has_distinct_codes() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let first = b1_plan_run("A", &[36]);
|
||||
let second = b1_plan_run("B", &[36]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&second, &first, &first], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs().iter().map(|g| (g.code, g.logical)).collect::<Vec<_>>(), [(2, "B"), (1, "A"), (1, "A")]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(input.source.codes().len(), 2);
|
||||
assert_eq!(input.source.codes()[0].subset_gid, input.source.codes()[1].subset_gid);
|
||||
assert_eq!((input.source.codes()[0].logical, input.source.codes()[1].logical), ("A", "B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_preserves_quarter_turn_slant_and_mark_rise_once() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.origin = solidcraft_geom::Vec2::new(100.0, 50.0);
|
||||
run.angle = std::f64::consts::FRAC_PI_2;
|
||||
run.slant = 0.25;
|
||||
run.glyphs[0].x = 3.0;
|
||||
run.glyphs[0].rise = 2.0;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
for (actual, expected) in plan.glyphs()[0].matrix_mm.into_iter().zip([0.0, 1.0, -1.0, 0.25, 98.0, 53.0]) {
|
||||
assert!((actual - expected).abs() < 1e-12, "fixed quarter-turn f64 trig allowance: {actual} != {expected}");
|
||||
}
|
||||
assert_eq!(run.origin, solidcraft_geom::Vec2::new(100.0, 50.0));
|
||||
assert_eq!((run.glyphs[0].x, run.glyphs[0].rise), (3.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_single_glyph_literal_cluster_is_not_split_or_normalized() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.text = "fi\u{301}".into();
|
||||
run.clusters[0].text = 0..4;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].logical, "fi\u{301}");
|
||||
assert_eq!(plan.font_input(*b"ABCDEF", 1 << 20).source.codes()[0].logical, "fi\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_rtl_keeps_positions_but_uses_logical_cluster_order() {
|
||||
let font = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
let mut run = b1_plan_run("AB", &[37, 36]);
|
||||
run.rtl = true;
|
||||
run.clusters[0].text = 1..2;
|
||||
run.clusters[1].text = 0..1;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert!(plan.runs()[0].rtl);
|
||||
assert_eq!(plan.glyphs().iter().map(|g| g.logical).collect::<String>(), "AB");
|
||||
assert_eq!(plan.glyphs().iter().map(|g| g.source_glyph_index).collect::<Vec<_>>(), [1, 0]);
|
||||
assert_eq!((plan.glyphs()[0].matrix_mm[4], plan.glyphs()[1].matrix_mm[4]), (0.375, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_multi_glyph_cluster_refuses_instead_of_duplicating_text() {
|
||||
let font = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
let mut run = b1_plan_run("AB", &[36, 37]);
|
||||
run.text = "A".into();
|
||||
run.clusters.truncate(1);
|
||||
run.clusters[0].glyphs = 0..2;
|
||||
run.clusters[0].text = 0..1;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("multi-glyph cluster extraction is not supported"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_tounicode_destination_limit_is_inclusive_and_never_split() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.text = "A".repeat(256);
|
||||
run.clusters[0].text = 0..256;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].logical.len(), 256);
|
||||
drop(plan);
|
||||
run.text.push('A');
|
||||
run.clusters[0].text.end += 1;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("512-byte destination"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_space_keeps_advance_and_text_despite_empty_outline() {
|
||||
let font = b1_plan_font(&[(3, 569)]);
|
||||
let run = b1_plan_run(" ", &[3]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!((plan.glyphs()[0].original_gid, plan.glyphs()[0].intrinsic_advance_fu, plan.glyphs()[0].logical), (3, 569, " "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_refuses_wrong_identity_descriptor_face_and_intrinsic_width() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for kind in 0..5 {
|
||||
let mut wrong = font.clone();
|
||||
match kind {
|
||||
0 => wrong.id[0] ^= 1,
|
||||
1 => wrong.face = 1,
|
||||
2 => wrong.metrics.stem_v = 181,
|
||||
3 => wrong.metrics.flags.italic = true,
|
||||
_ => wrong.glyphs.get_mut(&36).unwrap().advance = 1024,
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&wrong, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_refuses_invalid_partitions_unmatched_font_and_nonfinite_positions() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for kind in 0..6 {
|
||||
let mut wrong = run.clone();
|
||||
match kind {
|
||||
0 => wrong.clusters[0].glyphs.end = 2,
|
||||
1 => wrong.clusters[0].text.end = 2,
|
||||
2 => wrong.clusters.clear(),
|
||||
3 => wrong.font[0] ^= 1,
|
||||
4 => wrong.glyphs[0].x = f64::NAN,
|
||||
_ => { wrong.text = "\u{e9}".into(); wrong.clusters[0].text = 0..1; }
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&font, &[&wrong], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_bounds_storage_subsets_run_count_and_exact_used_font_table() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for (plan_bytes, subset_bytes) in [(0, 410_820), (1, 410_820), (1 << 20, 0), (1 << 20, 1)] {
|
||||
assert!(super::text_plan::plan_text(&font, &[&run], plan_bytes, subset_bytes).is_err());
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&font, &vec![&run; 1025], 1 << 20, 410_820).is_err());
|
||||
let extra = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
assert!(super::text_plan::plan_text(&extra, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_repeats_real_subset_mapping_and_resource_inputs() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let first = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let second = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let a = first.font_input(*b"ABCDEF", 1 << 20);
|
||||
let b = second.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(a.source.subset().bytes(), b.source.subset().bytes());
|
||||
assert_eq!(a.source.subset().mapping(), [(0, 0), (36, 1), (57, 2)]);
|
||||
assert_eq!(a.source.subset().mapping(), b.source.subset().mapping());
|
||||
assert_eq!(a.source.codes().iter().map(|g| (g.code, g.subset_gid, g.logical)).collect::<Vec<_>>(), [(1, 1, "A"), (2, 2, "V")]);
|
||||
assert_eq!(a.source.descriptor().flags, pdf_writer::types::FontFlags::NON_SYMBOLIC.bits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_font_byte_envelope_refuses_before_identity_or_table_work() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for len in [0, 410_819, 410_821] {
|
||||
let mut wrong = font.clone();
|
||||
// Identity is deliberately stale too: byte-envelope refusal has priority.
|
||||
wrong.bytes = std::sync::Arc::from(vec![0; len]);
|
||||
let error = super::text_plan::plan_text(&wrong, &[&run], 1, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("font byte length is outside the reviewed resource envelope"), "{error}");
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
// P2 B1 text-plan acceptance: original records, not a replacement layout engine.
|
||||
// Font constants are literal fields of the pinned original Liberation resource;
|
||||
// synthetic logical aliases below test the record contract, not real shaping.
|
||||
fn b1_plan_font(ids: &[(u16, u16)]) -> solidcraft_geom::paper::text::Font {
|
||||
use sha2::{Digest, Sha256};
|
||||
use solidcraft_geom::paper::text::{Font, FontFlags, FontGlyph, FontMetrics};
|
||||
let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(&include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/liberation-sans/LiberationSans-Regular.ttf"))[..]);
|
||||
let id = Sha256::new().chain_update(bytes.as_ref()).chain_update(0u32.to_le_bytes()).finalize().into();
|
||||
Font {
|
||||
bundled: 0, id, bytes, face: 0, postscript_name: "LiberationSans".into(), num_glyphs: 2620, units_per_em: 2048,
|
||||
metrics: FontMetrics {
|
||||
ascent: 1854, descent: -434, cap_height: 1409, bbox: [-1114, -621, 2666, 2007],
|
||||
italic_angle: 0.0, flags: FontFlags::default(), stem_v: 180,
|
||||
},
|
||||
glyphs: ids.iter().map(|&(id, advance)| (id, FontGlyph { advance, outline: vec![] })).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn b1_plan_run(text: &str, ids: &[u16]) -> solidcraft_geom::paper::text::TextRun {
|
||||
use solidcraft_geom::paper::text::{Cluster, PlacedGlyph, TextRun};
|
||||
assert_eq!(text.chars().count(), ids.len());
|
||||
let chars: Vec<_> = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect();
|
||||
TextRun {
|
||||
font: 0, scale: 1.0 / 2048.0, origin: solidcraft_geom::Vec2::new(0.0, 0.0), angle: 0.0, slant: 0.0, rtl: false,
|
||||
glyphs: ids.iter().enumerate().map(|(i, &id)| PlacedGlyph { id, x: i as f64 * 0.375, rise: 0.0, advance: 0.5 }).collect(),
|
||||
text: text.into(),
|
||||
clusters: ids.iter().enumerate().map(|(i, _)| Cluster { glyphs: i as u32..i as u32 + 1, text: chars[i] as u32..chars[i + 1] as u32 }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_keeps_intrinsic_width_separate_from_placed_advance() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.runs()[0].font_size_mm, 1.0);
|
||||
assert_eq!(plan.runs()[0].text, "AV");
|
||||
assert_eq!(plan.runs()[0].glyphs, 0..2);
|
||||
let g = &plan.glyphs()[1];
|
||||
assert_eq!((g.original_gid, g.intrinsic_advance_fu, g.placed_advance_mm), (57, 1366, 0.5));
|
||||
assert_eq!(g.matrix_mm, [1.0, 0.0, 0.0, 1.0, 0.375, 0.0]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(input.postscript_name, b"LiberationSans");
|
||||
assert_eq!(input.descriptor.unwrap().ascent_1000, 905.2734375);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_same_gid_with_distinct_logical_text_has_distinct_codes() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let first = b1_plan_run("A", &[36]);
|
||||
let second = b1_plan_run("B", &[36]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&second, &first, &first], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs().iter().map(|g| (g.code, g.logical)).collect::<Vec<_>>(), [(2, "B"), (1, "A"), (1, "A")]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(input.codes.len(), 2);
|
||||
assert_eq!(input.codes[0].subset_gid, input.codes[1].subset_gid);
|
||||
assert_eq!((input.codes[0].logical, input.codes[1].logical), ("A", "B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_preserves_quarter_turn_slant_and_mark_rise_once() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.origin = solidcraft_geom::Vec2::new(100.0, 50.0);
|
||||
run.angle = std::f64::consts::FRAC_PI_2;
|
||||
run.slant = 0.25;
|
||||
run.glyphs[0].x = 3.0;
|
||||
run.glyphs[0].rise = 2.0;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
for (actual, expected) in plan.glyphs()[0].matrix_mm.into_iter().zip([0.0, 1.0, -1.0, 0.25, 98.0, 53.0]) {
|
||||
assert!((actual - expected).abs() < 1e-12, "fixed quarter-turn f64 trig allowance: {actual} != {expected}");
|
||||
}
|
||||
assert_eq!(run.origin, solidcraft_geom::Vec2::new(100.0, 50.0));
|
||||
assert_eq!((run.glyphs[0].x, run.glyphs[0].rise), (3.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_single_glyph_literal_cluster_is_not_split_or_normalized() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.text = "fi\u{301}".into();
|
||||
run.clusters[0].text = 0..4;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].logical, "fi\u{301}");
|
||||
assert_eq!(plan.font_input(*b"ABCDEF", 1 << 20).codes[0].logical, "fi\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_rtl_keeps_positions_but_uses_logical_cluster_order() {
|
||||
let font = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
let mut run = b1_plan_run("AB", &[37, 36]);
|
||||
run.rtl = true;
|
||||
run.clusters[0].text = 1..2;
|
||||
run.clusters[1].text = 0..1;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert!(plan.runs()[0].rtl);
|
||||
assert_eq!(plan.glyphs().iter().map(|g| g.logical).collect::<String>(), "AB");
|
||||
assert_eq!(plan.glyphs().iter().map(|g| g.source_glyph_index).collect::<Vec<_>>(), [1, 0]);
|
||||
assert_eq!((plan.glyphs()[0].matrix_mm[4], plan.glyphs()[1].matrix_mm[4]), (0.375, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_multi_glyph_cluster_refuses_instead_of_duplicating_text() {
|
||||
let font = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
let mut run = b1_plan_run("AB", &[36, 37]);
|
||||
run.text = "A".into();
|
||||
run.clusters.truncate(1);
|
||||
run.clusters[0].glyphs = 0..2;
|
||||
run.clusters[0].text = 0..1;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("multi-glyph cluster extraction is not supported"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_tounicode_destination_limit_is_inclusive_and_never_split() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.text = "A".repeat(256);
|
||||
run.clusters[0].text = 0..256;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].logical.len(), 256);
|
||||
drop(plan);
|
||||
run.text.push('A');
|
||||
run.clusters[0].text.end += 1;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("512-byte destination"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_space_keeps_advance_and_text_despite_empty_outline() {
|
||||
let font = b1_plan_font(&[(3, 569)]);
|
||||
let run = b1_plan_run(" ", &[3]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!((plan.glyphs()[0].original_gid, plan.glyphs()[0].intrinsic_advance_fu, plan.glyphs()[0].logical), (3, 569, " "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_refuses_wrong_identity_descriptor_face_and_intrinsic_width() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for kind in 0..5 {
|
||||
let mut wrong = font.clone();
|
||||
match kind {
|
||||
0 => wrong.id[0] ^= 1,
|
||||
1 => wrong.face = 1,
|
||||
2 => wrong.metrics.stem_v = 181,
|
||||
3 => wrong.metrics.flags.italic = true,
|
||||
_ => wrong.glyphs.get_mut(&36).unwrap().advance = 1024,
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&wrong, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_refuses_invalid_partitions_unmatched_font_and_nonfinite_positions() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for kind in 0..6 {
|
||||
let mut wrong = run.clone();
|
||||
match kind {
|
||||
0 => wrong.clusters[0].glyphs.end = 2,
|
||||
1 => wrong.clusters[0].text.end = 2,
|
||||
2 => wrong.clusters.clear(),
|
||||
3 => wrong.font = 1,
|
||||
4 => wrong.glyphs[0].x = f64::NAN,
|
||||
_ => { wrong.text = "\u{e9}".into(); wrong.clusters[0].text = 0..1; }
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&font, &[&wrong], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_bounds_storage_subsets_run_count_and_exact_used_font_table() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for (plan_bytes, subset_bytes) in [(0, 410_820), (1, 410_820), (1 << 20, 0), (1 << 20, 1)] {
|
||||
assert!(super::text_plan::plan_text(&font, &[&run], plan_bytes, subset_bytes).is_err());
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&font, &vec![&run; 1025], 1 << 20, 410_820).is_err());
|
||||
let extra = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
assert!(super::text_plan::plan_text(&extra, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_repeats_real_subset_mapping_and_resource_inputs() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let first = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let second = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let a = first.font_input(*b"ABCDEF", 1 << 20);
|
||||
let b = second.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(a.subset.bytes(), b.subset.bytes());
|
||||
assert_eq!(a.subset.mapping(), [(0, 0), (36, 1), (57, 2)]);
|
||||
assert_eq!(a.subset.mapping(), b.subset.mapping());
|
||||
assert_eq!(a.codes.iter().map(|g| (g.code, g.subset_gid, g.logical)).collect::<Vec<_>>(), [(1, 1, "A"), (2, 2, "V")]);
|
||||
assert_eq!(a.descriptor.unwrap().flags, pdf_writer::types::FontFlags::NON_SYMBOLIC.bits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_font_byte_envelope_refuses_before_identity_or_table_work() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for len in [0, 410_819, 410_821] {
|
||||
let mut wrong = font.clone();
|
||||
// Identity is deliberately stale too: byte-envelope refusal has priority.
|
||||
wrong.bytes = std::sync::Arc::from(vec![0; len]);
|
||||
let error = super::text_plan::plan_text(&wrong, &[&run], 1, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("font byte length is outside the reviewed resource envelope"), "{error}");
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
// P2 B1 text-plan acceptance: original records, not a replacement layout engine.
|
||||
// Font constants are literal fields of the pinned original Liberation resource;
|
||||
// synthetic logical aliases below test the record contract, not real shaping.
|
||||
fn b1_plan_font(ids: &[(u16, u16)]) -> solidcraft_geom::paper::text::Font {
|
||||
use sha2::{Digest, Sha256};
|
||||
use solidcraft_geom::paper::text::{Font, FontFlags, FontGlyph, FontMetrics};
|
||||
let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(&include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/liberation-sans/LiberationSans-Regular.ttf"))[..]);
|
||||
let id = Sha256::new().chain_update(bytes.as_ref()).chain_update(0u32.to_le_bytes()).finalize().into();
|
||||
Font {
|
||||
id, bytes, face: 0, postscript_name: "LiberationSans".into(), num_glyphs: 2620, units_per_em: 2048,
|
||||
metrics: FontMetrics {
|
||||
ascent: 1854, descent: -434, cap_height: 1409, bbox: [-1114, -621, 2666, 2007],
|
||||
italic_angle: 0.0, flags: FontFlags::default(), stem_v: 180,
|
||||
},
|
||||
glyphs: ids.iter().map(|&(id, advance)| (id, FontGlyph { advance, outline: vec![] })).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn b1_plan_run(text: &str, ids: &[u16]) -> solidcraft_geom::paper::text::TextRun {
|
||||
use solidcraft_geom::paper::text::{Cluster, PlacedGlyph, TextRun};
|
||||
assert_eq!(text.chars().count(), ids.len());
|
||||
let chars: Vec<_> = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect();
|
||||
TextRun {
|
||||
font: b1_plan_font(&[]).id, scale: 1.0 / 2048.0, origin: solidcraft_geom::Vec2::new(0.0, 0.0), angle: 0.0, slant: 0.0, rtl: false,
|
||||
glyphs: ids.iter().enumerate().map(|(i, &id)| PlacedGlyph { id, x: i as f64 * 0.375, rise: 0.0, advance: 0.5 }).collect(),
|
||||
text: text.into(),
|
||||
clusters: ids.iter().enumerate().map(|(i, _)| Cluster { glyphs: i as u32..i as u32 + 1, text: chars[i] as u32..chars[i + 1] as u32 }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_keeps_intrinsic_width_separate_from_placed_advance() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.runs()[0].font_size_mm, 1.0);
|
||||
assert_eq!(plan.runs()[0].text, "AV");
|
||||
assert_eq!(plan.runs()[0].glyphs, 0..2);
|
||||
let g = &plan.glyphs()[1];
|
||||
assert_eq!((g.original_gid, g.intrinsic_advance_fu, g.placed_advance_mm), (57, 1366, 0.5));
|
||||
assert_eq!(g.matrix_mm, [1.0, 0.0, 0.0, 1.0, 0.375, 0.0]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(input.postscript_name, b"LiberationSans");
|
||||
assert_eq!(input.descriptor.unwrap().ascent_1000, 905.2734375);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_same_gid_with_distinct_logical_text_has_distinct_codes() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let first = b1_plan_run("A", &[36]);
|
||||
let second = b1_plan_run("B", &[36]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&second, &first, &first], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs().iter().map(|g| (g.code, g.logical)).collect::<Vec<_>>(), [(2, "B"), (1, "A"), (1, "A")]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(input.codes.len(), 2);
|
||||
assert_eq!(input.codes[0].subset_gid, input.codes[1].subset_gid);
|
||||
assert_eq!((input.codes[0].logical, input.codes[1].logical), ("A", "B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_preserves_quarter_turn_slant_and_mark_rise_once() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.origin = solidcraft_geom::Vec2::new(100.0, 50.0);
|
||||
run.angle = std::f64::consts::FRAC_PI_2;
|
||||
run.slant = 0.25;
|
||||
run.glyphs[0].x = 3.0;
|
||||
run.glyphs[0].rise = 2.0;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
for (actual, expected) in plan.glyphs()[0].matrix_mm.into_iter().zip([0.0, 1.0, -1.0, 0.25, 98.0, 53.0]) {
|
||||
assert!((actual - expected).abs() < 1e-12, "fixed quarter-turn f64 trig allowance: {actual} != {expected}");
|
||||
}
|
||||
assert_eq!(run.origin, solidcraft_geom::Vec2::new(100.0, 50.0));
|
||||
assert_eq!((run.glyphs[0].x, run.glyphs[0].rise), (3.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_single_glyph_literal_cluster_is_not_split_or_normalized() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.text = "fi\u{301}".into();
|
||||
run.clusters[0].text = 0..4;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].logical, "fi\u{301}");
|
||||
assert_eq!(plan.font_input(*b"ABCDEF", 1 << 20).codes[0].logical, "fi\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_rtl_keeps_positions_but_uses_logical_cluster_order() {
|
||||
let font = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
let mut run = b1_plan_run("AB", &[37, 36]);
|
||||
run.rtl = true;
|
||||
run.clusters[0].text = 1..2;
|
||||
run.clusters[1].text = 0..1;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert!(plan.runs()[0].rtl);
|
||||
assert_eq!(plan.glyphs().iter().map(|g| g.logical).collect::<String>(), "AB");
|
||||
assert_eq!(plan.glyphs().iter().map(|g| g.source_glyph_index).collect::<Vec<_>>(), [1, 0]);
|
||||
assert_eq!((plan.glyphs()[0].matrix_mm[4], plan.glyphs()[1].matrix_mm[4]), (0.375, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_multi_glyph_cluster_refuses_instead_of_duplicating_text() {
|
||||
let font = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
let mut run = b1_plan_run("AB", &[36, 37]);
|
||||
run.text = "A".into();
|
||||
run.clusters.truncate(1);
|
||||
run.clusters[0].glyphs = 0..2;
|
||||
run.clusters[0].text = 0..1;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("multi-glyph cluster extraction is not supported"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_tounicode_destination_limit_is_inclusive_and_never_split() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.text = "A".repeat(256);
|
||||
run.clusters[0].text = 0..256;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].logical.len(), 256);
|
||||
drop(plan);
|
||||
run.text.push('A');
|
||||
run.clusters[0].text.end += 1;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("512-byte destination"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_space_keeps_advance_and_text_despite_empty_outline() {
|
||||
let font = b1_plan_font(&[(3, 569)]);
|
||||
let run = b1_plan_run(" ", &[3]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!((plan.glyphs()[0].original_gid, plan.glyphs()[0].intrinsic_advance_fu, plan.glyphs()[0].logical), (3, 569, " "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_refuses_wrong_identity_descriptor_face_and_intrinsic_width() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for kind in 0..5 {
|
||||
let mut wrong = font.clone();
|
||||
match kind {
|
||||
0 => wrong.id[0] ^= 1,
|
||||
1 => wrong.face = 1,
|
||||
2 => wrong.metrics.stem_v = 181,
|
||||
3 => wrong.metrics.flags.italic = true,
|
||||
_ => wrong.glyphs.get_mut(&36).unwrap().advance = 1024,
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&wrong, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_refuses_invalid_partitions_unmatched_font_and_nonfinite_positions() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for kind in 0..6 {
|
||||
let mut wrong = run.clone();
|
||||
match kind {
|
||||
0 => wrong.clusters[0].glyphs.end = 2,
|
||||
1 => wrong.clusters[0].text.end = 2,
|
||||
2 => wrong.clusters.clear(),
|
||||
3 => wrong.font[0] ^= 1,
|
||||
4 => wrong.glyphs[0].x = f64::NAN,
|
||||
_ => { wrong.text = "\u{e9}".into(); wrong.clusters[0].text = 0..1; }
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&font, &[&wrong], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_bounds_storage_subsets_run_count_and_exact_used_font_table() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for (plan_bytes, subset_bytes) in [(0, 410_820), (1, 410_820), (1 << 20, 0), (1 << 20, 1)] {
|
||||
assert!(super::text_plan::plan_text(&font, &[&run], plan_bytes, subset_bytes).is_err());
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&font, &vec![&run; 1025], 1 << 20, 410_820).is_err());
|
||||
let extra = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
assert!(super::text_plan::plan_text(&extra, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_repeats_real_subset_mapping_and_resource_inputs() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let first = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let second = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let a = first.font_input(*b"ABCDEF", 1 << 20);
|
||||
let b = second.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(a.source.subset().bytes(), b.source.subset().bytes());
|
||||
assert_eq!(a.source.subset().mapping(), [(0, 0), (36, 1), (57, 2)]);
|
||||
assert_eq!(a.source.subset().mapping(), b.source.subset().mapping());
|
||||
assert_eq!(a.source.codes().iter().map(|g| (g.code, g.subset_gid, g.logical)).collect::<Vec<_>>(), [(1, 1, "A"), (2, 2, "V")]);
|
||||
assert_eq!(a.source.descriptor().flags, pdf_writer::types::FontFlags::NON_SYMBOLIC.bits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_font_byte_envelope_refuses_before_identity_or_table_work() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for len in [0, 410_819, 410_821] {
|
||||
let mut wrong = font.clone();
|
||||
// Identity is deliberately stale too: byte-envelope refusal has priority.
|
||||
wrong.bytes = std::sync::Arc::from(vec![0; len]);
|
||||
let error = super::text_plan::plan_text(&wrong, &[&run], 1, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("font byte length is outside the reviewed resource envelope"), "{error}");
|
||||
}
|
||||
}
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
//! Bounded PDF planning from the actual B1a Font/TextRun records (#4890 P2).
|
||||
//!
|
||||
//! This neither shapes text nor writes a page. It keeps intrinsic hmtx widths
|
||||
//! separate from the producer's placed glyph x/rise/advance. One glyph per logical
|
||||
//! cluster is supported, including a glyph carrying several characters; a
|
||||
//! multi-glyph cluster is refused until its extraction/content policy is supplied.
|
||||
//! The ToUnicode destination limit stays 512 UTF-16BE bytes, not a silent split.
|
||||
//!
|
||||
//! Plans retain no new font/document Arc: strings/font records are borrowed.
|
||||
//! Local vector storage and returned subset bytes have separate allowances. The
|
||||
//! existing subsetter/pdf-writer internal allocation limitations still apply.
|
||||
//! No parent registration, global PDF Ref allocator, page resource or text arm is
|
||||
//! introduced here. The parent must preserve the planned placements and reading
|
||||
//! order and qualify real PDF rendering/extraction; these records prove neither.
|
||||
|
||||
use std::mem::size_of;
|
||||
use std::ops::Range;
|
||||
|
||||
use pdf_writer::types::FontFlags;
|
||||
use sha2::{Digest, Sha256};
|
||||
use solidcraft_geom::paper::text::{Font, TextRun};
|
||||
use solidcraft_geom::paper::MAX_COORDINATE_MM;
|
||||
|
||||
use super::font_objects::{CodeToGlyph, DescriptorMetrics, FontObjectInput};
|
||||
use super::subset_font::{SubsetFont, subset_font};
|
||||
use crate::{IoError, Result};
|
||||
|
||||
const MAX_RUNS: usize = 1024;
|
||||
const MAX_GLYPHS: usize = 16_384;
|
||||
const MAX_PLAN_BYTES: usize = 16 << 20;
|
||||
const MAX_LOGICAL_BYTES: usize = 4 << 20;
|
||||
const MAX_CLUSTER_BYTES: usize = 768;
|
||||
const MAX_CLUSTER_UNITS: usize = 256;
|
||||
const APPROVED_FONT_BYTES: usize = 410_820;
|
||||
|
||||
/// One actual glyph, with a code for its original GID AND literal logical text.
|
||||
/// The matrix has unit basis vectors and a translation in sheet millimetres;
|
||||
/// font_size_mm is on its run. Parent applies its mm-to-point scale only once.
|
||||
pub(super) struct PlannedGlyph<'a> {
|
||||
pub(super) code: u16,
|
||||
pub(super) original_gid: u16,
|
||||
pub(super) source_glyph_index: usize,
|
||||
pub(super) intrinsic_advance_fu: u16,
|
||||
pub(super) placed_advance_mm: f64,
|
||||
pub(super) matrix_mm: [f64; 6],
|
||||
pub(super) logical: &'a str,
|
||||
}
|
||||
|
||||
/// Glyphs occupy one contiguous range in the plan, in logical reading order.
|
||||
/// For an RTL run their SOURCE indices descend; positions are never recomputed
|
||||
/// from font widths. Input run order remains unchanged.
|
||||
pub(super) struct PlannedRun<'a> {
|
||||
pub(super) glyphs: Range<usize>,
|
||||
pub(super) font_size_mm: f64,
|
||||
pub(super) rtl: bool,
|
||||
pub(super) text: &'a str,
|
||||
}
|
||||
|
||||
pub(super) struct TextPlan<'a> {
|
||||
font: &'a Font,
|
||||
subset: SubsetFont,
|
||||
descriptor: DescriptorMetrics,
|
||||
codes: Vec<CodeToGlyph<'a>>,
|
||||
runs: Vec<PlannedRun<'a>>,
|
||||
glyphs: Vec<PlannedGlyph<'a>>,
|
||||
}
|
||||
|
||||
impl TextPlan<'_> {
|
||||
pub(super) fn runs(&self) -> &[PlannedRun<'_>] {
|
||||
&self.runs
|
||||
}
|
||||
|
||||
pub(super) fn glyphs(&self) -> &[PlannedGlyph<'_>] {
|
||||
&self.glyphs
|
||||
}
|
||||
|
||||
/// Caller still supplies six globally reserved refs to font_objects, a
|
||||
/// globally unique subset tag, page resources and a cumulative output budget.
|
||||
pub(super) fn font_input(&self, subset_tag: [u8; 6], max_chunk_bytes: usize) -> FontObjectInput<'_> {
|
||||
FontObjectInput {
|
||||
source_font: self.font,
|
||||
subset: &self.subset,
|
||||
codes: &self.codes,
|
||||
descriptor: Some(&self.descriptor),
|
||||
postscript_name: self.font.postscript_name.as_bytes(),
|
||||
subset_tag,
|
||||
max_chunk_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(message: &'static str) -> IoError {
|
||||
IoError::Invalid(format!("PDF text plan: {message}"))
|
||||
}
|
||||
|
||||
fn reserve<T>(count: usize) -> Result<Vec<T>> {
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(count).map_err(|_| invalid("cannot allocate bounded plan storage"))?;
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn bytes<T>(count: usize) -> Result<usize> {
|
||||
count.checked_mul(size_of::<T>()).ok_or_else(|| invalid("plan storage overflow"))
|
||||
}
|
||||
|
||||
fn total(parts: &[usize], limit: usize) -> Result<usize> {
|
||||
parts.iter().try_fold(0usize, |n, part| {
|
||||
n.checked_add(*part).filter(|n| *n <= limit).ok_or_else(|| invalid("plan storage allowance exceeded"))
|
||||
})
|
||||
}
|
||||
|
||||
fn u16_at(bytes: &[u8], at: usize) -> Result<u16> {
|
||||
let end = at.checked_add(2).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
let value = bytes.get(at..end).and_then(|b| b.try_into().ok()).ok_or_else(|| invalid("truncated admitted font table"))?;
|
||||
Ok(u16::from_be_bytes(value))
|
||||
}
|
||||
|
||||
fn u32_at(bytes: &[u8], at: usize) -> Result<u32> {
|
||||
let end = at.checked_add(4).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
let value = bytes.get(at..end).and_then(|b| b.try_into().ok()).ok_or_else(|| invalid("truncated admitted font table"))?;
|
||||
Ok(u32::from_be_bytes(value))
|
||||
}
|
||||
|
||||
/// A bounded original hmtx view, not a general font parser. The subset adapter
|
||||
/// performs the existing exact-resource/embedding admission before plan return.
|
||||
fn table<'a>(bytes: &'a [u8], tag: &[u8; 4]) -> Result<&'a [u8]> {
|
||||
let count = usize::from(u16_at(bytes, 4)?);
|
||||
if count == 0 || count > 19 {
|
||||
return Err(invalid("font directory exceeds the reviewed resource"));
|
||||
}
|
||||
let mut found = None;
|
||||
for i in 0..count {
|
||||
let at = 12 + 16 * i;
|
||||
if bytes.get(at..at + 4) != Some(tag) {
|
||||
continue;
|
||||
}
|
||||
if found.is_some() {
|
||||
return Err(invalid("duplicate font table"));
|
||||
}
|
||||
let start = usize::try_from(u32_at(bytes, at + 8)?).map_err(|_| invalid("font offset exceeds the platform"))?;
|
||||
let length = usize::try_from(u32_at(bytes, at + 12)?).map_err(|_| invalid("font length exceeds the platform"))?;
|
||||
let end = start.checked_add(length).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
found = bytes.get(start..end);
|
||||
if found.is_none() {
|
||||
return Err(invalid("font table is outside its bytes"));
|
||||
}
|
||||
}
|
||||
found.ok_or_else(|| invalid("required font table is absent"))
|
||||
}
|
||||
|
||||
fn intrinsic_advance(font: &Font, gid: u16) -> Result<u16> {
|
||||
if gid >= font.num_glyphs {
|
||||
return Err(invalid("glyph is outside the admitted font"));
|
||||
}
|
||||
let count = usize::from(u16_at(table(&font.bytes, b"hhea")?, 34)?);
|
||||
if count == 0 || count > usize::from(font.num_glyphs) {
|
||||
return Err(invalid("invalid hmtx metric count"));
|
||||
}
|
||||
u16_at(table(&font.bytes, b"hmtx")?, usize::from(gid).min(count - 1) * 4)
|
||||
}
|
||||
|
||||
fn descriptor(font: &Font) -> Result<DescriptorMetrics> {
|
||||
// Public Font is hostile input too. Refuse its cheap byte envelope before
|
||||
// hashing or looking through tables, even when the caller's plan budget is tiny.
|
||||
if font.bytes.len() != APPROVED_FONT_BYTES {
|
||||
return Err(invalid("font byte length is outside the reviewed resource envelope"));
|
||||
}
|
||||
// B1 hashes bytes + faceLE. The existing subset admission hashes RAW bytes;
|
||||
// their digests are different identities and are never compared to each other.
|
||||
let m = &font.metrics;
|
||||
// Original fixed-resource metadata, not fitted/default descriptor values.
|
||||
// Values come from this exact Liberation Sans head/hhea/OS2/post and l glyph.
|
||||
if font.bundled != 0 || font.face != 0 || font.postscript_name != "LiberationSans"
|
||||
|| font.num_glyphs != 2620 || font.units_per_em != 2048
|
||||
|| m.bbox != [-1114, -621, 2666, 2007] || m.ascent != 1854 || m.descent != -434
|
||||
|| m.cap_height != 1409 || m.stem_v != 180 || m.italic_angle != 0.0
|
||||
|| m.flags.fixed_pitch || m.flags.serif || m.flags.italic || m.flags.symbolic
|
||||
{
|
||||
return Err(invalid("B1 font identity or metrics differ from the reviewed regular bundled face"));
|
||||
}
|
||||
let identity: [u8; 32] = Sha256::new().chain_update(font.bytes.as_ref()).chain_update(font.face.to_le_bytes()).finalize().into();
|
||||
if font.id != identity {
|
||||
return Err(invalid("B1 bytes-and-face identity does not match its supplied bytes"));
|
||||
}
|
||||
let k = 1000.0 / f32::from(font.units_per_em);
|
||||
Ok(DescriptorMetrics {
|
||||
bbox_1000: m.bbox.map(|n| f32::from(n) * k),
|
||||
ascent_1000: f32::from(m.ascent) * k,
|
||||
descent_1000: f32::from(m.descent) * k,
|
||||
cap_height_1000: f32::from(m.cap_height) * k,
|
||||
italic_angle_degrees: m.italic_angle as f32,
|
||||
stem_v_1000: f32::from(m.stem_v) * k,
|
||||
flags: FontFlags::NON_SYMBOLIC.bits(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check the actual B1 cluster partition and the narrower first P2 contract
|
||||
/// BEFORE allocating or subsetting. Returning an error exposes no partial plan.
|
||||
fn check_run(font: &Font, run: &TextRun) -> Result<usize> {
|
||||
if run.font != font.id || !(1..=4000).contains(&run.glyphs.len()) || run.text.len() > 4000 {
|
||||
return Err(invalid("run font, glyph count or text length is outside B1"));
|
||||
}
|
||||
let cap = run.scale * f64::from(font.metrics.cap_height);
|
||||
let within = |x: f64| x.is_finite() && x.abs() <= MAX_COORDINATE_MM;
|
||||
if !run.scale.is_finite() || !(1e-6..1e6).contains(&cap) || cap == 1e-6
|
||||
|| !within(run.origin.x) || !within(run.origin.y)
|
||||
|| !run.angle.is_finite() || run.angle.abs() > std::f64::consts::TAU
|
||||
|| !run.slant.is_finite() || run.slant.abs() > 1.0
|
||||
{
|
||||
return Err(invalid("run transform is outside B1"));
|
||||
}
|
||||
let mut glyph_end = 0usize;
|
||||
let mut text_end = if run.rtl { run.text.len() } else { 0 };
|
||||
for cluster in &run.clusters {
|
||||
let (g0, g1, t0, t1) = (
|
||||
cluster.glyphs.start as usize, cluster.glyphs.end as usize,
|
||||
cluster.text.start as usize, cluster.text.end as usize,
|
||||
);
|
||||
if g0 != glyph_end || g0 >= g1 || t0 >= t1 || (if run.rtl { t1 != text_end } else { t0 != text_end }) {
|
||||
return Err(invalid("clusters do not partition glyphs and logical text in B1 order"));
|
||||
}
|
||||
if g1 - g0 != 1 {
|
||||
return Err(invalid("multi-glyph cluster extraction is not supported by this text-plan increment"));
|
||||
}
|
||||
let logical = run.text.get(t0..t1).ok_or_else(|| invalid("cluster text is not whole UTF-8 characters"))?;
|
||||
if logical.len() > MAX_CLUSTER_BYTES || logical.encode_utf16().take(MAX_CLUSTER_UNITS + 1).count() > MAX_CLUSTER_UNITS {
|
||||
return Err(invalid("cluster exceeds the existing ToUnicode 512-byte destination contract"));
|
||||
}
|
||||
let glyph = run.glyphs.get(g0).ok_or_else(|| invalid("cluster glyph is outside the run"))?;
|
||||
if ![glyph.x, glyph.rise, glyph.advance].into_iter().all(within) {
|
||||
return Err(invalid("placed glyph is outside B1 coordinates"));
|
||||
}
|
||||
let intrinsic = font.glyphs.get(&glyph.id).ok_or_else(|| invalid("glyph is absent from the B1 font table"))?;
|
||||
if intrinsic.advance != intrinsic_advance(font, glyph.id)? {
|
||||
return Err(invalid("B1 intrinsic advance does not match the admitted hmtx"));
|
||||
}
|
||||
glyph_end = g1;
|
||||
text_end = if run.rtl { t0 } else { t1 };
|
||||
}
|
||||
if glyph_end != run.glyphs.len() || text_end != if run.rtl { 0 } else { run.text.len() } {
|
||||
return Err(invalid("clusters do not cover the whole run exactly once"));
|
||||
}
|
||||
Ok(run.glyphs.len())
|
||||
}
|
||||
|
||||
/// Resolve an F0 run through the already validated Scene table, never by a
|
||||
/// bundled number or Vec index. This increment still plans ONE font and all
|
||||
/// its runs; mixed-font input is rejected by the existing plan_text checks.
|
||||
/// Preparation is not proof of PDF embedding permission or trusted advances;
|
||||
/// the existing owned admission/provenance boundary remains mandatory.
|
||||
pub(super) fn plan_prepared_text<'a>(
|
||||
prepared: &solidcraft_geom::paper::Prepared<'a>,
|
||||
runs: &[&'a TextRun],
|
||||
max_plan_bytes: usize,
|
||||
max_subset_bytes: usize,
|
||||
) -> Result<TextPlan<'a>> {
|
||||
let first = runs.first().copied().ok_or_else(|| invalid("empty allowance or unsupported run count"))?;
|
||||
let font = prepared.font(first).ok_or_else(|| invalid("run font ID is absent from the prepared scene"))?;
|
||||
plan_text(font, runs, max_plan_bytes, max_subset_bytes)
|
||||
}
|
||||
|
||||
/// One bundled font and ALL its runs, in their existing paint order. Code keys
|
||||
/// are sorted (original GID, literal text), so iteration/export repetition cannot
|
||||
/// change their allocation. Code zero stays reserved for .notdef, not user text.
|
||||
pub(super) fn plan_text<'a>(font: &'a Font, runs: &[&'a TextRun], max_plan_bytes: usize, max_subset_bytes: usize) -> Result<TextPlan<'a>> {
|
||||
let limit = max_plan_bytes.min(MAX_PLAN_BYTES);
|
||||
if limit == 0 || max_subset_bytes == 0 || runs.is_empty() || runs.len() > MAX_RUNS {
|
||||
return Err(invalid("empty allowance or unsupported run count"));
|
||||
}
|
||||
let descriptor = descriptor(font)?;
|
||||
let mut count = 0usize;
|
||||
let mut logical_bytes = 0usize;
|
||||
for run in runs {
|
||||
count = count.checked_add(check_run(font, run)?).filter(|n| *n <= MAX_GLYPHS)
|
||||
.ok_or_else(|| invalid("combined glyph count exceeds 16384"))?;
|
||||
logical_bytes = logical_bytes.checked_add(run.text.len()).filter(|n| *n <= MAX_LOGICAL_BYTES)
|
||||
.ok_or_else(|| invalid("combined logical text exceeds its allowance"))?;
|
||||
}
|
||||
total(&[
|
||||
bytes::<(u16, &str)>(count)?, bytes::<u16>(count)?, bytes::<CodeToGlyph<'_>>(count)?,
|
||||
bytes::<PlannedGlyph<'_>>(count)?, bytes::<PlannedRun<'_>>(runs.len())?,
|
||||
], limit)?;
|
||||
let mut keys = reserve::<(u16, &str)>(count)?;
|
||||
let mut used = reserve::<u16>(count)?;
|
||||
let mut codes = reserve::<CodeToGlyph<'_>>(count)?;
|
||||
let mut planned_runs = reserve::<PlannedRun<'_>>(runs.len())?;
|
||||
let mut glyphs = reserve::<PlannedGlyph<'_>>(count)?;
|
||||
total(&[
|
||||
bytes::<(u16, &str)>(keys.capacity())?, bytes::<u16>(used.capacity())?, bytes::<CodeToGlyph<'_>>(codes.capacity())?,
|
||||
bytes::<PlannedGlyph<'_>>(glyphs.capacity())?, bytes::<PlannedRun<'_>>(planned_runs.capacity())?,
|
||||
], limit)?;
|
||||
for run in runs {
|
||||
for cluster in &run.clusters {
|
||||
let glyph = run.glyphs.get(cluster.glyphs.start as usize).ok_or_else(|| invalid("checked glyph disappeared"))?;
|
||||
if glyph.id == 0 {
|
||||
return Err(invalid(".notdef cannot replace a missing text glyph"));
|
||||
}
|
||||
let logical = run.text.get(cluster.text.start as usize..cluster.text.end as usize).ok_or_else(|| invalid("checked text disappeared"))?;
|
||||
keys.push((glyph.id, logical));
|
||||
}
|
||||
}
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
for (gid, _) in &keys {
|
||||
if used.last() != Some(gid) {
|
||||
used.push(*gid);
|
||||
}
|
||||
}
|
||||
if !font.glyphs.keys().copied().eq(used.iter().copied()) {
|
||||
return Err(invalid("the font table is not exactly the glyphs of the supplied runs"));
|
||||
}
|
||||
// Reuse the real existing admission, composite closure and subset library.
|
||||
// There is no duplicate raw-font admission hash or copied font implementation.
|
||||
let subset = subset_font(&font.bytes, font.face, &used, max_subset_bytes)?;
|
||||
for (index, (gid, logical)) in keys.iter().enumerate() {
|
||||
let code = u16::try_from(index + 1).map_err(|_| invalid("text codes exceed u16"))?;
|
||||
let at = subset.mapping().binary_search_by_key(gid, |(old, _)| *old).map_err(|_| invalid("subset lost an original glyph"))?;
|
||||
let (_, subset_gid) = subset.mapping().get(at).ok_or_else(|| invalid("subset mapping is incomplete"))?;
|
||||
codes.push(CodeToGlyph { code, subset_gid: *subset_gid, logical });
|
||||
}
|
||||
for run in runs {
|
||||
let first = glyphs.len();
|
||||
let (sin, cos) = run.angle.sin_cos();
|
||||
for logical_index in 0..run.clusters.len() {
|
||||
let at = if run.rtl { run.clusters.len() - 1 - logical_index } else { logical_index };
|
||||
let cluster = run.clusters.get(at).ok_or_else(|| invalid("checked cluster disappeared"))?;
|
||||
let source = cluster.glyphs.start as usize;
|
||||
let glyph = run.glyphs.get(source).ok_or_else(|| invalid("checked glyph disappeared"))?;
|
||||
let logical = run.text.get(cluster.text.start as usize..cluster.text.end as usize).ok_or_else(|| invalid("checked text disappeared"))?;
|
||||
let key = keys.binary_search(&(glyph.id, logical)).map_err(|_| invalid("text code is absent"))?;
|
||||
let code = codes.get(key).ok_or_else(|| invalid("text code is outside its table"))?.code;
|
||||
let matrix_mm = [
|
||||
cos, sin, cos * run.slant - sin, sin * run.slant + cos,
|
||||
run.origin.x + cos * glyph.x - sin * glyph.rise,
|
||||
run.origin.y + sin * glyph.x + cos * glyph.rise,
|
||||
];
|
||||
if !matrix_mm.iter().all(|n| n.is_finite()) {
|
||||
return Err(invalid("glyph transform is not finite"));
|
||||
}
|
||||
glyphs.push(PlannedGlyph {
|
||||
code, original_gid: glyph.id, source_glyph_index: source,
|
||||
intrinsic_advance_fu: intrinsic_advance(font, glyph.id)?, placed_advance_mm: glyph.advance,
|
||||
matrix_mm, logical,
|
||||
});
|
||||
}
|
||||
planned_runs.push(PlannedRun {
|
||||
glyphs: first..glyphs.len(), font_size_mm: run.scale * f64::from(font.units_per_em), rtl: run.rtl, text: &run.text,
|
||||
});
|
||||
}
|
||||
Ok(TextPlan { font, subset, descriptor, codes, runs: planned_runs, glyphs })
|
||||
}
|
||||
+1191
File diff suppressed because it is too large
Load Diff
+335
@@ -0,0 +1,335 @@
|
||||
//! Bounded PDF planning from the actual B1a Font/TextRun records (#4890 P2).
|
||||
//!
|
||||
//! This neither shapes text nor writes a page. It keeps intrinsic hmtx widths
|
||||
//! separate from the producer's placed glyph x/rise/advance. One glyph per logical
|
||||
//! cluster is supported, including a glyph carrying several characters; a
|
||||
//! multi-glyph cluster is refused until its extraction/content policy is supplied.
|
||||
//! The ToUnicode destination limit stays 512 UTF-16BE bytes, not a silent split.
|
||||
//!
|
||||
//! Plans retain no new font/document Arc: strings/font records are borrowed.
|
||||
//! Local vector storage and returned subset bytes have separate allowances. The
|
||||
//! existing subsetter/pdf-writer internal allocation limitations still apply.
|
||||
//! No parent registration, global PDF Ref allocator, page resource or text arm is
|
||||
//! introduced here. The parent must preserve the planned placements and reading
|
||||
//! order and qualify real PDF rendering/extraction; these records prove neither.
|
||||
|
||||
use std::mem::size_of;
|
||||
use std::ops::Range;
|
||||
|
||||
use pdf_writer::types::FontFlags;
|
||||
use sha2::{Digest, Sha256};
|
||||
use solidcraft_geom::paper::text::{Font, TextRun};
|
||||
use solidcraft_geom::paper::MAX_COORDINATE_MM;
|
||||
|
||||
use super::font_objects::{CodeToGlyph, DescriptorMetrics, FontObjectInput};
|
||||
use super::subset_font::{SubsetFont, subset_font};
|
||||
use crate::{IoError, Result};
|
||||
|
||||
const MAX_RUNS: usize = 1024;
|
||||
const MAX_GLYPHS: usize = 16_384;
|
||||
const MAX_PLAN_BYTES: usize = 16 << 20;
|
||||
const MAX_LOGICAL_BYTES: usize = 4 << 20;
|
||||
const MAX_CLUSTER_BYTES: usize = 768;
|
||||
const MAX_CLUSTER_UNITS: usize = 256;
|
||||
const APPROVED_FONT_BYTES: usize = 410_820;
|
||||
|
||||
/// One actual glyph, with a code for its original GID AND literal logical text.
|
||||
/// The matrix has unit basis vectors and a translation in sheet millimetres;
|
||||
/// font_size_mm is on its run. Parent applies its mm-to-point scale only once.
|
||||
pub(super) struct PlannedGlyph<'a> {
|
||||
pub(super) code: u16,
|
||||
pub(super) original_gid: u16,
|
||||
pub(super) source_glyph_index: usize,
|
||||
pub(super) intrinsic_advance_fu: u16,
|
||||
pub(super) placed_advance_mm: f64,
|
||||
pub(super) matrix_mm: [f64; 6],
|
||||
pub(super) logical: &'a str,
|
||||
}
|
||||
|
||||
/// Glyphs occupy one contiguous range in the plan, in logical reading order.
|
||||
/// For an RTL run their SOURCE indices descend; positions are never recomputed
|
||||
/// from font widths. Input run order remains unchanged.
|
||||
pub(super) struct PlannedRun<'a> {
|
||||
pub(super) glyphs: Range<usize>,
|
||||
pub(super) font_size_mm: f64,
|
||||
pub(super) rtl: bool,
|
||||
pub(super) text: &'a str,
|
||||
}
|
||||
|
||||
pub(super) struct TextPlan<'a> {
|
||||
font: &'a Font,
|
||||
subset: SubsetFont,
|
||||
descriptor: DescriptorMetrics,
|
||||
codes: Vec<CodeToGlyph<'a>>,
|
||||
runs: Vec<PlannedRun<'a>>,
|
||||
glyphs: Vec<PlannedGlyph<'a>>,
|
||||
}
|
||||
|
||||
impl TextPlan<'_> {
|
||||
pub(super) fn runs(&self) -> &[PlannedRun<'_>] {
|
||||
&self.runs
|
||||
}
|
||||
|
||||
pub(super) fn glyphs(&self) -> &[PlannedGlyph<'_>] {
|
||||
&self.glyphs
|
||||
}
|
||||
|
||||
/// Caller still supplies six globally reserved refs to font_objects, a
|
||||
/// globally unique subset tag, page resources and a cumulative output budget.
|
||||
pub(super) fn font_input(&self, subset_tag: [u8; 6], max_chunk_bytes: usize) -> FontObjectInput<'_> {
|
||||
FontObjectInput {
|
||||
source_font: self.font,
|
||||
subset: &self.subset,
|
||||
codes: &self.codes,
|
||||
descriptor: Some(&self.descriptor),
|
||||
postscript_name: self.font.postscript_name.as_bytes(),
|
||||
subset_tag,
|
||||
max_chunk_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(message: &'static str) -> IoError {
|
||||
IoError::Invalid(format!("PDF text plan: {message}"))
|
||||
}
|
||||
|
||||
fn reserve<T>(count: usize) -> Result<Vec<T>> {
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(count).map_err(|_| invalid("cannot allocate bounded plan storage"))?;
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn bytes<T>(count: usize) -> Result<usize> {
|
||||
count.checked_mul(size_of::<T>()).ok_or_else(|| invalid("plan storage overflow"))
|
||||
}
|
||||
|
||||
fn total(parts: &[usize], limit: usize) -> Result<usize> {
|
||||
parts.iter().try_fold(0usize, |n, part| {
|
||||
n.checked_add(*part).filter(|n| *n <= limit).ok_or_else(|| invalid("plan storage allowance exceeded"))
|
||||
})
|
||||
}
|
||||
|
||||
fn u16_at(bytes: &[u8], at: usize) -> Result<u16> {
|
||||
let end = at.checked_add(2).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
let value = bytes.get(at..end).and_then(|b| b.try_into().ok()).ok_or_else(|| invalid("truncated admitted font table"))?;
|
||||
Ok(u16::from_be_bytes(value))
|
||||
}
|
||||
|
||||
fn u32_at(bytes: &[u8], at: usize) -> Result<u32> {
|
||||
let end = at.checked_add(4).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
let value = bytes.get(at..end).and_then(|b| b.try_into().ok()).ok_or_else(|| invalid("truncated admitted font table"))?;
|
||||
Ok(u32::from_be_bytes(value))
|
||||
}
|
||||
|
||||
/// A bounded original hmtx view, not a general font parser. The subset adapter
|
||||
/// performs the existing exact-resource/embedding admission before plan return.
|
||||
fn table<'a>(bytes: &'a [u8], tag: &[u8; 4]) -> Result<&'a [u8]> {
|
||||
let count = usize::from(u16_at(bytes, 4)?);
|
||||
if count == 0 || count > 19 {
|
||||
return Err(invalid("font directory exceeds the reviewed resource"));
|
||||
}
|
||||
let mut found = None;
|
||||
for i in 0..count {
|
||||
let at = 12 + 16 * i;
|
||||
if bytes.get(at..at + 4) != Some(tag) {
|
||||
continue;
|
||||
}
|
||||
if found.is_some() {
|
||||
return Err(invalid("duplicate font table"));
|
||||
}
|
||||
let start = usize::try_from(u32_at(bytes, at + 8)?).map_err(|_| invalid("font offset exceeds the platform"))?;
|
||||
let length = usize::try_from(u32_at(bytes, at + 12)?).map_err(|_| invalid("font length exceeds the platform"))?;
|
||||
let end = start.checked_add(length).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
found = bytes.get(start..end);
|
||||
if found.is_none() {
|
||||
return Err(invalid("font table is outside its bytes"));
|
||||
}
|
||||
}
|
||||
found.ok_or_else(|| invalid("required font table is absent"))
|
||||
}
|
||||
|
||||
fn intrinsic_advance(font: &Font, gid: u16) -> Result<u16> {
|
||||
if gid >= font.num_glyphs {
|
||||
return Err(invalid("glyph is outside the admitted font"));
|
||||
}
|
||||
let count = usize::from(u16_at(table(&font.bytes, b"hhea")?, 34)?);
|
||||
if count == 0 || count > usize::from(font.num_glyphs) {
|
||||
return Err(invalid("invalid hmtx metric count"));
|
||||
}
|
||||
u16_at(table(&font.bytes, b"hmtx")?, usize::from(gid).min(count - 1) * 4)
|
||||
}
|
||||
|
||||
fn descriptor(font: &Font) -> Result<DescriptorMetrics> {
|
||||
// Public Font is hostile input too. Refuse its cheap byte envelope before
|
||||
// hashing or looking through tables, even when the caller's plan budget is tiny.
|
||||
if font.bytes.len() != APPROVED_FONT_BYTES {
|
||||
return Err(invalid("font byte length is outside the reviewed resource envelope"));
|
||||
}
|
||||
// B1 hashes bytes + faceLE. The existing subset admission hashes RAW bytes;
|
||||
// their digests are different identities and are never compared to each other.
|
||||
let m = &font.metrics;
|
||||
// Original fixed-resource metadata, not fitted/default descriptor values.
|
||||
// Values come from this exact Liberation Sans head/hhea/OS2/post and l glyph.
|
||||
if font.bundled != 0 || font.face != 0 || font.postscript_name != "LiberationSans"
|
||||
|| font.num_glyphs != 2620 || font.units_per_em != 2048
|
||||
|| m.bbox != [-1114, -621, 2666, 2007] || m.ascent != 1854 || m.descent != -434
|
||||
|| m.cap_height != 1409 || m.stem_v != 180 || m.italic_angle != 0.0
|
||||
|| m.flags.fixed_pitch || m.flags.serif || m.flags.italic || m.flags.symbolic
|
||||
{
|
||||
return Err(invalid("B1 font identity or metrics differ from the reviewed regular bundled face"));
|
||||
}
|
||||
let identity: [u8; 32] = Sha256::new().chain_update(font.bytes.as_ref()).chain_update(font.face.to_le_bytes()).finalize().into();
|
||||
if font.id != identity {
|
||||
return Err(invalid("B1 bytes-and-face identity does not match its supplied bytes"));
|
||||
}
|
||||
let k = 1000.0 / f32::from(font.units_per_em);
|
||||
Ok(DescriptorMetrics {
|
||||
bbox_1000: m.bbox.map(|n| f32::from(n) * k),
|
||||
ascent_1000: f32::from(m.ascent) * k,
|
||||
descent_1000: f32::from(m.descent) * k,
|
||||
cap_height_1000: f32::from(m.cap_height) * k,
|
||||
italic_angle_degrees: m.italic_angle as f32,
|
||||
stem_v_1000: f32::from(m.stem_v) * k,
|
||||
flags: FontFlags::NON_SYMBOLIC.bits(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check the actual B1 cluster partition and the narrower first P2 contract
|
||||
/// BEFORE allocating or subsetting. Returning an error exposes no partial plan.
|
||||
fn check_run(font: &Font, run: &TextRun) -> Result<usize> {
|
||||
if run.font != font.bundled || !(1..=4000).contains(&run.glyphs.len()) || run.text.len() > 4000 {
|
||||
return Err(invalid("run font, glyph count or text length is outside B1"));
|
||||
}
|
||||
let cap = run.scale * f64::from(font.metrics.cap_height);
|
||||
let within = |x: f64| x.is_finite() && x.abs() <= MAX_COORDINATE_MM;
|
||||
if !run.scale.is_finite() || !(1e-6..1e6).contains(&cap) || cap == 1e-6
|
||||
|| !within(run.origin.x) || !within(run.origin.y)
|
||||
|| !run.angle.is_finite() || run.angle.abs() > std::f64::consts::TAU
|
||||
|| !run.slant.is_finite() || run.slant.abs() > 1.0
|
||||
{
|
||||
return Err(invalid("run transform is outside B1"));
|
||||
}
|
||||
let mut glyph_end = 0usize;
|
||||
let mut text_end = if run.rtl { run.text.len() } else { 0 };
|
||||
for cluster in &run.clusters {
|
||||
let (g0, g1, t0, t1) = (
|
||||
cluster.glyphs.start as usize, cluster.glyphs.end as usize,
|
||||
cluster.text.start as usize, cluster.text.end as usize,
|
||||
);
|
||||
if g0 != glyph_end || g0 >= g1 || t0 >= t1 || (if run.rtl { t1 != text_end } else { t0 != text_end }) {
|
||||
return Err(invalid("clusters do not partition glyphs and logical text in B1 order"));
|
||||
}
|
||||
if g1 - g0 != 1 {
|
||||
return Err(invalid("multi-glyph cluster extraction is not supported by this text-plan increment"));
|
||||
}
|
||||
let logical = run.text.get(t0..t1).ok_or_else(|| invalid("cluster text is not whole UTF-8 characters"))?;
|
||||
if logical.len() > MAX_CLUSTER_BYTES || logical.encode_utf16().take(MAX_CLUSTER_UNITS + 1).count() > MAX_CLUSTER_UNITS {
|
||||
return Err(invalid("cluster exceeds the existing ToUnicode 512-byte destination contract"));
|
||||
}
|
||||
let glyph = run.glyphs.get(g0).ok_or_else(|| invalid("cluster glyph is outside the run"))?;
|
||||
if ![glyph.x, glyph.rise, glyph.advance].into_iter().all(within) {
|
||||
return Err(invalid("placed glyph is outside B1 coordinates"));
|
||||
}
|
||||
let intrinsic = font.glyphs.get(&glyph.id).ok_or_else(|| invalid("glyph is absent from the B1 font table"))?;
|
||||
if intrinsic.advance != intrinsic_advance(font, glyph.id)? {
|
||||
return Err(invalid("B1 intrinsic advance does not match the admitted hmtx"));
|
||||
}
|
||||
glyph_end = g1;
|
||||
text_end = if run.rtl { t0 } else { t1 };
|
||||
}
|
||||
if glyph_end != run.glyphs.len() || text_end != if run.rtl { 0 } else { run.text.len() } {
|
||||
return Err(invalid("clusters do not cover the whole run exactly once"));
|
||||
}
|
||||
Ok(run.glyphs.len())
|
||||
}
|
||||
|
||||
/// One bundled font and ALL its runs, in their existing paint order. Code keys
|
||||
/// are sorted (original GID, literal text), so iteration/export repetition cannot
|
||||
/// change their allocation. Code zero stays reserved for .notdef, not user text.
|
||||
pub(super) fn plan_text<'a>(font: &'a Font, runs: &[&'a TextRun], max_plan_bytes: usize, max_subset_bytes: usize) -> Result<TextPlan<'a>> {
|
||||
let limit = max_plan_bytes.min(MAX_PLAN_BYTES);
|
||||
if limit == 0 || max_subset_bytes == 0 || runs.is_empty() || runs.len() > MAX_RUNS {
|
||||
return Err(invalid("empty allowance or unsupported run count"));
|
||||
}
|
||||
let descriptor = descriptor(font)?;
|
||||
let mut count = 0usize;
|
||||
let mut logical_bytes = 0usize;
|
||||
for run in runs {
|
||||
count = count.checked_add(check_run(font, run)?).filter(|n| *n <= MAX_GLYPHS)
|
||||
.ok_or_else(|| invalid("combined glyph count exceeds 16384"))?;
|
||||
logical_bytes = logical_bytes.checked_add(run.text.len()).filter(|n| *n <= MAX_LOGICAL_BYTES)
|
||||
.ok_or_else(|| invalid("combined logical text exceeds its allowance"))?;
|
||||
}
|
||||
total(&[
|
||||
bytes::<(u16, &str)>(count)?, bytes::<u16>(count)?, bytes::<CodeToGlyph<'_>>(count)?,
|
||||
bytes::<PlannedGlyph<'_>>(count)?, bytes::<PlannedRun<'_>>(runs.len())?,
|
||||
], limit)?;
|
||||
let mut keys = reserve::<(u16, &str)>(count)?;
|
||||
let mut used = reserve::<u16>(count)?;
|
||||
let mut codes = reserve::<CodeToGlyph<'_>>(count)?;
|
||||
let mut planned_runs = reserve::<PlannedRun<'_>>(runs.len())?;
|
||||
let mut glyphs = reserve::<PlannedGlyph<'_>>(count)?;
|
||||
total(&[
|
||||
bytes::<(u16, &str)>(keys.capacity())?, bytes::<u16>(used.capacity())?, bytes::<CodeToGlyph<'_>>(codes.capacity())?,
|
||||
bytes::<PlannedGlyph<'_>>(glyphs.capacity())?, bytes::<PlannedRun<'_>>(planned_runs.capacity())?,
|
||||
], limit)?;
|
||||
for run in runs {
|
||||
for cluster in &run.clusters {
|
||||
let glyph = run.glyphs.get(cluster.glyphs.start as usize).ok_or_else(|| invalid("checked glyph disappeared"))?;
|
||||
if glyph.id == 0 {
|
||||
return Err(invalid(".notdef cannot replace a missing text glyph"));
|
||||
}
|
||||
let logical = run.text.get(cluster.text.start as usize..cluster.text.end as usize).ok_or_else(|| invalid("checked text disappeared"))?;
|
||||
keys.push((glyph.id, logical));
|
||||
}
|
||||
}
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
for (gid, _) in &keys {
|
||||
if used.last() != Some(gid) {
|
||||
used.push(*gid);
|
||||
}
|
||||
}
|
||||
if !font.glyphs.keys().copied().eq(used.iter().copied()) {
|
||||
return Err(invalid("the font table is not exactly the glyphs of the supplied runs"));
|
||||
}
|
||||
// Reuse the real existing admission, composite closure and subset library.
|
||||
// There is no duplicate raw-font admission hash or copied font implementation.
|
||||
let subset = subset_font(&font.bytes, font.face, &used, max_subset_bytes)?;
|
||||
for (index, (gid, logical)) in keys.iter().enumerate() {
|
||||
let code = u16::try_from(index + 1).map_err(|_| invalid("text codes exceed u16"))?;
|
||||
let at = subset.mapping().binary_search_by_key(gid, |(old, _)| *old).map_err(|_| invalid("subset lost an original glyph"))?;
|
||||
let (_, subset_gid) = subset.mapping().get(at).ok_or_else(|| invalid("subset mapping is incomplete"))?;
|
||||
codes.push(CodeToGlyph { code, subset_gid: *subset_gid, logical });
|
||||
}
|
||||
for run in runs {
|
||||
let first = glyphs.len();
|
||||
let (sin, cos) = run.angle.sin_cos();
|
||||
for logical_index in 0..run.clusters.len() {
|
||||
let at = if run.rtl { run.clusters.len() - 1 - logical_index } else { logical_index };
|
||||
let cluster = run.clusters.get(at).ok_or_else(|| invalid("checked cluster disappeared"))?;
|
||||
let source = cluster.glyphs.start as usize;
|
||||
let glyph = run.glyphs.get(source).ok_or_else(|| invalid("checked glyph disappeared"))?;
|
||||
let logical = run.text.get(cluster.text.start as usize..cluster.text.end as usize).ok_or_else(|| invalid("checked text disappeared"))?;
|
||||
let key = keys.binary_search(&(glyph.id, logical)).map_err(|_| invalid("text code is absent"))?;
|
||||
let code = codes.get(key).ok_or_else(|| invalid("text code is outside its table"))?.code;
|
||||
let matrix_mm = [
|
||||
cos, sin, cos * run.slant - sin, sin * run.slant + cos,
|
||||
run.origin.x + cos * glyph.x - sin * glyph.rise,
|
||||
run.origin.y + sin * glyph.x + cos * glyph.rise,
|
||||
];
|
||||
if !matrix_mm.iter().all(|n| n.is_finite()) {
|
||||
return Err(invalid("glyph transform is not finite"));
|
||||
}
|
||||
glyphs.push(PlannedGlyph {
|
||||
code, original_gid: glyph.id, source_glyph_index: source,
|
||||
intrinsic_advance_fu: intrinsic_advance(font, glyph.id)?, placed_advance_mm: glyph.advance,
|
||||
matrix_mm, logical,
|
||||
});
|
||||
}
|
||||
planned_runs.push(PlannedRun {
|
||||
glyphs: first..glyphs.len(), font_size_mm: run.scale * f64::from(font.units_per_em), rtl: run.rtl, text: &run.text,
|
||||
});
|
||||
}
|
||||
Ok(TextPlan { font, subset, descriptor, codes, runs: planned_runs, glyphs })
|
||||
}
|
||||
+1219
File diff suppressed because it is too large
Load Diff
+387
@@ -0,0 +1,387 @@
|
||||
//! Bounded PDF planning from the actual B1a Font/TextRun records (#4890 P2).
|
||||
//!
|
||||
//! This neither shapes text nor writes a page. It keeps intrinsic hmtx widths
|
||||
//! separate from the producer's placed glyph x/rise/advance. One glyph per logical
|
||||
//! cluster is supported, including a glyph carrying several characters; a
|
||||
//! multi-glyph cluster is refused until its extraction/content policy is supplied.
|
||||
//! The ToUnicode destination limit stays 512 UTF-16BE bytes, not a silent split.
|
||||
//!
|
||||
//! Plans retain no new font/document Arc: strings/font records are borrowed.
|
||||
//! Local vector storage and returned subset bytes have separate allowances. The
|
||||
//! existing subsetter/pdf-writer internal allocation limitations still apply.
|
||||
//! No parent registration, global PDF Ref allocator, page resource or text arm is
|
||||
//! introduced here. The parent must preserve the planned placements and reading
|
||||
//! order and qualify real PDF rendering/extraction; these records prove neither.
|
||||
|
||||
use std::mem::size_of;
|
||||
use std::ops::Range;
|
||||
|
||||
use pdf_writer::types::FontFlags;
|
||||
use sha2::{Digest, Sha256};
|
||||
use solidcraft_geom::paper::text::{Font, TextRun};
|
||||
use solidcraft_geom::paper::MAX_COORDINATE_MM;
|
||||
|
||||
use super::font_objects::{CodeToGlyph, DescriptorMetrics, FontObjectInput};
|
||||
use super::subset_font::{SubsetFont, subset_font};
|
||||
use crate::{IoError, Result};
|
||||
|
||||
const MAX_RUNS: usize = 1024;
|
||||
const MAX_GLYPHS: usize = 16_384;
|
||||
const MAX_PLAN_BYTES: usize = 16 << 20;
|
||||
const MAX_LOGICAL_BYTES: usize = 4 << 20;
|
||||
const MAX_CLUSTER_BYTES: usize = 768;
|
||||
const MAX_CLUSTER_UNITS: usize = 256;
|
||||
const APPROVED_FONT_BYTES: usize = 410_820;
|
||||
|
||||
/// One actual glyph, with a code for its original GID AND literal logical text.
|
||||
/// The matrix has unit basis vectors and a translation in sheet millimetres;
|
||||
/// font_size_mm is on its run. Parent applies its mm-to-point scale only once.
|
||||
pub(super) struct PlannedGlyph<'a> {
|
||||
pub(super) code: u16,
|
||||
pub(super) original_gid: u16,
|
||||
pub(super) source_glyph_index: usize,
|
||||
pub(super) intrinsic_advance_fu: u16,
|
||||
pub(super) placed_advance_mm: f64,
|
||||
pub(super) matrix_mm: [f64; 6],
|
||||
pub(super) logical: &'a str,
|
||||
}
|
||||
|
||||
/// Glyphs occupy one contiguous range in the plan, in logical reading order.
|
||||
/// For an RTL run their SOURCE indices descend; positions are never recomputed
|
||||
/// from font widths. Input run order remains unchanged.
|
||||
pub(super) struct PlannedRun<'a> {
|
||||
pub(super) glyphs: Range<usize>,
|
||||
pub(super) font_size_mm: f64,
|
||||
pub(super) rtl: bool,
|
||||
pub(super) text: &'a str,
|
||||
}
|
||||
|
||||
pub(super) struct TextPlan<'a> {
|
||||
font: &'a Font,
|
||||
subset: SubsetFont,
|
||||
descriptor: DescriptorMetrics,
|
||||
codes: Vec<CodeToGlyph<'a>>,
|
||||
runs: Vec<PlannedRun<'a>>,
|
||||
glyphs: Vec<PlannedGlyph<'a>>,
|
||||
}
|
||||
|
||||
/// Borrowed only from a successfully constructed TextPlan. Private fields
|
||||
/// bind checked source records, the factory-produced subset, descriptor and
|
||||
/// codes for one immutable plan; no raw-record or serde constructor exists.
|
||||
pub(super) struct FontPlanView<'a> {
|
||||
font: &'a Font,
|
||||
subset: &'a SubsetFont,
|
||||
descriptor: &'a DescriptorMetrics,
|
||||
codes: &'a [CodeToGlyph<'a>],
|
||||
}
|
||||
|
||||
impl<'a> FontPlanView<'a> {
|
||||
pub(super) fn font(&self) -> &'a Font { self.font }
|
||||
pub(super) fn subset(&self) -> &'a SubsetFont { self.subset }
|
||||
pub(super) fn descriptor(&self) -> &'a DescriptorMetrics { self.descriptor }
|
||||
pub(super) fn codes(&self) -> &'a [CodeToGlyph<'a>] { self.codes }
|
||||
}
|
||||
|
||||
impl TextPlan<'_> {
|
||||
pub(super) fn runs(&self) -> &[PlannedRun<'_>] {
|
||||
&self.runs
|
||||
}
|
||||
|
||||
pub(super) fn glyphs(&self) -> &[PlannedGlyph<'_>] {
|
||||
&self.glyphs
|
||||
}
|
||||
|
||||
/// Caller still supplies six globally reserved refs to font_objects, a
|
||||
/// globally unique subset tag, page resources and a cumulative output budget.
|
||||
pub(super) fn font_input(&self, subset_tag: [u8; 6], max_chunk_bytes: usize) -> FontObjectInput<'_> {
|
||||
FontObjectInput {
|
||||
source: FontPlanView {
|
||||
font: self.font,
|
||||
subset: &self.subset,
|
||||
descriptor: &self.descriptor,
|
||||
codes: &self.codes,
|
||||
},
|
||||
subset_tag,
|
||||
max_chunk_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(message: &'static str) -> IoError {
|
||||
IoError::Invalid(format!("PDF text plan: {message}"))
|
||||
}
|
||||
|
||||
fn reserve<T>(count: usize) -> Result<Vec<T>> {
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(count).map_err(|_| invalid("cannot allocate bounded plan storage"))?;
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn bytes<T>(count: usize) -> Result<usize> {
|
||||
count.checked_mul(size_of::<T>()).ok_or_else(|| invalid("plan storage overflow"))
|
||||
}
|
||||
|
||||
fn total(parts: &[usize], limit: usize) -> Result<usize> {
|
||||
parts.iter().try_fold(0usize, |n, part| {
|
||||
n.checked_add(*part).filter(|n| *n <= limit).ok_or_else(|| invalid("plan storage allowance exceeded"))
|
||||
})
|
||||
}
|
||||
|
||||
fn u16_at(bytes: &[u8], at: usize) -> Result<u16> {
|
||||
let end = at.checked_add(2).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
let value = bytes.get(at..end).and_then(|b| b.try_into().ok()).ok_or_else(|| invalid("truncated admitted font table"))?;
|
||||
Ok(u16::from_be_bytes(value))
|
||||
}
|
||||
|
||||
fn u32_at(bytes: &[u8], at: usize) -> Result<u32> {
|
||||
let end = at.checked_add(4).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
let value = bytes.get(at..end).and_then(|b| b.try_into().ok()).ok_or_else(|| invalid("truncated admitted font table"))?;
|
||||
Ok(u32::from_be_bytes(value))
|
||||
}
|
||||
|
||||
/// A bounded original hmtx view, not a general font parser. The subset adapter
|
||||
/// performs the existing exact-resource/embedding admission before plan return.
|
||||
fn table<'a>(bytes: &'a [u8], tag: &[u8; 4]) -> Result<&'a [u8]> {
|
||||
let count = usize::from(u16_at(bytes, 4)?);
|
||||
if count == 0 || count > 19 {
|
||||
return Err(invalid("font directory exceeds the reviewed resource"));
|
||||
}
|
||||
let mut found = None;
|
||||
for i in 0..count {
|
||||
let at = 12 + 16 * i;
|
||||
if bytes.get(at..at + 4) != Some(tag) {
|
||||
continue;
|
||||
}
|
||||
if found.is_some() {
|
||||
return Err(invalid("duplicate font table"));
|
||||
}
|
||||
let start = usize::try_from(u32_at(bytes, at + 8)?).map_err(|_| invalid("font offset exceeds the platform"))?;
|
||||
let length = usize::try_from(u32_at(bytes, at + 12)?).map_err(|_| invalid("font length exceeds the platform"))?;
|
||||
let end = start.checked_add(length).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
found = bytes.get(start..end);
|
||||
if found.is_none() {
|
||||
return Err(invalid("font table is outside its bytes"));
|
||||
}
|
||||
}
|
||||
found.ok_or_else(|| invalid("required font table is absent"))
|
||||
}
|
||||
|
||||
fn intrinsic_advance(font: &Font, gid: u16) -> Result<u16> {
|
||||
if gid >= font.num_glyphs {
|
||||
return Err(invalid("glyph is outside the admitted font"));
|
||||
}
|
||||
let count = usize::from(u16_at(table(&font.bytes, b"hhea")?, 34)?);
|
||||
if count == 0 || count > usize::from(font.num_glyphs) {
|
||||
return Err(invalid("invalid hmtx metric count"));
|
||||
}
|
||||
u16_at(table(&font.bytes, b"hmtx")?, usize::from(gid).min(count - 1) * 4)
|
||||
}
|
||||
|
||||
/// Current builtin bytes/face only, obtained by the existing None catalog path
|
||||
/// before any installed-font, filesystem or cache lookup. This does not attest
|
||||
/// mutable metrics/advances or waive the existing single subset admission.
|
||||
pub(super) fn check_builtin_source(font: &Font) -> Result<()> {
|
||||
if font.bytes.len() != APPROVED_FONT_BYTES {
|
||||
return Err(invalid("font byte length is outside the reviewed resource envelope"));
|
||||
}
|
||||
let builtin = solidcraft_doc::sketch::font_data(None)
|
||||
.map_err(|_| invalid("the builtin drawing font is unavailable"))?;
|
||||
if builtin.index != 0 || builtin.weight.is_some() || font.face != builtin.index
|
||||
|| font.bytes.as_ref() != builtin.bytes()
|
||||
{
|
||||
return Err(invalid("font bytes/face differ from the current builtin drawing font"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn descriptor(font: &Font) -> Result<DescriptorMetrics> {
|
||||
// Public Font is hostile input too. Refuse its cheap byte envelope before
|
||||
// hashing or looking through tables, even when the caller's plan budget is tiny.
|
||||
if font.bytes.len() != APPROVED_FONT_BYTES {
|
||||
return Err(invalid("font byte length is outside the reviewed resource envelope"));
|
||||
}
|
||||
check_builtin_source(font)?;
|
||||
// B1 hashes bytes + faceLE. The existing subset admission hashes RAW bytes;
|
||||
// their digests are different identities and are never compared to each other.
|
||||
let m = &font.metrics;
|
||||
// Original fixed-resource metadata, not fitted/default descriptor values.
|
||||
// Values come from this exact Liberation Sans head/hhea/OS2/post and l glyph.
|
||||
if font.face != 0 || font.postscript_name != "LiberationSans"
|
||||
|| font.num_glyphs != 2620 || font.units_per_em != 2048
|
||||
|| m.bbox != [-1114, -621, 2666, 2007] || m.ascent != 1854 || m.descent != -434
|
||||
|| m.cap_height != 1409 || m.stem_v != 180 || m.italic_angle != 0.0
|
||||
|| m.flags.fixed_pitch || m.flags.serif || m.flags.italic || m.flags.symbolic
|
||||
{
|
||||
return Err(invalid("B1 font identity or metrics differ from the reviewed regular bundled face"));
|
||||
}
|
||||
let identity: [u8; 32] = Sha256::new().chain_update(font.bytes.as_ref()).chain_update(font.face.to_le_bytes()).finalize().into();
|
||||
if font.id != identity {
|
||||
return Err(invalid("B1 bytes-and-face identity does not match its supplied bytes"));
|
||||
}
|
||||
let k = 1000.0 / f32::from(font.units_per_em);
|
||||
Ok(DescriptorMetrics {
|
||||
bbox_1000: m.bbox.map(|n| f32::from(n) * k),
|
||||
ascent_1000: f32::from(m.ascent) * k,
|
||||
descent_1000: f32::from(m.descent) * k,
|
||||
cap_height_1000: f32::from(m.cap_height) * k,
|
||||
italic_angle_degrees: m.italic_angle as f32,
|
||||
stem_v_1000: f32::from(m.stem_v) * k,
|
||||
flags: FontFlags::NON_SYMBOLIC.bits(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check the actual B1 cluster partition and the narrower first P2 contract
|
||||
/// BEFORE allocating or subsetting. Returning an error exposes no partial plan.
|
||||
fn check_run(font: &Font, run: &TextRun) -> Result<usize> {
|
||||
if run.font != font.id || !(1..=4000).contains(&run.glyphs.len()) || run.text.len() > 4000 {
|
||||
return Err(invalid("run font, glyph count or text length is outside B1"));
|
||||
}
|
||||
let cap = run.scale * f64::from(font.metrics.cap_height);
|
||||
let within = |x: f64| x.is_finite() && x.abs() <= MAX_COORDINATE_MM;
|
||||
if !run.scale.is_finite() || !(1e-6..1e6).contains(&cap) || cap == 1e-6
|
||||
|| !within(run.origin.x) || !within(run.origin.y)
|
||||
|| !run.angle.is_finite() || run.angle.abs() > std::f64::consts::TAU
|
||||
|| !run.slant.is_finite() || run.slant.abs() > 1.0
|
||||
{
|
||||
return Err(invalid("run transform is outside B1"));
|
||||
}
|
||||
let mut glyph_end = 0usize;
|
||||
let mut text_end = if run.rtl { run.text.len() } else { 0 };
|
||||
for cluster in &run.clusters {
|
||||
let (g0, g1, t0, t1) = (
|
||||
cluster.glyphs.start as usize, cluster.glyphs.end as usize,
|
||||
cluster.text.start as usize, cluster.text.end as usize,
|
||||
);
|
||||
if g0 != glyph_end || g0 >= g1 || t0 >= t1 || (if run.rtl { t1 != text_end } else { t0 != text_end }) {
|
||||
return Err(invalid("clusters do not partition glyphs and logical text in B1 order"));
|
||||
}
|
||||
if g1 - g0 != 1 {
|
||||
return Err(invalid("multi-glyph cluster extraction is not supported by this text-plan increment"));
|
||||
}
|
||||
let logical = run.text.get(t0..t1).ok_or_else(|| invalid("cluster text is not whole UTF-8 characters"))?;
|
||||
if logical.len() > MAX_CLUSTER_BYTES || logical.encode_utf16().take(MAX_CLUSTER_UNITS + 1).count() > MAX_CLUSTER_UNITS {
|
||||
return Err(invalid("cluster exceeds the existing ToUnicode 512-byte destination contract"));
|
||||
}
|
||||
let glyph = run.glyphs.get(g0).ok_or_else(|| invalid("cluster glyph is outside the run"))?;
|
||||
if ![glyph.x, glyph.rise, glyph.advance].into_iter().all(within) {
|
||||
return Err(invalid("placed glyph is outside B1 coordinates"));
|
||||
}
|
||||
let intrinsic = font.glyphs.get(&glyph.id).ok_or_else(|| invalid("glyph is absent from the B1 font table"))?;
|
||||
if intrinsic.advance != intrinsic_advance(font, glyph.id)? {
|
||||
return Err(invalid("B1 intrinsic advance does not match the admitted hmtx"));
|
||||
}
|
||||
glyph_end = g1;
|
||||
text_end = if run.rtl { t0 } else { t1 };
|
||||
}
|
||||
if glyph_end != run.glyphs.len() || text_end != if run.rtl { 0 } else { run.text.len() } {
|
||||
return Err(invalid("clusters do not cover the whole run exactly once"));
|
||||
}
|
||||
Ok(run.glyphs.len())
|
||||
}
|
||||
|
||||
/// Resolve an F0 run through the already validated Scene table, never by a
|
||||
/// bundled number or Vec index. This increment still plans ONE font and all
|
||||
/// its runs; mixed-font input is rejected by the existing plan_text checks.
|
||||
/// Preparation is not proof of PDF embedding permission or trusted advances;
|
||||
/// the existing owned admission/provenance boundary remains mandatory.
|
||||
pub(super) fn plan_prepared_text<'a>(
|
||||
prepared: &solidcraft_geom::paper::Prepared<'a>,
|
||||
runs: &[&'a TextRun],
|
||||
max_plan_bytes: usize,
|
||||
max_subset_bytes: usize,
|
||||
) -> Result<TextPlan<'a>> {
|
||||
let first = runs.first().copied().ok_or_else(|| invalid("empty allowance or unsupported run count"))?;
|
||||
let font = prepared.font(first).ok_or_else(|| invalid("run font ID is absent from the prepared scene"))?;
|
||||
plan_text(font, runs, max_plan_bytes, max_subset_bytes)
|
||||
}
|
||||
|
||||
/// One bundled font and ALL its runs, in their existing paint order. Code keys
|
||||
/// are sorted (original GID, literal text), so iteration/export repetition cannot
|
||||
/// change their allocation. Code zero stays reserved for .notdef, not user text.
|
||||
pub(super) fn plan_text<'a>(font: &'a Font, runs: &[&'a TextRun], max_plan_bytes: usize, max_subset_bytes: usize) -> Result<TextPlan<'a>> {
|
||||
let limit = max_plan_bytes.min(MAX_PLAN_BYTES);
|
||||
if limit == 0 || max_subset_bytes == 0 || runs.is_empty() || runs.len() > MAX_RUNS {
|
||||
return Err(invalid("empty allowance or unsupported run count"));
|
||||
}
|
||||
let descriptor = descriptor(font)?;
|
||||
let mut count = 0usize;
|
||||
let mut logical_bytes = 0usize;
|
||||
for run in runs {
|
||||
count = count.checked_add(check_run(font, run)?).filter(|n| *n <= MAX_GLYPHS)
|
||||
.ok_or_else(|| invalid("combined glyph count exceeds 16384"))?;
|
||||
logical_bytes = logical_bytes.checked_add(run.text.len()).filter(|n| *n <= MAX_LOGICAL_BYTES)
|
||||
.ok_or_else(|| invalid("combined logical text exceeds its allowance"))?;
|
||||
}
|
||||
total(&[
|
||||
bytes::<(u16, &str)>(count)?, bytes::<u16>(count)?, bytes::<CodeToGlyph<'_>>(count)?,
|
||||
bytes::<PlannedGlyph<'_>>(count)?, bytes::<PlannedRun<'_>>(runs.len())?,
|
||||
], limit)?;
|
||||
let mut keys = reserve::<(u16, &str)>(count)?;
|
||||
let mut used = reserve::<u16>(count)?;
|
||||
let mut codes = reserve::<CodeToGlyph<'_>>(count)?;
|
||||
let mut planned_runs = reserve::<PlannedRun<'_>>(runs.len())?;
|
||||
let mut glyphs = reserve::<PlannedGlyph<'_>>(count)?;
|
||||
total(&[
|
||||
bytes::<(u16, &str)>(keys.capacity())?, bytes::<u16>(used.capacity())?, bytes::<CodeToGlyph<'_>>(codes.capacity())?,
|
||||
bytes::<PlannedGlyph<'_>>(glyphs.capacity())?, bytes::<PlannedRun<'_>>(planned_runs.capacity())?,
|
||||
], limit)?;
|
||||
for run in runs {
|
||||
for cluster in &run.clusters {
|
||||
let glyph = run.glyphs.get(cluster.glyphs.start as usize).ok_or_else(|| invalid("checked glyph disappeared"))?;
|
||||
if glyph.id == 0 {
|
||||
return Err(invalid(".notdef cannot replace a missing text glyph"));
|
||||
}
|
||||
let logical = run.text.get(cluster.text.start as usize..cluster.text.end as usize).ok_or_else(|| invalid("checked text disappeared"))?;
|
||||
keys.push((glyph.id, logical));
|
||||
}
|
||||
}
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
for (gid, _) in &keys {
|
||||
if used.last() != Some(gid) {
|
||||
used.push(*gid);
|
||||
}
|
||||
}
|
||||
if !font.glyphs.keys().copied().eq(used.iter().copied()) {
|
||||
return Err(invalid("the font table is not exactly the glyphs of the supplied runs"));
|
||||
}
|
||||
// Reuse the real existing admission, composite closure and subset library.
|
||||
// There is no duplicate raw-font admission hash or copied font implementation.
|
||||
let subset = subset_font(&font.bytes, font.face, &used, max_subset_bytes)?;
|
||||
for (index, (gid, logical)) in keys.iter().enumerate() {
|
||||
let code = u16::try_from(index + 1).map_err(|_| invalid("text codes exceed u16"))?;
|
||||
let at = subset.mapping().binary_search_by_key(gid, |(old, _)| *old).map_err(|_| invalid("subset lost an original glyph"))?;
|
||||
let (_, subset_gid) = subset.mapping().get(at).ok_or_else(|| invalid("subset mapping is incomplete"))?;
|
||||
codes.push(CodeToGlyph { code, subset_gid: *subset_gid, logical });
|
||||
}
|
||||
for run in runs {
|
||||
let first = glyphs.len();
|
||||
let (sin, cos) = run.angle.sin_cos();
|
||||
for logical_index in 0..run.clusters.len() {
|
||||
let at = if run.rtl { run.clusters.len() - 1 - logical_index } else { logical_index };
|
||||
let cluster = run.clusters.get(at).ok_or_else(|| invalid("checked cluster disappeared"))?;
|
||||
let source = cluster.glyphs.start as usize;
|
||||
let glyph = run.glyphs.get(source).ok_or_else(|| invalid("checked glyph disappeared"))?;
|
||||
let logical = run.text.get(cluster.text.start as usize..cluster.text.end as usize).ok_or_else(|| invalid("checked text disappeared"))?;
|
||||
let key = keys.binary_search(&(glyph.id, logical)).map_err(|_| invalid("text code is absent"))?;
|
||||
let code = codes.get(key).ok_or_else(|| invalid("text code is outside its table"))?.code;
|
||||
let matrix_mm = [
|
||||
cos, sin, cos * run.slant - sin, sin * run.slant + cos,
|
||||
run.origin.x + cos * glyph.x - sin * glyph.rise,
|
||||
run.origin.y + sin * glyph.x + cos * glyph.rise,
|
||||
];
|
||||
if !matrix_mm.iter().all(|n| n.is_finite()) {
|
||||
return Err(invalid("glyph transform is not finite"));
|
||||
}
|
||||
glyphs.push(PlannedGlyph {
|
||||
code, original_gid: glyph.id, source_glyph_index: source,
|
||||
intrinsic_advance_fu: intrinsic_advance(font, glyph.id)?, placed_advance_mm: glyph.advance,
|
||||
matrix_mm, logical,
|
||||
});
|
||||
}
|
||||
planned_runs.push(PlannedRun {
|
||||
glyphs: first..glyphs.len(), font_size_mm: run.scale * f64::from(font.units_per_em), rtl: run.rtl, text: &run.text,
|
||||
});
|
||||
}
|
||||
Ok(TextPlan { font, subset, descriptor, codes, runs: planned_runs, glyphs })
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
diff --git a/acceptance/retained-b1-cases.rs b/acceptance/retained-b1-cases.rs
|
||||
--- a/acceptance/retained-b1-cases.rs
|
||||
+++ b/acceptance/retained-b1-cases.rs
|
||||
@@ -40,8 +40,8 @@
|
||||
assert_eq!((g.original_gid, g.intrinsic_advance_fu, g.placed_advance_mm), (57, 1366, 0.5));
|
||||
assert_eq!(g.matrix_mm, [1.0, 0.0, 0.0, 1.0, 0.375, 0.0]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
- assert_eq!(input.postscript_name, b"LiberationSans");
|
||||
- assert_eq!(input.descriptor.unwrap().ascent_1000, 905.2734375);
|
||||
+ assert_eq!(input.source.font().postscript_name.as_bytes(), b"LiberationSans");
|
||||
+ assert_eq!(input.source.descriptor().ascent_1000, 905.2734375);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -52,9 +52,9 @@
|
||||
let plan = super::text_plan::plan_text(&font, &[&second, &first, &first], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs().iter().map(|g| (g.code, g.logical)).collect::<Vec<_>>(), [(2, "B"), (1, "A"), (1, "A")]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
- assert_eq!(input.codes.len(), 2);
|
||||
- assert_eq!(input.codes[0].subset_gid, input.codes[1].subset_gid);
|
||||
- assert_eq!((input.codes[0].logical, input.codes[1].logical), ("A", "B"));
|
||||
+ assert_eq!(input.source.codes().len(), 2);
|
||||
+ assert_eq!(input.source.codes()[0].subset_gid, input.source.codes()[1].subset_gid);
|
||||
+ assert_eq!((input.source.codes()[0].logical, input.source.codes()[1].logical), ("A", "B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -82,7 +82,7 @@
|
||||
run.clusters[0].text = 0..4;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].logical, "fi\u{301}");
|
||||
- assert_eq!(plan.font_input(*b"ABCDEF", 1 << 20).codes[0].logical, "fi\u{301}");
|
||||
+ assert_eq!(plan.font_input(*b"ABCDEF", 1 << 20).source.codes()[0].logical, "fi\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
diff --git a/OWNED_APPENDIX_ONLY_NOT_CAM_pdf_tests.rs b/OWNED_APPENDIX_ONLY_NOT_CAM_pdf_tests.rs
|
||||
--- a/OWNED_APPENDIX_ONLY_NOT_CAM_pdf_tests.rs
|
||||
+++ b/OWNED_APPENDIX_ONLY_NOT_CAM_pdf_tests.rs
|
||||
@@ -7,7 +7,7 @@
|
||||
let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(&include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/liberation-sans/LiberationSans-Regular.ttf"))[..]);
|
||||
let id = Sha256::new().chain_update(bytes.as_ref()).chain_update(0u32.to_le_bytes()).finalize().into();
|
||||
Font {
|
||||
- bundled: 0, id, bytes, face: 0, postscript_name: "LiberationSans".into(), num_glyphs: 2620, units_per_em: 2048,
|
||||
+ id, bytes, face: 0, postscript_name: "LiberationSans".into(), num_glyphs: 2620, units_per_em: 2048,
|
||||
metrics: FontMetrics {
|
||||
ascent: 1854, descent: -434, cap_height: 1409, bbox: [-1114, -621, 2666, 2007],
|
||||
italic_angle: 0.0, flags: FontFlags::default(), stem_v: 180,
|
||||
@@ -21,7 +21,7 @@
|
||||
assert_eq!(text.chars().count(), ids.len());
|
||||
let chars: Vec<_> = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect();
|
||||
TextRun {
|
||||
- font: 0, scale: 1.0 / 2048.0, origin: solidcraft_geom::Vec2::new(0.0, 0.0), angle: 0.0, slant: 0.0, rtl: false,
|
||||
+ font: b1_plan_font(&[]).id, scale: 1.0 / 2048.0, origin: solidcraft_geom::Vec2::new(0.0, 0.0), angle: 0.0, slant: 0.0, rtl: false,
|
||||
glyphs: ids.iter().enumerate().map(|(i, &id)| PlacedGlyph { id, x: i as f64 * 0.375, rise: 0.0, advance: 0.5 }).collect(),
|
||||
text: text.into(),
|
||||
clusters: ids.iter().enumerate().map(|(i, _)| Cluster { glyphs: i as u32..i as u32 + 1, text: chars[i] as u32..chars[i + 1] as u32 }).collect(),
|
||||
@@ -40,8 +40,8 @@
|
||||
assert_eq!((g.original_gid, g.intrinsic_advance_fu, g.placed_advance_mm), (57, 1366, 0.5));
|
||||
assert_eq!(g.matrix_mm, [1.0, 0.0, 0.0, 1.0, 0.375, 0.0]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
- assert_eq!(input.postscript_name, b"LiberationSans");
|
||||
- assert_eq!(input.descriptor.unwrap().ascent_1000, 905.2734375);
|
||||
+ assert_eq!(input.source.font().postscript_name.as_bytes(), b"LiberationSans");
|
||||
+ assert_eq!(input.source.descriptor().ascent_1000, 905.2734375);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -52,9 +52,9 @@
|
||||
let plan = super::text_plan::plan_text(&font, &[&second, &first, &first], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs().iter().map(|g| (g.code, g.logical)).collect::<Vec<_>>(), [(2, "B"), (1, "A"), (1, "A")]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
- assert_eq!(input.codes.len(), 2);
|
||||
- assert_eq!(input.codes[0].subset_gid, input.codes[1].subset_gid);
|
||||
- assert_eq!((input.codes[0].logical, input.codes[1].logical), ("A", "B"));
|
||||
+ assert_eq!(input.source.codes().len(), 2);
|
||||
+ assert_eq!(input.source.codes()[0].subset_gid, input.source.codes()[1].subset_gid);
|
||||
+ assert_eq!((input.source.codes()[0].logical, input.source.codes()[1].logical), ("A", "B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -82,7 +82,7 @@
|
||||
run.clusters[0].text = 0..4;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].logical, "fi\u{301}");
|
||||
- assert_eq!(plan.font_input(*b"ABCDEF", 1 << 20).codes[0].logical, "fi\u{301}");
|
||||
+ assert_eq!(plan.font_input(*b"ABCDEF", 1 << 20).source.codes()[0].logical, "fi\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -161,7 +161,7 @@
|
||||
0 => wrong.clusters[0].glyphs.end = 2,
|
||||
1 => wrong.clusters[0].text.end = 2,
|
||||
2 => wrong.clusters.clear(),
|
||||
- 3 => wrong.font = 1,
|
||||
+ 3 => wrong.font[0] ^= 1,
|
||||
4 => wrong.glyphs[0].x = f64::NAN,
|
||||
_ => { wrong.text = "\u{e9}".into(); wrong.clusters[0].text = 0..1; }
|
||||
}
|
||||
@@ -189,11 +189,11 @@
|
||||
let second = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let a = first.font_input(*b"ABCDEF", 1 << 20);
|
||||
let b = second.font_input(*b"ABCDEF", 1 << 20);
|
||||
- assert_eq!(a.subset.bytes(), b.subset.bytes());
|
||||
- assert_eq!(a.subset.mapping(), [(0, 0), (36, 1), (57, 2)]);
|
||||
- assert_eq!(a.subset.mapping(), b.subset.mapping());
|
||||
- assert_eq!(a.codes.iter().map(|g| (g.code, g.subset_gid, g.logical)).collect::<Vec<_>>(), [(1, 1, "A"), (2, 2, "V")]);
|
||||
- assert_eq!(a.descriptor.unwrap().flags, pdf_writer::types::FontFlags::NON_SYMBOLIC.bits());
|
||||
+ assert_eq!(a.source.subset().bytes(), b.source.subset().bytes());
|
||||
+ assert_eq!(a.source.subset().mapping(), [(0, 0), (36, 1), (57, 2)]);
|
||||
+ assert_eq!(a.source.subset().mapping(), b.source.subset().mapping());
|
||||
+ assert_eq!(a.source.codes().iter().map(|g| (g.code, g.subset_gid, g.logical)).collect::<Vec<_>>(), [(1, 1, "A"), (2, 2, "V")]);
|
||||
+ assert_eq!(a.source.descriptor().flags, pdf_writer::types::FontFlags::NON_SYMBOLIC.bits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
diff --git a/crates/io/src/paper/pdf/text_plan.rs b/crates/io/src/paper/pdf/text_plan.rs
|
||||
--- a/crates/io/src/paper/pdf/text_plan.rs
|
||||
+++ b/crates/io/src/paper/pdf/text_plan.rs
|
||||
@@ -65,6 +65,23 @@
|
||||
glyphs: Vec<PlannedGlyph<'a>>,
|
||||
}
|
||||
|
||||
+/// Borrowed only from a successfully constructed TextPlan. Private fields
|
||||
+/// bind checked source records, the factory-produced subset, descriptor and
|
||||
+/// codes for one immutable plan; no raw-record or serde constructor exists.
|
||||
+pub(super) struct FontPlanView<'a> {
|
||||
+ font: &'a Font,
|
||||
+ subset: &'a SubsetFont,
|
||||
+ descriptor: &'a DescriptorMetrics,
|
||||
+ codes: &'a [CodeToGlyph<'a>],
|
||||
+}
|
||||
+
|
||||
+impl<'a> FontPlanView<'a> {
|
||||
+ pub(super) fn font(&self) -> &'a Font { self.font }
|
||||
+ pub(super) fn subset(&self) -> &'a SubsetFont { self.subset }
|
||||
+ pub(super) fn descriptor(&self) -> &'a DescriptorMetrics { self.descriptor }
|
||||
+ pub(super) fn codes(&self) -> &'a [CodeToGlyph<'a>] { self.codes }
|
||||
+}
|
||||
+
|
||||
impl TextPlan<'_> {
|
||||
pub(super) fn runs(&self) -> &[PlannedRun<'_>] {
|
||||
&self.runs
|
||||
@@ -78,11 +95,12 @@
|
||||
/// globally unique subset tag, page resources and a cumulative output budget.
|
||||
pub(super) fn font_input(&self, subset_tag: [u8; 6], max_chunk_bytes: usize) -> FontObjectInput<'_> {
|
||||
FontObjectInput {
|
||||
- source_font: self.font,
|
||||
- subset: &self.subset,
|
||||
- codes: &self.codes,
|
||||
- descriptor: Some(&self.descriptor),
|
||||
- postscript_name: self.font.postscript_name.as_bytes(),
|
||||
+ source: FontPlanView {
|
||||
+ font: self.font,
|
||||
+ subset: &self.subset,
|
||||
+ descriptor: &self.descriptor,
|
||||
+ codes: &self.codes,
|
||||
+ },
|
||||
subset_tag,
|
||||
max_chunk_bytes,
|
||||
}
|
||||
@@ -159,18 +177,36 @@
|
||||
u16_at(table(&font.bytes, b"hmtx")?, usize::from(gid).min(count - 1) * 4)
|
||||
}
|
||||
|
||||
+/// Current builtin bytes/face only, obtained by the existing None catalog path
|
||||
+/// before any installed-font, filesystem or cache lookup. This does not attest
|
||||
+/// mutable metrics/advances or waive the existing single subset admission.
|
||||
+pub(super) fn check_builtin_source(font: &Font) -> Result<()> {
|
||||
+ if font.bytes.len() != APPROVED_FONT_BYTES {
|
||||
+ return Err(invalid("font byte length is outside the reviewed resource envelope"));
|
||||
+ }
|
||||
+ let builtin = solidcraft_doc::sketch::font_data(None)
|
||||
+ .map_err(|_| invalid("the builtin drawing font is unavailable"))?;
|
||||
+ if builtin.index != 0 || builtin.weight.is_some() || font.face != builtin.index
|
||||
+ || font.bytes.as_ref() != builtin.bytes()
|
||||
+ {
|
||||
+ return Err(invalid("font bytes/face differ from the current builtin drawing font"));
|
||||
+ }
|
||||
+ Ok(())
|
||||
+}
|
||||
+
|
||||
fn descriptor(font: &Font) -> Result<DescriptorMetrics> {
|
||||
// Public Font is hostile input too. Refuse its cheap byte envelope before
|
||||
// hashing or looking through tables, even when the caller's plan budget is tiny.
|
||||
if font.bytes.len() != APPROVED_FONT_BYTES {
|
||||
return Err(invalid("font byte length is outside the reviewed resource envelope"));
|
||||
}
|
||||
+ check_builtin_source(font)?;
|
||||
// B1 hashes bytes + faceLE. The existing subset admission hashes RAW bytes;
|
||||
// their digests are different identities and are never compared to each other.
|
||||
let m = &font.metrics;
|
||||
// Original fixed-resource metadata, not fitted/default descriptor values.
|
||||
// Values come from this exact Liberation Sans head/hhea/OS2/post and l glyph.
|
||||
- if font.bundled != 0 || font.face != 0 || font.postscript_name != "LiberationSans"
|
||||
+ if font.face != 0 || font.postscript_name != "LiberationSans"
|
||||
|| font.num_glyphs != 2620 || font.units_per_em != 2048
|
||||
|| m.bbox != [-1114, -621, 2666, 2007] || m.ascent != 1854 || m.descent != -434
|
||||
|| m.cap_height != 1409 || m.stem_v != 180 || m.italic_angle != 0.0
|
||||
diff --git a/crates/io/src/paper/pdf/font_objects.rs b/crates/io/src/paper/pdf/font_objects.rs
|
||||
--- a/crates/io/src/paper/pdf/font_objects.rs
|
||||
+++ b/crates/io/src/paper/pdf/font_objects.rs
|
||||
@@ -15,10 +15,10 @@
|
||||
use pdf_writer::types::{CidFontType, FontFlags, SystemInfo};
|
||||
use pdf_writer::{Chunk, Name, Rect, Ref, Str};
|
||||
|
||||
-use sha2::{Digest, Sha256};
|
||||
use solidcraft_geom::paper::text::Font;
|
||||
|
||||
use super::subset_font::SubsetFont;
|
||||
+use super::text_plan::{FontPlanView, check_builtin_source};
|
||||
use super::to_unicode::write_to_unicode;
|
||||
use crate::{IoError, MAX_DRAWING_BYTES, Result};
|
||||
|
||||
@@ -140,19 +140,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
-/// IO parameters borrowing the actual B1 resource, not a global allocator.
|
||||
-/// Advances must come from the trusted bundled B1 producer, not JSON assertions;
|
||||
-/// resource identity alone does not attest its public per-glyph fields.
|
||||
+/// Production parameters: the source, subset, descriptor and codes are bound
|
||||
+/// by an opaque view obtainable only from a successful TextPlan.
|
||||
pub(super) struct FontObjectInput<'a> {
|
||||
- pub(super) source_font: &'a Font,
|
||||
- pub(super) subset: &'a SubsetFont,
|
||||
- pub(super) codes: &'a [CodeToGlyph<'a>],
|
||||
- pub(super) descriptor: Option<&'a DescriptorMetrics>,
|
||||
- pub(super) postscript_name: &'a [u8],
|
||||
+ pub(super) source: FontPlanView<'a>,
|
||||
pub(super) subset_tag: [u8; 6],
|
||||
pub(super) max_chunk_bytes: usize,
|
||||
}
|
||||
|
||||
+/// Internal preparation components, never an independently callable production
|
||||
+/// input. Component tests below retain their synthetic scope via private aliases.
|
||||
+struct FontObjectParts<'a> {
|
||||
+ source_font: &'a Font,
|
||||
+ subset: &'a SubsetFont,
|
||||
+ codes: &'a [CodeToGlyph<'a>],
|
||||
+ descriptor: Option<&'a DescriptorMetrics>,
|
||||
+ postscript_name: &'a [u8],
|
||||
+ subset_tag: [u8; 6],
|
||||
+ max_chunk_bytes: usize,
|
||||
+}
|
||||
+
|
||||
fn invalid(message: &'static str) -> IoError {
|
||||
IoError::Invalid(message.into())
|
||||
}
|
||||
@@ -196,23 +203,25 @@
|
||||
impl<'a> B1Widths<'a> {
|
||||
fn read(font: &'a Font, subset: &'a SubsetFont) -> Result<Self> {
|
||||
// Public B1 fields are bounded before digest, table walks or allocation.
|
||||
- // The actual subset factory admits just these source bytes/face and
|
||||
- // validates its returned program. Its private fields cannot be forged
|
||||
- // here. This identity check binds B1 to that same fixed-source policy;
|
||||
- // it does not cryptographically attest caller-modified advance records.
|
||||
+ // Production obtains these bound records only through FontPlanView;
|
||||
+ // TextPlan already checked every used original-GID advance and the
|
||||
+ // single admitted subset chain. These repeated cheap identity/mapping
|
||||
+ // guards also retain the independent synthetic component-test scope.
|
||||
if font.bytes.len() != MAX_FONT_BYTES {
|
||||
return Err(invalid("B1 font byte envelope differs from the reviewed resource"));
|
||||
}
|
||||
- if font.bundled != 0 || font.face != 0 || font.postscript_name.as_bytes() != POSTSCRIPT_NAME
|
||||
+ if font.face != 0 || font.postscript_name.as_bytes() != POSTSCRIPT_NAME
|
||||
|| font.num_glyphs != 2620 || font.units_per_em != 2048
|
||||
|| font.glyphs.len() > usize::from(font.num_glyphs)
|
||||
|| font.glyphs.keys().any(|gid| *gid >= font.num_glyphs)
|
||||
{
|
||||
return Err(invalid("B1 font metadata differs from the reviewed regular face"));
|
||||
}
|
||||
- let identity: [u8; 32] = Sha256::new().chain_update(font.bytes.as_ref())
|
||||
- .chain_update(font.face.to_le_bytes()).finalize().into();
|
||||
- if font.id != identity || identity != REVIEWED_B1_ID {
|
||||
+ // Exact current builtin bytes/face replace the removed numeric index.
|
||||
+ // The plan already checked bytes+face ID and admitted the SAME source
|
||||
+ // through subset_font. Compare the fixed B1 ID here without rehashing.
|
||||
+ check_builtin_source(font)?;
|
||||
+ if font.id != REVIEWED_B1_ID {
|
||||
return Err(invalid("B1 font bytes/face/id do not identify the reviewed resource"));
|
||||
}
|
||||
let glyphs = subset.mapping().len();
|
||||
@@ -295,7 +304,7 @@
|
||||
bound: usize,
|
||||
}
|
||||
|
||||
-fn prepare(input: FontObjectInput<'_>) -> Result<Prepared<'_>> {
|
||||
+fn prepare(input: FontObjectParts<'_>) -> Result<Prepared<'_>> {
|
||||
let descriptor = input.descriptor.ok_or_else(|| invalid("essential font descriptor metrics are missing"))?;
|
||||
let flags = descriptor.validate()?;
|
||||
let name = fixed_font_name(input.postscript_name, input.subset_tag)?;
|
||||
@@ -370,6 +379,21 @@
|
||||
/// Ref IDs/tag globally and later attach the Type0 ref to page resources.
|
||||
/// No caller Pdf is borrowed, so Result refusals cannot mutate that document.
|
||||
pub(super) fn font_objects(refs: FontObjectRefs, input: FontObjectInput<'_>) -> Result<Chunk> {
|
||||
+ let source = input.source;
|
||||
+ font_objects_parts(refs, FontObjectParts {
|
||||
+ source_font: source.font(),
|
||||
+ subset: source.subset(),
|
||||
+ codes: source.codes(),
|
||||
+ descriptor: Some(source.descriptor()),
|
||||
+ postscript_name: source.font().postscript_name.as_bytes(),
|
||||
+ subset_tag: input.subset_tag,
|
||||
+ max_chunk_bytes: input.max_chunk_bytes,
|
||||
+ })
|
||||
+}
|
||||
+
|
||||
+/// The raw preparation path is private to this module. Outside callers cannot
|
||||
+/// supply an independent Font/Subset pair or manufacture a validated view.
|
||||
+fn font_objects_parts(refs: FontObjectRefs, input: FontObjectParts<'_>) -> Result<Chunk> {
|
||||
refs.validate()?;
|
||||
let prepared = prepare(input)?;
|
||||
let Prepared { font, unicode, cid_map, widths, default_width, name, descriptor, flags, limit, bound } = prepared;
|
||||
@@ -423,6 +447,10 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
+ // Synthetic serialization/refusal components are not proof constructors.
|
||||
+ // Only these descendant tests can call the module-private raw parts path.
|
||||
+ use super::{FontObjectParts as FontObjectInput, font_objects_parts as font_objects};
|
||||
+ use sha2::{Digest, Sha256};
|
||||
use super::super::subset_font::subset_font;
|
||||
|
||||
// Unchanged legacy metric reader retained as a test-only independent
|
||||
@@ -555,7 +583,7 @@
|
||||
FONT.get_or_init(|| {
|
||||
use solidcraft_geom::paper::text::{FontFlags as B1Flags, FontGlyph, FontMetrics};
|
||||
Font {
|
||||
- bundled: 0, id: REVIEWED_B1_ID, bytes: std::sync::Arc::from(BUNDLED), face: 0,
|
||||
+ id: REVIEWED_B1_ID, bytes: std::sync::Arc::from(BUNDLED), face: 0,
|
||||
postscript_name: "LiberationSans".into(), num_glyphs: 2620, units_per_em: 2048,
|
||||
metrics: FontMetrics {
|
||||
ascent: 1854, descent: -434, cap_height: 1409, bbox: [-1114, -621, 2666, 2007],
|
||||
@@ -1144,7 +1172,7 @@
|
||||
let mut font = source_font().clone();
|
||||
match case {
|
||||
0 => font.face = 1,
|
||||
- 1 => font.bundled = 1,
|
||||
+ 1 => { let mut bytes = font.bytes.to_vec(); bytes[4096] ^= 1; font.bytes = std::sync::Arc::from(bytes); },
|
||||
2 => font.units_per_em = 0,
|
||||
3 => font.units_per_em = 1024,
|
||||
4 => font.num_glyphs = 0,
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
diff --git a/crates/io/src/paper/pdf/text_plan.rs b/crates/io/src/paper/pdf/text_plan.rs
|
||||
--- a/crates/io/src/paper/pdf/text_plan.rs
|
||||
+++ b/crates/io/src/paper/pdf/text_plan.rs
|
||||
@@ -65,6 +65,23 @@
|
||||
glyphs: Vec<PlannedGlyph<'a>>,
|
||||
}
|
||||
|
||||
+/// Borrowed only from a successfully constructed TextPlan. Private fields
|
||||
+/// bind checked source records, the factory-produced subset, descriptor and
|
||||
+/// codes for one immutable plan; no raw-record or serde constructor exists.
|
||||
+pub(super) struct FontPlanView<'a> {
|
||||
+ font: &'a Font,
|
||||
+ subset: &'a SubsetFont,
|
||||
+ descriptor: &'a DescriptorMetrics,
|
||||
+ codes: &'a [CodeToGlyph<'a>],
|
||||
+}
|
||||
+
|
||||
+impl<'a> FontPlanView<'a> {
|
||||
+ pub(super) fn font(&self) -> &'a Font { self.font }
|
||||
+ pub(super) fn subset(&self) -> &'a SubsetFont { self.subset }
|
||||
+ pub(super) fn descriptor(&self) -> &'a DescriptorMetrics { self.descriptor }
|
||||
+ pub(super) fn codes(&self) -> &'a [CodeToGlyph<'a>] { self.codes }
|
||||
+}
|
||||
+
|
||||
impl TextPlan<'_> {
|
||||
pub(super) fn runs(&self) -> &[PlannedRun<'_>] {
|
||||
&self.runs
|
||||
@@ -78,11 +95,12 @@
|
||||
/// globally unique subset tag, page resources and a cumulative output budget.
|
||||
pub(super) fn font_input(&self, subset_tag: [u8; 6], max_chunk_bytes: usize) -> FontObjectInput<'_> {
|
||||
FontObjectInput {
|
||||
- source_font: self.font,
|
||||
- subset: &self.subset,
|
||||
- codes: &self.codes,
|
||||
- descriptor: Some(&self.descriptor),
|
||||
- postscript_name: self.font.postscript_name.as_bytes(),
|
||||
+ source: FontPlanView {
|
||||
+ font: self.font,
|
||||
+ subset: &self.subset,
|
||||
+ descriptor: &self.descriptor,
|
||||
+ codes: &self.codes,
|
||||
+ },
|
||||
subset_tag,
|
||||
max_chunk_bytes,
|
||||
}
|
||||
@@ -159,18 +177,36 @@
|
||||
u16_at(table(&font.bytes, b"hmtx")?, usize::from(gid).min(count - 1) * 4)
|
||||
}
|
||||
|
||||
+/// Current builtin bytes/face only, obtained by the existing None catalog path
|
||||
+/// before any installed-font, filesystem or cache lookup. This does not attest
|
||||
+/// mutable metrics/advances or waive the existing single subset admission.
|
||||
+pub(super) fn check_builtin_source(font: &Font) -> Result<()> {
|
||||
+ if font.bytes.len() != APPROVED_FONT_BYTES {
|
||||
+ return Err(invalid("font byte length is outside the reviewed resource envelope"));
|
||||
+ }
|
||||
+ let builtin = solidcraft_doc::sketch::font_data(None)
|
||||
+ .map_err(|_| invalid("the builtin drawing font is unavailable"))?;
|
||||
+ if builtin.index != 0 || builtin.weight.is_some() || font.face != builtin.index
|
||||
+ || font.bytes.as_ref() != builtin.bytes()
|
||||
+ {
|
||||
+ return Err(invalid("font bytes/face differ from the current builtin drawing font"));
|
||||
+ }
|
||||
+ Ok(())
|
||||
+}
|
||||
+
|
||||
fn descriptor(font: &Font) -> Result<DescriptorMetrics> {
|
||||
// Public Font is hostile input too. Refuse its cheap byte envelope before
|
||||
// hashing or looking through tables, even when the caller's plan budget is tiny.
|
||||
if font.bytes.len() != APPROVED_FONT_BYTES {
|
||||
return Err(invalid("font byte length is outside the reviewed resource envelope"));
|
||||
}
|
||||
+ check_builtin_source(font)?;
|
||||
// B1 hashes bytes + faceLE. The existing subset admission hashes RAW bytes;
|
||||
// their digests are different identities and are never compared to each other.
|
||||
let m = &font.metrics;
|
||||
// Original fixed-resource metadata, not fitted/default descriptor values.
|
||||
// Values come from this exact Liberation Sans head/hhea/OS2/post and l glyph.
|
||||
- if font.bundled != 0 || font.face != 0 || font.postscript_name != "LiberationSans"
|
||||
+ if font.face != 0 || font.postscript_name != "LiberationSans"
|
||||
|| font.num_glyphs != 2620 || font.units_per_em != 2048
|
||||
|| m.bbox != [-1114, -621, 2666, 2007] || m.ascent != 1854 || m.descent != -434
|
||||
|| m.cap_height != 1409 || m.stem_v != 180 || m.italic_angle != 0.0
|
||||
@@ -197,7 +233,7 @@
|
||||
/// Check the actual B1 cluster partition and the narrower first P2 contract
|
||||
/// BEFORE allocating or subsetting. Returning an error exposes no partial plan.
|
||||
fn check_run(font: &Font, run: &TextRun) -> Result<usize> {
|
||||
- if run.font != font.bundled || !(1..=4000).contains(&run.glyphs.len()) || run.text.len() > 4000 {
|
||||
+ if run.font != font.id || !(1..=4000).contains(&run.glyphs.len()) || run.text.len() > 4000 {
|
||||
return Err(invalid("run font, glyph count or text length is outside B1"));
|
||||
}
|
||||
let cap = run.scale * f64::from(font.metrics.cap_height);
|
||||
@@ -241,6 +277,22 @@
|
||||
return Err(invalid("clusters do not cover the whole run exactly once"));
|
||||
}
|
||||
Ok(run.glyphs.len())
|
||||
+}
|
||||
+
|
||||
+/// Resolve an F0 run through the already validated Scene table, never by a
|
||||
+/// bundled number or Vec index. This increment still plans ONE font and all
|
||||
+/// its runs; mixed-font input is rejected by the existing plan_text checks.
|
||||
+/// Preparation is not proof of PDF embedding permission or trusted advances;
|
||||
+/// the existing owned admission/provenance boundary remains mandatory.
|
||||
+pub(super) fn plan_prepared_text<'a>(
|
||||
+ prepared: &solidcraft_geom::paper::Prepared<'a>,
|
||||
+ runs: &[&'a TextRun],
|
||||
+ max_plan_bytes: usize,
|
||||
+ max_subset_bytes: usize,
|
||||
+) -> Result<TextPlan<'a>> {
|
||||
+ let first = runs.first().copied().ok_or_else(|| invalid("empty allowance or unsupported run count"))?;
|
||||
+ let font = prepared.font(first).ok_or_else(|| invalid("run font ID is absent from the prepared scene"))?;
|
||||
+ plan_text(font, runs, max_plan_bytes, max_subset_bytes)
|
||||
}
|
||||
|
||||
/// One bundled font and ALL its runs, in their existing paint order. Code keys
|
||||
diff --git a/crates/io/src/paper/pdf/font_objects.rs b/crates/io/src/paper/pdf/font_objects.rs
|
||||
--- a/crates/io/src/paper/pdf/font_objects.rs
|
||||
+++ b/crates/io/src/paper/pdf/font_objects.rs
|
||||
@@ -15,10 +15,10 @@
|
||||
use pdf_writer::types::{CidFontType, FontFlags, SystemInfo};
|
||||
use pdf_writer::{Chunk, Name, Rect, Ref, Str};
|
||||
|
||||
-use sha2::{Digest, Sha256};
|
||||
use solidcraft_geom::paper::text::Font;
|
||||
|
||||
use super::subset_font::SubsetFont;
|
||||
+use super::text_plan::{FontPlanView, check_builtin_source};
|
||||
use super::to_unicode::write_to_unicode;
|
||||
use crate::{IoError, MAX_DRAWING_BYTES, Result};
|
||||
|
||||
@@ -140,19 +140,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
-/// IO parameters borrowing the actual B1 resource, not a global allocator.
|
||||
-/// Advances must come from the trusted bundled B1 producer, not JSON assertions;
|
||||
-/// resource identity alone does not attest its public per-glyph fields.
|
||||
+/// Production parameters: the source, subset, descriptor and codes are bound
|
||||
+/// by an opaque view obtainable only from a successful TextPlan.
|
||||
pub(super) struct FontObjectInput<'a> {
|
||||
- pub(super) source_font: &'a Font,
|
||||
- pub(super) subset: &'a SubsetFont,
|
||||
- pub(super) codes: &'a [CodeToGlyph<'a>],
|
||||
- pub(super) descriptor: Option<&'a DescriptorMetrics>,
|
||||
- pub(super) postscript_name: &'a [u8],
|
||||
+ pub(super) source: FontPlanView<'a>,
|
||||
pub(super) subset_tag: [u8; 6],
|
||||
pub(super) max_chunk_bytes: usize,
|
||||
}
|
||||
|
||||
+/// Internal preparation components, never an independently callable production
|
||||
+/// input. Component tests below retain their synthetic scope via private aliases.
|
||||
+struct FontObjectParts<'a> {
|
||||
+ source_font: &'a Font,
|
||||
+ subset: &'a SubsetFont,
|
||||
+ codes: &'a [CodeToGlyph<'a>],
|
||||
+ descriptor: Option<&'a DescriptorMetrics>,
|
||||
+ postscript_name: &'a [u8],
|
||||
+ subset_tag: [u8; 6],
|
||||
+ max_chunk_bytes: usize,
|
||||
+}
|
||||
+
|
||||
fn invalid(message: &'static str) -> IoError {
|
||||
IoError::Invalid(message.into())
|
||||
}
|
||||
@@ -196,23 +203,25 @@
|
||||
impl<'a> B1Widths<'a> {
|
||||
fn read(font: &'a Font, subset: &'a SubsetFont) -> Result<Self> {
|
||||
// Public B1 fields are bounded before digest, table walks or allocation.
|
||||
- // The actual subset factory admits just these source bytes/face and
|
||||
- // validates its returned program. Its private fields cannot be forged
|
||||
- // here. This identity check binds B1 to that same fixed-source policy;
|
||||
- // it does not cryptographically attest caller-modified advance records.
|
||||
+ // Production obtains these bound records only through FontPlanView;
|
||||
+ // TextPlan already checked every used original-GID advance and the
|
||||
+ // single admitted subset chain. These repeated cheap identity/mapping
|
||||
+ // guards also retain the independent synthetic component-test scope.
|
||||
if font.bytes.len() != MAX_FONT_BYTES {
|
||||
return Err(invalid("B1 font byte envelope differs from the reviewed resource"));
|
||||
}
|
||||
- if font.bundled != 0 || font.face != 0 || font.postscript_name.as_bytes() != POSTSCRIPT_NAME
|
||||
+ if font.face != 0 || font.postscript_name.as_bytes() != POSTSCRIPT_NAME
|
||||
|| font.num_glyphs != 2620 || font.units_per_em != 2048
|
||||
|| font.glyphs.len() > usize::from(font.num_glyphs)
|
||||
|| font.glyphs.keys().any(|gid| *gid >= font.num_glyphs)
|
||||
{
|
||||
return Err(invalid("B1 font metadata differs from the reviewed regular face"));
|
||||
}
|
||||
- let identity: [u8; 32] = Sha256::new().chain_update(font.bytes.as_ref())
|
||||
- .chain_update(font.face.to_le_bytes()).finalize().into();
|
||||
- if font.id != identity || identity != REVIEWED_B1_ID {
|
||||
+ // Exact current builtin bytes/face replace the removed numeric index.
|
||||
+ // The plan already checked bytes+face ID and admitted the SAME source
|
||||
+ // through subset_font. Compare the fixed B1 ID here without rehashing.
|
||||
+ check_builtin_source(font)?;
|
||||
+ if font.id != REVIEWED_B1_ID {
|
||||
return Err(invalid("B1 font bytes/face/id do not identify the reviewed resource"));
|
||||
}
|
||||
let glyphs = subset.mapping().len();
|
||||
@@ -295,7 +304,7 @@
|
||||
bound: usize,
|
||||
}
|
||||
|
||||
-fn prepare(input: FontObjectInput<'_>) -> Result<Prepared<'_>> {
|
||||
+fn prepare(input: FontObjectParts<'_>) -> Result<Prepared<'_>> {
|
||||
let descriptor = input.descriptor.ok_or_else(|| invalid("essential font descriptor metrics are missing"))?;
|
||||
let flags = descriptor.validate()?;
|
||||
let name = fixed_font_name(input.postscript_name, input.subset_tag)?;
|
||||
@@ -370,6 +379,21 @@
|
||||
/// Ref IDs/tag globally and later attach the Type0 ref to page resources.
|
||||
/// No caller Pdf is borrowed, so Result refusals cannot mutate that document.
|
||||
pub(super) fn font_objects(refs: FontObjectRefs, input: FontObjectInput<'_>) -> Result<Chunk> {
|
||||
+ let source = input.source;
|
||||
+ font_objects_parts(refs, FontObjectParts {
|
||||
+ source_font: source.font(),
|
||||
+ subset: source.subset(),
|
||||
+ codes: source.codes(),
|
||||
+ descriptor: Some(source.descriptor()),
|
||||
+ postscript_name: source.font().postscript_name.as_bytes(),
|
||||
+ subset_tag: input.subset_tag,
|
||||
+ max_chunk_bytes: input.max_chunk_bytes,
|
||||
+ })
|
||||
+}
|
||||
+
|
||||
+/// The raw preparation path is private to this module. Outside callers cannot
|
||||
+/// supply an independent Font/Subset pair or manufacture a validated view.
|
||||
+fn font_objects_parts(refs: FontObjectRefs, input: FontObjectParts<'_>) -> Result<Chunk> {
|
||||
refs.validate()?;
|
||||
let prepared = prepare(input)?;
|
||||
let Prepared { font, unicode, cid_map, widths, default_width, name, descriptor, flags, limit, bound } = prepared;
|
||||
@@ -423,6 +447,10 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
+ // Synthetic serialization/refusal components are not proof constructors.
|
||||
+ // Only these descendant tests can call the module-private raw parts path.
|
||||
+ use super::{FontObjectParts as FontObjectInput, font_objects_parts as font_objects};
|
||||
+ use sha2::{Digest, Sha256};
|
||||
use super::super::subset_font::subset_font;
|
||||
|
||||
// Unchanged legacy metric reader retained as a test-only independent
|
||||
@@ -555,7 +583,7 @@
|
||||
FONT.get_or_init(|| {
|
||||
use solidcraft_geom::paper::text::{FontFlags as B1Flags, FontGlyph, FontMetrics};
|
||||
Font {
|
||||
- bundled: 0, id: REVIEWED_B1_ID, bytes: std::sync::Arc::from(BUNDLED), face: 0,
|
||||
+ id: REVIEWED_B1_ID, bytes: std::sync::Arc::from(BUNDLED), face: 0,
|
||||
postscript_name: "LiberationSans".into(), num_glyphs: 2620, units_per_em: 2048,
|
||||
metrics: FontMetrics {
|
||||
ascent: 1854, descent: -434, cap_height: 1409, bbox: [-1114, -621, 2666, 2007],
|
||||
@@ -1144,7 +1172,7 @@
|
||||
let mut font = source_font().clone();
|
||||
match case {
|
||||
0 => font.face = 1,
|
||||
- 1 => font.bundled = 1,
|
||||
+ 1 => { let mut bytes = font.bytes.to_vec(); bytes[4096] ^= 1; font.bytes = std::sync::Arc::from(bytes); },
|
||||
2 => font.units_per_em = 0,
|
||||
3 => font.units_per_em = 1024,
|
||||
4 => font.num_glyphs = 0,
|
||||
@@ -0,0 +1,261 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"repository": "tangency-labs/solidcraft",
|
||||
"status": "SOURCE_ONLY_UNFORMATTED_UNCOMPILED_UNRUN_UNREGISTERED",
|
||||
"producer": {
|
||||
"B1a_PR": 9512,
|
||||
"B1a_head": "16c9e9119214a3595b1303e2f1116cd067986c38",
|
||||
"B1b_PR": 9513,
|
||||
"B1b_head": "a46d46e95a7f5c7680ae7440d3c9989846947128",
|
||||
"drawings_base": "562bb379a5b426506d7fc75cc069bc0936044671",
|
||||
"owner_reply": "https://github.com/tangency-labs/solidcraft/issues/4890#issuecomment-6532962216",
|
||||
"contract": "TextRun.font names Font.id; paper_text returns unique used Vec<Font>; no public Font.bundled or invented Font.embed.",
|
||||
"Font_embed": "F2 pending; not present in this proposal"
|
||||
},
|
||||
"lineage": {
|
||||
"public_predecessor_commit": "5f127aceb09f04dcfe6e4dc1d0253a292787dffa",
|
||||
"public_predecessor_url": "https://github.com/tangency-labs/solidcraft/tree/5f127aceb09f04dcfe6e4dc1d0253a292787dffa/proposals/drawings/2026-10-30/P2-width-Refs-composition",
|
||||
"private_source_v3_manifest_sha256": "1b6f64a24513cb069efd76708fd8788dd29ff09da802ea662c1a60da2bbc24ed",
|
||||
"v2_manifest_sha256": "6795b37c0189fed058a04ecf618e50cf34c6bc3952b2a14b62a0ff97d1b323f6",
|
||||
"canonical_private_predecessor_manifest_sha256": "c1981fe32538252f848da8ef1bb1bab440cc97b826308b36e5bfd6070a0f5262",
|
||||
"alternative_partial_manifest_sha256": "a1bc785b5fa6fb39f8a07359631110de5ccf30ec50464a0e48d1315a073a953e",
|
||||
"no_single_composed_Git_head": true
|
||||
},
|
||||
"source_reviews": {
|
||||
"prior_product_boundary_review_sha256": "eddb84822dda988afac07d3eae718b2d7dc90a2a078b6f815af9fbb34955b2e0",
|
||||
"v2_review_sha256": "faf09fbe4b49279770681338e417a5d36b58fd7e0f2416712fa80270f952a649",
|
||||
"v3_focal_accessor_closure_sha256": "335195903fca30866b1dbef23417b191439bee063e6bf35487858151c40c1fcf",
|
||||
"scope": "The single accessor source finding is closed. Two product candidates/patches are unchanged from v2; the focal closure is not a fresh compiler, negative-compilation result, PDF result or full-feature review."
|
||||
},
|
||||
"product_candidates": [
|
||||
{
|
||||
"path": "candidate/crates/io/src/paper/pdf/text_plan.rs",
|
||||
"sha256": "eddb5120912b934c41fd58cea345c9cc8e74f067995336da8401737a9a2fc027",
|
||||
"bytes": 18699
|
||||
},
|
||||
{
|
||||
"path": "candidate/crates/io/src/paper/pdf/font_objects.rs",
|
||||
"sha256": "393ed32fb7a7633fa949ad6758fe9989cab4633ea3771752886c3404558d8a33",
|
||||
"bytes": 58307
|
||||
}
|
||||
],
|
||||
"patch_routes": {
|
||||
"canonical": {
|
||||
"patch": "deltas/product-from-canonical.patch",
|
||||
"baselines": {
|
||||
"crates/io/src/paper/pdf/text_plan.rs": "before/canonical/crates/io/src/paper/pdf/text_plan.rs",
|
||||
"crates/io/src/paper/pdf/font_objects.rs": "before/canonical/crates/io/src/paper/pdf/font_objects.rs"
|
||||
}
|
||||
},
|
||||
"alternative_partial": {
|
||||
"patch": "deltas/product-from-alternative-partial.patch",
|
||||
"baselines": {
|
||||
"crates/io/src/paper/pdf/text_plan.rs": "before/alternative-partial/crates/io/src/paper/pdf/text_plan.rs",
|
||||
"crates/io/src/paper/pdf/font_objects.rs": "before/canonical/crates/io/src/paper/pdf/font_objects.rs"
|
||||
}
|
||||
},
|
||||
"retained_acceptance_from_original": {
|
||||
"patch": "deltas/acceptance-from-owned-appendix.patch",
|
||||
"logical_patch_filename": "OWNED_APPENDIX_ONLY_NOT_CAM_pdf_tests.rs",
|
||||
"baseline": "before/acceptance/owned-b1-cases.rs",
|
||||
"candidate": "acceptance/retained-b1-cases.rs"
|
||||
},
|
||||
"retained_acceptance_from_v2": {
|
||||
"patch": "deltas/acceptance-accessor-fix-from-v2.patch",
|
||||
"logical_patch_filename": "acceptance/retained-b1-cases.rs",
|
||||
"baseline": "before/acceptance/v2-retained-b1-cases.rs",
|
||||
"candidate": "acceptance/retained-b1-cases.rs"
|
||||
},
|
||||
"alternatives_not_sequential": true,
|
||||
"mixed_per_file_component_bases": true,
|
||||
"normal_existing_test_file_adoption_required": true
|
||||
},
|
||||
"acceptance": {
|
||||
"ordinary_declarations": {
|
||||
"existing_font_objects_inline": 24,
|
||||
"retained_B1": 13,
|
||||
"F0_wrapper": 4,
|
||||
"production_boundary": 5,
|
||||
"total": 46
|
||||
},
|
||||
"negative_compile_obligations": 4,
|
||||
"negative_file": "acceptance/negative-compile-obligations.rs",
|
||||
"negative_obligations_are_not_ordinary_tests": true,
|
||||
"negative_diagnostics_expected_not_observed": true,
|
||||
"ordinary_cases_not_reclassified_as_negative": true,
|
||||
"v3_delta": "Exactly6 normal acceptance lines/eight field reads use existing immutable view getters; no product/oracle/fixture/expected-value/seed/tolerance/budget change.",
|
||||
"all_registered": false,
|
||||
"all_compiled": false,
|
||||
"all_executed": false
|
||||
},
|
||||
"checked_boundary": {
|
||||
"mint": "TextPlan::font_input only constructs FontPlanView after plan_text returns successfully.",
|
||||
"view_private_fields": [
|
||||
"font",
|
||||
"subset",
|
||||
"descriptor",
|
||||
"codes"
|
||||
],
|
||||
"public_production_input": [
|
||||
"source:FontPlanView",
|
||||
"subset_tag",
|
||||
"max_chunk_bytes"
|
||||
],
|
||||
"raw_parts": "FontObjectParts and font_objects_parts are module-private, no safe sibling/public/serde entry; descendant component tests use explicit private aliases, never mint the view.",
|
||||
"same_immutable_source_subset": true,
|
||||
"checks_preserved": [
|
||||
"cheap byte/record allowances",
|
||||
"exact descriptor metadata",
|
||||
"bytes+face B1 ID",
|
||||
"original-GID FontGlyph.advance vs original hmtx",
|
||||
"exact used-glyph table",
|
||||
"one subset_font\u2192glyph_closure\u2192check_liberation_sans RAW admission",
|
||||
"subset mapping/program guards",
|
||||
"all Ref/tag/code/finite/missing/DW/output caps"
|
||||
]
|
||||
},
|
||||
"builtin_source_identity": {
|
||||
"api": "solidcraft_doc::sketch::font_data(None)",
|
||||
"fields": "fresh index0/weightNone; bounded original-byte slice equality and face equality",
|
||||
"before_filesystem_or_cache": true,
|
||||
"substitution_or_fallback": false,
|
||||
"new_parser": false,
|
||||
"new_admission": false,
|
||||
"duplicate_hash": false,
|
||||
"B1_hash_in_textplan": "existing bytes||faceLE check retained",
|
||||
"B1_hash_in_fontobjects": "removed redundant recomputation; fixed reviewedB1ID and exact builtin source comparison retained",
|
||||
"RAW_SHA256": "075e71be4c58beda172ecb574a799308f04c3d054c6a5b7434954d3b0f1b28d8",
|
||||
"B1_ID": "af02dbc8839b7cadae15e528aa3d9ff85c545b715b551d218bf38a937ee7e2b0",
|
||||
"licence_or_F2_waiver": false
|
||||
},
|
||||
"unmodified_source_admission": {
|
||||
"font_admission": {
|
||||
"repository_path": "crates/io/src/paper/pdf/font_admission.rs",
|
||||
"sha256": "a8abc3b4aaef9325694ec404512c228f9973b82006b5718eb304ef57d5c30b23",
|
||||
"bytes": 4170
|
||||
},
|
||||
"glyph_closure": {
|
||||
"repository_path": "crates/io/src/paper/pdf/glyph_closure.rs",
|
||||
"sha256": "a8d94c9996a5c718346e66d1983e6d2f2edb224286d3f45e92d731ab0013f1d8",
|
||||
"bytes": 38397
|
||||
},
|
||||
"subset_font": {
|
||||
"repository_path": "crates/io/src/paper/pdf/subset_font.rs",
|
||||
"sha256": "a9808063f2223fd9e86e208d55092b6b9e9a5ae7ba0eaba2dce5d557a7fd5f26",
|
||||
"bytes": 8941
|
||||
}
|
||||
},
|
||||
"upstream_source_pins": [
|
||||
{
|
||||
"path": "b1a-text",
|
||||
"repository_path": "crates/geom/src/paper/text.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/16c9e9119214a3595b1303e2f1116cd067986c38/crates/geom/src/paper/text.rs",
|
||||
"blob_sha1": "f16f1e703323a11a0dfbd17de680278006aca997",
|
||||
"sha256": "8b813342836802bdd6f7955ae26bcc3a605c2e9e6e358e4a2097c330d860eb65",
|
||||
"bytes": 14124
|
||||
},
|
||||
{
|
||||
"path": "f0-text",
|
||||
"repository_path": "crates/geom/src/paper/text.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/geom/src/paper/text.rs",
|
||||
"blob_sha1": "f16f1e703323a11a0dfbd17de680278006aca997",
|
||||
"sha256": "8b813342836802bdd6f7955ae26bcc3a605c2e9e6e358e4a2097c330d860eb65",
|
||||
"bytes": 14124
|
||||
},
|
||||
{
|
||||
"path": "f0-paper",
|
||||
"repository_path": "crates/geom/src/paper.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/geom/src/paper.rs",
|
||||
"blob_sha1": "f8f924b0529255efed81b5d2eaa00ae6e8692cb6",
|
||||
"sha256": "5b9fb55ddcfb1d1fa488622dfc69b6d2ee5545943e0d453645aaf8b421bf75d5",
|
||||
"bytes": 10593
|
||||
},
|
||||
{
|
||||
"path": "f0-layout",
|
||||
"repository_path": "crates/sketch/src/paper_text.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/sketch/src/paper_text.rs",
|
||||
"blob_sha1": "63a73ef4c34fe9a1a206e60dd9527b90744b078f",
|
||||
"sha256": "6b3dcb84ba8cdac8b50ee264443b0ac5e27296a1cd02f48ac74a5b2e80908553",
|
||||
"bytes": 10871
|
||||
},
|
||||
{
|
||||
"path": "f0-layout-tests",
|
||||
"repository_path": "crates/sketch/src/paper_text_tests.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/sketch/src/paper_text_tests.rs",
|
||||
"blob_sha1": "bab262b09dd31d93df6e50fe7cc7ca5385a28c60",
|
||||
"sha256": "6c06017f6afbeae37856e7298135a03efdad85b4b1ab89b928deb425828b88a4",
|
||||
"bytes": 9807
|
||||
},
|
||||
{
|
||||
"path": "f0-record-tests",
|
||||
"repository_path": "crates/geom/src/paper/text_tests.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/geom/src/paper/text_tests.rs",
|
||||
"blob_sha1": "16ffc7677f85ff0aff8a6ca67bc30e21cfffdfbc",
|
||||
"sha256": "0799c62e97576c1483437a15d72c58fe7dda1441e21019405b4cb65d90bd2ce3",
|
||||
"bytes": 12753
|
||||
},
|
||||
{
|
||||
"path": "font_admission.rs",
|
||||
"repository_path": "crates/io/src/paper/pdf/font_admission.rs",
|
||||
"url": "https://github.com/tangency-labs/solidcraft/blob/bf2a6fd567b03e1d25dcb66624e719a0cec109fc/crates/io/src/paper/pdf/font_admission.rs",
|
||||
"blob_sha1": "632d21b81d25626dd5fe6e3b28d274a96a640382",
|
||||
"sha256": "a8abc3b4aaef9325694ec404512c228f9973b82006b5718eb304ef57d5c30b23",
|
||||
"bytes": 4170
|
||||
},
|
||||
{
|
||||
"path": "subset_font.rs",
|
||||
"repository_path": "crates/io/src/paper/pdf/subset_font.rs",
|
||||
"url": "https://github.com/tangency-labs/solidcraft/blob/bf2a6fd567b03e1d25dcb66624e719a0cec109fc/crates/io/src/paper/pdf/subset_font.rs",
|
||||
"blob_sha1": "cef7bd532639faf95f5ec5f9ea7451b79b03c688",
|
||||
"sha256": "a9808063f2223fd9e86e208d55092b6b9e9a5ae7ba0eaba2dce5d557a7fd5f26",
|
||||
"bytes": 8941
|
||||
},
|
||||
{
|
||||
"path": "public-sources/9513-a46d/crates/sketch/src/fonts.rs",
|
||||
"repository_path": "crates/sketch/src/fonts.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/sketch/src/fonts.rs",
|
||||
"blob_sha1": "4b104ec49eac9516dc2f69aa56fff779eccdb7f2",
|
||||
"sha256": "f497592266ef11a4071d550277cf61c75ce4d2e6e40e4e0e3b8da6e06969c3d0",
|
||||
"bytes": 22385
|
||||
},
|
||||
{
|
||||
"path": "public-sources/9513-a46d/crates/doc/src/lib.rs",
|
||||
"repository_path": "crates/doc/src/lib.rs",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/doc/src/lib.rs",
|
||||
"blob_sha1": "0123c4bac25559c19a9f3e9c0043dfbc46dd3b3d",
|
||||
"sha256": "e67a61f9ef907a36105d0689a7808cad3e44175a14607d3f78a246af7e81a882",
|
||||
"bytes": 4091
|
||||
},
|
||||
{
|
||||
"path": "public-sources/9513-a46d/crates/io/Cargo.toml",
|
||||
"repository_path": "crates/io/Cargo.toml",
|
||||
"url": "https://github.com/CamWight/solidcraft/blob/a46d46e95a7f5c7680ae7440d3c9989846947128/crates/io/Cargo.toml",
|
||||
"blob_sha1": "e9a701fe1a60be6b30e083a911554a78a2e15b2e",
|
||||
"sha256": "e19c93f548baf29442657ec74e5307bccceaccf202491d80f192ed234bb55ce2",
|
||||
"bytes": 1467
|
||||
}
|
||||
],
|
||||
"Cam_parent_and_allocator": "Actual9647ce505658 parent/Refs remains untouched; no content/registration or old-byte result is supplied by this two-file boundary proposal.",
|
||||
"DW_single_question": {
|
||||
"url": "https://github.com/tangency-labs/solidcraft/pull/7626#issuecomment-6532370208",
|
||||
"status": "Answered by Cam6534292604; v3 was sealed before this decision.",
|
||||
"reply_url": "https://github.com/tangency-labs/solidcraft/pull/7626#issuecomment-6534292604"
|
||||
},
|
||||
"authorship": "Worker #1 and Engineer team original proposal/acceptance; referenced Cam/P2/font code retains existing original authorship and licences. No binary font/asset or new dependency included.",
|
||||
"owner_DW_decision_now": {
|
||||
"url": "https://github.com/tangency-labs/solidcraft/pull/7626#issuecomment-6534292604",
|
||||
"author": "CamWight",
|
||||
"created_at": "2026-10-30T21:53:12Z",
|
||||
"policy": "Complete explicit W for every emitted CID, no reliance on DW, no B1 GID0 width. Embedded program .notdef is separate.",
|
||||
"status": "OWNER_CONTRACT_RESOLVED_SUCCESSOR_IMPLEMENTATION_PENDING",
|
||||
"archived_v3_product_unchanged": true,
|
||||
"new_question_posted": false
|
||||
},
|
||||
"archived_v3_implementation_limits": [
|
||||
"V3 still refuses missing GID0. The owner has NOW resolved the policy as complete W for every emitted CID without DW fallback; separate v4 implementation/validation is pending. No product change in this payload.",
|
||||
"Parent module/content/font-resource registration, all-runs-per-font grouping and aggregate budgets remain outside this change.",
|
||||
"Multi-glyph ActualText/extraction remains explicitly unsupported.",
|
||||
"Exact integration/dependency/lock/compiler/native/wasm/CI/render/readback/old-vs-new PDF byte acceptance remain unrun.",
|
||||
"Embedding/package licensing and future Font.embed F2 remain separate; source view certifies only performed checks, not arbitrary outlines or historical origin of all public data."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# Source-only P2/B1 adaptation notes
|
||||
|
||||
The owner-proposed B1 intrinsic-width contract is resolved; actual B1 code is not yet delivered. These notes inspect existing private P2 code and introduce no API or implementation. The DW policy at the end is still under review: ISO32000-1 Table117 makes DW optional with default1000; complete explicit W coverage of emitted CIDs may remove the need for a separate .notdef width. This does not excuse missing widths for any emitted code or an absent content-coverage check. See the [official PDF standard](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf). No implementation/test result is claimed.
|
||||
|
||||
# P2 adaptation delta to Cam's proposed B1 font records
|
||||
|
||||
Private design/source delta only. No new signature, public API, record implementation, P2 source edit or executable B1 provider is supplied. No compiler, formatter, Cargo, renderer, GUI, Git or GitHub write.
|
||||
|
||||
## Authority and exact inspected sources
|
||||
|
||||
Cam's [B1 records before code](https://github.com/tangency-labs/solidcraft/issues/4890#issuecomment-6517001980), amended by [the concrete advance-width answer](https://github.com/tangency-labs/solidcraft/issues/4890#issuecomment-6518550794). The width question is ANSWERED. These are owner-proposed records; Cam explicitly says no code has been built.
|
||||
|
||||
Current proposed resource fields relevant here:
|
||||
- Font: id, bytes, face, postscript_name, num_glyphs, units_per_em, metrics, glyphs.
|
||||
- Font.glyphs: BTreeMap<u16, FontGlyph>, keyed by ORIGINAL GID from the exact admitted face.
|
||||
- FontGlyph: advance:u16 (intrinsic hmtx font units), outline:Vec<Vec<Seg2>>.
|
||||
- TextRun: font table index, scale/origin/angle/slant, placed glyphs, logical text, clusters and rtl. PlacedGlyph.advance remains a layout result, not a PDF font width.
|
||||
|
||||
Pinned private implementations:
|
||||
- font_objects.rs: private source checkpoint; exact SHA256 follows.
|
||||
SHA256 aef06369bad659706b1a30ebedfce1b2d8e89eaa4849e98d618bd3cf9ecb1eb1.
|
||||
- subset_font.rs: private source checkpoint; exact SHA256 follows.
|
||||
SHA256 a9808063f2223fd9e86e208d55092b6b9e9a5ae7ba0eaba2dce5d557a7fd5f26.
|
||||
- glyph_closure.rs: private source checkpoint; exact SHA256 follows.
|
||||
SHA256 a8d94c9996a5c718346e66d1983e6d2f2edb224286d3f45e92d731ab0013f1d8.
|
||||
|
||||
All references below bind those source bytes, not a future B1 revision.
|
||||
|
||||
## Exact emitter delta
|
||||
|
||||
| Current emitter site | Replacement responsibility after actual B1 delivery |
|
||||
| --- | --- |
|
||||
| lines155-200: MetricView fields/read scan subset head/hhea/hmtx/maxp; obtain unitsPerEm/numberOfHMetrics | Writer no longer reads a font program to obtain widths. Use the validated B1 Font.units_per_em and FontGlyph.advance; retain non-parser mapping/count/size guards separately. |
|
||||
| lines203-214: MetricView.advance(subset_gid), including compressed-tail lookup | Resolve the matching ORIGINAL GID through SubsetFont.mapping, then use that original GID's B1 FontGlyph.advance. The resolver already owns the last-hmtx-entry rule. Scale by1000/units_per_em, keeping finite/range checks. Zero advance is valid. |
|
||||
| line263: MetricView::read(input.subset) | Replace its metric-provider role with a validated same-resource bridge. Do not simply delete all validation hidden inside this call. |
|
||||
| line280: per-code advance call during admission | Validate CID/subset-GID mapping and required original-GID B1 width presence before code-derived allocations; do not fall back to0 or infer a width from text/placed advance. |
|
||||
| line298: widths.push((CID, metrics.advance(subset_gid))) | Same CID key; intrinsic width comes from B1 original-GID entry, through the validated subset mapping. Keep distinct CIDs sharing a GID, with identical intrinsic width but distinct logical mappings. |
|
||||
| line315: default_width=metrics.advance(0) | Separate .notdef/default-width policy; B1's exact-used-glyph table does NOT promise GID0 when it isn't used in a run. See below. |
|
||||
| lines124-153: part/word/dword/table | All production uses in this emitter belong to MetricView. After its remaining validation duties and default-width policy are resolved, this whole local SFNT byte-reader group can disappear from font_objects.rs. |
|
||||
|
||||
Keep the emission layer: Prepared.widths remains CID-indexed, and the existing W writer at lines346-348 emits each CID row. Type0/Identity-H, CIDToGIDMap, six reserved references, descriptor serialization, ToUnicode and local byte-bound preflight are separate responsibilities. No font re-layout or parsing is added to them.
|
||||
|
||||
## GID join and resource identity
|
||||
|
||||
Current SubsetFont.mapping stores (original_gid, subset_gid), ordered by original GID and dense by subset GID. Current CodeToGlyph rows contain only subset_gid. B1 Font.glyphs is keyed by original_gid. Therefore B1 glyphs[subset_gid] is WRONG after renumbering.
|
||||
|
||||
For each emitted CID: validate its subset_gid, obtain the corresponding original_gid from the checked mapping, require that original glyph's B1 entry, then use its advance. Preserve the original->subset relation for embedding and CIDToGIDMap. The B1 resource bytes/face/id must match the source supplied to subsetting; merely sharing a family string or a PostScript name is insufficient. Use checked lookups, bounded joins and existing caps, with no missing-entry fallback.
|
||||
|
||||
Composite components and .notdef are added by subsetting closure but may not appear in TextRun. They need embedded glyph programs, not invented scene FontGlyph entries or Unicode text. Require B1 widths for emitted glyphs; do not demand the entire closure appear in B1's exactly-used table. This distinction also prevents a false 'missing width' refusal for non-emitted components.
|
||||
|
||||
## Do not remove these guards with MetricView
|
||||
|
||||
MetricView::read currently mixes font parsing with ordered/dense mapping, original-GID0, subset-size and count guards (lines164-177). Keep those invariants using the immutable subset artifact/factory; do not treat removal of metric parsing as permission to trust an arbitrary mapping or caller-constructed record.
|
||||
|
||||
subset_font.rs production lines47-90 already owns glyph_closure, remapping, actual subsetter call and check_subset_program, returning immutable bytes/mapping. This is a subset-production/validation stage, not the PDF resource emitter.
|
||||
|
||||
glyph_closure.rs check_subset_program (lines485-528) still checks the returned program, including maxp/count, required tables, hhea.numberOfHMetrics and hmtx structural length. KEEP that structural safety check; a B1 advance table does not prove the subsetter returned a valid program. Independent test-side hmtx read-back also remains legitimate acceptance evidence. Neither should be confused with the emitter deriving W by parsing hmtx.
|
||||
|
||||
The private emitter's repeated maxp parse can move out only because the actual factory already validates the subset's count; preserve its trust/immutability boundary. Its hard-coded2048 read becomes provider units_per_em, but that does NOT automatically broaden the fixed-font admission. Current resource policy still admits the exact Liberation Sans bytes/face and TrueType FontFile2; arbitrary fonts, variable instances, CFF and installed fonts remain unsupported until separately implemented/reviewed.
|
||||
|
||||
## .notdef and DW: actual remaining integration decision
|
||||
|
||||
The old emitter always reads subset GID0 to write its default width, even with no code rows. The new B1 table contains exactly glyphs used by runs; it may contain neither original GID0 nor composite-only glyphs. It is incorrect to index a nonexistent B1 glyph0 or silently supply width0.
|
||||
|
||||
Before deleting the last metric-reader use, the emitter must choose an explicit default-width strategy with real content/CID coverage, OR consume separately admitted .notdef metrics from the appropriate resource/subsetting stage. The present B1 proposal does not itself provide that extra value. This is an identified private integration dependency, not a new public request or a reason to reopen Cam's answered intrinsic-width question. No option, new record field or public function is invented here.
|
||||
|
||||
## Metrics and actual code still required
|
||||
|
||||
FontGlyph.advance resolves the width authority, but no compiled B1 scene/resolver/builder exists in the inspected handoff. Actual adoption depends on:
|
||||
- final code/revision and validated resource lifetime: same bytes/face, stable id, bounded fields and font-unit values that cannot drift from the admitted resource;
|
||||
- the resolver's real hmtx-tail implementation and tests, including zero-width glyphs; missing glyphs refuse instead of using sketch's replacement;
|
||||
- final validated Font/TextRun/Cluster types and caps; logical/visual order and per-run glyph identity, without writer re-layout;
|
||||
- mapping Font.metrics from font units into the existing1000-unit DescriptorMetrics, with units_per_em checked before division, explicit flags/angle units/StemV semantics and unchanged finite/bound checks;
|
||||
- actual bundled-only resolver provenance/licences and supported static TrueType face; no system/path fallback;
|
||||
- the subset mapping/closure bridge and explicit DW decision above;
|
||||
- P2 page resource registration, globally reserved object refs/tags, two-byte content codes, positioned glyph content, ToUnicode/ActualText policy and aggregate budgets.
|
||||
|
||||
The current emitter's W checks do not qualify any of these absent consumers. The new B1 proposal also does not turn our old test-only P2 module registration into production PDF text support.
|
||||
|
||||
## Original acceptance to retain when the real bridge is implemented
|
||||
|
||||
1. Same fixed source widths: original GIDs2/3 have advance569 and GID0 has1536 at2048units/em. Keep literal expected W values569000/2048 and750; do not derive them from emitter output or placed advances.
|
||||
2. Original/subset/CID separation: the existing fixture maps originals0,2,3 to subsets0,1,2. A CID selecting subset2 must look up ORIGINAL3 in B1, not key2 by coincidence. Add distinct source advances in a separate synthetic bridge fixture so a wrong-key join cannot pass merely because both old widths are569.
|
||||
3. Two CIDs using the same original GID with different logical clusters retain equal W and distinct logical extraction; layout advances can deliberately differ without changing W.
|
||||
4. Advance0 remains0; missing original-GID entry, wrong font identity/face, zero units_per_em and invalid mapping refuse before partial resource emission.
|
||||
5. Preserve subset closure/structural checks, CID ordering, local byte bounds, sparse-code map bounds and original ToUnicode serializer rejection cases.
|
||||
6. For the provider's real code, independently verify compressed hmtx-tail widths and all admitted fonts against their exact byte/face source. A source document is not an executed provider check.
|
||||
7. Exercise the chosen .notdef/default-width policy with an empty run set and with a scene that never uses GID0; do not force extra glyphs into the B1 exactly-used table.
|
||||
8. Actual positioned page content plus independent extraction/rendering must qualify the final PDF separately. Metadata Unicode or a resource-only six-object Chunk is not searchable drawing text acceptance.
|
||||
|
||||
## Handoff
|
||||
|
||||
The removable metric reader is now located precisely, and the same-font intrinsic-width dataflow is settled. Keep this as a delta against the pinned private emitter until actual B1 code and the remaining private default-width/bridge decisions are present. No source/caps/assertions/dependencies/licences changed. W10v2 and both PDF reproducer proposals remain immutable and unrun.
|
||||
@@ -0,0 +1,29 @@
|
||||
# P2/B1 typed text-plan increment
|
||||
|
||||
**SOURCE ONLY — UNFORMATTED, UNCOMPILED, UNRUN, UNREGISTERED.** This is preserved WIP for [PDF #7626](https://github.com/tangency-labs/solidcraft/pull/7626), not finished PDF text support or a ready-for-review implementation. No user-visible default changes.
|
||||
|
||||
The patch adds `crates/io/src/paper/pdf/text_plan.rs` and thirteen original cases in the existing `pdf_tests.rs`. It consumes the actual B1 records from [#9512 at `8da9e989`](https://github.com/tangency-labs/solidcraft/pull/9512) and the actual resolver contract from [#9513 at `df3eff1c`](https://github.com/tangency-labs/solidcraft/pull/9513). Immutable heads, file blobs, byte hashes and dependency pins are in [source-bindings.json](source-bindings.json). These are separate draft branches; their composition has not been compiled.
|
||||
|
||||
## Included behavior
|
||||
|
||||
The typed plan keeps intrinsic `FontGlyph.advance` in font units separate from placed x/rise/advance in sheet millimetres. It preserves rotation, slant, source glyph positions and explicit RTL information without reshaping. Logical aliases of one GID have distinct PDF codes; repeated identical keys reuse a code. B1's bytes-plus-face identity is checked separately from the admitted font's raw-byte identity. The exact supported regular Liberation Sans face and descriptor remain unchanged.
|
||||
|
||||
Only one glyph per logical cluster is currently supported. Several characters carried by one glyph retain their literal string; a cluster with several glyphs refuses explicitly. Complete-cluster ToUnicode bounds remain intact. No text is duplicated across every glyph, silently split, normalized or claimed to be extractable from a real PDF. Local plan/subset allowances are explicit; they do not establish whole-process memory bounds.
|
||||
|
||||
The original thirteen written cases cover these distinctions, positions and RTL order, mapping repeatability, space-without-outline, identity/metrics/partition/finite checks, local storage limits, and byte-envelope refusal before digest work. Independent source review closed the admission-order finding in the preserved source; this is not type-checking or executed acceptance evidence.
|
||||
|
||||
## Compatibility scaffold, not the final B1 width migration
|
||||
|
||||
The six existing P2 helpers exactly match the published [P2 checkpoint `bf2a6fd5`](https://github.com/tangency-labs/solidcraft/tree/bf2a6fd567b03e1d25dcb66624e719a0cec109fc/crates/io/src/paper/pdf). This increment reuses their legacy `MetricView` and checks original `hmtx` provenance. It **does not implement** the final design described in the earlier [P2/B1 adaptation notes](../P2-B1-adaptation-notes.md): the resource emitter still has to consume validated B1 intrinsic widths, preserve mapping/subset safety, and remove its local metric parser. The old note's statement that B1 code was not yet delivered is historical; the actual B1 heads are now pinned here. The owner-set intrinsic-width authority remains B1, not placed advances.
|
||||
|
||||
The `.notdef`/`DW` policy and complete width coverage of emitted CIDs are still integration decisions. This packet neither invents B1 fields nor claims those decisions are settled. The next positive multi-glyph acceptance gap is described separately in [multi-glyph-acceptance-next.md](multi-glyph-acceptance-next.md).
|
||||
|
||||
## Applying the preserved work later
|
||||
|
||||
`text-plan-and-original-tests.patch` is bound to the **25-case P1** `pdf_tests.rs` at `506d419084a1cd6463c3117ea34d15f805a6ce37`, whose entire raw prefix is unchanged. The already published P2 checkpoint has **52 additional cases**: six are an unchanged suffix in its current 31-case `pdf_tests.rs`; 46 are in the existing helper modules. Do not replace the current test file with this older-base endpoint or lose any helper cases: retain that six-case suffix and compose the helper and the exact [original thirteen-case suffix](original-tests.rs) into the selected current source. The expected names are in [expected-tests-NOT-RUN.txt](expected-tests-NOT-RUN.txt). Ninety total declarations would result from retaining all 25 + 52 + 13 cases; that arithmetic is not an execution result.
|
||||
|
||||
Cam owns the many-page `paper_pdf`/`collect_layers`, content and common object-allocator window under [the agreed split](https://github.com/tangency-labs/solidcraft/pull/7626#issuecomment-6530265408). This increment supplies no parent module registration, font references, page resources, fixed ref numbers, subset tags, text-content emission or edits to that window. No shared writer is reserved by this archived increment.
|
||||
|
||||
Before production adoption: select and bind the actual composed B1/P2/many-page source; finish the width-provider and content seams; resolve the existing subsetter dependency and inspect the real lock delta; integrate normal module registration; preserve original tests; then use the sole builder for affected native/wasm/strict checks and the actual repository gate. Independent PDF rendering, reader extraction, multiple-page/font/layer and xref acceptance remain required. No old pass transfers to this source.
|
||||
|
||||
Original adapter and acceptance cases: Engineer team for 0xlillith. Referenced B1 and PDF code retains its existing authorship; no external implementation, private evidence, font binary or new asset is included.
|
||||
@@ -0,0 +1,13 @@
|
||||
paper::pdf::tests::b1_text_plan_keeps_intrinsic_width_separate_from_placed_advance
|
||||
paper::pdf::tests::b1_text_plan_same_gid_with_distinct_logical_text_has_distinct_codes
|
||||
paper::pdf::tests::b1_text_plan_preserves_quarter_turn_slant_and_mark_rise_once
|
||||
paper::pdf::tests::b1_text_plan_single_glyph_literal_cluster_is_not_split_or_normalized
|
||||
paper::pdf::tests::b1_text_plan_rtl_keeps_positions_but_uses_logical_cluster_order
|
||||
paper::pdf::tests::b1_text_plan_multi_glyph_cluster_refuses_instead_of_duplicating_text
|
||||
paper::pdf::tests::b1_text_plan_tounicode_destination_limit_is_inclusive_and_never_split
|
||||
paper::pdf::tests::b1_text_plan_space_keeps_advance_and_text_despite_empty_outline
|
||||
paper::pdf::tests::b1_text_plan_refuses_wrong_identity_descriptor_face_and_intrinsic_width
|
||||
paper::pdf::tests::b1_text_plan_refuses_invalid_partitions_unmatched_font_and_nonfinite_positions
|
||||
paper::pdf::tests::b1_text_plan_bounds_storage_subsets_run_count_and_exact_used_font_table
|
||||
paper::pdf::tests::b1_text_plan_repeats_real_subset_mapping_and_resource_inputs
|
||||
paper::pdf::tests::b1_text_plan_font_byte_envelope_refuses_before_identity_or_table_work
|
||||
@@ -0,0 +1,27 @@
|
||||
# Bounded successor: positive multi-glyph cluster acceptance
|
||||
|
||||
**Proposal only; no implementation, runtime, PDF or extraction result.** The current text plan correctly refuses this unsupported seam. Cam keeps ownership of parent content, global refs, `collect_layers` and many-page integration.
|
||||
|
||||
## Actual missing case
|
||||
|
||||
[B1a's real `Cluster`](https://github.com/tangency-labs/solidcraft/blob/8da9e9893d7dcf2c4b3584f6334b91352dc31dcb/crates/geom/src/paper/text.rs) associates a glyph range with a UTF-8 text-byte range. It explicitly represents both multiple characters in one glyph and multiple glyphs for a character with marks. [B1b's RTL/marks test](https://github.com/tangency-labs/solidcraft/blob/df3eff1ce9768c1d2cab3ed7cdb91471dd30e95f/crates/sketch/src/paper_text_tests.rs) checks mixed-direction runs, byte coverage and fewer clusters than characters. It does **not** assert that a particular cluster contains more than one glyph, and it does not produce or extract a PDF. The preserved P2 resources and new text-plan cases likewise have no successful multi-glyph page-content acceptance.
|
||||
|
||||
An original candidate fixture is `M \u{05E9}\u{05C1}\u{05B8}\u{05DD} 27` (Latin, Hebrew shin with two points, final mem, spaces and digits), through the actual bundled B1 resolver. Use a nonzero sheet origin, a quarter turn and its supported italic/slant style. Do not hand-invent glyph IDs or claim that this unrun string already produces the desired cluster.
|
||||
|
||||
First require that the actual returned scene passes `Scene::validate()`, then record a real returned cluster whose glyph range has length greater than one and whose exact logical byte slice includes the marked letter. Keep actual original GIDs, per-glyph x/rise/advance, intrinsic widths, run direction and logical slices. The candidate deliberately puts U+05C1 before U+05B8; compare that exact code-point sequence without Unicode normalization. If this candidate does not reach that seam, preserve the failed precondition and choose a justified fixture; do not skip or weaken the precondition.
|
||||
|
||||
## Success contract after an agreed content seam exists
|
||||
|
||||
1. All glyphs in that cluster retain the resolver's positions and are drawn exactly once. A mark's placement must survive origin, shear, rotation and the single sheet-to-point transform. A resource-only object chunk is insufficient evidence.
|
||||
2. The exact logical cluster text replaces that cluster's combined visual content **once**, without repeating its whole string on every glyph, dropping marks, normalizing Unicode or absorbing the neighboring space/digit clusters.
|
||||
3. A real independent PDF text consumer must extract the intended literal text once, with explicit checks for duplicate cluster strings and lost combining marks. Content/span structure and pixel/render evidence must independently guard over-wide replacement spans and missing/relocated visual glyphs. A raw metadata string or a single CMap assertion cannot establish this.
|
||||
4. Per-cluster replacement is distinct from global reading order across mixed bidi runs. Qualify run ordering separately against the actual emitted document; this case alone does not prove all RTL extraction correct.
|
||||
5. Preserve the current bounded refusal until the actual writer can enforce balanced marked-content scopes, exact glyph/text coverage, bounded encoded replacement text, local and aggregate output/work limits, and collision-free parent resources. The existing 512-byte ToUnicode destination policy must not be silently bypassed or changed by this proposal.
|
||||
|
||||
The current `CodeToGlyph` contract requires a nonempty logical string for each code. Assigning empty text to continuation marks is not an admitted shortcut; a new content policy must address this constraint explicitly.
|
||||
|
||||
One possible format route is a `Span` marked-content replacement, subject to actual writer support. The [PDF Association's specification errata, section 14.9.4](https://pdf-issues.pdfa.org/32000-2-2020/clause14.html#1494-replacement-text), describes ActualText on a Span marked-content sequence; its [tagged-PDF guide](https://pdfa.org/wp-content/uploads/2023/07/Tagged-PDF-Best-Practice-Guide.pdf) explains replacement text for otherwise unsuitable character content. These references establish a format option, **not** an implemented `pdf-writer` API, working integration, PDF/UA claim or reader result. No new production API or dependency is proposed here.
|
||||
|
||||
## Small compatible next unit
|
||||
|
||||
Prepare an original acceptance specification and source-bound fixture expectations for the actual B1 resolver, then give Cam an exact per-cluster coverage/position contract. Keep any prospective content adapter private and unregistered until the owner integrates it with the actual allocator and pages. Successful multi-glyph support requires changing the explicit refusal only together with the real content implementation and its executed acceptance, not by mapping the full cluster to each glyph.
|
||||
@@ -0,0 +1,211 @@
|
||||
|
||||
// P2 B1 text-plan acceptance: original records, not a replacement layout engine.
|
||||
// Font constants are literal fields of the pinned original Liberation resource;
|
||||
// synthetic logical aliases below test the record contract, not real shaping.
|
||||
fn b1_plan_font(ids: &[(u16, u16)]) -> solidcraft_geom::paper::text::Font {
|
||||
use sha2::{Digest, Sha256};
|
||||
use solidcraft_geom::paper::text::{Font, FontFlags, FontGlyph, FontMetrics};
|
||||
let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(&include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/liberation-sans/LiberationSans-Regular.ttf"))[..]);
|
||||
let id = Sha256::new().chain_update(bytes.as_ref()).chain_update(0u32.to_le_bytes()).finalize().into();
|
||||
Font {
|
||||
bundled: 0, id, bytes, face: 0, postscript_name: "LiberationSans".into(), num_glyphs: 2620, units_per_em: 2048,
|
||||
metrics: FontMetrics {
|
||||
ascent: 1854, descent: -434, cap_height: 1409, bbox: [-1114, -621, 2666, 2007],
|
||||
italic_angle: 0.0, flags: FontFlags::default(), stem_v: 180,
|
||||
},
|
||||
glyphs: ids.iter().map(|&(id, advance)| (id, FontGlyph { advance, outline: vec![] })).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn b1_plan_run(text: &str, ids: &[u16]) -> solidcraft_geom::paper::text::TextRun {
|
||||
use solidcraft_geom::paper::text::{Cluster, PlacedGlyph, TextRun};
|
||||
assert_eq!(text.chars().count(), ids.len());
|
||||
let chars: Vec<_> = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect();
|
||||
TextRun {
|
||||
font: 0, scale: 1.0 / 2048.0, origin: solidcraft_geom::Vec2::new(0.0, 0.0), angle: 0.0, slant: 0.0, rtl: false,
|
||||
glyphs: ids.iter().enumerate().map(|(i, &id)| PlacedGlyph { id, x: i as f64 * 0.375, rise: 0.0, advance: 0.5 }).collect(),
|
||||
text: text.into(),
|
||||
clusters: ids.iter().enumerate().map(|(i, _)| Cluster { glyphs: i as u32..i as u32 + 1, text: chars[i] as u32..chars[i + 1] as u32 }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_keeps_intrinsic_width_separate_from_placed_advance() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.runs()[0].font_size_mm, 1.0);
|
||||
assert_eq!(plan.runs()[0].text, "AV");
|
||||
assert_eq!(plan.runs()[0].glyphs, 0..2);
|
||||
let g = &plan.glyphs()[1];
|
||||
assert_eq!((g.original_gid, g.intrinsic_advance_fu, g.placed_advance_mm), (57, 1366, 0.5));
|
||||
assert_eq!(g.matrix_mm, [1.0, 0.0, 0.0, 1.0, 0.375, 0.0]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(input.postscript_name, b"LiberationSans");
|
||||
assert_eq!(input.descriptor.unwrap().ascent_1000, 905.2734375);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_same_gid_with_distinct_logical_text_has_distinct_codes() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let first = b1_plan_run("A", &[36]);
|
||||
let second = b1_plan_run("B", &[36]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&second, &first, &first], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs().iter().map(|g| (g.code, g.logical)).collect::<Vec<_>>(), [(2, "B"), (1, "A"), (1, "A")]);
|
||||
let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(input.codes.len(), 2);
|
||||
assert_eq!(input.codes[0].subset_gid, input.codes[1].subset_gid);
|
||||
assert_eq!((input.codes[0].logical, input.codes[1].logical), ("A", "B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_preserves_quarter_turn_slant_and_mark_rise_once() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.origin = solidcraft_geom::Vec2::new(100.0, 50.0);
|
||||
run.angle = std::f64::consts::FRAC_PI_2;
|
||||
run.slant = 0.25;
|
||||
run.glyphs[0].x = 3.0;
|
||||
run.glyphs[0].rise = 2.0;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
for (actual, expected) in plan.glyphs()[0].matrix_mm.into_iter().zip([0.0, 1.0, -1.0, 0.25, 98.0, 53.0]) {
|
||||
assert!((actual - expected).abs() < 1e-12, "fixed quarter-turn f64 trig allowance: {actual} != {expected}");
|
||||
}
|
||||
assert_eq!(run.origin, solidcraft_geom::Vec2::new(100.0, 50.0));
|
||||
assert_eq!((run.glyphs[0].x, run.glyphs[0].rise), (3.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_single_glyph_literal_cluster_is_not_split_or_normalized() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.text = "fi\u{301}".into();
|
||||
run.clusters[0].text = 0..4;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].logical, "fi\u{301}");
|
||||
assert_eq!(plan.font_input(*b"ABCDEF", 1 << 20).codes[0].logical, "fi\u{301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_rtl_keeps_positions_but_uses_logical_cluster_order() {
|
||||
let font = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
let mut run = b1_plan_run("AB", &[37, 36]);
|
||||
run.rtl = true;
|
||||
run.clusters[0].text = 1..2;
|
||||
run.clusters[1].text = 0..1;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert!(plan.runs()[0].rtl);
|
||||
assert_eq!(plan.glyphs().iter().map(|g| g.logical).collect::<String>(), "AB");
|
||||
assert_eq!(plan.glyphs().iter().map(|g| g.source_glyph_index).collect::<Vec<_>>(), [1, 0]);
|
||||
assert_eq!((plan.glyphs()[0].matrix_mm[4], plan.glyphs()[1].matrix_mm[4]), (0.375, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_multi_glyph_cluster_refuses_instead_of_duplicating_text() {
|
||||
let font = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
let mut run = b1_plan_run("AB", &[36, 37]);
|
||||
run.text = "A".into();
|
||||
run.clusters.truncate(1);
|
||||
run.clusters[0].glyphs = 0..2;
|
||||
run.clusters[0].text = 0..1;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("multi-glyph cluster extraction is not supported"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_tounicode_destination_limit_is_inclusive_and_never_split() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let mut run = b1_plan_run("A", &[36]);
|
||||
run.text = "A".repeat(256);
|
||||
run.clusters[0].text = 0..256;
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!(plan.glyphs()[0].logical.len(), 256);
|
||||
drop(plan);
|
||||
run.text.push('A');
|
||||
run.clusters[0].text.end += 1;
|
||||
let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("512-byte destination"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_space_keeps_advance_and_text_despite_empty_outline() {
|
||||
let font = b1_plan_font(&[(3, 569)]);
|
||||
let run = b1_plan_run(" ", &[3]);
|
||||
let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
assert_eq!((plan.glyphs()[0].original_gid, plan.glyphs()[0].intrinsic_advance_fu, plan.glyphs()[0].logical), (3, 569, " "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_refuses_wrong_identity_descriptor_face_and_intrinsic_width() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for kind in 0..5 {
|
||||
let mut wrong = font.clone();
|
||||
match kind {
|
||||
0 => wrong.id[0] ^= 1,
|
||||
1 => wrong.face = 1,
|
||||
2 => wrong.metrics.stem_v = 181,
|
||||
3 => wrong.metrics.flags.italic = true,
|
||||
_ => wrong.glyphs.get_mut(&36).unwrap().advance = 1024,
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&wrong, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_refuses_invalid_partitions_unmatched_font_and_nonfinite_positions() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for kind in 0..6 {
|
||||
let mut wrong = run.clone();
|
||||
match kind {
|
||||
0 => wrong.clusters[0].glyphs.end = 2,
|
||||
1 => wrong.clusters[0].text.end = 2,
|
||||
2 => wrong.clusters.clear(),
|
||||
3 => wrong.font = 1,
|
||||
4 => wrong.glyphs[0].x = f64::NAN,
|
||||
_ => { wrong.text = "\u{e9}".into(); wrong.clusters[0].text = 0..1; }
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&font, &[&wrong], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_bounds_storage_subsets_run_count_and_exact_used_font_table() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for (plan_bytes, subset_bytes) in [(0, 410_820), (1, 410_820), (1 << 20, 0), (1 << 20, 1)] {
|
||||
assert!(super::text_plan::plan_text(&font, &[&run], plan_bytes, subset_bytes).is_err());
|
||||
}
|
||||
assert!(super::text_plan::plan_text(&font, &vec![&run; 1025], 1 << 20, 410_820).is_err());
|
||||
let extra = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
assert!(super::text_plan::plan_text(&extra, &[&run], 1 << 20, 410_820).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_repeats_real_subset_mapping_and_resource_inputs() {
|
||||
let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
let run = b1_plan_run("AV", &[36, 57]);
|
||||
let first = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let second = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
let a = first.font_input(*b"ABCDEF", 1 << 20);
|
||||
let b = second.font_input(*b"ABCDEF", 1 << 20);
|
||||
assert_eq!(a.subset.bytes(), b.subset.bytes());
|
||||
assert_eq!(a.subset.mapping(), [(0, 0), (36, 1), (57, 2)]);
|
||||
assert_eq!(a.subset.mapping(), b.subset.mapping());
|
||||
assert_eq!(a.codes.iter().map(|g| (g.code, g.subset_gid, g.logical)).collect::<Vec<_>>(), [(1, 1, "A"), (2, 2, "V")]);
|
||||
assert_eq!(a.descriptor.unwrap().flags, pdf_writer::types::FontFlags::NON_SYMBOLIC.bits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b1_text_plan_font_byte_envelope_refuses_before_identity_or_table_work() {
|
||||
let font = b1_plan_font(&[(36, 1366)]);
|
||||
let run = b1_plan_run("A", &[36]);
|
||||
for len in [0, 410_819, 410_821] {
|
||||
let mut wrong = font.clone();
|
||||
// Identity is deliberately stale too: byte-envelope refusal has priority.
|
||||
wrong.bytes = std::sync::Arc::from(vec![0; len]);
|
||||
let error = super::text_plan::plan_text(&wrong, &[&run], 1, 410_820).err().unwrap().to_string();
|
||||
assert!(error.contains("font byte length is outside the reviewed resource envelope"), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"repository": "tangency-labs/solidcraft",
|
||||
"status": "SOURCE_ONLY_UNFORMATTED_UNCOMPILED_UNRUN_UNREGISTERED",
|
||||
"purpose": "Portable preservation of an original typed B1-to-P2 text-plan compatibility scaffold; no production integration or extraction qualification.",
|
||||
"actual_upstream_sources": [
|
||||
{
|
||||
"pr": 9512,
|
||||
"head": "8da9e9893d7dcf2c4b3584f6334b91352dc31dcb",
|
||||
"path": "crates/geom/src/paper/text.rs",
|
||||
"blob": "24e4e18a6584c58a97151b90d63e177ff7a6466d",
|
||||
"sha256": "ef4ff4758b167dc380fb783355fc46225445515794c03238ba2425abe6b2bc31",
|
||||
"bytes": 14489
|
||||
},
|
||||
{
|
||||
"pr": 9512,
|
||||
"head": "8da9e9893d7dcf2c4b3584f6334b91352dc31dcb",
|
||||
"path": "crates/geom/src/paper.rs",
|
||||
"blob": "4af6d5399d6e87c5a4c05e2223a892acff3d0928",
|
||||
"sha256": "deef0c4a343cf3390e445f77be002e3dea2e09e8e7ee29264429c85317c356b7",
|
||||
"bytes": 10602
|
||||
},
|
||||
{
|
||||
"pr": 9513,
|
||||
"head": "df3eff1ce9768c1d2cab3ed7cdb91471dd30e95f",
|
||||
"path": "crates/sketch/src/paper_text.rs",
|
||||
"blob": "549d9dbd1f4f63182e3608d8312dc3b291f6ec8b",
|
||||
"sha256": "1cb380a323b6c623ea6c45fd6d64d935dd3af2532bdc4d38eb9014a5b885e9a7",
|
||||
"bytes": 10821
|
||||
},
|
||||
{
|
||||
"pr": 9513,
|
||||
"head": "df3eff1ce9768c1d2cab3ed7cdb91471dd30e95f",
|
||||
"path": "crates/sketch/src/paper_text_tests.rs",
|
||||
"blob": "d309c01099a777385e67e735e1360a951b2ea520",
|
||||
"sha256": "4ef6cf1d7f562a306bea23888f75f39e047eff3db2278418b5ff2164803fd613",
|
||||
"bytes": 8936
|
||||
},
|
||||
{
|
||||
"pr": 7626,
|
||||
"head": "506d419084a1cd6463c3117ea34d15f805a6ce37",
|
||||
"path": "crates/io/src/paper/pdf.rs",
|
||||
"blob": "6ee0e8b1783822014dd096a6af5a0f2ec5e991ca",
|
||||
"sha256": "c416234e22414f10951f79c75b42ae3dc6128294312ace35ac5559113ab1af96",
|
||||
"bytes": 17306
|
||||
},
|
||||
{
|
||||
"pr": 9513,
|
||||
"head": "df3eff1ce9768c1d2cab3ed7cdb91471dd30e95f",
|
||||
"path": "crates/sketch/src/fonts.rs",
|
||||
"blob": "4b104ec49eac9516dc2f69aa56fff779eccdb7f2",
|
||||
"sha256": "f497592266ef11a4071d550277cf61c75ce4d2e6e40e4e0e3b8da6e06969c3d0",
|
||||
"bytes": 22385
|
||||
}
|
||||
],
|
||||
"existing_P2_checkpoint": {
|
||||
"branch": "codex/drawings-pdf-p2-wip-20261029",
|
||||
"head": "bf2a6fd567b03e1d25dcb66624e719a0cec109fc",
|
||||
"helper_sources": [
|
||||
{
|
||||
"path": "crates/io/src/paper/pdf/font_admission.rs",
|
||||
"blob": "632d21b81d25626dd5fe6e3b28d274a96a640382",
|
||||
"sha256": "a8abc3b4aaef9325694ec404512c228f9973b82006b5718eb304ef57d5c30b23",
|
||||
"bytes": 4170
|
||||
},
|
||||
{
|
||||
"path": "crates/io/src/paper/pdf/font_admission_tests.rs",
|
||||
"blob": "6b36605906398d7211f97fb181eacf96e6d8eaf4",
|
||||
"sha256": "b58637cf322658d93a3abb61acf2c3a970c466a7401edf69a3a0594e38c9da7a",
|
||||
"bytes": 3642
|
||||
},
|
||||
{
|
||||
"path": "crates/io/src/paper/pdf/font_objects.rs",
|
||||
"blob": "0c235bebaeb2c85120445c1f77aa300f4b30584b",
|
||||
"sha256": "aef06369bad659706b1a30ebedfce1b2d8e89eaa4849e98d618bd3cf9ecb1eb1",
|
||||
"bytes": 37811
|
||||
},
|
||||
{
|
||||
"path": "crates/io/src/paper/pdf/glyph_closure.rs",
|
||||
"blob": "677135bf60bcd8ef4ce464304803a52073a3cf08",
|
||||
"sha256": "a8d94c9996a5c718346e66d1983e6d2f2edb224286d3f45e92d731ab0013f1d8",
|
||||
"bytes": 38397
|
||||
},
|
||||
{
|
||||
"path": "crates/io/src/paper/pdf/subset_font.rs",
|
||||
"blob": "cef7bd532639faf95f5ec5f9ea7451b79b03c688",
|
||||
"sha256": "a9808063f2223fd9e86e208d55092b6b9e9a5ae7ba0eaba2dce5d557a7fd5f26",
|
||||
"bytes": 8941
|
||||
},
|
||||
{
|
||||
"path": "crates/io/src/paper/pdf/to_unicode.rs",
|
||||
"blob": "b043073e4ec292b5c89ca0aad8b66571a3c3a2b5",
|
||||
"sha256": "ba60e39cf6ed9a27475b12258b0979aa8f8659d482b5246e32ee6df3abe50d49",
|
||||
"bytes": 15674
|
||||
}
|
||||
],
|
||||
"matches_preserved_helper_bytes": true,
|
||||
"existing_tests": {
|
||||
"P1_declarations": 25,
|
||||
"P2_declarations": 52,
|
||||
"total_declarations": 77,
|
||||
"executed_by_this_packet": false,
|
||||
"P2_declarations_in_existing_pdf_tests_suffix": 6,
|
||||
"P2_declarations_in_helper_modules": 46
|
||||
},
|
||||
"registration": "Prototype test-only; not production PDF text support.",
|
||||
"dependency": "subsetter 0.2.6 with default-features=false remains a separately reviewed dependency; candidate lock resolution and production dependency placement are pending.",
|
||||
"existing_test_file": {
|
||||
"path": "crates/io/src/paper/pdf_tests.rs",
|
||||
"blob": "39c6a6767d73866c3b59f5417bbf4a27794ddaf3",
|
||||
"sha256": "d711a39b40568b0245ddf2e83908cfbcfb23614acadc951396ba0d6443d25401",
|
||||
"bytes": 48267,
|
||||
"test_declarations": 31,
|
||||
"P1_raw_prefix_unchanged": true,
|
||||
"literal_appendix_present_once": true,
|
||||
"appendix_declarations": 6
|
||||
}
|
||||
},
|
||||
"new_patch": {
|
||||
"path": "text-plan-and-original-tests.patch",
|
||||
"sha256": "1b7c6442993abc6b63c375ef4ef76facda25584c0106dc23ca7b3aed07150b85",
|
||||
"bytes": 27456,
|
||||
"endpoints": [
|
||||
{
|
||||
"path": "crates/io/src/paper/pdf/text_plan.rs",
|
||||
"before": null,
|
||||
"after_sha256": "b9e94d50d10fd13e163b7ac8cd6dd6867fc0842569a7e9a762ff9d1b4c68f902",
|
||||
"after_bytes": 16282
|
||||
},
|
||||
{
|
||||
"path": "crates/io/src/paper/pdf_tests.rs",
|
||||
"before_head": "506d419084a1cd6463c3117ea34d15f805a6ce37",
|
||||
"before_blob": "44a839a0a24d6db894d465e265a74a6ff3d2f755",
|
||||
"before_sha256": "19d9685bb5431fef9e643eb18dc8269106d693df053a901a8d93ca2e85c1d338",
|
||||
"before_bytes": 41918,
|
||||
"after_sha256": "d5fcbddcc2619a2d5484b758c8101940cc2dea0cbd9e5ecdbdb174fbf60606df",
|
||||
"after_bytes": 52099,
|
||||
"old_declarations": 25,
|
||||
"added_declarations": 13,
|
||||
"after_declarations": 38,
|
||||
"line_endings": "LF; complete raw before prefix retained"
|
||||
}
|
||||
],
|
||||
"original_test_suffix": {
|
||||
"path": "original-tests.rs",
|
||||
"sha256": "da4954af11a65e8bac65aed22eabf766f5ff05e8a9cf89b15d95a3494d909188",
|
||||
"bytes": 10181,
|
||||
"declarations": 13
|
||||
},
|
||||
"application_constraint": "The patch is based on the 25-case P1 test file, not the current P2 source. Do not replace the current 31-case pdf_tests.rs: preserve its original six-case P2 suffix when appending the new 13 cases, and preserve all 46 existing helper-module cases. No automatic application or registration is supplied."
|
||||
},
|
||||
"font_source": {
|
||||
"head": "df3eff1ce9768c1d2cab3ed7cdb91471dd30e95f",
|
||||
"blob": "328ef8be272813fa4864b1c2405491049dd3a7a3",
|
||||
"bytes": 410820,
|
||||
"raw_sha256": "075e71be4c58beda172ecb574a799308f04c3d054c6a5b7434954d3b0f1b28d8",
|
||||
"policy": "Only the already admitted bundled regular Liberation Sans face 0; no font bytes are included in this packet. Existing attribution and OFL remain applicable.",
|
||||
"B1_identity": "SHA256(font bytes followed by little-endian face index); separate from raw resource SHA256.",
|
||||
"path": "assets/fonts/liberation-sans/LiberationSans-Regular.ttf"
|
||||
},
|
||||
"static_review": {
|
||||
"kind": "Independent source review; not compiler, test, PDF or reader evidence.",
|
||||
"review_sha256": "93b67a3b2ccb8504ac6f8b7eaaecd703696edd7528f5c370b18086f122254e2a",
|
||||
"finding_closed_in_source": "Exact font-byte envelope is admitted before digest/table work; original twelve cases retained and a thirteenth original refusal case added.",
|
||||
"patch_literal_replay": "Both LF endpoints reconstructed by literal unified-hunk replay; no Git operation or execution of Rust code."
|
||||
},
|
||||
"remaining_constraints": [
|
||||
"Single glyph per logical cluster only; multi-glyph clusters explicitly refuse. No repeated full logical text per glyph, silent split, normalization, or unsupported extraction claim.",
|
||||
"This compatibility scaffold checks original hmtx provenance and reuses the legacy MetricView font-object helper. It does not implement the final B1-owned intrinsic-width provider or remove the emitter-local parser.",
|
||||
"DW/.notdef policy, exact emitted-CID width coverage and remaining legacy validation duties must be settled before final width-provider migration.",
|
||||
"Cam owns the many-page parent, collect_layers, content, global object allocator and page-resource integration. No fixed ref/font tag assumptions or shared edits are supplied.",
|
||||
"B1a/B1b and PDF snapshots are separate draft branches, not a composed validated source.",
|
||||
"No parent registration, production dependency/lock resolution, compiler/formatter/test result, runtime PDF extraction, whole-project gate, readiness or merge is supplied."
|
||||
],
|
||||
"authorship": "Original adapter and acceptance cases by the Engineer team for 0xlillith; referenced B1 and PDF work retains its existing contributor authorship. No external implementation code or new asset is copied."
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
diff --git a/crates/io/src/paper/pdf/text_plan.rs b/crates/io/src/paper/pdf/text_plan.rs
|
||||
--- /dev/null
|
||||
+++ b/crates/io/src/paper/pdf/text_plan.rs
|
||||
@@ -0,0 +1,334 @@
|
||||
+//! Bounded PDF planning from the actual B1a Font/TextRun records (#4890 P2).
|
||||
+//!
|
||||
+//! This neither shapes text nor writes a page. It keeps intrinsic hmtx widths
|
||||
+//! separate from the producer's placed glyph x/rise/advance. One glyph per logical
|
||||
+//! cluster is supported, including a glyph carrying several characters; a
|
||||
+//! multi-glyph cluster is refused until its extraction/content policy is supplied.
|
||||
+//! The ToUnicode destination limit stays 512 UTF-16BE bytes, not a silent split.
|
||||
+//!
|
||||
+//! Plans retain no new font/document Arc: strings/font records are borrowed.
|
||||
+//! Local vector storage and returned subset bytes have separate allowances. The
|
||||
+//! existing subsetter/pdf-writer internal allocation limitations still apply.
|
||||
+//! No parent registration, global PDF Ref allocator, page resource or text arm is
|
||||
+//! introduced here. The parent must preserve the planned placements and reading
|
||||
+//! order and qualify real PDF rendering/extraction; these records prove neither.
|
||||
+
|
||||
+use std::mem::size_of;
|
||||
+use std::ops::Range;
|
||||
+
|
||||
+use pdf_writer::types::FontFlags;
|
||||
+use sha2::{Digest, Sha256};
|
||||
+use solidcraft_geom::paper::text::{Font, TextRun};
|
||||
+use solidcraft_geom::paper::MAX_COORDINATE_MM;
|
||||
+
|
||||
+use super::font_objects::{CodeToGlyph, DescriptorMetrics, FontObjectInput};
|
||||
+use super::subset_font::{SubsetFont, subset_font};
|
||||
+use crate::{IoError, Result};
|
||||
+
|
||||
+const MAX_RUNS: usize = 1024;
|
||||
+const MAX_GLYPHS: usize = 16_384;
|
||||
+const MAX_PLAN_BYTES: usize = 16 << 20;
|
||||
+const MAX_LOGICAL_BYTES: usize = 4 << 20;
|
||||
+const MAX_CLUSTER_BYTES: usize = 768;
|
||||
+const MAX_CLUSTER_UNITS: usize = 256;
|
||||
+const APPROVED_FONT_BYTES: usize = 410_820;
|
||||
+
|
||||
+/// One actual glyph, with a code for its original GID AND literal logical text.
|
||||
+/// The matrix has unit basis vectors and a translation in sheet millimetres;
|
||||
+/// font_size_mm is on its run. Parent applies its mm-to-point scale only once.
|
||||
+pub(super) struct PlannedGlyph<'a> {
|
||||
+ pub(super) code: u16,
|
||||
+ pub(super) original_gid: u16,
|
||||
+ pub(super) source_glyph_index: usize,
|
||||
+ pub(super) intrinsic_advance_fu: u16,
|
||||
+ pub(super) placed_advance_mm: f64,
|
||||
+ pub(super) matrix_mm: [f64; 6],
|
||||
+ pub(super) logical: &'a str,
|
||||
+}
|
||||
+
|
||||
+/// Glyphs occupy one contiguous range in the plan, in logical reading order.
|
||||
+/// For an RTL run their SOURCE indices descend; positions are never recomputed
|
||||
+/// from font widths. Input run order remains unchanged.
|
||||
+pub(super) struct PlannedRun<'a> {
|
||||
+ pub(super) glyphs: Range<usize>,
|
||||
+ pub(super) font_size_mm: f64,
|
||||
+ pub(super) rtl: bool,
|
||||
+ pub(super) text: &'a str,
|
||||
+}
|
||||
+
|
||||
+pub(super) struct TextPlan<'a> {
|
||||
+ font: &'a Font,
|
||||
+ subset: SubsetFont,
|
||||
+ descriptor: DescriptorMetrics,
|
||||
+ codes: Vec<CodeToGlyph<'a>>,
|
||||
+ runs: Vec<PlannedRun<'a>>,
|
||||
+ glyphs: Vec<PlannedGlyph<'a>>,
|
||||
+}
|
||||
+
|
||||
+impl TextPlan<'_> {
|
||||
+ pub(super) fn runs(&self) -> &[PlannedRun<'_>] {
|
||||
+ &self.runs
|
||||
+ }
|
||||
+
|
||||
+ pub(super) fn glyphs(&self) -> &[PlannedGlyph<'_>] {
|
||||
+ &self.glyphs
|
||||
+ }
|
||||
+
|
||||
+ /// Caller still supplies six globally reserved refs to font_objects, a
|
||||
+ /// globally unique subset tag, page resources and a cumulative output budget.
|
||||
+ pub(super) fn font_input(&self, subset_tag: [u8; 6], max_chunk_bytes: usize) -> FontObjectInput<'_> {
|
||||
+ FontObjectInput {
|
||||
+ subset: &self.subset,
|
||||
+ codes: &self.codes,
|
||||
+ descriptor: Some(&self.descriptor),
|
||||
+ postscript_name: self.font.postscript_name.as_bytes(),
|
||||
+ subset_tag,
|
||||
+ max_chunk_bytes,
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+fn invalid(message: &'static str) -> IoError {
|
||||
+ IoError::Invalid(format!("PDF text plan: {message}"))
|
||||
+}
|
||||
+
|
||||
+fn reserve<T>(count: usize) -> Result<Vec<T>> {
|
||||
+ let mut values = Vec::new();
|
||||
+ values.try_reserve_exact(count).map_err(|_| invalid("cannot allocate bounded plan storage"))?;
|
||||
+ Ok(values)
|
||||
+}
|
||||
+
|
||||
+fn bytes<T>(count: usize) -> Result<usize> {
|
||||
+ count.checked_mul(size_of::<T>()).ok_or_else(|| invalid("plan storage overflow"))
|
||||
+}
|
||||
+
|
||||
+fn total(parts: &[usize], limit: usize) -> Result<usize> {
|
||||
+ parts.iter().try_fold(0usize, |n, part| {
|
||||
+ n.checked_add(*part).filter(|n| *n <= limit).ok_or_else(|| invalid("plan storage allowance exceeded"))
|
||||
+ })
|
||||
+}
|
||||
+
|
||||
+fn u16_at(bytes: &[u8], at: usize) -> Result<u16> {
|
||||
+ let end = at.checked_add(2).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
+ let value = bytes.get(at..end).and_then(|b| b.try_into().ok()).ok_or_else(|| invalid("truncated admitted font table"))?;
|
||||
+ Ok(u16::from_be_bytes(value))
|
||||
+}
|
||||
+
|
||||
+fn u32_at(bytes: &[u8], at: usize) -> Result<u32> {
|
||||
+ let end = at.checked_add(4).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
+ let value = bytes.get(at..end).and_then(|b| b.try_into().ok()).ok_or_else(|| invalid("truncated admitted font table"))?;
|
||||
+ Ok(u32::from_be_bytes(value))
|
||||
+}
|
||||
+
|
||||
+/// A bounded original hmtx view, not a general font parser. The subset adapter
|
||||
+/// performs the existing exact-resource/embedding admission before plan return.
|
||||
+fn table<'a>(bytes: &'a [u8], tag: &[u8; 4]) -> Result<&'a [u8]> {
|
||||
+ let count = usize::from(u16_at(bytes, 4)?);
|
||||
+ if count == 0 || count > 19 {
|
||||
+ return Err(invalid("font directory exceeds the reviewed resource"));
|
||||
+ }
|
||||
+ let mut found = None;
|
||||
+ for i in 0..count {
|
||||
+ let at = 12 + 16 * i;
|
||||
+ if bytes.get(at..at + 4) != Some(tag) {
|
||||
+ continue;
|
||||
+ }
|
||||
+ if found.is_some() {
|
||||
+ return Err(invalid("duplicate font table"));
|
||||
+ }
|
||||
+ let start = usize::try_from(u32_at(bytes, at + 8)?).map_err(|_| invalid("font offset exceeds the platform"))?;
|
||||
+ let length = usize::try_from(u32_at(bytes, at + 12)?).map_err(|_| invalid("font length exceeds the platform"))?;
|
||||
+ let end = start.checked_add(length).ok_or_else(|| invalid("font table range overflow"))?;
|
||||
+ found = bytes.get(start..end);
|
||||
+ if found.is_none() {
|
||||
+ return Err(invalid("font table is outside its bytes"));
|
||||
+ }
|
||||
+ }
|
||||
+ found.ok_or_else(|| invalid("required font table is absent"))
|
||||
+}
|
||||
+
|
||||
+fn intrinsic_advance(font: &Font, gid: u16) -> Result<u16> {
|
||||
+ if gid >= font.num_glyphs {
|
||||
+ return Err(invalid("glyph is outside the admitted font"));
|
||||
+ }
|
||||
+ let count = usize::from(u16_at(table(&font.bytes, b"hhea")?, 34)?);
|
||||
+ if count == 0 || count > usize::from(font.num_glyphs) {
|
||||
+ return Err(invalid("invalid hmtx metric count"));
|
||||
+ }
|
||||
+ u16_at(table(&font.bytes, b"hmtx")?, usize::from(gid).min(count - 1) * 4)
|
||||
+}
|
||||
+
|
||||
+fn descriptor(font: &Font) -> Result<DescriptorMetrics> {
|
||||
+ // Public Font is hostile input too. Refuse its cheap byte envelope before
|
||||
+ // hashing or looking through tables, even when the caller's plan budget is tiny.
|
||||
+ if font.bytes.len() != APPROVED_FONT_BYTES {
|
||||
+ return Err(invalid("font byte length is outside the reviewed resource envelope"));
|
||||
+ }
|
||||
+ // B1 hashes bytes + faceLE. The existing subset admission hashes RAW bytes;
|
||||
+ // their digests are different identities and are never compared to each other.
|
||||
+ let m = &font.metrics;
|
||||
+ // Original fixed-resource metadata, not fitted/default descriptor values.
|
||||
+ // Values come from this exact Liberation Sans head/hhea/OS2/post and l glyph.
|
||||
+ if font.bundled != 0 || font.face != 0 || font.postscript_name != "LiberationSans"
|
||||
+ || font.num_glyphs != 2620 || font.units_per_em != 2048
|
||||
+ || m.bbox != [-1114, -621, 2666, 2007] || m.ascent != 1854 || m.descent != -434
|
||||
+ || m.cap_height != 1409 || m.stem_v != 180 || m.italic_angle != 0.0
|
||||
+ || m.flags.fixed_pitch || m.flags.serif || m.flags.italic || m.flags.symbolic
|
||||
+ {
|
||||
+ return Err(invalid("B1 font identity or metrics differ from the reviewed regular bundled face"));
|
||||
+ }
|
||||
+ let identity: [u8; 32] = Sha256::new().chain_update(font.bytes.as_ref()).chain_update(font.face.to_le_bytes()).finalize().into();
|
||||
+ if font.id != identity {
|
||||
+ return Err(invalid("B1 bytes-and-face identity does not match its supplied bytes"));
|
||||
+ }
|
||||
+ let k = 1000.0 / f32::from(font.units_per_em);
|
||||
+ Ok(DescriptorMetrics {
|
||||
+ bbox_1000: m.bbox.map(|n| f32::from(n) * k),
|
||||
+ ascent_1000: f32::from(m.ascent) * k,
|
||||
+ descent_1000: f32::from(m.descent) * k,
|
||||
+ cap_height_1000: f32::from(m.cap_height) * k,
|
||||
+ italic_angle_degrees: m.italic_angle as f32,
|
||||
+ stem_v_1000: f32::from(m.stem_v) * k,
|
||||
+ flags: FontFlags::NON_SYMBOLIC.bits(),
|
||||
+ })
|
||||
+}
|
||||
+
|
||||
+/// Check the actual B1 cluster partition and the narrower first P2 contract
|
||||
+/// BEFORE allocating or subsetting. Returning an error exposes no partial plan.
|
||||
+fn check_run(font: &Font, run: &TextRun) -> Result<usize> {
|
||||
+ if run.font != font.bundled || !(1..=4000).contains(&run.glyphs.len()) || run.text.len() > 4000 {
|
||||
+ return Err(invalid("run font, glyph count or text length is outside B1"));
|
||||
+ }
|
||||
+ let cap = run.scale * f64::from(font.metrics.cap_height);
|
||||
+ let within = |x: f64| x.is_finite() && x.abs() <= MAX_COORDINATE_MM;
|
||||
+ if !run.scale.is_finite() || !(1e-6..1e6).contains(&cap) || cap == 1e-6
|
||||
+ || !within(run.origin.x) || !within(run.origin.y)
|
||||
+ || !run.angle.is_finite() || run.angle.abs() > std::f64::consts::TAU
|
||||
+ || !run.slant.is_finite() || run.slant.abs() > 1.0
|
||||
+ {
|
||||
+ return Err(invalid("run transform is outside B1"));
|
||||
+ }
|
||||
+ let mut glyph_end = 0usize;
|
||||
+ let mut text_end = if run.rtl { run.text.len() } else { 0 };
|
||||
+ for cluster in &run.clusters {
|
||||
+ let (g0, g1, t0, t1) = (
|
||||
+ cluster.glyphs.start as usize, cluster.glyphs.end as usize,
|
||||
+ cluster.text.start as usize, cluster.text.end as usize,
|
||||
+ );
|
||||
+ if g0 != glyph_end || g0 >= g1 || t0 >= t1 || (if run.rtl { t1 != text_end } else { t0 != text_end }) {
|
||||
+ return Err(invalid("clusters do not partition glyphs and logical text in B1 order"));
|
||||
+ }
|
||||
+ if g1 - g0 != 1 {
|
||||
+ return Err(invalid("multi-glyph cluster extraction is not supported by this text-plan increment"));
|
||||
+ }
|
||||
+ let logical = run.text.get(t0..t1).ok_or_else(|| invalid("cluster text is not whole UTF-8 characters"))?;
|
||||
+ if logical.len() > MAX_CLUSTER_BYTES || logical.encode_utf16().take(MAX_CLUSTER_UNITS + 1).count() > MAX_CLUSTER_UNITS {
|
||||
+ return Err(invalid("cluster exceeds the existing ToUnicode 512-byte destination contract"));
|
||||
+ }
|
||||
+ let glyph = run.glyphs.get(g0).ok_or_else(|| invalid("cluster glyph is outside the run"))?;
|
||||
+ if ![glyph.x, glyph.rise, glyph.advance].into_iter().all(within) {
|
||||
+ return Err(invalid("placed glyph is outside B1 coordinates"));
|
||||
+ }
|
||||
+ let intrinsic = font.glyphs.get(&glyph.id).ok_or_else(|| invalid("glyph is absent from the B1 font table"))?;
|
||||
+ if intrinsic.advance != intrinsic_advance(font, glyph.id)? {
|
||||
+ return Err(invalid("B1 intrinsic advance does not match the admitted hmtx"));
|
||||
+ }
|
||||
+ glyph_end = g1;
|
||||
+ text_end = if run.rtl { t0 } else { t1 };
|
||||
+ }
|
||||
+ if glyph_end != run.glyphs.len() || text_end != if run.rtl { 0 } else { run.text.len() } {
|
||||
+ return Err(invalid("clusters do not cover the whole run exactly once"));
|
||||
+ }
|
||||
+ Ok(run.glyphs.len())
|
||||
+}
|
||||
+
|
||||
+/// One bundled font and ALL its runs, in their existing paint order. Code keys
|
||||
+/// are sorted (original GID, literal text), so iteration/export repetition cannot
|
||||
+/// change their allocation. Code zero stays reserved for .notdef, not user text.
|
||||
+pub(super) fn plan_text<'a>(font: &'a Font, runs: &[&'a TextRun], max_plan_bytes: usize, max_subset_bytes: usize) -> Result<TextPlan<'a>> {
|
||||
+ let limit = max_plan_bytes.min(MAX_PLAN_BYTES);
|
||||
+ if limit == 0 || max_subset_bytes == 0 || runs.is_empty() || runs.len() > MAX_RUNS {
|
||||
+ return Err(invalid("empty allowance or unsupported run count"));
|
||||
+ }
|
||||
+ let descriptor = descriptor(font)?;
|
||||
+ let mut count = 0usize;
|
||||
+ let mut logical_bytes = 0usize;
|
||||
+ for run in runs {
|
||||
+ count = count.checked_add(check_run(font, run)?).filter(|n| *n <= MAX_GLYPHS)
|
||||
+ .ok_or_else(|| invalid("combined glyph count exceeds 16384"))?;
|
||||
+ logical_bytes = logical_bytes.checked_add(run.text.len()).filter(|n| *n <= MAX_LOGICAL_BYTES)
|
||||
+ .ok_or_else(|| invalid("combined logical text exceeds its allowance"))?;
|
||||
+ }
|
||||
+ total(&[
|
||||
+ bytes::<(u16, &str)>(count)?, bytes::<u16>(count)?, bytes::<CodeToGlyph<'_>>(count)?,
|
||||
+ bytes::<PlannedGlyph<'_>>(count)?, bytes::<PlannedRun<'_>>(runs.len())?,
|
||||
+ ], limit)?;
|
||||
+ let mut keys = reserve::<(u16, &str)>(count)?;
|
||||
+ let mut used = reserve::<u16>(count)?;
|
||||
+ let mut codes = reserve::<CodeToGlyph<'_>>(count)?;
|
||||
+ let mut planned_runs = reserve::<PlannedRun<'_>>(runs.len())?;
|
||||
+ let mut glyphs = reserve::<PlannedGlyph<'_>>(count)?;
|
||||
+ total(&[
|
||||
+ bytes::<(u16, &str)>(keys.capacity())?, bytes::<u16>(used.capacity())?, bytes::<CodeToGlyph<'_>>(codes.capacity())?,
|
||||
+ bytes::<PlannedGlyph<'_>>(glyphs.capacity())?, bytes::<PlannedRun<'_>>(planned_runs.capacity())?,
|
||||
+ ], limit)?;
|
||||
+ for run in runs {
|
||||
+ for cluster in &run.clusters {
|
||||
+ let glyph = run.glyphs.get(cluster.glyphs.start as usize).ok_or_else(|| invalid("checked glyph disappeared"))?;
|
||||
+ if glyph.id == 0 {
|
||||
+ return Err(invalid(".notdef cannot replace a missing text glyph"));
|
||||
+ }
|
||||
+ let logical = run.text.get(cluster.text.start as usize..cluster.text.end as usize).ok_or_else(|| invalid("checked text disappeared"))?;
|
||||
+ keys.push((glyph.id, logical));
|
||||
+ }
|
||||
+ }
|
||||
+ keys.sort_unstable();
|
||||
+ keys.dedup();
|
||||
+ for (gid, _) in &keys {
|
||||
+ if used.last() != Some(gid) {
|
||||
+ used.push(*gid);
|
||||
+ }
|
||||
+ }
|
||||
+ if !font.glyphs.keys().copied().eq(used.iter().copied()) {
|
||||
+ return Err(invalid("the font table is not exactly the glyphs of the supplied runs"));
|
||||
+ }
|
||||
+ // Reuse the real existing admission, composite closure and subset library.
|
||||
+ // There is no duplicate raw-font admission hash or copied font implementation.
|
||||
+ let subset = subset_font(&font.bytes, font.face, &used, max_subset_bytes)?;
|
||||
+ for (index, (gid, logical)) in keys.iter().enumerate() {
|
||||
+ let code = u16::try_from(index + 1).map_err(|_| invalid("text codes exceed u16"))?;
|
||||
+ let at = subset.mapping().binary_search_by_key(gid, |(old, _)| *old).map_err(|_| invalid("subset lost an original glyph"))?;
|
||||
+ let (_, subset_gid) = subset.mapping().get(at).ok_or_else(|| invalid("subset mapping is incomplete"))?;
|
||||
+ codes.push(CodeToGlyph { code, subset_gid: *subset_gid, logical });
|
||||
+ }
|
||||
+ for run in runs {
|
||||
+ let first = glyphs.len();
|
||||
+ let (sin, cos) = run.angle.sin_cos();
|
||||
+ for logical_index in 0..run.clusters.len() {
|
||||
+ let at = if run.rtl { run.clusters.len() - 1 - logical_index } else { logical_index };
|
||||
+ let cluster = run.clusters.get(at).ok_or_else(|| invalid("checked cluster disappeared"))?;
|
||||
+ let source = cluster.glyphs.start as usize;
|
||||
+ let glyph = run.glyphs.get(source).ok_or_else(|| invalid("checked glyph disappeared"))?;
|
||||
+ let logical = run.text.get(cluster.text.start as usize..cluster.text.end as usize).ok_or_else(|| invalid("checked text disappeared"))?;
|
||||
+ let key = keys.binary_search(&(glyph.id, logical)).map_err(|_| invalid("text code is absent"))?;
|
||||
+ let code = codes.get(key).ok_or_else(|| invalid("text code is outside its table"))?.code;
|
||||
+ let matrix_mm = [
|
||||
+ cos, sin, cos * run.slant - sin, sin * run.slant + cos,
|
||||
+ run.origin.x + cos * glyph.x - sin * glyph.rise,
|
||||
+ run.origin.y + sin * glyph.x + cos * glyph.rise,
|
||||
+ ];
|
||||
+ if !matrix_mm.iter().all(|n| n.is_finite()) {
|
||||
+ return Err(invalid("glyph transform is not finite"));
|
||||
+ }
|
||||
+ glyphs.push(PlannedGlyph {
|
||||
+ code, original_gid: glyph.id, source_glyph_index: source,
|
||||
+ intrinsic_advance_fu: intrinsic_advance(font, glyph.id)?, placed_advance_mm: glyph.advance,
|
||||
+ matrix_mm, logical,
|
||||
+ });
|
||||
+ }
|
||||
+ planned_runs.push(PlannedRun {
|
||||
+ glyphs: first..glyphs.len(), font_size_mm: run.scale * f64::from(font.units_per_em), rtl: run.rtl, text: &run.text,
|
||||
+ });
|
||||
+ }
|
||||
+ Ok(TextPlan { font, subset, descriptor, codes, runs: planned_runs, glyphs })
|
||||
+}
|
||||
diff --git a/crates/io/src/paper/pdf_tests.rs b/crates/io/src/paper/pdf_tests.rs
|
||||
--- a/crates/io/src/paper/pdf_tests.rs
|
||||
+++ b/crates/io/src/paper/pdf_tests.rs
|
||||
@@ -786,3 +786,214 @@
|
||||
assert!(super::content_with_limit(&grid.groups, prefix + tail.len() - 1).is_err());
|
||||
}
|
||||
}
|
||||
+
|
||||
+// P2 B1 text-plan acceptance: original records, not a replacement layout engine.
|
||||
+// Font constants are literal fields of the pinned original Liberation resource;
|
||||
+// synthetic logical aliases below test the record contract, not real shaping.
|
||||
+fn b1_plan_font(ids: &[(u16, u16)]) -> solidcraft_geom::paper::text::Font {
|
||||
+ use sha2::{Digest, Sha256};
|
||||
+ use solidcraft_geom::paper::text::{Font, FontFlags, FontGlyph, FontMetrics};
|
||||
+ let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(&include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/liberation-sans/LiberationSans-Regular.ttf"))[..]);
|
||||
+ let id = Sha256::new().chain_update(bytes.as_ref()).chain_update(0u32.to_le_bytes()).finalize().into();
|
||||
+ Font {
|
||||
+ bundled: 0, id, bytes, face: 0, postscript_name: "LiberationSans".into(), num_glyphs: 2620, units_per_em: 2048,
|
||||
+ metrics: FontMetrics {
|
||||
+ ascent: 1854, descent: -434, cap_height: 1409, bbox: [-1114, -621, 2666, 2007],
|
||||
+ italic_angle: 0.0, flags: FontFlags::default(), stem_v: 180,
|
||||
+ },
|
||||
+ glyphs: ids.iter().map(|&(id, advance)| (id, FontGlyph { advance, outline: vec![] })).collect(),
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+fn b1_plan_run(text: &str, ids: &[u16]) -> solidcraft_geom::paper::text::TextRun {
|
||||
+ use solidcraft_geom::paper::text::{Cluster, PlacedGlyph, TextRun};
|
||||
+ assert_eq!(text.chars().count(), ids.len());
|
||||
+ let chars: Vec<_> = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect();
|
||||
+ TextRun {
|
||||
+ font: 0, scale: 1.0 / 2048.0, origin: solidcraft_geom::Vec2::new(0.0, 0.0), angle: 0.0, slant: 0.0, rtl: false,
|
||||
+ glyphs: ids.iter().enumerate().map(|(i, &id)| PlacedGlyph { id, x: i as f64 * 0.375, rise: 0.0, advance: 0.5 }).collect(),
|
||||
+ text: text.into(),
|
||||
+ clusters: ids.iter().enumerate().map(|(i, _)| Cluster { glyphs: i as u32..i as u32 + 1, text: chars[i] as u32..chars[i + 1] as u32 }).collect(),
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_keeps_intrinsic_width_separate_from_placed_advance() {
|
||||
+ let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
+ let run = b1_plan_run("AV", &[36, 57]);
|
||||
+ let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
+ assert_eq!(plan.runs()[0].font_size_mm, 1.0);
|
||||
+ assert_eq!(plan.runs()[0].text, "AV");
|
||||
+ assert_eq!(plan.runs()[0].glyphs, 0..2);
|
||||
+ let g = &plan.glyphs()[1];
|
||||
+ assert_eq!((g.original_gid, g.intrinsic_advance_fu, g.placed_advance_mm), (57, 1366, 0.5));
|
||||
+ assert_eq!(g.matrix_mm, [1.0, 0.0, 0.0, 1.0, 0.375, 0.0]);
|
||||
+ let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
+ assert_eq!(input.postscript_name, b"LiberationSans");
|
||||
+ assert_eq!(input.descriptor.unwrap().ascent_1000, 905.2734375);
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_same_gid_with_distinct_logical_text_has_distinct_codes() {
|
||||
+ let font = b1_plan_font(&[(36, 1366)]);
|
||||
+ let first = b1_plan_run("A", &[36]);
|
||||
+ let second = b1_plan_run("B", &[36]);
|
||||
+ let plan = super::text_plan::plan_text(&font, &[&second, &first, &first], 1 << 20, 410_820).unwrap();
|
||||
+ assert_eq!(plan.glyphs().iter().map(|g| (g.code, g.logical)).collect::<Vec<_>>(), [(2, "B"), (1, "A"), (1, "A")]);
|
||||
+ let input = plan.font_input(*b"ABCDEF", 1 << 20);
|
||||
+ assert_eq!(input.codes.len(), 2);
|
||||
+ assert_eq!(input.codes[0].subset_gid, input.codes[1].subset_gid);
|
||||
+ assert_eq!((input.codes[0].logical, input.codes[1].logical), ("A", "B"));
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_preserves_quarter_turn_slant_and_mark_rise_once() {
|
||||
+ let font = b1_plan_font(&[(36, 1366)]);
|
||||
+ let mut run = b1_plan_run("A", &[36]);
|
||||
+ run.origin = solidcraft_geom::Vec2::new(100.0, 50.0);
|
||||
+ run.angle = std::f64::consts::FRAC_PI_2;
|
||||
+ run.slant = 0.25;
|
||||
+ run.glyphs[0].x = 3.0;
|
||||
+ run.glyphs[0].rise = 2.0;
|
||||
+ let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
+ for (actual, expected) in plan.glyphs()[0].matrix_mm.into_iter().zip([0.0, 1.0, -1.0, 0.25, 98.0, 53.0]) {
|
||||
+ assert!((actual - expected).abs() < 1e-12, "fixed quarter-turn f64 trig allowance: {actual} != {expected}");
|
||||
+ }
|
||||
+ assert_eq!(run.origin, solidcraft_geom::Vec2::new(100.0, 50.0));
|
||||
+ assert_eq!((run.glyphs[0].x, run.glyphs[0].rise), (3.0, 2.0));
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_single_glyph_literal_cluster_is_not_split_or_normalized() {
|
||||
+ let font = b1_plan_font(&[(36, 1366)]);
|
||||
+ let mut run = b1_plan_run("A", &[36]);
|
||||
+ run.text = "fi\u{301}".into();
|
||||
+ run.clusters[0].text = 0..4;
|
||||
+ let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
+ assert_eq!(plan.glyphs()[0].logical, "fi\u{301}");
|
||||
+ assert_eq!(plan.font_input(*b"ABCDEF", 1 << 20).codes[0].logical, "fi\u{301}");
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_rtl_keeps_positions_but_uses_logical_cluster_order() {
|
||||
+ let font = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
+ let mut run = b1_plan_run("AB", &[37, 36]);
|
||||
+ run.rtl = true;
|
||||
+ run.clusters[0].text = 1..2;
|
||||
+ run.clusters[1].text = 0..1;
|
||||
+ let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
+ assert!(plan.runs()[0].rtl);
|
||||
+ assert_eq!(plan.glyphs().iter().map(|g| g.logical).collect::<String>(), "AB");
|
||||
+ assert_eq!(plan.glyphs().iter().map(|g| g.source_glyph_index).collect::<Vec<_>>(), [1, 0]);
|
||||
+ assert_eq!((plan.glyphs()[0].matrix_mm[4], plan.glyphs()[1].matrix_mm[4]), (0.375, 0.0));
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_multi_glyph_cluster_refuses_instead_of_duplicating_text() {
|
||||
+ let font = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
+ let mut run = b1_plan_run("AB", &[36, 37]);
|
||||
+ run.text = "A".into();
|
||||
+ run.clusters.truncate(1);
|
||||
+ run.clusters[0].glyphs = 0..2;
|
||||
+ run.clusters[0].text = 0..1;
|
||||
+ let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
+ assert!(error.contains("multi-glyph cluster extraction is not supported"), "{error}");
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_tounicode_destination_limit_is_inclusive_and_never_split() {
|
||||
+ let font = b1_plan_font(&[(36, 1366)]);
|
||||
+ let mut run = b1_plan_run("A", &[36]);
|
||||
+ run.text = "A".repeat(256);
|
||||
+ run.clusters[0].text = 0..256;
|
||||
+ let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
+ assert_eq!(plan.glyphs()[0].logical.len(), 256);
|
||||
+ drop(plan);
|
||||
+ run.text.push('A');
|
||||
+ run.clusters[0].text.end += 1;
|
||||
+ let error = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).err().unwrap().to_string();
|
||||
+ assert!(error.contains("512-byte destination"), "{error}");
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_space_keeps_advance_and_text_despite_empty_outline() {
|
||||
+ let font = b1_plan_font(&[(3, 569)]);
|
||||
+ let run = b1_plan_run(" ", &[3]);
|
||||
+ let plan = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
+ assert_eq!((plan.glyphs()[0].original_gid, plan.glyphs()[0].intrinsic_advance_fu, plan.glyphs()[0].logical), (3, 569, " "));
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_refuses_wrong_identity_descriptor_face_and_intrinsic_width() {
|
||||
+ let font = b1_plan_font(&[(36, 1366)]);
|
||||
+ let run = b1_plan_run("A", &[36]);
|
||||
+ for kind in 0..5 {
|
||||
+ let mut wrong = font.clone();
|
||||
+ match kind {
|
||||
+ 0 => wrong.id[0] ^= 1,
|
||||
+ 1 => wrong.face = 1,
|
||||
+ 2 => wrong.metrics.stem_v = 181,
|
||||
+ 3 => wrong.metrics.flags.italic = true,
|
||||
+ _ => wrong.glyphs.get_mut(&36).unwrap().advance = 1024,
|
||||
+ }
|
||||
+ assert!(super::text_plan::plan_text(&wrong, &[&run], 1 << 20, 410_820).is_err());
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_refuses_invalid_partitions_unmatched_font_and_nonfinite_positions() {
|
||||
+ let font = b1_plan_font(&[(36, 1366)]);
|
||||
+ let run = b1_plan_run("A", &[36]);
|
||||
+ for kind in 0..6 {
|
||||
+ let mut wrong = run.clone();
|
||||
+ match kind {
|
||||
+ 0 => wrong.clusters[0].glyphs.end = 2,
|
||||
+ 1 => wrong.clusters[0].text.end = 2,
|
||||
+ 2 => wrong.clusters.clear(),
|
||||
+ 3 => wrong.font = 1,
|
||||
+ 4 => wrong.glyphs[0].x = f64::NAN,
|
||||
+ _ => { wrong.text = "\u{e9}".into(); wrong.clusters[0].text = 0..1; }
|
||||
+ }
|
||||
+ assert!(super::text_plan::plan_text(&font, &[&wrong], 1 << 20, 410_820).is_err());
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_bounds_storage_subsets_run_count_and_exact_used_font_table() {
|
||||
+ let font = b1_plan_font(&[(36, 1366)]);
|
||||
+ let run = b1_plan_run("A", &[36]);
|
||||
+ for (plan_bytes, subset_bytes) in [(0, 410_820), (1, 410_820), (1 << 20, 0), (1 << 20, 1)] {
|
||||
+ assert!(super::text_plan::plan_text(&font, &[&run], plan_bytes, subset_bytes).is_err());
|
||||
+ }
|
||||
+ assert!(super::text_plan::plan_text(&font, &vec![&run; 1025], 1 << 20, 410_820).is_err());
|
||||
+ let extra = b1_plan_font(&[(36, 1366), (37, 1366)]);
|
||||
+ assert!(super::text_plan::plan_text(&extra, &[&run], 1 << 20, 410_820).is_err());
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_repeats_real_subset_mapping_and_resource_inputs() {
|
||||
+ let font = b1_plan_font(&[(36, 1366), (57, 1366)]);
|
||||
+ let run = b1_plan_run("AV", &[36, 57]);
|
||||
+ let first = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
+ let second = super::text_plan::plan_text(&font, &[&run], 1 << 20, 410_820).unwrap();
|
||||
+ let a = first.font_input(*b"ABCDEF", 1 << 20);
|
||||
+ let b = second.font_input(*b"ABCDEF", 1 << 20);
|
||||
+ assert_eq!(a.subset.bytes(), b.subset.bytes());
|
||||
+ assert_eq!(a.subset.mapping(), [(0, 0), (36, 1), (57, 2)]);
|
||||
+ assert_eq!(a.subset.mapping(), b.subset.mapping());
|
||||
+ assert_eq!(a.codes.iter().map(|g| (g.code, g.subset_gid, g.logical)).collect::<Vec<_>>(), [(1, 1, "A"), (2, 2, "V")]);
|
||||
+ assert_eq!(a.descriptor.unwrap().flags, pdf_writer::types::FontFlags::NON_SYMBOLIC.bits());
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn b1_text_plan_font_byte_envelope_refuses_before_identity_or_table_work() {
|
||||
+ let font = b1_plan_font(&[(36, 1366)]);
|
||||
+ let run = b1_plan_run("A", &[36]);
|
||||
+ for len in [0, 410_819, 410_821] {
|
||||
+ let mut wrong = font.clone();
|
||||
+ // Identity is deliberately stale too: byte-envelope refusal has priority.
|
||||
+ wrong.bytes = std::sync::Arc::from(vec![0; len]);
|
||||
+ let error = super::text_plan::plan_text(&wrong, &[&run], 1, 410_820).err().unwrap().to_string();
|
||||
+ assert!(error.contains("font byte length is outside the reviewed resource envelope"), "{error}");
|
||||
+ }
|
||||
+}
|
||||
@@ -0,0 +1,45 @@
|
||||
# P2 additive composition: B1 widths + actual #9647 references
|
||||
|
||||
SOURCE ONLY — UNFORMATTED, UNCOMPILED, UNRUN, UNREGISTERED. This is a new private composed successor. The width 9379, Refs 8ebe and compatibility b68 packets remain immutable. No Cam parent function, shared source, index, Git/GitHub, dependency/lock, module registration, build or runtime was changed.
|
||||
|
||||
## What was composed
|
||||
|
||||
The B1 width-provider `font_objects.rs` is the starting point. The exact 28-line Refs reservation adapter is added to it; removing that exact adapter reproduces the entire width-provider candidate byte for byte. We did **not** replace the helper with the older full Refs candidate and restore production MetricView. Production widths still use the actual `source_font`, map subset GIDs back to original GIDs, and read `FontGlyph.advance / units_per_em`. The old SFNT reader remains only inside the test module as its unchanged independent oracle. Source/face identity checks, missing-width errors and the strict missing-GID 0 DW refusal remain unchanged.
|
||||
|
||||
The added private reservation function uses Cam's actual `Refs` type/`next()` six times, validates with the existing helper policy, and commits its staging cursor only after all six IDs are admissible. No second persistent allocator, fixed 6+layers arithmetic, duplicated cap, font-tag policy or parent call site is added.
|
||||
|
||||
The text-plan consumer is the exact width-successor copy, whose sole change from b68 is `source_font: self.font` in `FontObjectInput`. Its actual B1 positions, intrinsic/placed distinction and compatibility checks are not edited here.
|
||||
|
||||
## All written acceptance preserved
|
||||
|
||||
- Helper: all 11 earlier test bodies and the 13 original width-provider cases are byte-identical to the width successor: 24 declarations.
|
||||
- Existing `pdf_tests.rs`: Cam's RAW 28-case source plus the exact 8 Refs cases form the unchanged prefix. The original 6 P2 appendix cases and original 13 compatibility-plan cases then append unchanged: 55 declarations.
|
||||
- The remaining unchanged P2 helper modules contain 35 further declarations. A later normally registered assembly retaining those modules therefore has 114 declarations in these components. This is source accounting, **not** a compiler inventory or executed test count.
|
||||
|
||||
No original test, assertion, expected value, tolerance, ignore or seed was changed. Separate appendix copies show exact provenance; do not append the 13 width cases again, since they already live in the helper. The old standalone helpers/packet endpoints must not overwrite this composed source during adoption.
|
||||
|
||||
## Baselines and owner window
|
||||
|
||||
Actual B1a/B1b sources remain 9512`8da9e989` / 9513`df3eff1c`. Cam's actual many-page source is 9647`ce505658`, sole parent 506d4190, whose Refs allocates catalog/tree, page/content pairs, Info, then shared layers. The canonical old P2 helper is bf2a6fd5. Exact source hashes and blobs are in `source-bindings.json`.
|
||||
|
||||
The cumulative patch is deliberately bound per file: helper against canonical P2, consumer against the b68 typed plan, and tests against actual Cam 9647. These are not yet one integrated Git base. The separate deltas show adding Refs to the width helper and adding widths to the old Refs helper. They are alternatives for inspecting the same final helper, not sequential patches to apply twice. The tests delta from the Refs candidate preserves its complete RAW prefix and appends older owned cases.
|
||||
|
||||
Cam's `paper_pdf`, `paper_pdf_pages`, `Refs`, `collect_layers`, page resources and content remain untouched; their pinned copy is read-only reference evidence. The adapter is not wired into his builder. Parent font tags/resources, aggregate limits, actual emitted-CID coverage, prepared/grid text preservation and real page content are still integration work.
|
||||
|
||||
## Explicit blockers retained
|
||||
|
||||
1. **Default width:** our existing owner question 6532370208 is still pending at the recorded live read. The actual B1 table contains only placed glyphs and normally lacks unused GID 0. Font-object preparation still refuses that case; no fallback, extra B1 field, unused glyph insertion or new DW policy was invented. No duplicate public question was posted.
|
||||
2. **Trusted font records:** bytes/face identity does not attest arbitrary JSON/mutated B1 advances. Production use needs the real trusted B1 producer/rehydration boundary. No new provenance API or second production hmtx authority is invented here.
|
||||
3. **Font/text emission:** no composed parent resource/content/allocator registration or full content/CID proof exists. Multi-glyph ActualText remains explicitly unsupported by the preserved typed plan; layout/RTL/extraction are not qualified.
|
||||
4. **Old one-page bytes:** source tracing supports unchanged one-page inputs/write order, but actual 506d-versus-ce505 output comparison remains unrun. Cam's wrapper-versus-builder test compares two routes to the new implementation, not a frozen old-source output. Source/text identity is not PDF-byte acceptance.
|
||||
5. **Execution:** actual source/toolchain/dependency-lock admission, independent composed review, sole-builder compiler/native/wasm/strict/repository gate, PDF-reader/render/extraction and final merge checks remain pending. No earlier pass or resource grant transfers to this source.
|
||||
|
||||
Changes a default: no. Original team adapter/cases preserve existing B1, P2 and Cam authorship. No new asset, dependency, external implementation or commercial CAD data is included.
|
||||
|
||||
## Archive status
|
||||
|
||||
This README records proposal preparation. The subsequent independent source
|
||||
composition review found no additional actionable composition defect. Publishing
|
||||
these files preserves the code; it does not satisfy the dependency, trust,
|
||||
registration, emitted-font or execution gates listed above. No complete checkout
|
||||
or single integrated Git base is supplied by this three-path patch.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"repository": "tangency-labs/solidcraft",
|
||||
"status": "PRIVATE_ADDITIVE_COMPOSITION_SOURCE_ONLY_UNFORMATTED_UNCOMPILED_UNRUN_UNREGISTERED",
|
||||
"component_manifests": [
|
||||
{
|
||||
"name": "B1 width provider",
|
||||
"sha256": "9379fd44c71be48739eb4b9f66f548fcc17e0bfb56e191fe9873fc3325faabe6"
|
||||
},
|
||||
{
|
||||
"name": "actual9647 Refs seam",
|
||||
"sha256": "8ebe216ff83edef5afe30cf96331c0a5155e80e2375371cb8927c493f42a71b0"
|
||||
},
|
||||
{
|
||||
"name": "original B1 compatibility plan",
|
||||
"sha256": "b68f29cf6f28159a04996a4466724ef0ad1a0f4c34f904cf7e966c4e9ff72b89"
|
||||
}
|
||||
],
|
||||
"actual_sources": {
|
||||
"B1a": {
|
||||
"PR": 9512,
|
||||
"head": "8da9e9893d7dcf2c4b3584f6334b91352dc31dcb"
|
||||
},
|
||||
"B1b": {
|
||||
"PR": 9513,
|
||||
"head": "df3eff1ce9768c1d2cab3ed7cdb91471dd30e95f"
|
||||
},
|
||||
"P2_legacy_helpers": {
|
||||
"head": "bf2a6fd567b03e1d25dcb66624e719a0cec109fc",
|
||||
"font_objects_sha256": "aef06369bad659706b1a30ebedfce1b2d8e89eaa4849e98d618bd3cf9ecb1eb1"
|
||||
},
|
||||
"Cam_manypage": {
|
||||
"PR": 9647,
|
||||
"head": "ce505658375f2b2c87ca39f45f328fb2f9107738",
|
||||
"parent": "506d419084a1cd6463c3117ea34d15f805a6ce37",
|
||||
"pdf_rs_blob": "f58b70e654d0a562811cfe1fe498ae9bf22a29ed",
|
||||
"pdf_rs_sha256": "78557b1499620bb94069a0f7dcb67054412a165361656eabdbf2d67b92443d3e",
|
||||
"pdf_tests_blob": "745772f0c08a441dbbdae8ac20b27c0964d90142",
|
||||
"pdf_tests_sha256": "05a100fcad4e40734c6a7ac16e83f7fe24acaa2328f2471c64a24ea91d040473"
|
||||
}
|
||||
},
|
||||
"mixed_baselines": "The cumulative three-path patch uses exact per-file component baselines, not a claim that all files came from one composed Git head. Select and bind an actual integrated base before application/build.",
|
||||
"owned_candidates": [
|
||||
{
|
||||
"path": "crates/io/src/paper/pdf/font_objects.rs",
|
||||
"before_sha256": "aef06369bad659706b1a30ebedfce1b2d8e89eaa4849e98d618bd3cf9ecb1eb1",
|
||||
"candidate_sha256": "0fa3690fe9a3b5846593a9b9dbb624753ce3f172fae0801b664ae752a0353109",
|
||||
"candidate_bytes": 57072
|
||||
},
|
||||
{
|
||||
"path": "crates/io/src/paper/pdf/text_plan.rs",
|
||||
"before_sha256": "b9e94d50d10fd13e163b7ac8cd6dd6867fc0842569a7e9a762ff9d1b4c68f902",
|
||||
"candidate_sha256": "3e6ddbda7c7b5a12d4fb1098bbc441349510d54628468e0e42893625a3a20914",
|
||||
"candidate_bytes": 16318
|
||||
},
|
||||
{
|
||||
"path": "crates/io/src/paper/pdf_tests.rs",
|
||||
"before_sha256": "05a100fcad4e40734c6a7ac16e83f7fe24acaa2328f2471c64a24ea91d040473",
|
||||
"candidate_sha256": "bc4f1d08bde20e200456430fd2e41f3d0d67c4fb16203ca9cc081d6b27c25754",
|
||||
"candidate_bytes": 69534
|
||||
}
|
||||
],
|
||||
"appendix_order": "Current Refs tests (Cam28 + our8) retained as RAW prefix; append unchanged originalP2six then compatibility13. Width13 are already inline in the existing helper (not duplicated).",
|
||||
"remaining_P2_helper_sources": [
|
||||
{
|
||||
"dest": "crates/io/src/paper/pdf/font_admission.rs",
|
||||
"tests": 0,
|
||||
"prefix": "paper::pdf::font_admission::tests::",
|
||||
"sha256": "a8abc3b4aaef9325694ec404512c228f9973b82006b5718eb304ef57d5c30b23"
|
||||
},
|
||||
{
|
||||
"dest": "crates/io/src/paper/pdf/font_admission_tests.rs",
|
||||
"tests": 6,
|
||||
"prefix": "paper::pdf::font_admission::tests::",
|
||||
"sha256": "b58637cf322658d93a3abb61acf2c3a970c466a7401edf69a3a0594e38c9da7a"
|
||||
},
|
||||
{
|
||||
"dest": "crates/io/src/paper/pdf/font_objects.rs",
|
||||
"tests": 11,
|
||||
"prefix": "paper::pdf::font_objects::tests::",
|
||||
"sha256": "aef06369bad659706b1a30ebedfce1b2d8e89eaa4849e98d618bd3cf9ecb1eb1"
|
||||
},
|
||||
{
|
||||
"dest": "crates/io/src/paper/pdf/glyph_closure.rs",
|
||||
"tests": 12,
|
||||
"prefix": "paper::pdf::glyph_closure::tests::",
|
||||
"sha256": "a8d94c9996a5c718346e66d1983e6d2f2edb224286d3f45e92d731ab0013f1d8"
|
||||
},
|
||||
{
|
||||
"dest": "crates/io/src/paper/pdf/subset_font.rs",
|
||||
"tests": 6,
|
||||
"prefix": "paper::pdf::subset_font::tests::",
|
||||
"sha256": "a9808063f2223fd9e86e208d55092b6b9e9a5ae7ba0eaba2dce5d557a7fd5f26"
|
||||
},
|
||||
{
|
||||
"dest": "crates/io/src/paper/pdf/to_unicode.rs",
|
||||
"tests": 11,
|
||||
"prefix": "paper::pdf::to_unicode::tests::",
|
||||
"sha256": "ba60e39cf6ed9a27475b12258b0979aa8f8659d482b5246e32ee6df3abe50d49"
|
||||
},
|
||||
{
|
||||
"dest": "APPEND_UNCHANGED_TO_crates/io/src/paper/pdf_tests.rs",
|
||||
"tests": 6,
|
||||
"prefix": "paper::pdf::tests::",
|
||||
"sha256": "bd2dbf92283263a548d98455fcfbe6363a11c5cfa2a88f940eee0f87247eed70"
|
||||
}
|
||||
],
|
||||
"owner_question": {
|
||||
"url": "https://github.com/tangency-labs/solidcraft/pull/7626#issuecomment-6532370208",
|
||||
"state_at_read": "No later third-party answer in the fetched conversation; no duplicate question/comment posted."
|
||||
},
|
||||
"Cam_parent_candidate": "No changed pdf.rs supplied. reference-unchanged/ce505658 is a byte-verified read-only source copy only.",
|
||||
"no_dependency_lock_registration_or_command_changes": true
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# Tests NOT RUN — Drawings P2 width/Refs composition
|
||||
|
||||
These are79 source declarations in the selected three-file composition (24font helper,55PDFtests), not a compiler-discovered/registered suite or execution result. No Rust tests, PDF writer/reader, renderer or extraction acceptance ran. The other35 P2 declarations referred to in the source provenance are outside this selected payload and are not independently inventoried here. DW/GID0, trusted B1 producer, parent font emission/registration and exact predecessor PDF-byte acceptance remain pending.
|
||||
|
||||
| Source path | Declaration | State |
|
||||
| --- | --- | --- |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | six_real_refs_link_exact_objects_and_preserve_raw_font_bytes | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | distinct_codes_share_a_gid_without_losing_literal_logical_clusters | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | sparse_maximum_cid_has_bounded_map_and_zero_stays_notdef | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | widths_use_the_mapped_subset_gid_but_are_indexed_by_cid | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | invalid_code_order_gid_and_logical_clusters_refuse | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | essential_descriptor_fields_refuse_missing_invalid_and_underflow_values | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | fixed_name_tag_and_real_ref_constraints_are_explicit | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | preflight_byte_allowance_is_inclusive_and_actual_serialization_is_below_it | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | repeated_complete_inputs_are_deterministic_and_leave_source_unchanged | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | full_u16_code_space_is_inclusive_and_one_extra_record_refuses | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | source_derived_p3_offset_arithmetic_is_a_caller_reservation_not_a_registry | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_use_original_gid_join_and_cid_index_with_distinct_real_advances | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_keep_real_zero_advance_and_exact_intrinsic_units | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_arithmetic_has_literal_zero_fraction_and_u16_envelope_oracles | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_arithmetic_refuses_zero_and_out_of_contract_units_without_dividing | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_arithmetic_preserves_inclusive_serialized_metric_bound | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_missing_original_advance_refuses_without_subset_metric_fallback | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_unemitted_closure_glyphs_do_not_require_invented_b1_entries | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_omitted_notdef_still_plans_used_width_but_blocks_default_policy | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_raw_digest_cannot_replace_b1_bytes_plus_face_identity | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_byte_envelope_refuses_before_identity_work | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_fixed_face_upem_glyph_count_and_used_id_caps_stay_closed | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_missing_subset_ids_refuse_even_when_b1_has_other_widths | NOT RUN |
|
||||
| crates/io/src/paper/pdf/font_objects.rs | b1_widths_binding_and_emission_leave_source_records_program_and_mapping_unchanged | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | an_a3_sheet_is_its_size_in_points | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | fifty_millimetres_on_paper_are_141_732_points | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | the_same_scene_gives_the_same_bytes_and_dates_come_only_from_output_time | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | every_cross_reference_points_at_its_object | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | the_byte_offset_oracle_refuses_shifted_or_corrupted_references | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | the_number_oracle_separates_page_scale_from_the_coordinate_grid | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | arcs_are_cubics_within_a_ten_thousandth_of_a_millimetre | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | strokes_and_fills_are_painted_as_the_other_writers_paint_them | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | what_cannot_be_written_is_refused | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | content_budget_includes_the_header_and_the_final_graphics_restore | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | content_budget_stops_inside_one_long_path_before_its_paint_operator | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | content_budget_stops_inside_one_arcs_cubic_expansion | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | content_budget_is_cumulative_over_groups_and_fill_loops | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | content_budget_rejects_an_append_before_mutating_existing_bytes | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | content_budget_reuses_the_original_formatter_at_integer_extremes | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | pdf_layers_share_object_kinds_and_keep_noncontiguous_paint_order | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | pdf_layers_cover_all_five_kinds_and_preserve_paint_and_styles | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | pdf_layers_obey_content_budget_and_skip_empty_groups | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | flat_pdf_omits_optional_content_and_preserves_vector_paint | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | flat_pdf_empty_scene_and_content_budget_keep_their_boundaries | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | flat_pdf_reproduces_the_original_p1_a3_sample_bytes | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | adjacent_pdf_groups_share_one_run_without_reordering_noncontiguous_layers | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | empty_pdf_groups_do_not_break_adjacent_painted_runs_or_invent_layers | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | a_merged_pdf_run_keeps_all_paint_and_shared_width_and_dash_state | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | merged_pdf_run_limits_charge_transitions_and_the_final_close | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | one_page_is_the_one_page_call_byte_for_byte | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | pages_follow_in_order_each_its_sheets_size_sharing_one_layer_a_kind | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | pages_are_refused_past_their_limits | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_font_refs_follow_actual_one_page_core_without_fixed_number_allocation | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_font_refs_follow_real_three_page_info_and_shared_layer_counter | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_font_refs_multiple_fonts_consume_disjoint_six_object_windows | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_font_refs_refuse_core_ids_and_leave_parent_cursor_unchanged | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_font_refs_keep_existing_inclusive_resource_ref_limit | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_font_refs_range_and_partial_overflow_refusals_do_not_consume_any_refs | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_font_refs_nonpositive_cursor_refuses_before_pdf_writer_ref_construction | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_font_refs_identical_parent_contexts_produce_identical_refs_and_next_cursor | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_tounicode_literal_utf16be_covers_ascii_bmp_supplementary_and_clusters | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_tounicode_one_hundred_and_one_mapping_blocks_have_exact_independent_bytes | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_tounicode_destination_boundary_is_512_decoded_bytes_not_utf8_or_hex_length | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_tounicode_u16_extremes_repeated_destinations_and_strict_source_order | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_tounicode_cumulative_output_budget_is_based_on_literal_bytes_including_tail | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | p2_tounicode_empty_table_has_complete_codespace_and_no_zero_mapping_block | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_keeps_intrinsic_width_separate_from_placed_advance | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_same_gid_with_distinct_logical_text_has_distinct_codes | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_preserves_quarter_turn_slant_and_mark_rise_once | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_single_glyph_literal_cluster_is_not_split_or_normalized | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_rtl_keeps_positions_but_uses_logical_cluster_order | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_multi_glyph_cluster_refuses_instead_of_duplicating_text | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_tounicode_destination_limit_is_inclusive_and_never_split | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_space_keeps_advance_and_text_despite_empty_outline | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_refuses_wrong_identity_descriptor_face_and_intrinsic_width | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_refuses_invalid_partitions_unmatched_font_and_nonfinite_positions | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_bounds_storage_subsets_run_count_and_exact_used_font_table | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_repeats_real_subset_mapping_and_resource_inputs | NOT RUN |
|
||||
| crates/io/src/paper/pdf_tests.rs | b1_text_plan_font_byte_envelope_refuses_before_identity_or_table_work | NOT RUN |
|
||||
@@ -0,0 +1,44 @@
|
||||
# V2 error argument and unit composition
|
||||
|
||||
This is original source reasoning, NOT an executed formal proof or runtime result. The v1 derivation is retained byte-for-byte in v1/DERIVATION.md. Existing construction/provenance and its Bernstein-control argument remain; no external implementation is copied.
|
||||
|
||||
## Extension from a quarter to a half turn
|
||||
|
||||
For an exact-real subarc theta with0<abs(theta)<=pi, beta=abs(theta)/4 lies in(0,pi/4]. The unchanged tangent/midpoint cubic has
|
||||
|
||||
||B(t)||^2/r^2 -1 =16 sin(beta)^6/cos(beta)^2 *t^2(1-t)^2(2t-1)^2.
|
||||
|
||||
With z=t(1-t), z^2(1-4z)<=1/108, so radial deviation is at most
|
||||
r*(2/27)*sin(beta)^6/cos(beta)^2.
|
||||
|
||||
The ideal cubic stays in the circular sector: in the symmetric coordinates its endpoint controls are(c,-s),(c+k*s,-s+k*c),(c+k*s,s-k*c),(c,s), with c=cos(theta/2)>=0 and k=(4/3)tan(theta/4). The sector is convex through a half turn; interior x is positive, including at theta=pi. Its continuous argument covers the arc's endpoint angles. Radial projection in both directions gives the Hausdorff bound. The negative sweep is a reflection.
|
||||
|
||||
Because beta<=pi/4<1,
|
||||
sin(beta)<=beta and cos(beta)^2>=1-beta^2>0.
|
||||
The existing larger r*(2/27)*beta^6/(1-beta^2) therefore remains valid; its proof does not require beta<=pi/8. At a half turn this deliberately conservative bound may need more pieces than the tight formula.
|
||||
|
||||
The angle endpoint range becomes[-2pi,2pi]. The same24-term Taylor sums and outward remainder recurrences are retained, with remainder bounds |x|^49/49! and |x|^48/48!. Those derivative bounds are global, so no new period-reduction or libm assumption is introduced. Wider intervals can refuse certification rather than relax error.
|
||||
|
||||
Each stored control remains inside its computed enclosure; its max L1 displacement bounds the cubic perturbation because Bernstein weights are nonnegative and sum to1. Adding the largest control uncertainty to the outward ideal bound proves the result's error_bound in the arc's own units.
|
||||
|
||||
## Finite generic input is not authored paper admission
|
||||
|
||||
The converter checks finite center, finite radius>0, start in[-PI,PI],0<abs(sweep)<=PI, finite error>0 and cap1..64. It deliberately does not call Seg2::check(MAX_COORDINATE_MM): that validator fixes authored mm geometry, whereas Piece::seg returns grid steps. A finite positive subnormal radius is not an invalid radius; representability, collapse or a failed certificate can still refuse it. Zero and negative radius refuse before arithmetic.
|
||||
|
||||
No source coordinate/radius clamp, saturation, normalization or catch-and-return-success exists. Arithmetic overflow/nonfinite enclosure, precision collapse, allocation failure and unqualified error discard the local result. The search still has at most64 trials/2080 attempted pieces and64 logical temporary pieces, with unchanged fixed scalar recurrence bounds. This is not a measured memory/time claim.
|
||||
|
||||
## What the writer must prove
|
||||
|
||||
If the helper returns a stored cubic C with d_H(C,A)<=E and the writer changes each stored control Pi to Qi, Bernstein convexity gives a second displacement bound
|
||||
D=max_i ||Qi-Pi|| (or a conservative L1 bound).
|
||||
Then d_H(Q,A)<=E+D. This includes replacements of the first or last endpoint: it is not enough to call an arbitrary replacement 'rounding'.
|
||||
|
||||
For nearest-grid rounding alone, D<=sqrt(2)/2 grid steps. When the last endpoint is forcibly set to the walked to, the adapter must bound that actual displacement too; the generic helper cannot certify the caller's chosen target. Piece::seg's reconstructed f64 center/start/sweep need not make its exact-real endpoint identical to the integer to. No completed end-replacement proof or adapter is claimed here.
|
||||
|
||||
On the actual TEN_NANOMETRES grid, one millimetre is100000 steps and ARC_TOLERANCE_MM1e-4 is10 steps. The writer reserves its rounding/replacement allowance before asking the helper, and converts the final report to mm exactly once. SVG/shared-walk records and all existing writer code remain unchanged in this packet.
|
||||
|
||||
## Independent expectations
|
||||
|
||||
Half-turn controls and midpoint are literal unit-circle geometry. The slightly-over-quarter endpoint sign/range follows delta=1/4096 and delta-delta^3/6<=sin(delta)<=delta. The quarter certificate floor follows the analytic maximum z=1/6; the known deviation exceeds0.00027 radius, so a false zero certificate fails. Tests do not use dense radial samples to supply a certificate or record writer output as the expected answer.
|
||||
|
||||
The thirteen written helper tests and the separate two writer fixtures are NOT COMPILED and NOT RUN.
|
||||
@@ -0,0 +1,13 @@
|
||||
# W10 arc converter v2 - unintegrated source checkpoint
|
||||
|
||||
This archives original work following Cam's contract decision in #4890, comment6518014281. It is **not applied, compiled, tested, registered as a production API, or ready to merge**. Independent mathematical/source review is pending. Historical v1 remains preserved in the existing W10 archive.
|
||||
|
||||
`cubics.rs` and `cubics-v2-and-tests.patch` provide the same-units converter and thirteen new analytical tests appended to the existing geometry test file. Signed sweeps through a half turn, finite positive radii, representability/certificate refusals and caps are explicit. No production module hook or writer adapter is included. See DERIVATION-v2.md and manifest.json for the source argument, limits and actual receiver pins.
|
||||
|
||||
`pdf-off-grid-two-tests.patch` is a SEPARATE proposal against the pinned PDF PR receiver in manifest.json, not against the geometry receiver. The two ordinary-circle fixtures came from Cam's simulation. The real walk/Piece::seg/export tests have not run; neither a reproduced writer defect nor successful output is claimed. Pair them with the actual production correction/adapter before a gate. Do not mix their source bases or add failing tests to main.
|
||||
|
||||
The writer must compose the converter certificate with the actual displacement of every rounded or replaced control. A forced walked endpoint is not automatically within a half-step diagonal of the stored endpoint; that proof and the adapter are still pending. No new public API, consumer adoption, font/dependency/asset change or claim of prior validation is made.
|
||||
|
||||
Separately, Cam has supplied B1's intrinsic glyph widths alongside outlines in FontGlyph (comment6518550794). This resolves the record-level PDF width question, but the author says no B1 implementation exists yet. P2 must bind to actual delivered code, keeping intrinsic widths separate from placed glyph advances. Engineer acknowledged this in comment6519182623.
|
||||
|
||||
Source checkpoint only. Runtime, focused tests, lint/wasm, repository gates and final integration remain pending under the single-builder protocol.
|
||||
@@ -0,0 +1,440 @@
|
||||
diff --git a/crates/geom/src/paper/cubics.rs b/crates/geom/src/paper/cubics.rs
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/crates/geom/src/paper/cubics.rs
|
||||
@@ -0,0 +1,233 @@
|
||||
+//! PRIVATE V2 CANDIDATE: circular arcs to cubic pieces in the arc's own units.
|
||||
+//!
|
||||
+//! Owner boundary: issue4890 comment6518014281. Coordinates, radius, error and
|
||||
+//! returned error_bound share one length unit; angles remain radians.
|
||||
+//! No millimetre floor/ceiling from authored paper-scene admission is imposed.
|
||||
+//! Scene/Prepared/Grid validation and writer adapters remain unchanged.
|
||||
+//!
|
||||
+//! The construction is CamWight's original P1 tangent/midpoint circular cubic
|
||||
+//! (MIT OR Apache-2.0). The enclosure implementation is original project work.
|
||||
+//! Mathematical context: A. Riskus, Information Technology and Control35(4),
|
||||
+//! 2006, section3, doi:10.5755/j01.itc.35.4.11812. No external code is imported.
|
||||
+//!
|
||||
+//! For beta=abs(sweep)/(4*n)<=PI/4, the ideal radial deviation is at most
|
||||
+//! r*(2/27)*sin(beta)^6/cos(beta)^2, bounded above here by
|
||||
+//! r*(2/27)*beta^6/(1-beta^2). Stored controls use outward Taylor enclosures.
|
||||
+//! Their greatest L1 displacement bounds the cubic's positional perturbation
|
||||
+//! by Bernstein convexity. No system trigonometric accuracy is assumed.
|
||||
+//! Scalar operations require ordinary IEEE754 binary64 round-to-nearest with
|
||||
+//! gradual underflow, not a fast-math/flush-to-zero rewrite.
|
||||
+//!
|
||||
+//! The certificate excludes writer gridding and replacement of either endpoint.
|
||||
+//! Those displacements must be bounded separately by the consumer. The result
|
||||
+//! is not an authored/validated Scene and no writer is connected by this file.
|
||||
+
|
||||
+use std::f64::consts::PI;
|
||||
+
|
||||
+use crate::paper::Invalid;
|
||||
+use crate::{Seg2, Vec2};
|
||||
+
|
||||
+type Result<T> = std::result::Result<T, Invalid>;
|
||||
+
|
||||
+/// The inherited P1 per-arc ceiling; caller limits may only tighten it.
|
||||
+pub(crate) const MAX_ARC_CUBICS: usize = 64;
|
||||
+
|
||||
+/// A private candidate result, not a serialized record or a prepared scene.
|
||||
+#[derive(Clone, Debug, PartialEq)]
|
||||
+pub(crate) struct ArcCubics {
|
||||
+ pub(crate) pieces: Vec<Seg2>,
|
||||
+ /// Approximation plus stored-control uncertainty, in the arc's own units.
|
||||
+ /// Not a writer's gridding, endpoint-replacement or physical-print error.
|
||||
+ pub(crate) error_bound: f64,
|
||||
+}
|
||||
+
|
||||
+/// Converts one circular arc with an absolute error allowance in its own units.
|
||||
+/// Every successful piece is Seg2::Cubic. The center is finite, radius finite
|
||||
+/// and strictly positive, start in [-PI,PI], and 0<abs(sweep)<=PI. The caller
|
||||
+/// cap is1..=64. Unrepresentable/collapsed geometry or an uncertifiable request
|
||||
+/// refuses atomically; no normalization, scaling or input mutation occurs.
|
||||
+pub(crate) fn circular_arc(arc: &Seg2, error: f64, max_pieces: usize) -> Result<ArcCubics> {
|
||||
+ let Seg2::Arc { center, radius, start, sweep } = *arc else {
|
||||
+ return Err(Invalid("cubic arc candidate: only a circular arc is supported"));
|
||||
+ };
|
||||
+ if !center.is_finite() || !radius.is_finite() || radius <= 0.0 {
|
||||
+ return Err(Invalid("cubic arc candidate: finite center and finite strictly positive radius required"));
|
||||
+ }
|
||||
+ if !error.is_finite() || error <= 0.0 {
|
||||
+ return Err(Invalid("cubic arc candidate: the error allowance is finite and positive, in the arc units"));
|
||||
+ }
|
||||
+ if !(1..=MAX_ARC_CUBICS).contains(&max_pieces) {
|
||||
+ return Err(Invalid("cubic arc candidate: the piece cap is from1 to64"));
|
||||
+ }
|
||||
+ if !start.is_finite() || start.abs() > PI || !sweep.is_finite() || sweep == 0.0 || sweep.abs() > PI {
|
||||
+ return Err(Invalid("cubic arc candidate: start in [-PI,PI], nonzero sweep at most a half turn"));
|
||||
+ }
|
||||
+
|
||||
+ for n in 1..=max_pieces {
|
||||
+ let h = Enclosure::point(sweep).div(Enclosure::point(n as f64))?;
|
||||
+ let ideal_error = radial_bound(radius, h)?;
|
||||
+ if ideal_error > error {
|
||||
+ continue;
|
||||
+ }
|
||||
+ let mut pieces = Vec::new();
|
||||
+ pieces.try_reserve_exact(n).map_err(|_| Invalid("cubic arc candidate: cannot allocate bounded pieces"))?;
|
||||
+ let mut error_bound: f64 = 0.0;
|
||||
+ for i in 0..n {
|
||||
+ // The same expression evaluates each shared end once per neighbour,
|
||||
+ // yielding bit-identical stored endpoints, with no endpoint snap.
|
||||
+ let angle = |j: usize| {
|
||||
+ Enclosure::point(start).add(
|
||||
+ Enclosure::point(sweep).mul(Enclosure::point(j as f64).div(Enclosure::point(n as f64))?)?,
|
||||
+ )
|
||||
+ };
|
||||
+ let (cubic, control_error) = make_cubic(center, radius, angle(i)?, angle(i + 1)?, h)?;
|
||||
+ let total = Enclosure::point(ideal_error).add(Enclosure::point(control_error))?.hi;
|
||||
+ error_bound = error_bound.max(total);
|
||||
+ pieces.push(cubic);
|
||||
+ }
|
||||
+ if error_bound <= error {
|
||||
+ return Ok(ArcCubics { pieces, error_bound });
|
||||
+ }
|
||||
+ // Numerical uncertainty is part of the error, never an excuse to use a
|
||||
+ // looser tolerance. A later n may qualify; at the caller cap refuse all.
|
||||
+ }
|
||||
+ Err(Invalid("cubic arc candidate: cannot certify the requested mm error within the piece cap"))
|
||||
+}
|
||||
+
|
||||
+/// The exact-real radial bound with sin(beta)<=beta and
|
||||
+/// cos(beta)^2>=1-beta^2, evaluated outwards. The domain is beta<=PI/4, whose square is below1.
|
||||
+fn radial_bound(radius: f64, h: Enclosure) -> Result<f64> {
|
||||
+ let beta = Enclosure::point(h.abs_upper()).div(Enclosure::point(4.0))?;
|
||||
+ let squared = beta.mul(beta)?;
|
||||
+ let sixth = squared.mul(squared)?.mul(squared)?;
|
||||
+ let denominator = Enclosure::point(1.0).sub(squared)?;
|
||||
+ let coefficient = Enclosure::point(2.0).div(Enclosure::point(27.0))?;
|
||||
+ let bound = Enclosure::point(radius).mul(coefficient)?.mul(sixth)?.div(denominator)?;
|
||||
+ Ok(bound.hi)
|
||||
+}
|
||||
+
|
||||
+fn make_cubic(center: Vec2, radius: f64, a0: Enclosure, a1: Enclosure, h: Enclosure) -> Result<(Seg2, f64)> {
|
||||
+ let (s0, c0) = sin_cos(a0)?;
|
||||
+ let (s1, c1) = sin_cos(a1)?;
|
||||
+ let (sh, ch) = sin_cos(h.div(Enclosure::point(4.0))?)?;
|
||||
+ let r = Enclosure::point(radius);
|
||||
+ let handle = r.mul(Enclosure::point(4.0).div(Enclosure::point(3.0))?)?.mul(sh.div(ch)?)?;
|
||||
+ let cx = Enclosure::point(center.x);
|
||||
+ let cy = Enclosure::point(center.y);
|
||||
+ let x0 = cx.add(r.mul(c0)?)?;
|
||||
+ let y0 = cy.add(r.mul(s0)?)?;
|
||||
+ let x3 = cx.add(r.mul(c1)?)?;
|
||||
+ let y3 = cy.add(r.mul(s1)?)?;
|
||||
+ let (p0, e0) = stored_point(x0, y0)?;
|
||||
+ let (p1, e1) = stored_point(x0.sub(handle.mul(s0)?)?, y0.add(handle.mul(c0)?)?)?;
|
||||
+ let (p2, e2) = stored_point(x3.add(handle.mul(s1)?)?, y3.sub(handle.mul(c1)?)?)?;
|
||||
+ let (p3, e3) = stored_point(x3, y3)?;
|
||||
+ if p0 == p3 || p0 == p1 || p2 == p3 {
|
||||
+ return Err(Invalid("cubic arc candidate: coordinate precision collapses an endpoint or tangent"));
|
||||
+ }
|
||||
+ Ok((Seg2::Cubic { p0, p1, p2, p3 }, e0.max(e1).max(e2).max(e3)))
|
||||
+}
|
||||
+
|
||||
+/// Nonnegative Bernstein coefficients sum to1, so the greatest control
|
||||
+/// displacement bounds the complete curve displacement. L1 bounds Euclidean
|
||||
+/// distance without trusting a rounded sqrt.
|
||||
+fn stored_point(x: Enclosure, y: Enclosure) -> Result<(Vec2, f64)> {
|
||||
+ let px = x.midpoint();
|
||||
+ let py = y.midpoint();
|
||||
+ let ex = x.sub(Enclosure::point(px))?.abs_upper();
|
||||
+ let ey = y.sub(Enclosure::point(py))?.abs_upper();
|
||||
+ let error = Enclosure::point(ex).add(Enclosure::point(ey))?.hi;
|
||||
+ Ok((Vec2::new(px, py), error))
|
||||
+}
|
||||
+
|
||||
+/// Twenty-four terms, then an explicit Taylor/Lagrange remainder. Input
|
||||
+/// angles stay inside +/-2PI; no period reduction or system trig is needed.
|
||||
+/// sin's polynomial has degree47 (degree48 coefficient0), cos's degree46
|
||||
+/// (degree47 coefficient0), giving remainder orders49 and48 respectively.
|
||||
+fn sin_cos(x: Enclosure) -> Result<(Enclosure, Enclosure)> {
|
||||
+ let negative_square = x.mul(x)?.neg();
|
||||
+ let mut sine_term = x;
|
||||
+ let mut cosine_term = Enclosure::point(1.0);
|
||||
+ let mut sine = sine_term;
|
||||
+ let mut cosine = cosine_term;
|
||||
+ for k in 1..24 {
|
||||
+ let twice = (2 * k) as f64;
|
||||
+ sine_term = sine_term.mul(negative_square)?.div(Enclosure::point(twice * (twice + 1.0)))?;
|
||||
+ cosine_term = cosine_term.mul(negative_square)?.div(Enclosure::point((twice - 1.0) * twice))?;
|
||||
+ sine = sine.add(sine_term)?;
|
||||
+ cosine = cosine.add(cosine_term)?;
|
||||
+ }
|
||||
+ let sine_tail = taylor_tail(x.abs_upper(), 49)?;
|
||||
+ let cosine_tail = taylor_tail(x.abs_upper(), 48)?;
|
||||
+ Ok((
|
||||
+ sine.add(Enclosure { lo: -sine_tail, hi: sine_tail })?,
|
||||
+ cosine.add(Enclosure { lo: -cosine_tail, hi: cosine_tail })?,
|
||||
+ ))
|
||||
+}
|
||||
+
|
||||
+fn taylor_tail(magnitude: f64, order: usize) -> Result<f64> {
|
||||
+ let mut tail = Enclosure::point(1.0);
|
||||
+ for j in 1..=order {
|
||||
+ tail = tail.mul(Enclosure::point(magnitude))?.div(Enclosure::point(j as f64))?;
|
||||
+ }
|
||||
+ Ok(tail.hi)
|
||||
+}
|
||||
+
|
||||
+/// Local private arithmetic for this helper, not a second public interval API.
|
||||
+/// Exact f64 inputs denote single real numbers. Every operation expands each
|
||||
+/// rounded endpoint by one representable neighbour; nonfinite endpoints and
|
||||
+/// division by an interval containing zero fail closed.
|
||||
+#[derive(Clone, Copy, Debug)]
|
||||
+struct Enclosure {
|
||||
+ lo: f64,
|
||||
+ hi: f64,
|
||||
+}
|
||||
+
|
||||
+impl Enclosure {
|
||||
+ fn point(value: f64) -> Self {
|
||||
+ Self { lo: value, hi: value }
|
||||
+ }
|
||||
+
|
||||
+ fn outward(lo: f64, hi: f64) -> Result<Self> {
|
||||
+ let (lo, hi) = (lo.next_down(), hi.next_up());
|
||||
+ if lo.is_finite() && hi.is_finite() && lo <= hi {
|
||||
+ Ok(Self { lo, hi })
|
||||
+ } else {
|
||||
+ Err(Invalid("cubic arc candidate: arithmetic enclosure unavailable"))
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ fn add(self, rhs: Self) -> Result<Self> {
|
||||
+ Self::outward(self.lo + rhs.lo, self.hi + rhs.hi)
|
||||
+ }
|
||||
+
|
||||
+ fn neg(self) -> Self {
|
||||
+ Self { lo: -self.hi, hi: -self.lo }
|
||||
+ }
|
||||
+
|
||||
+ fn sub(self, rhs: Self) -> Result<Self> {
|
||||
+ self.add(rhs.neg())
|
||||
+ }
|
||||
+
|
||||
+ fn mul(self, rhs: Self) -> Result<Self> {
|
||||
+ let (lo, hi) = [self.lo * rhs.lo, self.lo * rhs.hi, self.hi * rhs.lo, self.hi * rhs.hi]
|
||||
+ .into_iter()
|
||||
+ .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v)));
|
||||
+ Self::outward(lo, hi)
|
||||
+ }
|
||||
+
|
||||
+ fn div(self, rhs: Self) -> Result<Self> {
|
||||
+ if rhs.lo <= 0.0 && rhs.hi >= 0.0 {
|
||||
+ return Err(Invalid("cubic arc candidate: denominator contains zero"));
|
||||
+ }
|
||||
+ self.mul(Self::outward(1.0 / rhs.hi, 1.0 / rhs.lo)?)
|
||||
+ }
|
||||
+
|
||||
+ fn abs_upper(self) -> f64 {
|
||||
+ self.lo.abs().max(self.hi.abs())
|
||||
+ }
|
||||
+
|
||||
+ fn midpoint(self) -> f64 {
|
||||
+ (self.lo * 0.5 + self.hi * 0.5).max(self.lo).min(self.hi)
|
||||
+ }
|
||||
+}
|
||||
diff --git a/crates/geom/src/paper_tests.rs b/crates/geom/src/paper_tests.rs
|
||||
--- a/crates/geom/src/paper_tests.rs
|
||||
+++ b/crates/geom/src/paper_tests.rs
|
||||
@@ -306,3 +306,198 @@
|
||||
v["groups"][0]["object"] = json!(u64::MAX);
|
||||
read(v).unwrap().validate().unwrap();
|
||||
}
|
||||
+
|
||||
+#[path = "paper/cubics.rs"]
|
||||
+mod circular_arc_v2_candidate;
|
||||
+
|
||||
+fn v2_arc(center: Vec2, radius: f64, start: f64, sweep: f64) -> Seg2 {
|
||||
+ Seg2::Arc { center, radius, start, sweep }
|
||||
+}
|
||||
+
|
||||
+fn v2_controls(piece: &Seg2) -> [Vec2; 4] {
|
||||
+ let Seg2::Cubic { p0, p1, p2, p3 } = *piece else { panic!("not a cubic: {piece:?}") };
|
||||
+ [p0, p1, p2, p3]
|
||||
+}
|
||||
+
|
||||
+fn v2_check(result: &circular_arc_v2_candidate::ArcCubics, error: f64, cap: usize) {
|
||||
+ assert!(!result.pieces.is_empty() && result.pieces.len() <= cap);
|
||||
+ assert!(result.error_bound.is_finite() && result.error_bound >= 0.0 && result.error_bound <= error);
|
||||
+ for piece in &result.pieces {
|
||||
+ let [a, b, c, d] = v2_controls(piece);
|
||||
+ assert!([a, b, c, d].into_iter().all(Vec2::is_finite));
|
||||
+ assert!(a != d && a != b && c != d);
|
||||
+ }
|
||||
+ for pair in result.pieces.windows(2) {
|
||||
+ let a = pair[0].end();
|
||||
+ let b = pair[1].start();
|
||||
+ assert_eq!((a.x.to_bits(), a.y.to_bits()), (b.x.to_bits(), b.y.to_bits()));
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+fn v2_near(actual: Vec2, expected: Vec2) {
|
||||
+ assert!(actual.dist(expected) <= 1e-9, "{actual:?} != {expected:?}");
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_signed_half_turn_has_analytic_controls_and_midpoint() {
|
||||
+ for sign in [-1.0, 1.0] {
|
||||
+ let result = circular_arc_v2_candidate::circular_arc(&v2_arc(Vec2::ZERO, 1.0, 0.0, sign * PI), 0.1, 1).unwrap();
|
||||
+ v2_check(&result, 0.1, 1);
|
||||
+ let [a, b, c, d] = v2_controls(&result.pieces[0]);
|
||||
+ for (actual, expected) in [(a, p(1.0, 0.0)), (b, p(1.0, sign * 4.0 / 3.0)), (c, p(-1.0, sign * 4.0 / 3.0)), (d, p(-1.0, 0.0))] {
|
||||
+ v2_near(actual, expected);
|
||||
+ }
|
||||
+ // The standard half-turn cubic interpolates the circular midpoint.
|
||||
+ v2_near((a + b * 3.0 + c * 3.0 + d) / 8.0, p(0.0, sign));
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_past_quarter_is_converted_without_clamping_its_endpoint() {
|
||||
+ // The extra turn is the exact binary fraction1/4096, not a sampled output.
|
||||
+ let sweep = FRAC_PI_2 + 1.0 / 4096.0;
|
||||
+ for sign in [-1.0, 1.0] {
|
||||
+ let result = circular_arc_v2_candidate::circular_arc(&v2_arc(Vec2::ZERO, 1.0, 0.0, sign * sweep), 1e-3, 64).unwrap();
|
||||
+ v2_check(&result, 1e-3, 64);
|
||||
+ let end = result.pieces.last().unwrap().end();
|
||||
+ // sin(delta) is between delta-delta^3/6 and delta: a quarter-clamped x=0 fails.
|
||||
+ assert!(end.x > -0.000245 && end.x < -0.000243);
|
||||
+ assert!(end.y * sign > 0.9999999 && end.y * sign < 1.0);
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_error_and_coordinates_use_the_same_units_without_double_conversion() {
|
||||
+ let source = v2_arc(p(2.0, -3.0), 1.0, 0.0, FRAC_PI_2);
|
||||
+ let steps_per_mm = 100_000.0; // Actual10nm grid:1mm=100000steps.
|
||||
+ let in_steps = v2_arc(p(2.0, -3.0) * steps_per_mm, steps_per_mm, 0.0, FRAC_PI_2);
|
||||
+ let a = circular_arc_v2_candidate::circular_arc(&source, 9e-5, 64).unwrap();
|
||||
+ let b = circular_arc_v2_candidate::circular_arc(&in_steps, 9.0, 64).unwrap();
|
||||
+ v2_check(&a, 9e-5, 64);
|
||||
+ v2_check(&b, 9.0, 64);
|
||||
+ assert_eq!(a.pieces.len(), b.pieces.len());
|
||||
+ for (left, right) in a.pieces.iter().zip(&b.pieces) {
|
||||
+ for (mm, steps) in v2_controls(left).into_iter().zip(v2_controls(right)) {
|
||||
+ v2_near(mm, steps / steps_per_mm);
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_grid_scale_coordinates_are_not_subject_to_authored_paper_limits() {
|
||||
+ let arc = v2_arc(p(1_000_000.0, 2_000_000.0), 10_000_000.0, -PI, PI);
|
||||
+ let result = circular_arc_v2_candidate::circular_arc(&arc, 9.0, 64).unwrap();
|
||||
+ v2_check(&result, 9.0, 64);
|
||||
+ assert!(result.pieces.len() > 1);
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_positive_radius_below_the_former_mm_floor_is_not_invalid() {
|
||||
+ let result = circular_arc_v2_candidate::circular_arc(&v2_arc(Vec2::ZERO, 1e-13, 0.0, FRAC_PI_2), 1e-15, 1).unwrap();
|
||||
+ v2_check(&result, 1e-15, 1);
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_subnormal_radius_is_positive_but_may_fail_representation_or_certification() {
|
||||
+ let result = circular_arc_v2_candidate::circular_arc(&v2_arc(Vec2::ZERO, f64::from_bits(1), 0.0, PI), 1e-3, 64);
|
||||
+ match result {
|
||||
+ Ok(converted) => v2_check(&converted, 1e-3, 64),
|
||||
+ Err(error) => {
|
||||
+ assert!(error.0.contains("enclosure") || error.0.contains("precision") || error.0.contains("certify"), "{error}");
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_zero_negative_and_nonfinite_radii_are_invalid() {
|
||||
+ for radius in [0.0, -0.0, -f64::from_bits(1), -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
+ assert!(circular_arc_v2_candidate::circular_arc(&v2_arc(Vec2::ZERO, radius, 0.0, PI), 1.0, 64).is_err());
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_overflow_and_precision_collapse_refuse_without_mutation() {
|
||||
+ for arc in [
|
||||
+ v2_arc(p(f64::MAX, f64::MAX), f64::MAX, 0.0, FRAC_PI_2),
|
||||
+ v2_arc(p(1e15, 1e15), 1e-12, 0.5, 1e-12),
|
||||
+ ] {
|
||||
+ let before = arc;
|
||||
+ assert!(circular_arc_v2_candidate::circular_arc(&arc, f64::MAX, 64).is_err());
|
||||
+ assert_eq!(arc, before);
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_certificate_does_not_omit_the_analytic_quarter_turn_deviation() {
|
||||
+ let result = circular_arc_v2_candidate::circular_arc(&v2_arc(Vec2::ZERO, 1.0, 0.0, FRAC_PI_2), 1e-3, 1).unwrap();
|
||||
+ v2_check(&result, 1e-3, 1);
|
||||
+ // For the standard tangent/midpoint quarter cubic, z=t(1-t)=1/6
|
||||
+ // maximizes radial-square error. Closed-form half-angle algebra gives
|
||||
+ // radial deviation >0.00027; this is an analytic lower bound, not sampling.
|
||||
+ assert!(result.error_bound > 0.00027);
|
||||
+ let [a, b, c, d] = v2_controls(&result.pieces[0]);
|
||||
+ let k = 4.0 * (2.0_f64.sqrt() - 1.0) / 3.0;
|
||||
+ for (actual, expected) in [(a, p(1.0, 0.0)), (b, p(1.0, k)), (c, p(k, 1.0)), (d, p(0.0, 1.0))] {
|
||||
+ v2_near(actual, expected);
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_allowance_and_piece_cap_never_coarsen_or_truncate_a_request() {
|
||||
+ let arc = v2_arc(Vec2::ZERO, 1.0, 0.0, PI);
|
||||
+ // One half-turn cubic strays by more than0.018, so1e-4 cannot be certified.
|
||||
+ assert!(circular_arc_v2_candidate::circular_arc(&arc, 1e-4, 1).is_err());
|
||||
+ let result = circular_arc_v2_candidate::circular_arc(&arc, 1e-4, 64).unwrap();
|
||||
+ v2_check(&result, 1e-4, 64);
|
||||
+ assert!(result.pieces.len() > 1);
|
||||
+ assert!(circular_arc_v2_candidate::circular_arc(&arc, f64::from_bits(1), 64).is_err());
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_start_and_sweep_domain_is_explicit_without_normalization() {
|
||||
+ for start in [-PI, PI] {
|
||||
+ for sweep in [-PI, PI] {
|
||||
+ let result = circular_arc_v2_candidate::circular_arc(&v2_arc(Vec2::ZERO, 1.0, start, sweep), 0.1, 1).unwrap();
|
||||
+ v2_check(&result, 0.1, 1);
|
||||
+ v2_near(result.pieces[0].start(), p(-1.0, 0.0));
|
||||
+ v2_near(result.pieces[0].end(), p(1.0, 0.0));
|
||||
+ }
|
||||
+ }
|
||||
+ for (start, sweep) in [(PI.next_up(), 0.5), (-PI.next_up(), 0.5), (0.0, PI.next_up()), (0.0, -PI.next_up()), (0.0, 0.0), (0.0, -0.0)] {
|
||||
+ assert!(circular_arc_v2_candidate::circular_arc(&v2_arc(Vec2::ZERO, 1.0, start, sweep), 0.1, 64).is_err());
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_invalid_parameters_and_nonarcs_refuse_with_bounded_work() {
|
||||
+ let good = v2_arc(Vec2::ZERO, 1.0, 0.0, PI);
|
||||
+ for error in [0.0, -0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
+ assert!(circular_arc_v2_candidate::circular_arc(&good, error, 64).is_err());
|
||||
+ }
|
||||
+ for cap in [0, 65, usize::MAX] {
|
||||
+ assert!(circular_arc_v2_candidate::circular_arc(&good, 1.0, cap).is_err());
|
||||
+ }
|
||||
+ for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
+ for arc in [v2_arc(p(value, 0.0), 1.0, 0.0, PI), v2_arc(p(0.0, value), 1.0, 0.0, PI), v2_arc(Vec2::ZERO, 1.0, value, PI), v2_arc(Vec2::ZERO, 1.0, 0.0, value)] {
|
||||
+ assert!(circular_arc_v2_candidate::circular_arc(&arc, 1.0, 64).is_err());
|
||||
+ }
|
||||
+ }
|
||||
+ assert!(circular_arc_v2_candidate::circular_arc(&line(Vec2::ZERO, Vec2::X), 1.0, 64).is_err());
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn v2_serial_failures_and_other_unit_requests_do_not_replace_retained_results() {
|
||||
+ let arc = v2_arc(Vec2::ZERO, 1.0, 0.0, PI);
|
||||
+ let first = circular_arc_v2_candidate::circular_arc(&arc, 0.1, 1).unwrap();
|
||||
+ let bits = |result: &circular_arc_v2_candidate::ArcCubics| {
|
||||
+ result.pieces.iter().flat_map(|p| v2_controls(p).into_iter().flat_map(|p| [p.x.to_bits(), p.y.to_bits()]))
|
||||
+ .chain(std::iter::once(result.error_bound.to_bits())).collect::<Vec<_>>()
|
||||
+ };
|
||||
+ let before = bits(&first);
|
||||
+ assert!(circular_arc_v2_candidate::circular_arc(&arc, 0.1, 0).is_err());
|
||||
+ let grid_arc = v2_arc(p(1e6, 2e6), 1e5, 0.0, FRAC_PI_2);
|
||||
+ v2_check(&circular_arc_v2_candidate::circular_arc(&grid_arc, 9.0, 64).unwrap(), 9.0, 64);
|
||||
+ assert!(circular_arc_v2_candidate::circular_arc(&arc, 1e-4, 1).is_err());
|
||||
+ assert_eq!(bits(&circular_arc_v2_candidate::circular_arc(&arc, 0.1, 1).unwrap()), before);
|
||||
+ assert_eq!(bits(&first), before);
|
||||
+}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! PRIVATE V2 CANDIDATE: circular arcs to cubic pieces in the arc's own units.
|
||||
//!
|
||||
//! Owner boundary: issue4890 comment6518014281. Coordinates, radius, error and
|
||||
//! returned error_bound share one length unit; angles remain radians.
|
||||
//! No millimetre floor/ceiling from authored paper-scene admission is imposed.
|
||||
//! Scene/Prepared/Grid validation and writer adapters remain unchanged.
|
||||
//!
|
||||
//! The construction is CamWight's original P1 tangent/midpoint circular cubic
|
||||
//! (MIT OR Apache-2.0). The enclosure implementation is original project work.
|
||||
//! Mathematical context: A. Riskus, Information Technology and Control35(4),
|
||||
//! 2006, section3, doi:10.5755/j01.itc.35.4.11812. No external code is imported.
|
||||
//!
|
||||
//! For beta=abs(sweep)/(4*n)<=PI/4, the ideal radial deviation is at most
|
||||
//! r*(2/27)*sin(beta)^6/cos(beta)^2, bounded above here by
|
||||
//! r*(2/27)*beta^6/(1-beta^2). Stored controls use outward Taylor enclosures.
|
||||
//! Their greatest L1 displacement bounds the cubic's positional perturbation
|
||||
//! by Bernstein convexity. No system trigonometric accuracy is assumed.
|
||||
//! Scalar operations require ordinary IEEE754 binary64 round-to-nearest with
|
||||
//! gradual underflow, not a fast-math/flush-to-zero rewrite.
|
||||
//!
|
||||
//! The certificate excludes writer gridding and replacement of either endpoint.
|
||||
//! Those displacements must be bounded separately by the consumer. The result
|
||||
//! is not an authored/validated Scene and no writer is connected by this file.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::paper::Invalid;
|
||||
use crate::{Seg2, Vec2};
|
||||
|
||||
type Result<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
/// The inherited P1 per-arc ceiling; caller limits may only tighten it.
|
||||
pub(crate) const MAX_ARC_CUBICS: usize = 64;
|
||||
|
||||
/// A private candidate result, not a serialized record or a prepared scene.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ArcCubics {
|
||||
pub(crate) pieces: Vec<Seg2>,
|
||||
/// Approximation plus stored-control uncertainty, in the arc's own units.
|
||||
/// Not a writer's gridding, endpoint-replacement or physical-print error.
|
||||
pub(crate) error_bound: f64,
|
||||
}
|
||||
|
||||
/// Converts one circular arc with an absolute error allowance in its own units.
|
||||
/// Every successful piece is Seg2::Cubic. The center is finite, radius finite
|
||||
/// and strictly positive, start in [-PI,PI], and 0<abs(sweep)<=PI. The caller
|
||||
/// cap is1..=64. Unrepresentable/collapsed geometry or an uncertifiable request
|
||||
/// refuses atomically; no normalization, scaling or input mutation occurs.
|
||||
pub(crate) fn circular_arc(arc: &Seg2, error: f64, max_pieces: usize) -> Result<ArcCubics> {
|
||||
let Seg2::Arc { center, radius, start, sweep } = *arc else {
|
||||
return Err(Invalid("cubic arc candidate: only a circular arc is supported"));
|
||||
};
|
||||
if !center.is_finite() || !radius.is_finite() || radius <= 0.0 {
|
||||
return Err(Invalid("cubic arc candidate: finite center and finite strictly positive radius required"));
|
||||
}
|
||||
if !error.is_finite() || error <= 0.0 {
|
||||
return Err(Invalid("cubic arc candidate: the error allowance is finite and positive, in the arc units"));
|
||||
}
|
||||
if !(1..=MAX_ARC_CUBICS).contains(&max_pieces) {
|
||||
return Err(Invalid("cubic arc candidate: the piece cap is from1 to64"));
|
||||
}
|
||||
if !start.is_finite() || start.abs() > PI || !sweep.is_finite() || sweep == 0.0 || sweep.abs() > PI {
|
||||
return Err(Invalid("cubic arc candidate: start in [-PI,PI], nonzero sweep at most a half turn"));
|
||||
}
|
||||
|
||||
for n in 1..=max_pieces {
|
||||
let h = Enclosure::point(sweep).div(Enclosure::point(n as f64))?;
|
||||
let ideal_error = radial_bound(radius, h)?;
|
||||
if ideal_error > error {
|
||||
continue;
|
||||
}
|
||||
let mut pieces = Vec::new();
|
||||
pieces.try_reserve_exact(n).map_err(|_| Invalid("cubic arc candidate: cannot allocate bounded pieces"))?;
|
||||
let mut error_bound: f64 = 0.0;
|
||||
for i in 0..n {
|
||||
// The same expression evaluates each shared end once per neighbour,
|
||||
// yielding bit-identical stored endpoints, with no endpoint snap.
|
||||
let angle = |j: usize| {
|
||||
Enclosure::point(start).add(
|
||||
Enclosure::point(sweep).mul(Enclosure::point(j as f64).div(Enclosure::point(n as f64))?)?,
|
||||
)
|
||||
};
|
||||
let (cubic, control_error) = make_cubic(center, radius, angle(i)?, angle(i + 1)?, h)?;
|
||||
let total = Enclosure::point(ideal_error).add(Enclosure::point(control_error))?.hi;
|
||||
error_bound = error_bound.max(total);
|
||||
pieces.push(cubic);
|
||||
}
|
||||
if error_bound <= error {
|
||||
return Ok(ArcCubics { pieces, error_bound });
|
||||
}
|
||||
// Numerical uncertainty is part of the error, never an excuse to use a
|
||||
// looser tolerance. A later n may qualify; at the caller cap refuse all.
|
||||
}
|
||||
Err(Invalid("cubic arc candidate: cannot certify the requested mm error within the piece cap"))
|
||||
}
|
||||
|
||||
/// The exact-real radial bound with sin(beta)<=beta and
|
||||
/// cos(beta)^2>=1-beta^2, evaluated outwards. The domain is beta<=PI/4, whose square is below1.
|
||||
fn radial_bound(radius: f64, h: Enclosure) -> Result<f64> {
|
||||
let beta = Enclosure::point(h.abs_upper()).div(Enclosure::point(4.0))?;
|
||||
let squared = beta.mul(beta)?;
|
||||
let sixth = squared.mul(squared)?.mul(squared)?;
|
||||
let denominator = Enclosure::point(1.0).sub(squared)?;
|
||||
let coefficient = Enclosure::point(2.0).div(Enclosure::point(27.0))?;
|
||||
let bound = Enclosure::point(radius).mul(coefficient)?.mul(sixth)?.div(denominator)?;
|
||||
Ok(bound.hi)
|
||||
}
|
||||
|
||||
fn make_cubic(center: Vec2, radius: f64, a0: Enclosure, a1: Enclosure, h: Enclosure) -> Result<(Seg2, f64)> {
|
||||
let (s0, c0) = sin_cos(a0)?;
|
||||
let (s1, c1) = sin_cos(a1)?;
|
||||
let (sh, ch) = sin_cos(h.div(Enclosure::point(4.0))?)?;
|
||||
let r = Enclosure::point(radius);
|
||||
let handle = r.mul(Enclosure::point(4.0).div(Enclosure::point(3.0))?)?.mul(sh.div(ch)?)?;
|
||||
let cx = Enclosure::point(center.x);
|
||||
let cy = Enclosure::point(center.y);
|
||||
let x0 = cx.add(r.mul(c0)?)?;
|
||||
let y0 = cy.add(r.mul(s0)?)?;
|
||||
let x3 = cx.add(r.mul(c1)?)?;
|
||||
let y3 = cy.add(r.mul(s1)?)?;
|
||||
let (p0, e0) = stored_point(x0, y0)?;
|
||||
let (p1, e1) = stored_point(x0.sub(handle.mul(s0)?)?, y0.add(handle.mul(c0)?)?)?;
|
||||
let (p2, e2) = stored_point(x3.add(handle.mul(s1)?)?, y3.sub(handle.mul(c1)?)?)?;
|
||||
let (p3, e3) = stored_point(x3, y3)?;
|
||||
if p0 == p3 || p0 == p1 || p2 == p3 {
|
||||
return Err(Invalid("cubic arc candidate: coordinate precision collapses an endpoint or tangent"));
|
||||
}
|
||||
Ok((Seg2::Cubic { p0, p1, p2, p3 }, e0.max(e1).max(e2).max(e3)))
|
||||
}
|
||||
|
||||
/// Nonnegative Bernstein coefficients sum to1, so the greatest control
|
||||
/// displacement bounds the complete curve displacement. L1 bounds Euclidean
|
||||
/// distance without trusting a rounded sqrt.
|
||||
fn stored_point(x: Enclosure, y: Enclosure) -> Result<(Vec2, f64)> {
|
||||
let px = x.midpoint();
|
||||
let py = y.midpoint();
|
||||
let ex = x.sub(Enclosure::point(px))?.abs_upper();
|
||||
let ey = y.sub(Enclosure::point(py))?.abs_upper();
|
||||
let error = Enclosure::point(ex).add(Enclosure::point(ey))?.hi;
|
||||
Ok((Vec2::new(px, py), error))
|
||||
}
|
||||
|
||||
/// Twenty-four terms, then an explicit Taylor/Lagrange remainder. Input
|
||||
/// angles stay inside +/-2PI; no period reduction or system trig is needed.
|
||||
/// sin's polynomial has degree47 (degree48 coefficient0), cos's degree46
|
||||
/// (degree47 coefficient0), giving remainder orders49 and48 respectively.
|
||||
fn sin_cos(x: Enclosure) -> Result<(Enclosure, Enclosure)> {
|
||||
let negative_square = x.mul(x)?.neg();
|
||||
let mut sine_term = x;
|
||||
let mut cosine_term = Enclosure::point(1.0);
|
||||
let mut sine = sine_term;
|
||||
let mut cosine = cosine_term;
|
||||
for k in 1..24 {
|
||||
let twice = (2 * k) as f64;
|
||||
sine_term = sine_term.mul(negative_square)?.div(Enclosure::point(twice * (twice + 1.0)))?;
|
||||
cosine_term = cosine_term.mul(negative_square)?.div(Enclosure::point((twice - 1.0) * twice))?;
|
||||
sine = sine.add(sine_term)?;
|
||||
cosine = cosine.add(cosine_term)?;
|
||||
}
|
||||
let sine_tail = taylor_tail(x.abs_upper(), 49)?;
|
||||
let cosine_tail = taylor_tail(x.abs_upper(), 48)?;
|
||||
Ok((
|
||||
sine.add(Enclosure { lo: -sine_tail, hi: sine_tail })?,
|
||||
cosine.add(Enclosure { lo: -cosine_tail, hi: cosine_tail })?,
|
||||
))
|
||||
}
|
||||
|
||||
fn taylor_tail(magnitude: f64, order: usize) -> Result<f64> {
|
||||
let mut tail = Enclosure::point(1.0);
|
||||
for j in 1..=order {
|
||||
tail = tail.mul(Enclosure::point(magnitude))?.div(Enclosure::point(j as f64))?;
|
||||
}
|
||||
Ok(tail.hi)
|
||||
}
|
||||
|
||||
/// Local private arithmetic for this helper, not a second public interval API.
|
||||
/// Exact f64 inputs denote single real numbers. Every operation expands each
|
||||
/// rounded endpoint by one representable neighbour; nonfinite endpoints and
|
||||
/// division by an interval containing zero fail closed.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Enclosure {
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
}
|
||||
|
||||
impl Enclosure {
|
||||
fn point(value: f64) -> Self {
|
||||
Self { lo: value, hi: value }
|
||||
}
|
||||
|
||||
fn outward(lo: f64, hi: f64) -> Result<Self> {
|
||||
let (lo, hi) = (lo.next_down(), hi.next_up());
|
||||
if lo.is_finite() && hi.is_finite() && lo <= hi {
|
||||
Ok(Self { lo, hi })
|
||||
} else {
|
||||
Err(Invalid("cubic arc candidate: arithmetic enclosure unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
fn add(self, rhs: Self) -> Result<Self> {
|
||||
Self::outward(self.lo + rhs.lo, self.hi + rhs.hi)
|
||||
}
|
||||
|
||||
fn neg(self) -> Self {
|
||||
Self { lo: -self.hi, hi: -self.lo }
|
||||
}
|
||||
|
||||
fn sub(self, rhs: Self) -> Result<Self> {
|
||||
self.add(rhs.neg())
|
||||
}
|
||||
|
||||
fn mul(self, rhs: Self) -> Result<Self> {
|
||||
let (lo, hi) = [self.lo * rhs.lo, self.lo * rhs.hi, self.hi * rhs.lo, self.hi * rhs.hi]
|
||||
.into_iter()
|
||||
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v)));
|
||||
Self::outward(lo, hi)
|
||||
}
|
||||
|
||||
fn div(self, rhs: Self) -> Result<Self> {
|
||||
if rhs.lo <= 0.0 && rhs.hi >= 0.0 {
|
||||
return Err(Invalid("cubic arc candidate: denominator contains zero"));
|
||||
}
|
||||
self.mul(Self::outward(1.0 / rhs.hi, 1.0 / rhs.lo)?)
|
||||
}
|
||||
|
||||
fn abs_upper(self) -> f64 {
|
||||
self.lo.abs().max(self.hi.abs())
|
||||
}
|
||||
|
||||
fn midpoint(self) -> f64 {
|
||||
(self.lo * 0.5 + self.hi * 0.5).max(self.lo).min(self.hi)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Independent source review state
|
||||
|
||||
The admitted same-units half-turn helper received an independent source/mathematical review. No blocking mathematical defect was identified under its stated floating-point/domain assumptions; this is not machine-checked or executed proof. One P3 diagnostic still said mm; unit-wording-only.patch changes only that literal to a generic error allowance. Apply it after the exact archived v2 helper; it changes no arithmetic, caps or test expectation.
|
||||
|
||||
Runtime and receiver rebinding remain pending. Writer integration still must compose the helper bound with the actual displacement of every control, including walked from/to replacements. No PDF certificate or reproduced off-grid writer failure is claimed.
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"date": "2026-10-30",
|
||||
"authority": "https://github.com/tangency-labs/solidcraft/issues/4890#issuecomment-6518014281",
|
||||
"status": "PRIVATE_SOURCE_CANDIDATE_NOT_COMPILED_NOT_RUN",
|
||||
"geometry_receiver": {
|
||||
"repo": "tangency-labs/solidcraft",
|
||||
"commit": "fefca6bfa0c295408f66c4859bb8204ab0560552",
|
||||
"test_path": "crates/geom/src/paper_tests.rs",
|
||||
"test_blob": "88eda91c45d133ced5fa390eadfe525c76db4e39",
|
||||
"helper_path": "crates/geom/src/paper/cubics.rs",
|
||||
"source_hash": "a5d84ad3c4ee69599b8b2290bfd5c6248c86da559efb4d4393ddfdea86d100bc",
|
||||
"test_hash": "e7b5bae343981fe7905f3804f71ee808880589d0505a15733412c4d39072dd37",
|
||||
"patch_sha256": "ab73449432ba5f92791d6668f1129e5969cf03cf031ca0d04cd45343c6d80086"
|
||||
},
|
||||
"pdf_test_receiver": {
|
||||
"repo": "CamWight/solidcraft",
|
||||
"pr": 7626,
|
||||
"head": "506d419084a1cd6463c3117ea34d15f805a6ce37",
|
||||
"path": "crates/io/src/paper/pdf_tests.rs",
|
||||
"base_blob": "44a839a0a24d6db894d465e265a74a6ff3d2f755",
|
||||
"writer_blob": "6ee0e8b1783822014dd096a6af5a0f2ec5e991ca",
|
||||
"grid_blob": "28415627394ce94da1e1c7238ec580baccf2a76b",
|
||||
"patch_sha256": "2f02736a7d2204a0c6c8f12450b71de02caecd1ec4b0573d4c908ba80ad1c40e",
|
||||
"candidate_sha256": "4fe8ae467669f132b4309f3d865e3a3477e8163c65b7ed70c3539bc0387b22b6"
|
||||
},
|
||||
"original_v1_preserved": {
|
||||
"helper_sha256": "22e634fd8380f80fcdec09cccba52d45d309c44ab2f614bceab79f557da4e0e7",
|
||||
"original11tests_patch_sha256": "ad6912aa3f89ed778737828d9a60d2d1cdb0054d7a59c1af0400c4ba7699b100",
|
||||
"older_three_boundary_packet_untouched": true
|
||||
},
|
||||
"source_observations": {
|
||||
"generic_radius_finite_above_zero": true,
|
||||
"finite_center": true,
|
||||
"no_authored_mm_limits": true,
|
||||
"start_bounded_by_PI": true,
|
||||
"nonzero_sweep_bounded_by_PI": true,
|
||||
"error_same_arc_units": true,
|
||||
"cap_1_to_64": true,
|
||||
"output_is_Seg2_Cubic": true,
|
||||
"interval_radial_Taylor_arithmetic_bodies_unchanged": true,
|
||||
"writer_adapter_not_included": true,
|
||||
"production_module_registration_not_included": true
|
||||
},
|
||||
"written_tests": {
|
||||
"existing_geom_unchanged": 11,
|
||||
"new_geom": 13,
|
||||
"existing_pdf_unchanged": 25,
|
||||
"new_pdf": 2,
|
||||
"executed": 0
|
||||
},
|
||||
"static_validation": [
|
||||
"string comparison of original arithmetic bodies",
|
||||
"original receiver retained as complete prefix",
|
||||
"all new cases in existing test files",
|
||||
"hash inventory of actual saved files",
|
||||
"old v1 package retained"
|
||||
],
|
||||
"blockers": [
|
||||
"independent reviewer plus sole builder qualification",
|
||||
"no completed shared production module/API registration",
|
||||
"actual writer grid/end-replacement bound and adapter not implemented",
|
||||
"off-grid writer premise/export tests unexecuted and may expose actual writer failure",
|
||||
"new sources inherit no D8/W21/v1 historical results"
|
||||
],
|
||||
"mutations": "Only new private packet; no shared source/index/Git/Cargo/formatter/runtime/application changes"
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
diff --git a/crates/io/src/paper/pdf_tests.rs b/crates/io/src/paper/pdf_tests.rs
|
||||
--- a/crates/io/src/paper/pdf_tests.rs
|
||||
+++ b/crates/io/src/paper/pdf_tests.rs
|
||||
@@ -786,3 +786,50 @@
|
||||
assert!(super::content_with_limit(&grid.groups, prefix + tail.len() - 1).is_err());
|
||||
}
|
||||
}
|
||||
+
|
||||
+fn off_grid_circle_case(center: Vec2, radius: f64, start: f64) {
|
||||
+ let sheet = scene(
|
||||
+ p(100.0, 100.0),
|
||||
+ vec![stroke(vec![Seg2::Arc { center, radius, start, sweep: TAU }], true, 0.2, None)],
|
||||
+ );
|
||||
+ let prepared = sheet.prepare().unwrap();
|
||||
+ let on = prepared.on_grid(Grid::TEN_NANOMETRES).unwrap();
|
||||
+ let GridItem::Stroke { path, .. } = &on.groups[0].items[0] else { panic!("expected the original circle stroke") };
|
||||
+ let (mut at, mut rebuilt, mut over_quarter) = (path.start, 0usize, false);
|
||||
+ for piece in &path.pieces {
|
||||
+ if let Piece::Arc { .. } = piece {
|
||||
+ let Seg2::Arc { radius, sweep, .. } = piece.seg(at) else { panic!("expected an arc reconstruction") };
|
||||
+ assert!(radius.is_finite() && radius > 0.0);
|
||||
+ rebuilt += 1;
|
||||
+ over_quarter |= sweep.abs() > FRAC_PI_2 + 1e-9;
|
||||
+ }
|
||||
+ at = piece.to();
|
||||
+ }
|
||||
+ assert_eq!(rebuilt, 4, "one full circle split into its four quarter pieces");
|
||||
+ assert!(over_quarter, "check Cam's stated reconstruction premise in the actual walk");
|
||||
+ assert!(path.closed);
|
||||
+ assert_eq!(at, path.start);
|
||||
+ let (bytes, report) = paper_pdf(&prepared, &NONE).expect("an ordinary off-grid circle must export");
|
||||
+ assert_eq!(report, on.report);
|
||||
+ let drawing = content(&bytes);
|
||||
+ assert!(check_number_grid(&drawing).is_ok());
|
||||
+ assert!(check_xref(&bytes).is_ok());
|
||||
+ let curves = cubics(&drawing);
|
||||
+ assert!(!curves.is_empty());
|
||||
+ for curve in &curves {
|
||||
+ assert!(curve.iter().all(|p| p.is_finite()));
|
||||
+ }
|
||||
+ assert!(curves.first().unwrap()[0].dist(curves.last().unwrap()[3]) <= 1e-12);
|
||||
+ assert!(ops(&drawing).iter().any(|(op, _)| op == "h"), "closed circle remains a closed path");
|
||||
+ // No sampled-radial certificate or writer-output-derived reference is introduced.
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn off_grid_one_mm_circle_exports_after_real_quarter_reconstruction() {
|
||||
+ off_grid_circle_case(p(10.0001234, 20.0009876), 1.0, 0.3);
|
||||
+}
|
||||
+
|
||||
+#[test]
|
||||
+fn off_grid_half_mm_circle_exports_after_real_quarter_reconstruction() {
|
||||
+ off_grid_circle_case(p(50.0000033, 40.0000071), 0.5, 1.1);
|
||||
+}
|
||||
@@ -0,0 +1,12 @@
|
||||
diff --git a/crates/geom/src/paper/cubics.rs b/crates/geom/src/paper/cubics.rs
|
||||
--- a/crates/geom/src/paper/cubics.rs
|
||||
+++ b/crates/geom/src/paper/cubics.rs
|
||||
@@ -91,7 +91,7 @@
|
||||
// Numerical uncertainty is part of the error, never an excuse to use a
|
||||
// looser tolerance. A later n may qualify; at the caller cap refuse all.
|
||||
}
|
||||
- Err(Invalid("cubic arc candidate: cannot certify the requested mm error within the piece cap"))
|
||||
+ Err(Invalid("cubic arc candidate: cannot certify the requested error allowance within the piece cap"))
|
||||
}
|
||||
|
||||
/// The exact-real radial bound with sin(beta)<=beta and
|
||||
@@ -0,0 +1,12 @@
|
||||
# W21 NoPictures export acceptance — source-only continuation
|
||||
|
||||
The original three-test increment is preserved here for the agreed contribution to [#7591](https://github.com/tangency-labs/solidcraft/pull/7591), under [Cam’s test-file handoff](https://github.com/tangency-labs/solidcraft/issues/4890#issuecomment-6503678820). This is a source archive, not the stacked implementation PR or completed W21 delivery.
|
||||
|
||||
Receiver checked 30 October 2026: `5ddde8e23a86c530f2b4d2395980e3f7b88048ae`, `crates/io/src/paper/png_tests.rs` blob `39e4bc69c53f4cefe1f6b4cba8a23ec29af93502`. Only this existing test file is affected. Its complete original bytes and 14 tests stay intact. The two original NoPictures parity tests are unchanged; one new determinism test reuses each Prepared across both real writers, reversing call order on repeats. The separate three picture proposals are excluded. No Scene, Prepared, Grid, exporter implementation or dependency changes.
|
||||
|
||||
The absolute tests check preplaced 50/25 mm paper lengths and 0.2 mm stroke widths at 254 DPI, plus outward rounding and partial edges on a 100.08 × 80.08 mm sheet. Expected values come from authored paper geometry. Repeatability compares complete encoded SVG/PNG bytes and reports, separately from those geometry assertions. This does not test model projection, rasterize SVG, or qualify pictures, dashes, text, clipping or all of W21.
|
||||
|
||||
Independent source review found no actionable blocker within this scope. Patch reconstruction and byte-prefix preservation were checked. **Three new / seventeen total test declarations; not compiled or run.** No result from D8 or the render build qualifies this source. The agreed stacked draft, exact-source sole-builder registration/execution and applicable repository checks remain pending. Existing contributor ownership and the single build queue are preserved.
|
||||
|
||||
Patch SHA-256: `48c3d33ca4a225f7f8a1b33d90accb62ebc8976e829d20538adaad56f0176540`.
|
||||
Expected candidate-file SHA-256: `893c9d6a62b192dae8b5cb1fb7415e920d3afb3a8f9a6056cb3c8d57ae13d833`.
|
||||
@@ -0,0 +1,3 @@
|
||||
paper::png::tests::svg_and_png_keep_mixed_scale_paper_lengths_and_fixed_width
|
||||
paper::png::tests::svg_and_png_keep_fractional_sheet_edges_on_the_outward_grid
|
||||
paper::png::tests::svg_and_png_repeat_the_same_prepared_scene_byte_for_byte
|
||||
@@ -0,0 +1,120 @@
|
||||
--- a/crates/io/src/paper/png_tests.rs
|
||||
+++ b/crates/io/src/paper/png_tests.rs
|
||||
@@ -426,3 +426,117 @@
|
||||
pictured(&scene(p(20.0, 10.0), vec![picture(1, p(30.0, 30.0), p(40.0, 40.0), 1.0)]), RGB_254, &provider);
|
||||
assert!(provider.asked.borrow().is_empty());
|
||||
}
|
||||
+
|
||||
+/// One authored 100 mm length is already placed on paper at 1:2 and 1:4.
|
||||
+/// These are consumer fixtures, not a model-view transform or a new view kind.
|
||||
+fn mixed_scale_paper_pair() -> Scene {
|
||||
+ Scene {
|
||||
+ size_mm: p(100.0, 80.0),
|
||||
+ groups: [(101, p(10.0, 20.0), p(60.0, 20.0)), (202, p(10.0, 40.0), p(35.0, 40.0))]
|
||||
+ .into_iter()
|
||||
+ .map(|(object, a, b)| Group {
|
||||
+ object,
|
||||
+ kind: ObjectKind::DrawingBorder,
|
||||
+ items: vec![Item::Stroke { path: vec![line(a, b)], closed: false, width_mm: 0.2 }],
|
||||
+ })
|
||||
+ .collect(),
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// Both real writers consume this same checked scene; no picture source is needed.
|
||||
+fn svg_png_pair(scene: &Scene) -> (String, Image, PngReport) {
|
||||
+ let prepared = scene.prepare().unwrap();
|
||||
+ let (svg, svg_report) = crate::paper::paper_svg(&prepared, &NoPictures, 300.0).unwrap();
|
||||
+ let mut png = Vec::new();
|
||||
+ let png_report = paper_png(&prepared, AT_254, &NoPictures, &mut png).unwrap();
|
||||
+ assert_eq!(svg_report, png_report.grid, "both consumers use the same number-grid rules");
|
||||
+ let image = read(&png);
|
||||
+ assert_eq!((image.info.color_type, image.info.bit_depth), (png::ColorType::Grayscale, png::BitDepth::Eight));
|
||||
+ (String::from_utf8(svg).unwrap(), image, png_report)
|
||||
+}
|
||||
+
|
||||
+/// 254 dpi is 10 px/mm: 50/25 mm occupy 500/250 px while both 0.2 mm widths stay 2 px.
|
||||
+/// All coordinates are hand-derived from the preplaced fixture, not recorded output.
|
||||
+#[test]
|
||||
+fn svg_and_png_keep_mixed_scale_paper_lengths_and_fixed_width() {
|
||||
+ let (svg, image, report) = svg_png_pair(&mixed_scale_paper_pair());
|
||||
+ assert!(svg.contains("width=\"100mm\" height=\"80mm\" viewBox=\"0 0 100 80\""));
|
||||
+ assert_eq!(svg.matches("<g data-object=").count(), 2);
|
||||
+ assert_eq!(svg.matches("<path ").count(), 2);
|
||||
+ for path in [
|
||||
+ "<path stroke-width=\"0.2\" d=\"M10 60L60 60\"/>",
|
||||
+ "<path stroke-width=\"0.2\" d=\"M10 40L35 40\"/>",
|
||||
+ ] {
|
||||
+ assert_eq!(svg.matches(path).count(), 1, "{path}");
|
||||
+ }
|
||||
+ let first = svg.find("<g data-object=\"101\" data-kind=\"drawing_border\">").unwrap();
|
||||
+ let second = svg.find("<g data-object=\"202\" data-kind=\"drawing_border\">").unwrap();
|
||||
+ assert!(first < second, "group order is retained");
|
||||
+ assert_eq!(report.pixels, [1000, 800]);
|
||||
+ assert_eq!((image.info.width, image.info.height), (1000, 800));
|
||||
+ // Butt ends are [100,600) and [100,350). After the y flip, their two complete
|
||||
+ // black rows are 599/600 and 399/400. Every other pixel is white.
|
||||
+ for y in 0..800 {
|
||||
+ for x in 0..1000 {
|
||||
+ let ink = ((599..601).contains(&y) && (100..600).contains(&x)) || ((399..401).contains(&y) && (100..350).contains(&x));
|
||||
+ assert_eq!(image.at(x, y), if ink { &[0][..] } else { &[255][..] }, "pixel ({x},{y})");
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// The vector sheet is exactly 100.08 by 80.08 mm. At 254 dpi its 1000.8 by
|
||||
+/// 800.8 pixel extent needs an outward 1001 by 801 image. Ink reaches both last
|
||||
+/// partial pixels; rounding away that row/column or moving the origin loses it.
|
||||
+#[test]
|
||||
+fn svg_and_png_keep_fractional_sheet_edges_on_the_outward_grid() {
|
||||
+ let sheet = scene(
|
||||
+ p(100.08, 80.08),
|
||||
+ vec![Item::Fill { region: Region2 { outer: rectangle(p(99.0, 0.0), p(100.08, 1.0)), holes: vec![] } }],
|
||||
+ );
|
||||
+ let (svg, image, report) = svg_png_pair(&sheet);
|
||||
+ assert!(svg.contains("width=\"100.08mm\" height=\"80.08mm\" viewBox=\"0 0 100.08 80.08\""));
|
||||
+ assert_eq!(svg.matches("<path ").count(), 1);
|
||||
+ assert!(svg.contains("<path fill=\"#000\" stroke=\"none\" fill-rule=\"evenodd\" d=\"M99 80.08L100.08 80.08L100.08 79.08L99 79.08Z\"/>"));
|
||||
+ assert_eq!(report.pixels, [1001, 801]);
|
||||
+ assert_eq!((image.info.width, image.info.height), (1001, 801));
|
||||
+ assert_eq!(image.at(995, 795), [0], "inside the fill");
|
||||
+ assert_eq!(image.at(989, 795), [255], "left of the fill");
|
||||
+ assert_eq!(image.at(995, 789), [255], "above the fill");
|
||||
+ // The last column/row have 0.8 coverage and the corner 0.64. They are
|
||||
+ // partial dark pixels, not fully black and not white; no exact AA byte is
|
||||
+ // assumed. The existing PNG tests use the same mid-grey ink distinction.
|
||||
+ for (x, y) in [(1000, 795), (995, 800), (1000, 800)] {
|
||||
+ let grey = image.at(x, y)[0];
|
||||
+ assert!(grey > 0 && grey < 128, "ink in the last partial pixel ({x},{y}): {grey}");
|
||||
+ }
|
||||
+ let top = image.at(995, 790)[0];
|
||||
+ assert!(top > 128 && top < 255, "the first partial row has only 0.2 coverage: {top}");
|
||||
+}
|
||||
+
|
||||
+/// Reusing one Prepared must not make either exporter depend on prior traversal.
|
||||
+/// Compare whole encoded files as well as reports, for both exact and outward-rounded sheets.
|
||||
+#[test]
|
||||
+fn svg_and_png_repeat_the_same_prepared_scene_byte_for_byte() {
|
||||
+ for sheet in [
|
||||
+ mixed_scale_paper_pair(),
|
||||
+ scene(
|
||||
+ p(100.08, 80.08),
|
||||
+ vec![Item::Fill { region: Region2 { outer: rectangle(p(99.0, 0.0), p(100.08, 1.0)), holes: vec![] } }],
|
||||
+ ),
|
||||
+ ] {
|
||||
+ let prepared = sheet.prepare().unwrap();
|
||||
+ let (svg, svg_report) = crate::paper::paper_svg(&prepared, &NoPictures, 300.0).unwrap();
|
||||
+ let mut png = Vec::new();
|
||||
+ let png_report = paper_png(&prepared, AT_254, &NoPictures, &mut png).unwrap();
|
||||
+ assert_eq!(svg_report, png_report.grid);
|
||||
+ for _ in 0..2 {
|
||||
+ let mut repeated_png = Vec::new();
|
||||
+ let repeated_png_report = paper_png(&prepared, AT_254, &NoPictures, &mut repeated_png).unwrap();
|
||||
+ let (repeated_svg, repeated_svg_report) = crate::paper::paper_svg(&prepared, &NoPictures, 300.0).unwrap();
|
||||
+ assert_eq!(repeated_svg, svg);
|
||||
+ assert_eq!(repeated_png, png);
|
||||
+ assert_eq!(repeated_svg_report, svg_report);
|
||||
+ assert_eq!(repeated_png_report, png_report);
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
@@ -0,0 +1,11 @@
|
||||
# X1 flat-pattern caller compatibility - source-only increment
|
||||
|
||||
This two-file proposal is against PR6184 head3fb2eef92dce4bafe9cfb3f68a8334985d4d83ee. It is NOT applied, formatted, compiled or run. The live PR remains draft/conflicted; this archive does not resolve divergence, transfer old validation or mark the unit delivered.
|
||||
|
||||
Authority: Cam's https://github.com/tangency-labs/solidcraft/pull/6184#issuecomment-6522562149 and our existing ACK https://github.com/tangency-labs/solidcraft/pull/6184#issuecomment-6522733382 . The caller-local exception retains the first support, strictly-less-than1e-9mm centre/radius difference, common sweep sign and strictly-less-than1e-9rad difference from one whole turn. Generic Loop2 recognition stays strict. No kernel or source-provenance repair is included.
|
||||
|
||||
The real shared path converter validates style, every original piece and its200000-piece cap BEFORE the flat-pattern caller can combine output to CIRCLE. JSON uses the same caller-only compatibility. Layers/order/bends/options/writer and all53 existing output pins remain unchanged. Seven original boundary/admission regressions append to the exact18-test prefix; all25 remain unexecuted for this candidate.
|
||||
|
||||
Independent source review found no blocker in the bounded delta. The original producer helper is deliberately retained: source comparison of its six original cases and the trimmed-cap5-entity expectation does not prove that removing the helper preserves actual generated geometry and byte pins. Its removal requires a separately qualified change, not speculative cleanup here.
|
||||
|
||||
Before implementation publication/readiness, integrate the exact final X1 source, resolve actual branch divergence with current maintainer rules, run the unchanged original cases and53 pins plus added/affected tests, strict checks, wasm and the actual repository gate. Render source and its single builder were not changed or run by this source preparation.
|
||||
@@ -0,0 +1,185 @@
|
||||
--- a/crates/engine/src/cmd/sheet.rs
|
||||
+++ b/crates/engine/src/cmd/sheet.rs
|
||||
@@ -479,12 +479,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
+/// The flat-pattern caller's historical whole-circle compatibility bound.
|
||||
+/// Keep this local: generic Loop2 recognition and DXF path admission stay strict.
|
||||
+fn loop_circle(lp: &solidcraft_geom::Loop2) -> Option<(Vec2, f64)> {
|
||||
+ use solidcraft_geom::Seg2;
|
||||
+ let Some(Seg2::Arc { center, radius, sweep, .. }) = lp.segs.first() else { return None };
|
||||
+ let total: f64 = lp.segs.iter().filter_map(|s| if let Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum();
|
||||
+ let circular = lp.segs.iter().all(
|
||||
+ |s| matches!(s, Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r - *radius).abs() < 1e-9 && a.signum() == sweep.signum()),
|
||||
+ );
|
||||
+ (circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9).then_some((*center, *radius))
|
||||
+}
|
||||
+
|
||||
+/// Admit through the shared converter before applying this caller's legacy merge.
|
||||
+/// An invalid piece, style or budget still refuses instead of being hidden by CIRCLE.
|
||||
+fn flat_loop_shapes(lp: &solidcraft_geom::Loop2, style: solidcraft_io::DxfPathStyle) -> solidcraft_io::Result<Vec<solidcraft_io::DxfShape>> {
|
||||
+ use solidcraft_io::{DxfPath, DxfShape, path_shapes};
|
||||
+ let mut shapes = path_shapes(DxfPath::Closed(lp), style)?;
|
||||
+ if let Some((c, r)) = loop_circle(lp) {
|
||||
+ shapes.clear();
|
||||
+ shapes.push(DxfShape::Circle { c, r });
|
||||
+ }
|
||||
+ Ok(shapes)
|
||||
+}
|
||||
+
|
||||
/// A loop's exact shape for the flat pattern JSON: a circle, or its lines and arcs (other
|
||||
/// curves as point lists at the flat pattern's 0.001 mm tolerance).
|
||||
fn loop_shape(lp: &solidcraft_geom::Loop2) -> Value {
|
||||
use solidcraft_geom::Seg2;
|
||||
let pt = |v: Vec2| json!([v.x, v.y]);
|
||||
- if let Some((center, radius)) = lp.as_circle() {
|
||||
+ if let Some((center, radius)) = loop_circle(lp) {
|
||||
return json!({"circle": {"center": pt(center), "radius": radius}});
|
||||
}
|
||||
let segments: Vec<Value> = lp
|
||||
@@ -654,7 +678,7 @@
|
||||
/// per pocket depth and PERFORATION. Inner contours are written before the outline, so a program
|
||||
/// that follows the file's order cuts inside-out. Free-form curves as `o.curves` says.
|
||||
pub fn flat_dxf(sh: &SheetBody, o: &DxfOptions) -> Result<String> {
|
||||
- use solidcraft_io::{DxfEntity, DxfPath, DxfPathStyle, DxfShape, path_shapes};
|
||||
+ use solidcraft_io::{DxfEntity, DxfPathStyle, DxfShape};
|
||||
let regions = sh.flat_regions()?;
|
||||
let pockets = sh.machining()?;
|
||||
let letters = sh.letters();
|
||||
@@ -728,7 +752,7 @@
|
||||
DxfCurves::Exact => DxfPathStyle::Exact,
|
||||
DxfCurves::Arcs => DxfPathStyle::Arcs { tolerance_mm: solidcraft_kernel::PLANAR_FIT_TOL },
|
||||
};
|
||||
- let shapes = path_shapes(DxfPath::Closed(h), style)
|
||||
+ let shapes = flat_loop_shapes(h, style)
|
||||
.map_err(|e| EngineError::Other(format!("flat-pattern body {:?}, layer {layer:?}, contour {}: {e}", sh.body, i + 1)))?;
|
||||
entities.extend(shapes.into_iter().map(|shape| DxfEntity { layer: layer.clone(), shape }));
|
||||
}
|
||||
--- a/crates/engine/src/cmd/sheet_dxf_tests.rs
|
||||
+++ b/crates/engine/src/cmd/sheet_dxf_tests.rs
|
||||
@@ -909,3 +909,124 @@
|
||||
capture.finish();
|
||||
}
|
||||
}
|
||||
+
|
||||
+/// Independent caller-level values for the explicitly reviewed legacy contract.
|
||||
+/// The generic recognizer and its tests are not relaxed by these flat-pattern rules.
|
||||
+mod caller_circle_compatibility {
|
||||
+ use super::*;
|
||||
+ use solidcraft_geom::Loop2;
|
||||
+ use solidcraft_io::{DxfPathStyle, DxfShape};
|
||||
+
|
||||
+ fn halves(center: Vec2, radius: f64, second_center: Vec2, second_radius: f64) -> Loop2 {
|
||||
+ Loop2 { segs: vec![
|
||||
+ Seg2::Arc { center, radius, start: 0.0, sweep: PI },
|
||||
+ Seg2::Arc { center: second_center, radius: second_radius, start: PI, sweep: PI },
|
||||
+ ] }
|
||||
+ }
|
||||
+ fn styles() -> [DxfPathStyle; 2] {
|
||||
+ [DxfPathStyle::Exact, DxfPathStyle::Arcs { tolerance_mm: 0.001 }]
|
||||
+ }
|
||||
+ fn expect_circle(lp: &Loop2, center: Vec2, radius: f64) {
|
||||
+ assert_eq!(super::super::loop_circle(lp), Some((center, radius)));
|
||||
+ let json = super::super::loop_shape(lp);
|
||||
+ assert_eq!(json["circle"]["center"], json!([center.x, center.y]));
|
||||
+ assert_eq!(json["circle"]["radius"].as_f64(), Some(radius));
|
||||
+ for style in styles() {
|
||||
+ let shapes = super::super::flat_loop_shapes(lp, style).unwrap();
|
||||
+ assert_eq!(shapes.len(), 1);
|
||||
+ assert!(matches!(shapes[0], DxfShape::Circle { c, r } if c == center && r == radius), "{style:?}: {shapes:?}");
|
||||
+ }
|
||||
+ }
|
||||
+ fn expect_arcs(lp: &Loop2) {
|
||||
+ assert!(super::super::loop_circle(lp).is_none());
|
||||
+ assert!(lp.as_circle().is_none(), "generic recognition must remain strict");
|
||||
+ let json = super::super::loop_shape(lp);
|
||||
+ assert!(json.get("circle").is_none());
|
||||
+ let segments = json["segments"].as_array().unwrap();
|
||||
+ assert_eq!(segments.len(), lp.segs.len());
|
||||
+ assert!(segments.iter().all(|s| s.get("arc").is_some()));
|
||||
+ for style in styles() {
|
||||
+ let shapes = super::super::flat_loop_shapes(lp, style).unwrap();
|
||||
+ assert_eq!(shapes.len(), lp.segs.len());
|
||||
+ assert!(shapes.iter().all(|s| matches!(s, DxfShape::Arc { .. })), "{style:?}: {shapes:?}");
|
||||
+ }
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn trimmed_cap_support_witness_is_one_flat_circle_but_not_a_generic_circle() {
|
||||
+ // Captured source supports from the existing original regression, not new expected output.
|
||||
+ let center = Vec2::new(25.0, 25.0);
|
||||
+ let radius = 1.9999999999999987;
|
||||
+ let second = Vec2::new(24.999999999999996, 25.0);
|
||||
+ let other_radius = 2.0000000000000004;
|
||||
+ assert!(center.dist(second) > 0.0 && center.dist(second) < 1e-9);
|
||||
+ assert!(other_radius > radius && other_radius - radius < 1e-9);
|
||||
+ let lp = halves(center, radius, second, other_radius);
|
||||
+ assert!(lp.as_circle().is_none(), "unequal stored supports remain unequal in generic geom");
|
||||
+ expect_circle(&lp, center, radius);
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn center_distance_at_and_just_past_bound_keeps_both_arcs() {
|
||||
+ let center = Vec2::new(0.0, 0.0);
|
||||
+ let inside = 0.5e-9;
|
||||
+ expect_circle(&halves(center, 1.0, Vec2::new(inside, 0.0), 1.0), center, 1.0);
|
||||
+ for offset in [1e-9, f64::from_bits(1e-9_f64.to_bits() + 1)] {
|
||||
+ let second = Vec2::new(offset, 0.0);
|
||||
+ assert!(second.dist(center) >= 1e-9);
|
||||
+ expect_arcs(&halves(center, 1.0, second, 1.0));
|
||||
+ }
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn radius_difference_just_past_bound_keeps_both_arcs() {
|
||||
+ let center = Vec2::new(0.0, 0.0);
|
||||
+ let radius = 1.0;
|
||||
+ let inside = radius + 0.5e-9;
|
||||
+ let outside = f64::from_bits((radius + 1e-9_f64).to_bits() + 1);
|
||||
+ assert!(inside - radius < 1e-9);
|
||||
+ assert!(outside - radius > 1e-9);
|
||||
+ expect_circle(&halves(center, radius, center, inside), center, radius);
|
||||
+ expect_arcs(&halves(center, radius, center, outside));
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn angular_sum_just_past_one_turn_bound_keeps_both_arcs() {
|
||||
+ let center = Vec2::new(0.0, 0.0);
|
||||
+ let mut inside = halves(center, 1.0, center, 1.0);
|
||||
+ inside.segs[1] = Seg2::Arc { center, radius: 1.0, start: PI, sweep: PI + 0.5e-9 };
|
||||
+ expect_circle(&inside, center, 1.0);
|
||||
+ let total = f64::from_bits((TAU + 1e-9).to_bits() + 1);
|
||||
+ let second = total - PI;
|
||||
+ assert!(((PI + second).abs() - TAU).abs() > 1e-9);
|
||||
+ let outside = Loop2 { segs: vec![
|
||||
+ Seg2::Arc { center, radius: 1.0, start: 0.0, sweep: PI },
|
||||
+ Seg2::Arc { center, radius: 1.0, start: PI, sweep: second },
|
||||
+ ] };
|
||||
+ expect_arcs(&outside);
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn double_turn_is_four_arcs_in_both_flat_export_styles() {
|
||||
+ let center = Vec2::new(0.0, 0.0);
|
||||
+ let lp = Loop2 { segs: (0..4).map(|i| Seg2::Arc { center, radius: 1.0, start: f64::from(i) * PI, sweep: PI }).collect() };
|
||||
+ expect_arcs(&lp);
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn mixed_signs_with_net_one_turn_are_still_four_arcs() {
|
||||
+ let center = Vec2::new(0.0, 0.0);
|
||||
+ let lp = Loop2 { segs: [PI, PI, PI, -PI].into_iter().enumerate().map(|(i, sweep)| {
|
||||
+ Seg2::Arc { center, radius: 1.0, start: i as f64 * PI, sweep }
|
||||
+ }).collect() };
|
||||
+ assert_eq!([PI, PI, PI, -PI].iter().sum::<f64>(), TAU);
|
||||
+ expect_arcs(&lp);
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn caller_merge_never_bypasses_shared_converter_admission() {
|
||||
+ let center = Vec2::new(0.0, 0.0);
|
||||
+ let invalid_start = Loop2 { segs: vec![Seg2::Arc { center, radius: 1.0, start: f64::NAN, sweep: TAU }] };
|
||||
+ assert!(super::super::loop_circle(&invalid_start).is_some(), "historical helper ignores start; admission must not");
|
||||
+ for style in styles() { assert!(super::super::flat_loop_shapes(&invalid_start, style).is_err()); }
|
||||
+ let valid = halves(center, 1.0, center, 1.0);
|
||||
+ assert!(super::super::flat_loop_shapes(&valid, DxfPathStyle::Arcs { tolerance_mm: f64::NAN }).is_err());
|
||||
+ // The existing shared path cap stays200000 even if a caller could merge to one entity.
|
||||
+ let over_budget = Loop2 { segs: vec![Seg2::Arc { center, radius: 1.0, start: 0.0, sweep: TAU / 200001.0 }; 200001] };
|
||||
+ assert!(super::super::loop_circle(&over_budget).is_some());
|
||||
+ for style in styles() { assert!(super::super::flat_loop_shapes(&over_budget, style).is_err()); }
|
||||
+ }
|
||||
+}
|
||||
@@ -0,0 +1,31 @@
|
||||
# X1 combined source reconciliation: eleven paths
|
||||
|
||||
**Source-only, unformatted, uncompiled and unrun. Cargo.lock unresolved; whole-workspace API compatibility unqualified. Not a standalone validated main tree, a whole-PR rebase, resolved mergeability or readiness.**
|
||||
|
||||
This archive combines nine original-X1 prerequisite candidates with the exact immutable caller/test pair from the [two-file parent archive](../x1-main-reconciliation/README.md). It is an equivalent combined alternative, not a sequential follow-up patch to apply after that parent. Apply the sole [combined-source.patch](combined-source.patch) once to the exact frozen-main before source; do not apply the parent deltas again. Before/candidate snapshots are for inspection and replay, not additional patches.
|
||||
|
||||
Frozen main is `8e9e2700275a363ac63ee5178aca7e33fac06722`. Original X1 is head `3fb2eef92dce4bafe9cfb3f68a8334985d4d83ee` over merge base `0312808b92d4523894d2af57c99401c65886e24d`; its reviewed caller successor is archive `28d90fae50dc9b060434a393305b0c93dff0444a`. Only the legitimate original-X1 source delta is reconciled. The historical 459-path divergent comparison was not imported. [Portable bindings](source-bindings.json) distinguish Git commit identifiers from RAW file SHA256 values and record every before/candidate state.
|
||||
|
||||
## Source supplied and absent-file status
|
||||
|
||||
The eleven paths consist of the immutable parent `sheet.rs`/`sheet_dxf_tests.rs`, plus doc sheet conversion, engine manifest, geom lib/profile/tests, IO lib/writer and two added IO path files. Nine modified files have literal before snapshots. `crates/io/src/dxf_out/path.rs` and `crates/io/tests/dxf_paths.rs` were absent at frozen main: their zero-byte before snapshots are explicitly marked `existed=false` and `placeholder_only=true`, never evidence that empty source files existed. The patch creates both through `/dev/null` and `-0,0`; their candidate bytes are exactly original X1.
|
||||
|
||||
Five modified prerequisite paths preserve unique original contexts; four independently commute with nineteen foreign hunks. Two export conflicts are resolved only at their existing lines: geom retains `Similarity2` while adding original `EllipseArc`/`conic_ellipse`; IO uses the original X1 `DxfPath`/`DxfPathStyle`/`DxfVertex`/`path_shapes` export migration, preserving current surrounding code. The entire document candidate equals original X1, including its directed-turn certificate. Existing `Out::num` is unchanged; no X4 number policy is introduced.
|
||||
|
||||
## Preserved contracts and public API qualification
|
||||
|
||||
Original strict generic recognizer/extraction bodies and all 47 test bodies added by original X1 remain literal. Five common tests differ from old X1 because frozen main already changed them; this reconciliation preserves those current-main bodies exactly. The parent pair remains byte-identical, including 53 original expected output tuples and its 18-test prefix plus seven caller cases. There are 117 source declarations in the nine prerequisites and 25 in the parent: 142 total, including inherited and conditional tests. This is not 142 new or executed tests.
|
||||
|
||||
The caller still admits style and every original piece through the shared bounded path converter before its already agreed local CIRCLE merge. Generic strictness, original limits/refusals, fixed pin expectations and the producer turn helper are not relaxed or removed. The original five-entity trimmed-cap acceptance remains required.
|
||||
|
||||
The public removal of `loop_shapes`/`seg_shape` is inherited from original X1; it is a real API change. Captured internal test calls use local test-only wrappers. No dangling consumer was demonstrated in captured source, but the callgraph is partial and does not prove complete frozen-main public-API or exhaustive-enum consumer coverage. Actual affected compilation and suites must qualify that compatibility. This archive supplies no new fallback or speculative adapter.
|
||||
|
||||
## Remaining dependency and execution work
|
||||
|
||||
`Cargo.lock` is deliberately not a candidate or part of the combined patch. The engine manifest adds the original workspace `sha2` dev-dependency; an existing root `sha2 = "0.10"` and lock package `0.10.9` do not resolve the changed engine dependency by themselves. Actual resolver execution, lock-delta inspection and exact-source pinning remain required before `--locked` or readiness claims.
|
||||
|
||||
Then qualify the same coherent candidate with the original seven sheet regressions, seven caller boundary/admission cases, unchanged 53 pins, affected doc/geom/IO/sheet/view/API suites, strict checks, wasm and full repository CI, plus actual observable output. No old PASS transfers. No formatter, compiler, test/oracle, app/GPU, merge or readiness result is claimed here. Kernel, Cam's view receiver/current-view migration, X2/X3/X4, A8/paper/picture/multipage work and other contributors' windows remain outside the authored changes.
|
||||
|
||||
Existing caller policy references: [Cam's decision](https://github.com/tangency-labs/solidcraft/pull/6184#issuecomment-6522562149) and [prior ACK/source review](https://github.com/tangency-labs/solidcraft/pull/6184#issuecomment-6522733382). No new compatibility choice is introduced.
|
||||
|
||||
Original X1 source is credited to the 0xlillith team in PR6184. Worker1 prepared source composition and the two explicit export merges; existing SolidCraft contributors' foreign source stays preserved. Existing MIT OR Apache-2.0 licensing and attribution remain intact. No external CAD code/assets or private control/proof files are bundled.
|
||||
+1066
File diff suppressed because it is too large
Load Diff
+58
@@ -0,0 +1,58 @@
|
||||
[package]
|
||||
name = "solidcraft-engine"
|
||||
description = "SolidCraft engine: the session, the command registry (everything is a command), undo/redo and selection"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Optional authored texture/HDR bytes; the command registry and metadata remain available.
|
||||
bundled-render-library = []
|
||||
raytrace-cpu = ["solidcraft-render/raytrace-cpu"]
|
||||
# Turns on the parameter-contract audit recorder (`SOLIDCRAFT_PARAM_AUDIT`, see
|
||||
# `src/schema/record.rs`), for checking a new contract against the whole test suite.
|
||||
param-audit = []
|
||||
solver-profiling = ["solidcraft-sketch/solver-profiling"]
|
||||
|
||||
[[example]]
|
||||
name = "sketch_analysis_bench"
|
||||
required-features = ["solver-profiling"]
|
||||
|
||||
[[example]]
|
||||
name = "sketch_drag_bench"
|
||||
required-features = ["solver-profiling"]
|
||||
|
||||
[[example]]
|
||||
name = "sketch_target_bench"
|
||||
required-features = ["solver-profiling"]
|
||||
|
||||
[dependencies]
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-sketch = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
solidcraft-doc = { workspace = true }
|
||||
solidcraft-io = { workspace = true }
|
||||
solidcraft-render = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml_edit = "0.25"
|
||||
schemars = { workspace = true }
|
||||
serde_path_to_error = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Model-cache tests also run in a source tarball build (no git commit).
|
||||
solidcraft-io = { workspace = true, features = ["trust-unknown-source"] }
|
||||
# Normal CI exercises profiling assertions without enabling the production feature.
|
||||
solidcraft-sketch = { workspace = true, features = ["solver-profiling"] }
|
||||
|
||||
[[example]]
|
||||
name = "render_resource_workflow"
|
||||
required-features = ["raytrace-cpu"]
|
||||
+1186
File diff suppressed because it is too large
Load Diff
+616
@@ -0,0 +1,616 @@
|
||||
//! The flat pattern's DXF keeps ellipses and free-form curves as ELLIPSE and SPLINE entities, and
|
||||
//! a laser-cutting shop can use it as it is: layers by role, bend notes, letters cut or etched,
|
||||
//! one file per sheet body.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::{Seg2, Vec2};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A 100 × 60 base flange (the default 2.5 mm rule) with `curves` drawn in a sketch and cut through it.
|
||||
fn plate_with(curves: &[(&str, Value)]) -> Session {
|
||||
plate_in(Session::default(), curves)
|
||||
}
|
||||
|
||||
/// A 100 × 60 base flange in `s` (of its rule), with `curves` drawn in a sketch and cut through it.
|
||||
fn plate_in(mut s: Session, curves: &[(&str, Value)]) -> Session {
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
if curves.is_empty() {
|
||||
return s;
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
|
||||
for (command, params) in curves {
|
||||
run(&mut s, command, params.clone());
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A session whose active rule is 2 mm sheet with an inner bend radius of 1 mm.
|
||||
fn sheet_of_2mm() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Sheet2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A 100 × 60 tray of 2 mm sheet with `curves` cut through its base: three 20 mm walls bend up and
|
||||
/// one bends down.
|
||||
fn tray_with(curves: &[(&str, Value)]) -> Session {
|
||||
let mut s = plate_in(sheet_of_2mm(), curves);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2], [100, 30, 2], [50, 60, 2]], "height": 20}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[0, 30, 2]], "height": 20, "flip": true}));
|
||||
s
|
||||
}
|
||||
|
||||
/// What a tray is marked with: a Ø5 hole, a 20 × 5 slot and the label "AB-12", 10 mm high at (20, 28).
|
||||
fn marks() -> Vec<(&'static str, Value)> {
|
||||
vec![
|
||||
("sketch.circle.center", json!({"center": [15, 15], "diameter": 5})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [70, 40], "p1": [90, 45]})),
|
||||
("sketch.text", json!({"text": "AB-12", "at": [20, 28], "height": 10})),
|
||||
]
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, params: Value) -> String {
|
||||
run(s, "sheet.export_dxf", params)["dxf"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// The entities of a DXF text: type and group codes.
|
||||
type Entity = (String, Vec<(i32, String)>);
|
||||
|
||||
fn entities(dxf: &str) -> Vec<Entity> {
|
||||
let lines: Vec<&str> = dxf.lines().collect();
|
||||
let pairs: Vec<(i32, &str)> = lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim()))).collect();
|
||||
let start = pairs.iter().position(|p| *p == (2, "ENTITIES")).unwrap_or(0);
|
||||
let mut out: Vec<Entity> = Vec::new();
|
||||
for (code, value) in pairs.iter().skip(start + 1) {
|
||||
match (*code, *value) {
|
||||
(0, "ENDSEC") => break,
|
||||
(0, kind) => out.push((kind.to_string(), Vec::new())),
|
||||
_ => {
|
||||
if let Some(last) = out.last_mut() {
|
||||
last.1.push((*code, value.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn values(e: &Entity, code: i32) -> Vec<f64> {
|
||||
e.1.iter().filter(|(c, _)| *c == code).filter_map(|(_, v)| v.parse().ok()).collect()
|
||||
}
|
||||
|
||||
fn count(es: &[Entity], kind: &str) -> usize {
|
||||
es.iter().filter(|e| e.0 == kind).count()
|
||||
}
|
||||
|
||||
fn point(e: &Entity, code: i32) -> Vec2 {
|
||||
Vec2::new(values(e, code).first().copied().unwrap_or(f64::NAN), values(e, code + 10).first().copied().unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
/// All loops of the flat pattern (outlines, cut-outs, islands).
|
||||
fn loops(s: &Session) -> Vec<solidcraft_geom::Loop2> {
|
||||
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
|
||||
regions.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.clone())).collect()
|
||||
}
|
||||
|
||||
/// The profiles of the DXF read into a new sketch: their areas, largest first.
|
||||
fn reimported_areas(dxf: &str) -> Vec<f64> {
|
||||
let mut s = Session::default();
|
||||
let id = run(&mut s, "sketch.insert_dxf", json!({"text": dxf, "name": "Back"}))["sketch"].as_u64().unwrap_or(0);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mut areas: Vec<f64> = s.model.state().sketch(id).map(|ss| ss.profiles.iter().map(|p| p.area).collect()).unwrap_or_default();
|
||||
areas.sort_by(|a, b| b.total_cmp(a));
|
||||
areas
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_elliptic_cut_out_is_one_ellipse() {
|
||||
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
|
||||
let dxf = export(&mut s, json!({}));
|
||||
let es = entities(&dxf);
|
||||
// The four lines of the outline and one ELLIPSE: no other entity.
|
||||
assert_eq!((count(&es, "LINE"), count(&es, "ELLIPSE"), es.len()), (4, 1, 5), "{:?}", es.iter().map(|e| &e.0).collect::<Vec<_>>());
|
||||
// By hand: centre (40, 30), the major semi-axis 15 along x (to (55, 30) or (25, 30)), the
|
||||
// minor one 8, so the ratio is 8/15; the whole turn runs from parameter 0 to 2π.
|
||||
let e = es.iter().find(|e| e.0 == "ELLIPSE").unwrap();
|
||||
let (c, major) = (point(e, 10), point(e, 11));
|
||||
assert!(c.dist(Vec2::new(40.0, 30.0)) < 1e-9 && (major.x.abs() - 15.0).abs() < 1e-9 && major.y.abs() < 1e-9, "{c:?} {major:?}");
|
||||
assert!((values(e, 40)[0] - 8.0 / 15.0).abs() < 1e-9 && values(e, 41)[0] == 0.0 && (values(e, 42)[0] - TAU).abs() < 1e-9);
|
||||
assert_eq!(values(e, 230), [1.0]);
|
||||
// Our reader gets the same ellipse back, and the same plate: 6000 mm² less π·15·8.
|
||||
let read = solidcraft_io::read_dxf(&dxf).unwrap();
|
||||
assert!(read.iter().any(|g| matches!(g, solidcraft_io::Geom2::Ellipse { c, major, minor }
|
||||
if c.dist(Vec2::new(40.0, 30.0)) < 1e-9 && (major.dist(*c) - 15.0).abs() < 1e-9 && (minor - 8.0).abs() < 1e-9)));
|
||||
let areas = reimported_areas(&dxf);
|
||||
assert!(areas.len() == 2 && (areas[0] - (6000.0 - PI * 120.0)).abs() < 1e-6 && (areas[1] - PI * 120.0).abs() < 1e-6, "{areas:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spline_cut_out_is_the_splines_it_was() {
|
||||
let points = json!([[20, 15], [40, 45], [65, 40], [85, 20]]);
|
||||
let mut s = plate_with(&[("sketch.spline.fit_point", json!({"points": points})), ("sketch.line", json!({"points": [[85, 20], [20, 15]]}))]);
|
||||
let dxf = export(&mut s, json!({}));
|
||||
let es = entities(&dxf);
|
||||
// The cut-out's segments of the flat pattern, one entity each: its cubic pieces are SPLINEs
|
||||
// of degree 3 over the same four control points, its line a LINE.
|
||||
let hole: Vec<Seg2> = loops(&s).get(1).map(|l| l.segs.clone()).unwrap_or_default();
|
||||
let cubics: Vec<[Vec2; 4]> =
|
||||
hole.iter().filter_map(|g| if let Seg2::Cubic { p0, p1, p2, p3 } = g { Some([*p0, *p1, *p2, *p3]) } else { None }).collect();
|
||||
assert!(!cubics.is_empty() && cubics.len() + 1 == hole.len(), "{hole:?}");
|
||||
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (5, cubics.len(), 5 + cubics.len()));
|
||||
for (spline, want) in es.iter().filter(|e| e.0 == "SPLINE").zip(&cubics) {
|
||||
assert_eq!((values(spline, 70), values(spline, 71), values(spline, 72), values(spline, 73)), (vec![8.0], vec![3.0], vec![8.0], vec![4.0]));
|
||||
assert_eq!(values(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]);
|
||||
let (xs, ys) = (values(spline, 10), values(spline, 20));
|
||||
assert!(want.iter().enumerate().all(|(i, p)| (xs[i] - p.x).abs() < 1e-9 && (ys[i] - p.y).abs() < 1e-9), "{xs:?} {ys:?} {want:?}");
|
||||
}
|
||||
// The same plate comes back.
|
||||
let areas = reimported_areas(&dxf);
|
||||
let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default()[0].area();
|
||||
assert!((areas[0] - plate).abs() < 1e-6, "{areas:?} {plate}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_g4_blend_cut_out_is_one_spline_of_degree_nine_and_closes() {
|
||||
// Three sides of a 20 × 20 square, closed on the right by a G4 blend from (40, 20) to (40, 40):
|
||||
// one Bézier of degree 9 over ten control points (solidcraft#5269).
|
||||
let mut s = plate_with(&[]);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
|
||||
let mut line =
|
||||
|a: [i32; 2], b: [i32; 2]| run(&mut s, "sketch.line", json!({"points": [a, b]}))["curves"][0].as_str().unwrap_or_default().to_string();
|
||||
let (bottom, top) = (line([20, 20], [40, 20]), line([40, 40], [20, 40]));
|
||||
line([20, 40], [20, 20]);
|
||||
run(&mut s, "sketch.blend_curve", json!({"a": format!("{bottom}.end"), "b": format!("{top}.start"), "continuity": "g4"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
let hole: Vec<Seg2> = loops(&s).get(1).map(|l| l.segs.clone()).unwrap_or_default();
|
||||
let blend: Vec<Vec2> = hole.iter().find_map(|g| if let Seg2::Bezier { n: 9, p } = g { Some(p[..10].to_vec()) } else { None }).unwrap_or_default();
|
||||
assert_eq!((blend.len(), hole.len()), (10, 4), "{hole:?}");
|
||||
|
||||
// One SPLINE of degree 9 with all ten control points: its ends are the blend's own.
|
||||
let dxf = export(&mut s, json!({}));
|
||||
let es = entities(&dxf);
|
||||
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (7, 1, 8));
|
||||
let spline = es.iter().find(|e| e.0 == "SPLINE").unwrap();
|
||||
assert_eq!((values(spline, 70), values(spline, 71), values(spline, 72), values(spline, 73)), (vec![8.0], vec![9.0], vec![20.0], vec![10.0]));
|
||||
let ctrl: Vec<Vec2> = values(spline, 10).into_iter().zip(values(spline, 20)).map(|(x, y)| Vec2::new(x, y)).collect();
|
||||
assert!(ctrl.len() == 10 && ctrl.iter().zip(&blend).all(|(a, b)| a.dist(*b) < 1e-9), "{ctrl:?} {blend:?}");
|
||||
let ends = [ctrl[0], ctrl[9]];
|
||||
assert!(ends.contains(&Vec2::new(40.0, 20.0)) && ends.contains(&Vec2::new(40.0, 40.0)), "{ends:?}");
|
||||
|
||||
// The cut-out closes, so the same plate and the same cut-out come back.
|
||||
let areas = reimported_areas(&dxf);
|
||||
let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default()[0].area();
|
||||
assert!(areas.len() == 2 && (areas[0] - plate).abs() < 1e-6 && (areas[1] - (6000.0 - plate)).abs() < 1e-6, "{areas:?} {plate}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_splines_and_lines_not_chords() {
|
||||
let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]);
|
||||
let dxf = export(&mut s, json!({}));
|
||||
let es = entities(&dxf);
|
||||
// One entity for each segment of the flat pattern: a straight one a LINE, a glyph's
|
||||
// quadratic and cubic curves SPLINEs of degree 2 and 3.
|
||||
let segs: Vec<Seg2> = loops(&s).iter().flat_map(|l| l.segs.clone()).collect();
|
||||
let n = |f: &dyn Fn(&Seg2) -> bool| segs.iter().filter(|g| f(g)).count();
|
||||
let (lines, quadratics, cubics) = (
|
||||
n(&|g| matches!(g, Seg2::Line { .. })),
|
||||
n(&|g| matches!(g, Seg2::Conic { w, .. } if (*w - 1.0).abs() < 1e-12)),
|
||||
n(&|g| matches!(g, Seg2::Cubic { .. })),
|
||||
);
|
||||
assert_eq!(lines + quadratics + cubics, segs.len(), "only lines and Béziers");
|
||||
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (lines, quadratics + cubics, segs.len()));
|
||||
let degree = |d: f64| es.iter().filter(|e| e.0 == "SPLINE" && values(e, 71) == [d]).count();
|
||||
assert_eq!((degree(2.0), degree(3.0)), (quadratics, cubics));
|
||||
// The plate with the letters cut out comes back from the file with its area (the largest region; the islands inside the
|
||||
// letters come in no fixed order).
|
||||
let areas = reimported_areas(&dxf);
|
||||
let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default().iter().map(|r| r.area()).fold(0.0, f64::max);
|
||||
assert!((areas[0] - plate).abs() < 1e-3, "{areas:?} {plate}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arcs_option_writes_only_lines_and_tangent_arcs_within_a_thousandth() {
|
||||
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
|
||||
let dxf = export(&mut s, json!({"curves": "arcs"}));
|
||||
let es = entities(&dxf);
|
||||
assert!(es.iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", es.iter().map(|e| &e.0).collect::<Vec<_>>());
|
||||
let arcs: Vec<(Vec2, f64, [Vec2; 2], Vec<Vec2>)> = es
|
||||
.iter()
|
||||
.filter(|e| e.0 == "ARC")
|
||||
.map(|e| {
|
||||
let (c, r, (a0, a1)) = (point(e, 10), values(e, 40)[0], (values(e, 50)[0].to_radians(), values(e, 51)[0].to_radians()));
|
||||
let sweep = (a1 - a0).rem_euclid(TAU);
|
||||
let samples = (0..=20).map(|k| c + Vec2::from_angle(a0 + sweep * f64::from(k) / 20.0) * r).collect::<Vec<Vec2>>();
|
||||
(c, r, [c + Vec2::from_angle(a0) * r, c + Vec2::from_angle(a1) * r], samples)
|
||||
})
|
||||
.collect();
|
||||
// Far fewer than the 336 chords of 0.001 mm.
|
||||
assert!(arcs.len() >= 8 && arcs.len() < 100, "{} arcs", arcs.len());
|
||||
// Distance to the ellipse ((x − 40)/15)² + ((y − 30)/8)² = 1, to first order |f| / |∇f|.
|
||||
let off = |p: Vec2| {
|
||||
let (x, y) = (p.x - 40.0, p.y - 30.0);
|
||||
let f = (x / 15.0).powi(2) + (y / 8.0).powi(2) - 1.0;
|
||||
f.abs() / Vec2::new(2.0 * x / 225.0, 2.0 * y / 64.0).len()
|
||||
};
|
||||
// (The joint of a biarc's two arcs lies within the tolerance of the curve, not on it.)
|
||||
for (_, _, ends, samples) in &arcs {
|
||||
let offs: Vec<f64> = samples.iter().map(|p| off(*p)).collect();
|
||||
assert!(offs.iter().all(|d| *d <= 1e-3 + 1e-6), "{ends:?}: {offs:?}");
|
||||
}
|
||||
// Tangent: at each joint the radii of the two arcs lie on one line.
|
||||
for (i, (c, r, ends, _)) in arcs.iter().enumerate() {
|
||||
for end in ends {
|
||||
let others: Vec<_> = arcs.iter().enumerate().filter(|(j, (_, _, e, _))| *j != i && e.iter().any(|q| q.dist(*end) < 1e-6)).collect();
|
||||
assert_eq!(others.len(), 1, "joint {end:?}");
|
||||
let (_, (c2, r2, _, _)) = others[0];
|
||||
assert!((*end - *c).cross(*end - *c2).abs() < 1e-6 * r * r2, "arcs are not tangent at {end:?}");
|
||||
}
|
||||
}
|
||||
// The plate, read back, is the exact one within the perimeter times 0.001 mm.
|
||||
let areas = reimported_areas(&dxf);
|
||||
assert!((areas[0] - (6000.0 - PI * 120.0)).abs() < 0.1, "{areas:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arcs_option_has_no_splines_for_text_and_the_curves_value_is_checked() {
|
||||
let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]);
|
||||
let es = entities(&export(&mut s, json!({"curves": "ARCS"})));
|
||||
assert!(es.iter().all(|e| e.0 == "LINE" || e.0 == "ARC") && count(&es, "ARC") > 0);
|
||||
let exact = entities(&export(&mut s, json!({"curves": "exact"})));
|
||||
assert!(count(&exact, "SPLINE") > 0 && count(&exact, "ARC") == 0);
|
||||
assert!(s.execute("sheet.export_dxf", &json!({"curves": "chords"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_lines_and_layers_are_still_there() {
|
||||
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
|
||||
let out = run(&mut s, "sheet.export_dxf", json!({}));
|
||||
let es = entities(out["dxf"].as_str().unwrap_or_default());
|
||||
let on = |layer: &str| es.iter().filter(|e| e.1.contains(&(8, layer.to_string()))).count();
|
||||
assert_eq!(on("BEND_UP") + on("BEND_DOWN"), out["bends"].as_u64().unwrap_or(0) as usize);
|
||||
// The ellipse is a cut-out, on CUT; the outline is the plate's.
|
||||
assert!(on("BEND_UP") + on("BEND_DOWN") > 0 && count(&es, "ELLIPSE") == 1 && on("CUT") == 1);
|
||||
assert_eq!(on("OUTLINE") + on("CUT"), es.len() - on("BEND_UP") - on("BEND_DOWN"));
|
||||
}
|
||||
|
||||
/// The layer an entity is on.
|
||||
fn layer_of(e: &Entity) -> String {
|
||||
e.1.iter().find(|(c, _)| *c == 8).map(|(_, v)| v.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The text of an entity's group code.
|
||||
fn string(e: &Entity, code: i32) -> String {
|
||||
e.1.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What the entities of a layer are, without their handles and the layer: to compare layers of two files.
|
||||
fn on_layer(es: &[Entity], layer: &str) -> Vec<(String, Vec<(i32, String)>)> {
|
||||
es.iter().filter(|e| layer_of(e) == layer).map(|e| (e.0.clone(), e.1.iter().filter(|(c, _)| ![5, 8].contains(c)).cloned().collect())).collect()
|
||||
}
|
||||
|
||||
/// The LAYER table: name → (colour, linetype).
|
||||
fn layer_table(dxf: &str) -> std::collections::BTreeMap<String, (i64, String)> {
|
||||
let lines: Vec<&str> = dxf.lines().collect();
|
||||
let pairs: Vec<(i32, &str)> = lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim()))).collect();
|
||||
let start = pairs.iter().position(|p| *p == (2, "LAYER")).unwrap_or(0);
|
||||
let mut records: Vec<Vec<(i32, &str)>> = Vec::new();
|
||||
for p in pairs.iter().skip(start + 1) {
|
||||
match *p {
|
||||
(0, "ENDTAB") => break,
|
||||
(0, _) => records.push(Vec::new()),
|
||||
_ => {
|
||||
if let Some(record) = records.last_mut() {
|
||||
record.push(*p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
records
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let get = |code: i32| r.iter().find(|(c, _)| *c == code).map(|(_, v)| *v);
|
||||
Some((get(2)?.to_string(), (get(62)?.parse().ok()?, get(6)?.to_string())))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn vec2(v: &Value) -> Vec2 {
|
||||
Vec2::new(v[0].as_f64().unwrap_or(f64::NAN), v[1].as_f64().unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flat_pattern_is_on_layers_by_role_and_inner_contours_come_first() {
|
||||
let mut s = tray_with(&marks());
|
||||
let out = run(&mut s, "sheet.export_dxf", json!({}));
|
||||
let dxf = out["dxf"].as_str().unwrap_or_default();
|
||||
let es = entities(dxf);
|
||||
let on = |layer: &str| es.iter().filter(|e| layer_of(e) == layer).collect::<Vec<_>>();
|
||||
// OUTLINE: the tray's outer contour alone, one entity for each of its segments (lines and arcs).
|
||||
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
|
||||
let plate =
|
||||
regions.iter().max_by(|a, b| a.area().total_cmp(&b.area())).map(|r| r.outer.clone()).unwrap_or(solidcraft_geom::Loop2 { segs: Vec::new() });
|
||||
assert_eq!(on("OUTLINE").len(), solidcraft_io::loop_shapes(&plate).len());
|
||||
assert!(on("OUTLINE").iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", on("OUTLINE").iter().map(|e| &e.0).collect::<Vec<_>>());
|
||||
// CUT: what is cut out inside it that is no text: the Ø5 hole as one CIRCLE, the slot's four lines.
|
||||
let cut = on("CUT");
|
||||
assert_eq!((count_of(&cut, "CIRCLE"), count_of(&cut, "LINE"), cut.len()), (1, 4, 5), "{:?}", cut.iter().map(|e| &e.0).collect::<Vec<_>>());
|
||||
// TEXT: the letters of "AB-12" and the counters of A and B, all within the label's box (5 letters
|
||||
// of at most 10 mm from (20, 28)).
|
||||
let letters = on("TEXT");
|
||||
assert!(letters.len() > 20, "{} letter entities", letters.len());
|
||||
for e in &letters {
|
||||
let p = point(e, 10);
|
||||
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", e.0);
|
||||
}
|
||||
// The bend lines by direction: three walls up, one down.
|
||||
assert_eq!((on("BEND_UP").len(), on("BEND_DOWN").len(), out["bends"].as_u64()), (3, 1, Some(4)));
|
||||
// Inner contours are written before the outline, so a program that follows the file cuts inside-out.
|
||||
let last_inner = es.iter().rposition(|e| matches!(layer_of(e).as_str(), "CUT" | "TEXT"));
|
||||
let first_outline = es.iter().position(|e| layer_of(e) == "OUTLINE");
|
||||
let (Some(last_inner), Some(first_outline)) = (last_inner, first_outline) else { panic!("{last_inner:?} {first_outline:?}") };
|
||||
assert!(last_inner < first_outline, "an inner contour at {last_inner}, the outline from {first_outline}");
|
||||
// The layers, with the colours and linetypes the guide gives; nothing else is declared.
|
||||
let want = [
|
||||
("0", 7, "Continuous"),
|
||||
("OUTLINE", 7, "Continuous"),
|
||||
("CUT", 4, "Continuous"),
|
||||
("TEXT", 2, "Continuous"),
|
||||
("BEND_UP", 1, "DASHED"),
|
||||
("BEND_DOWN", 5, "DASHED"),
|
||||
];
|
||||
assert_eq!(layer_table(dxf), want.map(|(n, c, l)| (n.to_string(), (c, l.to_string()))).into_iter().collect());
|
||||
}
|
||||
|
||||
/// How many entities of `kind` there are among `list`.
|
||||
fn count_of(list: &[&Entity], kind: &str) -> usize {
|
||||
list.iter().filter(|e| e.0 == kind).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_can_be_etched_instead_of_cut() {
|
||||
let mut s = tray_with(&marks());
|
||||
let cut = entities(&export(&mut s, json!({})));
|
||||
let etched_dxf = export(&mut s, json!({"text": "etch"}));
|
||||
let etched = entities(&etched_dxf);
|
||||
// The same contours, on ETCH instead of TEXT: nothing of them is cut.
|
||||
assert!(!on_layer(&cut, "TEXT").is_empty());
|
||||
assert_eq!(on_layer(&etched, "ETCH"), on_layer(&cut, "TEXT"));
|
||||
assert!(on_layer(&etched, "TEXT").is_empty());
|
||||
// The hole and the slot are no text: they stay where they were, as do the outline and the bends.
|
||||
for layer in ["OUTLINE", "CUT", "BEND_UP", "BEND_DOWN"] {
|
||||
assert_eq!(on_layer(&etched, layer), on_layer(&cut, layer), "{layer}");
|
||||
}
|
||||
let layers = layer_table(&etched_dxf);
|
||||
assert_eq!(layers.get("ETCH"), Some(&(6, "Continuous".to_string())));
|
||||
assert!(!layers.contains_key("TEXT"));
|
||||
// `cut` is the default, and a value that is neither is refused.
|
||||
assert_eq!(export(&mut s, json!({"text": "cut"})), export(&mut s, json!({})));
|
||||
assert!(s.execute("sheet.export_dxf", &json!({"text": "fill"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engraved_text_is_etched_and_other_pockets_keep_their_depth_layer() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Marks"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [70, 40], "p1": [90, 45]}));
|
||||
run(&mut s, "sketch.text", json!({"text": "AB-12", "at": [20, 28], "height": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Part way through the sheet: pockets, 1 mm deep from the bottom.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Marks", "distance": 1, "operation": "cut"}));
|
||||
let dxf = export(&mut s, json!({}));
|
||||
let es = entities(&dxf);
|
||||
// The rectangle is a pocket of the depth its layer names; the letters are a marking, on ETCH.
|
||||
let pocket = "POCKET_BOTTOM_Z0.000000_TO_1.000000_MM";
|
||||
let on = |layer: &str| es.iter().filter(|e| layer_of(e) == layer).collect::<Vec<_>>();
|
||||
assert_eq!((count_of(&on(pocket), "LINE"), on(pocket).len()), (4, 4));
|
||||
let letters = on("ETCH");
|
||||
assert!(letters.len() > 20, "{} letter entities", letters.len());
|
||||
for e in &letters {
|
||||
let p = point(e, 10);
|
||||
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", e.0);
|
||||
}
|
||||
let layers = layer_table(&dxf);
|
||||
assert!(layers.contains_key(pocket) && layers.contains_key("ETCH") && !layers.contains_key("TEXT"), "{layers:?}");
|
||||
// `text` is about letters cut through: it changes nothing here.
|
||||
assert_eq!(on_layer(&entities(&export(&mut s, json!({"text": "etch"}))), "ETCH"), on_layer(&es, "ETCH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bend_note_sits_beside_each_bend_line_when_asked_for() {
|
||||
let mut s = tray_with(&[]);
|
||||
let flat = run(&mut s, "sheet.flat_pattern", json!({}));
|
||||
let bends = flat["bends"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(bends.len(), 4);
|
||||
// Not unless asked for: a program that follows the file would etch them.
|
||||
let plain = export(&mut s, json!({}));
|
||||
assert!(entities(&plain).iter().all(|e| e.0 != "TEXT") && !layer_table(&plain).contains_key("BEND_NOTES"));
|
||||
let dxf = export(&mut s, json!({"bend_notes": true}));
|
||||
let notes: Vec<Entity> = entities(&dxf).into_iter().filter(|e| e.0 == "TEXT").collect();
|
||||
assert_eq!(notes.len(), 4);
|
||||
assert!(notes.iter().all(|e| layer_of(e) == "BEND_NOTES"));
|
||||
assert_eq!(layer_table(&dxf).get("BEND_NOTES"), Some(&(8, "Continuous".to_string())));
|
||||
for b in &bends {
|
||||
// The walls bend by 90° round an inner radius of 1 mm: "B1 UP 90° R1" (`%%d` is the degree sign).
|
||||
let up = b["direction"] == "up";
|
||||
let text = format!("B{} {} 90%%d R1", b["bend"].as_u64().unwrap_or(0), if up { "UP" } else { "DOWN" });
|
||||
let Some(note) = notes.iter().find(|e| string(e, 1) == text) else {
|
||||
panic!("no note `{text}` among {:?}", notes.iter().map(|e| string(e, 1)).collect::<Vec<_>>())
|
||||
};
|
||||
// Centred (72 = 1), 3 mm high, parallel to the line: along x it reads left to right, along y upwards.
|
||||
let (start, end) = (vec2(&b["start"]), vec2(&b["end"]));
|
||||
let angle = if (end - start).x.abs() > (end - start).y.abs() { 0.0 } else { 90.0 };
|
||||
assert!(values(note, 72) == [1.0] && values(note, 40) == [3.0] && (values(note, 50)[0] - angle).abs() < 1e-9, "{note:?}");
|
||||
// On the left of the way it reads, half its height from the middle of the line.
|
||||
let off = point(note, 11) - (start + end) * 0.5;
|
||||
assert!(off.dist(Vec2::from_angle(angle.to_radians()).perp() * 1.5) < 1e-9, "{text}: {off:?}");
|
||||
}
|
||||
// The height is the caller's.
|
||||
let tall = entities(&export(&mut s, json!({"bend_notes": true, "note_height": 5})));
|
||||
let note = tall.iter().find(|e| e.0 == "TEXT");
|
||||
assert!(note.is_some_and(|e| values(e, 40) == [5.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bend_note_gives_the_included_angle_when_the_rule_reads_it_so() {
|
||||
// The included angle is 180° less the bend angle: 122° for a 58° bend, 90° for a 90° one.
|
||||
for (deg, included) in [(58.0, 122.0), (90.0, 90.0)] {
|
||||
let mut plain = Vec::new();
|
||||
for display in ["bend", "included"] {
|
||||
let mut s = sheet_of_2mm();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Sheet2", "angle_display": display}));
|
||||
let mut s = plate_in(s, &[]);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20, "angle": format!("{deg} deg")}));
|
||||
let dxf = export(&mut s, json!({"bend_notes": true}));
|
||||
let notes: Vec<String> = entities(&dxf).iter().filter(|e| e.0 == "TEXT").map(|e| string(e, 1)).collect();
|
||||
let shown = if display == "included" { included } else { deg };
|
||||
assert_eq!(notes, [format!("B1 UP {shown}%%d R1")], "{deg}° under {display}");
|
||||
plain.push(export(&mut s, json!({})));
|
||||
}
|
||||
// Without notes the setting changes nothing in the file.
|
||||
assert_eq!(plain.first(), plain.get(1), "{deg}°");
|
||||
}
|
||||
}
|
||||
|
||||
/// A body whose base flange names its rule keeps that rule's notes whichever rule is active when
|
||||
/// the DXF is written, and a bend down reads its included angle the same way.
|
||||
#[test]
|
||||
fn a_bend_note_follows_the_bodys_own_rule_and_bends_down_too() {
|
||||
let mut s = sheet_of_2mm();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Sheet2", "angle_display": "included"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base", "rule": "Sheet2"}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20, "angle": "58 deg", "flip": true}));
|
||||
let notes = |s: &mut Session| -> Vec<String> {
|
||||
entities(&export(s, json!({"bend_notes": true}))).iter().filter(|e| e.0 == "TEXT").map(|e| string(e, 1)).collect()
|
||||
};
|
||||
assert_eq!(notes(&mut s), ["B1 DOWN 122%%d R1"]);
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Other", "thickness": "2 mm", "angle_display": "bend", "active": true}));
|
||||
assert_eq!(notes(&mut s), ["B1 DOWN 122%%d R1"], "another active rule does not change this body's notes");
|
||||
}
|
||||
|
||||
/// Two sheet bodies: a lid and, 200 mm away, a side wall with one bend.
|
||||
fn lid_and_wall() -> Session {
|
||||
let mut s = Session::default();
|
||||
for (sketch, p0, p1, body) in [("Lid", [0, 0], [100, 60], "Lid"), ("Wall", [200, 0], [250, 40], "Side Wall")] {
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": sketch}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": sketch, "body_name": body}));
|
||||
}
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[225, 0, 2.5]], "height": 15, "body": "Side Wall"}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_sheet_body_can_have_a_file_of_its_own() {
|
||||
let mut s = lid_and_wall();
|
||||
let r = run(&mut s, "sheet.export_dxf", json!({"per_body": true}));
|
||||
let files = r["files"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(files.iter().map(|f| f["body"].as_str().unwrap_or_default()).collect::<Vec<_>>(), ["Lid", "Side Wall"]);
|
||||
assert_eq!(files.iter().map(|f| f["bends"].as_u64()).collect::<Vec<_>>(), [Some(0), Some(1)]);
|
||||
// Each is the file `body` asks for: the lid's has no bend line, the wall's has its one.
|
||||
for f in &files {
|
||||
let single = export(&mut s, json!({"body": f["body"]}));
|
||||
assert_eq!(f["dxf"].as_str(), Some(single.as_str()), "{}", f["body"]);
|
||||
}
|
||||
let bend_lines = |f: &Value| entities(f["dxf"].as_str().unwrap_or_default()).iter().filter(|e| layer_of(e) == "BEND_UP").count();
|
||||
assert_eq!((bend_lines(&files[0]), bend_lines(&files[1])), (0, 1));
|
||||
// With a path, each body is written to a file named after it, beside the path, and no text comes back.
|
||||
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-dxf-per-body-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap_or_default();
|
||||
let path = dir.join("panel.dxf").to_string_lossy().to_string();
|
||||
let r = run(&mut s, "sheet.export_dxf", json!({"per_body": true, "path": path}));
|
||||
let written = r["files"].as_array().cloned().unwrap_or_default();
|
||||
let names: Vec<_> = written
|
||||
.iter()
|
||||
.map(|f| std::path::Path::new(f["path"].as_str().unwrap_or_default()).file_name().map(|n| n.to_string_lossy().to_string()))
|
||||
.collect();
|
||||
assert_eq!(names, [Some("panel-Lid.dxf".to_string()), Some("panel-Side Wall.dxf".to_string())]);
|
||||
for (f, written) in files.iter().zip(&written) {
|
||||
assert!(written.get("dxf").is_none());
|
||||
let on_disk = std::fs::read_to_string(written["path"].as_str().unwrap_or_default()).unwrap_or_default();
|
||||
assert_eq!(f["dxf"].as_str(), Some(on_disk.as_str()));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_export_options_are_refused_not_ignored() {
|
||||
let mut s = tray_with(&[]);
|
||||
for p in [
|
||||
json!({"text": "fill"}),
|
||||
json!({"text": 1}),
|
||||
json!({"bend_notes": "yes"}),
|
||||
json!({"bend_notes": 1}),
|
||||
json!({"note_height": 0}),
|
||||
json!({"note_height": -3}),
|
||||
json!({"note_height": 1e9}),
|
||||
json!({"note_height": "tall"}),
|
||||
json!({"per_body": "yes"}),
|
||||
json!({"per_body": true, "body": "Body1"}),
|
||||
] {
|
||||
assert!(s.execute("sheet.export_dxf", &p).is_err(), "{p}");
|
||||
}
|
||||
// Nothing was left half done.
|
||||
assert!(s.execute("sheet.export_dxf", &json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_of_a_body_is_named_after_it_and_no_two_share_a_name() {
|
||||
// (Windows writes the separator it adds as `\`.)
|
||||
let names = |path: &str, bodies: &[&str]| -> Vec<String> {
|
||||
super::body_paths(path, &bodies.iter().map(|b| b.to_string()).collect::<Vec<_>>()).into_iter().map(|p| p.replace('\\', "/")).collect()
|
||||
};
|
||||
assert_eq!(names("out/panel.dxf", &["Lid", "Side Wall"]), ["out/panel-Lid.dxf", "out/panel-Side Wall.dxf"]);
|
||||
// The extension is the path's, and .dxf when it has none.
|
||||
assert_eq!(names("out/panel", &["Lid"]), ["out/panel-Lid.dxf"]);
|
||||
assert_eq!(names("panel.DXF", &["Lid"]), ["panel-Lid.DXF"]);
|
||||
// What a file name cannot hold becomes `_`; a name that is nothing but that stays a name.
|
||||
assert_eq!(names("p.dxf", &["a/b:c", " ", "wall."]), ["p-a_b_c.dxf", "p-body.dxf", "p-wall.dxf"]);
|
||||
// Two bodies that would share a file (in any case) get a number.
|
||||
assert_eq!(names("p.dxf", &["Lid", "lid", "LID", "Lid_2"]), ["p-Lid.dxf", "p-lid_2.dxf", "p-LID_3.dxf", "p-Lid_2_2.dxf"]);
|
||||
}
|
||||
|
||||
/// #4314: a cutter that cuts every line gets a file without the bend lines, or with everything
|
||||
/// on layer 0. The default keeps the role layers and the dashed bend lines.
|
||||
#[test]
|
||||
fn bend_lines_can_be_left_out_and_everything_put_on_layer_0() {
|
||||
let mut s = tray_with(&marks());
|
||||
let layer = |e: &Entity| e.1.iter().find(|(c, _)| *c == 8).map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
let default = entities(&export(&mut s, json!({})));
|
||||
let bends = |es: &[Entity]| es.iter().filter(|e| matches!(layer(e).as_str(), "BEND_UP" | "BEND_DOWN")).count();
|
||||
assert_eq!(bends(&default), 4, "the tray's four bends");
|
||||
let without = entities(&export(&mut s, json!({"bend_lines": false})));
|
||||
assert_eq!(bends(&without), 0);
|
||||
assert_eq!(without.len(), default.len() - 4, "only the bend lines go");
|
||||
let single = entities(&export(&mut s, json!({"layers": "single"})));
|
||||
assert_eq!(single.len(), default.len());
|
||||
assert!(single.iter().all(|e| layer(e) == "0"), "{:?}", single.iter().map(layer).collect::<Vec<_>>());
|
||||
for bad in [json!({"bend_lines": "no"}), json!({"layers": "one"})] {
|
||||
assert!(s.execute("sheet.export_dxf", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
//! Geometry foundation for SolidCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
|
||||
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
|
||||
//! (B-spline basis and NURBS surfaces in [`bspline`], spline spaces and the operators that make
|
||||
//! splines compatible in [`spline_space`], one-surface fills of closed loops in [`fill`],
|
||||
//! surfaces through curve networks in [`network`], T-splines in [`tspline`]). Units are millimetres.
|
||||
//!
|
||||
//! This crate has no knowledge of sketches, documents or a modelling backend; every other crate
|
||||
//! builds on it.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod aabb2;
|
||||
mod arcfit;
|
||||
pub mod blend;
|
||||
pub mod brep;
|
||||
pub mod bspline;
|
||||
mod bvh;
|
||||
pub mod cell;
|
||||
mod chain;
|
||||
pub mod compare;
|
||||
pub mod drawing_sheet;
|
||||
pub mod drawing_view;
|
||||
pub mod env;
|
||||
pub mod fill;
|
||||
pub mod fill_general;
|
||||
mod grid;
|
||||
pub mod hlr;
|
||||
pub mod inertia;
|
||||
mod mesh;
|
||||
pub mod mesh_distance;
|
||||
pub mod network;
|
||||
pub mod paper;
|
||||
mod plane;
|
||||
mod profile;
|
||||
pub mod section;
|
||||
pub mod simplify;
|
||||
pub mod spline_space;
|
||||
pub mod srgb;
|
||||
pub mod tess;
|
||||
pub mod title_block;
|
||||
pub mod tspline;
|
||||
mod unit_vec3;
|
||||
mod vec;
|
||||
|
||||
pub use aabb2::Aabb2;
|
||||
pub use arcfit::MAX_FIT_PIECES;
|
||||
pub use blend::hermite_bezier;
|
||||
pub use bvh::{Bvh, MeshIndex};
|
||||
pub use chain::{Chain, Closed, Ends, Open, Path, Planar, Ring};
|
||||
pub use grid::Grid;
|
||||
pub use mesh::{Aabb3, BoxedTriangle, InsideIndex, MassProperties, Mesh, MeshMeasure, find_close_pair};
|
||||
pub use plane::Plane;
|
||||
pub use profile::{Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, Similarity2, bezier, regions_apart};
|
||||
pub use unit_vec3::UnitVec3;
|
||||
pub use vec::{Vec2, Vec3};
|
||||
|
||||
/// Geometric tolerance for coincidence tests (mm).
|
||||
pub const EPS: f64 = 1e-9;
|
||||
/// Linear tolerance used for "same point" decisions in modelling (mm).
|
||||
pub const LINEAR_TOL: f64 = 1e-6;
|
||||
|
||||
/// `x` if finite, else `fallback`.
|
||||
pub fn finite_or(x: f64, fallback: f64) -> f64 {
|
||||
if x.is_finite() { x } else { fallback }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
+1176
File diff suppressed because it is too large
Load Diff
+987
@@ -0,0 +1,987 @@
|
||||
use crate::*;
|
||||
|
||||
fn cube_mesh(s: f64) -> Mesh {
|
||||
let p = |x: f64, y: f64, z: f64| Vec3::new(x * s, y * s, z * s);
|
||||
let positions = vec![p(0., 0., 0.), p(1., 0., 0.), p(1., 1., 0.), p(0., 1., 0.), p(0., 0., 1.), p(1., 0., 1.), p(1., 1., 1.), p(0., 1., 1.)];
|
||||
let triangles =
|
||||
vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]];
|
||||
let n = triangles.len();
|
||||
Mesh {
|
||||
normals: vec![Vec3::Z; 8],
|
||||
positions,
|
||||
triangles,
|
||||
tri_face: vec![0; n],
|
||||
edges: vec![],
|
||||
display_only_edges: false,
|
||||
seams: vec![],
|
||||
smooth_edges: vec![],
|
||||
sharp_edges: vec![],
|
||||
edge_faces: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_edge_masks_keep_identity_and_break_only_certified_sharp_patches() {
|
||||
let mut mesh =
|
||||
Mesh { edges: vec![vec![Vec3::ZERO, Vec3::Z]; 2], edge_faces: vec![vec![0, 1], vec![1, 2]], seams: vec![true, true], ..Mesh::default() };
|
||||
let stored = serde_json::to_value(&mesh).unwrap();
|
||||
assert_eq!(mesh.surface_patch(0), vec![0, 1, 2]);
|
||||
mesh.smooth_edges = vec![true, false];
|
||||
assert_eq!(mesh.surface_patch(0), vec![0, 1, 2]);
|
||||
assert!(!mesh.shaded_edge_visible(0));
|
||||
mesh.smooth_edges[0] = false;
|
||||
mesh.sharp_edges = vec![true, false];
|
||||
assert!(mesh.shaded_edge_visible(0));
|
||||
assert!(mesh.edge_pickable(0));
|
||||
assert!(!mesh.edge_pickable(1));
|
||||
assert_eq!(mesh.surface_patch(0), vec![0]);
|
||||
assert_eq!(mesh.surface_patch(1), vec![1, 2]);
|
||||
assert_eq!(mesh.edges.len(), 2);
|
||||
assert_eq!(mesh.face_edges(1), vec![0, 1]);
|
||||
mesh.display_only_edges = true;
|
||||
assert!(mesh.edge_visible(0) && mesh.shaded_edge_visible(0));
|
||||
assert!(!mesh.edge_pickable(0));
|
||||
assert_eq!(serde_json::to_value(&mesh).unwrap(), stored);
|
||||
let reopened: Mesh = serde_json::from_value(stored).unwrap();
|
||||
assert!(reopened.smooth_edges.is_empty() && reopened.sharp_edges.is_empty());
|
||||
assert!(Mesh::default().shaded_edge_visible(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_measure() {
|
||||
let m = cube_mesh(2.0).measure();
|
||||
assert!((m.volume - 8.0).abs() < 1e-12);
|
||||
assert!((m.area - 24.0).abs() < 1e-12);
|
||||
assert!(m.centroid.dist(Vec3::new(1.0, 1.0, 1.0)) < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raycast_hits_front_face() {
|
||||
let m = cube_mesh(2.0);
|
||||
let (t, _) = m.raycast(Vec3::new(1.0, 1.0, 10.0), Vec3::new(0.0, 0.0, -1.0)).unwrap();
|
||||
assert!((t - 8.0).abs() < 1e-9);
|
||||
assert!(m.raycast(Vec3::new(5.0, 5.0, 10.0), Vec3::new(0.0, 0.0, -1.0)).is_none());
|
||||
}
|
||||
|
||||
/// A ray through a vertex or edge that triangles share never slips between them: a grid of
|
||||
/// triangles on a square, hit at every inner vertex and edge midpoint from oblique directions
|
||||
/// (on the square's own border nothing is shared, and a ray grazing it may miss).
|
||||
#[test]
|
||||
fn raycast_never_slips_between_triangles() {
|
||||
let n = 6;
|
||||
let positions: Vec<Vec3> =
|
||||
(0..=n).flat_map(|j| (0..=n).map(move |i| Vec3::new(f64::from(i) * 40.0 / 6.0 + 100.0, f64::from(j) * 5.0, 20.0))).collect();
|
||||
let id = |i: u32, j: u32| j * (n + 1) + i;
|
||||
let mut triangles = Vec::new();
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
triangles.push([id(i, j), id(i + 1, j), id(i + 1, j + 1)]);
|
||||
triangles.push([id(i, j), id(i + 1, j + 1), id(i, j + 1)]);
|
||||
}
|
||||
}
|
||||
let tri_face = vec![0; triangles.len()];
|
||||
let m = Mesh { positions, triangles, tri_face, ..Default::default() };
|
||||
let dirs = [Vec3::new(-0.57735, 0.57735, -0.57735), Vec3::new(0.3, -0.7, -0.648), Vec3::new(-0.123456789, -0.2, -1.0), Vec3::new(0.0, 0.0, -1.0)];
|
||||
let mut targets = Vec::new();
|
||||
for j in 1..2 * n {
|
||||
for i in 1..2 * n {
|
||||
targets.push(Vec3::new(f64::from(i) * 20.0 / 6.0 + 100.0, f64::from(j) * 2.5, 20.0));
|
||||
}
|
||||
}
|
||||
for d in dirs {
|
||||
for p in &targets {
|
||||
let o = *p - d * 50.0;
|
||||
let hit = m.raycast(o, d);
|
||||
assert!(hit.is_some_and(|(t, _)| (t - 50.0).abs() < 1e-6), "{p:?} along {d:?}: {hit:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planes() {
|
||||
assert_eq!(Plane::XY.normal(), Vec3::Z);
|
||||
assert_eq!(Plane::XZ.normal(), Vec3::Y);
|
||||
assert_eq!(Plane::YZ.normal(), Vec3::X);
|
||||
let p = Plane::XZ.to_world(Vec2::new(3.0, 4.0));
|
||||
assert_eq!(p, Vec3::new(3.0, 0.0, -4.0));
|
||||
assert_eq!(Plane::XZ.to_local(p), Vec2::new(3.0, 4.0));
|
||||
let hit = Plane::XY.offset(5.0).intersect_ray(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -2.0)).unwrap();
|
||||
assert!(hit.dist(Vec3::new(1.0, 2.0, 5.0)) < 1e-12);
|
||||
assert!(Plane::new(Vec3::ZERO, Vec3::X, Vec3::X).is_none());
|
||||
let f = Plane::from_normal(Vec3::ZERO, Vec3::new(0.0, 0.0, 3.0)).unwrap();
|
||||
assert!((f.normal() - Vec3::Z).len() < 1e-12);
|
||||
assert!(Plane::from_normal(Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vec_ops_and_serde() {
|
||||
let a = Vec3::new(1.0, 2.0, 3.0);
|
||||
assert_eq!(a.cross(Vec3::X), Vec3::new(0.0, 3.0, -2.0));
|
||||
assert_eq!(Vec2::new(3.0, 4.0).len(), 5.0);
|
||||
assert!(Vec2::ZERO.normalized().is_none());
|
||||
assert!(Vec3::new(0.5, 0.0, 0.0).dist_to_segment(Vec3::ZERO, Vec3::X) < 1e-12);
|
||||
assert_eq!(serde_json::to_string(&a).unwrap(), "[1.0,2.0,3.0]");
|
||||
let b: Vec2 = serde_json::from_str("[1,2]").unwrap();
|
||||
assert_eq!(b, Vec2::new(1.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aabb() {
|
||||
let mut b = Aabb3::EMPTY;
|
||||
assert!(b.is_empty());
|
||||
b.add(Vec3::new(1.0, 2.0, 3.0));
|
||||
b.add(Vec3::new(f64::NAN, 0.0, 0.0));
|
||||
b.add(Vec3::new(-1.0, 0.0, 0.0));
|
||||
assert_eq!(b.size(), Vec3::new(2.0, 2.0, 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_chains_follow_smooth_edges() {
|
||||
// A line, then an arc leaving it tangentially, then a sharp corner.
|
||||
let mut m = Mesh { positions: vec![Vec3::ZERO, Vec3::new(30.0, 30.0, 0.0)], ..Default::default() };
|
||||
m.edges.push(vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0)]);
|
||||
let arc: Vec<Vec3> = (0..=16)
|
||||
.map(|k| {
|
||||
let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::FRAC_PI_2 * k as f64 / 16.0;
|
||||
Vec3::new(10.0 + 5.0 * a.cos(), 5.0 + 5.0 * a.sin(), 0.0)
|
||||
})
|
||||
.collect();
|
||||
m.edges.push(arc);
|
||||
m.edges.push(vec![Vec3::new(15.0, 5.0, 0.0), Vec3::new(5.0, 5.0, 0.0)]);
|
||||
m.edge_faces = vec![vec![0], vec![0, 1], vec![1]];
|
||||
let mut c = m.tangent_chain(0, 5f64.to_radians());
|
||||
c.sort();
|
||||
assert_eq!(c, vec![0, 1]);
|
||||
assert_eq!(m.tangent_chain(2, 5f64.to_radians()), vec![2]);
|
||||
assert_eq!(m.face_edges(1), vec![1, 2]);
|
||||
// A smooth seam where two faces share the same normal along the edge is not a corner to round,
|
||||
// so tangent chaining stops at it.
|
||||
let seam = Mesh {
|
||||
positions: vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0), Vec3::new(20.0, 0.0, 0.0), Vec3::new(15.0, 5.0, 0.0)],
|
||||
normals: vec![Vec3::Z; 4],
|
||||
triangles: vec![[0, 1, 3], [1, 2, 3], [1, 2, 3]],
|
||||
tri_face: vec![0, 1, 2],
|
||||
edges: vec![vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0)], vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(20.0, 0.0, 0.0)]],
|
||||
edge_faces: vec![vec![0], vec![1, 2]],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(seam.tangent_chain(0, 5f64.to_radians()), vec![0]);
|
||||
}
|
||||
|
||||
/// A helix of `pieces` edge pieces (a thread's crest), each five points long.
|
||||
fn helix(pieces: usize) -> Mesh {
|
||||
let at = |t: f64| Vec3::new(4.0 * t.cos(), 4.0 * t.sin(), 0.2 * t);
|
||||
let step = 0.05;
|
||||
let mut m = Mesh::default();
|
||||
for k in 0..pieces {
|
||||
let t0 = k as f64 * step * 4.0;
|
||||
m.edges.push((0..=4).map(|j| at(t0 + j as f64 * step)).collect());
|
||||
m.edge_faces.push(vec![0]);
|
||||
}
|
||||
// The mesh's points (its size sets the join tolerance).
|
||||
m.positions = m.edges.iter().flatten().copied().collect();
|
||||
m
|
||||
}
|
||||
|
||||
/// The tangent chain of a helix of `pieces` pieces: every piece of it joins, and how long finding
|
||||
/// the chain took. The mesh is built before the clock starts.
|
||||
fn helix_chain(pieces: usize) -> std::time::Duration {
|
||||
let m = helix(pieces);
|
||||
let began = std::time::Instant::now();
|
||||
let chain = m.tangent_chain(pieces / 2, 2f64.to_radians());
|
||||
let took = began.elapsed();
|
||||
let mut sorted = chain.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(sorted, (0..pieces).collect::<Vec<_>>(), "every piece of the helix is in the chain");
|
||||
took
|
||||
}
|
||||
|
||||
/// Whether a job four times the size took at most about twice linear: `pair` times the small job,
|
||||
/// then the big one. Linear is 4x, the quadratic that the bug cost 16x or more, so eight times the
|
||||
/// small time is the limit. The load of the other test binaries can rise between the two runs, so a
|
||||
/// pair that misses is measured again, up to three times. Load only ever slows a run, so each size
|
||||
/// keeps its fastest time and the ratio is taken of those: a retry cannot pass a regression on a
|
||||
/// small run that a spike happened to slow, once an earlier small run was fast.
|
||||
///
|
||||
/// `slack` is the room the noise of a busy machine gets, on top of the ratio.
|
||||
fn scales_linearly(mut pair: impl FnMut() -> (std::time::Duration, std::time::Duration), slack: std::time::Duration) -> Result<(), String> {
|
||||
let limit = |small: f64| 8.0 * small + slack.as_secs_f64();
|
||||
let (mut small, mut big) = (f64::INFINITY, f64::INFINITY);
|
||||
let mut runs = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let (s, b) = pair();
|
||||
small = small.min(s.as_secs_f64());
|
||||
big = big.min(b.as_secs_f64());
|
||||
runs.push(format!("{s:?} then {b:?}"));
|
||||
if big < limit(small) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(format!("{}; fastest {small:.3} s and {big:.3} s, limit {:.3} s", runs.join(", "), limit(small)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scales_linearly_keeps_each_sizes_fastest_run() {
|
||||
let pairs = |p: Vec<(u64, u64)>| {
|
||||
let mut p = p.into_iter().map(|(s, b)| (std::time::Duration::from_millis(s), std::time::Duration::from_millis(b)));
|
||||
move || p.next().unwrap_or((std::time::Duration::from_secs(1), std::time::Duration::from_secs(1)))
|
||||
};
|
||||
let slack = std::time::Duration::from_millis(0);
|
||||
// Linear: four times the work, four times the time.
|
||||
assert!(scales_linearly(pairs(vec![(5, 20)]), slack).is_ok());
|
||||
// A big run slowed by load, then a quiet pair.
|
||||
assert!(scales_linearly(pairs(vec![(5, 80), (5, 20)]), slack).is_ok());
|
||||
// A quadratic cost (16x) whose later small runs a spike slowed: the fast first one still counts.
|
||||
let r = scales_linearly(pairs(vec![(5, 80), (15, 80), (15, 80)]), slack);
|
||||
assert!(r.as_ref().is_err_and(|e| e.contains("fastest 0.005 s and 0.080 s, limit 0.040 s")), "{r:?}");
|
||||
}
|
||||
|
||||
/// #3745: the tangent chain of a long helix (a thread's crest, in many edge pieces) is found
|
||||
/// without scanning every edge for every edge of it: hovering a threaded part's edge looked for
|
||||
/// its rim this way, and took seconds inside the frame. Every piece joins, in edge order.
|
||||
///
|
||||
/// A ratio and not a limit: a test must not depend on wall-clock timing (CONTRIBUTING.md), the
|
||||
/// machine is busy. Four times the pieces must not cost sixteen times the time.
|
||||
#[test]
|
||||
fn a_long_helical_tangent_chain_is_found_quickly() {
|
||||
/// The room the noise of a run on a busy machine gets, on top of the ratio: the chain of 750
|
||||
/// pieces takes a fraction of a second, so this is the noise of one run and not a change of
|
||||
/// scale.
|
||||
const SLACK: std::time::Duration = std::time::Duration::from_millis(20);
|
||||
let pieces = 3_000usize;
|
||||
if let Err(missed) = scales_linearly(|| (helix_chain(pieces / 4), helix_chain(pieces)), SLACK) {
|
||||
panic!("{pieces} pieces took {missed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_rims_group_only_closed_cocircular_edges() {
|
||||
let arc = |start: f64, sweep: f64, yscale: f64| -> Vec<Vec3> {
|
||||
(0..=32)
|
||||
.map(|i| {
|
||||
let a = start + sweep * i as f64 / 32.0;
|
||||
Vec3::new(10.0 * a.cos(), yscale * 10.0 * a.sin(), 5.0)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let pi = std::f64::consts::PI;
|
||||
let mut m = Mesh {
|
||||
positions: vec![Vec3::new(-10.0, -10.0, 5.0), Vec3::new(10.0, 10.0, 5.0)],
|
||||
edges: vec![arc(0.0, pi, 1.0), arc(2.0 * pi, -pi, 1.0)],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(m.circular_rim(0), vec![0, 1]);
|
||||
assert_eq!(m.circular_rim(1), vec![0, 1]);
|
||||
// A hidden seam cannot form a selectable rim.
|
||||
m.seams = vec![false, true];
|
||||
assert_eq!(m.circular_rim(0), vec![0]);
|
||||
m.seams.clear();
|
||||
m.edges[1] = arc(pi, pi / 2.0, 1.0);
|
||||
assert_eq!(m.circular_rim(0), vec![0]);
|
||||
// A closed, smooth ellipse is not a circle.
|
||||
m.edges = vec![arc(0.0, pi, 0.7), arc(pi, pi, 0.7)];
|
||||
assert_eq!(m.circular_rim(0), vec![0]);
|
||||
assert_eq!(m.circular_rim(99), vec![99]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_patches_cross_only_hidden_seams() {
|
||||
let mut m = Mesh { edge_faces: vec![vec![0, 1], vec![1, 2], vec![2, 3]], seams: vec![true, true, false], ..Default::default() };
|
||||
assert_eq!(m.surface_patch(0), vec![0, 1, 2]);
|
||||
assert_eq!(m.surface_patch(2), vec![0, 1, 2]);
|
||||
assert_eq!(m.surface_patch(3), vec![3]);
|
||||
m.seams.clear();
|
||||
assert_eq!(m.surface_patch(0), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubic_and_conic_segments_measure_exactly() {
|
||||
use crate::{Loop2, Seg2, Vec2};
|
||||
// An ellipse (a = 5, b = 3) as four quarter conics: area π a b.
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let (a, b) = (5.0, 3.0);
|
||||
let q = |p0: Vec2, c: Vec2, p1: Vec2| Seg2::Conic { a: p0, apex: c, b: p1, w };
|
||||
let e = Loop2 {
|
||||
segs: vec![
|
||||
q(Vec2::new(a, 0.0), Vec2::new(a, b), Vec2::new(0.0, b)),
|
||||
q(Vec2::new(0.0, b), Vec2::new(-a, b), Vec2::new(-a, 0.0)),
|
||||
q(Vec2::new(-a, 0.0), Vec2::new(-a, -b), Vec2::new(0.0, -b)),
|
||||
q(Vec2::new(0.0, -b), Vec2::new(a, -b), Vec2::new(a, 0.0)),
|
||||
],
|
||||
};
|
||||
assert!((e.signed_area() - std::f64::consts::PI * a * b).abs() < 1e-9, "{}", e.signed_area());
|
||||
for s in &e.segs {
|
||||
let m = s.point_at(0.37);
|
||||
assert!(((m.x / a).powi(2) + (m.y / b).powi(2) - 1.0).abs() < 1e-12);
|
||||
}
|
||||
// A cubic on a straight line has the line's length; reversing flips the area sign.
|
||||
let c = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(1.0, 0.0), p2: Vec2::new(2.0, 0.0), p3: Vec2::new(3.0, 0.0) };
|
||||
assert!((c.length() - 3.0).abs() < 1e-12);
|
||||
// Square with one bulging cubic side: area = 1 + the cubic's area over the chord.
|
||||
let bulge = Seg2::Cubic { p0: Vec2::new(1.0, 0.0), p1: Vec2::new(1.5, 1.0 / 3.0), p2: Vec2::new(1.5, 2.0 / 3.0), p3: Vec2::new(1.0, 1.0) };
|
||||
let sq = Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(1.0, 0.0) },
|
||||
bulge,
|
||||
Seg2::Line { a: Vec2::new(1.0, 1.0), b: Vec2::new(0.0, 1.0) },
|
||||
Seg2::Line { a: Vec2::new(0.0, 1.0), b: Vec2::new(0.0, 0.0) },
|
||||
],
|
||||
};
|
||||
// x(t) - 1 = 1.5 t(1-t) (Bernstein: 3·0.5·t(1-t)²+3·0.5·t²(1-t)), y = t: ∫ 1.5 t(1-t) dt = 0.25.
|
||||
assert!((sq.signed_area() - 1.25).abs() < 1e-12, "{}", sq.signed_area());
|
||||
let rev = bulge.reversed();
|
||||
assert!((rev.area_term() + bulge.area_term()).abs() < 1e-12);
|
||||
assert!(bulge.polyline(1e-4).len() > 4);
|
||||
// Halves cover the same curve.
|
||||
let (l, r) = bulge.split_half();
|
||||
assert!(l.point_at(0.5).dist(bulge.point_at(0.25)) < 1e-12);
|
||||
assert!(r.point_at(0.5).dist(bulge.point_at(0.75)) < 1e-12);
|
||||
// Conic halves are re-parameterised but stay on the ellipse.
|
||||
let (l2, r2) = e.segs[0].split_half();
|
||||
for t in [0.1, 0.5, 0.9] {
|
||||
for q in [l2.point_at(t), r2.point_at(t)] {
|
||||
assert!(((q.x / a).powi(2) + (q.y / b).powi(2) - 1.0).abs() < 1e-12, "{q:?}");
|
||||
}
|
||||
}
|
||||
for (seg, (l, r)) in [(bulge, (l, r)), (e.segs[0], (l2, r2))] {
|
||||
assert!(l.end().dist(r.start()) < 1e-12);
|
||||
assert!((l.area_term() + r.area_term() - seg.area_term()).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_at_and_closest_param() {
|
||||
use crate::{Seg2, Vec2};
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let quarter = Seg2::Conic { a: Vec2::new(10.0, 0.0), apex: Vec2::new(10.0, 10.0), b: Vec2::new(0.0, 10.0), w };
|
||||
for t in [0.2, 0.5, 0.8] {
|
||||
let (l, r) = quarter.split_at(t);
|
||||
assert!(l.end().dist(quarter.point_at(t)) < 1e-12 && r.start().dist(l.end()) < 1e-12);
|
||||
for s in [0.3, 0.7] {
|
||||
assert!((l.point_at(s).len() - 10.0).abs() < 1e-12 && (r.point_at(s).len() - 10.0).abs() < 1e-12);
|
||||
}
|
||||
assert!((l.area_term() + r.area_term() - quarter.area_term()).abs() < 1e-12);
|
||||
}
|
||||
let c = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(1.0, 2.0), p2: Vec2::new(3.0, 2.0), p3: Vec2::new(4.0, 0.0) };
|
||||
let q = c.point_at(0.37);
|
||||
assert!((c.closest_param(q + Vec2::new(0.0, 0.0)) - 0.37).abs() < 1e-9);
|
||||
let (l, r) = c.split_at(0.37);
|
||||
assert!(l.end().dist(q) < 1e-12 && r.start().dist(q) < 1e-12);
|
||||
assert!((quarter.closest_param(Vec2::new(20.0, 20.0)) - 0.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// An axis-aligned box as a closed mesh, with `n` x `n` quads on each side.
|
||||
fn box_mesh(min: Vec3, max: Vec3, n: usize) -> Mesh {
|
||||
let mut m = Mesh::default();
|
||||
let s = max - min;
|
||||
let at = |c: [f64; 3]| min + Vec3::new(s.x * c[0], s.y * c[1], s.z * c[2]);
|
||||
for axis in 0..3 {
|
||||
for side in [0.0, 1.0] {
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let corner = |a: usize, b: usize| {
|
||||
let mut c = [0.0; 3];
|
||||
c[axis] = side;
|
||||
c[(axis + 1) % 3] = a as f64 / n as f64;
|
||||
c[(axis + 2) % 3] = b as f64 / n as f64;
|
||||
at(c)
|
||||
};
|
||||
let k = m.positions.len() as u32;
|
||||
m.positions.extend([corner(i, j), corner(i + 1, j), corner(i + 1, j + 1), corner(i, j + 1)]);
|
||||
m.triangles.extend(if side > 0.0 { [[k, k + 1, k + 2], [k, k + 2, k + 3]] } else { [[k, k + 2, k + 1], [k, k + 3, k + 2]] });
|
||||
// Faces: 2 * axis, plus one on the far side.
|
||||
m.tri_face.extend([(2 * axis) as u32 + side as u32; 2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meshes_cross_only_where_their_surfaces_pass_through_each_other() {
|
||||
let cube = |x: f64, y: f64, n: usize| box_mesh(Vec3::new(x, y, 0.0), Vec3::new(x + 10.0, y + 10.0, 10.0), n);
|
||||
let a = cube(0.0, 0.0, 1);
|
||||
// A corner 0.15 deep in the other cube (0.02 % of its volume), and a deep overlap.
|
||||
for (x, y, n) in [(9.85, 3.0, 1), (9.85, 3.0, 7), (9.85, 9.85, 2), (5.0, 5.0, 3)] {
|
||||
assert!(a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}");
|
||||
assert!(cube(x, y, n).crosses(&a, 0.01), "{x} {y} {n}");
|
||||
}
|
||||
// Apart, touching on a face or an edge, and the same body.
|
||||
for (x, y, n) in [(10.5, 0.0, 1), (10.0, 0.0, 1), (10.0, 3.0, 5), (10.0, 10.0, 2), (0.0, 0.0, 1), (0.0, 0.0, 4)] {
|
||||
assert!(!a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}");
|
||||
}
|
||||
// One inside the other, standing on a face, and sharing a face plane from inside.
|
||||
let inner = box_mesh(Vec3::new(2.0, 2.0, 2.0), Vec3::new(8.0, 8.0, 8.0), 2);
|
||||
let on_top = box_mesh(Vec3::new(2.0, 2.0, 10.0), Vec3::new(8.0, 8.0, 14.0), 2);
|
||||
let flush = box_mesh(Vec3::new(2.0, 2.0, 4.0), Vec3::new(8.0, 8.0, 10.0), 2);
|
||||
assert!(!a.crosses(&inner, 0.01) && !inner.crosses(&a, 0.01));
|
||||
assert!(!a.crosses(&on_top, 0.01) && !a.crosses(&flush, 0.01));
|
||||
// A slab overlap between faces that are parallel or in one plane has no crossing.
|
||||
assert!(!a.crosses(&cube(9.85, 0.0, 1), 0.01));
|
||||
// Passing through by less than the tolerance doesn't count.
|
||||
assert!(!a.crosses(&cube(9.995, 3.0, 1), 0.01));
|
||||
assert!(!a.crosses(&cube(9.85, 3.0, 1), f64::NAN));
|
||||
// Crossings left out where the caller knows better: only those, by where they are.
|
||||
let corner = cube(9.85, 9.85, 2);
|
||||
assert!(!a.crosses_except(&corner, 0.01, |_, _, _| true));
|
||||
assert!(!a.crosses_except(&corner, 0.01, |p, _, _| p.x > 9.8 && p.y > 9.8));
|
||||
assert!(a.crosses_except(&corner, 0.01, |p, _, _| p.x < 9.8));
|
||||
// And by the faces that cross: `a`'s side at x = 10 through the corner's at y = 9.85, and
|
||||
// `a`'s at y = 10 through the corner's at x = 9.85.
|
||||
assert!(!a.crosses_except(&corner, 0.01, |_, f, g| matches!((f, g), (1, 2) | (3, 0))));
|
||||
assert!(a.crosses_except(&corner, 0.01, |_, f, g| (f, g) == (1, 2)));
|
||||
assert!(a.crosses_except(&corner, 0.01, |_, f, g| (f, g) == (3, 0)));
|
||||
}
|
||||
|
||||
/// A closed box mesh `[x0, x0+a] x [y0, y0+b] x [z0, z0+c]`, outward-facing.
|
||||
fn offset_box(o: Vec3, a: f64, b: f64, c: f64) -> Mesh {
|
||||
let p = |x: f64, y: f64, z: f64| o + Vec3::new(x * a, y * b, z * c);
|
||||
let positions = vec![p(0., 0., 0.), p(1., 0., 0.), p(1., 1., 0.), p(0., 1., 0.), p(0., 0., 1.), p(1., 0., 1.), p(1., 1., 1.), p(0., 1., 1.)];
|
||||
let triangles =
|
||||
vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]];
|
||||
Mesh { positions, triangles, ..Default::default() }
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64, rel: f64) -> bool {
|
||||
(a - b).abs() <= rel * b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_properties_of_a_box_match_the_closed_form() {
|
||||
let (a, b, c) = (20.0, 30.0, 40.0);
|
||||
let m = offset_box(Vec3::ZERO, a, b, c).mass_properties().expect("a closed box has mass properties");
|
||||
let v = a * b * c;
|
||||
assert!(close(m.volume, v, 1e-12), "{m:?}");
|
||||
assert!((m.centroid - Vec3::new(10.0, 15.0, 20.0)).len() < 1e-9, "{m:?}");
|
||||
let i = m.inertia;
|
||||
assert!(close(i[0][0], v * (b * b + c * c) / 12.0, 1e-9), "{i:?}");
|
||||
assert!(close(i[1][1], v * (a * a + c * c) / 12.0, 1e-9), "{i:?}");
|
||||
assert!(close(i[2][2], v * (a * a + b * b) / 12.0, 1e-9), "{i:?}");
|
||||
for (r, cc) in [(0, 1), (0, 2), (1, 2)] {
|
||||
assert!(i[r][cc].abs() < 1e-6 && i[cc][r].abs() < 1e-6, "{i:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_properties_of_a_cylinder_are_close() {
|
||||
// A cylinder of radius 10 and height 30 along z, 256 sides, capped by fans.
|
||||
let (r, h, n) = (10.0, 30.0, 256usize);
|
||||
let mut positions = vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, h)];
|
||||
for k in 0..n {
|
||||
let t = std::f64::consts::TAU * k as f64 / n as f64;
|
||||
positions.push(Vec3::new(r * t.cos(), r * t.sin(), 0.0));
|
||||
positions.push(Vec3::new(r * t.cos(), r * t.sin(), h));
|
||||
}
|
||||
let mut triangles = Vec::new();
|
||||
for k in 0..n as u32 {
|
||||
let (b0, t0) = (2 + 2 * k, 3 + 2 * k);
|
||||
let (b1, t1) = (2 + 2 * ((k + 1) % n as u32), 3 + 2 * ((k + 1) % n as u32));
|
||||
triangles.push([0, b1, b0]);
|
||||
triangles.push([1, t0, t1]);
|
||||
triangles.push([b0, b1, t1]);
|
||||
triangles.push([b0, t1, t0]);
|
||||
}
|
||||
let m = Mesh { positions, triangles, ..Default::default() }.mass_properties().expect("closed");
|
||||
let v = std::f64::consts::PI * r * r * h;
|
||||
assert!(close(m.volume, v, 1e-3), "{m:?}");
|
||||
assert!(close(m.inertia[2][2], m.volume * r * r / 2.0, 1e-3), "{m:?}");
|
||||
assert!(close(m.inertia[0][0], m.volume * (3.0 * r * r + h * h) / 12.0, 1e-3), "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_properties_are_translation_invariant_about_the_centroid() {
|
||||
let at_origin = offset_box(Vec3::ZERO, 20.0, 30.0, 40.0).mass_properties().unwrap();
|
||||
let moved = offset_box(Vec3::new(100.0, -50.0, 7.0), 20.0, 30.0, 40.0).mass_properties().unwrap();
|
||||
assert!((moved.centroid - Vec3::new(110.0, -35.0, 27.0)).len() < 1e-9, "{moved:?}");
|
||||
for r in 0..3 {
|
||||
for c in 0..3 {
|
||||
assert!((moved.inertia[r][c] - at_origin.inertia[r][c]).abs() <= 1e-9 * at_origin.inertia[0][0], "{r}{c}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_properties_of_an_inward_facing_box_are_positive() {
|
||||
let mut m = offset_box(Vec3::ZERO, 2.0, 3.0, 4.0);
|
||||
for t in &mut m.triangles {
|
||||
t.swap(1, 2);
|
||||
}
|
||||
let p = m.mass_properties().unwrap();
|
||||
assert!(close(p.volume, 24.0, 1e-12) && p.inertia[0][0] > 0.0, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_properties_of_an_open_or_empty_mesh_are_none() {
|
||||
let mut open = offset_box(Vec3::ZERO, 20.0, 30.0, 40.0);
|
||||
open.triangles.truncate(10);
|
||||
assert!(open.mass_properties().is_none(), "a box missing a face has no mass");
|
||||
assert!(Mesh::default().mass_properties().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_properties_need_consistently_oriented_faces() {
|
||||
// Every edge still has two triangles, but one face of the box points inwards: its signed
|
||||
// volume would cancel part of the rest.
|
||||
let mut m = offset_box(Vec3::ZERO, 20.0, 30.0, 40.0);
|
||||
for t in m.triangles.iter_mut().take(2) {
|
||||
t.swap(1, 2);
|
||||
}
|
||||
assert!(m.mass_properties().is_none());
|
||||
}
|
||||
|
||||
/// `m` with every vertex moved by the linear map `l` (row by row).
|
||||
fn mapped(m: &Mesh, l: &inertia::Tensor3) -> Mesh {
|
||||
let apply = |p: Vec3| {
|
||||
let [x, y, z] = l.map(|r| r[0] * p.x + r[1] * p.y + r[2] * p.z);
|
||||
Vec3::new(x, y, z)
|
||||
};
|
||||
Mesh { positions: m.positions.iter().map(|p| apply(*p)).collect(), triangles: m.triangles.clone(), ..Default::default() }
|
||||
}
|
||||
|
||||
fn tensors_close(a: &inertia::Tensor3, b: &inertia::Tensor3, tol: f64) -> bool {
|
||||
a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gyration_follows_a_rotation_a_scale_and_a_mirror_of_the_body() {
|
||||
let base = offset_box(Vec3::new(5.0, -3.0, 2.0), 20.0, 30.0, 40.0);
|
||||
let props = base.mass_properties().unwrap();
|
||||
let g = props.gyration().unwrap();
|
||||
assert!(tensors_close(&inertia::from_gyration(&g, props.volume), &props.inertia, 1e-6 * props.inertia[0][0]));
|
||||
let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
let (cx, sx) = (20f64.to_radians().cos(), 20f64.to_radians().sin());
|
||||
let turn_z = [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]];
|
||||
let turn_x = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]];
|
||||
let turn = [0, 1, 2].map(|i| [0, 1, 2].map(|j| (0..3).map(|k| turn_z[i][k] * turn_x[k][j]).sum::<f64>()));
|
||||
for l in [turn, [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.5]], [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]] {
|
||||
let moved = mapped(&base, &l).mass_properties().expect("a mapped box is still closed");
|
||||
let want = moved.inertia;
|
||||
let got = inertia::from_gyration(&inertia::transformed(&g, &l), moved.volume);
|
||||
assert!(tensors_close(&got, &want, 1e-6 * want[0][0].abs().max(want[2][2].abs())), "{l:?}\n{got:?}\n{want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn principal_axes_of_a_turned_box_are_its_edges() {
|
||||
let (a, b, c) = (20.0, 30.0, 40.0);
|
||||
let v = a * b * c;
|
||||
let (cs, sn) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
let turn = [[cs, -sn, 0.0], [sn, cs, 0.0], [0.0, 0.0, 1.0]];
|
||||
let m = mapped(&offset_box(Vec3::ZERO, a, b, c), &turn).mass_properties().unwrap();
|
||||
assert!(m.inertia[0][1].abs() > 1.0, "turned about z, the box has a product of inertia: {:?}", m.inertia);
|
||||
let (moments, axes) = inertia::principal(&m.inertia, 0.0).unwrap();
|
||||
// Smallest about the longest edge (z), largest about the shortest (the turned x).
|
||||
let want = [v * (a * a + b * b) / 12.0, v * (a * a + c * c) / 12.0, v * (b * b + c * c) / 12.0];
|
||||
for (got, want) in moments.iter().zip(want) {
|
||||
assert!(close(*got, want, 1e-9), "{moments:?} {want}");
|
||||
}
|
||||
let edges = [Vec3::Z, Vec3::new(-sn, cs, 0.0), Vec3::new(cs, sn, 0.0)];
|
||||
for (axis, edge) in axes.iter().zip(edges) {
|
||||
assert!((axis.dot(edge).abs() - 1.0).abs() < 1e-9, "{axes:?}");
|
||||
}
|
||||
assert!((axes[0].cross(axes[1]) - axes[2]).len() < 1e-12, "right-handed: {axes:?}");
|
||||
// The first two point along the world axis nearest them: +z, then +y.
|
||||
assert!(axes[0].z > 0.0 && axes[1].y > 0.0, "{axes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifted_inertia_about_a_box_corner_matches_the_closed_form() {
|
||||
let (a, b, c) = (20.0, 30.0, 40.0);
|
||||
let m = offset_box(Vec3::ZERO, a, b, c).mass_properties().unwrap();
|
||||
let mass = 3.0 * m.volume;
|
||||
let at_centre = m.inertia.map(|r| r.map(|x| 3.0 * x));
|
||||
let corner = inertia::shifted(&at_centre, mass, Vec3::ZERO - m.centroid);
|
||||
let want = [
|
||||
[mass * (b * b + c * c) / 3.0, -mass * a * b / 4.0, -mass * a * c / 4.0],
|
||||
[-mass * a * b / 4.0, mass * (a * a + c * c) / 3.0, -mass * b * c / 4.0],
|
||||
[-mass * a * c / 4.0, -mass * b * c / 4.0, mass * (a * a + b * b) / 3.0],
|
||||
];
|
||||
assert!(tensors_close(&corner, &want, 1e-9 * want[0][0]), "{corner:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn principal_axes_of_equal_zero_or_broken_tensors() {
|
||||
let sphere = [[5.0, 0.0, 0.0], [0.0, 5.0, 0.0], [0.0, 0.0, 5.0]];
|
||||
assert_eq!(inertia::principal(&sphere, 0.0), Some(([5.0; 3], [Vec3::X, Vec3::Y, Vec3::Z])));
|
||||
assert_eq!(inertia::principal(&[[0.0; 3]; 3], 0.0), Some(([0.0; 3], [Vec3::X, Vec3::Y, Vec3::Z])));
|
||||
assert!(inertia::principal(&[[f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], 0.0).is_none());
|
||||
assert!(inertia::principal(&[[f64::INFINITY, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], 0.0).is_none());
|
||||
// Products of inertia far larger than the moments still settle.
|
||||
let (moments, _) = inertia::principal(&[[1.0, 1e12, 0.0], [1e12, 1.0, 0.0], [0.0, 0.0, 1.0]], 0.0).unwrap();
|
||||
assert!(close(moments[0], -1e12, 1e-9) && close(moments[2], 1e12, 1e-9), "{moments:?}");
|
||||
// A tessellated pin along z: x and y differ in the fifth digit, with a stray product between
|
||||
// them. Exactly, their axes are turned at random; counted as equal, they are X and Y.
|
||||
let pin = [[7.99497, -0.000535, 0.0], [-0.000535, 7.99536, 0.0], [0.0, 0.0, 5.18447]];
|
||||
let (_, exact) = inertia::principal(&pin, 0.0).unwrap();
|
||||
assert!(exact[1].x.abs() < 0.99, "{exact:?}");
|
||||
assert_eq!(inertia::principal(&pin, 1e-3).map(|(_, axes)| axes), Some([Vec3::Z, Vec3::X, Vec3::Y]));
|
||||
// The same with the round pair smallest (a disc), and all three alike (a cube's tessellation).
|
||||
let disc = [[3.00001, 0.0002, 0.0], [0.0002, 3.0, 0.0], [0.0, 0.0, 6.0]];
|
||||
assert_eq!(inertia::principal(&disc, 1e-3).map(|(_, axes)| axes), Some([Vec3::X, Vec3::Y, Vec3::Z]));
|
||||
let cube = [[2.0, 1e-5, 0.0], [1e-5, 2.0001, 0.0], [0.0, 0.0, 1.9999]];
|
||||
assert_eq!(inertia::principal(&cube, 1e-3).map(|(_, axes)| axes), Some([Vec3::X, Vec3::Y, Vec3::Z]));
|
||||
}
|
||||
|
||||
mod close_pairs {
|
||||
use super::*;
|
||||
|
||||
/// A small deterministic generator, so the random cases below repeat from run to run.
|
||||
struct Lcg(u64);
|
||||
|
||||
impl Lcg {
|
||||
fn unit(&mut self) -> f64 {
|
||||
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
(self.0 >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
fn point(&mut self) -> Vec3 {
|
||||
Vec3::new(self.unit(), self.unit(), self.unit())
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed(tri: [Vec3; 3]) -> BoxedTriangle {
|
||||
let mut bounds = Aabb3::EMPTY;
|
||||
for p in tri {
|
||||
bounds.add(p);
|
||||
}
|
||||
(tri, bounds)
|
||||
}
|
||||
|
||||
/// `count` small triangles scattered over a cube of side `spread` at `origin`. On the lattice their
|
||||
/// corners sit on whole numbers, so many touch exactly, share edges or lie in one plane.
|
||||
fn scatter(rng: &mut Lcg, count: usize, origin: Vec3, spread: f64, size: f64, lattice: bool) -> Vec<BoxedTriangle> {
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let centre = origin + rng.point() * spread;
|
||||
let mut tri = [Vec3::ZERO; 3];
|
||||
for corner in &mut tri {
|
||||
let mut p = centre + (rng.point() - Vec3::new(0.5, 0.5, 0.5)) * (2.0 * size);
|
||||
if lattice {
|
||||
p = Vec3::new(p.x.round(), p.y.round(), p.z.round());
|
||||
}
|
||||
*corner = p;
|
||||
}
|
||||
out.push(boxed(tri));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A pair that "meets" for these tests: two corners within `gap` of each other.
|
||||
fn corners_meet(t: &[Vec3; 3], u: &[Vec3; 3], gap: f64) -> bool {
|
||||
t.iter().any(|p| u.iter().any(|q| p.dist(*q) <= gap))
|
||||
}
|
||||
|
||||
fn boxes_close(x: &Aabb3, y: &Aabb3, gap: f64) -> bool {
|
||||
x.min.x <= y.max.x + gap
|
||||
&& y.min.x <= x.max.x + gap
|
||||
&& x.min.y <= y.max.y + gap
|
||||
&& y.min.y <= x.max.y + gap
|
||||
&& x.min.z <= y.max.z + gap
|
||||
&& y.min.z <= x.max.z + gap
|
||||
}
|
||||
|
||||
/// Every pair tried, which is how the contact tests worked before they used a grid.
|
||||
fn every_pair(a: &[BoxedTriangle], b: &[BoxedTriangle], gap: f64) -> bool {
|
||||
a.iter().any(|(x, xb)| b.iter().any(|(y, yb)| boxes_close(xb, yb, gap) && corners_meet(x, y, gap)))
|
||||
}
|
||||
|
||||
/// A flat grid of `n` x `n` squares, two triangles each, at height `z`.
|
||||
fn flat_grid(n: usize, z: f64) -> Vec<BoxedTriangle> {
|
||||
let p = |i: usize, j: usize| Vec3::new(i as f64, j as f64, z);
|
||||
let mut out = Vec::with_capacity(2 * n * n);
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
out.push(boxed([p(i, j), p(i + 1, j), p(i + 1, j + 1)]));
|
||||
out.push(boxed([p(i, j), p(i + 1, j + 1), p(i, j + 1)]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The grid hands `test` the pairs that trying every pair finds: on random triangles, on
|
||||
/// triangles that touch exactly (whole-number corners) and far from the origin.
|
||||
#[test]
|
||||
fn the_grid_finds_the_pairs_that_trying_every_pair_finds() {
|
||||
let mut rng = Lcg(0x232);
|
||||
let (mut met, mut apart) = (0, 0);
|
||||
for case in 0..600usize {
|
||||
let lattice = case % 3 == 0;
|
||||
let origin = if case % 5 == 0 { Vec3::new(1.0e6, -2.0e6, 3.0e6) } else { Vec3::ZERO };
|
||||
let gap = [1e-9, 0.01, 0.2][(case / 3) % 3];
|
||||
let (na, nb) = (1 + (rng.unit() * 150.0) as usize, 1 + (rng.unit() * 150.0) as usize);
|
||||
let shift = (rng.point() - Vec3::new(0.5, 0.5, 0.5)) * 8.0;
|
||||
let size = 0.3 + rng.unit();
|
||||
let a = scatter(&mut rng, na, origin, 5.0, size, lattice);
|
||||
let b = scatter(&mut rng, nb, origin + shift, 5.0, size, lattice);
|
||||
let want = every_pair(&a, &b, gap);
|
||||
let mut budget = usize::MAX;
|
||||
let got = find_close_pair(&a, &b, gap, &mut budget, |(x, _), (y, _)| corners_meet(x, y, gap));
|
||||
assert_eq!(got, Some(want), "case {case}: {na} x {nb} triangles, gap {gap}, lattice {lattice}");
|
||||
if want {
|
||||
met += 1;
|
||||
} else {
|
||||
apart += 1;
|
||||
}
|
||||
}
|
||||
assert!(met > 50 && apart > 50, "the cases should cover both answers: {met} meet, {apart} apart");
|
||||
let mut budget = usize::MAX;
|
||||
assert_eq!(find_close_pair(&[], &flat_grid(2, 0.0), 0.1, &mut budget, |_, _| true), Some(false));
|
||||
assert_eq!(find_close_pair(&flat_grid(2, 0.0), &[], 0.1, &mut budget, |_, _| true), Some(false));
|
||||
}
|
||||
|
||||
/// Two flat meshes of 16,200 triangles each, one above the other: all their boxes overlap, which
|
||||
/// is 262 million pairs and far past a budget of 40 million.
|
||||
#[test]
|
||||
fn big_meshes_with_overlapping_boxes_are_searched_without_trying_every_pair() {
|
||||
let (low, high) = (flat_grid(90, 0.0), flat_grid(90, 0.5));
|
||||
assert!(low.len() * high.len() > 200_000_000);
|
||||
let mut budget = 40_000_000;
|
||||
assert_eq!(find_close_pair(&low, &high, 0.1, &mut budget, |(x, _), (y, _)| corners_meet(x, y, 0.1)), Some(false));
|
||||
let mut budget = 40_000_000;
|
||||
assert_eq!(find_close_pair(&low, &high, 0.6, &mut budget, |(x, _), (y, _)| corners_meet(x, y, 0.6)), Some(true));
|
||||
}
|
||||
|
||||
/// A pile of triangles in one place is still a product of pairs: the budget refuses it.
|
||||
#[test]
|
||||
fn a_pile_of_triangles_in_one_place_runs_out_of_budget() {
|
||||
let pile = |height: f64| {
|
||||
let up = Vec3::Z * height;
|
||||
vec![boxed([up, Vec3::X + up, Vec3::Y + up]); 7000]
|
||||
};
|
||||
let (low, high) = (pile(0.0), pile(0.5));
|
||||
let mut budget = 40_000_000;
|
||||
// Their boxes are 0.5 apart, so within a gap of 1 every pair is a candidate: 49 million tests.
|
||||
assert_eq!(find_close_pair(&low, &high, 1.0, &mut budget, |_, _| false), None);
|
||||
}
|
||||
|
||||
/// The grid's own work counts: a budget too small to file a big mesh is refused even when no
|
||||
/// pair is ever tested.
|
||||
#[test]
|
||||
fn filing_and_visiting_cells_count_against_the_budget() {
|
||||
let (low, high) = (flat_grid(30, 0.0), flat_grid(30, 5.0));
|
||||
let mut budget = 100;
|
||||
assert_eq!(find_close_pair(&low, &high, 0.1, &mut budget, |_, _| false), None);
|
||||
let mut budget = 10_000_000;
|
||||
assert_eq!(find_close_pair(&low, &high, 0.1, &mut budget, |_, _| false), Some(false));
|
||||
assert!(budget < 10_000_000, "work was counted");
|
||||
}
|
||||
}
|
||||
|
||||
mod blend {
|
||||
use super::*;
|
||||
|
||||
/// `j!` for the small orders the Hermite and quotient identities use.
|
||||
fn fact(j: usize) -> f64 {
|
||||
(1..=j).fold(1.0, |a, b| a * b as f64)
|
||||
}
|
||||
|
||||
/// The derivatives 1…`order` a Bézier's control polygon carries at its end `at_end`: the j-th
|
||||
/// difference of the controls, scaled by `n!/(n − j)!`. This is the textbook endpoint identity
|
||||
/// of a Bézier, written here independently of the library's.
|
||||
fn control_derivatives(ctrl: &[Vec2], at_end: bool, order: usize) -> Vec<Vec2> {
|
||||
let c: Vec<Vec2> = if at_end { ctrl.iter().rev().copied().collect() } else { ctrl.to_vec() };
|
||||
let n = c.len() - 1;
|
||||
let (mut diffs, mut scale) = (c, 1.0);
|
||||
let mut out = Vec::new();
|
||||
for j in 1..=order {
|
||||
diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
scale *= (n + 1 - j) as f64;
|
||||
out.push(diffs.first().copied().unwrap_or_default() * scale);
|
||||
}
|
||||
// The reversed controls describe `P(1 − s)`, so reversing again to read the original
|
||||
// parameterisation negates the odd orders.
|
||||
if at_end {
|
||||
for (i, v) in out.iter_mut().enumerate() {
|
||||
if i % 2 == 0 {
|
||||
*v = -*v;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermite_bezier_meets_the_derivative_data_at_both_ends() {
|
||||
let (p0, p1) = (Vec2::new(1.0, 2.0), Vec2::new(-3.0, 4.5));
|
||||
let d0 = [Vec2::new(2.0, 1.0), Vec2::new(-0.5, 3.0), Vec2::new(1.5, 1.0), Vec2::new(0.25, -2.0)];
|
||||
let d1 = [Vec2::new(-1.0, 0.5), Vec2::new(2.0, -1.5), Vec2::new(-0.75, 0.5), Vec2::new(3.0, 1.25)];
|
||||
for k in 0..=MAX_DERIVATIVE_ORDER {
|
||||
let cps = hermite_bezier(p0, p1, &d0[..k], &d1[..k]).unwrap();
|
||||
assert_eq!(cps.len(), 2 * k + 2, "degree 2k+1 has 2k+2 controls");
|
||||
assert_eq!(cps[0], p0);
|
||||
assert_eq!(*cps.last().unwrap(), p1);
|
||||
for (got, want) in control_derivatives(&cps, false, k).iter().zip(&d0[..k]) {
|
||||
assert!(got.dist(*want) < 1e-11, "k={k} at t=0: {got:?} vs {want:?}");
|
||||
}
|
||||
for (got, want) in control_derivatives(&cps, true, k).iter().zip(&d1[..k]) {
|
||||
assert!(got.dist(*want) < 1e-11, "k={k} at t=1: {got:?} vs {want:?}");
|
||||
}
|
||||
// The piece is exactly the single Bézier `bezier` hands back, and its ends are the two
|
||||
// joints.
|
||||
let seg = bezier(&cps).unwrap();
|
||||
assert!(seg.start().dist(p0) < 1e-12 && seg.end().dist(p1) < 1e-12);
|
||||
for (got, want) in seg.derivatives(0.0, k).unwrap().iter().zip(&d0[..k]) {
|
||||
assert!(got.dist(*want) < 1e-10, "k={k} at t=0: {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermite_bezier_refuses_hostile_data() {
|
||||
let (p0, p1) = (Vec2::ZERO, Vec2::X);
|
||||
let d = [Vec2::Y, Vec2::X];
|
||||
assert!(hermite_bezier(p0, p1, &d, &d).is_some());
|
||||
assert!(hermite_bezier(p0, p1, &d, &d[..1]).is_none(), "the ends must agree on the order");
|
||||
assert!(hermite_bezier(p0, p1, &[Vec2::ZERO; 5], &[Vec2::ZERO; 5]).is_none(), "order above G4");
|
||||
assert!(hermite_bezier(Vec2::new(f64::NAN, 0.0), p1, &[], &[]).is_none());
|
||||
assert!(hermite_bezier(p0, p1, &[Vec2::new(f64::INFINITY, 0.0)], &[Vec2::Y]).is_none());
|
||||
assert!(hermite_bezier(p0, p1, &[], &[Vec2::Y]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seg_derivatives_are_exact_for_lines_and_polynomial_pieces() {
|
||||
let line = Seg2::Line { a: Vec2::new(-1.0, 2.0), b: Vec2::new(3.0, 5.0) };
|
||||
let d = line.derivatives(0.0, MAX_DERIVATIVE_ORDER).unwrap();
|
||||
assert!(d[0].dist(Vec2::new(4.0, 3.0)) < 1e-12);
|
||||
assert_eq!(d[1].len() + d[2].len() + d[3].len(), 0.0, "a line has no curvature");
|
||||
let cubic = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(3.0, 9.0), p2: Vec2::new(7.0, -4.0), p3: Vec2::new(11.0, 2.0) };
|
||||
let ctrl = [Vec2::new(0.0, 0.0), Vec2::new(3.0, 9.0), Vec2::new(7.0, -4.0), Vec2::new(11.0, 2.0)];
|
||||
for (t, at_end) in [(0.0, false), (1.0, true)] {
|
||||
for (got, want) in cubic.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap().iter().zip(control_derivatives(&ctrl, at_end, 4)) {
|
||||
assert!(got.dist(want) < 1e-9, "t={t}: {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
for t in [0.0, 0.35, 1.0] {
|
||||
assert!(cubic.derivatives(t, 1).unwrap()[0].dist(cubic.derivative(t)) < 1e-9, "t={t}");
|
||||
}
|
||||
// A degree-9 piece (the G4 blend's own shape) at both ends.
|
||||
let pts: Vec<Vec2> = (0..10).map(|i| Vec2::new(i as f64 * 0.7, (i as f64 * 1.3).sin())).collect();
|
||||
let seg = bezier(&pts).unwrap();
|
||||
assert!(matches!(seg, Seg2::Bezier { n: 9, .. }));
|
||||
for (t, at_end) in [(0.0, false), (1.0, true)] {
|
||||
for (got, want) in seg.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap().iter().zip(control_derivatives(&pts, at_end, 4)) {
|
||||
assert!(got.dist(want) < 1e-9, "t={t}: {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seg_derivatives_follow_the_circular_recurrence() {
|
||||
let arc = Seg2::Arc { center: Vec2::new(2.0, -1.0), radius: 5.0, start: 0.7, sweep: -1.9 };
|
||||
let Seg2::Arc { center, sweep, .. } = arc else { panic!("an arc") };
|
||||
for t in [0.0, 0.4, 1.0] {
|
||||
let d = arc.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap();
|
||||
let p = arc.point_at(t);
|
||||
let sw2 = sweep * sweep;
|
||||
// On a circle every derivative follows from the second: P″ = −sw²(P − c), and the rest
|
||||
// differentiate that relation, so a line and an arc have zero curvature rates.
|
||||
assert!(d[1].dist((p - center) * -sw2) < 1e-9, "t={t}");
|
||||
assert!(d[2].dist(d[0] * -sw2) < 1e-9, "t={t}");
|
||||
assert!(d[3].dist(d[1] * -sw2) < 1e-9, "t={t}");
|
||||
assert!(d[0].dist(arc.derivative(t)) < 1e-9, "t={t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seg_derivatives_of_a_conic_satisfy_the_quotient_identity() {
|
||||
let conic = Seg2::Conic { a: Vec2::new(-2.0, 0.0), apex: Vec2::new(0.0, 3.0), b: Vec2::new(4.0, 0.5), w: 0.7 };
|
||||
let Seg2::Conic { a, apex, b, w } = conic else { panic!("a conic") };
|
||||
// The rational quadratic's numerator and weight polynomials, straight from their Bernstein
|
||||
// form, and their power coefficients at `t` by differences (exact for a quadratic).
|
||||
let n_at = |x: f64| a * ((1.0 - x) * (1.0 - x)) + apex * (2.0 * w * x * (1.0 - x)) + b * (x * x);
|
||||
let d_at = |x: f64| (1.0 - x) * (1.0 - x) + 2.0 * w * x * (1.0 - x) + x * x;
|
||||
for t in [0.0, 0.25, 1.0] {
|
||||
let h = 1e-3;
|
||||
// Taylor coefficients at `t` (differences are exact for a quadratic): f, f′/1!, f″/2!.
|
||||
let coef_v = |f: &dyn Fn(f64) -> Vec2| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)];
|
||||
let coef_s = |f: &dyn Fn(f64) -> f64| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)];
|
||||
let n = coef_v(&n_at);
|
||||
let d = coef_s(&d_at);
|
||||
let got = conic.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap();
|
||||
// Taylor coefficients of P: P(t) and P⁽ʲ⁾(t)/j!. Leibniz on N = P·D then reads
|
||||
// Σ xᵢ·dⱼ₋ᵢ = nⱼ at every order, which the quotient rule must reproduce.
|
||||
let x: Vec<Vec2> = std::iter::once(conic.point_at(t)).chain(got.iter().enumerate().map(|(i, v)| *v / fact(i + 1))).collect();
|
||||
for j in 0..=4usize {
|
||||
let lhs: Vec2 = (0..=j).fold(Vec2::ZERO, |acc, i| acc + x[i] * d.get(j - i).copied().unwrap_or(0.0));
|
||||
let rhs = n.get(j).copied().unwrap_or(Vec2::ZERO);
|
||||
assert!(lhs.dist(rhs) < 1e-8 * rhs.len().max(1.0), "t={t} j={j}: {lhs:?} vs {rhs:?}");
|
||||
}
|
||||
assert!(got[0].dist(conic.derivative(t)) < 1e-8, "t={t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seg_derivatives_refuse_hostile_input_and_degenerate_pieces() {
|
||||
let c = Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::X, p2: Vec2::Y, p3: Vec2::X + Vec2::Y };
|
||||
assert!(c.derivatives(0.0, MAX_DERIVATIVE_ORDER + 1).is_none(), "no order above G4");
|
||||
assert!(c.derivatives(f64::NAN, 2).is_none());
|
||||
let nan = Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X };
|
||||
assert!(nan.derivatives(0.0, 1).is_none());
|
||||
// A conic whose weight vanishes is a straight chord drawn at zero speed: its derivatives
|
||||
// are finite, but the first is zero, which is what a blend refuses.
|
||||
let flat = Seg2::Conic { a: Vec2::ZERO, apex: Vec2::new(1.0, 1.0), b: Vec2::new(2.0, 0.0), w: 0.0 };
|
||||
let d = flat.derivatives(0.0, 1).unwrap();
|
||||
assert_eq!(d[0], Vec2::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_from_points_holds_them_and_grows() {
|
||||
let b: Aabb3 = [Vec3::new(1.0, 2.0, 3.0), Vec3::new(-1.0, 0.0, 5.0), Vec3::new(f64::INFINITY, 0.0, 0.0)].into_iter().collect();
|
||||
assert_eq!((b.min, b.max), (Vec3::new(-1.0, 0.0, 3.0), Vec3::new(1.0, 2.0, 5.0)), "the infinite point is skipped");
|
||||
assert!(b.contains(Vec3::new(0.0, 1.0, 4.0)) && !b.contains(Vec3::new(0.0, 1.0, 6.0)));
|
||||
assert!(!b.contains(Vec3::new(f64::NAN, 1.0, 4.0)));
|
||||
assert!(b.inflated(1.0).contains(Vec3::new(0.0, 1.0, 6.0)));
|
||||
assert!(Aabb3::EMPTY.inflated(1e9).is_empty() && !Aabb3::EMPTY.contains(Vec3::ZERO));
|
||||
let far = Aabb3 { min: Vec3::new(2.0, 0.0, 3.0), max: Vec3::new(3.0, 1.0, 4.0) };
|
||||
assert!(!b.meets(&far) && b.inflated(1.0).meets(&far) && !b.meets(&Aabb3::EMPTY));
|
||||
let both = b.intersection(&b.inflated(1.0).intersection(&Aabb3 { min: Vec3::new(0.0, 1.0, 4.0), max: Vec3::new(9.0, 9.0, 9.0) }));
|
||||
assert_eq!((both.min, both.max), (Vec3::new(0.0, 1.0, 4.0), Vec3::new(1.0, 2.0, 5.0)));
|
||||
assert!(b.intersection(&far).is_empty());
|
||||
}
|
||||
|
||||
/// A similarity moves every kind of segment exactly: the moved segment's points are the original's
|
||||
/// points moved, its length scales, and an arc stays an arc turning the same way.
|
||||
#[test]
|
||||
fn a_similarity_moves_each_kind_of_segment_exactly() {
|
||||
let m = Similarity2::new(2.5, 0.7, Vec2::new(-3.0, 11.0)).unwrap();
|
||||
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
|
||||
for (i, q) in p.iter_mut().take(6).enumerate() {
|
||||
*q = Vec2::new(i as f64, (i * i) as f64 * 0.3);
|
||||
}
|
||||
let segments = [
|
||||
Seg2::Line { a: Vec2::new(1.0, 2.0), b: Vec2::new(4.0, -1.0) },
|
||||
Seg2::Arc { center: Vec2::new(2.0, 1.0), radius: 3.0, start: 0.4, sweep: -2.1 },
|
||||
Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(1.0, 2.0), p2: Vec2::new(3.0, 2.0), p3: Vec2::new(4.0, 0.0) },
|
||||
Seg2::Conic { a: Vec2::new(10.0, 0.0), apex: Vec2::new(10.0, 10.0), b: Vec2::new(0.0, 10.0), w: std::f64::consts::FRAC_1_SQRT_2 },
|
||||
Seg2::Bezier { n: 5, p },
|
||||
];
|
||||
for seg in segments {
|
||||
let moved = seg.similar(&m);
|
||||
for i in 0..=8 {
|
||||
let t = f64::from(i) / 8.0;
|
||||
assert!((moved.point_at(t) - m.apply(seg.point_at(t))).len() < 1e-9, "{seg:?} at {t}");
|
||||
}
|
||||
assert!((moved.length() - seg.length() * m.scale()).abs() < 1e-9 * seg.length().max(1.0), "{seg:?}");
|
||||
assert_eq!(std::mem::discriminant(&moved), std::mem::discriminant(&seg));
|
||||
}
|
||||
// An arc keeps its sweep: the turning direction survives a similarity.
|
||||
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep: -1.0 };
|
||||
assert!(matches!(arc.similar(&m), Seg2::Arc { sweep, .. } if sweep == -1.0));
|
||||
// The identity changes nothing.
|
||||
let identity = Similarity2::new(1.0, 0.0, Vec2::ZERO).unwrap();
|
||||
for seg in segments {
|
||||
assert_eq!(seg.similar(&identity), seg);
|
||||
}
|
||||
// A similarity scales by a finite number above 0, and turns and moves by finite ones.
|
||||
for (scale, angle, shift) in [
|
||||
(0.0, 0.0, Vec2::ZERO),
|
||||
(-1.0, 0.0, Vec2::ZERO),
|
||||
(f64::NAN, 0.0, Vec2::ZERO),
|
||||
(1.0, f64::INFINITY, Vec2::ZERO),
|
||||
(1.0, 0.0, Vec2::new(f64::NAN, 0.0)),
|
||||
] {
|
||||
assert!(Similarity2::new(scale, angle, shift).is_none(), "{scale} {angle} {shift:?}");
|
||||
}
|
||||
}
|
||||
+1130
File diff suppressed because it is too large
Load Diff
+1388
File diff suppressed because it is too large
Load Diff
+1158
File diff suppressed because it is too large
Load Diff
+59
@@ -0,0 +1,59 @@
|
||||
[package]
|
||||
name = "solidcraft-engine"
|
||||
description = "SolidCraft engine: the session, the command registry (everything is a command), undo/redo and selection"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Optional authored texture/HDR bytes; the command registry and metadata remain available.
|
||||
bundled-render-library = []
|
||||
raytrace-cpu = ["solidcraft-render/raytrace-cpu"]
|
||||
# Turns on the parameter-contract audit recorder (`SOLIDCRAFT_PARAM_AUDIT`, see
|
||||
# `src/schema/record.rs`), for checking a new contract against the whole test suite.
|
||||
param-audit = []
|
||||
solver-profiling = ["solidcraft-sketch/solver-profiling"]
|
||||
|
||||
[[example]]
|
||||
name = "sketch_analysis_bench"
|
||||
required-features = ["solver-profiling"]
|
||||
|
||||
[[example]]
|
||||
name = "sketch_drag_bench"
|
||||
required-features = ["solver-profiling"]
|
||||
|
||||
[[example]]
|
||||
name = "sketch_target_bench"
|
||||
required-features = ["solver-profiling"]
|
||||
|
||||
[dependencies]
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-sketch = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
solidcraft-doc = { workspace = true }
|
||||
solidcraft-io = { workspace = true }
|
||||
solidcraft-render = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml_edit = "0.25"
|
||||
schemars = { workspace = true }
|
||||
serde_path_to_error = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
sha2 = { workspace = true }
|
||||
# Model-cache tests also run in a source tarball build (no git commit).
|
||||
solidcraft-io = { workspace = true, features = ["trust-unknown-source"] }
|
||||
# Normal CI exercises profiling assertions without enabling the production feature.
|
||||
solidcraft-sketch = { workspace = true, features = ["solver-profiling"] }
|
||||
|
||||
[[example]]
|
||||
name = "render_resource_workflow"
|
||||
required-features = ["raytrace-cpu"]
|
||||
+1188
File diff suppressed because it is too large
Load Diff
+1032
File diff suppressed because it is too large
Load Diff
+68
@@ -0,0 +1,68 @@
|
||||
//! Geometry foundation for SolidCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
|
||||
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
|
||||
//! (B-spline basis and NURBS surfaces in [`bspline`], spline spaces and the operators that make
|
||||
//! splines compatible in [`spline_space`], one-surface fills of closed loops in [`fill`],
|
||||
//! surfaces through curve networks in [`network`], T-splines in [`tspline`]). Units are millimetres.
|
||||
//!
|
||||
//! This crate has no knowledge of sketches, documents or a modelling backend; every other crate
|
||||
//! builds on it.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod aabb2;
|
||||
mod arcfit;
|
||||
pub mod blend;
|
||||
pub mod brep;
|
||||
pub mod bspline;
|
||||
mod bvh;
|
||||
pub mod cell;
|
||||
mod chain;
|
||||
pub mod compare;
|
||||
pub mod drawing_sheet;
|
||||
pub mod drawing_view;
|
||||
pub mod env;
|
||||
pub mod fill;
|
||||
pub mod fill_general;
|
||||
mod grid;
|
||||
pub mod hlr;
|
||||
pub mod inertia;
|
||||
mod mesh;
|
||||
pub mod mesh_distance;
|
||||
pub mod network;
|
||||
pub mod paper;
|
||||
mod plane;
|
||||
mod profile;
|
||||
pub mod section;
|
||||
pub mod simplify;
|
||||
pub mod spline_space;
|
||||
pub mod srgb;
|
||||
pub mod tess;
|
||||
pub mod title_block;
|
||||
pub mod tspline;
|
||||
mod unit_vec3;
|
||||
mod vec;
|
||||
|
||||
pub use aabb2::Aabb2;
|
||||
pub use arcfit::MAX_FIT_PIECES;
|
||||
pub use blend::hermite_bezier;
|
||||
pub use bvh::{Bvh, MeshIndex};
|
||||
pub use chain::{Chain, Closed, Ends, Open, Path, Planar, Ring};
|
||||
pub use grid::Grid;
|
||||
pub use mesh::{Aabb3, BoxedTriangle, InsideIndex, MassProperties, Mesh, MeshMeasure, find_close_pair};
|
||||
pub use plane::Plane;
|
||||
pub use profile::{EllipseArc, Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, Similarity2, bezier, conic_ellipse, regions_apart};
|
||||
pub use unit_vec3::UnitVec3;
|
||||
pub use vec::{Vec2, Vec3};
|
||||
|
||||
/// Geometric tolerance for coincidence tests (mm).
|
||||
pub const EPS: f64 = 1e-9;
|
||||
/// Linear tolerance used for "same point" decisions in modelling (mm).
|
||||
pub const LINEAR_TOL: f64 = 1e-6;
|
||||
|
||||
/// `x` if finite, else `fallback`.
|
||||
pub fn finite_or(x: f64, fallback: f64) -> f64 {
|
||||
if x.is_finite() { x } else { fallback }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
+1796
File diff suppressed because it is too large
Load Diff
+1617
File diff suppressed because it is too large
Load Diff
+1193
File diff suppressed because it is too large
Load Diff
+740
@@ -0,0 +1,740 @@
|
||||
//! Bounded conversion of borrowed planar paths to DXF geometry.
|
||||
//!
|
||||
//! Admission precedes recognition and fitting. A failed conversion returns no partial path;
|
||||
//! piece errors always refer to the original input, including after arc splitting or fitting.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use solidcraft_geom::{Loop2, MAX_BEZIER_DEGREE, MAX_FIT_PIECES, Seg2, Vec2, conic_ellipse};
|
||||
|
||||
use super::{DxfShape, bezier_spline, wrap};
|
||||
use crate::{IoError, Result};
|
||||
|
||||
/// A borrowed sequence of planar pieces, optionally carrying the caller's closed-loop intent.
|
||||
/// This is not a certificate of continuity or topology. Whole-circle recognition uses geom's
|
||||
/// bounded angular rounding cells, not bitwise endpoint identity. Polyline joins require
|
||||
/// equal stored endpoints and otherwise emit separate open runs without filling or snapping.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum DxfPath<'a> {
|
||||
Open(&'a [Seg2]),
|
||||
/// Permit whole-loop recognition and an explicit closing flag when demonstrated.
|
||||
/// This does not certify or repair the input topology; recognition has the policies above.
|
||||
Closed(&'a Loop2),
|
||||
}
|
||||
|
||||
/// How a path's curves are represented. Coordinates and tolerances are in millimetres.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum DxfPathStyle {
|
||||
/// Preserve analytic curves and the original polynomial/rational spline controls.
|
||||
Exact,
|
||||
/// Collect consecutive lines and circular arcs into polylines with signed bulges.
|
||||
/// Other curves retain their exact representation and interrupt the polyline run.
|
||||
/// An arc that cannot preserve its support as a bulge retains its ARC entity instead.
|
||||
Polyline,
|
||||
/// Fit free-form pieces with the existing bounded line/arc fitter.
|
||||
/// Its sampling is not an analytic, uniform error certificate.
|
||||
Arcs { tolerance_mm: f64 },
|
||||
}
|
||||
|
||||
/// A polyline vertex. `bulge` describes the edge leaving `at`: `tan(sweep / 4)`.
|
||||
/// The terminal vertex of an open polyline has zero bulge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DxfVertex {
|
||||
pub at: Vec2,
|
||||
pub bulge: f64,
|
||||
}
|
||||
|
||||
// Separate per-path budgets: one entity may contain many vertices. These do not bound an
|
||||
// entire drawing made from many calls. Check before allocating from or expanding input.
|
||||
pub(super) const MAX_PATH_ITEMS: usize = 200_000;
|
||||
const MAX_FITTED_PATH_PIECES: usize = MAX_FIT_PIECES * 8;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Piece {
|
||||
index: usize,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
impl Piece {
|
||||
fn invalid(self, reason: impl std::fmt::Display) -> IoError {
|
||||
IoError::Invalid(format!("DXF path: piece {} of {}: {reason}", self.index + 1, self.total))
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(reason: &str) -> IoError {
|
||||
IoError::Invalid(format!("DXF path: {reason}"))
|
||||
}
|
||||
|
||||
/// Convert a path without mutating it, dropping pieces, or changing their order.
|
||||
///
|
||||
/// Invalid options are rejected even for empty paths. Input pieces, output entities and
|
||||
/// accumulated polyline vertices each have a separate limit of 200,000. If fitting is needed,
|
||||
/// its 2,048-piece per-input and 16,384-piece per-path limits include retained lines/arcs.
|
||||
/// Pure line/arc paths bypass the fitting limit. Positive finite conic weights are supported;
|
||||
/// other rational domains are outside this converter's admission contract.
|
||||
/// `Closed` permits recognition, not an invented closing edge: only equal stored endpoints
|
||||
/// share a polyline vertex. Mixed or fragmented runs remain open, in original piece order.
|
||||
pub fn path_shapes(path: DxfPath<'_>, style: DxfPathStyle) -> Result<Vec<DxfShape>> {
|
||||
if let DxfPathStyle::Arcs { tolerance_mm } = style
|
||||
&& !(tolerance_mm.is_finite() && tolerance_mm >= 1e-9)
|
||||
{
|
||||
return Err(invalid("arc tolerance must be finite and at least 1e-9 mm"));
|
||||
}
|
||||
let (segs, closed) = match path {
|
||||
DxfPath::Open(segs) => (segs, None),
|
||||
DxfPath::Closed(lp) => (lp.segs.as_slice(), Some(lp)),
|
||||
};
|
||||
if segs.len() > MAX_PATH_ITEMS {
|
||||
return Err(invalid("input piece budget exceeded"));
|
||||
}
|
||||
// Do not call endpoint, recognition or fitting helpers before validating degrees.
|
||||
for (index, seg) in segs.iter().enumerate() {
|
||||
admit(seg, Piece { index, total: segs.len() })?;
|
||||
}
|
||||
if let Some(lp) = closed {
|
||||
if let Some((c, r)) = lp.as_circle() {
|
||||
// Every circular piece was admitted, including its support bounds.
|
||||
legacy_positive(r, Piece { index: 0, total: segs.len() }, "circle radius")?;
|
||||
return Ok(vec![DxfShape::Circle { c, r }]);
|
||||
}
|
||||
if !matches!(style, DxfPathStyle::Arcs { .. })
|
||||
&& let Some((c, major, ratio)) = lp.as_ellipse()
|
||||
{
|
||||
// Keep the recognizer's validated support. Only its axis sign may follow the
|
||||
// first conic's historical DXF frame: negating the axis preserves a full ellipse,
|
||||
// whereas replacing its centre/ratio from another extraction need not do so.
|
||||
let first = lp.segs.first().and_then(|seg| match *seg {
|
||||
Seg2::Conic { a, apex, b, w } => conic_ellipse(a, apex, b, w),
|
||||
_ => None,
|
||||
});
|
||||
let mut major = major;
|
||||
if let Some(first) = first {
|
||||
let scale = major.x.abs().max(major.y.abs());
|
||||
let first_scale = first.major.x.abs().max(first.major.y.abs());
|
||||
if scale > 0.0 && first_scale > 0.0 && (major / scale).dot(first.major / first_scale) < 0.0 {
|
||||
major = -major;
|
||||
}
|
||||
}
|
||||
if ellipse_representable(c, major, ratio, 0.0, TAU) {
|
||||
return Ok(vec![DxfShape::Ellipse { c, major, ratio, t0: 0.0, t1: TAU }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
match style {
|
||||
DxfPathStyle::Polyline => polylines(segs, closed.is_some()),
|
||||
DxfPathStyle::Arcs { tolerance_mm } if segs.iter().any(|seg| !seg.is_line_or_arc()) => fitted(segs, tolerance_mm),
|
||||
DxfPathStyle::Exact | DxfPathStyle::Arcs { .. } => {
|
||||
let mut shapes = Vec::new();
|
||||
for (index, seg) in segs.iter().enumerate() {
|
||||
push_shape(&mut shapes, exact(seg, Piece { index, total: segs.len() })?)?;
|
||||
}
|
||||
Ok(shapes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn admit(seg: &Seg2, piece: Piece) -> Result<()> {
|
||||
let finite = match *seg {
|
||||
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
if !(center.is_finite() && radius.is_finite() && start.is_finite() && sweep.is_finite()) {
|
||||
return Err(piece.invalid("non-finite circular arc"));
|
||||
}
|
||||
if radius <= 0.0 {
|
||||
return Err(piece.invalid("arc radius must be positive"));
|
||||
}
|
||||
if sweep == 0.0 {
|
||||
return Err(piece.invalid("arc sweep must be nonzero"));
|
||||
}
|
||||
if sweep.abs() > TAU {
|
||||
return Err(piece.invalid("arc sweep exceeds one turn"));
|
||||
}
|
||||
let end = start + sweep;
|
||||
if !end.is_finite() || end == start {
|
||||
return Err(piece.invalid("arc end angle is non-finite or unresolved"));
|
||||
}
|
||||
if !(start.to_degrees().is_finite() && end.to_degrees().is_finite()) {
|
||||
return Err(piece.invalid("arc angles cannot be represented in degrees"));
|
||||
}
|
||||
if !circle_bounds_finite(center, radius) {
|
||||
return Err(piece.invalid("circular support bounds are not finite"));
|
||||
}
|
||||
seg.start().is_finite() && seg.end().is_finite()
|
||||
}
|
||||
Seg2::Conic { a, apex, b, w } => {
|
||||
if !(w.is_finite() && w > 0.0) {
|
||||
return Err(piece.invalid("conic weight must be finite and positive"));
|
||||
}
|
||||
a.is_finite() && apex.is_finite() && b.is_finite()
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => [p0, p1, p2, p3].iter().all(|p| p.is_finite()),
|
||||
Seg2::Bezier { n, ref p } => {
|
||||
if !(4..=MAX_BEZIER_DEGREE).contains(&usize::from(n)) {
|
||||
return Err(piece.invalid(format_args!("unsupported Bezier degree {n}")));
|
||||
}
|
||||
p.get(..=usize::from(n)).is_some_and(|pts| pts.iter().all(|p| p.is_finite()))
|
||||
}
|
||||
};
|
||||
if !finite {
|
||||
return Err(piece.invalid("non-finite coordinate"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn circle_bounds_finite(c: Vec2, r: f64) -> bool {
|
||||
c.is_finite() && r.is_finite() && r > 0.0 && (c - Vec2::new(r, r)).is_finite() && (c + Vec2::new(r, r)).is_finite()
|
||||
}
|
||||
|
||||
// The existing writer uses twelve decimals. Preserve the original rational controls rather
|
||||
// than clamp a thinner ellipse's ratio to its decimal resolution. This is a representation
|
||||
// restriction, not a minimum permitted size for source geometry.
|
||||
fn ellipse_representable(c: Vec2, major: Vec2, ratio: f64, t0: f64, t1: f64) -> bool {
|
||||
if !(c.is_finite() && major.is_finite() && ratio.is_finite() && (1e-12..=1.0).contains(&ratio) && t0.is_finite() && t1.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
if major == Vec2::ZERO || legacy_point(major) == Some(Vec2::ZERO) || legacy_number(t0) == legacy_number(t1) {
|
||||
return false;
|
||||
}
|
||||
let minor = major.perp() * ratio;
|
||||
// Bound the writer's actual c + major*cos(t) + minor*sin(t) evaluation, including
|
||||
// intermediate additions; a finite centre and two finite axes alone are insufficient.
|
||||
let extent = Vec2::new(major.x.abs() + minor.x.abs(), major.y.abs() + minor.y.abs());
|
||||
minor.is_finite() && extent.is_finite() && (c - extent).is_finite() && (c + extent).is_finite()
|
||||
}
|
||||
|
||||
fn exact(seg: &Seg2, piece: Piece) -> Result<DxfShape> {
|
||||
let shape = match *seg {
|
||||
Seg2::Line { a, b } => {
|
||||
if a != b && legacy_point(a) == legacy_point(b) {
|
||||
return Err(piece.invalid("line endpoints collapse at DXF writer precision"));
|
||||
}
|
||||
DxfShape::Line(a, b)
|
||||
}
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
legacy_positive(radius, piece, "arc radius")?;
|
||||
// Admission already refused >TAU. A shorter arc must not acquire a closing edge.
|
||||
if sweep.abs() == TAU {
|
||||
return Ok(DxfShape::Circle { c: center, r: radius });
|
||||
}
|
||||
let (a0, a1) = if sweep > 0.0 { (start, start + sweep) } else { (start + sweep, start) };
|
||||
let (a0, a1) = (a0.to_degrees().rem_euclid(360.0), a1.to_degrees().rem_euclid(360.0));
|
||||
if legacy_number(a0).map(|a| a.rem_euclid(360.0)) == legacy_number(a1).map(|a| a.rem_euclid(360.0)) {
|
||||
return Err(piece.invalid("arc endpoints collapse in the DXF angular representation"));
|
||||
}
|
||||
DxfShape::Arc { c: center, r: radius, a0, a1 }
|
||||
}
|
||||
Seg2::Conic { a, apex, b, w } => {
|
||||
if let Some(e) = conic_ellipse(a, apex, b, w) {
|
||||
// Geom preserves direction; DXF ELLIPSE parameters run counter-clockwise.
|
||||
// Keep the helper's endpoint expressions intact, including its CW ordering.
|
||||
let (start, end) = if e.end > e.start { (e.start, e.end) } else { (e.end, e.start) };
|
||||
let (t0, t1) = (wrap(start), wrap(end));
|
||||
if ellipse_representable(e.center, e.major, e.ratio, t0, t1) {
|
||||
return Ok(DxfShape::Ellipse { c: e.center, major: e.major, ratio: e.ratio, t0, t1 });
|
||||
}
|
||||
}
|
||||
// Recognition failure is not invalid input. Positive weights keep this spline
|
||||
// inside the finite control hull even if ellipse extraction would overflow.
|
||||
legacy_positive(w, piece, "conic weight")?;
|
||||
bezier_spline(&[a, apex, b], (w != 1.0).then(|| vec![1.0, w, 1.0]))
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => bezier_spline(&[p0, p1, p2, p3], None),
|
||||
Seg2::Bezier { n, ref p } => {
|
||||
let pts = p.get(..=usize::from(n)).ok_or_else(|| piece.invalid(format_args!("unsupported Bezier degree {n}")))?;
|
||||
bezier_spline(pts, None)
|
||||
}
|
||||
};
|
||||
if let DxfShape::Spline { ctrl, .. } = &shape
|
||||
&& let Some(first) = ctrl.first()
|
||||
&& ctrl.iter().any(|p| p != first)
|
||||
&& ctrl.iter().all(|p| legacy_point(*p) == legacy_point(*first))
|
||||
{
|
||||
return Err(piece.invalid("spline controls collapse at DXF writer precision"));
|
||||
}
|
||||
Ok(shape)
|
||||
}
|
||||
|
||||
// Mirror Out::num's numeric precision for admission only; never rewrite its output or
|
||||
// substitute rounded input controls. New bulged-polyline fields use round-trip precision.
|
||||
fn legacy_number(value: f64) -> Option<f64> {
|
||||
if !value.is_finite() {
|
||||
return None;
|
||||
}
|
||||
format!("{value:.12}").parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
fn legacy_point(point: Vec2) -> Option<Vec2> {
|
||||
Some(Vec2::new(legacy_number(point.x)?, legacy_number(point.y)?))
|
||||
}
|
||||
|
||||
fn legacy_positive(value: f64, piece: Piece, name: &str) -> Result<()> {
|
||||
if !legacy_number(value).is_some_and(|v| v > 0.0) {
|
||||
return Err(piece.invalid(format_args!("{name} is not positive at DXF writer precision")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fitted(segs: &[Seg2], tolerance: f64) -> Result<Vec<DxfShape>> {
|
||||
let mut shapes = Vec::new();
|
||||
let mut count = 0usize;
|
||||
for (index, seg) in segs.iter().enumerate() {
|
||||
let piece = Piece { index, total: segs.len() };
|
||||
if seg.is_line_or_arc() {
|
||||
count_fit(&mut count, 1, piece)?;
|
||||
push_shape(&mut shapes, exact(seg, piece)?)?;
|
||||
continue;
|
||||
}
|
||||
let adjusted = seg.fit_arcs(tolerance).ok_or_else(|| piece.invalid("curve could not be fitted within the arc fitting budget"))?;
|
||||
if adjusted.is_empty() {
|
||||
return Err(piece.invalid("fitting produced no pieces"));
|
||||
}
|
||||
if adjusted.len() > MAX_FIT_PIECES {
|
||||
return Err(piece.invalid("per-piece arc fitting budget exceeded"));
|
||||
}
|
||||
count_fit(&mut count, adjusted.len(), piece)?;
|
||||
for fitted in adjusted {
|
||||
if !fitted.is_line_or_arc() {
|
||||
return Err(piece.invalid("arc fitting produced a non-line/arc piece"));
|
||||
}
|
||||
admit(&fitted, piece)?;
|
||||
push_shape(&mut shapes, exact(&fitted, piece)?)?;
|
||||
}
|
||||
}
|
||||
Ok(shapes)
|
||||
}
|
||||
|
||||
fn count_fit(count: &mut usize, additional: usize, piece: Piece) -> Result<()> {
|
||||
let total = count.checked_add(additional).ok_or_else(|| piece.invalid("path arc fitting budget exceeded"))?;
|
||||
if total > MAX_FITTED_PATH_PIECES {
|
||||
return Err(piece.invalid("path arc fitting budget exceeded"));
|
||||
}
|
||||
*count = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_shape(shapes: &mut Vec<DxfShape>, shape: DxfShape) -> Result<()> {
|
||||
if shapes.len() >= MAX_PATH_ITEMS {
|
||||
return Err(invalid("output entity budget exceeded"));
|
||||
}
|
||||
shapes.try_reserve(1).map_err(|_| invalid("cannot allocate output entities"))?;
|
||||
shapes.push(shape);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Run {
|
||||
vertices: Vec<DxfVertex>,
|
||||
end: Option<Vec2>,
|
||||
}
|
||||
|
||||
struct Polylines {
|
||||
shapes: Vec<DxfShape>,
|
||||
run: Run,
|
||||
vertices: usize,
|
||||
uninterrupted: bool,
|
||||
}
|
||||
|
||||
impl Polylines {
|
||||
fn reserve_vertices(&self, additional: usize) -> Result<()> {
|
||||
if self.vertices.checked_add(additional).is_none_or(|n| n > MAX_PATH_ITEMS) {
|
||||
return Err(invalid("polyline vertex budget exceeded"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vertex(&mut self, vertex: DxfVertex) -> Result<()> {
|
||||
self.reserve_vertices(1)?;
|
||||
self.run.vertices.try_reserve(1).map_err(|_| invalid("cannot allocate polyline vertices"))?;
|
||||
self.run.vertices.push(vertex);
|
||||
self.vertices += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edge(&mut self, a: Vec2, b: Vec2, bulge: f64, piece: Piece) -> Result<()> {
|
||||
if self.run.end.is_some_and(|end| end != a) {
|
||||
// Sharing is exact in stored coordinates (including +/-0). Never replace an
|
||||
// incoming endpoint with the outgoing one, or bridge them with a straight edge.
|
||||
self.flush(false)?;
|
||||
self.uninterrupted = false;
|
||||
}
|
||||
if !(a.is_finite() && b.is_finite() && bulge.is_finite()) {
|
||||
return Err(piece.invalid("non-finite polyline vertex or bulge"));
|
||||
}
|
||||
self.vertex(DxfVertex { at: a, bulge })?;
|
||||
self.run.end = Some(b);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush(&mut self, closed: bool) -> Result<()> {
|
||||
let Some(end) = self.run.end else { return Ok(()) };
|
||||
if !closed {
|
||||
self.vertex(DxfVertex { at: end, bulge: 0.0 })?;
|
||||
}
|
||||
let vertices = std::mem::take(&mut self.run.vertices);
|
||||
self.run.end = None;
|
||||
push_shape(&mut self.shapes, DxfShape::BulgedPolyline { vertices, closed })
|
||||
}
|
||||
|
||||
fn exact(&mut self, shape: DxfShape) -> Result<()> {
|
||||
self.flush(false)?;
|
||||
self.uninterrupted = false;
|
||||
push_shape(&mut self.shapes, shape)
|
||||
}
|
||||
}
|
||||
|
||||
fn polylines(segs: &[Seg2], closed: bool) -> Result<Vec<DxfShape>> {
|
||||
let mut out = Polylines { shapes: Vec::new(), run: Run::default(), vertices: 0, uninterrupted: true };
|
||||
for (index, seg) in segs.iter().enumerate() {
|
||||
let piece = Piece { index, total: segs.len() };
|
||||
match *seg {
|
||||
Seg2::Line { a, b } => out.edge(a, b, 0.0, piece)?,
|
||||
Seg2::Arc { center, radius, start, sweep } if sweep.abs() < TAU => {
|
||||
let (a, b) = (seg.start(), seg.end());
|
||||
if sweep.abs() > PI {
|
||||
// Split at the actual midpoint angle, keeping both source endpoints.
|
||||
let mid_angle = start + sweep * 0.5;
|
||||
if mid_angle == start || mid_angle == start + sweep {
|
||||
out.exact(exact(seg, piece)?)?;
|
||||
continue;
|
||||
}
|
||||
let mid = center + Vec2::from_angle(mid_angle) * radius;
|
||||
let Some(bulge) = arc_bulge(sweep * 0.5) else {
|
||||
out.exact(exact(seg, piece)?)?;
|
||||
continue;
|
||||
};
|
||||
if !can_pack(a, mid, bulge, center, radius, sweep * 0.5) || !can_pack(mid, b, bulge, center, radius, sweep * 0.5) {
|
||||
out.exact(exact(seg, piece)?)?;
|
||||
continue;
|
||||
}
|
||||
// Both halves must qualify before either is appended.
|
||||
// A prior disjoint run also needs its terminal zero-bulge vertex.
|
||||
let terminal = usize::from(out.run.end.is_some_and(|end| end != a));
|
||||
out.reserve_vertices(2 + terminal)?;
|
||||
out.edge(a, mid, bulge, piece)?;
|
||||
out.edge(mid, b, bulge, piece)?;
|
||||
} else {
|
||||
if let Some(bulge) = arc_bulge(sweep)
|
||||
&& can_pack(a, b, bulge, center, radius, sweep)
|
||||
{
|
||||
out.edge(a, b, bulge, piece)?;
|
||||
} else {
|
||||
out.exact(exact(seg, piece)?)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => out.exact(exact(seg, piece)?)?,
|
||||
}
|
||||
}
|
||||
// Only a run that covers the entire closed path can receive the closing flag. Its last
|
||||
// outgoing bulge stays on the final vertex; the first vertex is not appended a second time.
|
||||
// A single authored zero-length line stays an open two-vertex entity; it cannot form a
|
||||
// writable one-vertex closed polyline. Exact preserves the original LINE unchanged.
|
||||
let close =
|
||||
closed && out.uninterrupted && out.run.vertices.len() >= 2 && out.run.vertices.first().is_some_and(|first| Some(first.at) == out.run.end);
|
||||
out.flush(close)?;
|
||||
Ok(out.shapes)
|
||||
}
|
||||
|
||||
fn arc_bulge(sweep: f64) -> Option<f64> {
|
||||
// A semicircle has bulge exactly +/-1; all other admitted pieces use tan(sweep/4).
|
||||
let bulge = if sweep.abs() == PI { sweep.signum() } else { (sweep * 0.25).tan() };
|
||||
(bulge.is_finite() && bulge != 0.0 && bulge.abs() <= 1.0).then_some(bulge)
|
||||
}
|
||||
|
||||
// Packing is optional. Check algebraic support residuals of the values that will actually
|
||||
// be serialized, normalized around the ORIGINAL centre/radius. A world-coordinate ULP
|
||||
// allowance would hide local distortion (for example radius .75 at centre (2^52, 2^52)).
|
||||
// This is an operational acceptance policy at arithmetic roundoff scale, not an identity
|
||||
// proof or a universal error guarantee for sin/cos/tan. No coordinates or parameters are
|
||||
// adjusted to pass it. Serialization must round-trip these same f64 vertices and bulges.
|
||||
fn can_pack(a: Vec2, z: Vec2, bulge: f64, center: Vec2, radius: f64, sweep: f64) -> bool {
|
||||
if !radius.is_normal() || bulge.signum() != sweep.signum() || sweep.abs() > PI {
|
||||
return false;
|
||||
}
|
||||
let (from, to) = (a - center, z - center);
|
||||
if [from.x, from.y, to.x, to.y].iter().any(|&value| normal_or_zero(value).is_none()) {
|
||||
return false;
|
||||
}
|
||||
let (u, v) = (from / radius, to / radius);
|
||||
if [(from.x, u.x), (from.y, u.y), (to.x, v.x), (to.y, v.y)].iter().any(|&(input, output)| input != 0.0 && output == 0.0) {
|
||||
return false;
|
||||
}
|
||||
let Some((ui, vi)) = SupportPoint::normalized(a, center, radius).zip(SupportPoint::normalized(z, center, radius)) else {
|
||||
return false;
|
||||
};
|
||||
support_residuals(u, v, bulge, ui, vi).is_some() && bulge_arc(DxfVertex { at: a, bulge }, z).is_some()
|
||||
}
|
||||
|
||||
// The gamma_n relative-error model applies to normal arithmetic. Conservatively retain
|
||||
// ARC on subnormal intermediate results instead of adding an unbounded underflow margin.
|
||||
fn normal_or_zero(value: f64) -> Option<f64> {
|
||||
(value == 0.0 || value.is_normal()).then_some(value)
|
||||
}
|
||||
|
||||
fn normal_product(a: f64, b: f64) -> Option<f64> {
|
||||
let product = normal_or_zero(a * b)?;
|
||||
(product != 0.0 || a == 0.0 || b == 0.0).then_some(product)
|
||||
}
|
||||
|
||||
// These nonnegative quantities are admission limits, so round DOWN: uncertainty in the
|
||||
// comparison must not enlarge the allowed geometric discrepancy.
|
||||
fn lower_add(a: f64, b: f64) -> f64 {
|
||||
if a == 0.0 {
|
||||
b
|
||||
} else if b == 0.0 {
|
||||
a
|
||||
} else {
|
||||
(a + b).next_down()
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_mul(a: f64, b: f64) -> f64 {
|
||||
if a == 0.0 || b == 0.0 { 0.0 } else { (a * b).next_down() }
|
||||
}
|
||||
|
||||
fn gamma_lower(operations: f64) -> f64 {
|
||||
let nu = lower_mul(operations, f64::EPSILON * 0.5);
|
||||
(nu / (1.0 - nu).next_up()).next_down()
|
||||
}
|
||||
|
||||
/// Outward enclosure starting at the actual serialized vertices and original support,
|
||||
/// including their normalization. This does not enclose a hypothetical exact trigonometric
|
||||
/// construction of those vertices; their stored f64 values are the operands being checked.
|
||||
#[derive(Clone, Copy)]
|
||||
struct SupportInterval {
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
}
|
||||
|
||||
impl SupportInterval {
|
||||
fn exact(value: f64) -> Self {
|
||||
Self { lo: value, hi: value }
|
||||
}
|
||||
|
||||
fn enclosing(lo: f64, hi: f64) -> Option<Self> {
|
||||
let (lo, hi) = (lo.next_down(), hi.next_up());
|
||||
Self::checked(lo, hi)
|
||||
}
|
||||
|
||||
fn checked(lo: f64, hi: f64) -> Option<Self> {
|
||||
(lo.is_finite() && hi.is_finite() && lo <= hi).then_some(Self { lo, hi })
|
||||
}
|
||||
|
||||
fn is_exact(self, value: f64) -> bool {
|
||||
self.lo == value && self.hi == value
|
||||
}
|
||||
|
||||
fn negated(self) -> Self {
|
||||
Self { lo: -self.hi, hi: -self.lo }
|
||||
}
|
||||
|
||||
fn from_residual(value: f64, residual: f64, exact_zero: bool) -> Option<Self> {
|
||||
if !value.is_finite() || !residual.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if residual == 0.0 && !exact_zero {
|
||||
// An FMA residual can underflow. Zero alone does not prove exactness.
|
||||
return Self::enclosing(value, value);
|
||||
}
|
||||
let lo = if residual < 0.0 { value.next_down() } else { value };
|
||||
let hi = if residual > 0.0 { value.next_up() } else { value };
|
||||
Self::checked(lo, hi)
|
||||
}
|
||||
|
||||
fn sum(a: f64, b: f64) -> Option<Self> {
|
||||
// TwoSum recovers the exact addition residual in the absence of overflow.
|
||||
let sum = a + b;
|
||||
let virtual_b = sum - a;
|
||||
let virtual_a = sum - virtual_b;
|
||||
let error_a = a - virtual_a;
|
||||
let error_b = b - virtual_b;
|
||||
let residual = error_a + error_b;
|
||||
if [sum, virtual_b, virtual_a, error_a, error_b, residual].iter().any(|v| !v.is_finite()) {
|
||||
return Self::enclosing(sum, sum);
|
||||
}
|
||||
Self::from_residual(sum, residual, true)
|
||||
}
|
||||
|
||||
fn product(a: f64, b: f64) -> Option<Self> {
|
||||
let product = a * b;
|
||||
if !product.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if a == 0.0
|
||||
|| b == 0.0
|
||||
|| a.abs() == 1.0
|
||||
|| b.abs() == 1.0
|
||||
|| (product.is_normal() && ((power_of_two(a) && b.is_normal()) || (power_of_two(b) && a.is_normal())))
|
||||
{
|
||||
return Some(Self::exact(product));
|
||||
}
|
||||
Self::from_residual(product, a.mul_add(b, -product), false)
|
||||
}
|
||||
|
||||
fn quotient(a: f64, divisor: f64) -> Option<Self> {
|
||||
let quotient = a / divisor;
|
||||
if !quotient.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if a == 0.0 || divisor == 1.0 || a.abs() == divisor || (power_of_two(divisor) && a.is_normal() && quotient.is_normal()) {
|
||||
return Some(Self::exact(quotient));
|
||||
}
|
||||
// A positive divisor preserves the sign of a - quotient*divisor.
|
||||
Self::from_residual(quotient, (-quotient).mul_add(divisor, a), false)
|
||||
}
|
||||
|
||||
fn add(self, rhs: Self) -> Option<Self> {
|
||||
if self.is_exact(0.0) {
|
||||
return Some(rhs);
|
||||
}
|
||||
if rhs.is_exact(0.0) {
|
||||
return Some(self);
|
||||
}
|
||||
Self::checked(Self::sum(self.lo, rhs.lo)?.lo, Self::sum(self.hi, rhs.hi)?.hi)
|
||||
}
|
||||
|
||||
fn sub(self, rhs: Self) -> Option<Self> {
|
||||
self.add(rhs.negated())
|
||||
}
|
||||
|
||||
fn mul(self, rhs: Self) -> Option<Self> {
|
||||
if self.is_exact(0.0) || rhs.is_exact(0.0) {
|
||||
return Some(Self::exact(0.0));
|
||||
}
|
||||
if self.is_exact(1.0) {
|
||||
return Some(rhs);
|
||||
}
|
||||
if rhs.is_exact(1.0) {
|
||||
return Some(self);
|
||||
}
|
||||
if self.is_exact(-1.0) {
|
||||
return Some(rhs.negated());
|
||||
}
|
||||
if rhs.is_exact(-1.0) {
|
||||
return Some(self.negated());
|
||||
}
|
||||
let products =
|
||||
[Self::product(self.lo, rhs.lo)?, Self::product(self.lo, rhs.hi)?, Self::product(self.hi, rhs.lo)?, Self::product(self.hi, rhs.hi)?];
|
||||
let lo = products.iter().map(|p| p.lo).fold(f64::INFINITY, f64::min);
|
||||
let hi = products.iter().map(|p| p.hi).fold(f64::NEG_INFINITY, f64::max);
|
||||
Self::checked(lo, hi)
|
||||
}
|
||||
|
||||
fn div_positive(self, divisor: f64) -> Option<Self> {
|
||||
if !divisor.is_finite() || divisor <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Self::checked(Self::quotient(self.lo, divisor)?.lo, Self::quotient(self.hi, divisor)?.hi)
|
||||
}
|
||||
|
||||
fn fits(self, bound: f64) -> bool {
|
||||
bound.is_normal() && bound > 0.0 && self.lo >= -bound && self.hi <= bound
|
||||
}
|
||||
}
|
||||
|
||||
fn power_of_two(value: f64) -> bool {
|
||||
// A normal binary64 power of two has no fraction bits, independent of its sign.
|
||||
value.is_normal() && value.to_bits() & ((1_u64 << 52) - 1) == 0
|
||||
}
|
||||
|
||||
struct SupportPoint {
|
||||
x: SupportInterval,
|
||||
y: SupportInterval,
|
||||
}
|
||||
|
||||
impl SupportPoint {
|
||||
fn normalized(point: Vec2, center: Vec2, radius: f64) -> Option<Self> {
|
||||
let exact = SupportInterval::exact;
|
||||
Some(Self { x: exact(point.x).sub(exact(center.x))?.div_positive(radius)?, y: exact(point.y).sub(exact(center.y))?.div_positive(radius)? })
|
||||
}
|
||||
}
|
||||
|
||||
fn support_residuals(u: Vec2, v: Vec2, b: f64, ui: SupportPoint, vi: SupportPoint) -> Option<()> {
|
||||
if [u.x, u.y, v.x, v.y, b].iter().any(|&value| normal_or_zero(value).is_none()) {
|
||||
return None;
|
||||
}
|
||||
let sum = u + v;
|
||||
let d = v - u;
|
||||
if [sum.x, sum.y, d.x, d.y].iter().any(|&value| normal_or_zero(value).is_none()) {
|
||||
return None;
|
||||
}
|
||||
let square = normal_product(b, b)?;
|
||||
if square == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let twice = normal_product(2.0, b)?;
|
||||
let minus = normal_or_zero(1.0 - square)?;
|
||||
normal_or_zero(normal_product(twice, sum.x)? - normal_product(d.y, minus)?)?;
|
||||
normal_or_zero(normal_product(twice, sum.y)? + normal_product(d.x, minus)?)?;
|
||||
let plus = normal_or_zero(1.0 + square)?;
|
||||
let chord2 = normal_or_zero(normal_product(d.x, d.x)? + normal_product(d.y, d.y)?)?;
|
||||
if chord2 == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let left = normal_product(chord2, normal_product(plus, plus)?)?;
|
||||
let right = normal_product(16.0, square)?;
|
||||
normal_or_zero(left - right)?;
|
||||
|
||||
// qC = 2*b*(u+v) + perp(v-u)*(1-b*b); qC/(4*b) is the reconstructed
|
||||
// centre relative to the original. qR = |v-u|²*(1+b²)² - 16*b²;
|
||||
// qR/(16*b²) is its squared-radius discrepancy. Bounds use unit-circle scale,
|
||||
// with a shared infinity norm for centre coordinates, including near-zero ones.
|
||||
let norm = |p: Vec2| p.x.abs().max(p.y.abs());
|
||||
let b2_lo = lower_mul(b.abs(), b.abs());
|
||||
let plus_lo = lower_add(1.0, b2_lo);
|
||||
let center_scale = lower_add(lower_mul(lower_mul(2.0, b.abs()), lower_add(norm(u), norm(v))), lower_mul(norm(d), plus_lo));
|
||||
let chord2_lo = lower_add(lower_mul(d.x.abs(), d.x.abs()), lower_mul(d.y.abs(), d.y.abs()));
|
||||
let radius_scale = lower_add(lower_mul(chord2_lo, lower_mul(plus_lo, plus_lo)), lower_mul(16.0, b2_lo));
|
||||
|
||||
// Centre: 8 scalar normalizations, 4 vector +/- components, b² and 2b,
|
||||
// 1-b², four products and two final +/- = 21 operations.
|
||||
// Radius: 8 normalizations, 2 chord components, b², 1+b², three operations
|
||||
// for |d|², a square, a product, scaling by 16 and a subtraction = 19.
|
||||
// Also cap normalized centre and squared-radius error independently of conditioning:
|
||||
// |qC|/(4|b|) <= gamma_21 and |qR|/(16b²) <= gamma_19. Small b must not amplify
|
||||
// an accepted residual into large geometric distortion. Limits round inward.
|
||||
let center_bound = lower_mul(gamma_lower(21.0), center_scale).min(lower_mul(lower_mul(4.0, b.abs()), gamma_lower(21.0)));
|
||||
let radius_bound = lower_mul(gamma_lower(19.0), radius_scale).min(lower_mul(lower_mul(16.0, b2_lo), gamma_lower(19.0)));
|
||||
|
||||
let exact = SupportInterval::exact;
|
||||
let ib = exact(b);
|
||||
let square = ib.mul(ib)?;
|
||||
let twice = exact(2.0).mul(ib)?;
|
||||
let minus = exact(1.0).sub(square)?;
|
||||
let plus = exact(1.0).add(square)?;
|
||||
let dx = vi.x.sub(ui.x)?;
|
||||
let dy = vi.y.sub(ui.y)?;
|
||||
let qx = twice.mul(ui.x.add(vi.x)?)?.sub(dy.mul(minus)?)?;
|
||||
let qy = twice.mul(ui.y.add(vi.y)?)?.add(dx.mul(minus)?)?;
|
||||
let qr = dx.mul(dx)?.add(dy.mul(dy)?)?.mul(plus.mul(plus)?)?.sub(exact(16.0).mul(square)?)?;
|
||||
// Inclusion of the WHOLE enclosure is required; merely containing zero is insufficient.
|
||||
(qx.fits(center_bound) && qy.fits(center_bound) && qr.fits(radius_bound)).then_some(())
|
||||
}
|
||||
|
||||
/// Reconstruct a non-straight polyline edge with a signed sweep for bounds and validation.
|
||||
/// Avoid squaring the chord length or first forming 1/bulge: those can overflow even when
|
||||
/// the resulting radius is finite. A zero chord with a nonzero bulge is not an arc.
|
||||
pub(super) fn bulge_arc(from: DxfVertex, to: Vec2) -> Option<Seg2> {
|
||||
let b = from.bulge;
|
||||
if !(from.at.is_finite() && to.is_finite() && b.is_finite()) || b == 0.0 || b.abs() > 1.0 {
|
||||
return None;
|
||||
}
|
||||
let chord = to - from.at;
|
||||
let length = chord.len();
|
||||
if !chord.is_finite() || !length.is_finite() || length == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let scaled = (length * 0.25) / b;
|
||||
let radius = scaled.abs() * (1.0 + b * b);
|
||||
let center = from.at * 0.5 + to * 0.5 + (chord / length).perp() * (scaled * (1.0 - b * b));
|
||||
if !circle_bounds_finite(center, radius) {
|
||||
return None;
|
||||
}
|
||||
let radial = from.at - center;
|
||||
if !radial.is_finite() || radial == Vec2::ZERO {
|
||||
return None;
|
||||
}
|
||||
let start = radial.angle();
|
||||
let sweep = 4.0 * b.atan();
|
||||
(start.is_finite() && sweep.is_finite() && sweep != 0.0).then_some(Seg2::Arc { center, radius, start, sweep })
|
||||
}
|
||||
+1388
File diff suppressed because it is too large
Load Diff
+854
@@ -0,0 +1,854 @@
|
||||
//! Public path conversion and DXF readback acceptance, using original analytic fixtures.
|
||||
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, PI, TAU};
|
||||
|
||||
use solidcraft_geom::{Loop2, MAX_BEZIER_CTRL, Seg2, Vec2, bezier};
|
||||
use solidcraft_io::{DxfEntity, DxfPath, DxfPathStyle, DxfShape, DxfVertex, Geom2, IoError, path_shapes, read_dxf, write_dxf_drawing};
|
||||
|
||||
// Accepted fixtures span at most 40 mm. Legacy entities keep twelve decimal places;
|
||||
// bulged polylines may retain more. These bounds allow roundoff in the independent
|
||||
// inverse-bulge calculation and reader, not fitting error or an output-format requirement.
|
||||
const POSITION_EPS: f64 = 1e-8;
|
||||
const BULGE_EPS: f64 = 1e-11;
|
||||
|
||||
fn point_near(actual: Vec2, expected: Vec2) {
|
||||
assert!(actual.dist(expected) < POSITION_EPS, "actual {actual:?}, expected {expected:?}");
|
||||
}
|
||||
|
||||
fn number_near(actual: f64, expected: f64, tolerance: f64) {
|
||||
assert!((actual - expected).abs() < tolerance, "actual {actual}, expected {expected}, tolerance {tolerance}");
|
||||
}
|
||||
|
||||
fn styles() -> [DxfPathStyle; 3] {
|
||||
[DxfPathStyle::Exact, DxfPathStyle::Polyline, DxfPathStyle::Arcs { tolerance_mm: 0.01 }]
|
||||
}
|
||||
|
||||
fn drawing(shapes: &[DxfShape]) -> String {
|
||||
let entities: Vec<_> = shapes.iter().cloned().map(|shape| DxfEntity { layer: "0".into(), shape }).collect();
|
||||
write_dxf_drawing(&[], &entities).unwrap()
|
||||
}
|
||||
|
||||
type RawEntity<'a> = (&'a str, Vec<(i32, &'a str)>);
|
||||
|
||||
fn raw_entities(text: &str) -> Vec<RawEntity<'_>> {
|
||||
let lines: Vec<_> = text.lines().collect();
|
||||
assert_eq!(lines.len() % 2, 0);
|
||||
let pairs: Vec<_> = lines.chunks_exact(2).map(|p| (p[0].trim().parse::<i32>().unwrap(), p[1].trim())).collect();
|
||||
let start = pairs.iter().position(|p| *p == (2, "ENTITIES")).unwrap();
|
||||
let mut entities: Vec<RawEntity<'_>> = Vec::new();
|
||||
for &(code, value) in &pairs[start + 1..] {
|
||||
match (code, value) {
|
||||
(0, "ENDSEC") => return entities,
|
||||
(0, kind) => entities.push((kind, Vec::new())),
|
||||
pair => entities.last_mut().unwrap().1.push(pair),
|
||||
}
|
||||
}
|
||||
panic!("missing ENTITIES terminator");
|
||||
}
|
||||
|
||||
fn scalar(entity: &RawEntity<'_>, code: i32) -> f64 {
|
||||
let values: Vec<_> = entity.1.iter().filter(|p| p.0 == code).map(|p| p.1.parse::<f64>().unwrap()).collect();
|
||||
assert_eq!(values.len(), 1, "group {code}: {entity:?}");
|
||||
values[0]
|
||||
}
|
||||
|
||||
// Read group 42 beside its owning vertex, rather than collecting all bulges separately:
|
||||
// omitting a zero group must not shift the next nonzero bulge to a different edge.
|
||||
fn raw_polyline(entity: &RawEntity<'_>) -> (Vec<DxfVertex>, bool) {
|
||||
assert_eq!(entity.0, "LWPOLYLINE");
|
||||
let mut vertices: Vec<DxfVertex> = Vec::new();
|
||||
for &(code, value) in &entity.1 {
|
||||
match code {
|
||||
10 => vertices.push(DxfVertex { at: Vec2::new(value.parse().unwrap(), f64::NAN), bulge: 0.0 }),
|
||||
20 => vertices.last_mut().unwrap().at.y = value.parse().unwrap(),
|
||||
42 => vertices.last_mut().unwrap().bulge = value.parse().unwrap(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(scalar(entity, 90), vertices.len() as f64);
|
||||
assert!(vertices.iter().all(|v| v.at.is_finite() && v.bulge.is_finite()));
|
||||
let flags = scalar(entity, 70) as u32;
|
||||
(vertices, flags & 1 != 0)
|
||||
}
|
||||
|
||||
fn one_polyline(shapes: &[DxfShape], closed: bool) -> &[DxfVertex] {
|
||||
let [DxfShape::BulgedPolyline { vertices, closed: actual }] = shapes else { panic!("{shapes:?}") };
|
||||
assert_eq!(*actual, closed);
|
||||
vertices
|
||||
}
|
||||
|
||||
fn assert_serialized_vertices(text: &str, expected: &[DxfVertex], closed: bool) -> Vec<DxfVertex> {
|
||||
let entities = raw_entities(text);
|
||||
assert_eq!(entities.len(), 1);
|
||||
let (vertices, actual_closed) = raw_polyline(&entities[0]);
|
||||
assert_eq!(actual_closed, closed);
|
||||
assert_eq!(vertices.len(), expected.len());
|
||||
for (actual, expected) in vertices.iter().zip(expected) {
|
||||
point_near(actual.at, expected.at);
|
||||
number_near(actual.bulge, expected.bulge, BULGE_EPS);
|
||||
}
|
||||
vertices
|
||||
}
|
||||
|
||||
// Algebraic inverse from chord and sagitta ratio; deliberately does not use the reader's
|
||||
// atan/sin/cos construction, the converter, or Seg2::fit_arcs as an oracle.
|
||||
fn assert_arc_support(a: &DxfVertex, b: &DxfVertex, center: Vec2, radius: f64) {
|
||||
assert!(a.bulge != 0.0);
|
||||
let chord = b.at - a.at;
|
||||
let square = a.bulge * a.bulge;
|
||||
let recovered_center = (a.at + b.at) * 0.5 + chord.perp() * ((1.0 - square) / (4.0 * a.bulge));
|
||||
let recovered_radius = chord.len() * (1.0 + square) / (4.0 * a.bulge.abs());
|
||||
point_near(recovered_center, center);
|
||||
number_near(recovered_radius, radius, POSITION_EPS);
|
||||
}
|
||||
|
||||
fn assert_readback(text: &str, expected: &[Seg2]) {
|
||||
let actual = read_dxf(text).unwrap();
|
||||
assert_eq!(actual.len(), expected.len(), "{actual:?}");
|
||||
for (actual, expected) in actual.iter().zip(expected) {
|
||||
match (actual, expected) {
|
||||
(Geom2::Line(a, b), Seg2::Line { a: ea, b: eb }) => {
|
||||
point_near(*a, *ea);
|
||||
point_near(*b, *eb);
|
||||
}
|
||||
(Geom2::Arc { c, a, b }, Seg2::Arc { center, radius, sweep, .. }) => {
|
||||
point_near(*c, *center);
|
||||
number_near(a.dist(*c), *radius, POSITION_EPS);
|
||||
number_near(b.dist(*c), *radius, POSITION_EPS);
|
||||
// Geom2 normalizes clockwise arcs to counter-clockwise endpoint order.
|
||||
let (ea, eb) = if *sweep > 0.0 { (expected.start(), expected.end()) } else { (expected.end(), expected.start()) };
|
||||
point_near(*a, ea);
|
||||
point_near(*b, eb);
|
||||
}
|
||||
_ => panic!("read {actual:?}, expected {expected:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_arc_style(shapes: &[DxfShape], segment: &Seg2) {
|
||||
assert!(!shapes.is_empty(), "nonempty curve was discarded");
|
||||
assert!(shapes.iter().all(|s| matches!(s, DxfShape::Line(..) | DxfShape::Arc { .. } | DxfShape::Circle { .. })));
|
||||
let ends: Vec<_> = shapes
|
||||
.iter()
|
||||
.flat_map(|shape| match shape {
|
||||
DxfShape::Line(a, b) => vec![*a, *b],
|
||||
DxfShape::Arc { c, r, a0, a1 } => vec![*c + Vec2::from_angle(a0.to_radians()) * *r, *c + Vec2::from_angle(a1.to_radians()) * *r],
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
for endpoint in [segment.start(), segment.end()] {
|
||||
assert!(ends.iter().any(|p| p.dist(endpoint) < POSITION_EPS), "missing endpoint {endpoint:?}: {shapes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitive_paths_dispatch_by_style_without_losing_arc_direction() {
|
||||
let line = Seg2::Line { a: Vec2::new(2.0, -3.0), b: Vec2::new(11.0, 4.0) };
|
||||
for style in styles() {
|
||||
let out = path_shapes(DxfPath::Open(&[line]), style).unwrap();
|
||||
match style {
|
||||
DxfPathStyle::Polyline => {
|
||||
let vertices = one_polyline(&out, false);
|
||||
assert_eq!(vertices.len(), 2);
|
||||
assert!(vertices.iter().all(|v| v.bulge == 0.0));
|
||||
}
|
||||
_ => assert_eq!(out, vec![DxfShape::Line(line.start(), line.end())]),
|
||||
}
|
||||
assert_readback(&drawing(&out), &[line]);
|
||||
for sweep in [-FRAC_PI_2, FRAC_PI_2] {
|
||||
let arc = Seg2::Arc { center: Vec2::new(3.0, -5.0), radius: 7.0, start: 0.3, sweep };
|
||||
let out = path_shapes(DxfPath::Open(&[arc]), style).unwrap();
|
||||
match style {
|
||||
DxfPathStyle::Polyline => {
|
||||
let vertices = one_polyline(&out, false);
|
||||
assert_eq!(vertices.len(), 2);
|
||||
number_near(vertices[0].bulge, sweep.signum() * (2.0_f64.sqrt() - 1.0), BULGE_EPS);
|
||||
assert_eq!(vertices[1].bulge, 0.0);
|
||||
}
|
||||
_ => assert!(matches!(out.as_slice(), [DxfShape::Arc { .. }])),
|
||||
}
|
||||
assert_readback(&drawing(&out), &[arc]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conic_styles_keep_exact_ellipses_and_rational_splines_unless_fitting_is_requested() {
|
||||
let (a, apex, b) = (Vec2::new(12.0, 0.0), Vec2::new(12.0, 5.0), Vec2::new(0.0, 5.0));
|
||||
for w in [FRAC_1_SQRT_2, 1.0, 1.75] {
|
||||
let conic = Seg2::Conic { a, apex, b, w };
|
||||
for style in styles() {
|
||||
let out = path_shapes(DxfPath::Open(&[conic]), style).unwrap();
|
||||
if matches!(style, DxfPathStyle::Arcs { .. }) {
|
||||
assert_arc_style(&out, &conic);
|
||||
} else if w < 1.0 {
|
||||
assert!(matches!(out.as_slice(), [DxfShape::Ellipse { .. }]));
|
||||
} else {
|
||||
let [DxfShape::Spline { degree, ctrl, weights, .. }] = out.as_slice() else { panic!("{out:?}") };
|
||||
assert_eq!(*degree, 2);
|
||||
assert_eq!(ctrl, &[a, apex, b]);
|
||||
let expected_weights = (w != 1.0).then(|| vec![1.0, w, 1.0]);
|
||||
assert_eq!(weights, &expected_weights);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degrees_one_through_nine_use_their_actual_public_curve_representation() {
|
||||
for degree in 1..=9 {
|
||||
let points: Vec<_> = (0..=degree).map(|i| Vec2::new(2.0 * i as f64, 0.08 * (i * (degree - i)) as f64)).collect();
|
||||
let segment = bezier(&points).unwrap();
|
||||
for style in styles() {
|
||||
let out = path_shapes(DxfPath::Open(&[segment]), style).unwrap();
|
||||
if matches!(style, DxfPathStyle::Arcs { .. }) {
|
||||
assert_arc_style(&out, &segment);
|
||||
} else if degree == 1 {
|
||||
if matches!(style, DxfPathStyle::Polyline) {
|
||||
assert_eq!(one_polyline(&out, false).len(), 2);
|
||||
} else {
|
||||
assert_eq!(out, vec![DxfShape::Line(points[0], points[1])]);
|
||||
}
|
||||
} else {
|
||||
let [DxfShape::Spline { degree: actual, ctrl, knots, .. }] = out.as_slice() else { panic!("{out:?}") };
|
||||
assert_eq!(usize::from(*actual), degree);
|
||||
assert_eq!(ctrl, &points);
|
||||
assert_eq!(knots.len(), 2 * (degree + 1));
|
||||
assert!(knots[..=degree].iter().all(|k| *k == 0.0));
|
||||
assert!(knots[degree + 1..].iter().all(|k| *k == 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_circle_and_ellipse_dispatch_through_the_same_public_path_entry() {
|
||||
let (center, u, v) = (Vec2::new(4.0, -2.0), Vec2::new(12.0, 0.0), Vec2::new(0.0, 5.0));
|
||||
let quarter = |u, v| Seg2::Conic { a: center + u, apex: center + u + v, b: center + v, w: FRAC_1_SQRT_2 };
|
||||
let ellipse = Loop2 { segs: vec![quarter(u, v), quarter(v, -u), quarter(-u, -v), quarter(-v, u)] };
|
||||
for style in styles() {
|
||||
let circle = Loop2::circle(center, 6.0);
|
||||
let out = path_shapes(DxfPath::Closed(&circle), style).unwrap();
|
||||
assert_eq!(out, vec![DxfShape::Circle { c: center, r: 6.0 }]);
|
||||
for sweep in [-TAU, TAU] {
|
||||
let arc = Seg2::Arc { center, radius: 6.0, start: 0.4, sweep };
|
||||
assert_eq!(path_shapes(DxfPath::Open(&[arc]), style).unwrap(), out);
|
||||
}
|
||||
let out = path_shapes(DxfPath::Closed(&ellipse), style).unwrap();
|
||||
if matches!(style, DxfPathStyle::Arcs { .. }) {
|
||||
assert!(!out.is_empty());
|
||||
assert!(out.iter().all(|s| matches!(s, DxfShape::Line(..) | DxfShape::Arc { .. } | DxfShape::Circle { .. })));
|
||||
} else {
|
||||
assert!(matches!(out.as_slice(), [DxfShape::Ellipse { .. }]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_turn_boundary_splits_only_larger_arcs_and_keeps_signed_bulges() {
|
||||
let (center, radius, start) = (Vec2::new(12.0, -7.0), 9.0, 0.37);
|
||||
for magnitude in [PI - 1e-6, PI, PI + 1e-6, 1.5 * PI] {
|
||||
for sign in [-1.0, 1.0] {
|
||||
let sweep = sign * magnitude;
|
||||
let arc = Seg2::Arc { center, radius, start, sweep };
|
||||
let out = path_shapes(DxfPath::Open(&[arc]), DxfPathStyle::Polyline).unwrap();
|
||||
let vertices = one_polyline(&out, false);
|
||||
let count = if magnitude > PI { 2 } else { 1 };
|
||||
assert_eq!(vertices.len(), count + 1);
|
||||
let expected: Vec<_> = (0..=count)
|
||||
.map(|i| {
|
||||
let angle = start + sweep * i as f64 / count as f64;
|
||||
DxfVertex {
|
||||
at: center + Vec2::new(angle.cos(), angle.sin()) * radius,
|
||||
bulge: if i == count { 0.0 } else { (sweep / (4.0 * count as f64)).tan() },
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for (actual, expected) in vertices.iter().zip(&expected) {
|
||||
point_near(actual.at, expected.at);
|
||||
number_near(actual.bulge, expected.bulge, BULGE_EPS);
|
||||
}
|
||||
let text = drawing(&out);
|
||||
let raw = assert_serialized_vertices(&text, &expected, false);
|
||||
for edge in raw.windows(2) {
|
||||
assert_arc_support(&edge[0], &edge[1], center, radius);
|
||||
}
|
||||
let pieces: Vec<_> = (0..count)
|
||||
.map(|i| Seg2::Arc { center, radius, start: start + sweep * i as f64 / count as f64, sweep: sweep / count as f64 })
|
||||
.collect();
|
||||
assert_readback(&text, &pieces);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_runs_preserve_zero_bulges_between_arcs_and_have_no_closing_edge() {
|
||||
let first = Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: 4.0, start: FRAC_PI_2, sweep: -FRAC_PI_2 };
|
||||
let second = Seg2::Arc { center: Vec2::new(9.0, 0.0), radius: 3.0, start: PI, sweep: FRAC_PI_2 };
|
||||
let path = [first, Seg2::Line { a: first.end(), b: second.start() }, second];
|
||||
let q = 2.0_f64.sqrt() - 1.0;
|
||||
for reverse in [false, true] {
|
||||
let segments = if reverse { path.iter().rev().map(Seg2::reversed).collect::<Vec<_>>() } else { path.to_vec() };
|
||||
let out = path_shapes(DxfPath::Open(&segments), DxfPathStyle::Polyline).unwrap();
|
||||
let vertices = one_polyline(&out, false);
|
||||
assert_eq!(vertices.len(), 4);
|
||||
let bulges = [-q, 0.0, q, 0.0];
|
||||
let expected: Vec<_> =
|
||||
(0..4).map(|i| DxfVertex { at: if i < 3 { segments[i].start() } else { segments[2].end() }, bulge: bulges[i] }).collect();
|
||||
let text = drawing(&out);
|
||||
let raw = assert_serialized_vertices(&text, &expected, false);
|
||||
for (i, segment) in segments.iter().enumerate() {
|
||||
if let Seg2::Arc { center, radius, .. } = segment {
|
||||
assert_arc_support(&raw[i], &raw[i + 1], *center, *radius);
|
||||
}
|
||||
}
|
||||
assert_readback(&text, &segments);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_last_arc_owns_the_final_bulge_and_reversal_moves_it_to_its_new_edge() {
|
||||
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 4.0, start: 0.0, sweep: FRAC_PI_2 };
|
||||
let (corner1, corner2) = (Vec2::new(-3.0, 4.0), Vec2::new(-3.0, 0.0));
|
||||
let loop_ = Loop2 {
|
||||
segs: vec![Seg2::Line { a: arc.end(), b: corner1 }, Seg2::Line { a: corner1, b: corner2 }, Seg2::Line { a: corner2, b: arc.start() }, arc],
|
||||
};
|
||||
for (path, bulges) in [(loop_.clone(), [0.0, 0.0, 0.0, 2.0_f64.sqrt() - 1.0]), (loop_.reversed(), [1.0 - 2.0_f64.sqrt(), 0.0, 0.0, 0.0])] {
|
||||
let out = path_shapes(DxfPath::Closed(&path), DxfPathStyle::Polyline).unwrap();
|
||||
let vertices = one_polyline(&out, true);
|
||||
assert_eq!(vertices.len(), 4, "closed paths must not duplicate the first vertex");
|
||||
let expected: Vec<_> = path.segs.iter().zip(bulges).map(|(s, bulge)| DxfVertex { at: s.start(), bulge }).collect();
|
||||
let text = drawing(&out);
|
||||
let raw = assert_serialized_vertices(&text, &expected, true);
|
||||
for (i, segment) in path.segs.iter().enumerate() {
|
||||
if let Seg2::Arc { center, radius, .. } = segment {
|
||||
assert_arc_support(&raw[i], &raw[(i + 1) % raw.len()], *center, *radius);
|
||||
}
|
||||
}
|
||||
assert_readback(&text, &path.segs);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_curves_interrupt_closed_polyline_runs_without_inventing_closing_lines() {
|
||||
let (a, b, c, d, e) = (Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(8.0, 4.0), Vec2::new(8.0, 9.0), Vec2::new(0.0, 12.0));
|
||||
let lines = [(a, b), (c, d), (e, a)];
|
||||
let path = Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Line { a, b },
|
||||
Seg2::Cubic { p0: b, p1: Vec2::new(6.0, 0.0), p2: Vec2::new(8.0, 2.0), p3: c },
|
||||
Seg2::Line { a: c, b: d },
|
||||
Seg2::Conic { a: d, apex: Vec2::new(8.0, 12.0), b: e, w: FRAC_1_SQRT_2 },
|
||||
Seg2::Line { a: e, b: a },
|
||||
],
|
||||
};
|
||||
let out = path_shapes(DxfPath::Closed(&path), DxfPathStyle::Polyline).unwrap();
|
||||
assert_eq!(out.iter().filter(|s| matches!(s, DxfShape::Spline { degree: 3, .. })).count(), 1);
|
||||
assert_eq!(out.iter().filter(|s| matches!(s, DxfShape::Ellipse { .. })).count(), 1);
|
||||
let text = drawing(&out);
|
||||
let entities = raw_entities(&text);
|
||||
let mut edges = Vec::new();
|
||||
for entity in entities.iter().filter(|e| e.0 == "LWPOLYLINE") {
|
||||
let (vertices, closed) = raw_polyline(entity);
|
||||
assert!(!closed, "a run interrupted by an exact curve is open");
|
||||
assert!(vertices.iter().all(|v| v.bulge == 0.0));
|
||||
edges.extend(vertices.windows(2).map(|v| (v[0].at, v[1].at)));
|
||||
}
|
||||
// Prefix/suffix runs may be joined across the original seam. Either representation must
|
||||
// contain exactly the three authored line edges and no chord across a free-form curve.
|
||||
assert_eq!(edges.len(), lines.len());
|
||||
for (a, b) in lines {
|
||||
assert_eq!(edges.iter().filter(|(ea, eb)| ea.dist(a) < POSITION_EPS && eb.dist(b) < POSITION_EPS).count(), 1);
|
||||
}
|
||||
let read = read_dxf(&text).unwrap();
|
||||
assert_eq!(read.iter().filter(|s| matches!(s, Geom2::Line(..))).count(), 3);
|
||||
}
|
||||
|
||||
fn invalid(path: &[Seg2], style: DxfPathStyle) -> String {
|
||||
match path_shapes(DxfPath::Open(path), style) {
|
||||
Err(IoError::Invalid(message)) => message,
|
||||
other => panic!("expected contextual invalid-path error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_are_validated_even_for_empty_and_line_only_paths() {
|
||||
let line = [Seg2::Line { a: Vec2::ZERO, b: Vec2::X }];
|
||||
for tolerance_mm in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0, 0.5e-9] {
|
||||
let style = DxfPathStyle::Arcs { tolerance_mm };
|
||||
invalid(&[], style);
|
||||
invalid(&line, style);
|
||||
}
|
||||
for style in styles().into_iter().chain([DxfPathStyle::Arcs { tolerance_mm: 1e-9 }]) {
|
||||
assert!(path_shapes(DxfPath::Open(&[]), style).unwrap().is_empty());
|
||||
assert!(!path_shapes(DxfPath::Open(&line), style).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_bezier_storage_is_refused_before_helpers_can_clamp_the_degree() {
|
||||
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
|
||||
for (i, point) in p.iter_mut().enumerate() {
|
||||
*point = Vec2::new(i as f64, (i % 3) as f64);
|
||||
}
|
||||
for n in [0, 1, 2, 3, 10, u8::MAX] {
|
||||
for style in styles() {
|
||||
invalid(&[Seg2::Bezier { n, p }], style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn piece_errors_keep_original_input_indices_when_earlier_curves_can_expand() {
|
||||
let predecessors = [
|
||||
Seg2::Arc { center: Vec2::new(4.0, 2.0), radius: 3.0, start: 0.4, sweep: 1.5 * PI },
|
||||
Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(0.0, 8.0), p2: Vec2::new(7.0, -5.0), p3: Vec2::new(9.0, 1.0) },
|
||||
];
|
||||
for predecessor in predecessors {
|
||||
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
|
||||
p[0] = predecessor.end();
|
||||
let path = [Seg2::Line { a: predecessor.start() - Vec2::X, b: predecessor.start() }, predecessor, Seg2::Bezier { n: 10, p }];
|
||||
for style in styles() {
|
||||
let message = invalid(&path, style);
|
||||
assert!(message.contains("piece 3 of 3"), "{message}");
|
||||
assert!(message.contains("degree 10"), "{message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_intent_with_a_gap_preserves_pieces_as_open_runs_without_bridging() {
|
||||
// Closed is caller intent, not a topology certificate. The explicit gap from (1,0)
|
||||
// to (2,0) must remain absent from the output, even though the last point is the first.
|
||||
let path = Loop2 { segs: vec![Seg2::Line { a: Vec2::ZERO, b: Vec2::X }, Seg2::Line { a: Vec2::new(2.0, 0.0), b: Vec2::ZERO }] };
|
||||
for style in styles() {
|
||||
let out = path_shapes(DxfPath::Closed(&path), style).unwrap();
|
||||
let text = drawing(&out);
|
||||
if matches!(style, DxfPathStyle::Polyline) {
|
||||
assert_eq!(out.len(), 2);
|
||||
for (shape, segment) in out.iter().zip(&path.segs) {
|
||||
let vertices = one_polyline(std::slice::from_ref(shape), false);
|
||||
assert_eq!(vertices.len(), 2);
|
||||
point_near(vertices[0].at, segment.start());
|
||||
point_near(vertices[1].at, segment.end());
|
||||
assert!(vertices.iter().all(|v| v.bulge == 0.0));
|
||||
}
|
||||
for entity in raw_entities(&text) {
|
||||
assert!(!raw_polyline(&entity).1);
|
||||
}
|
||||
} else {
|
||||
assert_eq!(out, vec![DxfShape::Line(Vec2::ZERO, Vec2::X), DxfShape::Line(Vec2::new(2.0, 0.0), Vec2::ZERO)]);
|
||||
}
|
||||
assert_readback(&text, &path.segs);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonfinite_controls_and_invalid_arcs_are_rejected_in_every_style() {
|
||||
let controls = [
|
||||
Seg2::Line { a: Vec2::ZERO, b: Vec2::new(f64::NAN, 2.0) },
|
||||
Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(1.0, f64::INFINITY), p2: Vec2::new(2.0, 1.0), p3: Vec2::new(3.0, 0.0) },
|
||||
Seg2::Conic { a: Vec2::ZERO, apex: Vec2::new(1.0, 2.0), b: Vec2::new(2.0, 0.0), w: f64::NAN },
|
||||
// Finite inputs may still overflow the derived arc endpoint or bounds.
|
||||
Seg2::Arc { center: Vec2::new(f64::MAX, 0.0), radius: f64::MAX, start: 0.0, sweep: FRAC_PI_2 },
|
||||
];
|
||||
for style in styles() {
|
||||
for segment in controls {
|
||||
invalid(&[segment], style);
|
||||
}
|
||||
for radius in [0.0, -1.0, f64::INFINITY, f64::NAN] {
|
||||
invalid(&[Seg2::Arc { center: Vec2::ZERO, radius, start: 0.0, sweep: PI }], style);
|
||||
}
|
||||
// A one-ULP overrun is still more than one turn; the old near-TAU shortcut must not
|
||||
// silently turn it into a circle. Larger positive/negative overruns are also refused.
|
||||
for sweep in [0.0, f64::NAN, f64::INFINITY, f64::from_bits(TAU.to_bits() + 1), -f64::from_bits(TAU.to_bits() + 1), 1.1 * TAU, -1.1 * TAU] {
|
||||
invalid(&[Seg2::Arc { center: Vec2::ZERO, radius: 3.0, start: 0.0, sweep }], style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_cannot_report_success_after_discarding_a_nonempty_piece() {
|
||||
// The existing fitter may return Some(empty) for a point-sized cubic. The public path
|
||||
// converter must refuse that result instead of silently dropping an authored piece.
|
||||
let at = Vec2::new(2.0, 3.0);
|
||||
let point_curve = Seg2::Cubic { p0: at, p1: at, p2: at, p3: at };
|
||||
invalid(&[point_curve], DxfPathStyle::Arcs { tolerance_mm: 0.01 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulged_arc_extents_include_cardinal_points_between_its_vertices() {
|
||||
let arc = Seg2::Arc { center: Vec2::new(12.0, -7.0), radius: 9.0, start: 0.2, sweep: 1.5 * PI };
|
||||
let out = path_shapes(DxfPath::Open(&[arc]), DxfPathStyle::Polyline).unwrap();
|
||||
let text = drawing(&out);
|
||||
let lines: Vec<_> = text.lines().collect();
|
||||
let pairs: Vec<_> = lines.chunks_exact(2).map(|p| (p[0].trim().parse::<i32>().unwrap(), p[1].trim())).collect();
|
||||
let bound = |name: &str| {
|
||||
let start = pairs.iter().position(|p| *p == (9, name)).unwrap();
|
||||
let fields: Vec<_> = pairs[start + 1..].iter().take_while(|p| p.0 != 9 && p.0 != 0).copied().collect();
|
||||
let entity = (name, fields);
|
||||
Vec2::new(scalar(&entity, 10), scalar(&entity, 20))
|
||||
};
|
||||
// The arc crosses pi/2, pi and 3pi/2. None of the two-piece polyline's interior
|
||||
// vertices supplies all three extremes; the rightmost point is its original start.
|
||||
point_near(bound("$EXTMIN"), Vec2::new(3.0, -16.0));
|
||||
point_near(bound("$EXTMAX"), Vec2::new(12.0 + 9.0 * 0.2_f64.cos(), 2.0));
|
||||
}
|
||||
|
||||
// Resource-boundary fixtures retain small exact-integer coordinates and do not serialize
|
||||
// the large paths. Repeated nonzero pieces are intentional: conversion must preserve them,
|
||||
// not deduplicate geometry to make a path fit a resource budget.
|
||||
const PATH_ITEM_LIMIT: usize = 200_000;
|
||||
|
||||
fn alternating_lines(count: usize) -> Vec<Seg2> {
|
||||
(0..count).map(|i| if i & 1 == 0 { Seg2::Line { a: Vec2::ZERO, b: Vec2::X } } else { Seg2::Line { a: Vec2::X, b: Vec2::ZERO } }).collect()
|
||||
}
|
||||
|
||||
fn assert_line_entities(shapes: &[DxfShape], segments: &[Seg2]) {
|
||||
assert_eq!(shapes.len(), segments.len());
|
||||
for (actual, expected) in shapes.iter().zip(segments) {
|
||||
let Seg2::Line { a, b } = expected else { panic!("line fixture expected") };
|
||||
assert_eq!(actual, &DxfShape::Line(*a, *b));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_and_exact_entity_budgets_accept_the_boundary_without_truncation() {
|
||||
let mut path = alternating_lines(PATH_ITEM_LIMIT);
|
||||
{
|
||||
let shapes = path_shapes(DxfPath::Open(&path), DxfPathStyle::Exact).unwrap();
|
||||
// Exact conversion emits one entity per piece. This reaches the public entity
|
||||
// boundary as well as the independent input boundary; all geometry must remain.
|
||||
assert_eq!(shapes.len(), PATH_ITEM_LIMIT);
|
||||
assert_line_entities(&shapes, &path);
|
||||
}
|
||||
path.push(Seg2::Line { a: Vec2::ZERO, b: Vec2::X });
|
||||
for style in styles() {
|
||||
let message = invalid(&path, style);
|
||||
assert!(message.contains("input piece budget"), "{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_and_closed_vertex_budgets_count_the_terminal_vertex_differently() {
|
||||
let path = Loop2 { segs: alternating_lines(PATH_ITEM_LIMIT) };
|
||||
{
|
||||
let shapes = path_shapes(DxfPath::Open(&path.segs[..PATH_ITEM_LIMIT - 1]), DxfPathStyle::Polyline).unwrap();
|
||||
let vertices = one_polyline(&shapes, false);
|
||||
assert_eq!(vertices.len(), PATH_ITEM_LIMIT);
|
||||
for (i, vertex) in vertices.iter().enumerate() {
|
||||
assert_eq!(vertex.at, if i & 1 == 0 { Vec2::ZERO } else { Vec2::X });
|
||||
assert_eq!(vertex.bulge, 0.0);
|
||||
}
|
||||
}
|
||||
{
|
||||
// The same even-length chain ends at its start. Closed is only caller intent;
|
||||
// these authored overlapping edges still all count, without a duplicate terminal.
|
||||
let shapes = path_shapes(DxfPath::Closed(&path), DxfPathStyle::Polyline).unwrap();
|
||||
let vertices = one_polyline(&shapes, true);
|
||||
assert_eq!(vertices.len(), PATH_ITEM_LIMIT);
|
||||
for (i, vertex) in vertices.iter().enumerate() {
|
||||
assert_eq!(vertex.at, if i & 1 == 0 { Vec2::ZERO } else { Vec2::X });
|
||||
assert_eq!(vertex.bulge, 0.0);
|
||||
}
|
||||
}
|
||||
// Still within the input limit, but its open representation needs 200,001 vertices.
|
||||
let message = invalid(&path.segs, DxfPathStyle::Polyline);
|
||||
assert!(message.contains("polyline vertex budget"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_budget_is_accumulated_across_disconnected_runs() {
|
||||
let line = Seg2::Line { a: Vec2::ZERO, b: Vec2::X };
|
||||
let mut path = vec![line; PATH_ITEM_LIMIT / 2];
|
||||
{
|
||||
let shapes = path_shapes(DxfPath::Open(&path), DxfPathStyle::Polyline).unwrap();
|
||||
assert_eq!(shapes.len(), PATH_ITEM_LIMIT / 2);
|
||||
for shape in &shapes {
|
||||
let vertices = one_polyline(std::slice::from_ref(shape), false);
|
||||
assert_eq!(vertices.len(), 2);
|
||||
assert_eq!(vertices[0].at, Vec2::ZERO);
|
||||
assert_eq!(vertices[1].at, Vec2::X);
|
||||
assert!(vertices.iter().all(|v| v.bulge == 0.0));
|
||||
}
|
||||
}
|
||||
path.push(line);
|
||||
// Only 100,001 inputs/entities; allowing each tiny run its own budget would miss this.
|
||||
let message = invalid(&path, DxfPathStyle::Polyline);
|
||||
assert!(message.contains("polyline vertex budget"), "{message}");
|
||||
}
|
||||
|
||||
fn assert_budget_run_geometry(shapes: &[DxfShape], segments: &[Seg2]) -> usize {
|
||||
assert_eq!(shapes.len(), segments.len(), "every fixture piece is a separate run or exact arc");
|
||||
let mut vertex_count = 0;
|
||||
for (shape, segment) in shapes.iter().zip(segments) {
|
||||
match (shape, segment) {
|
||||
(DxfShape::Arc { c, r, a0, a1 }, Seg2::Arc { center, radius, .. }) => {
|
||||
// The fixture's 270-degree arcs may legally remain exact ARC entities.
|
||||
assert_eq!(c, center);
|
||||
assert_eq!(r, radius);
|
||||
assert_eq!((*a0, *a1), (0.0, 270.0));
|
||||
}
|
||||
(DxfShape::BulgedPolyline { vertices, closed }, Seg2::Arc { center, radius, .. }) => {
|
||||
assert!(!closed);
|
||||
assert_eq!(vertices.len(), 3);
|
||||
point_near(vertices[0].at, segment.start());
|
||||
point_near(vertices[2].at, segment.end());
|
||||
assert_eq!(vertices[2].bulge, 0.0);
|
||||
let mut sweep = 0.0;
|
||||
for edge in vertices.windows(2) {
|
||||
assert!(edge[0].bulge > 0.0 && edge[0].bulge <= 1.0);
|
||||
assert_arc_support(&edge[0], &edge[1], *center, *radius);
|
||||
sweep += 4.0 * edge[0].bulge.atan();
|
||||
}
|
||||
number_near(sweep, 1.5 * PI, BULGE_EPS);
|
||||
vertex_count += vertices.len();
|
||||
}
|
||||
(DxfShape::BulgedPolyline { vertices, closed }, Seg2::Line { a, b }) => {
|
||||
assert!(!closed);
|
||||
assert_eq!(vertices.len(), 2);
|
||||
assert_eq!(vertices[0].at, *a);
|
||||
assert_eq!(vertices[1].at, *b);
|
||||
assert!(vertices.iter().all(|v| v.bulge == 0.0));
|
||||
vertex_count += vertices.len();
|
||||
}
|
||||
_ => panic!("fixture geometry lost: {segment:?} became {shape:?}"),
|
||||
}
|
||||
}
|
||||
vertex_count
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_arc_packing_preserves_support_and_never_exceeds_the_vertex_budget() {
|
||||
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 4.0, start: 0.0, sweep: 1.5 * PI };
|
||||
// A packed disconnected 270-degree arc has two curved edges and three vertices.
|
||||
// 66,666 such runs plus an isolated line need at most 200,000 vertices, including
|
||||
// when every arc packs. Exact ARC fallback uses no polyline vertices and is permitted.
|
||||
let arc_count = PATH_ITEM_LIMIT / 3;
|
||||
let mut path = vec![arc; arc_count];
|
||||
path.push(Seg2::Line { a: Vec2::X, b: Vec2::new(2.0, 0.0) });
|
||||
{
|
||||
let shapes = path_shapes(DxfPath::Open(&path), DxfPathStyle::Polyline).unwrap();
|
||||
assert!(assert_budget_run_geometry(&shapes, &path) <= PATH_ITEM_LIMIT);
|
||||
}
|
||||
path.push(arc);
|
||||
assert!(path.len() < PATH_ITEM_LIMIT);
|
||||
// If the expanded representation exceeds the budget, refuse it. If optional exact
|
||||
// fallbacks keep it within the budget, success must preserve every original support.
|
||||
// This never calibrates expectations to which arcs the current packer happens to admit.
|
||||
match path_shapes(DxfPath::Open(&path), DxfPathStyle::Polyline) {
|
||||
Ok(shapes) => assert!(assert_budget_run_geometry(&shapes, &path) <= PATH_ITEM_LIMIT),
|
||||
Err(IoError::Invalid(message)) => assert!(message.contains("polyline vertex budget"), "{message}"),
|
||||
other => panic!("unexpected path failure: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_mixed_paths_apply_the_aggregate_fitting_budget_to_retained_primitives() {
|
||||
const FITTING_LIMIT: usize = 16_384;
|
||||
const PER_CURVE_LIMIT: usize = 2_048;
|
||||
let style = DxfPathStyle::Arcs { tolerance_mm: 0.01 };
|
||||
let arc = Seg2::Arc { center: Vec2::new(2.0, 0.0), radius: 1.0, start: 0.0, sweep: FRAC_PI_2 };
|
||||
{
|
||||
let mut primitives = alternating_lines(FITTING_LIMIT);
|
||||
primitives.push(arc);
|
||||
let shapes = path_shapes(DxfPath::Open(&primitives), style).unwrap();
|
||||
assert_eq!(shapes.len(), FITTING_LIMIT + 1);
|
||||
assert_line_entities(&shapes[..FITTING_LIMIT], &primitives[..FITTING_LIMIT]);
|
||||
assert_arc_style(&shapes[FITTING_LIMIT..], &arc);
|
||||
}
|
||||
|
||||
// A straight cubic has an exact, inexpensive fit. No assertion depends on how many
|
||||
// pieces the fitter chooses: any admitted fit has 1..=2,048 output pieces.
|
||||
let curve = Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::X, p2: Vec2::new(2.0, 0.0), p3: Vec2::new(3.0, 0.0) };
|
||||
let retained = FITTING_LIMIT - PER_CURVE_LIMIT;
|
||||
let mut mixed = alternating_lines(retained);
|
||||
mixed.push(curve);
|
||||
{
|
||||
let shapes = path_shapes(DxfPath::Open(&mixed), style).unwrap();
|
||||
assert!((retained + 1..=FITTING_LIMIT).contains(&shapes.len()));
|
||||
assert_line_entities(&shapes[..retained], &mixed[..retained]);
|
||||
assert_arc_style(&shapes[retained..], &curve);
|
||||
}
|
||||
assert_eq!(mixed.pop(), Some(curve));
|
||||
mixed.extend(alternating_lines(PER_CURVE_LIMIT));
|
||||
mixed.push(curve);
|
||||
// 16,384 retained primitives already exhaust the mixed-path budget. Every nonempty
|
||||
// fitted result exceeds it, regardless of the fitter's particular subdivision count.
|
||||
assert_eq!(mixed.len(), FITTING_LIMIT + 1);
|
||||
let message = invalid(&mixed, style);
|
||||
assert!(message.contains("path arc fitting budget"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_translation_cannot_turn_a_three_quarter_unit_radius_into_a_unit_radius() {
|
||||
let coordinate = (1_u64 << 52) as f64;
|
||||
let center = Vec2::new(coordinate, coordinate);
|
||||
let radius = 0.75;
|
||||
let arc = Seg2::Arc { center, radius, start: 0.0, sweep: FRAC_PI_2 };
|
||||
let shapes = path_shapes(DxfPath::Open(&[arc]), DxfPathStyle::Polyline).unwrap();
|
||||
let text = drawing(&shapes);
|
||||
let entities = raw_entities(&text);
|
||||
assert_eq!(entities.len(), 1);
|
||||
match entities[0].0 {
|
||||
"ARC" => {
|
||||
// ARC preserves the center, radius and angle directly, even when its derived
|
||||
// f64 endpoints round. This exact fallback is allowed when packing loses support.
|
||||
assert_eq!(scalar(&entities[0], 10), center.x);
|
||||
assert_eq!(scalar(&entities[0], 20), center.y);
|
||||
assert_eq!(scalar(&entities[0], 40), radius);
|
||||
assert_eq!(scalar(&entities[0], 50), 0.0);
|
||||
assert_eq!(scalar(&entities[0], 51), 90.0);
|
||||
}
|
||||
"LWPOLYLINE" => {
|
||||
let (vertices, closed) = raw_polyline(&entities[0]);
|
||||
assert!(!closed && vertices.len() >= 2);
|
||||
assert_eq!(vertices[0].at, arc.start());
|
||||
assert_eq!(vertices.last().unwrap().at, arc.end());
|
||||
assert_eq!(vertices.last().unwrap().bulge, 0.0);
|
||||
let mut sweep = 0.0;
|
||||
for edge in vertices.windows(2) {
|
||||
// Translate BEFORE reconstructing. At 2^52, adding a center correction in
|
||||
// world coordinates can erase it and make a wrong circle appear valid.
|
||||
let (a, b, bulge) = (edge[0].at - center, edge[1].at - center, edge[0].bulge);
|
||||
assert!(bulge > 0.0 && bulge <= 1.0);
|
||||
let chord = b - a;
|
||||
let square = bulge * bulge;
|
||||
let recovered_center = (a + b) * 0.5 + chord.perp() * ((1.0 - square) / (4.0 * bulge));
|
||||
let recovered_radius = chord.len() * (1.0 + square) / (4.0 * bulge);
|
||||
// Small local arithmetic gets an epsilon-scale allowance, never an ULP
|
||||
// allowance based on the huge translation (which could hide 0.25 mm loss).
|
||||
let support_epsilon = 64.0 * f64::EPSILON * radius;
|
||||
assert!(recovered_center.len() <= support_epsilon, "local center {recovered_center:?}");
|
||||
assert!((recovered_radius - radius).abs() <= support_epsilon, "radius {recovered_radius}, expected {radius}");
|
||||
sweep += 4.0 * bulge.atan();
|
||||
}
|
||||
number_near(sweep, FRAC_PI_2, 64.0 * f64::EPSILON);
|
||||
}
|
||||
kind => panic!("source arc support was lost or replaced: {kind}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_serialized_arc_span(shapes: &[DxfShape], arc: &Seg2, span_error_degrees: f64) {
|
||||
let Seg2::Arc { center, radius, sweep, .. } = arc else { panic!("arc fixture expected") };
|
||||
assert!(matches!(shapes, [DxfShape::Arc { .. }]), "a partial arc must not become a circle or disappear: {shapes:?}");
|
||||
let text = drawing(shapes);
|
||||
let entities = raw_entities(&text);
|
||||
assert_eq!(entities.len(), 1);
|
||||
assert_eq!(entities[0].0, "ARC");
|
||||
assert_eq!(scalar(&entities[0], 10), center.x);
|
||||
assert_eq!(scalar(&entities[0], 20), center.y);
|
||||
assert_eq!(scalar(&entities[0], 40), *radius);
|
||||
let (start, end) = (scalar(&entities[0], 50), scalar(&entities[0], 51));
|
||||
assert!(start.is_finite() && end.is_finite());
|
||||
// 360 and 0 are the same direction. Comparing just their stored numbers would
|
||||
// accept a degenerate ARC, even if they were distinct before serialization.
|
||||
let serialized_sweep = (end - start).rem_euclid(360.0);
|
||||
assert!(serialized_sweep > 0.0 && serialized_sweep < 360.0, "collapsed serialized ARC: {start} -> {end}");
|
||||
number_near(serialized_sweep, sweep.abs().to_degrees(), span_error_degrees);
|
||||
}
|
||||
|
||||
fn preserved_arc_or_contextual_refusal(arc: Seg2, style: DxfPathStyle) {
|
||||
let Seg2::Arc { sweep, .. } = arc else { panic!("arc fixture expected") };
|
||||
match path_shapes(DxfPath::Open(&[arc]), style) {
|
||||
// A future precise writer may preserve these spans. Allow the rounding of unit
|
||||
// conversion/subtraction, not an absolute tolerance that can swallow a tiny arc.
|
||||
Ok(shapes) => assert_serialized_arc_span(&shapes, &arc, 2.0 * f64::EPSILON * sweep.abs().to_degrees()),
|
||||
Err(IoError::Invalid(message)) => {
|
||||
assert!(message.contains("piece 1 of 1"), "{message}");
|
||||
assert!(message.contains("arc"), "{message}");
|
||||
}
|
||||
other => panic!("unexpected arc failure: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_zero_crossing_arcs_keep_their_span_or_refuse_with_piece_context() {
|
||||
for style in [DxfPathStyle::Exact, DxfPathStyle::Arcs { tolerance_mm: 0.01 }] {
|
||||
for (start, sweep) in [(-1e-16, 1e-16), (0.0, -1e-16)] {
|
||||
preserved_arc_or_contextual_refusal(Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start, sweep }, style);
|
||||
}
|
||||
// A small but ordinary arc remains supported. Two independently rounded degree
|
||||
// fields contribute at most 1e-12 degrees; subtraction adds at most a few ULPs.
|
||||
let writer_error = 1e-12 + 4.0 * f64::EPSILON * 360.0;
|
||||
for sweep in [-1e-6, 1e-6] {
|
||||
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep };
|
||||
let shapes = path_shapes(DxfPath::Open(&[arc]), style).unwrap();
|
||||
assert_serialized_arc_span(&shapes, &arc, writer_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_arc_one_ulp_short_of_a_turn_cannot_serialize_as_a_circle_or_zero_span() {
|
||||
let almost_turn = f64::from_bits(TAU.to_bits() - 1);
|
||||
assert!(almost_turn < TAU);
|
||||
for style in [DxfPathStyle::Exact, DxfPathStyle::Arcs { tolerance_mm: 0.01 }] {
|
||||
for sweep in [almost_turn, -almost_turn] {
|
||||
preserved_arc_or_contextual_refusal(Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep }, style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_ellipse_starts_preserve_the_legacy_first_quarter_parameterization() {
|
||||
let vertices = [Vec2::new(15.0, 0.0), Vec2::new(0.0, 8.0), Vec2::new(-15.0, 0.0), Vec2::new(0.0, -8.0)];
|
||||
// The original full-loop writer keeps the first conic's frame instead of canonicalizing
|
||||
// its major-axis sign. For these four quarters the independent legacy signs are +--+.
|
||||
// These constants come from the analytic ellipse and legacy frame/decimal conventions;
|
||||
// no converter or recognizer is used to calculate expected values.
|
||||
let major_x = [15.0, -15.0, -15.0, 15.0];
|
||||
let serialized_major_x = ["15.0", "-15.0", "-15.0", "15.0"];
|
||||
for (first, (expected_major_x, expected_serialized)) in major_x.into_iter().zip(serialized_major_x).enumerate() {
|
||||
let path = Loop2 {
|
||||
segs: (0..4)
|
||||
.map(|offset| {
|
||||
let (a, b) = (vertices[(first + offset) % 4], vertices[(first + offset + 1) % 4]);
|
||||
Seg2::Conic { a, apex: a + b, b, w: FRAC_1_SQRT_2 }
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let shapes = path_shapes(DxfPath::Closed(&path), DxfPathStyle::Exact).unwrap();
|
||||
let [DxfShape::Ellipse { c, major, ratio, t0, t1 }] = shapes.as_slice() else { panic!("{shapes:?}") };
|
||||
point_near(*c, Vec2::ZERO);
|
||||
point_near(*major, Vec2::new(expected_major_x, 0.0));
|
||||
number_near(*ratio, 8.0 / 15.0, 1e-12);
|
||||
assert_eq!((*t0, *t1), (0.0, TAU));
|
||||
let text = drawing(&shapes);
|
||||
let entities = raw_entities(&text);
|
||||
assert_eq!(entities.len(), 1);
|
||||
assert_eq!(entities[0].0, "ELLIPSE");
|
||||
let group = |code| entities[0].1.iter().filter(|(c, _)| *c == code).map(|(_, value)| *value).collect::<Vec<_>>();
|
||||
assert_eq!(group(11), vec![expected_serialized]);
|
||||
assert_eq!(group(21), vec!["0.0"]);
|
||||
assert_eq!(group(40), vec!["0.533333333333"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_zero_length_line_in_a_closed_path_remains_a_writable_open_polyline() {
|
||||
let at = Vec2::new(2.0, -3.0);
|
||||
let path = Loop2 { segs: vec![Seg2::Line { a: at, b: at }] };
|
||||
let exact = path_shapes(DxfPath::Closed(&path), DxfPathStyle::Exact).unwrap();
|
||||
assert_eq!(exact, vec![DxfShape::Line(at, at)]);
|
||||
let exact_text = drawing(&exact);
|
||||
let exact_entities = raw_entities(&exact_text);
|
||||
assert_eq!(exact_entities.len(), 1);
|
||||
assert_eq!(exact_entities[0].0, "LINE");
|
||||
for (code, expected) in [(10, at.x), (20, at.y), (11, at.x), (21, at.y)] {
|
||||
assert_eq!(scalar(&exact_entities[0], code), expected);
|
||||
}
|
||||
|
||||
let shapes = path_shapes(DxfPath::Closed(&path), DxfPathStyle::Polyline).unwrap();
|
||||
let expected = [DxfVertex { at, bulge: 0.0 }; 2];
|
||||
assert_eq!(one_polyline(&shapes, false), expected.as_slice());
|
||||
// Inspect the emitted entity: the current reader intentionally skips zero-length
|
||||
// polyline edges, so it cannot establish that this authored piece was written.
|
||||
let text = drawing(&shapes);
|
||||
let entities = raw_entities(&text);
|
||||
assert_eq!(entities.len(), 1);
|
||||
let (vertices, closed) = raw_polyline(&entities[0]);
|
||||
assert_eq!(vertices.as_slice(), expected.as_slice());
|
||||
assert!(!closed);
|
||||
assert_eq!(scalar(&entities[0], 70), 128.0);
|
||||
}
|
||||
+3902
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
{
|
||||
"scope": "COMBINED_ELEVEN_SOURCE_ALTERNATIVE_NINE_PREREQUISITES_PLUS_IMMUTABLE_TWO",
|
||||
"commits": {
|
||||
"frozen_main": "8e9e2700275a363ac63ee5178aca7e33fac06722",
|
||||
"original_merge_base": "0312808b92d4523894d2af57c99401c65886e24d",
|
||||
"original_X1_PR_head": "3fb2eef92dce4bafe9cfb3f68a8334985d4d83ee",
|
||||
"reviewed_caller_successor_archive": "28d90fae50dc9b060434a393305b0c93dff0444a"
|
||||
},
|
||||
"immutable_parent": {
|
||||
"archive_path": "proposals/drawings/2026-10-31/x1-main-reconciliation/README.md",
|
||||
"source_packet_sha256": "a2b7feb21197eaa0b3d2b3aeadbacb1ecc37c1486da8c3d34bc2d5873e127fb1",
|
||||
"paths": [
|
||||
"crates/engine/src/cmd/sheet.rs",
|
||||
"crates/engine/src/cmd/sheet_dxf_tests.rs"
|
||||
],
|
||||
"candidate_bytes_unchanged": true
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"repository_path": "crates/engine/src/cmd/sheet.rs",
|
||||
"lineage": "immutable-two-file-parent",
|
||||
"before": {
|
||||
"file": "before/crates/engine/src/cmd/sheet.rs",
|
||||
"existed": true,
|
||||
"placeholder_only": false,
|
||||
"bytes": 58207,
|
||||
"sha256": "0ded6d11902e5da7b5923ca0aa46bf4a3dc1af7d6fbae344deb63f65ca4c33b5"
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/engine/src/cmd/sheet.rs",
|
||||
"existed": true,
|
||||
"bytes": 58421,
|
||||
"sha256": "aa8a67b736a553bec91072d763567d26252212485d1d3806bed93fcc909483dc"
|
||||
},
|
||||
"delta_status": "modified"
|
||||
},
|
||||
{
|
||||
"repository_path": "crates/engine/src/cmd/sheet_dxf_tests.rs",
|
||||
"lineage": "immutable-two-file-parent",
|
||||
"before": {
|
||||
"file": "before/crates/engine/src/cmd/sheet_dxf_tests.rs",
|
||||
"existed": true,
|
||||
"placeholder_only": false,
|
||||
"bytes": 33759,
|
||||
"sha256": "ead1645edffec671b6bc65e8e5b69d32c2c77b4cc21b1550d395a2d167582895"
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/engine/src/cmd/sheet_dxf_tests.rs",
|
||||
"existed": true,
|
||||
"bytes": 59739,
|
||||
"sha256": "480ee7b2dc1a6879fb21d4a1652790d7c6d80ffc5ecf337af43887b176cc367d"
|
||||
},
|
||||
"delta_status": "modified"
|
||||
},
|
||||
{
|
||||
"repository_path": "crates/doc/src/sheet_convert.rs",
|
||||
"lineage": "nine-original-X1-prerequisites",
|
||||
"before": {
|
||||
"file": "before/crates/doc/src/sheet_convert.rs",
|
||||
"existed": true,
|
||||
"placeholder_only": false,
|
||||
"bytes": 57398,
|
||||
"sha256": "03e5b17120cf82238e692b7d91579228266836efd6d703c7c33feeb85d675923"
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/doc/src/sheet_convert.rs",
|
||||
"existed": true,
|
||||
"bytes": 61522,
|
||||
"sha256": "8203bb8516af1c454b85cd47dca7584beedbfd00a5e47f43691d5580bc17f2af"
|
||||
},
|
||||
"delta_status": "modified"
|
||||
},
|
||||
{
|
||||
"repository_path": "crates/engine/Cargo.toml",
|
||||
"lineage": "nine-original-X1-prerequisites",
|
||||
"before": {
|
||||
"file": "before/crates/engine/Cargo.toml",
|
||||
"existed": true,
|
||||
"placeholder_only": false,
|
||||
"bytes": 1901,
|
||||
"sha256": "5fc857d82e98a1530e890151ae3bb374796c7192e7e5e375ef8db38ffb3b1229"
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/engine/Cargo.toml",
|
||||
"existed": true,
|
||||
"bytes": 1929,
|
||||
"sha256": "7da35c47e10974ca945da35241d47a2061656308c7f668730c142ae38cff4376"
|
||||
},
|
||||
"delta_status": "modified"
|
||||
},
|
||||
{
|
||||
"repository_path": "crates/geom/src/lib.rs",
|
||||
"lineage": "nine-original-X1-prerequisites",
|
||||
"before": {
|
||||
"file": "before/crates/geom/src/lib.rs",
|
||||
"existed": true,
|
||||
"placeholder_only": false,
|
||||
"bytes": 2092,
|
||||
"sha256": "a4040eea4c35d974a98686f9698936877c6bb3bc8d892c618fd25a8db97a934f"
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/geom/src/lib.rs",
|
||||
"existed": true,
|
||||
"bytes": 2119,
|
||||
"sha256": "ad676478063b9c70cbb255b03a22aa4169ce5c9c17233f5ec02ad1cbd72f20b6"
|
||||
},
|
||||
"delta_status": "modified"
|
||||
},
|
||||
{
|
||||
"repository_path": "crates/geom/src/profile.rs",
|
||||
"lineage": "nine-original-X1-prerequisites",
|
||||
"before": {
|
||||
"file": "before/crates/geom/src/profile.rs",
|
||||
"existed": true,
|
||||
"placeholder_only": false,
|
||||
"bytes": 53676,
|
||||
"sha256": "e8284e95b92df84d1abf9038ea978dc3039c0707162824377bc5cec5f5275820"
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/geom/src/profile.rs",
|
||||
"existed": true,
|
||||
"bytes": 81773,
|
||||
"sha256": "852b05c58553f6194fb406796191aa6dd1bd7d768e25dd5c8ed11105e4a487dd"
|
||||
},
|
||||
"delta_status": "modified"
|
||||
},
|
||||
{
|
||||
"repository_path": "crates/geom/src/tests.rs",
|
||||
"lineage": "nine-original-X1-prerequisites",
|
||||
"before": {
|
||||
"file": "before/crates/geom/src/tests.rs",
|
||||
"existed": true,
|
||||
"placeholder_only": false,
|
||||
"bytes": 48678,
|
||||
"sha256": "546e13b97e2677ee7727137ecbfc92e85dae8dab49a8baacf7067be4a194c221"
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/geom/src/tests.rs",
|
||||
"existed": true,
|
||||
"bytes": 79042,
|
||||
"sha256": "4575115c2f6b0f89338b5880f14d5227af676bd79ba54f85b9dccbf08171c9c4"
|
||||
},
|
||||
"delta_status": "modified"
|
||||
},
|
||||
{
|
||||
"repository_path": "crates/io/src/lib.rs",
|
||||
"lineage": "nine-original-X1-prerequisites",
|
||||
"before": {
|
||||
"file": "before/crates/io/src/lib.rs",
|
||||
"existed": true,
|
||||
"placeholder_only": false,
|
||||
"bytes": 66940,
|
||||
"sha256": "55a57192bb2c19b295bc853e8f3190da21749560ac130c8d71aaa8ef673ce762"
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/io/src/lib.rs",
|
||||
"existed": true,
|
||||
"bytes": 66963,
|
||||
"sha256": "ea63c2c4de5d33797b1d8b43f8d5fe64bbf8c2a796eca6236a42c458cb3f1e59"
|
||||
},
|
||||
"delta_status": "modified"
|
||||
},
|
||||
{
|
||||
"repository_path": "crates/io/src/dxf_out.rs",
|
||||
"lineage": "nine-original-X1-prerequisites",
|
||||
"before": {
|
||||
"file": "before/crates/io/src/dxf_out.rs",
|
||||
"existed": true,
|
||||
"placeholder_only": false,
|
||||
"bytes": 54374,
|
||||
"sha256": "a07117e119656e226a231663a4eb79fa7e26f496d52e27cf2b3e7fdeb8ed6cad"
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/io/src/dxf_out.rs",
|
||||
"existed": true,
|
||||
"bytes": 56773,
|
||||
"sha256": "f73e0a5e8dbc76b227868dd0b8a6e5687f5e14c90bd6bcd4306e2edc77ff6563"
|
||||
},
|
||||
"delta_status": "modified"
|
||||
},
|
||||
{
|
||||
"repository_path": "crates/io/src/dxf_out/path.rs",
|
||||
"lineage": "nine-original-X1-prerequisites",
|
||||
"before": {
|
||||
"file": "before/crates/io/src/dxf_out/path.rs",
|
||||
"existed": false,
|
||||
"placeholder_only": true,
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/io/src/dxf_out/path.rs",
|
||||
"existed": true,
|
||||
"bytes": 31971,
|
||||
"sha256": "20ceae95bbc0ea7920dd302bcd3d6e782c104abf3c8fa255fe0205a224a7fca1"
|
||||
},
|
||||
"delta_status": "added-from-/dev/null"
|
||||
},
|
||||
{
|
||||
"repository_path": "crates/io/tests/dxf_paths.rs",
|
||||
"lineage": "nine-original-X1-prerequisites",
|
||||
"before": {
|
||||
"file": "before/crates/io/tests/dxf_paths.rs",
|
||||
"existed": false,
|
||||
"placeholder_only": true,
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/io/tests/dxf_paths.rs",
|
||||
"existed": true,
|
||||
"bytes": 41983,
|
||||
"sha256": "4746f852d3bce3985c1efba23d1fb066b0db6efa53474a7e6c0fc4218c227cb6"
|
||||
},
|
||||
"delta_status": "added-from-/dev/null"
|
||||
}
|
||||
],
|
||||
"sole_delta": {
|
||||
"file": "combined-source.patch",
|
||||
"bytes": 190980,
|
||||
"sha256": "5642cc7d96ae1e865f9d6fe5f0fb6e462901bafbc4ba57b2015c47a56e7780b2",
|
||||
"apply": "Apply once to exact frozen main before files. This combined alternative already contains the two-file parent; never apply it sequentially after the parent delta or add parent deltas again."
|
||||
},
|
||||
"absence_semantics": "Two zero-byte before snapshots are archive inspection placeholders only, with existed=false/placeholder_only=true. Both source paths were absent at frozen main. The sole patch creates them from /dev/null; do not install before snapshots as an existing source tree.",
|
||||
"lock": {
|
||||
"path": "Cargo.lock",
|
||||
"included_as_candidate": false,
|
||||
"included_in_delta": false,
|
||||
"resolved": false,
|
||||
"remaining": "Actual resolver execution, lock delta inspection and source pinning are required for the original workspace sha2 dev-dependency before locked build/readiness claims."
|
||||
},
|
||||
"preservation": {
|
||||
"original_X1_added_test_bodies_literal_in_nine": 47,
|
||||
"strict_recognition_functions_literal": 3,
|
||||
"common_test_bodies_retaining_actual_frozen_main": 5,
|
||||
"source_declarations_in_nine": 117,
|
||||
"immutable_parent_declarations": 25,
|
||||
"combined_source_declarations": 142,
|
||||
"original_parent_pin_tuples": 53,
|
||||
"new_compatibility_contract": false
|
||||
},
|
||||
"owner_policy_references": [
|
||||
"https://github.com/tangency-labs/solidcraft/pull/6184#issuecomment-6522562149",
|
||||
"https://github.com/tangency-labs/solidcraft/pull/6184#issuecomment-6522733382"
|
||||
],
|
||||
"independent_source_review_sha256": "cda57870419f5017193cd0841adcd8bf28cf383a8e7b23f19bcffc36c72f48f8",
|
||||
"qualification": "SOURCE_ONLY_UNFORMATTED_UNCOMPILED_UNRUN_LOCK_UNRESOLVED_API_CONSUMERS_UNQUALIFIED_NOT_READY",
|
||||
"hash_kinds": "Values under commits are Git commit identifiers; SHA256 fields identify exact source/archive bytes and are not commits or runtime results."
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# X1 two-file reconciliation on frozen main
|
||||
|
||||
**Source-only, unformatted, uncompiled and unrun. Not standalone main, a whole-PR rebase, resolved mergeability or readiness.**
|
||||
|
||||
This archive composes the two owned X1 caller/test files onto frozen main `8e9e2700275a363ac63ee5178aca7e33fac06722`. The original X1 head is `3fb2eef92dce4bafe9cfb3f68a8334985d4d83ee` over merge base `0312808b92d4523894d2af57c99401c65886e24d`; the exact existing caller successor is archive `28d90fae50dc9b060434a393305b0c93dff0444a`. No new compatibility choice or generic tolerance is introduced.
|
||||
|
||||
## Payload and application
|
||||
|
||||
[Portable bindings](source-bindings.json) list both exact before/candidate snapshots and RAW deltas. Apply each delta once to its matching exact before file. The paths are disjoint; snapshots are for inspection and replay, not additional patches. Only `crates/engine/src/cmd/sheet.rs` and `crates/engine/src/cmd/sheet_dxf_tests.rs` are supplied. Source SHA256 values identify bytes; the four commit identifiers above identify Git history.
|
||||
|
||||
Independent RAW replay reconstructed the old-head-plus-successor source, transplanted six unique owned hunks and restored the originals with inverse deltas. Twelve foreign sheet-code hunks commute over the successor to the exact candidate. The foreign main payload is preserved. This integrity evidence is not runtime validation.
|
||||
|
||||
## Preserved behavior and acceptance
|
||||
|
||||
The shared path converter still validates style, every original piece and its 200000-piece cap before the flat-pattern-only CIRCLE merge. The historical caller retains the first support, strictly-less-than `1e-9` mm center/radius differences, common sweep sign and strictly-less-than `1e-9` rad difference from one turn. Generic recognition stays strict; JSON retains the historical caller helper. Layers/order/bends/options/writer behavior is outside the new compatibility scope.
|
||||
|
||||
All 53 original expected name/length/SHA256 tuples remain literal. The full 18-test original-head prefix and exact seven-case successor appendix are retained: 25 declarations, all unrun for this candidate. The pre-existing OUTLINE assertion's migration to `path_shapes` is already in original X1, not a new expectation adjustment in this reconciliation. The original trimmed-cap five-entity acceptance and producer turn helper remain required; their removal is not part of this unit.
|
||||
|
||||
## Required integration remains separate
|
||||
|
||||
The captured legitimate merge-base-to-X1 comparison lists 12 paths. The historical 459-path divergent comparison was not imported. Only two paths are reconciled here; the other ten original X1 integration/acceptance paths still require preservation-aware reconciliation:
|
||||
|
||||
- `Cargo.lock`
|
||||
- `crates/doc/src/sheet_convert.rs`
|
||||
- `crates/engine/Cargo.toml`
|
||||
- `crates/geom/src/lib.rs`
|
||||
- `crates/geom/src/profile.rs`
|
||||
- `crates/geom/src/tests.rs`
|
||||
- `crates/io/src/dxf_out.rs`
|
||||
- `crates/io/src/dxf_out/path.rs`
|
||||
- `crates/io/src/lib.rs`
|
||||
- `crates/io/tests/dxf_paths.rs`
|
||||
|
||||
Frozen main lacks the required X1 path exports/converter, original recognizers, engine test dependency and producer turn helper at the referenced receivers. No prerequisite is silently installed, substituted or removed by this archive. Do not apply these two snapshots alone and call the project buildable. Cam's view receiver, X2/X3/X4, paper/picture/multipage work and other contributors' windows remain outside this source unit.
|
||||
|
||||
## Policy references and status
|
||||
|
||||
- [Caller decision](https://github.com/tangency-labs/solidcraft/pull/6184#issuecomment-6522562149).
|
||||
- [Existing ACK/source-review reference](https://github.com/tangency-labs/solidcraft/pull/6184#issuecomment-6522733382).
|
||||
- [Original plan](https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6234779815).
|
||||
|
||||
The caller policy is already agreed; this archive reuses the reviewed successor and introduces no new owner choice. Tracker text is separate and is not included or posted here. Existing source-bound historical results do not transfer. A later coherent candidate must run the original seven regressions, seven caller cases and 53 pins, affected suites, strict checks, wasm and full repository CI, with actual observable-output evidence. No compiler/test/oracle, GUI/GPU, full-CI, merge or readiness success follows from source replay.
|
||||
|
||||
Original X1 source is credited to the 0xlillith team in PR6184; CamWight supplied the existing caller-policy reference, and Worker1 prepared this exact two-path composition. Other SolidCraft contributors' frozen-main source remains preserved. Existing repository MIT OR Apache-2.0 licensing and attribution remain intact; this archive does not relicense prior work or bundle external CAD code/assets.
|
||||
+1186
File diff suppressed because it is too large
Load Diff
+616
@@ -0,0 +1,616 @@
|
||||
//! The flat pattern's DXF keeps ellipses and free-form curves as ELLIPSE and SPLINE entities, and
|
||||
//! a laser-cutting shop can use it as it is: layers by role, bend notes, letters cut or etched,
|
||||
//! one file per sheet body.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::{Seg2, Vec2};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A 100 × 60 base flange (the default 2.5 mm rule) with `curves` drawn in a sketch and cut through it.
|
||||
fn plate_with(curves: &[(&str, Value)]) -> Session {
|
||||
plate_in(Session::default(), curves)
|
||||
}
|
||||
|
||||
/// A 100 × 60 base flange in `s` (of its rule), with `curves` drawn in a sketch and cut through it.
|
||||
fn plate_in(mut s: Session, curves: &[(&str, Value)]) -> Session {
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
if curves.is_empty() {
|
||||
return s;
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
|
||||
for (command, params) in curves {
|
||||
run(&mut s, command, params.clone());
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A session whose active rule is 2 mm sheet with an inner bend radius of 1 mm.
|
||||
fn sheet_of_2mm() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Sheet2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A 100 × 60 tray of 2 mm sheet with `curves` cut through its base: three 20 mm walls bend up and
|
||||
/// one bends down.
|
||||
fn tray_with(curves: &[(&str, Value)]) -> Session {
|
||||
let mut s = plate_in(sheet_of_2mm(), curves);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2], [100, 30, 2], [50, 60, 2]], "height": 20}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[0, 30, 2]], "height": 20, "flip": true}));
|
||||
s
|
||||
}
|
||||
|
||||
/// What a tray is marked with: a Ø5 hole, a 20 × 5 slot and the label "AB-12", 10 mm high at (20, 28).
|
||||
fn marks() -> Vec<(&'static str, Value)> {
|
||||
vec![
|
||||
("sketch.circle.center", json!({"center": [15, 15], "diameter": 5})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [70, 40], "p1": [90, 45]})),
|
||||
("sketch.text", json!({"text": "AB-12", "at": [20, 28], "height": 10})),
|
||||
]
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, params: Value) -> String {
|
||||
run(s, "sheet.export_dxf", params)["dxf"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// The entities of a DXF text: type and group codes.
|
||||
type Entity = (String, Vec<(i32, String)>);
|
||||
|
||||
fn entities(dxf: &str) -> Vec<Entity> {
|
||||
let lines: Vec<&str> = dxf.lines().collect();
|
||||
let pairs: Vec<(i32, &str)> = lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim()))).collect();
|
||||
let start = pairs.iter().position(|p| *p == (2, "ENTITIES")).unwrap_or(0);
|
||||
let mut out: Vec<Entity> = Vec::new();
|
||||
for (code, value) in pairs.iter().skip(start + 1) {
|
||||
match (*code, *value) {
|
||||
(0, "ENDSEC") => break,
|
||||
(0, kind) => out.push((kind.to_string(), Vec::new())),
|
||||
_ => {
|
||||
if let Some(last) = out.last_mut() {
|
||||
last.1.push((*code, value.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn values(e: &Entity, code: i32) -> Vec<f64> {
|
||||
e.1.iter().filter(|(c, _)| *c == code).filter_map(|(_, v)| v.parse().ok()).collect()
|
||||
}
|
||||
|
||||
fn count(es: &[Entity], kind: &str) -> usize {
|
||||
es.iter().filter(|e| e.0 == kind).count()
|
||||
}
|
||||
|
||||
fn point(e: &Entity, code: i32) -> Vec2 {
|
||||
Vec2::new(values(e, code).first().copied().unwrap_or(f64::NAN), values(e, code + 10).first().copied().unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
/// All loops of the flat pattern (outlines, cut-outs, islands).
|
||||
fn loops(s: &Session) -> Vec<solidcraft_geom::Loop2> {
|
||||
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
|
||||
regions.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.clone())).collect()
|
||||
}
|
||||
|
||||
/// The profiles of the DXF read into a new sketch: their areas, largest first.
|
||||
fn reimported_areas(dxf: &str) -> Vec<f64> {
|
||||
let mut s = Session::default();
|
||||
let id = run(&mut s, "sketch.insert_dxf", json!({"text": dxf, "name": "Back"}))["sketch"].as_u64().unwrap_or(0);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mut areas: Vec<f64> = s.model.state().sketch(id).map(|ss| ss.profiles.iter().map(|p| p.area).collect()).unwrap_or_default();
|
||||
areas.sort_by(|a, b| b.total_cmp(a));
|
||||
areas
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_elliptic_cut_out_is_one_ellipse() {
|
||||
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
|
||||
let dxf = export(&mut s, json!({}));
|
||||
let es = entities(&dxf);
|
||||
// The four lines of the outline and one ELLIPSE: no other entity.
|
||||
assert_eq!((count(&es, "LINE"), count(&es, "ELLIPSE"), es.len()), (4, 1, 5), "{:?}", es.iter().map(|e| &e.0).collect::<Vec<_>>());
|
||||
// By hand: centre (40, 30), the major semi-axis 15 along x (to (55, 30) or (25, 30)), the
|
||||
// minor one 8, so the ratio is 8/15; the whole turn runs from parameter 0 to 2π.
|
||||
let e = es.iter().find(|e| e.0 == "ELLIPSE").unwrap();
|
||||
let (c, major) = (point(e, 10), point(e, 11));
|
||||
assert!(c.dist(Vec2::new(40.0, 30.0)) < 1e-9 && (major.x.abs() - 15.0).abs() < 1e-9 && major.y.abs() < 1e-9, "{c:?} {major:?}");
|
||||
assert!((values(e, 40)[0] - 8.0 / 15.0).abs() < 1e-9 && values(e, 41)[0] == 0.0 && (values(e, 42)[0] - TAU).abs() < 1e-9);
|
||||
assert_eq!(values(e, 230), [1.0]);
|
||||
// Our reader gets the same ellipse back, and the same plate: 6000 mm² less π·15·8.
|
||||
let read = solidcraft_io::read_dxf(&dxf).unwrap();
|
||||
assert!(read.iter().any(|g| matches!(g, solidcraft_io::Geom2::Ellipse { c, major, minor }
|
||||
if c.dist(Vec2::new(40.0, 30.0)) < 1e-9 && (major.dist(*c) - 15.0).abs() < 1e-9 && (minor - 8.0).abs() < 1e-9)));
|
||||
let areas = reimported_areas(&dxf);
|
||||
assert!(areas.len() == 2 && (areas[0] - (6000.0 - PI * 120.0)).abs() < 1e-6 && (areas[1] - PI * 120.0).abs() < 1e-6, "{areas:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spline_cut_out_is_the_splines_it_was() {
|
||||
let points = json!([[20, 15], [40, 45], [65, 40], [85, 20]]);
|
||||
let mut s = plate_with(&[("sketch.spline.fit_point", json!({"points": points})), ("sketch.line", json!({"points": [[85, 20], [20, 15]]}))]);
|
||||
let dxf = export(&mut s, json!({}));
|
||||
let es = entities(&dxf);
|
||||
// The cut-out's segments of the flat pattern, one entity each: its cubic pieces are SPLINEs
|
||||
// of degree 3 over the same four control points, its line a LINE.
|
||||
let hole: Vec<Seg2> = loops(&s).get(1).map(|l| l.segs.clone()).unwrap_or_default();
|
||||
let cubics: Vec<[Vec2; 4]> =
|
||||
hole.iter().filter_map(|g| if let Seg2::Cubic { p0, p1, p2, p3 } = g { Some([*p0, *p1, *p2, *p3]) } else { None }).collect();
|
||||
assert!(!cubics.is_empty() && cubics.len() + 1 == hole.len(), "{hole:?}");
|
||||
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (5, cubics.len(), 5 + cubics.len()));
|
||||
for (spline, want) in es.iter().filter(|e| e.0 == "SPLINE").zip(&cubics) {
|
||||
assert_eq!((values(spline, 70), values(spline, 71), values(spline, 72), values(spline, 73)), (vec![8.0], vec![3.0], vec![8.0], vec![4.0]));
|
||||
assert_eq!(values(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]);
|
||||
let (xs, ys) = (values(spline, 10), values(spline, 20));
|
||||
assert!(want.iter().enumerate().all(|(i, p)| (xs[i] - p.x).abs() < 1e-9 && (ys[i] - p.y).abs() < 1e-9), "{xs:?} {ys:?} {want:?}");
|
||||
}
|
||||
// The same plate comes back.
|
||||
let areas = reimported_areas(&dxf);
|
||||
let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default()[0].area();
|
||||
assert!((areas[0] - plate).abs() < 1e-6, "{areas:?} {plate}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_g4_blend_cut_out_is_one_spline_of_degree_nine_and_closes() {
|
||||
// Three sides of a 20 × 20 square, closed on the right by a G4 blend from (40, 20) to (40, 40):
|
||||
// one Bézier of degree 9 over ten control points (solidcraft#5269).
|
||||
let mut s = plate_with(&[]);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
|
||||
let mut line =
|
||||
|a: [i32; 2], b: [i32; 2]| run(&mut s, "sketch.line", json!({"points": [a, b]}))["curves"][0].as_str().unwrap_or_default().to_string();
|
||||
let (bottom, top) = (line([20, 20], [40, 20]), line([40, 40], [20, 40]));
|
||||
line([20, 40], [20, 20]);
|
||||
run(&mut s, "sketch.blend_curve", json!({"a": format!("{bottom}.end"), "b": format!("{top}.start"), "continuity": "g4"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
let hole: Vec<Seg2> = loops(&s).get(1).map(|l| l.segs.clone()).unwrap_or_default();
|
||||
let blend: Vec<Vec2> = hole.iter().find_map(|g| if let Seg2::Bezier { n: 9, p } = g { Some(p[..10].to_vec()) } else { None }).unwrap_or_default();
|
||||
assert_eq!((blend.len(), hole.len()), (10, 4), "{hole:?}");
|
||||
|
||||
// One SPLINE of degree 9 with all ten control points: its ends are the blend's own.
|
||||
let dxf = export(&mut s, json!({}));
|
||||
let es = entities(&dxf);
|
||||
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (7, 1, 8));
|
||||
let spline = es.iter().find(|e| e.0 == "SPLINE").unwrap();
|
||||
assert_eq!((values(spline, 70), values(spline, 71), values(spline, 72), values(spline, 73)), (vec![8.0], vec![9.0], vec![20.0], vec![10.0]));
|
||||
let ctrl: Vec<Vec2> = values(spline, 10).into_iter().zip(values(spline, 20)).map(|(x, y)| Vec2::new(x, y)).collect();
|
||||
assert!(ctrl.len() == 10 && ctrl.iter().zip(&blend).all(|(a, b)| a.dist(*b) < 1e-9), "{ctrl:?} {blend:?}");
|
||||
let ends = [ctrl[0], ctrl[9]];
|
||||
assert!(ends.contains(&Vec2::new(40.0, 20.0)) && ends.contains(&Vec2::new(40.0, 40.0)), "{ends:?}");
|
||||
|
||||
// The cut-out closes, so the same plate and the same cut-out come back.
|
||||
let areas = reimported_areas(&dxf);
|
||||
let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default()[0].area();
|
||||
assert!(areas.len() == 2 && (areas[0] - plate).abs() < 1e-6 && (areas[1] - (6000.0 - plate)).abs() < 1e-6, "{areas:?} {plate}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_splines_and_lines_not_chords() {
|
||||
let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]);
|
||||
let dxf = export(&mut s, json!({}));
|
||||
let es = entities(&dxf);
|
||||
// One entity for each segment of the flat pattern: a straight one a LINE, a glyph's
|
||||
// quadratic and cubic curves SPLINEs of degree 2 and 3.
|
||||
let segs: Vec<Seg2> = loops(&s).iter().flat_map(|l| l.segs.clone()).collect();
|
||||
let n = |f: &dyn Fn(&Seg2) -> bool| segs.iter().filter(|g| f(g)).count();
|
||||
let (lines, quadratics, cubics) = (
|
||||
n(&|g| matches!(g, Seg2::Line { .. })),
|
||||
n(&|g| matches!(g, Seg2::Conic { w, .. } if (*w - 1.0).abs() < 1e-12)),
|
||||
n(&|g| matches!(g, Seg2::Cubic { .. })),
|
||||
);
|
||||
assert_eq!(lines + quadratics + cubics, segs.len(), "only lines and Béziers");
|
||||
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (lines, quadratics + cubics, segs.len()));
|
||||
let degree = |d: f64| es.iter().filter(|e| e.0 == "SPLINE" && values(e, 71) == [d]).count();
|
||||
assert_eq!((degree(2.0), degree(3.0)), (quadratics, cubics));
|
||||
// The plate with the letters cut out comes back from the file with its area (the largest region; the islands inside the
|
||||
// letters come in no fixed order).
|
||||
let areas = reimported_areas(&dxf);
|
||||
let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default().iter().map(|r| r.area()).fold(0.0, f64::max);
|
||||
assert!((areas[0] - plate).abs() < 1e-3, "{areas:?} {plate}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arcs_option_writes_only_lines_and_tangent_arcs_within_a_thousandth() {
|
||||
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
|
||||
let dxf = export(&mut s, json!({"curves": "arcs"}));
|
||||
let es = entities(&dxf);
|
||||
assert!(es.iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", es.iter().map(|e| &e.0).collect::<Vec<_>>());
|
||||
let arcs: Vec<(Vec2, f64, [Vec2; 2], Vec<Vec2>)> = es
|
||||
.iter()
|
||||
.filter(|e| e.0 == "ARC")
|
||||
.map(|e| {
|
||||
let (c, r, (a0, a1)) = (point(e, 10), values(e, 40)[0], (values(e, 50)[0].to_radians(), values(e, 51)[0].to_radians()));
|
||||
let sweep = (a1 - a0).rem_euclid(TAU);
|
||||
let samples = (0..=20).map(|k| c + Vec2::from_angle(a0 + sweep * f64::from(k) / 20.0) * r).collect::<Vec<Vec2>>();
|
||||
(c, r, [c + Vec2::from_angle(a0) * r, c + Vec2::from_angle(a1) * r], samples)
|
||||
})
|
||||
.collect();
|
||||
// Far fewer than the 336 chords of 0.001 mm.
|
||||
assert!(arcs.len() >= 8 && arcs.len() < 100, "{} arcs", arcs.len());
|
||||
// Distance to the ellipse ((x − 40)/15)² + ((y − 30)/8)² = 1, to first order |f| / |∇f|.
|
||||
let off = |p: Vec2| {
|
||||
let (x, y) = (p.x - 40.0, p.y - 30.0);
|
||||
let f = (x / 15.0).powi(2) + (y / 8.0).powi(2) - 1.0;
|
||||
f.abs() / Vec2::new(2.0 * x / 225.0, 2.0 * y / 64.0).len()
|
||||
};
|
||||
// (The joint of a biarc's two arcs lies within the tolerance of the curve, not on it.)
|
||||
for (_, _, ends, samples) in &arcs {
|
||||
let offs: Vec<f64> = samples.iter().map(|p| off(*p)).collect();
|
||||
assert!(offs.iter().all(|d| *d <= 1e-3 + 1e-6), "{ends:?}: {offs:?}");
|
||||
}
|
||||
// Tangent: at each joint the radii of the two arcs lie on one line.
|
||||
for (i, (c, r, ends, _)) in arcs.iter().enumerate() {
|
||||
for end in ends {
|
||||
let others: Vec<_> = arcs.iter().enumerate().filter(|(j, (_, _, e, _))| *j != i && e.iter().any(|q| q.dist(*end) < 1e-6)).collect();
|
||||
assert_eq!(others.len(), 1, "joint {end:?}");
|
||||
let (_, (c2, r2, _, _)) = others[0];
|
||||
assert!((*end - *c).cross(*end - *c2).abs() < 1e-6 * r * r2, "arcs are not tangent at {end:?}");
|
||||
}
|
||||
}
|
||||
// The plate, read back, is the exact one within the perimeter times 0.001 mm.
|
||||
let areas = reimported_areas(&dxf);
|
||||
assert!((areas[0] - (6000.0 - PI * 120.0)).abs() < 0.1, "{areas:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arcs_option_has_no_splines_for_text_and_the_curves_value_is_checked() {
|
||||
let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]);
|
||||
let es = entities(&export(&mut s, json!({"curves": "ARCS"})));
|
||||
assert!(es.iter().all(|e| e.0 == "LINE" || e.0 == "ARC") && count(&es, "ARC") > 0);
|
||||
let exact = entities(&export(&mut s, json!({"curves": "exact"})));
|
||||
assert!(count(&exact, "SPLINE") > 0 && count(&exact, "ARC") == 0);
|
||||
assert!(s.execute("sheet.export_dxf", &json!({"curves": "chords"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_lines_and_layers_are_still_there() {
|
||||
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
|
||||
let out = run(&mut s, "sheet.export_dxf", json!({}));
|
||||
let es = entities(out["dxf"].as_str().unwrap_or_default());
|
||||
let on = |layer: &str| es.iter().filter(|e| e.1.contains(&(8, layer.to_string()))).count();
|
||||
assert_eq!(on("BEND_UP") + on("BEND_DOWN"), out["bends"].as_u64().unwrap_or(0) as usize);
|
||||
// The ellipse is a cut-out, on CUT; the outline is the plate's.
|
||||
assert!(on("BEND_UP") + on("BEND_DOWN") > 0 && count(&es, "ELLIPSE") == 1 && on("CUT") == 1);
|
||||
assert_eq!(on("OUTLINE") + on("CUT"), es.len() - on("BEND_UP") - on("BEND_DOWN"));
|
||||
}
|
||||
|
||||
/// The layer an entity is on.
|
||||
fn layer_of(e: &Entity) -> String {
|
||||
e.1.iter().find(|(c, _)| *c == 8).map(|(_, v)| v.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The text of an entity's group code.
|
||||
fn string(e: &Entity, code: i32) -> String {
|
||||
e.1.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What the entities of a layer are, without their handles and the layer: to compare layers of two files.
|
||||
fn on_layer(es: &[Entity], layer: &str) -> Vec<(String, Vec<(i32, String)>)> {
|
||||
es.iter().filter(|e| layer_of(e) == layer).map(|e| (e.0.clone(), e.1.iter().filter(|(c, _)| ![5, 8].contains(c)).cloned().collect())).collect()
|
||||
}
|
||||
|
||||
/// The LAYER table: name → (colour, linetype).
|
||||
fn layer_table(dxf: &str) -> std::collections::BTreeMap<String, (i64, String)> {
|
||||
let lines: Vec<&str> = dxf.lines().collect();
|
||||
let pairs: Vec<(i32, &str)> = lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim()))).collect();
|
||||
let start = pairs.iter().position(|p| *p == (2, "LAYER")).unwrap_or(0);
|
||||
let mut records: Vec<Vec<(i32, &str)>> = Vec::new();
|
||||
for p in pairs.iter().skip(start + 1) {
|
||||
match *p {
|
||||
(0, "ENDTAB") => break,
|
||||
(0, _) => records.push(Vec::new()),
|
||||
_ => {
|
||||
if let Some(record) = records.last_mut() {
|
||||
record.push(*p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
records
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let get = |code: i32| r.iter().find(|(c, _)| *c == code).map(|(_, v)| *v);
|
||||
Some((get(2)?.to_string(), (get(62)?.parse().ok()?, get(6)?.to_string())))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn vec2(v: &Value) -> Vec2 {
|
||||
Vec2::new(v[0].as_f64().unwrap_or(f64::NAN), v[1].as_f64().unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flat_pattern_is_on_layers_by_role_and_inner_contours_come_first() {
|
||||
let mut s = tray_with(&marks());
|
||||
let out = run(&mut s, "sheet.export_dxf", json!({}));
|
||||
let dxf = out["dxf"].as_str().unwrap_or_default();
|
||||
let es = entities(dxf);
|
||||
let on = |layer: &str| es.iter().filter(|e| layer_of(e) == layer).collect::<Vec<_>>();
|
||||
// OUTLINE: the tray's outer contour alone, one entity for each of its segments (lines and arcs).
|
||||
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
|
||||
let plate =
|
||||
regions.iter().max_by(|a, b| a.area().total_cmp(&b.area())).map(|r| r.outer.clone()).unwrap_or(solidcraft_geom::Loop2 { segs: Vec::new() });
|
||||
assert_eq!(on("OUTLINE").len(), solidcraft_io::loop_shapes(&plate).len());
|
||||
assert!(on("OUTLINE").iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", on("OUTLINE").iter().map(|e| &e.0).collect::<Vec<_>>());
|
||||
// CUT: what is cut out inside it that is no text: the Ø5 hole as one CIRCLE, the slot's four lines.
|
||||
let cut = on("CUT");
|
||||
assert_eq!((count_of(&cut, "CIRCLE"), count_of(&cut, "LINE"), cut.len()), (1, 4, 5), "{:?}", cut.iter().map(|e| &e.0).collect::<Vec<_>>());
|
||||
// TEXT: the letters of "AB-12" and the counters of A and B, all within the label's box (5 letters
|
||||
// of at most 10 mm from (20, 28)).
|
||||
let letters = on("TEXT");
|
||||
assert!(letters.len() > 20, "{} letter entities", letters.len());
|
||||
for e in &letters {
|
||||
let p = point(e, 10);
|
||||
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", e.0);
|
||||
}
|
||||
// The bend lines by direction: three walls up, one down.
|
||||
assert_eq!((on("BEND_UP").len(), on("BEND_DOWN").len(), out["bends"].as_u64()), (3, 1, Some(4)));
|
||||
// Inner contours are written before the outline, so a program that follows the file cuts inside-out.
|
||||
let last_inner = es.iter().rposition(|e| matches!(layer_of(e).as_str(), "CUT" | "TEXT"));
|
||||
let first_outline = es.iter().position(|e| layer_of(e) == "OUTLINE");
|
||||
let (Some(last_inner), Some(first_outline)) = (last_inner, first_outline) else { panic!("{last_inner:?} {first_outline:?}") };
|
||||
assert!(last_inner < first_outline, "an inner contour at {last_inner}, the outline from {first_outline}");
|
||||
// The layers, with the colours and linetypes the guide gives; nothing else is declared.
|
||||
let want = [
|
||||
("0", 7, "Continuous"),
|
||||
("OUTLINE", 7, "Continuous"),
|
||||
("CUT", 4, "Continuous"),
|
||||
("TEXT", 2, "Continuous"),
|
||||
("BEND_UP", 1, "DASHED"),
|
||||
("BEND_DOWN", 5, "DASHED"),
|
||||
];
|
||||
assert_eq!(layer_table(dxf), want.map(|(n, c, l)| (n.to_string(), (c, l.to_string()))).into_iter().collect());
|
||||
}
|
||||
|
||||
/// How many entities of `kind` there are among `list`.
|
||||
fn count_of(list: &[&Entity], kind: &str) -> usize {
|
||||
list.iter().filter(|e| e.0 == kind).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_can_be_etched_instead_of_cut() {
|
||||
let mut s = tray_with(&marks());
|
||||
let cut = entities(&export(&mut s, json!({})));
|
||||
let etched_dxf = export(&mut s, json!({"text": "etch"}));
|
||||
let etched = entities(&etched_dxf);
|
||||
// The same contours, on ETCH instead of TEXT: nothing of them is cut.
|
||||
assert!(!on_layer(&cut, "TEXT").is_empty());
|
||||
assert_eq!(on_layer(&etched, "ETCH"), on_layer(&cut, "TEXT"));
|
||||
assert!(on_layer(&etched, "TEXT").is_empty());
|
||||
// The hole and the slot are no text: they stay where they were, as do the outline and the bends.
|
||||
for layer in ["OUTLINE", "CUT", "BEND_UP", "BEND_DOWN"] {
|
||||
assert_eq!(on_layer(&etched, layer), on_layer(&cut, layer), "{layer}");
|
||||
}
|
||||
let layers = layer_table(&etched_dxf);
|
||||
assert_eq!(layers.get("ETCH"), Some(&(6, "Continuous".to_string())));
|
||||
assert!(!layers.contains_key("TEXT"));
|
||||
// `cut` is the default, and a value that is neither is refused.
|
||||
assert_eq!(export(&mut s, json!({"text": "cut"})), export(&mut s, json!({})));
|
||||
assert!(s.execute("sheet.export_dxf", &json!({"text": "fill"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engraved_text_is_etched_and_other_pockets_keep_their_depth_layer() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Marks"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [70, 40], "p1": [90, 45]}));
|
||||
run(&mut s, "sketch.text", json!({"text": "AB-12", "at": [20, 28], "height": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Part way through the sheet: pockets, 1 mm deep from the bottom.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Marks", "distance": 1, "operation": "cut"}));
|
||||
let dxf = export(&mut s, json!({}));
|
||||
let es = entities(&dxf);
|
||||
// The rectangle is a pocket of the depth its layer names; the letters are a marking, on ETCH.
|
||||
let pocket = "POCKET_BOTTOM_Z0.000000_TO_1.000000_MM";
|
||||
let on = |layer: &str| es.iter().filter(|e| layer_of(e) == layer).collect::<Vec<_>>();
|
||||
assert_eq!((count_of(&on(pocket), "LINE"), on(pocket).len()), (4, 4));
|
||||
let letters = on("ETCH");
|
||||
assert!(letters.len() > 20, "{} letter entities", letters.len());
|
||||
for e in &letters {
|
||||
let p = point(e, 10);
|
||||
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", e.0);
|
||||
}
|
||||
let layers = layer_table(&dxf);
|
||||
assert!(layers.contains_key(pocket) && layers.contains_key("ETCH") && !layers.contains_key("TEXT"), "{layers:?}");
|
||||
// `text` is about letters cut through: it changes nothing here.
|
||||
assert_eq!(on_layer(&entities(&export(&mut s, json!({"text": "etch"}))), "ETCH"), on_layer(&es, "ETCH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bend_note_sits_beside_each_bend_line_when_asked_for() {
|
||||
let mut s = tray_with(&[]);
|
||||
let flat = run(&mut s, "sheet.flat_pattern", json!({}));
|
||||
let bends = flat["bends"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(bends.len(), 4);
|
||||
// Not unless asked for: a program that follows the file would etch them.
|
||||
let plain = export(&mut s, json!({}));
|
||||
assert!(entities(&plain).iter().all(|e| e.0 != "TEXT") && !layer_table(&plain).contains_key("BEND_NOTES"));
|
||||
let dxf = export(&mut s, json!({"bend_notes": true}));
|
||||
let notes: Vec<Entity> = entities(&dxf).into_iter().filter(|e| e.0 == "TEXT").collect();
|
||||
assert_eq!(notes.len(), 4);
|
||||
assert!(notes.iter().all(|e| layer_of(e) == "BEND_NOTES"));
|
||||
assert_eq!(layer_table(&dxf).get("BEND_NOTES"), Some(&(8, "Continuous".to_string())));
|
||||
for b in &bends {
|
||||
// The walls bend by 90° round an inner radius of 1 mm: "B1 UP 90° R1" (`%%d` is the degree sign).
|
||||
let up = b["direction"] == "up";
|
||||
let text = format!("B{} {} 90%%d R1", b["bend"].as_u64().unwrap_or(0), if up { "UP" } else { "DOWN" });
|
||||
let Some(note) = notes.iter().find(|e| string(e, 1) == text) else {
|
||||
panic!("no note `{text}` among {:?}", notes.iter().map(|e| string(e, 1)).collect::<Vec<_>>())
|
||||
};
|
||||
// Centred (72 = 1), 3 mm high, parallel to the line: along x it reads left to right, along y upwards.
|
||||
let (start, end) = (vec2(&b["start"]), vec2(&b["end"]));
|
||||
let angle = if (end - start).x.abs() > (end - start).y.abs() { 0.0 } else { 90.0 };
|
||||
assert!(values(note, 72) == [1.0] && values(note, 40) == [3.0] && (values(note, 50)[0] - angle).abs() < 1e-9, "{note:?}");
|
||||
// On the left of the way it reads, half its height from the middle of the line.
|
||||
let off = point(note, 11) - (start + end) * 0.5;
|
||||
assert!(off.dist(Vec2::from_angle(angle.to_radians()).perp() * 1.5) < 1e-9, "{text}: {off:?}");
|
||||
}
|
||||
// The height is the caller's.
|
||||
let tall = entities(&export(&mut s, json!({"bend_notes": true, "note_height": 5})));
|
||||
let note = tall.iter().find(|e| e.0 == "TEXT");
|
||||
assert!(note.is_some_and(|e| values(e, 40) == [5.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bend_note_gives_the_included_angle_when_the_rule_reads_it_so() {
|
||||
// The included angle is 180° less the bend angle: 122° for a 58° bend, 90° for a 90° one.
|
||||
for (deg, included) in [(58.0, 122.0), (90.0, 90.0)] {
|
||||
let mut plain = Vec::new();
|
||||
for display in ["bend", "included"] {
|
||||
let mut s = sheet_of_2mm();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Sheet2", "angle_display": display}));
|
||||
let mut s = plate_in(s, &[]);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20, "angle": format!("{deg} deg")}));
|
||||
let dxf = export(&mut s, json!({"bend_notes": true}));
|
||||
let notes: Vec<String> = entities(&dxf).iter().filter(|e| e.0 == "TEXT").map(|e| string(e, 1)).collect();
|
||||
let shown = if display == "included" { included } else { deg };
|
||||
assert_eq!(notes, [format!("B1 UP {shown}%%d R1")], "{deg}° under {display}");
|
||||
plain.push(export(&mut s, json!({})));
|
||||
}
|
||||
// Without notes the setting changes nothing in the file.
|
||||
assert_eq!(plain.first(), plain.get(1), "{deg}°");
|
||||
}
|
||||
}
|
||||
|
||||
/// A body whose base flange names its rule keeps that rule's notes whichever rule is active when
|
||||
/// the DXF is written, and a bend down reads its included angle the same way.
|
||||
#[test]
|
||||
fn a_bend_note_follows_the_bodys_own_rule_and_bends_down_too() {
|
||||
let mut s = sheet_of_2mm();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Sheet2", "angle_display": "included"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base", "rule": "Sheet2"}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20, "angle": "58 deg", "flip": true}));
|
||||
let notes = |s: &mut Session| -> Vec<String> {
|
||||
entities(&export(s, json!({"bend_notes": true}))).iter().filter(|e| e.0 == "TEXT").map(|e| string(e, 1)).collect()
|
||||
};
|
||||
assert_eq!(notes(&mut s), ["B1 DOWN 122%%d R1"]);
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Other", "thickness": "2 mm", "angle_display": "bend", "active": true}));
|
||||
assert_eq!(notes(&mut s), ["B1 DOWN 122%%d R1"], "another active rule does not change this body's notes");
|
||||
}
|
||||
|
||||
/// Two sheet bodies: a lid and, 200 mm away, a side wall with one bend.
|
||||
fn lid_and_wall() -> Session {
|
||||
let mut s = Session::default();
|
||||
for (sketch, p0, p1, body) in [("Lid", [0, 0], [100, 60], "Lid"), ("Wall", [200, 0], [250, 40], "Side Wall")] {
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": sketch}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": sketch, "body_name": body}));
|
||||
}
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[225, 0, 2.5]], "height": 15, "body": "Side Wall"}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_sheet_body_can_have_a_file_of_its_own() {
|
||||
let mut s = lid_and_wall();
|
||||
let r = run(&mut s, "sheet.export_dxf", json!({"per_body": true}));
|
||||
let files = r["files"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(files.iter().map(|f| f["body"].as_str().unwrap_or_default()).collect::<Vec<_>>(), ["Lid", "Side Wall"]);
|
||||
assert_eq!(files.iter().map(|f| f["bends"].as_u64()).collect::<Vec<_>>(), [Some(0), Some(1)]);
|
||||
// Each is the file `body` asks for: the lid's has no bend line, the wall's has its one.
|
||||
for f in &files {
|
||||
let single = export(&mut s, json!({"body": f["body"]}));
|
||||
assert_eq!(f["dxf"].as_str(), Some(single.as_str()), "{}", f["body"]);
|
||||
}
|
||||
let bend_lines = |f: &Value| entities(f["dxf"].as_str().unwrap_or_default()).iter().filter(|e| layer_of(e) == "BEND_UP").count();
|
||||
assert_eq!((bend_lines(&files[0]), bend_lines(&files[1])), (0, 1));
|
||||
// With a path, each body is written to a file named after it, beside the path, and no text comes back.
|
||||
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-dxf-per-body-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap_or_default();
|
||||
let path = dir.join("panel.dxf").to_string_lossy().to_string();
|
||||
let r = run(&mut s, "sheet.export_dxf", json!({"per_body": true, "path": path}));
|
||||
let written = r["files"].as_array().cloned().unwrap_or_default();
|
||||
let names: Vec<_> = written
|
||||
.iter()
|
||||
.map(|f| std::path::Path::new(f["path"].as_str().unwrap_or_default()).file_name().map(|n| n.to_string_lossy().to_string()))
|
||||
.collect();
|
||||
assert_eq!(names, [Some("panel-Lid.dxf".to_string()), Some("panel-Side Wall.dxf".to_string())]);
|
||||
for (f, written) in files.iter().zip(&written) {
|
||||
assert!(written.get("dxf").is_none());
|
||||
let on_disk = std::fs::read_to_string(written["path"].as_str().unwrap_or_default()).unwrap_or_default();
|
||||
assert_eq!(f["dxf"].as_str(), Some(on_disk.as_str()));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_export_options_are_refused_not_ignored() {
|
||||
let mut s = tray_with(&[]);
|
||||
for p in [
|
||||
json!({"text": "fill"}),
|
||||
json!({"text": 1}),
|
||||
json!({"bend_notes": "yes"}),
|
||||
json!({"bend_notes": 1}),
|
||||
json!({"note_height": 0}),
|
||||
json!({"note_height": -3}),
|
||||
json!({"note_height": 1e9}),
|
||||
json!({"note_height": "tall"}),
|
||||
json!({"per_body": "yes"}),
|
||||
json!({"per_body": true, "body": "Body1"}),
|
||||
] {
|
||||
assert!(s.execute("sheet.export_dxf", &p).is_err(), "{p}");
|
||||
}
|
||||
// Nothing was left half done.
|
||||
assert!(s.execute("sheet.export_dxf", &json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_of_a_body_is_named_after_it_and_no_two_share_a_name() {
|
||||
// (Windows writes the separator it adds as `\`.)
|
||||
let names = |path: &str, bodies: &[&str]| -> Vec<String> {
|
||||
super::body_paths(path, &bodies.iter().map(|b| b.to_string()).collect::<Vec<_>>()).into_iter().map(|p| p.replace('\\', "/")).collect()
|
||||
};
|
||||
assert_eq!(names("out/panel.dxf", &["Lid", "Side Wall"]), ["out/panel-Lid.dxf", "out/panel-Side Wall.dxf"]);
|
||||
// The extension is the path's, and .dxf when it has none.
|
||||
assert_eq!(names("out/panel", &["Lid"]), ["out/panel-Lid.dxf"]);
|
||||
assert_eq!(names("panel.DXF", &["Lid"]), ["panel-Lid.DXF"]);
|
||||
// What a file name cannot hold becomes `_`; a name that is nothing but that stays a name.
|
||||
assert_eq!(names("p.dxf", &["a/b:c", " ", "wall."]), ["p-a_b_c.dxf", "p-body.dxf", "p-wall.dxf"]);
|
||||
// Two bodies that would share a file (in any case) get a number.
|
||||
assert_eq!(names("p.dxf", &["Lid", "lid", "LID", "Lid_2"]), ["p-Lid.dxf", "p-lid_2.dxf", "p-LID_3.dxf", "p-Lid_2_2.dxf"]);
|
||||
}
|
||||
|
||||
/// #4314: a cutter that cuts every line gets a file without the bend lines, or with everything
|
||||
/// on layer 0. The default keeps the role layers and the dashed bend lines.
|
||||
#[test]
|
||||
fn bend_lines_can_be_left_out_and_everything_put_on_layer_0() {
|
||||
let mut s = tray_with(&marks());
|
||||
let layer = |e: &Entity| e.1.iter().find(|(c, _)| *c == 8).map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
let default = entities(&export(&mut s, json!({})));
|
||||
let bends = |es: &[Entity]| es.iter().filter(|e| matches!(layer(e).as_str(), "BEND_UP" | "BEND_DOWN")).count();
|
||||
assert_eq!(bends(&default), 4, "the tray's four bends");
|
||||
let without = entities(&export(&mut s, json!({"bend_lines": false})));
|
||||
assert_eq!(bends(&without), 0);
|
||||
assert_eq!(without.len(), default.len() - 4, "only the bend lines go");
|
||||
let single = entities(&export(&mut s, json!({"layers": "single"})));
|
||||
assert_eq!(single.len(), default.len());
|
||||
assert!(single.iter().all(|e| layer(e) == "0"), "{:?}", single.iter().map(layer).collect::<Vec<_>>());
|
||||
for bad in [json!({"bend_lines": "no"}), json!({"layers": "one"})] {
|
||||
assert!(s.execute("sheet.export_dxf", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
+1188
File diff suppressed because it is too large
Load Diff
+1032
File diff suppressed because it is too large
Load Diff
+74
@@ -0,0 +1,74 @@
|
||||
diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs
|
||||
--- a/crates/engine/src/cmd/sheet.rs
|
||||
+++ b/crates/engine/src/cmd/sheet.rs
|
||||
@@ -703,7 +703,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
-/// The centre and radius of a loop that is one whole circle (often two semicircles).
|
||||
+/// The flat-pattern caller's historical whole-circle compatibility bound.
|
||||
+/// Keep this local: generic Loop2 recognition and DXF path admission stay strict.
|
||||
fn loop_circle(lp: &solidcraft_geom::Loop2) -> Option<(Vec2, f64)> {
|
||||
use solidcraft_geom::Seg2;
|
||||
let Some(Seg2::Arc { center, radius, sweep, .. }) = lp.segs.first() else { return None };
|
||||
@@ -714,6 +715,18 @@
|
||||
(circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9).then_some((*center, *radius))
|
||||
}
|
||||
|
||||
+/// Admit through the shared converter before applying this caller's legacy merge.
|
||||
+/// An invalid piece, style or budget still refuses instead of being hidden by CIRCLE.
|
||||
+fn flat_loop_shapes(lp: &solidcraft_geom::Loop2, style: solidcraft_io::DxfPathStyle) -> solidcraft_io::Result<Vec<solidcraft_io::DxfShape>> {
|
||||
+ use solidcraft_io::{DxfPath, DxfShape, path_shapes};
|
||||
+ let mut shapes = path_shapes(DxfPath::Closed(lp), style)?;
|
||||
+ if let Some((c, r)) = loop_circle(lp) {
|
||||
+ shapes.clear();
|
||||
+ shapes.push(DxfShape::Circle { c, r });
|
||||
+ }
|
||||
+ Ok(shapes)
|
||||
+}
|
||||
+
|
||||
/// A loop's exact shape for the flat pattern JSON: a circle, or its lines and arcs (other
|
||||
/// curves as point lists at the flat pattern's 0.001 mm tolerance).
|
||||
fn loop_shape(lp: &solidcraft_geom::Loop2) -> Value {
|
||||
@@ -889,7 +902,7 @@
|
||||
/// per pocket depth and PERFORATION. Inner contours are written before the outline, so a program
|
||||
/// that follows the file's order cuts inside-out. Free-form curves as `o.curves` says.
|
||||
pub fn flat_dxf(sh: &SheetBody, o: &DxfOptions) -> Result<String> {
|
||||
- use solidcraft_io::{DxfEntity, DxfShape};
|
||||
+ use solidcraft_io::{DxfEntity, DxfPathStyle, DxfShape};
|
||||
let regions = sh.flat_regions()?;
|
||||
let pockets = sh.machining()?;
|
||||
let letters = sh.letters();
|
||||
@@ -958,25 +971,14 @@
|
||||
}
|
||||
loops.sort_by_key(|(pass, ..)| *pass);
|
||||
let mut entities = Vec::new();
|
||||
- for (_, h, layer) in loops {
|
||||
- // A circle is often represented by two semicircles. Keep the whole analytic
|
||||
- // boundary as one CIRCLE rather than approximating outer boundaries with chords.
|
||||
- if let Some((center, radius)) = loop_circle(h) {
|
||||
- entities.push(DxfEntity { layer, shape: DxfShape::Circle { c: center, r: radius } });
|
||||
- continue;
|
||||
- }
|
||||
- let fitted;
|
||||
- let h = match o.curves {
|
||||
- DxfCurves::Arcs if !h.is_line_arc() => {
|
||||
- fitted = h
|
||||
- .fit_arcs(solidcraft_kernel::PLANAR_FIT_TOL)
|
||||
- .ok_or_else(|| EngineError::Other("a flat-pattern curve could not be written as arcs".into()))?;
|
||||
- &fitted
|
||||
- }
|
||||
- _ => h,
|
||||
+ for (i, (_, h, layer)) in loops.into_iter().enumerate() {
|
||||
+ let style = match o.curves {
|
||||
+ DxfCurves::Exact => DxfPathStyle::Exact,
|
||||
+ DxfCurves::Arcs => DxfPathStyle::Arcs { tolerance_mm: solidcraft_kernel::PLANAR_FIT_TOL },
|
||||
};
|
||||
- // A whole ellipse is one ELLIPSE; every other segment is an entity of its own.
|
||||
- entities.extend(solidcraft_io::loop_shapes(h).into_iter().map(|shape| DxfEntity { layer: layer.clone(), shape }));
|
||||
+ let shapes = flat_loop_shapes(h, style)
|
||||
+ .map_err(|e| EngineError::Other(format!("flat-pattern body {:?}, layer {layer:?}, contour {}: {e}", sh.body, i + 1)))?;
|
||||
+ entities.extend(shapes.into_iter().map(|shape| DxfEntity { layer: layer.clone(), shape }));
|
||||
}
|
||||
for b in sh.bends() {
|
||||
if !o.no_bend_lines {
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
diff --git a/crates/engine/src/cmd/sheet_dxf_tests.rs b/crates/engine/src/cmd/sheet_dxf_tests.rs
|
||||
--- a/crates/engine/src/cmd/sheet_dxf_tests.rs
|
||||
+++ b/crates/engine/src/cmd/sheet_dxf_tests.rs
|
||||
@@ -346,7 +346,10 @@
|
||||
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
|
||||
let plate =
|
||||
regions.iter().max_by(|a, b| a.area().total_cmp(&b.area())).map(|r| r.outer.clone()).unwrap_or(solidcraft_geom::Loop2 { segs: Vec::new() });
|
||||
- assert_eq!(on("OUTLINE").len(), solidcraft_io::loop_shapes(&plate).len());
|
||||
+ assert_eq!(
|
||||
+ on("OUTLINE").len(),
|
||||
+ solidcraft_io::path_shapes(solidcraft_io::DxfPath::Closed(&plate), solidcraft_io::DxfPathStyle::Exact).unwrap().len(),
|
||||
+ );
|
||||
assert!(on("OUTLINE").iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", on("OUTLINE").iter().map(|e| &e.0).collect::<Vec<_>>());
|
||||
// CUT: what is cut out inside it that is no text: the Ø5 hole as one CIRCLE, the slot's four lines.
|
||||
let cut = on("CUT");
|
||||
@@ -612,5 +615,418 @@
|
||||
assert!(single.iter().all(|e| layer(e) == "0"), "{:?}", single.iter().map(layer).collect::<Vec<_>>());
|
||||
for bad in [json!({"bend_lines": "no"}), json!({"layers": "one"})] {
|
||||
assert!(s.execute("sheet.export_dxf", &bad).is_err(), "{bad}");
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+// Outputs recorded from the pre-conversion exporter at 0312808b92d4523894d2af57c99401c65886e24d.
|
||||
+// A fresh-process comparison matched all 53 payloads on 2026-10-26 before conversion changes.
|
||||
+// These original byte pins are fixed expectations, never regenerated by the test.
|
||||
+#[cfg(not(target_arch = "wasm32"))]
|
||||
+mod byte_regressions {
|
||||
+ use super::*;
|
||||
+ use sha2::{Digest, Sha256};
|
||||
+
|
||||
+ type ExpectedOutput = (&'static str, usize, &'static str);
|
||||
+
|
||||
+ const EXPECTED_OUTPUTS: [ExpectedOutput; 53] = [
|
||||
+ ("circle-cutout-shapes.json", 48, "c634654c54b427c376b74eb30facd6c6d4cfa41ad4ae5dd51d3b31afc76ed458"),
|
||||
+ ("circle-two-halves-exact.dxf", 3111, "c98ab7a7e331e312525ad99234bb48d5135e38d36a72f8b4bf2be6d3182dffd7"),
|
||||
+ ("circle-two-halves-arcs.dxf", 3111, "c98ab7a7e331e312525ad99234bb48d5135e38d36a72f8b4bf2be6d3182dffd7"),
|
||||
+ ("round-ended-slot-exact.dxf", 3486, "ce06b45cc49ce900d82f9a8b532ccac2751fa039abc1fb40046c4ce13c77deb1"),
|
||||
+ ("round-ended-slot-arcs.dxf", 3486, "ce06b45cc49ce900d82f9a8b532ccac2751fa039abc1fb40046c4ce13c77deb1"),
|
||||
+ ("ellipse-exact.dxf", 3199, "6a8b64f71ee0c4b1fd7b9d63ab0709c6c1878a65cf05281220b49730684a4f89"),
|
||||
+ ("ellipse-arcs.dxf", 14381, "afe1c9fa34d3982108a8d56d2d11af9b14c8788385e04222256f71819686597d"),
|
||||
+ ("cubic-spline-exact.dxf", 4109, "fedda740d4e0e38c501730385adcd42b635887fef300d6a9a59e48c160b9fa19"),
|
||||
+ ("cubic-spline-arcs.dxf", 11094, "209e16b90beb40b99f54d29de98affee686ab97d906078976cc55ba1e99fdf77"),
|
||||
+ ("degree-nine-exact.dxf", 3974, "a32cc2e579b520e1d0d4e47a37bf58e6800da9758b2b4d95b34ec55048cd4705"),
|
||||
+ ("degree-nine-arcs.dxf", 11885, "42eeadbdc9963a7b9602a47789a02b98951c6c2d0c628198760a4c5eeddd5b1c"),
|
||||
+ ("text-outlines-exact.dxf", 11267, "23953beaa0a0ab2b9ac6938b9705d8f9b88ad3df02e6972a5013aaaab644611f"),
|
||||
+ ("text-outlines-arcs.dxf", 23531, "c3adba4518e067d1c78d5e63c6203029cb741630f14447e43ee26f96fb20876c"),
|
||||
+ ("tray-default.dxf", 24381, "1239b1d78db1f5c3e5a3d6a0cfd43530e51f5548f669d3411b94fd179b1592c6"),
|
||||
+ ("tray-exact-cut-bends-true-roles.dxf", 25300, "c58cf63afb438c80943541cfaf85b1b40c8cc2a08f7ded78ecd826b8d6159394"),
|
||||
+ ("tray-exact-cut-bends-true-single.dxf", 24167, "f8ede11b566e9f8bc547fec9e7fda816a8d50b51c2c9e47eaa474ca303e6090f"),
|
||||
+ ("tray-exact-cut-bends-false-roles.dxf", 24758, "90a3f799c989acc69c5d968486bd20eea4eca33630d1849183c3f32f780c05b4"),
|
||||
+ ("tray-exact-cut-bends-false-single.dxf", 23651, "cc6498088bd2edcb9ccf115c665dc3b5fb4dc56bf4c7366ac2f1ced1a922cb16"),
|
||||
+ ("tray-exact-etch-bends-true-roles.dxf", 25300, "df421a23079401e89ba029620e2e377a4a5c4cc1a386391abeb0ad2885814d47"),
|
||||
+ ("tray-exact-etch-bends-true-single.dxf", 24167, "03606ca21dcc3f8bc3f68f6fc0af8f459162dab6ed6b444f10b67f229876f093"),
|
||||
+ ("tray-exact-etch-bends-false-roles.dxf", 24758, "0cd8654b1da7505ba13124f9f0a0ecfeb4687a0c0fcaa25dd87edabac1f63c3a"),
|
||||
+ ("tray-exact-etch-bends-false-single.dxf", 23651, "14a3ea13772c7f64675c6ff0a017df56664cd89a48ecced2c600a07cba95c8d6"),
|
||||
+ ("tray-arcs-cut-bends-true-roles.dxf", 42484, "e298e9eb113995884a4764210463b86b57ec30080213b182aedcee9e46d1912b"),
|
||||
+ ("tray-arcs-cut-bends-true-single.dxf", 40982, "2346420e716e8325fd513919f323c1d22fa871694e88b720f034d2018ff18743"),
|
||||
+ ("tray-arcs-cut-bends-false-roles.dxf", 41938, "6988b20d8030ee46071ad0c8e86f65d54d1be3755b7ead04a2bb8c21021defde"),
|
||||
+ ("tray-arcs-cut-bends-false-single.dxf", 40462, "4d9b84ba711f7421bcd1c6833d12ad47f3da16ac949f32347b8392459f0495d1"),
|
||||
+ ("tray-arcs-etch-bends-true-roles.dxf", 42484, "e0eda322c4c75619de535bfd279f4e5616d22f30fdfa7ee0c65b86366ac5b796"),
|
||||
+ ("tray-arcs-etch-bends-true-single.dxf", 40982, "19dd6d5cdfbf8177f28d74fd29f1760c54b122071dc61401df86f16bfd744fa3"),
|
||||
+ ("tray-arcs-etch-bends-false-roles.dxf", 41938, "41022f19af172e40307dd37abac43982979bc42992071643dab48de2a59aba01"),
|
||||
+ ("tray-arcs-etch-bends-false-single.dxf", 40462, "8e6a94cb3a126de3268783e55bde1c7c8c145a202384bbf446ef336f1cdc31e7"),
|
||||
+ ("rule-bend-down-exact-bends-true-roles.dxf", 4065, "b927b9ffe6e49e6289e719b57a8ad8e204fa44dbeb0a23fcdf65aa5a116dbe87"),
|
||||
+ ("rule-bend-down-exact-bends-false-single.dxf", 3308, "f97cc60bbe0cab1b75bf7965d750565b2593183e029699915c8ac6bd3ac0b9c0"),
|
||||
+ ("rule-bend-down-arcs-bends-true-roles.dxf", 4065, "b927b9ffe6e49e6289e719b57a8ad8e204fa44dbeb0a23fcdf65aa5a116dbe87"),
|
||||
+ ("rule-bend-down-arcs-bends-false-single.dxf", 3308, "f97cc60bbe0cab1b75bf7965d750565b2593183e029699915c8ac6bd3ac0b9c0"),
|
||||
+ ("rule-included-down-exact-bends-true-roles.dxf", 4066, "3ee6825be836054c2869ba959d611292d34c8b109746ba4b98b82f6cdc064aa2"),
|
||||
+ ("rule-included-down-exact-bends-false-single.dxf", 3309, "515ab9cace64fe66758cfb601b7d9614f2e62d35d8a1efbeb8ef1d6890130d1d"),
|
||||
+ ("rule-included-down-arcs-bends-true-roles.dxf", 4066, "3ee6825be836054c2869ba959d611292d34c8b109746ba4b98b82f6cdc064aa2"),
|
||||
+ ("rule-included-down-arcs-bends-false-single.dxf", 3309, "515ab9cace64fe66758cfb601b7d9614f2e62d35d8a1efbeb8ef1d6890130d1d"),
|
||||
+ ("pocket-and-engraved-text-exact.dxf", 20843, "df0b996889dc152e66b01b6d56f35337edd109b15d55ab5693ea86c079d14a22"),
|
||||
+ ("pocket-and-engraved-text-arcs.dxf", 37997, "8d92294fc2ab84d73fbae2df73f5060252db9b54f8c0c040ebf05f6102568add"),
|
||||
+ ("perforation-exact.dxf", 6480, "703ac2570c91da7d4fa41e889e9f03e48611c9fee91876325be877ab7c2aa24c"),
|
||||
+ ("perforation-arcs.dxf", 6480, "703ac2570c91da7d4fa41e889e9f03e48611c9fee91876325be877ab7c2aa24c"),
|
||||
+ ("per-body-0-exact.dxf", 3014, "19bda8ed16b26471a3b417147d7cc4642fda9bdea548f6c61725100944fbdbc6"),
|
||||
+ ("per-body-1-exact.dxf", 3734, "0c7d54b955202fd01cc4771cacbfe7cdb4fbbbfd6944fbba3fc1f1718ce6f148"),
|
||||
+ ("per-body-0-arcs.dxf", 3014, "19bda8ed16b26471a3b417147d7cc4642fda9bdea548f6c61725100944fbdbc6"),
|
||||
+ ("per-body-1-arcs.dxf", 3734, "0c7d54b955202fd01cc4771cacbfe7cdb4fbbbfd6944fbba3fc1f1718ce6f148"),
|
||||
+ ("writer-bmp-controls.dxf", 2430, "4fd801becee559ff017c0c84d80b881d8da7cc9e960ea76e00ef311035bcfd73"),
|
||||
+ ("writer-bmp-cap.dxf", 4138, "d17379aa2ad648e6d356df5b58f2222e28472554248a29407cf932507fafdcfd"),
|
||||
+ ("unicode-early.txt", 159, "90c374179d2388832a9bbcafbbabd4c3887edde4989eedd9cfcbd48b4199b4f1"),
|
||||
+ ("unicode-first-supplementary.txt", 159, "e14a0d3eb9fabbc361d3804fb00830c013771ad79ba52f24bd12d7ea8a8a02a0"),
|
||||
+ ("unicode-last-scalar.txt", 166, "f931160242245ee3cdf4f9b551bb20c2a4883d52114208a6b66d83dffca4a484"),
|
||||
+ ("unicode-after-cap.txt", 161, "6b415a0446cf33531d23a6768790ea562441eb23b70092e77dea4ee99951c617"),
|
||||
+ ("unicode-far-after-cap.txt", 163, "87709acb08a4e729b4480182419ad843ab2c7887fc73dd23bc86a3e86f35beaa"),
|
||||
+ ];
|
||||
+
|
||||
+ struct BytePins {
|
||||
+ expected: &'static [ExpectedOutput; 53],
|
||||
+ next: usize,
|
||||
+ }
|
||||
+
|
||||
+ impl BytePins {
|
||||
+ fn new(expected: &'static [ExpectedOutput; 53]) -> Self {
|
||||
+ Self { expected, next: 0 }
|
||||
+ }
|
||||
+
|
||||
+ fn put(&mut self, name: &str, bytes: &[u8]) {
|
||||
+ assert!(!bytes.is_empty(), "empty original-output case: {name}");
|
||||
+ let &(expected_name, expected_len, expected_sha256) =
|
||||
+ self.expected.get(self.next).unwrap_or_else(|| panic!("unexpected extra original-output case: {name}"));
|
||||
+ assert_eq!(name, expected_name, "original-output case {} changed order or name", self.next + 1);
|
||||
+ assert_eq!(bytes.len(), expected_len, "original byte length changed for {name}");
|
||||
+ let actual_sha256 = format!("{:x}", Sha256::digest(bytes));
|
||||
+ assert_eq!(actual_sha256, expected_sha256, "original bytes changed for {name}");
|
||||
+ self.next += 1;
|
||||
+ }
|
||||
+
|
||||
+ fn dxf(&mut self, name: &str, text: &str) {
|
||||
+ assert!(text.contains("AC1015") && text.ends_with(" 0\nEOF\n"), "incomplete DXF: {name}");
|
||||
+ self.put(&format!("{name}.dxf"), text.as_bytes());
|
||||
+ }
|
||||
+
|
||||
+ fn finish(self) {
|
||||
+ assert_eq!(self.next, self.expected.len(), "every original-output case must be checked");
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ fn modes(capture: &mut BytePins, name: &str, s: &mut Session) {
|
||||
+ for curves in ["exact", "arcs"] {
|
||||
+ let dxf = export(s, json!({"curves": curves}));
|
||||
+ let es = entities(&dxf);
|
||||
+ assert!(!es.is_empty());
|
||||
+ if curves == "arcs" {
|
||||
+ assert!(es.iter().all(|e| matches!(e.0.as_str(), "LINE" | "ARC" | "CIRCLE")));
|
||||
+ }
|
||||
+ capture.dxf(&format!("{name}-{curves}"), &dxf);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ fn g4_plate() -> Session {
|
||||
+ let mut s = plate_with(&[]);
|
||||
+ run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
|
||||
+ let mut line = |a: [i32; 2], b: [i32; 2]| run(&mut s, "sketch.line", json!({"points": [a, b]}))["curves"][0].as_str().unwrap().to_string();
|
||||
+ let bottom = line([20, 20], [40, 20]);
|
||||
+ let top = line([40, 40], [20, 40]);
|
||||
+ line([20, 40], [20, 20]);
|
||||
+ run(&mut s, "sketch.blend_curve", json!({"a": format!("{bottom}.end"), "b": format!("{top}.start"), "continuity": "g4"}));
|
||||
+ run(&mut s, "sketch.finish", json!({}));
|
||||
+ run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
+ let es = entities(&export(&mut s, json!({})));
|
||||
+ assert_eq!(es.iter().filter(|e| e.0 == "SPLINE" && values(e, 71) == [9.0]).count(), 1);
|
||||
+ s
|
||||
+ }
|
||||
+
|
||||
+ fn rule_plate(display: &str) -> Session {
|
||||
+ let mut s = sheet_of_2mm();
|
||||
+ run(&mut s, "sheet.manage_rules", json!({"name": "Sheet2", "angle_display": display}));
|
||||
+ run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
+ run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
+ run(&mut s, "sketch.finish", json!({}));
|
||||
+ run(&mut s, "sheet.flange", json!({"sketch": "Base", "rule": "Sheet2"}));
|
||||
+ run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20, "angle": "58 deg", "flip": true}));
|
||||
+ s
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn flat_export_preserves_original_bytes() {
|
||||
+ let mut capture = BytePins::new(&EXPECTED_OUTPUTS);
|
||||
+
|
||||
+ let mut circle = plate_with(&[("sketch.circle.center", json!({"center": [25, 25], "radius": 4}))]);
|
||||
+ let circular = loops(&circle).into_iter().find(|lp| lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }))).unwrap();
|
||||
+ assert_eq!(circular.segs.len(), 2, "the baseline must exercise a circular hole made of two semicircles");
|
||||
+ assert!(circular.segs.iter().all(|s| matches!(s, Seg2::Arc { sweep, .. } if (sweep.abs() - PI).abs() < 1e-9)));
|
||||
+ assert_eq!(count(&entities(&export(&mut circle, json!({}))), "CIRCLE"), 1);
|
||||
+ let flat = run(&mut circle, "sheet.flat_pattern", json!({}));
|
||||
+ assert!(flat["cutout_shapes"].as_array().unwrap().iter().any(|v| v.get("circle").is_some()));
|
||||
+ capture.put("circle-cutout-shapes.json", &serde_json::to_vec(&flat["cutout_shapes"]).unwrap());
|
||||
+ modes(&mut capture, "circle-two-halves", &mut circle);
|
||||
+
|
||||
+ let mut slot = plate_with(&[("sketch.slot.center_to_center", json!({"p0": [20, 20], "p1": [40, 20], "width": 6}))]);
|
||||
+ let es = entities(&export(&mut slot, json!({})));
|
||||
+ assert_eq!((count(&es, "LINE"), count(&es, "ARC"), es.len()), (6, 2, 8));
|
||||
+ modes(&mut capture, "round-ended-slot", &mut slot);
|
||||
+
|
||||
+ let mut ellipse = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
|
||||
+ assert_eq!(count(&entities(&export(&mut ellipse, json!({}))), "ELLIPSE"), 1);
|
||||
+ modes(&mut capture, "ellipse", &mut ellipse);
|
||||
+
|
||||
+ let mut spline = plate_with(&[
|
||||
+ ("sketch.spline.fit_point", json!({"points": [[20, 15], [40, 45], [65, 40], [85, 20]]})),
|
||||
+ ("sketch.line", json!({"points": [[85, 20], [20, 15]]})),
|
||||
+ ]);
|
||||
+ assert!(count(&entities(&export(&mut spline, json!({}))), "SPLINE") > 0);
|
||||
+ modes(&mut capture, "cubic-spline", &mut spline);
|
||||
+ modes(&mut capture, "degree-nine", &mut g4_plate());
|
||||
+
|
||||
+ let mut text = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]);
|
||||
+ assert!(count(&entities(&export(&mut text, json!({}))), "SPLINE") > 0);
|
||||
+ modes(&mut capture, "text-outlines", &mut text);
|
||||
+
|
||||
+ let mut tray = tray_with(&marks());
|
||||
+ let default = export(&mut tray, json!({}));
|
||||
+ assert_eq!(default, export(&mut tray, json!({"curves": "exact", "text": "cut", "bend_lines": true, "layers": "roles"})));
|
||||
+ capture.dxf("tray-default", &default);
|
||||
+ for curves in ["exact", "arcs"] {
|
||||
+ for text in ["cut", "etch"] {
|
||||
+ for bend_lines in [true, false] {
|
||||
+ for layers in ["roles", "single"] {
|
||||
+ let dxf = export(
|
||||
+ &mut tray,
|
||||
+ json!({"curves": curves, "text": text, "bend_lines": bend_lines, "layers": layers, "bend_notes": true}),
|
||||
+ );
|
||||
+ let es = entities(&dxf);
|
||||
+ assert_eq!(count(&es, "TEXT"), 4, "notes remain independent of bend lines");
|
||||
+ if layers == "single" {
|
||||
+ assert!(es.iter().all(|e| layer_of(e) == "0"));
|
||||
+ } else {
|
||||
+ let bends = es.iter().filter(|e| matches!(layer_of(e).as_str(), "BEND_UP" | "BEND_DOWN")).count();
|
||||
+ assert_eq!(bends, if bend_lines { 4 } else { 0 });
|
||||
+ assert!(es.iter().any(|e| layer_of(e) == if text == "cut" { "TEXT" } else { "ETCH" }));
|
||||
+ let inner = es.iter().rposition(|e| matches!(layer_of(e).as_str(), "CUT" | "TEXT" | "ETCH")).unwrap();
|
||||
+ let outline = es.iter().position(|e| layer_of(e) == "OUTLINE").unwrap();
|
||||
+ assert!(inner < outline);
|
||||
+ }
|
||||
+ capture.dxf(&format!("tray-{curves}-{text}-bends-{bend_lines}-{layers}"), &dxf);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ for display in ["bend", "included"] {
|
||||
+ let mut s = rule_plate(display);
|
||||
+ let before = export(&mut s, json!({"bend_notes": true}));
|
||||
+ run(
|
||||
+ &mut s,
|
||||
+ "sheet.manage_rules",
|
||||
+ json!({"name": "Other", "thickness": "2 mm", "angle_display": if display == "bend" { "included" } else { "bend" }, "active": true}),
|
||||
+ );
|
||||
+ assert_eq!(export(&mut s, json!({"bend_notes": true})), before, "the named body's rule wins over the active rule");
|
||||
+ for curves in ["exact", "arcs"] {
|
||||
+ for (bend_lines, layers) in [(true, "roles"), (false, "single")] {
|
||||
+ let dxf = export(&mut s, json!({"curves": curves, "bend_notes": true, "bend_lines": bend_lines, "layers": layers}));
|
||||
+ let notes: Vec<_> = entities(&dxf).iter().filter(|e| e.0 == "TEXT").map(|e| string(e, 1)).collect();
|
||||
+ assert_eq!(notes, [if display == "included" { "B1 DOWN 122%%d R1" } else { "B1 DOWN 58%%d R1" }]);
|
||||
+ capture.dxf(&format!("rule-{display}-down-{curves}-bends-{bend_lines}-{layers}"), &dxf);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ let mut pocket = plate_with(&[]);
|
||||
+ run(&mut pocket, "sketch.create", json!({"plane": "XY", "name": "Marks"}));
|
||||
+ run(&mut pocket, "sketch.rectangle.two_point", json!({"p0": [70, 40], "p1": [90, 45]}));
|
||||
+ run(&mut pocket, "sketch.text", json!({"text": "AB-12", "at": [20, 28], "height": 10}));
|
||||
+ run(&mut pocket, "sketch.finish", json!({}));
|
||||
+ run(&mut pocket, "solid.extrude", json!({"sketch": "Marks", "distance": 1, "operation": "cut"}));
|
||||
+ let es = entities(&export(&mut pocket, json!({})));
|
||||
+ assert_eq!(on_layer(&es, "POCKET_BOTTOM_Z0.000000_TO_1.000000_MM").len(), 4);
|
||||
+ assert!(!on_layer(&es, "ETCH").is_empty());
|
||||
+ modes(&mut capture, "pocket-and-engraved-text", &mut pocket);
|
||||
+
|
||||
+ let mut perforated = plate_with(&[]);
|
||||
+ run(&mut perforated, "sheet.perforate", json!({"diameter": 2, "pitch": 12, "margin": 6, "pattern": "square"}));
|
||||
+ let es = entities(&export(&mut perforated, json!({})));
|
||||
+ assert!(es.iter().any(|e| layer_of(e) == "PERFORATION" && e.0 == "CIRCLE"));
|
||||
+ modes(&mut capture, "perforation", &mut perforated);
|
||||
+
|
||||
+ let mut bodies = lid_and_wall();
|
||||
+ for curves in ["exact", "arcs"] {
|
||||
+ let out = run(&mut bodies, "sheet.export_dxf", json!({"per_body": true, "curves": curves}));
|
||||
+ let files = out["files"].as_array().unwrap();
|
||||
+ assert_eq!(files.iter().map(|f| f["body"].as_str().unwrap()).collect::<Vec<_>>(), ["Lid", "Side Wall"]);
|
||||
+ for (i, file) in files.iter().enumerate() {
|
||||
+ let text = file["dxf"].as_str().unwrap();
|
||||
+ assert_eq!(text, export(&mut bodies, json!({"body": file["body"], "curves": curves})));
|
||||
+ capture.dxf(&format!("per-body-{i}-{curves}"), text);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // The writer is the reachable API for arbitrary TEXT. Sheet text is outlines and
|
||||
+ // bend notes are generated ASCII; no synthetic public command is added to inject text.
|
||||
+ use solidcraft_io::{DxfEntity, DxfLayer, DxfShape, IoError, write_dxf_drawing};
|
||||
+ let note = |text: String| DxfEntity {
|
||||
+ layer: "NOTES".into(),
|
||||
+ shape: DxfShape::Text { at: Vec2::new(30.0, 12.0), height: 3.0, angle_deg: 90.0, text },
|
||||
+ };
|
||||
+ let layers = [DxfLayer::new("NOTES", 8)];
|
||||
+ for (name, text) in
|
||||
+ [("writer-bmp-controls", "ASCII %%d %%p %%c ° 中 \u{ffff}\n\t\u{7f}".to_string()), ("writer-bmp-cap", "\u{ffff}".repeat(251))]
|
||||
+ {
|
||||
+ let dxf = write_dxf_drawing(&layers, &[note(text)]).unwrap();
|
||||
+ let es = entities(&dxf);
|
||||
+ let value = string(es.iter().find(|e| e.0 == "TEXT").unwrap(), 1);
|
||||
+ if name == "writer-bmp-cap" {
|
||||
+ assert_eq!(value, "\\U+FFFF".repeat(250));
|
||||
+ } else {
|
||||
+ // This parser trims the three replaced trailing controls; the full byte pin
|
||||
+ // below still includes every space in the actual group-code value.
|
||||
+ assert_eq!(value, "ASCII %%d %%p %%c \\U+00B0 \\U+4E2D \\U+FFFF");
|
||||
+ assert!(dxf.contains("\\U+FFFF \n"));
|
||||
+ }
|
||||
+ capture.dxf(name, &dxf);
|
||||
+ }
|
||||
+ for (name, text, code, position) in [
|
||||
+ ("unicode-early", "中\u{2000b}".to_string(), "U+2000B", 2),
|
||||
+ ("unicode-first-supplementary", "\u{10000}".to_string(), "U+10000", 1),
|
||||
+ ("unicode-last-scalar", "\u{10ffff}".to_string(), "U+10FFFF", 1),
|
||||
+ ("unicode-after-cap", format!("{}\u{2000b}", "中".repeat(250)), "U+2000B", 251),
|
||||
+ ("unicode-far-after-cap", format!("{}\u{2000b}", "中".repeat(10_000)), "U+2000B", 10_001),
|
||||
+ ] {
|
||||
+ let entities = [DxfEntity { layer: "OUTLINE".into(), shape: DxfShape::Line(Vec2::ZERO, Vec2::X) }, note(text)];
|
||||
+ let error = write_dxf_drawing(&layers, &entities).unwrap_err();
|
||||
+ assert!(matches!(error, IoError::Invalid(_)));
|
||||
+ let message = error.to_string();
|
||||
+ assert!(message.contains("DXF TEXT entity 2 on layer \"NOTES\""));
|
||||
+ assert!(message.contains(code) && message.contains(&format!("at position {position}")));
|
||||
+ assert!(message.len() < 300);
|
||||
+ capture.put(&format!("{name}.txt"), message.as_bytes());
|
||||
+ }
|
||||
+ capture.finish();
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+/// Independent caller-level values for the explicitly reviewed legacy contract.
|
||||
+/// The generic recognizer and its tests are not relaxed by these flat-pattern rules.
|
||||
+mod caller_circle_compatibility {
|
||||
+ use super::*;
|
||||
+ use solidcraft_geom::Loop2;
|
||||
+ use solidcraft_io::{DxfPathStyle, DxfShape};
|
||||
+
|
||||
+ fn halves(center: Vec2, radius: f64, second_center: Vec2, second_radius: f64) -> Loop2 {
|
||||
+ Loop2 { segs: vec![
|
||||
+ Seg2::Arc { center, radius, start: 0.0, sweep: PI },
|
||||
+ Seg2::Arc { center: second_center, radius: second_radius, start: PI, sweep: PI },
|
||||
+ ] }
|
||||
+ }
|
||||
+ fn styles() -> [DxfPathStyle; 2] {
|
||||
+ [DxfPathStyle::Exact, DxfPathStyle::Arcs { tolerance_mm: 0.001 }]
|
||||
+ }
|
||||
+ fn expect_circle(lp: &Loop2, center: Vec2, radius: f64) {
|
||||
+ assert_eq!(super::super::loop_circle(lp), Some((center, radius)));
|
||||
+ let json = super::super::loop_shape(lp);
|
||||
+ assert_eq!(json["circle"]["center"], json!([center.x, center.y]));
|
||||
+ assert_eq!(json["circle"]["radius"].as_f64(), Some(radius));
|
||||
+ for style in styles() {
|
||||
+ let shapes = super::super::flat_loop_shapes(lp, style).unwrap();
|
||||
+ assert_eq!(shapes.len(), 1);
|
||||
+ assert!(matches!(shapes[0], DxfShape::Circle { c, r } if c == center && r == radius), "{style:?}: {shapes:?}");
|
||||
+ }
|
||||
+ }
|
||||
+ fn expect_arcs(lp: &Loop2) {
|
||||
+ assert!(super::super::loop_circle(lp).is_none());
|
||||
+ assert!(lp.as_circle().is_none(), "generic recognition must remain strict");
|
||||
+ let json = super::super::loop_shape(lp);
|
||||
+ assert!(json.get("circle").is_none());
|
||||
+ let segments = json["segments"].as_array().unwrap();
|
||||
+ assert_eq!(segments.len(), lp.segs.len());
|
||||
+ assert!(segments.iter().all(|s| s.get("arc").is_some()));
|
||||
+ for style in styles() {
|
||||
+ let shapes = super::super::flat_loop_shapes(lp, style).unwrap();
|
||||
+ assert_eq!(shapes.len(), lp.segs.len());
|
||||
+ assert!(shapes.iter().all(|s| matches!(s, DxfShape::Arc { .. })), "{style:?}: {shapes:?}");
|
||||
+ }
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn trimmed_cap_support_witness_is_one_flat_circle_but_not_a_generic_circle() {
|
||||
+ // Captured source supports from the existing original regression, not new expected output.
|
||||
+ let center = Vec2::new(25.0, 25.0);
|
||||
+ let radius = 1.9999999999999987;
|
||||
+ let second = Vec2::new(24.999999999999996, 25.0);
|
||||
+ let other_radius = 2.0000000000000004;
|
||||
+ assert!(center.dist(second) > 0.0 && center.dist(second) < 1e-9);
|
||||
+ assert!(other_radius > radius && other_radius - radius < 1e-9);
|
||||
+ let lp = halves(center, radius, second, other_radius);
|
||||
+ assert!(lp.as_circle().is_none(), "unequal stored supports remain unequal in generic geom");
|
||||
+ expect_circle(&lp, center, radius);
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn center_distance_at_and_just_past_bound_keeps_both_arcs() {
|
||||
+ let center = Vec2::new(0.0, 0.0);
|
||||
+ let inside = 0.5e-9;
|
||||
+ expect_circle(&halves(center, 1.0, Vec2::new(inside, 0.0), 1.0), center, 1.0);
|
||||
+ for offset in [1e-9, f64::from_bits(1e-9_f64.to_bits() + 1)] {
|
||||
+ let second = Vec2::new(offset, 0.0);
|
||||
+ assert!(second.dist(center) >= 1e-9);
|
||||
+ expect_arcs(&halves(center, 1.0, second, 1.0));
|
||||
+ }
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn radius_difference_just_past_bound_keeps_both_arcs() {
|
||||
+ let center = Vec2::new(0.0, 0.0);
|
||||
+ let radius = 1.0;
|
||||
+ let inside = radius + 0.5e-9;
|
||||
+ let outside = f64::from_bits((radius + 1e-9_f64).to_bits() + 1);
|
||||
+ assert!(inside - radius < 1e-9);
|
||||
+ assert!(outside - radius > 1e-9);
|
||||
+ expect_circle(&halves(center, radius, center, inside), center, radius);
|
||||
+ expect_arcs(&halves(center, radius, center, outside));
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn angular_sum_just_past_one_turn_bound_keeps_both_arcs() {
|
||||
+ let center = Vec2::new(0.0, 0.0);
|
||||
+ let mut inside = halves(center, 1.0, center, 1.0);
|
||||
+ inside.segs[1] = Seg2::Arc { center, radius: 1.0, start: PI, sweep: PI + 0.5e-9 };
|
||||
+ expect_circle(&inside, center, 1.0);
|
||||
+ let total = f64::from_bits((TAU + 1e-9).to_bits() + 1);
|
||||
+ let second = total - PI;
|
||||
+ assert!(((PI + second).abs() - TAU).abs() > 1e-9);
|
||||
+ let outside = Loop2 { segs: vec![
|
||||
+ Seg2::Arc { center, radius: 1.0, start: 0.0, sweep: PI },
|
||||
+ Seg2::Arc { center, radius: 1.0, start: PI, sweep: second },
|
||||
+ ] };
|
||||
+ expect_arcs(&outside);
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn double_turn_is_four_arcs_in_both_flat_export_styles() {
|
||||
+ let center = Vec2::new(0.0, 0.0);
|
||||
+ let lp = Loop2 { segs: (0..4).map(|i| Seg2::Arc { center, radius: 1.0, start: f64::from(i) * PI, sweep: PI }).collect() };
|
||||
+ expect_arcs(&lp);
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn mixed_signs_with_net_one_turn_are_still_four_arcs() {
|
||||
+ let center = Vec2::new(0.0, 0.0);
|
||||
+ let lp = Loop2 { segs: [PI, PI, PI, -PI].into_iter().enumerate().map(|(i, sweep)| {
|
||||
+ Seg2::Arc { center, radius: 1.0, start: i as f64 * PI, sweep }
|
||||
+ }).collect() };
|
||||
+ assert_eq!([PI, PI, PI, -PI].iter().sum::<f64>(), TAU);
|
||||
+ expect_arcs(&lp);
|
||||
+ }
|
||||
+ #[test]
|
||||
+ fn caller_merge_never_bypasses_shared_converter_admission() {
|
||||
+ let center = Vec2::new(0.0, 0.0);
|
||||
+ let invalid_start = Loop2 { segs: vec![Seg2::Arc { center, radius: 1.0, start: f64::NAN, sweep: TAU }] };
|
||||
+ assert!(super::super::loop_circle(&invalid_start).is_some(), "historical helper ignores start; admission must not");
|
||||
+ for style in styles() { assert!(super::super::flat_loop_shapes(&invalid_start, style).is_err()); }
|
||||
+ let valid = halves(center, 1.0, center, 1.0);
|
||||
+ assert!(super::super::flat_loop_shapes(&valid, DxfPathStyle::Arcs { tolerance_mm: f64::NAN }).is_err());
|
||||
+ // The existing shared path cap stays200000 even if a caller could merge to one entity.
|
||||
+ let over_budget = Loop2 { segs: vec![Seg2::Arc { center, radius: 1.0, start: 0.0, sweep: TAU / 200001.0 }; 200001] };
|
||||
+ assert!(super::super::loop_circle(&over_budget).is_some());
|
||||
+ for style in styles() { assert!(super::super::flat_loop_shapes(&over_budget, style).is_err()); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"scope": "TWO_FILE_RECONCILIATION_CONDITIONAL_ON_EXISTING_X1_PREREQUISITES",
|
||||
"commits": {
|
||||
"frozen_main": "8e9e2700275a363ac63ee5178aca7e33fac06722",
|
||||
"original_PR_head": "3fb2eef92dce4bafe9cfb3f68a8334985d4d83ee",
|
||||
"original_merge_base": "0312808b92d4523894d2af57c99401c65886e24d",
|
||||
"reviewed_caller_archive": "28d90fae50dc9b060434a393305b0c93dff0444a"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"repository_path": "crates/engine/src/cmd/sheet.rs",
|
||||
"before": {
|
||||
"file": "before/crates/engine/src/cmd/sheet.rs",
|
||||
"sha256": "0ded6d11902e5da7b5923ca0aa46bf4a3dc1af7d6fbae344deb63f65ca4c33b5",
|
||||
"bytes": 58207
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/engine/src/cmd/sheet.rs",
|
||||
"sha256": "aa8a67b736a553bec91072d763567d26252212485d1d3806bed93fcc909483dc",
|
||||
"bytes": 58421
|
||||
},
|
||||
"delta": {
|
||||
"file": "deltas/crates/engine/src/cmd/sheet.rs.patch",
|
||||
"sha256": "469d0b09814f5f9ef9ec9b1ba46de6a406ddfe986cf69121ee8bccb690c1bbe6",
|
||||
"bytes": 3772
|
||||
}
|
||||
},
|
||||
{
|
||||
"repository_path": "crates/engine/src/cmd/sheet_dxf_tests.rs",
|
||||
"before": {
|
||||
"file": "before/crates/engine/src/cmd/sheet_dxf_tests.rs",
|
||||
"sha256": "ead1645edffec671b6bc65e8e5b69d32c2c77b4cc21b1550d395a2d167582895",
|
||||
"bytes": 33759
|
||||
},
|
||||
"candidate": {
|
||||
"file": "candidate/crates/engine/src/cmd/sheet_dxf_tests.rs",
|
||||
"sha256": "480ee7b2dc1a6879fb21d4a1652790d7c6d80ffc5ecf337af43887b176cc367d",
|
||||
"bytes": 59739
|
||||
},
|
||||
"delta": {
|
||||
"file": "deltas/crates/engine/src/cmd/sheet_dxf_tests.rs.patch",
|
||||
"sha256": "dacfe9e03f28880b069af8af9c03bdd9a7013cb46621ae6abac74a3cb2974f58",
|
||||
"bytes": 27575
|
||||
}
|
||||
}
|
||||
],
|
||||
"application": "Apply each delta exactly once to its own exact frozen-main before file. Two disjoint paths; snapshots are inspection alternatives, not more patches. This is not a full X1 integration.",
|
||||
"remaining_original_X1_paths_not_provided": [
|
||||
"Cargo.lock",
|
||||
"crates/doc/src/sheet_convert.rs",
|
||||
"crates/engine/Cargo.toml",
|
||||
"crates/geom/src/lib.rs",
|
||||
"crates/geom/src/profile.rs",
|
||||
"crates/geom/src/tests.rs",
|
||||
"crates/io/src/dxf_out.rs",
|
||||
"crates/io/src/dxf_out/path.rs",
|
||||
"crates/io/src/lib.rs",
|
||||
"crates/io/tests/dxf_paths.rs"
|
||||
],
|
||||
"required_external_contracts": [
|
||||
"DxfPath/DxfPathStyle/path_shapes exports and shared converter original-piece/style/200000-piece admission",
|
||||
"Original strict generic Loop2 recognizers and negative tests",
|
||||
"Original certified_round_wall_turn producer helper and original trimmed-cap five-entity acceptance",
|
||||
"Original engine sha2 dev dependency, coherent Cargo.lock and original IO/geom/doc acceptance sources"
|
||||
],
|
||||
"owner_decision_and_plan_URLs": [
|
||||
"https://github.com/tangency-labs/solidcraft/pull/6184#issuecomment-6522562149",
|
||||
"https://github.com/tangency-labs/solidcraft/pull/6184#issuecomment-6522733382",
|
||||
"https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6234779815"
|
||||
],
|
||||
"preservation": {
|
||||
"original_pin_tuples": 53,
|
||||
"original_head_declarations": 18,
|
||||
"archived_appended_declarations": 7,
|
||||
"total_declarations": 25,
|
||||
"new_compatibility_contract": false,
|
||||
"foreign_payload_preserved": true
|
||||
},
|
||||
"source_review_sha256": "5f19f4262a7019f67fe3c351c30eda940f437758ab6d0d92d151e4b7f0e592d2",
|
||||
"status": "SOURCE_ONLY_UNFORMATTED_UNCOMPILED_UNRUN_NOT_STANDALONE_NOT_READY",
|
||||
"hash_kind_note": "The 40-hex values under commits are Git commit identifiers. 64-hex file/source-review SHA256 values are byte identities, not commits or execution results."
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user