forked from 1b-agent/opencadstudio-upstream
fix: harden the issue-fix wave after full adversarial review
Five reviewers (PDF, snap, commands, otrack/help, printing) covered the eight issue-fix commits; every blocker and major is fixed here. Printing (#1261 follow-up): - printto params now quote the printer name and pad the empty driver/ port slots handlers substitute ("Microsoft Print to PDF" was split into three arguments and the job silently lost). - Level-2 enumeration also merges when the level-4 list omits the configured default, not only when it is empty; a level-2 error no longer discards the default-printer merge. - The printer list loads in a background task instead of blocking the UI thread (level-2 queries can wait out RPC timeouts). - CTB registration: collisions keep the existing file but now say so, and only container-parsed tables are registered (the permissive zlib heuristic stays session-only so junk cannot persist). Snapping (#1030/#1319 follow-up): - Edge-wire key_vertices gain NaN separators: junctions between unrelated mesh edges no longer fabricate phantom midpoints. - The wire cap counts segments (not separator points) and decimates instead of dropping, so dense meshes keep partial endpoint coverage instead of losing snap entirely. - f64 endpoint recovery is a one-shot per-wire map instead of an O(points x geoms) rescan on every mouse move. Commands (#1082 class): the same typed-value-on-web bug also hit CYLINDER/BOX/WEDGE/CONE/PYRAMID heights, HELIX final height (wrong value, not just dropped), and SWEEP reference/new lengths — all now commit as text at exactly those steps, each pinned by a regression test that fails without the override (plus the missing native-route twin for EXTRUDE). PDF (#702 follow-up): heavy CTB pens bypass the Form XObject (their half-width exceeds the /BBox SDF margin and was flat-chopped), CTM scale entries round to 4 significant digits (0.01 quantized small text by ~1%), degenerate quads skip emission, the atlas snapshot is taken once per export instead of per text entity, and the rounding comments no longer conflate page points with glyph-plane units. OTRACK (#1313 follow-up): the guide's polar quantization diverges from the snapper's drafting-gated polar during window-corner drags — documented; an orphaned grid-step doc block is reunited with its function.
This commit is contained in:
1 parent
5ea4294d3d
commit
d135b15ab6
18 files changed
+1326
-138
No files matched your search
@@ -1771,4 +1771,476 @@ mod tests {
|
||||
"EXTRUDE must finish after the typed height"
|
||||
);
|
||||
}
|
||||
|
||||
/// (min, max) of `coord` over every wire point of the Solid3D entities in
|
||||
/// tab 0's document. Lets the typed-value regression tests below assert
|
||||
/// the committed geometry (the height / swept size), not just that some
|
||||
/// solid appeared.
|
||||
fn solid_wire_extent(
|
||||
app: &OpenCADStudio,
|
||||
coord: impl Fn(&acadrust::types::Vector3) -> f64,
|
||||
) -> Option<(f64, f64)> {
|
||||
let mut extent: Option<(f64, f64)> = None;
|
||||
for entity in app.tabs[0].scene.document.entities() {
|
||||
let acadrust::EntityType::Solid3D(solid) = entity else {
|
||||
continue;
|
||||
};
|
||||
for point in solid.wires.iter().flat_map(|wire| wire.points.iter()) {
|
||||
let value = coord(point);
|
||||
extent = Some(match extent {
|
||||
Some((low, high)) => (low.min(value), high.max(value)),
|
||||
None => (value, value),
|
||||
});
|
||||
}
|
||||
}
|
||||
extent
|
||||
}
|
||||
|
||||
fn solid_count(app: &OpenCADStudio) -> usize {
|
||||
app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| matches!(e, acadrust::EntityType::Solid3D(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The native twin of `extrude_typed_height_from_dyn_field`: the same
|
||||
/// EXTRUDE Height step driven through the focused command line
|
||||
/// (`CommandInput` → `CommandSubmit`) — the route a desktop user whose
|
||||
/// command line holds keyboard focus takes. Both routes must extrude by
|
||||
/// the typed height (#1082).
|
||||
#[test]
|
||||
fn extrude_typed_height_from_command_line() {
|
||||
use crate::app::Message;
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
// A closed rectangle in the XY plane.
|
||||
use acadrust::entities::LwVertex;
|
||||
use acadrust::types::Vector2;
|
||||
let rect = acadrust::LwPolyline {
|
||||
vertices: [
|
||||
(-10.0, -10.0),
|
||||
(10.0, -10.0),
|
||||
(10.0, 10.0),
|
||||
(-10.0, 10.0),
|
||||
]
|
||||
.iter()
|
||||
.map(|(x, y)| LwVertex::new(Vector2::new(*x, *y)))
|
||||
.collect(),
|
||||
is_closed: true,
|
||||
..Default::default()
|
||||
};
|
||||
let handle = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.add_entity(acadrust::EntityType::LwPolyline(rect))
|
||||
.unwrap();
|
||||
|
||||
let _ = app.update(Message::CommandInput("EXTRUDE".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.feed_command(crate::command::StepInput::EntityPick(
|
||||
handle,
|
||||
glam::DVec3::new(-10.0, -10.0, 0.0),
|
||||
));
|
||||
// Bare Enter finishes selection → Height step.
|
||||
let _ = app.update(Message::CommandFinalize);
|
||||
|
||||
// Focused command line: the typed height goes through the input
|
||||
// widget, not the dynamic-input field buffer.
|
||||
let _ = app.update(Message::CommandInput("5".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
|
||||
let (min_z, max_z) =
|
||||
solid_wire_extent(&app, |p| p.z).expect("typed height must extrude");
|
||||
assert!(
|
||||
(max_z - min_z - 5.0).abs() < 1e-6,
|
||||
"extruded extent must be the typed 5, got {min_z}..{max_z}"
|
||||
);
|
||||
assert!(
|
||||
app.tabs[0].active_cmd.is_none(),
|
||||
"EXTRUDE must finish after the typed height"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #1082 class regression for CYLINDER: the Height prompt accepts
|
||||
/// keyword letters (2Point/Axis), so on web the typed digits land in the
|
||||
/// dynamic-input Height box and the commit must parse them as the scalar
|
||||
/// height instead of resolving an in-plane point whose height is ~0.
|
||||
#[test]
|
||||
fn cylinder_typed_height_from_dyn_field() {
|
||||
use crate::app::Message;
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
|
||||
let _ = app.update(Message::CommandInput("CYLINDER".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
// Base center and radius through the command line.
|
||||
let _ = app.update(Message::CommandInput("0,0".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("3".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
// → Height step with a Height dynamic-input box.
|
||||
assert!(
|
||||
app.tabs[0]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.role == crate::command::DynRole::Height)
|
||||
);
|
||||
|
||||
for ch in ["5"] {
|
||||
let _ = app.update(Message::CommandAppendChar(ch.to_string()));
|
||||
}
|
||||
assert_eq!(app.command_line.input, "");
|
||||
assert!(
|
||||
app.tabs[0]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.buffer.as_deref() == Some("5"))
|
||||
);
|
||||
|
||||
let _ = app.update(Message::CommandFinalize);
|
||||
|
||||
let (min_z, max_z) = solid_wire_extent(&app, |p| p.z)
|
||||
.expect("typed height must build the cylinder");
|
||||
assert!(
|
||||
(max_z - 5.0).abs() < 1e-6 && min_z.abs() < 1e-6,
|
||||
"cylinder height must be the typed 5, got {min_z}..{max_z}"
|
||||
);
|
||||
assert!(
|
||||
app.tabs[0].active_cmd.is_none(),
|
||||
"CYLINDER must finish after the typed height"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #1082 class regression for BOX (and WEDGE, which shares
|
||||
/// `BoxStep::Height`): the typed height must commit as the scalar height,
|
||||
/// not resolve to an in-plane point whose plane-normal ordinate is ~0.
|
||||
#[test]
|
||||
fn box_and_wedge_typed_height_from_dyn_field() {
|
||||
use crate::app::Message;
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
|
||||
// ── BOX: 10×5 footprint, typed height 7 ──────────────────────────
|
||||
let _ = app.update(Message::CommandInput("BOX".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("0,0".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("10,5".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
assert!(
|
||||
app.tabs[0]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.role == crate::command::DynRole::Height)
|
||||
);
|
||||
for ch in ["7"] {
|
||||
let _ = app.update(Message::CommandAppendChar(ch.to_string()));
|
||||
}
|
||||
let _ = app.update(Message::CommandFinalize);
|
||||
|
||||
assert_eq!(solid_count(&app), 1, "BOX must commit after the typed height");
|
||||
let (min_x, max_x) = solid_wire_extent(&app, |p| p.x).expect("box solid");
|
||||
let (min_y, max_y) = solid_wire_extent(&app, |p| p.y).expect("box solid");
|
||||
let (min_z, max_z) = solid_wire_extent(&app, |p| p.z).expect("box solid");
|
||||
assert!(
|
||||
(min_x - 0.0).abs() < 1e-6
|
||||
&& (max_x - 10.0).abs() < 1e-6
|
||||
&& (min_y - 0.0).abs() < 1e-6
|
||||
&& (max_y - 5.0).abs() < 1e-6,
|
||||
"box footprint must stay 10×5, got x {min_x}..{max_x}, y {min_y}..{max_y}"
|
||||
);
|
||||
assert!(
|
||||
(min_z - 0.0).abs() < 1e-6 && (max_z - 7.0).abs() < 1e-6,
|
||||
"box height must be the typed 7, got {min_z}..{max_z}"
|
||||
);
|
||||
assert!(app.tabs[0].active_cmd.is_none());
|
||||
|
||||
// ── WEDGE: same Height step (BoxStep::Height), typed height 9 ────
|
||||
let _ = app.update(Message::CommandInput("WEDGE".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("0,0".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("10,5".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
assert!(
|
||||
app.tabs[0]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.role == crate::command::DynRole::Height)
|
||||
);
|
||||
for ch in ["9"] {
|
||||
let _ = app.update(Message::CommandAppendChar(ch.to_string()));
|
||||
}
|
||||
let _ = app.update(Message::CommandFinalize);
|
||||
|
||||
assert_eq!(solid_count(&app), 2, "WEDGE must commit after the typed height");
|
||||
let (min_z, max_z) = solid_wire_extent(&app, |p| p.z).expect("wedge solid");
|
||||
assert!(
|
||||
(min_z - 0.0).abs() < 1e-6 && (max_z - 9.0).abs() < 1e-6,
|
||||
"wedge height must be the typed 9 (aggregate over both solids), got {min_z}..{max_z}"
|
||||
);
|
||||
assert!(app.tabs[0].active_cmd.is_none());
|
||||
}
|
||||
|
||||
/// Issue #1082 class regression for CONE and PYRAMID: their Height (and
|
||||
/// HeightAfterTopRadius) prompts accept keyword letters, so the typed
|
||||
/// digits must commit through `on_text_input` as the scalar height. Both
|
||||
/// legs drive the Top-radius sub-prompt first so the committed solid is a
|
||||
/// frustum whose top face lands in the wireframe (an apex cone's wires
|
||||
/// carry only the base ellipse), and so the shared
|
||||
/// `HeightAfterTopRadius` arm is covered too.
|
||||
#[test]
|
||||
fn cone_and_pyramid_typed_height_from_dyn_field() {
|
||||
use crate::app::Message;
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
|
||||
// ── CONE: base center 0,0 radius 3, top radius 1, typed height 5 ─
|
||||
let _ = app.update(Message::CommandInput("CONE".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("0,0".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("3".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
assert!(
|
||||
app.tabs[0]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.role == crate::command::DynRole::Height)
|
||||
);
|
||||
// Top-radius sub-prompt, then the height (HeightAfterTopRadius).
|
||||
let _ = app.update(Message::CommandInput("T".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("1".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
assert!(
|
||||
app.tabs[0]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.role == crate::command::DynRole::Height)
|
||||
);
|
||||
for ch in ["5"] {
|
||||
let _ = app.update(Message::CommandAppendChar(ch.to_string()));
|
||||
}
|
||||
let _ = app.update(Message::CommandFinalize);
|
||||
|
||||
assert_eq!(solid_count(&app), 1, "CONE must commit after the typed height");
|
||||
let (min_z, max_z) = solid_wire_extent(&app, |p| p.z).expect("cone solid");
|
||||
let (.., max_r) = solid_wire_extent(&app, |p| p.x.hypot(p.y)).expect("cone solid");
|
||||
assert!(
|
||||
(max_r - 3.0).abs() < 1e-6,
|
||||
"cone base radius must stay 3, got {max_r}"
|
||||
);
|
||||
assert!(
|
||||
min_z.abs() < 1e-6 && (max_z - 5.0).abs() < 1e-6,
|
||||
"cone height must be the typed 5, got {min_z}..{max_z}"
|
||||
);
|
||||
assert!(app.tabs[0].active_cmd.is_none());
|
||||
|
||||
// ── PYRAMID: base center 0,0 radius 3, top radius 2, typed height 8
|
||||
let _ = app.update(Message::CommandInput("PYRAMID".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("0,0".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("3".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
assert!(
|
||||
app.tabs[0]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.role == crate::command::DynRole::Height)
|
||||
);
|
||||
let _ = app.update(Message::CommandInput("T".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("2".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
assert!(
|
||||
app.tabs[0]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.role == crate::command::DynRole::Height)
|
||||
);
|
||||
for ch in ["8"] {
|
||||
let _ = app.update(Message::CommandAppendChar(ch.to_string()));
|
||||
}
|
||||
let _ = app.update(Message::CommandFinalize);
|
||||
|
||||
assert_eq!(
|
||||
solid_count(&app),
|
||||
2,
|
||||
"PYRAMID must commit after the typed height"
|
||||
);
|
||||
let (min_z, max_z) = solid_wire_extent(&app, |p| p.z).expect("pyramid solid");
|
||||
assert!(
|
||||
min_z.abs() < 1e-6 && (max_z - 8.0).abs() < 1e-6,
|
||||
"pyramid height must be the typed 8 (aggregate over both solids), got {min_z}..{max_z}"
|
||||
);
|
||||
assert!(app.tabs[0].active_cmd.is_none());
|
||||
}
|
||||
|
||||
/// Issue #1082 class regression for HELIX: at the Final (height) prompt
|
||||
/// the unfixed dyn commit resolves the typed span into an in-plane point
|
||||
/// anchored at the last pick and commits that point's distance from the
|
||||
/// center as the height — a wrong value whenever the previous pick wasn't
|
||||
/// the center. Entering the top radius through its dynamic box anchors
|
||||
/// the height prompt at (3,0,0), setting up exactly that case.
|
||||
#[test]
|
||||
fn helix_typed_height_from_dyn_field() {
|
||||
use crate::app::Message;
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
|
||||
let _ = app.update(Message::CommandInput("HELIX".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
// Center, then a typed base radius.
|
||||
let _ = app.update(Message::CommandInput("0,0".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("2".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
// Pick the top radius through the dynamic-input field (the only web
|
||||
// route): the resolved point (3,0,0) becomes the anchor/last point,
|
||||
// off the center — exactly the case where an unfixed point-resolved
|
||||
// typed height would commit the wrong value.
|
||||
for ch in ["3"] {
|
||||
let _ = app.update(Message::CommandAppendChar(ch.to_string()));
|
||||
}
|
||||
let _ = app.update(Message::CommandFinalize);
|
||||
// → Final step with a Distance dynamic-input box.
|
||||
assert!(
|
||||
app.tabs[0]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.role == crate::command::DynRole::Distance)
|
||||
);
|
||||
assert_eq!(app.last_point, Some(glam::DVec3::new(3.0, 0.0, 0.0)));
|
||||
|
||||
for ch in ["5"] {
|
||||
let _ = app.update(Message::CommandAppendChar(ch.to_string()));
|
||||
}
|
||||
assert_eq!(app.command_line.input, "");
|
||||
assert!(
|
||||
app.tabs[0]
|
||||
.dyn_fields
|
||||
.iter()
|
||||
.any(|f| f.buffer.as_deref() == Some("5"))
|
||||
);
|
||||
|
||||
let _ = app.update(Message::CommandFinalize);
|
||||
|
||||
let helix = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.find_map(|e| match e {
|
||||
acadrust::EntityType::Helix(helix) => Some(helix.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("typed height must create the helix");
|
||||
let height = helix.turn_height * helix.turns;
|
||||
assert!(
|
||||
(height - 5.0).abs() < 1e-6,
|
||||
"helix height must be the typed 5, got {height}"
|
||||
);
|
||||
assert!(
|
||||
(helix.axis_vector.z - 1.0).abs() < 1e-9,
|
||||
"helix axis must stay the plane normal"
|
||||
);
|
||||
assert!(
|
||||
app.tabs[0].active_cmd.is_none(),
|
||||
"HELIX must finish after the typed height"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #1082 class regression for SWEEP's reference-scale prompts: the
|
||||
/// typed reference length must be parsed as a length (advancing to the
|
||||
/// new-length prompt), and the typed new length must set the scale
|
||||
/// (new/reference) — visible in the swept solid's size. Unfixed, the
|
||||
/// typed reference length is dropped as a stray first reference point.
|
||||
#[test]
|
||||
fn sweep_typed_scale_lengths_from_dyn_field() {
|
||||
use crate::app::Message;
|
||||
use acadrust::types::Vector3;
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
// A unit circle profile swept along a vertical line path.
|
||||
let mut circle = acadrust::Circle::new();
|
||||
circle.radius = 1.0;
|
||||
let profile = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.add_entity(acadrust::EntityType::Circle(circle))
|
||||
.unwrap();
|
||||
let path = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.add_entity(acadrust::EntityType::Line(acadrust::Line::from_points(
|
||||
Vector3::new(0.0, 0.0, 0.0),
|
||||
Vector3::new(0.0, 0.0, 10.0),
|
||||
)))
|
||||
.unwrap();
|
||||
|
||||
let _ = app.update(Message::CommandInput("SWEEP".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
// Select the profile, bare Enter finishes selection → path prompt.
|
||||
let _ = app.feed_command(crate::command::StepInput::SelectionComplete(vec![profile]));
|
||||
let _ = app.update(Message::CommandFinalize);
|
||||
// Enter the reference-scale sub-prompt through the command line.
|
||||
let _ = app.update(Message::CommandInput("SCALE".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
let _ = app.update(Message::CommandInput("REFERENCE".to_string()));
|
||||
let _ = app.update(Message::CommandSubmit);
|
||||
// → ReferenceLength prompt with dynamic-input boxes showing.
|
||||
assert!(!app.tabs[0].dyn_fields.is_empty());
|
||||
|
||||
// Typed reference length 2 commits through the dyn-field buffer.
|
||||
for ch in ["2"] {
|
||||
let _ = app.update(Message::CommandAppendChar(ch.to_string()));
|
||||
}
|
||||
assert_eq!(app.command_line.input, "");
|
||||
let _ = app.update(Message::CommandFinalize);
|
||||
let prompt = app.tabs[0]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.prompt())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
prompt.to_lowercase().contains("new length"),
|
||||
"typed reference length must advance to the new-length prompt, got: {prompt}"
|
||||
);
|
||||
|
||||
// Typed new length 6 → scale 6/2 = 3 → back to the path prompt.
|
||||
for ch in ["6"] {
|
||||
let _ = app.update(Message::CommandAppendChar(ch.to_string()));
|
||||
}
|
||||
let _ = app.update(Message::CommandFinalize);
|
||||
let prompt = app.tabs[0]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.prompt())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
prompt.to_lowercase().contains("sweep path"),
|
||||
"typed new length must return to the path prompt, got: {prompt}"
|
||||
);
|
||||
|
||||
// Picking the path finishes the sweep: the unit circle must have been
|
||||
// scaled by 3, so the swept solid's wires reach radius 3.
|
||||
let _ = app.feed_command(crate::command::StepInput::EntityPick(
|
||||
path,
|
||||
glam::DVec3::new(0.0, 0.0, 5.0),
|
||||
));
|
||||
|
||||
let (.., max_r) = solid_wire_extent(&app, |p| p.x.hypot(p.y))
|
||||
.expect("sweep must create a solid");
|
||||
assert!(
|
||||
(max_r - 3.0).abs() < 1e-3,
|
||||
"swept radius must be the profile 1 × scale 3, got {max_r}"
|
||||
);
|
||||
assert!(
|
||||
app.tabs[0].active_cmd.is_none(),
|
||||
"SWEEP must finish after the path pick"
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -3260,6 +3260,11 @@ pub enum Message {
|
||||
/// Web never constructs it — printing opens the PDF preview instead.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
PrintResult(Result<String, String>),
|
||||
/// Printers enumerated off the UI thread for the Plot dialog's dropdown;
|
||||
/// the dialog opens immediately and the list lands whenever the spooler
|
||||
/// query finishes. Web never constructs it — it has no system printers.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
PrintersListed(Vec<String>),
|
||||
/// Open the full Plot / Print dialog (seeds state from the layout).
|
||||
PlotDialogOpen,
|
||||
/// An edit inside the Plot / Print dialog.
|
||||
@@ -3290,8 +3295,12 @@ pub enum Message {
|
||||
/// Open file dialog to load a CTB/STB plot style table.
|
||||
PlotStyleLoad,
|
||||
/// Callback when the user picks (or cancels) a CTB/STB file.
|
||||
/// `Ok(None)` = cancelled; `Err` = the picked file failed to parse.
|
||||
PlotStyleLoaded(Result<Option<crate::io::plot_style::PlotStyleTable>, String>),
|
||||
/// `Ok(None)` = cancelled; `Err` = the picked file failed to parse. The
|
||||
/// table carries an optional non-fatal note for the command line (e.g.
|
||||
/// registration kept an already-registered table of the same name).
|
||||
PlotStyleLoaded(
|
||||
Result<Option<(crate::io::plot_style::PlotStyleTable, Option<String>)>, String>,
|
||||
),
|
||||
/// Clear the active plot style table.
|
||||
PlotStyleClear,
|
||||
/// Open/close the Plot Style panel.
|
||||
|
||||
+15
-1
@@ -4041,7 +4041,6 @@ impl OpenCADStudio {
|
||||
.collect();
|
||||
// Keep session-only choices while loading drawing fields from the layout.
|
||||
let d = &mut self.plot_dialog;
|
||||
d.printers = crate::io::print_to_printer::list_printers();
|
||||
d.plot_styles = crate::io::plot_style::available_ctb_names();
|
||||
d.scales = scales;
|
||||
d.plot_views = plot_views;
|
||||
@@ -4124,6 +4123,21 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
self.active_modal = Some(crate::app::ModalKind::Plot);
|
||||
// Enumerating printers can block for seconds on spooler RPC (dead
|
||||
// print servers), so it must not run on the UI thread: open the
|
||||
// dialog immediately and let the printer dropdown populate when the
|
||||
// worker replies with Message::PrintersListed. The list is session
|
||||
// state on the dialog, so the late assignment re-renders the pick
|
||||
// list without touching the user's selection. Native only — the web
|
||||
// build has no system printers and offers PDF output alone.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
background_task(
|
||||
crate::io::print_to_printer::list_printers,
|
||||
Message::PrintersListed,
|
||||
)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Task::none()
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -8335,12 +8335,19 @@ impl OpenCADStudio {
|
||||
.push_error(crate::tf!("Print failed: {e}").as_ref());
|
||||
Task::none()
|
||||
}
|
||||
// The printer list arrives after the Plot dialog already opened
|
||||
// (enumeration runs off the UI thread); assigning it re-renders
|
||||
// the dropdown while leaving the user's selection untouched.
|
||||
Message::PrintersListed(printers) => {
|
||||
self.plot_dialog.printers = printers;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Plot Style Table ──────────────────────────────────────────────
|
||||
Message::PlotStyleLoad => {
|
||||
Task::perform(crate::io::pick_plot_style(), Message::PlotStyleLoaded)
|
||||
}
|
||||
Message::PlotStyleLoaded(Ok(Some(table))) => {
|
||||
Message::PlotStyleLoaded(Ok(Some((table, note)))) => {
|
||||
if table.is_stb {
|
||||
self.command_line.push_error(
|
||||
crate::t!(
|
||||
@@ -8364,6 +8371,11 @@ impl OpenCADStudio {
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
// Non-fatal registration feedback — e.g. the picked file was
|
||||
// already registered and the existing table was kept.
|
||||
if let Some(note) = note {
|
||||
self.command_line.push_info(¬e);
|
||||
}
|
||||
self.active_plot_style = Some(table);
|
||||
self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names();
|
||||
Task::none()
|
||||
|
||||
@@ -771,6 +771,9 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
// state machine stay untouched.
|
||||
let (_, (ux, uy, _)) = self.drafting_grid_basis(i);
|
||||
let (ux, uy) = (ux.as_dvec3(), uy.as_dvec3());
|
||||
// Guides quantize to the polar increment even
|
||||
// while a window-corner drag suppresses polar in
|
||||
// the snapper (drafting gate) — cosmetic only.
|
||||
let step = self.polar_mode.then_some(self.polar_increment_deg);
|
||||
let perp_on =
|
||||
self.snapper.is_on(crate::snap::SnapType::Perpendicular);
|
||||
|
||||
+134
-11
@@ -1526,8 +1526,11 @@ mod save_failure_tests {
|
||||
/// `Ok(None)` means the user cancelled the dialog; `Err` carries a parse
|
||||
/// failure so it reaches the command line instead of dying silently (the
|
||||
/// legacy-CTB complaint in #1261d was partly that a file which failed to
|
||||
/// parse produced no feedback at all).
|
||||
pub async fn pick_plot_style() -> Result<Option<plot_style::PlotStyleTable>, String> {
|
||||
/// parse produced no feedback at all). The table is paired with an optional
|
||||
/// non-fatal note for the command line — currently the registration
|
||||
/// collision message below.
|
||||
pub async fn pick_plot_style()
|
||||
-> Result<Option<(plot_style::PlotStyleTable, Option<String>)>, String> {
|
||||
let dialog = crate::sys::file_dialog()
|
||||
.set_title(crate::t!("Load Plot Style Table").as_ref())
|
||||
.add_filter(
|
||||
@@ -1553,17 +1556,137 @@ pub async fn pick_plot_style() -> Result<Option<plot_style::PlotStyleTable>, Str
|
||||
})?;
|
||||
// Loading a table registers it, the way AutoCAD does: copy it next to the
|
||||
// built-ins so it stays in the Plot dialog's table list after restart.
|
||||
// Failures are non-fatal — the table is already applied to this plot.
|
||||
// Only real AutoCAD containers are registered — the permissive heuristic
|
||||
// also accepts junk (any text file, a stray deflate stream), and writing
|
||||
// those into the folder would pollute it permanently — so heuristic
|
||||
// parses stay loadable for this session only. Failures are non-fatal —
|
||||
// the table is already applied to this plot.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !table.is_stb {
|
||||
if let (Ok(dir), Some(file_name)) = (plot_style::plot_styles_dir(), path.file_name()) {
|
||||
let registered = dir.join(file_name);
|
||||
if !registered.exists() {
|
||||
let _ = std::fs::copy(&path, ®istered);
|
||||
}
|
||||
}
|
||||
let note = should_register_plot_style(&table)
|
||||
.then(|| register_picked_plot_style(&path))
|
||||
.flatten();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let note = None;
|
||||
Ok(Some((table, note)))
|
||||
}
|
||||
|
||||
/// Whether a loaded table should be registered permanently: only real
|
||||
/// AutoCAD CTB containers. Named (STB) tables are not offered in the
|
||||
/// color-dependent list, and heuristic parses (plain text, a stray deflate
|
||||
/// stream) must not be written into the plot-styles folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn should_register_plot_style(table: &plot_style::PlotStyleTable) -> bool {
|
||||
!table.is_stb && table.structured
|
||||
}
|
||||
|
||||
/// Copy the picked CTB into the per-user plot-styles folder, reporting a
|
||||
/// command-line note when the already-registered table was kept instead.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn register_picked_plot_style(path: &Path) -> Option<String> {
|
||||
plot_style::plot_styles_dir()
|
||||
.ok()
|
||||
.and_then(|dir| register_plot_style_into(&dir, path))
|
||||
}
|
||||
|
||||
/// Copy `path` into `dir` unless a table with the same file name is already
|
||||
/// registered there. A collision keeps the registered file — overwriting it
|
||||
/// from a stray pick would silently destroy edits made through the Plot
|
||||
/// Style panel — and returns a note naming the kept table so the user hears
|
||||
/// about it. Copy failures stay silent: the table is already applied to the
|
||||
/// current plot.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn register_plot_style_into(dir: &Path, path: &Path) -> Option<String> {
|
||||
let file_name = path.file_name()?;
|
||||
let registered = dir.join(file_name);
|
||||
if registered.exists() {
|
||||
return Some(
|
||||
crate::tf!(
|
||||
"'{}' is already registered; the existing table was kept.",
|
||||
file_name.to_string_lossy()
|
||||
)
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
let _ = std::fs::copy(path, ®istered);
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod plot_style_registration_tests {
|
||||
use super::{register_plot_style_into, should_register_plot_style};
|
||||
use crate::io::plot_style::{self, PlotStyleTable};
|
||||
|
||||
fn scratch_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ocs_ctb_register_{tag}_{}_{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A name collision keeps the registered table and surfaces a note
|
||||
/// naming it instead of silently discarding the pick.
|
||||
#[test]
|
||||
fn a_collision_keeps_the_registered_table_and_notes_it() {
|
||||
let dir = scratch_dir("collide");
|
||||
let picked = dir.join("picked.ctb");
|
||||
std::fs::write(&picked, b"freshly picked").unwrap();
|
||||
std::fs::write(dir.join("picked.ctb"), b"already registered").unwrap();
|
||||
let note = register_plot_style_into(&dir, &picked).expect("collision note");
|
||||
assert!(note.contains("picked.ctb"), "{note}");
|
||||
assert!(note.contains("already registered"), "{note}");
|
||||
assert!(note.contains("kept"), "{note}");
|
||||
assert_eq!(
|
||||
std::fs::read(dir.join("picked.ctb")).unwrap(),
|
||||
b"already registered"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Without a collision the picked table is copied and no note is pushed.
|
||||
#[test]
|
||||
fn a_fresh_table_is_copied_without_a_note() {
|
||||
let dir = scratch_dir("fresh");
|
||||
let picked = dir.join("source").join("new.ctb");
|
||||
std::fs::create_dir_all(picked.parent().unwrap()).unwrap();
|
||||
std::fs::write(&picked, b"fresh bytes").unwrap();
|
||||
assert!(register_plot_style_into(&dir, &picked).is_none());
|
||||
assert_eq!(
|
||||
std::fs::read(dir.join("new.ctb")).unwrap(),
|
||||
b"fresh bytes"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Registration is reserved for real AutoCAD containers: the permissive
|
||||
/// heuristic loads junk files for the session but they must never be
|
||||
/// written into the plot-styles folder.
|
||||
#[test]
|
||||
fn only_structured_containers_are_registrable() {
|
||||
let builtin = PlotStyleTable::builtin(plot_style::DEFAULT_PLOT_STYLE).unwrap();
|
||||
assert!(builtin.structured);
|
||||
assert!(should_register_plot_style(&builtin));
|
||||
let mut stb = builtin.clone();
|
||||
stb.is_stb = true;
|
||||
assert!(!should_register_plot_style(&stb));
|
||||
|
||||
// A loose zlib stream without the container header still parses…
|
||||
use std::io::Write;
|
||||
let mut enc =
|
||||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
enc.write_all(b"description=\"junk\n").unwrap();
|
||||
let mut loose = b"any first line\n".to_vec();
|
||||
loose.extend_from_slice(&enc.finish().unwrap());
|
||||
let junk = PlotStyleTable::from_bytes("junk.ctb", &loose).unwrap();
|
||||
// …but stays session-only.
|
||||
assert!(!junk.structured);
|
||||
assert!(!should_register_plot_style(&junk));
|
||||
}
|
||||
Ok(Some(table))
|
||||
}
|
||||
|
||||
// ── Image file picker ─────────────────────────────────────────────────────
|
||||
|
||||
+181
-36
@@ -506,6 +506,11 @@ pub fn render_pdf_bytes(
|
||||
// Glyph Form XObjects keyed by the atlas tile key — shared across pages so
|
||||
// multi-layout exports bake each unique glyph once.
|
||||
let mut glyph_forms = std::collections::HashMap::new();
|
||||
// Snapshot the atlas once for the whole export (see `atlas_export_snapshot`):
|
||||
// `emit_text` used to re-snapshot per TEXT entity, re-cloning every baked
|
||||
// glyph's strokes and fill triangulation each call while the forms cache
|
||||
// here makes only the first copy useful.
|
||||
let atlas = atlas_export_snapshot(pages);
|
||||
for (index, page) in pages.iter().enumerate() {
|
||||
append_pdf_page(
|
||||
&mut doc,
|
||||
@@ -513,6 +518,7 @@ pub fn render_pdf_bytes(
|
||||
&mut glyph_forms,
|
||||
page,
|
||||
plot_style,
|
||||
atlas.as_ref(),
|
||||
)
|
||||
.map_err(|error| format!("Page {}: {error}", index + 1))?;
|
||||
}
|
||||
@@ -520,12 +526,48 @@ pub fn render_pdf_bytes(
|
||||
Ok(doc.save(&PdfSaveOptions::default(), &mut warnings))
|
||||
}
|
||||
|
||||
/// Per-export atlas snapshot: every baked glyph's vector geometry keyed by its
|
||||
/// tile `uv_min`, plus the reserved solid-texel key for decoration bars.
|
||||
type AtlasExport = (
|
||||
std::collections::HashMap<u64, crate::scene::text::sdf_atlas::GlyphExport>,
|
||||
u64,
|
||||
);
|
||||
|
||||
/// Snapshot the atlas' baked-glyph geometry ONCE per export, not per TEXT
|
||||
/// entity.
|
||||
///
|
||||
/// `export_table` re-resolves each baked entry through its `Face` and clones
|
||||
/// its strokes and fill triangulation, so calling it from every `emit_text`
|
||||
/// re-copied the whole atlas for each of the hundreds of text wires a drawing
|
||||
/// carries — while holding the global atlas mutex. Export is single-threaded
|
||||
/// here and the glyph forms are cached document-wide, so one snapshot serves
|
||||
/// every page and entity. Returns `None` when no page carries text (nothing
|
||||
/// to snapshot) or the atlas mutex is poisoned (the old per-entity code
|
||||
/// skipped text in that case too).
|
||||
fn atlas_export_snapshot(pages: &[PdfPageInput]) -> Option<AtlasExport> {
|
||||
let has_text = pages.iter().any(|page| {
|
||||
page.content
|
||||
.wires
|
||||
.iter()
|
||||
.any(|wire| !wire.wire.text_verts.is_empty())
|
||||
});
|
||||
if !has_text {
|
||||
return None;
|
||||
}
|
||||
let atlas = crate::scene::text::sdf_atlas::text_atlas().lock().ok()?;
|
||||
Some((
|
||||
atlas.export_table(),
|
||||
crate::scene::text::sdf_atlas::uv_key(atlas.solid_uv()),
|
||||
))
|
||||
}
|
||||
|
||||
fn append_pdf_page(
|
||||
doc: &mut PdfDocument,
|
||||
image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
|
||||
glyph_forms: &mut std::collections::HashMap<u64, printpdf::XObjectId>,
|
||||
page: &PdfPageInput,
|
||||
fallback_plot_style: Option<&PlotStyleTable>,
|
||||
atlas: Option<&AtlasExport>,
|
||||
) -> Result<(), String> {
|
||||
// Paper dimensions are validated like the content coordinates below: a
|
||||
// NaN/inf/zero sheet (`width < MIN` compares false for NaN) would be
|
||||
@@ -758,6 +800,7 @@ fn append_pdf_page(
|
||||
emit_text(
|
||||
doc,
|
||||
glyph_forms,
|
||||
atlas,
|
||||
&mut ops,
|
||||
std::slice::from_ref(&wire.wire),
|
||||
ox,
|
||||
@@ -1551,10 +1594,12 @@ fn emit_hatch(
|
||||
// ── Text (SDF glyph quads → vector strokes / fills) ────────────────────────
|
||||
|
||||
/// Format a coordinate as the shortest PDF real with 0.01 resolution
|
||||
/// (`3`, `3.5`, `-0.25`). 0.01 pt ≈ 3.5 µm on paper — visually lossless, but
|
||||
/// it keeps path operands half the size of printpdf's default
|
||||
/// shortest-round-trip f32 text (`41.5445 28.712158` → `41.54 28.71`), which
|
||||
/// dominates text-heavy vector pages (#702).
|
||||
/// (`3`, `3.5`, `-0.25`). The resolution is in the units of whichever stream
|
||||
/// the operand lands in: 0.01 pt (≈ 3.5 µm on paper) for page-space operands,
|
||||
/// 0.01 glyph-plane units (≈ 0.1% of the 9-unit atlas tile) for Form XObject
|
||||
/// content. Both are visually lossless, and half the size of printpdf's
|
||||
/// default shortest-round-trip f32 text (`41.5445 28.712158` → `41.54 28.71`),
|
||||
/// which dominates text-heavy vector pages (#702).
|
||||
fn pdf_real(v: f32) -> String {
|
||||
let rounded = (v * 100.0).round() / 100.0;
|
||||
if rounded == rounded.trunc() && rounded.abs() < 1e15 {
|
||||
@@ -1570,6 +1615,23 @@ fn pdf_real(v: f32) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// Round a `cm` scale operand to 4 significant digits (`0.7874015` → `0.7874`).
|
||||
///
|
||||
/// The glyph `cm` scale entries are pt per glyph-plane unit — ~0.5–0.9 for
|
||||
/// small text — so quantizing them on the absolute 0.01 grid used for
|
||||
/// translations fixed the ratio by up to ~1%, accumulating visible
|
||||
/// advance-width drift across a text run. Four significant digits keep the
|
||||
/// relative error within ~0.05%; translations stay at 0.01 because they are
|
||||
/// absolute page positions, not ratios.
|
||||
fn round_sig4(v: f64) -> f64 {
|
||||
if !v.is_finite() || v == 0.0 {
|
||||
return v;
|
||||
}
|
||||
let decimals = (3 - v.abs().log10().floor() as i32).clamp(0, 17);
|
||||
let factor = 10f64.powi(decimals);
|
||||
(v * factor).round() / factor
|
||||
}
|
||||
|
||||
/// Serialize one glyph's vector geometry as a compact PDF content stream in
|
||||
/// glyph-plane units (the 9-unit space the atlas tile spans).
|
||||
///
|
||||
@@ -1698,9 +1760,13 @@ fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||
/// quad's `uv_min` and map it into that quad by affine interpolation of the
|
||||
/// plane rect — so a stroke (LFF) font emits polylines and a filled TrueType
|
||||
/// glyph emits filled triangles, exactly where the SDF quad sits.
|
||||
///
|
||||
/// The atlas geometry comes from `atlas`, the export-wide snapshot taken once
|
||||
/// in `render_pdf_bytes`.
|
||||
fn emit_text(
|
||||
doc: &mut PdfDocument,
|
||||
glyph_forms: &mut std::collections::HashMap<u64, printpdf::XObjectId>,
|
||||
atlas: Option<&AtlasExport>,
|
||||
ops: &mut Vec<Op>,
|
||||
wires: &[WireModel],
|
||||
ox: f64,
|
||||
@@ -1714,12 +1780,8 @@ fn emit_text(
|
||||
if wires.iter().all(|w| w.text_verts.is_empty()) {
|
||||
return;
|
||||
}
|
||||
// Snapshot the atlas' baked-glyph geometry once; drop the lock before use.
|
||||
let (table, solid_key) = {
|
||||
let Ok(atlas) = sdf_atlas::text_atlas().lock() else {
|
||||
return;
|
||||
};
|
||||
(atlas.export_table(), sdf_atlas::uv_key(atlas.solid_uv()))
|
||||
let Some((table, solid_key)) = atlas else {
|
||||
return;
|
||||
};
|
||||
|
||||
// `Op::SetLineDashPattern` is persistent graphics state and the wire pass
|
||||
@@ -1816,11 +1878,79 @@ fn emit_text(
|
||||
let uy = (br[1] - bl[1]) / sx as f64;
|
||||
let vx = (tl[0] - bl[0]) / sy as f64;
|
||||
let vy = (tl[1] - bl[1]) / sy as f64;
|
||||
// Degenerate quad (collapsed corners → zero-area transform):
|
||||
// skip emission, mirroring the plane guard above, instead of
|
||||
// letting a near-zero scale reach the pen division below and
|
||||
// paint a page-spanning hairline.
|
||||
let det = ux * vy - uy * vx;
|
||||
if det.abs() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let origin_x = bl[0] - ux * pmin[0] as f64 - vx * pmin[1] as f64;
|
||||
let origin_y = bl[1] - uy * pmin[0] as f64 - vy * pmin[1] as f64;
|
||||
let m2pt = MM_TO_PT as f64;
|
||||
let r2 = |v: f64| ((v * 100.0).round() / 100.0) as f32;
|
||||
|
||||
if let Some(ctb_pen) = lw_override.filter(|_| ge.fill_tris.is_empty()) {
|
||||
// Heavy CTB pen: bypass the glyph Form XObject and stroke the
|
||||
// centrelines DIRECTLY in page space. The Form /BBox is the
|
||||
// atlas tile rect, whose 1.5-unit SDF spread margin bounds
|
||||
// the nominal pen — an absolute CTB lineweight is arbitrary
|
||||
// pt and can exceed it (2.5 mm text + 1.0 mm pen → 1.79-unit
|
||||
// half-width > 1.5), squaring round caps off at the tile
|
||||
// edge. The override is per text entity, so every stroked
|
||||
// glyph of the entity takes this one path — no per-glyph
|
||||
// mixing of Form and direct pens (a filled TrueType
|
||||
// fallback glyph keeps its Form; it has no pen to clip).
|
||||
// The Form-dedupe win is irrelevant for the rare heavy-pen
|
||||
// case. The pen is already divided by the page CTM scale
|
||||
// above, so it stays absolute on paper.
|
||||
if last_outline.map(|c| color_differs(c, [r, g, b])).unwrap_or(true) {
|
||||
ops.push(Op::SetOutlineColor {
|
||||
col: Color::Rgb(Rgb {
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
icc_profile: None,
|
||||
}),
|
||||
});
|
||||
last_outline = Some([r, g, b]);
|
||||
}
|
||||
ops.push(Op::SetOutlineThickness {
|
||||
pt: Pt(if ge.bold {
|
||||
ctb_pen * 1.7
|
||||
} else {
|
||||
ctb_pen
|
||||
}),
|
||||
});
|
||||
let map = |p: [f32; 2]| -> Point {
|
||||
let u = ((p[0] - pmin[0]) / sx) as f64;
|
||||
let v = ((p[1] - pmin[1]) / sy) as f64;
|
||||
point(
|
||||
bl[0] + u * (br[0] - bl[0]) + v * (tl[0] - bl[0]),
|
||||
bl[1] + u * (br[1] - bl[1]) + v * (tl[1] - bl[1]),
|
||||
)
|
||||
};
|
||||
for stroke in &ge.strokes {
|
||||
if stroke.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
ops.push(Op::DrawLine {
|
||||
line: Line {
|
||||
points: stroke
|
||||
.iter()
|
||||
.map(|&p| LinePoint {
|
||||
p: map(p),
|
||||
bezier: false,
|
||||
})
|
||||
.collect(),
|
||||
is_closed: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Colour ops go OUTSIDE the per-glyph q/Q: the CTM must be
|
||||
// scoped, but fill colour has to persist past the Q or the
|
||||
// next glyph would fill with the page-default black despite
|
||||
@@ -1840,12 +1970,13 @@ fn emit_text(
|
||||
last_fill = Some([r, g, b]);
|
||||
}
|
||||
} else {
|
||||
// Stroke (LFF/SHX pen) font or hollow glyph.
|
||||
// Match the SDF atlas' nominal glyph-space pen instead of
|
||||
// borrowing the entity lineweight: Roman Duplex and similar
|
||||
// Stroke (LFF/SHX pen) font or hollow glyph, nominal pen:
|
||||
// match the SDF atlas' glyph-space pen instead of borrowing
|
||||
// the entity lineweight — Roman Duplex and similar
|
||||
// multi-stroke faces rely on that band to close the narrow
|
||||
// gaps between parallel centrelines. An explicit CTB
|
||||
// lineweight still wins and stays absolute under the plot CTM.
|
||||
// gaps between parallel centrelines. (An explicit CTB
|
||||
// lineweight never reaches this Form path; it takes the
|
||||
// direct heavy-pen path above.)
|
||||
if last_outline.map(|c| color_differs(c, [r, g, b])).unwrap_or(true) {
|
||||
ops.push(Op::SetOutlineColor {
|
||||
col: Color::Rgb(Rgb {
|
||||
@@ -1857,28 +1988,27 @@ fn emit_text(
|
||||
});
|
||||
last_outline = Some([r, g, b]);
|
||||
}
|
||||
let pen = if let Some(ctb_pen) = lw_override {
|
||||
if ge.bold {
|
||||
ctb_pen * 1.7
|
||||
} else {
|
||||
ctb_pen
|
||||
}
|
||||
} else {
|
||||
let glyph_unit_mm = (((tl[0] - bl[0]).powi(2) + (tl[1] - bl[1]).powi(2))
|
||||
.sqrt()
|
||||
/ sy.abs() as f64) as f32;
|
||||
let glyph_unit_mm = (((tl[0] - bl[0]).powi(2) + (tl[1] - bl[1]).powi(2))
|
||||
.sqrt()
|
||||
/ sy.abs() as f64) as f32;
|
||||
let pen =
|
||||
(2.0 * sdf_atlas::stroke_pen_half_units(ge.bold) * glyph_unit_mm * MM_TO_PT)
|
||||
.max(0.1)
|
||||
};
|
||||
.max(0.1);
|
||||
stroke_pen = Some(((pen * 100.0).round() / 100.0).max(0.1));
|
||||
}
|
||||
ops.push(Op::SaveGraphicsState);
|
||||
ops.push(Op::SetTransformationMatrix {
|
||||
matrix: printpdf::CurTransMat::Raw([
|
||||
r2(ux * m2pt),
|
||||
r2(uy * m2pt),
|
||||
r2(vx * m2pt),
|
||||
r2(vy * m2pt),
|
||||
// Scale entries carry the pt-per-glyph-unit ratio
|
||||
// (~0.5–0.9 for small text) and are rounded to 4
|
||||
// significant digits — the 0.01 absolute grid would
|
||||
// quantize them by ~1% and visibly drift the advance
|
||||
// width across a text run.
|
||||
round_sig4(ux * m2pt) as f32,
|
||||
round_sig4(uy * m2pt) as f32,
|
||||
round_sig4(vx * m2pt) as f32,
|
||||
round_sig4(vy * m2pt) as f32,
|
||||
// Translations are absolute page positions: 0.01 pt.
|
||||
r2((origin_x + ox) * m2pt),
|
||||
r2((origin_y + oy) * m2pt),
|
||||
]),
|
||||
@@ -1886,10 +2016,12 @@ fn emit_text(
|
||||
if let Some(pen) = stroke_pen {
|
||||
// Line width is GEOMETRIC state: the CTM above scales a `w`
|
||||
// set before it, so the pen is re-set per glyph, under its
|
||||
// own CTM, pre-divided by the quad's area scale to keep the
|
||||
// on-paper width identical to stroking in page units.
|
||||
let det = (ux * vy - uy * vx).abs() * m2pt * m2pt;
|
||||
let unit_scale = det.sqrt().max(1e-6) as f32;
|
||||
// own CTM, pre-divided by the quad's transform.
|
||||
// sqrt(|det|) is the geometric-mean width — it matches the
|
||||
// on-screen SDF band, and is exact only for uniform scales
|
||||
// (an anisotropic quad strokes marginally off in its thin
|
||||
// axis, the same compromise the SDF render makes).
|
||||
let unit_scale = (det.abs() * m2pt * m2pt).sqrt() as f32;
|
||||
ops.push(Op::SetOutlineThickness {
|
||||
pt: Pt((pen / unit_scale * 100.0).round() / 100.0),
|
||||
});
|
||||
@@ -1912,7 +2044,7 @@ fn emit_text(
|
||||
transform: Default::default(),
|
||||
});
|
||||
ops.push(Op::RestoreGraphicsState);
|
||||
} else if key == solid_key {
|
||||
} else if key == *solid_key {
|
||||
// Decoration bar (underline / overline / strike): the quad is a
|
||||
// solid-texel rectangle — fill it directly from its corners.
|
||||
if last_fill.map(|c| color_differs(c, [r, g, b])).unwrap_or(true) {
|
||||
@@ -2030,6 +2162,19 @@ mod tests {
|
||||
assert_eq!(downsample_target(2, 2, 1500.0, 150), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_sig4_keeps_four_significant_digits() {
|
||||
// Small-text pt-per-unit ratios keep 4 decimals…
|
||||
assert!((round_sig4(0.78740156) - 0.7874).abs() < 1e-12);
|
||||
assert!((round_sig4(-0.512399) - (-0.5124)).abs() < 1e-12);
|
||||
// …larger magnitudes shift the kept digits, not the precision…
|
||||
assert!((round_sig4(7.8740156) - 7.874).abs() < 1e-12);
|
||||
assert!((round_sig4(28.346456) - 28.35).abs() < 1e-12);
|
||||
// …and tiny ratios gain decimals instead of collapsing onto 0.01/0.1.
|
||||
assert!((round_sig4(0.078740156) - 0.07874).abs() < 1e-12);
|
||||
assert_eq!(round_sig4(0.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plot_quality_dpi_caps_match_the_dialog_presets() {
|
||||
assert_eq!(PlotQuality::High.target_dpi(), None);
|
||||
|
||||
+47
-8
@@ -166,6 +166,12 @@ pub struct PlotStyleTable {
|
||||
pub name: String,
|
||||
/// Whether this is a named-style (STB) table rather than color-based (CTB).
|
||||
pub is_stb: bool,
|
||||
/// Whether the bytes parsed through the real AutoCAD container
|
||||
/// (`PIAFILEVERSION_2.0,CTBVER1/STBVER1,compress`) rather than the
|
||||
/// permissive heuristic fallback that also accepts loose text or deflate
|
||||
/// streams. Only container tables are registered permanently; heuristic
|
||||
/// parses stay loadable for the session only.
|
||||
pub structured: bool,
|
||||
pub description: String,
|
||||
pub scale_factor: f32,
|
||||
pub apply_factor: bool,
|
||||
@@ -183,6 +189,7 @@ impl PlotStyleTable {
|
||||
PlotStyleTable {
|
||||
name: name.into(),
|
||||
is_stb: false,
|
||||
structured: false,
|
||||
description: String::new(),
|
||||
scale_factor: 1.0,
|
||||
apply_factor: false,
|
||||
@@ -207,8 +214,12 @@ impl PlotStyleTable {
|
||||
pub fn from_bytes(name: impl Into<String>, raw: &[u8]) -> Result<Self, String> {
|
||||
let name = name.into();
|
||||
let is_stb = name.to_ascii_lowercase().ends_with(".stb");
|
||||
let text = decompress_ctb(raw)?;
|
||||
parse_plot_style_text(&text, name, is_stb)
|
||||
let (text, structured) = decompress_ctb(raw)?;
|
||||
let mut table = parse_plot_style_text(&text, name, is_stb)?;
|
||||
// Record the parse route so callers can refuse to permanently
|
||||
// register tables that only the heuristic accepted.
|
||||
table.structured = structured;
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
pub fn builtin(name: &str) -> Result<Self, String> {
|
||||
@@ -434,17 +445,19 @@ impl PlotStyleTable {
|
||||
|
||||
// ── Deflate helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Decompress a CTB/STB file's raw bytes into the text content.
|
||||
///
|
||||
fn decompress_ctb(data: &[u8]) -> Result<String, String> {
|
||||
/// Decompress a CTB/STB file's raw bytes into the text content, reporting
|
||||
/// whether the real AutoCAD container was used. `false` means the permissive
|
||||
/// heuristic fallback (skip the first line, try zlib/deflate) accepted it.
|
||||
fn decompress_ctb(data: &[u8]) -> Result<(String, bool), String> {
|
||||
// CTB and STB files share this container; only the embedded version tag
|
||||
// differs ("CTBVER1" vs "STBVER1"). Accept both so real AutoCAD STB files
|
||||
// take the structured path instead of falling through to the raw
|
||||
// deflate heuristic below (where they fail with a confusing zlib error).
|
||||
const CTB_PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
|
||||
const STB_PREFIX: &[u8] = b"PIAFILEVERSION_2.0,STBVER1,compress\r\npmzlibcodec";
|
||||
let structured = data.starts_with(CTB_PREFIX) || data.starts_with(STB_PREFIX);
|
||||
let mut decoded = Vec::new();
|
||||
if data.starts_with(CTB_PREFIX) || data.starts_with(STB_PREFIX) {
|
||||
if structured {
|
||||
if data.len() < 60 {
|
||||
return Err("CTB header is truncated".into());
|
||||
}
|
||||
@@ -490,7 +503,8 @@ fn decompress_ctb(data: &[u8]) -> Result<String, String> {
|
||||
if decoded.last() == Some(&0) {
|
||||
decoded.pop();
|
||||
}
|
||||
String::from_utf8(decoded).map_err(|e| format!("CTB text is not UTF-8: {e}"))
|
||||
let text = String::from_utf8(decoded).map_err(|e| format!("CTB text is not UTF-8: {e}"))?;
|
||||
Ok((text, structured))
|
||||
}
|
||||
|
||||
/// Compress plot-style text content as a CTB/STB file.
|
||||
@@ -694,6 +708,7 @@ fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result<PlotS
|
||||
Ok(PlotStyleTable {
|
||||
name,
|
||||
is_stb,
|
||||
structured: false,
|
||||
description,
|
||||
scale_factor,
|
||||
apply_factor,
|
||||
@@ -785,12 +800,36 @@ mod round_trip_tests {
|
||||
fn stb_container_headers_parse() {
|
||||
let mut bytes =
|
||||
compress_ctb(PlotStyleTable::identity("x").to_text().as_bytes()).expect("compress");
|
||||
// "PIAFILEVERSION_2.0," is 18 bytes, so 19..22 holds the "CTB" tag.
|
||||
// "PIAFILEVERSION_2.0," is 19 bytes, so 19..22 holds the "CTB" tag.
|
||||
bytes[19..22].copy_from_slice(b"STB");
|
||||
let table = PlotStyleTable::from_bytes("mystyles.stb", &bytes).expect("stb parses");
|
||||
assert!(table.is_stb);
|
||||
}
|
||||
|
||||
/// The parse route must be visible to callers: only real AutoCAD
|
||||
/// containers may be registered permanently, while heuristic parses stay
|
||||
/// loadable for the session only.
|
||||
#[test]
|
||||
fn only_container_parses_are_flagged_structured() {
|
||||
let container =
|
||||
compress_ctb(PlotStyleTable::identity("x").to_text().as_bytes()).expect("compress");
|
||||
assert!(PlotStyleTable::from_bytes("a.ctb", &container)
|
||||
.unwrap()
|
||||
.structured);
|
||||
|
||||
// The same payload without the container header still loads through
|
||||
// the permissive heuristic, but must not claim the structured route.
|
||||
use std::io::Write;
|
||||
let mut enc =
|
||||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
enc.write_all(b"description=\"loose\n").unwrap();
|
||||
let zlib = enc.finish().unwrap();
|
||||
let mut loose = b"any first line\n".to_vec();
|
||||
loose.extend_from_slice(&zlib);
|
||||
let table = PlotStyleTable::from_bytes("b.ctb", &loose).unwrap();
|
||||
assert!(!table.structured);
|
||||
}
|
||||
|
||||
/// Named tables are refused with the actual limitation, not a generic
|
||||
/// invalid-name error (and without touching the file system).
|
||||
#[test]
|
||||
|
||||
+57
-12
@@ -117,11 +117,16 @@ fn windows_printers() -> std::io::Result<Vec<String>> {
|
||||
// but on Windows 11 it can miss per-user network printers that only the
|
||||
// spooler RPC (level 2) reports (#1261a). The level-2 query is expensive —
|
||||
// it opens every remote connection and can wait out RPC timeouts on dead
|
||||
// print servers — so it only runs when the cached query came back empty;
|
||||
// machines whose level-4 list works never pay for it. (The flags stay
|
||||
// LOCAL|CONNECTIONS on purpose: the EnumPrintersW documentation restricts
|
||||
// level 4 to exactly those flags, and PRINTER_ENUM_NETWORK is only valid
|
||||
// with level 1, so completeness comes from the level-2 retry, not flags.)
|
||||
// print servers — so it only runs when the cached list looks incomplete:
|
||||
// either it came back empty, or it does not contain the configured
|
||||
// default printer, which is exactly the case where the default is one of
|
||||
// the hidden per-user connections. Machines whose level-4 list already
|
||||
// covers the default never pay for it. A level-2 failure is not fatal:
|
||||
// the level-4 names stand and the default merge below still guarantees
|
||||
// the default is offered. (The flags stay LOCAL|CONNECTIONS on purpose:
|
||||
// the EnumPrintersW documentation restricts level 4 to exactly those
|
||||
// flags, and PRINTER_ENUM_NETWORK is only valid with level 1, so
|
||||
// completeness comes from the level-2 retry, not flags.)
|
||||
let mut names = enum_printer_names(
|
||||
4,
|
||||
PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS,
|
||||
@@ -129,18 +134,26 @@ fn windows_printers() -> std::io::Result<Vec<String>> {
|
||||
// pPrinterName is the first field of PRINTER_INFO_4W.
|
||||
0,
|
||||
)?;
|
||||
if names.is_empty() {
|
||||
names = enum_printer_names(
|
||||
let default = windows_default_printer();
|
||||
let hides_default = default
|
||||
.as_ref()
|
||||
.is_some_and(|name| !names.iter().any(|n| n.eq_ignore_ascii_case(name)));
|
||||
if names.is_empty() || hides_default {
|
||||
// Errors here are deliberately swallowed: falling through with the
|
||||
// level-4 names still lets the default merge below run.
|
||||
if let Ok(extra) = enum_printer_names(
|
||||
2,
|
||||
PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS,
|
||||
std::mem::size_of::<PRINTER_INFO_2W>(),
|
||||
// pPrinterName is the second pointer field of PRINTER_INFO_2W.
|
||||
std::mem::size_of::<usize>(),
|
||||
)?;
|
||||
) {
|
||||
names.extend(extra);
|
||||
}
|
||||
}
|
||||
// The spooler's own default is authoritative even when enumeration missed
|
||||
// it; always offer it explicitly so the list can never hide it (#1261b).
|
||||
if let Some(default) = windows_default_printer() {
|
||||
if let Some(default) = default {
|
||||
if !names.iter().any(|name| name.eq_ignore_ascii_case(&default)) {
|
||||
names.push(default);
|
||||
}
|
||||
@@ -301,6 +314,18 @@ fn windows_default_printer() -> Option<String> {
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
/// Build the `lpParameters` string for the shell `printto` verb. The verb
|
||||
/// passes four arguments — file, printer, driver, port — and registered PDF
|
||||
/// handlers quote each slot (Acrobat: `/t "%1" "%2" "%3" "%4"`), i.e. they
|
||||
/// whitespace-tokenise the parameter string. A bare printer name therefore
|
||||
/// shatters on spaces ("Microsoft Print to PDF" becomes printer=Microsoft,
|
||||
/// driver=Print, port=to PDF) and the job is lost with all UI suppressed.
|
||||
/// Quote the name and pass the empty driver/port slots handlers expect.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn printto_params(printer: &str) -> String {
|
||||
format!("\"{printer}\" \"\" \"\"")
|
||||
}
|
||||
|
||||
/// Probe the spooler for `name` before dispatching a job (#1261c).
|
||||
/// `OpenPrinterW` with no `PRINTER_DEFAULTSW` is a pure
|
||||
/// existence/permission check: it spools nothing and shows no UI, and gives a
|
||||
@@ -368,11 +393,24 @@ fn describe_shell_execute_failure(code: u32) -> &'static str {
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
mod printer_buffer_tests {
|
||||
use super::{
|
||||
describe_shell_execute_failure, ensure_printer_exists, windows_default_printer,
|
||||
windows_printer_names,
|
||||
describe_shell_execute_failure, ensure_printer_exists, printto_params,
|
||||
windows_default_printer, windows_printer_names,
|
||||
};
|
||||
use windows_sys::Win32::Graphics::Printing::{PRINTER_INFO_2W, PRINTER_INFO_4W};
|
||||
|
||||
/// The `printto` parameters are whitespace-tokenised by registered PDF
|
||||
/// handlers (Acrobat: `/t "%1" "%2" "%3" "%4"`), so the printer name must
|
||||
/// arrive quoted with the empty driver/port slots padded — a bare
|
||||
/// multi-word name used to split into printer/driver/port garbage.
|
||||
#[test]
|
||||
fn printto_params_quote_the_printer_and_pad_empty_slots() {
|
||||
assert_eq!(
|
||||
printto_params("Microsoft Print to PDF"),
|
||||
"\"Microsoft Print to PDF\" \"\" \"\""
|
||||
);
|
||||
assert_eq!(printto_params("LaserJet"), "\"LaserJet\" \"\" \"\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printer_names_stay_within_the_returned_buffer() {
|
||||
assert!(
|
||||
@@ -682,8 +720,15 @@ fn dispatch_to_printer_opts(path: &std::path::Path, opts: &PrintOptions) -> Resu
|
||||
if let Some(name) = &target {
|
||||
ensure_printer_exists(name)?;
|
||||
}
|
||||
// One construction point serves both the resolved default and a
|
||||
// named selection (`target` covers the two); the name is quoted via
|
||||
// `printto_params` so handlers cannot tokenize it on spaces.
|
||||
let (verb, params, label) = match &target {
|
||||
Some(name) => (wide("printto"), Some(wide(name)), name.clone()),
|
||||
Some(name) => (
|
||||
wide("printto"),
|
||||
Some(wide(&printto_params(name))),
|
||||
name.clone(),
|
||||
),
|
||||
None => (wide("print"), None, "default printer".to_string()),
|
||||
};
|
||||
let params_ptr = params
|
||||
|
||||
@@ -353,6 +353,21 @@ impl CadCommand for HelixCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// The Final step's dynamic box is the helix height — a span along the
|
||||
// plane normal — but its typed value would otherwise resolve into an
|
||||
// in-plane point anchored at the last pick, and `on_point` commits that
|
||||
// point's distance from the center as the height: a wrong value whenever
|
||||
// the previous pick (e.g. the top radius) wasn't the center. This is the
|
||||
// only entry route on web, where the canvas — not the command line —
|
||||
// holds keyboard focus. `on_text_input` parses the same string the
|
||||
// command-line path would (keywords and `parse_typed_length`), so both
|
||||
// entry routes behave identically (#1082). The Base/TopRadius steps keep
|
||||
// committing typed digits as points: their distance from the center IS
|
||||
// the radius.
|
||||
fn dyn_commit_as_text(&self) -> bool {
|
||||
self.step == Step::Final
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let value = text.trim();
|
||||
let keyword = value.to_ascii_uppercase();
|
||||
|
||||
@@ -1862,6 +1862,21 @@ impl CadCommand for SweepCommand {
|
||||
matches!(self.step, SweepStep::PickPath | SweepStep::ReferenceLength | SweepStep::NewLength)
|
||||
}
|
||||
|
||||
// The reference-scale prompts read a typed length, but they also accept
|
||||
// keyword letters, so without this a value committed from the dynamic
|
||||
// box (the only route on web, where the canvas — not the command line —
|
||||
// holds keyboard focus) resolves into an in-plane point instead: at
|
||||
// `ReferenceLength` the typed length is dropped and stored as a stray
|
||||
// first reference pick, at `NewLength` the scale is computed from the
|
||||
// distance of that arbitrary point to the length anchor. `on_text_input`
|
||||
// parses the same strings the command-line path would (keywords and
|
||||
// `parse_typed_length`), so both entry routes behave identically (#1082).
|
||||
// PickPath stays a point step: its letters are option keywords and its
|
||||
// picks are path entities.
|
||||
fn dyn_commit_as_text(&self) -> bool {
|
||||
matches!(self.step, SweepStep::ReferenceLength | SweepStep::NewLength)
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let value = text.trim();
|
||||
if value.is_empty() {
|
||||
|
||||
@@ -779,6 +779,18 @@ impl CadCommand for CylinderCommand {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// The Height step's dynamic box is a span along the base-plane normal,
|
||||
// not a point in the drawing plane. Without this, a typed value committed
|
||||
// from the box (the only route on web, where the canvas — not the command
|
||||
// line — holds keyboard focus) is resolved into an in-plane point whose
|
||||
// height projects to ~0 and `commit_height` silently re-prompts.
|
||||
// `on_text_input` parses the same string the command-line path would
|
||||
// (keywords and `parse_typed_length`), so both entry routes behave
|
||||
// identically (#1082).
|
||||
fn dyn_commit_as_text(&self) -> bool {
|
||||
matches!(self.step, Step::Height)
|
||||
}
|
||||
}
|
||||
|
||||
fn push_break(points: &mut Vec<[f32; 3]>) {
|
||||
|
||||
@@ -3405,6 +3405,33 @@ impl CadCommand for PrimitiveCommand {
|
||||
}
|
||||
self.height_step.then(|| self.cursor_height(cursor))
|
||||
}
|
||||
|
||||
// The height prompts' dynamic boxes are spans along the working-plane
|
||||
// normal, not points in the drawing plane. These steps accept keyword
|
||||
// letters (2Point / Axis endpoint / Top radius), so without this a typed
|
||||
// value committed from the box (the only route on web, where the canvas —
|
||||
// not the command line — holds keyboard focus) is resolved into an
|
||||
// in-plane point whose height projects to ~0 and the commit is silently
|
||||
// dropped with a re-prompt. `on_text_input` parses the same string the
|
||||
// command-line path would (keywords and `parse_typed_length`), so both
|
||||
// entry routes behave identically (#1082). BOX and WEDGE share
|
||||
// `BoxStep::Height`; the 2Point/AxisEndpoint sub-steps keep committing
|
||||
// as points, and SPHERE/TORUS radius steps type digits as in-plane
|
||||
// distances by design.
|
||||
fn dyn_commit_as_text(&self) -> bool {
|
||||
match self.shape {
|
||||
Shape::Cone => matches!(
|
||||
self.cone_step,
|
||||
ConeStep::Height | ConeStep::HeightAfterTopRadius
|
||||
),
|
||||
Shape::Pyramid => matches!(
|
||||
self.pyramid_step,
|
||||
PyramidStep::Height | PyramidStep::HeightAfterTopRadius
|
||||
),
|
||||
Shape::Box | Shape::Wedge => self.box_step == BoxStep::Height,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footprint preview ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,37 +10,96 @@ use super::wire_model::WireModel;
|
||||
/// of the mesh converters' `SNAP_TABLE_MAX_VERTICES`: a mesh dense enough to
|
||||
/// exceed this has edges far too close together to pick apart at snap
|
||||
/// aperture size, and duplicating them into the wire set would cost more
|
||||
/// memory than the whole rest of the interaction set.
|
||||
/// memory than the whole rest of the interaction set. The builder budgets
|
||||
/// SEGMENTS against this (each costs two endpoints plus a NaN separator) and
|
||||
/// decimates rather than dropping when it is exceeded.
|
||||
pub const SNAP_EDGE_WIRE_MAX_POINTS: usize = 100_000;
|
||||
|
||||
/// Segment budget implied by [`SNAP_EDGE_WIRE_MAX_POINTS`]: S segments cost
|
||||
/// `3·S − 1` wire points (two endpoints each plus a NaN separator between
|
||||
/// neighbours), so the point cap admits at most `(cap + 1) / 3` segments.
|
||||
const MAX_EDGE_SEGMENTS: usize = (SNAP_EDGE_WIRE_MAX_POINTS + 1) / 3;
|
||||
|
||||
/// Halve `kept` in place, retaining every second segment (indices 0, 2, …).
|
||||
fn halve_kept_segments(kept: &mut Vec<([f64; 3], [f64; 3])>) {
|
||||
let mut write = 0;
|
||||
for read in (0..kept.len()).step_by(2) {
|
||||
kept[write] = kept[read];
|
||||
write += 1;
|
||||
}
|
||||
kept.truncate(write);
|
||||
}
|
||||
|
||||
/// Build the interaction-only edge wire shared by every volume entity
|
||||
/// (3D solids, regions, bodies, surfaces, plain meshes).
|
||||
///
|
||||
/// `pairs` yields one `(a, b)` world-space segment per edge. The wire carries
|
||||
/// the segments as NaN-separated point pairs (so `points` never chains two
|
||||
/// unrelated edges together) plus the same pairs in `key_vertices` (so
|
||||
/// Endpoint/Midpoint snap sees every true edge end / edge middle, not the
|
||||
/// ends of an artificial chain). It is `display_visible: false` /
|
||||
/// `plot_visible: false`: the mesh pipeline already draws these edges, the
|
||||
/// wire exists so the SNAPPER and the proximity pick see them exactly like
|
||||
/// any 2-D entity's geometry (#1030).
|
||||
/// unrelated edges together) plus the same NaN-separated pairs in
|
||||
/// `key_vertices` (so Endpoint/Midpoint snap sees every true edge end / edge
|
||||
/// middle, not the ends of an artificial chain between unrelated edges —
|
||||
/// Midpoint consumers treat consecutive `key_vertices` as one segment). It is
|
||||
/// `display_visible: false` / `plot_visible: false`: the mesh pipeline
|
||||
/// already draws these edges, the wire exists so the SNAPPER and the
|
||||
/// proximity pick see them exactly like any 2-D entity's geometry (#1030).
|
||||
///
|
||||
/// Returns `None` when the edge count exceeds [`SNAP_EDGE_WIRE_MAX_POINTS`].
|
||||
/// Past [`MAX_EDGE_SEGMENTS`] the wire is not dropped but DECIMATED: the
|
||||
/// segment pairs are uniformly subsampled (every Nth, N a power of two, with
|
||||
/// the first and last segments always retained) down to the budget. Dropping
|
||||
/// the wire outright used to cost dense meshes ALL endpoint snap; decimation
|
||||
/// keeps partial coverage at the same bounded memory. The tradeoff: mid-wire
|
||||
/// edges thin out (their Midpoint/Nearest coverage becomes sparser) while
|
||||
/// both ends of the mesh stay snap-reachable.
|
||||
///
|
||||
/// Returns `None` only when `pairs` yields no segments at all.
|
||||
pub fn edge_snap_wire(
|
||||
name: String,
|
||||
color: [f32; 4],
|
||||
selected: bool,
|
||||
pairs: impl Iterator<Item = ([f64; 3], [f64; 3])>,
|
||||
) -> Option<WireModel> {
|
||||
let mut points: Vec<[f32; 3]> = Vec::new();
|
||||
let mut points_low: Vec<[f32; 3]> = Vec::new();
|
||||
let mut key_vertices: Vec<[f64; 3]> = Vec::new();
|
||||
// ── Segment selection with bounded memory ─────────────────────────────
|
||||
// Stream the pairs and keep at most MAX_EDGE_SEGMENTS of them: whenever
|
||||
// the kept buffer fills, halve it and double the stride. That is uniform
|
||||
// "every Nth" subsampling (power-of-two N) without ever buffering more
|
||||
// than the budget — a runaway mesh costs O(cap) here, not O(edges), and
|
||||
// its (already resident) tessellation is never duplicated.
|
||||
let mut kept: Vec<([f64; 3], [f64; 3])> = Vec::new();
|
||||
let mut stride = 1usize;
|
||||
let mut total = 0usize;
|
||||
let mut last_pair: Option<([f64; 3], [f64; 3])> = None;
|
||||
for pair in pairs {
|
||||
if total % stride == 0 {
|
||||
if kept.len() >= MAX_EDGE_SEGMENTS {
|
||||
halve_kept_segments(&mut kept);
|
||||
stride *= 2;
|
||||
}
|
||||
kept.push(pair);
|
||||
}
|
||||
last_pair = Some(pair);
|
||||
total += 1;
|
||||
}
|
||||
// Force-retain the final segment so decimation never removes the far end
|
||||
// of the mesh: `(total - 1) % stride != 0` proves it is not already kept
|
||||
// (every kept entry sits at an index that is a multiple of `stride`).
|
||||
if let Some(last) = last_pair {
|
||||
if (total - 1) % stride != 0 {
|
||||
if kept.len() >= MAX_EDGE_SEGMENTS {
|
||||
halve_kept_segments(&mut kept);
|
||||
}
|
||||
kept.push(last);
|
||||
}
|
||||
}
|
||||
|
||||
let mut points: Vec<[f32; 3]> = Vec::with_capacity(kept.len().saturating_mul(3));
|
||||
let mut points_low: Vec<[f32; 3]> = Vec::with_capacity(kept.len().saturating_mul(3));
|
||||
let mut key_vertices: Vec<[f64; 3]> = Vec::with_capacity(kept.len().saturating_mul(3));
|
||||
let mut aabb = WireModel::UNBOUNDED_AABB;
|
||||
let mut extend = |p: [f64; 3]| {
|
||||
let extend = |p: [f64; 3], aabb: &mut [f32; 4]| {
|
||||
let (x, y) = (p[0] as f32, p[1] as f32);
|
||||
if x.is_finite() && y.is_finite() {
|
||||
if aabb == WireModel::UNBOUNDED_AABB {
|
||||
aabb = [x, y, x, y];
|
||||
if *aabb == WireModel::UNBOUNDED_AABB {
|
||||
*aabb = [x, y, x, y];
|
||||
} else {
|
||||
aabb[0] = aabb[0].min(x);
|
||||
aabb[1] = aabb[1].min(y);
|
||||
@@ -49,16 +108,14 @@ pub fn edge_snap_wire(
|
||||
}
|
||||
}
|
||||
};
|
||||
for (a, b) in pairs {
|
||||
if points.len() + 2 > SNAP_EDGE_WIRE_MAX_POINTS {
|
||||
return None;
|
||||
}
|
||||
if !points.is_empty() {
|
||||
for (index, (a, b)) in kept.into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
points.push([f32::NAN; 3]);
|
||||
points_low.push([0.0; 3]);
|
||||
if points.len() + 2 > SNAP_EDGE_WIRE_MAX_POINTS {
|
||||
return None;
|
||||
}
|
||||
// Mirrors `points`: without this separator Midpoint would pair
|
||||
// the last vertex of one edge with the first vertex of the next
|
||||
// and fabricate a floating midpoint between unrelated edges.
|
||||
key_vertices.push([f64::NAN; 3]);
|
||||
}
|
||||
for p in [a, b] {
|
||||
let (hx, lx) = WireModel::split_ds(p[0]);
|
||||
@@ -67,7 +124,7 @@ pub fn edge_snap_wire(
|
||||
points.push([hx, hy, hz]);
|
||||
points_low.push([lx, ly, lz]);
|
||||
key_vertices.push(p);
|
||||
extend(p);
|
||||
extend(p, &mut aabb);
|
||||
}
|
||||
}
|
||||
if points.is_empty() {
|
||||
|
||||
@@ -273,7 +273,8 @@ pub fn edge_wires(body: &Body) -> Vec<acadrust::entities::Wire> {
|
||||
/// (`Solid3D::wires` and friends — pre-computed B-rep edge chains, also set
|
||||
/// on the temporary live-section entity). Each chain contributes its
|
||||
/// consecutive point pairs as snap segments (#1030). Returns `None` when the
|
||||
/// chains carry no usable segments or exceed the shared snap-wire point cap.
|
||||
/// chains carry no usable segments; past the shared snap-wire point cap
|
||||
/// `edge_snap_wire` decimates instead of dropping.
|
||||
pub fn entity_edge_snap_wire(
|
||||
chains: &[acadrust::entities::Wire],
|
||||
name: String,
|
||||
|
||||
+159
-37
@@ -2491,37 +2491,64 @@ fn wire_plane(wire: &WireModel) -> Option<WirePlane> {
|
||||
Some(WirePlane { origin, axis_x, axis_y, normal: axis_x.cross(axis_y).normalize() })
|
||||
}
|
||||
|
||||
/// The f64 endpoints behind a `TangentGeom::Line`. The geom itself stores
|
||||
/// f32, which quantises survey-scale coordinates by centimetres; the wire's
|
||||
/// tessellation points (a double-single pair) hold the same vertices in full
|
||||
/// precision, so when a consecutive point pair matches the geom, use it.
|
||||
fn line_geom_endpoints_f64(wire: &WireModel, p1: [f32; 3], p2: [f32; 3]) -> (DVec3, DVec3) {
|
||||
if wire.points.len() == 2 {
|
||||
return (wp_f64(wire, 0), wp_f64(wire, 1));
|
||||
}
|
||||
let f32_pair = (Vec3::from_array(p1).as_dvec3(), Vec3::from_array(p2).as_dvec3());
|
||||
// The f64 pair matches the geom's own segment when it sits within the
|
||||
// f32 cast's rounding error (≤ 0.5 ulp of the coordinate magnitude).
|
||||
// Take the CLOSEST pair, not the first within tolerance: adjacent
|
||||
// polyline vertices can sit closer together than the quantisation at
|
||||
// survey scale, and a first-match scan would then grab a neighbour's
|
||||
// segment.
|
||||
let mag = p1.iter().chain(p2.iter()).fold(0.0f32, |m, c| m.max(c.abs()));
|
||||
let tol = (mag * f32::EPSILON) as f64 + 1e-9;
|
||||
let mut best: Option<(f64, DVec3, DVec3)> = None;
|
||||
/// Lookup key for [`line_endpoint_map`]: the f32 bit patterns of the six
|
||||
/// endpoint coordinates with the two lowest mantissa bits cleared (a ~4-ulp
|
||||
/// grid). The geom's stored f32 and the f32 cast of the wire's double-single
|
||||
/// reconstruction sit within an ulp of each other, so both quantise into the
|
||||
/// same bucket; distinct segments only collide when they share an endpoint
|
||||
/// to well below snap distinguishability.
|
||||
type LineGeomKey = ([u32; 3], [u32; 3]);
|
||||
|
||||
fn line_geom_key(p1: [f32; 3], p2: [f32; 3]) -> LineGeomKey {
|
||||
const MASK: u32 = !0b11;
|
||||
let quantise =
|
||||
|p: [f32; 3]| [p[0].to_bits() & MASK, p[1].to_bits() & MASK, p[2].to_bits() & MASK];
|
||||
(quantise(p1), quantise(p2))
|
||||
}
|
||||
|
||||
/// One-shot map from a `TangentGeom::Line`'s quantised f32 endpoint pair to
|
||||
/// the wire's full-precision f64 pair behind it, so every Line geom of the
|
||||
/// wire resolves with an O(1) lookup instead of rescanning the whole
|
||||
/// tessellation per geom (which made `curves_in_frame` O(points × geoms) for
|
||||
/// every wire pair on every snap). First-wins on quantisation collisions:
|
||||
/// adjacent vertices closer than the grid are indistinguishable at snap
|
||||
/// scale anyway.
|
||||
fn line_endpoint_map(wire: &WireModel) -> HashMap<LineGeomKey, (DVec3, DVec3)> {
|
||||
let mut map =
|
||||
HashMap::with_capacity_and_hasher(wire.points.len() / 2 + 1, Default::default());
|
||||
for index in 0..wire.points.len().saturating_sub(1) {
|
||||
let a = wp_f64(wire, index);
|
||||
let b = wp_f64(wire, index + 1);
|
||||
// NaN separators split runs; either side being NaN never matches.
|
||||
// NaN separators split runs; neither side of them can match a geom.
|
||||
if !a.is_finite() || !b.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let miss = (a - f32_pair.0).length_squared() + (b - f32_pair.1).length_squared();
|
||||
if miss <= 4.0 * tol * tol && best.map_or(true, |(m, _, _)| miss < m) {
|
||||
best = Some((miss, a, b));
|
||||
}
|
||||
let key = line_geom_key(a.as_vec3().to_array(), b.as_vec3().to_array());
|
||||
map.entry(key).or_insert((a, b));
|
||||
}
|
||||
best.map(|(_, a, b)| (a, b)).unwrap_or(f32_pair)
|
||||
map
|
||||
}
|
||||
|
||||
/// The f64 endpoints behind a `TangentGeom::Line`. The geom itself stores
|
||||
/// f32, which quantises survey-scale coordinates by centimetres; the wire's
|
||||
/// tessellation points (a double-single pair) hold the same vertices in full
|
||||
/// precision, so resolve the geom through [`line_endpoint_map`] (built once
|
||||
/// per wire by the caller). A geom that misses the map falls back to its own
|
||||
/// f32 pair widened — exactly the no-match behaviour of the scan this
|
||||
/// replaced.
|
||||
fn line_geom_endpoints_f64(
|
||||
wire: &WireModel,
|
||||
p1: [f32; 3],
|
||||
p2: [f32; 3],
|
||||
endpoints: &HashMap<LineGeomKey, (DVec3, DVec3)>,
|
||||
) -> (DVec3, DVec3) {
|
||||
if wire.points.len() == 2 {
|
||||
return (wp_f64(wire, 0), wp_f64(wire, 1));
|
||||
}
|
||||
endpoints
|
||||
.get(&line_geom_key(p1, p2))
|
||||
.copied()
|
||||
.unwrap_or((Vec3::from_array(p1).as_dvec3(), Vec3::from_array(p2).as_dvec3()))
|
||||
}
|
||||
|
||||
/// Decompose a wire into its analytic curves inside `frame`.
|
||||
@@ -2549,10 +2576,14 @@ fn curves_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option<Vec<
|
||||
};
|
||||
|
||||
let mut curves = Vec::with_capacity(wire.tangent_geoms.len());
|
||||
// Built on the FIRST Line geom and shared by every geom of this wire:
|
||||
// one O(points) pass instead of an O(points) scan per Line geom.
|
||||
let mut line_endpoints: Option<HashMap<LineGeomKey, (DVec3, DVec3)>> = None;
|
||||
for geom in &wire.tangent_geoms {
|
||||
let curve = match geom {
|
||||
TangentGeom::Line { p1, p2 } => {
|
||||
let (p1, p2) = line_geom_endpoints_f64(wire, *p1, *p2);
|
||||
let endpoints = line_endpoints.get_or_insert_with(|| line_endpoint_map(wire));
|
||||
let (p1, p2) = line_geom_endpoints_f64(wire, *p1, *p2, endpoints);
|
||||
(frame.contains(p1, tol) && frame.contains(p2, tol)).then(|| {
|
||||
let (start, end) = (frame.to_2d(p1), frame.to_2d(p2));
|
||||
// A crossing can sit exactly on a segment endpoint (the
|
||||
@@ -2668,6 +2699,9 @@ fn exact_curve_intersections(wire_a: &WireModel, wire_b: &WireModel) -> Option<V
|
||||
return None;
|
||||
}
|
||||
|
||||
// NOTE: the nominal 1e-9 request is dominated by PLANE_TOL —
|
||||
// `max(1e-9, PLANE_TOL)` is effectively 1e-7 here, matching the plane
|
||||
// membership tolerance the frame reduction above already applied.
|
||||
let tolerance = cadkernel::geom2d::Tolerance::new(1e-9_f64.max(PLANE_TOL));
|
||||
let linear = tolerance.linear();
|
||||
let mut points: Vec<DVec3> = Vec::new();
|
||||
@@ -4259,6 +4293,11 @@ mod ext_tests {
|
||||
];
|
||||
let wire =
|
||||
entity_edge_snap_wire(&chains, "42".to_owned(), [0.8, 0.8, 0.85, 1.0]).expect("wire");
|
||||
// Segment pairs stay NaN-separated across CHAIN junctions too (3 + 3
|
||||
// + 1 = 7 pairs → 6 separators), so Midpoint never pairs the last
|
||||
// vertex of one chain with the first vertex of the next.
|
||||
let separators = wire.key_vertices.iter().filter(|p| !p[0].is_finite()).count();
|
||||
assert_eq!(separators, 6);
|
||||
let wires = [wire];
|
||||
let mut snapper = Snapper::default();
|
||||
snapper.snap_enabled = true;
|
||||
@@ -4283,24 +4322,107 @@ mod ext_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_snap_wire_caps_runaway_meshes() {
|
||||
use crate::scene::model::mesh_model::{edge_snap_wire, SNAP_EDGE_WIRE_MAX_POINTS};
|
||||
let segment = ([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
|
||||
// Each pair costs up to 3 points (2 endpoints + a NaN separator), so
|
||||
// a third of the cap fits and the full cap must not.
|
||||
let pairs = std::iter::repeat(segment).take(SNAP_EDGE_WIRE_MAX_POINTS / 3);
|
||||
assert!(edge_snap_wire("ok".into(), [1.0; 4], false, pairs).is_some());
|
||||
let pairs = std::iter::repeat(segment).take(SNAP_EDGE_WIRE_MAX_POINTS);
|
||||
/// Inverse of [`snap_one`]: the same camera scaffolding, asserting that
|
||||
/// nothing snap-enabled lands anywhere near `near`.
|
||||
fn snap_none(snapper: &mut Snapper, wires: &[WireModel], near: DVec3) {
|
||||
// Zoomed out: 1 world unit ≈ 5 px, same as `snap_one`.
|
||||
let view_rot = Mat4::from_scale(Vec3::splat(0.01));
|
||||
let bounds = Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1000.0,
|
||||
height: 1000.0,
|
||||
};
|
||||
let eye = DVec3::new(near.x, near.y, 500.0);
|
||||
let cursor_screen = world_to_screen(near, view_rot, eye, bounds);
|
||||
let res = snapper.snap(
|
||||
near, cursor_screen, wires, view_rot, eye, bounds, Vec3::ZERO,
|
||||
(Vec3::X, Vec3::Y, Vec3::Z), None,
|
||||
);
|
||||
assert!(
|
||||
edge_snap_wire("too big".into(), [1.0; 4], false, pairs).is_none(),
|
||||
"meshes past the point cap must ship no snap wire"
|
||||
res.is_none(),
|
||||
"nothing must snap near {near:?}, got {:?}",
|
||||
res.map(|r| (r.snap_type, r.world))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_snap_wire_disjoint_edges_have_no_phantom_midpoint() {
|
||||
use crate::scene::model::mesh_model::edge_snap_wire;
|
||||
// Two DISJOINT triangles 10 units apart, fed exactly like
|
||||
// `MeshLodSet::snap_edge_wire` feeds `edge_snap_wire` (three segment
|
||||
// pairs per triangle).
|
||||
let triangle = |ox: f64| {
|
||||
let a = [ox, 0.0, 0.0];
|
||||
let b = [ox + 1.0, 0.0, 0.0];
|
||||
let c = [ox + 0.5, 1.0, 0.0];
|
||||
[(a, b), (b, c), (c, a)]
|
||||
};
|
||||
let pairs = triangle(0.0).into_iter().chain(triangle(10.0));
|
||||
let wire = edge_snap_wire("t".into(), [1.0; 4], false, pairs).expect("wire");
|
||||
// `key_vertices` must separate the segment pairs with NaN exactly
|
||||
// like `points` does. Without the separators, every Midpoint consumer
|
||||
// pairs the LAST vertex of one edge with the FIRST vertex of the next
|
||||
// — here triangle 0's (0,0,0) with triangle 10's (10,0,0) — and
|
||||
// fabricates a floating midpoint between unrelated edges.
|
||||
let separators = wire.key_vertices.iter().filter(|p| !p[0].is_finite()).count();
|
||||
assert_eq!(separators, 5, "one NaN separator between each of the 6 pairs");
|
||||
|
||||
let wires = [wire];
|
||||
let mut snapper = Snapper::default();
|
||||
snapper.snap_enabled = true;
|
||||
snapper.enabled = [SnapType::Midpoint].into_iter().collect();
|
||||
// The junction's phantom midpoint would sit at (5, 0, 0); the nearest
|
||||
// true midpoint is ~4.3 world units away, far outside the aperture.
|
||||
snap_none(&mut snapper, &wires, DVec3::new(5.0, 0.0, 0.0));
|
||||
// Sanity: a true edge midpoint still snaps through key_vertices.
|
||||
snap_one(
|
||||
&mut snapper,
|
||||
&wires,
|
||||
DVec3::new(0.6, 0.05, 0.0),
|
||||
SnapType::Midpoint,
|
||||
DVec3::new(0.5, 0.0, 0.0),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_snap_wire_caps_runaway_meshes() {
|
||||
use crate::scene::model::mesh_model::{edge_snap_wire, SNAP_EDGE_WIRE_MAX_POINTS};
|
||||
let segment = |i: usize| ([i as f64, 0.0, 0.0], [(i + 1) as f64, 0.0, 0.0]);
|
||||
let segment_count = |wire: &WireModel| {
|
||||
// One NaN separator between neighbouring pairs → runs − 1.
|
||||
wire.points.iter().filter(|p| !p[0].is_finite()).count() + 1
|
||||
};
|
||||
// Each pair costs up to 3 points (2 endpoints + a NaN separator), so
|
||||
// a third of the point cap in SEGMENTS fits without decimation.
|
||||
let fitting = SNAP_EDGE_WIRE_MAX_POINTS / 3;
|
||||
let wire = edge_snap_wire("ok".into(), [1.0; 4], false, (0..fitting).map(segment))
|
||||
.expect("wire at the segment budget");
|
||||
assert_eq!(segment_count(&wire), fitting);
|
||||
assert!(wire.points.len() <= SNAP_EDGE_WIRE_MAX_POINTS);
|
||||
|
||||
// Past the budget the wire is DECIMATED, not dropped: the segment
|
||||
// pairs are uniformly subsampled down to the budget so dense meshes
|
||||
// keep (partial) endpoint coverage instead of losing their edge wire
|
||||
// — and with it all endpoint snap — entirely.
|
||||
let total = SNAP_EDGE_WIRE_MAX_POINTS; // 3x the segment budget
|
||||
let wire = edge_snap_wire("decimated".into(), [1.0; 4], false, (0..total).map(segment))
|
||||
.expect("over-cap mesh must still ship a decimated snap wire");
|
||||
let kept = segment_count(&wire);
|
||||
assert!(kept < total, "over-cap wire must be decimated, kept {kept}");
|
||||
assert!(
|
||||
wire.points.len() <= SNAP_EDGE_WIRE_MAX_POINTS,
|
||||
"decimated wire must honour the point cap ({} points)",
|
||||
wire.points.len()
|
||||
);
|
||||
// The first and last source segments survive decimation, so both ends
|
||||
// of the mesh stay endpoint-snappable.
|
||||
assert!(wire.key_vertices.contains(&[0.0, 0.0, 0.0]));
|
||||
assert!(wire.key_vertices.contains(&[total as f64, 0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_solid_with_mesh_cache_snaps_endpoint_midpoint_nearest() {
|
||||
use crate::scene::model::mesh_model::MeshLodSet;
|
||||
use crate::scene::Scene;
|
||||
use acadrust::entities::Solid3D;
|
||||
|
||||
|
||||
+6
-6
@@ -567,12 +567,6 @@ pub struct GridCanvasState {
|
||||
pub cache: canvas::Cache<iced::Renderer>,
|
||||
}
|
||||
|
||||
/// Compute the adaptive grid step size (world units) from camera zoom.
|
||||
///
|
||||
/// Returns the smallest power-of-5 multiple of 1.0 that places grid lines at
|
||||
/// least `MIN_GRID_PX` pixels apart at the camera pivot. Both orthographic and
|
||||
/// perspective cameras have the same vertical scale there. Camera rotation is
|
||||
/// intentionally absent so orbiting cannot rescale the visible grid or snap.
|
||||
/// Perpendicular screen distance (px) from `p` to the infinite line through
|
||||
/// `a` and `b`. Degenerate (`a`≈`b`) falls back to the point distance. Used
|
||||
/// to tell whether a tracking vector really passes through the locked cursor
|
||||
@@ -632,6 +626,12 @@ fn clip_seg(p0: Point, p1: Point, bounds: iced::Rectangle) -> Option<(Point, Poi
|
||||
))
|
||||
}
|
||||
|
||||
/// Compute the adaptive grid step size (world units) from camera zoom.
|
||||
///
|
||||
/// Returns the smallest power-of-5 multiple of 1.0 that places grid lines at
|
||||
/// least `MIN_GRID_PX` pixels apart at the camera pivot. Both orthographic and
|
||||
/// perspective cameras have the same vertical scale there. Camera rotation is
|
||||
/// intentionally absent so orbiting cannot rescale the visible grid or snap.
|
||||
pub fn compute_grid_step(distance: f32, fov_y: f32, bounds: iced::Rectangle) -> f32 {
|
||||
let half_height = distance * (fov_y * 0.5).tan();
|
||||
if !half_height.is_finite() || half_height <= 1e-9 || bounds.height <= 0.0 {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
use OpenCADStudio::io::pdf_export::{
|
||||
render_pdf_bytes, PdfPageInput, PdfPlotOptions, PlotContent, PlotWire,
|
||||
};
|
||||
use OpenCADStudio::io::plot_style::{LW_TABLE, PlotStyleTable};
|
||||
use OpenCADStudio::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily};
|
||||
use OpenCADStudio::scene::pipeline::text_gpu::push_glyph_vertices;
|
||||
use OpenCADStudio::scene::text::glyph_quads::layout_glyph_quads;
|
||||
@@ -153,6 +154,82 @@ fn stroke_text_page_size_is_bounded() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Heavy-pen (CTB lineweight override) leg: an absolute pen is arbitrary pt and
|
||||
/// can exceed the glyph Form XObject /BBox's 1.5-unit SDF spread margin
|
||||
/// (2.5 mm text + 1.0 mm pen → 1.79-unit half-width), squaring round caps off
|
||||
/// at the tile edge. Heavy-pen entities must bypass the Forms and stroke
|
||||
/// directly in page space, while the default-pen export of the same page keeps
|
||||
/// its Form replay (and size class).
|
||||
#[test]
|
||||
fn heavy_pen_text_bypasses_forms_and_light_pen_sizes_hold() {
|
||||
let heavy_ctb = {
|
||||
let mut ctb = PlotStyleTable::identity("heavy-pen.ctb");
|
||||
let one_mm = LW_TABLE.iter().position(|&mm| mm == 1.0).unwrap() as u8;
|
||||
let entry = &mut ctb.aci_entries[1];
|
||||
entry.lineweight = one_mm;
|
||||
entry.color = Some([0, 0, 0]);
|
||||
ctb
|
||||
};
|
||||
// The same 200-entity stroke-text page as the bounded leg above, with the
|
||||
// wires on ACI 1 so the CTB's 1.0 mm pen applies.
|
||||
let wires: Vec<PlotWire> = (0..200)
|
||||
.map(|i| {
|
||||
let x = 10.0 + (i % 5) as f64 * 40.0;
|
||||
let y = 10.0 + (i / 5) as f64 * 7.0;
|
||||
let mut wire = text_wire(
|
||||
"txt",
|
||||
"DIMENSION TEXT 12345.6789 - TEXT ANNOTATION LINE",
|
||||
[x, y, 0.0],
|
||||
2.5,
|
||||
);
|
||||
wire.wire.aci = 1;
|
||||
wire
|
||||
})
|
||||
.collect();
|
||||
let glyph_quads: usize = wires.iter().map(|w| w.wire.text_verts.len() / 6).sum();
|
||||
let page = a4_page(wires, Vec::new());
|
||||
|
||||
let heavy =
|
||||
render_pdf_bytes(std::slice::from_ref(&page), Some(&heavy_ctb)).expect("heavy export");
|
||||
println!(
|
||||
"heavy-pen (1.0 mm CTB) stroke-text 200-entity A4 page: {} bytes for {} glyph quads",
|
||||
heavy.len(),
|
||||
glyph_quads
|
||||
);
|
||||
assert!(heavy.starts_with(b"%PDF"), "not a PDF");
|
||||
assert!(
|
||||
heavy.len() > 100_000,
|
||||
"heavy-pen text page suspiciously small: {}",
|
||||
heavy.len()
|
||||
);
|
||||
// The bypass engaged: zero glyph Form `/Do` replays in the heavy render.
|
||||
// (Page content streams are uncompressed, so the operator is greppable.)
|
||||
let do_count = |bytes: &[u8]| bytes.windows(3).filter(|w| w == b" Do").count();
|
||||
assert_eq!(
|
||||
do_count(&heavy),
|
||||
0,
|
||||
"heavy-pen glyphs still replay clipped Form XObjects"
|
||||
);
|
||||
|
||||
// Light-pen regression fence: the same page without a plot style keeps
|
||||
// every glyph on the Form path (one `/Do` per quad) and its size class.
|
||||
let light = render_pdf_bytes(std::slice::from_ref(&page), None).expect("light export");
|
||||
println!(
|
||||
"same page without CTB: {} bytes ({} Form /Do replays)",
|
||||
light.len(),
|
||||
do_count(&light)
|
||||
);
|
||||
assert!(
|
||||
do_count(&light) >= glyph_quads,
|
||||
"Form glyph replay vanished from the light path"
|
||||
);
|
||||
assert!(
|
||||
light.len() < 2_000_000,
|
||||
"light-pen text page regressed to {} bytes",
|
||||
light.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Pattern hatch + dense polyline comparison leg: identifies which content
|
||||
/// type dominates a mixed page. ANSI31-style 45° hatch over an A4-sized rect.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user