forked from 1b-agent/opencadstudio-upstream
PDF underlays: attach prompts and definitions, display adjustments and clipping, Properties, snapping, PDFCLIP and PDFIMPORT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc9d5edbff
commit
0c50013ca7
50 files changed
+2770
-325
No files matched your search
@@ -17,6 +17,8 @@ mod mview_block;
|
||||
mod open_obj;
|
||||
mod pc_attach;
|
||||
pub(crate) mod pdf_attach;
|
||||
pub(crate) mod pdf_clip;
|
||||
pub(crate) mod pdf_import;
|
||||
mod snap_underlays;
|
||||
pub(crate) mod solid3d_cmds;
|
||||
mod underlay_layers;
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
use acadrust::entities::Underlay;
|
||||
// PDFATTACH / -PDFATTACH — attach a PDF page as an underlay.
|
||||
//
|
||||
// Path to PDF file to attach: (-PDFATTACH only)
|
||||
// Enter page number or [?] <1>:
|
||||
// Specify insertion point:
|
||||
// Base image size: Width: 7.8740, Height: 3.9369, Meters
|
||||
// Specify scale factor or [Unit] <1>:
|
||||
// Specify rotation <0>:
|
||||
//
|
||||
// The definition (one per file and page, named "<file> - <page>" in the
|
||||
// ACAD_PDFDEFINITIONS dictionary) is created with the underlay when it is
|
||||
// placed, so a cancelled attach leaves nothing and undo removes both.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use acadrust::entities::{Underlay, UnderlayDisplayFlags};
|
||||
use acadrust::objects::{Dictionary, ObjectType, UnderlayDefinition};
|
||||
use acadrust::types::{Handle, Vector3};
|
||||
use acadrust::EntityType;
|
||||
use acadrust::{CadDocument, EntityType};
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, WorkingPlane};
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::t;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub const ICON: IconKind =
|
||||
IconKind::Svg(include_bytes!("../../../assets/icons/underlay_layers.svg"));
|
||||
@@ -19,18 +35,254 @@ pub fn tool() -> ToolDef {
|
||||
}
|
||||
}
|
||||
|
||||
/// Units the Unit option offers: keyword, the name the prompt shows.
|
||||
const UNITS: [(&str, &str); 9] = [
|
||||
("MM", "Millimeters"),
|
||||
("Centimeter", "Centimeters"),
|
||||
("Meter", "Meters"),
|
||||
("Kilometer", "Kilometers"),
|
||||
("Inch", "Inches"),
|
||||
("Foot", "Feet"),
|
||||
("Yard", "Yards"),
|
||||
("MILe", "Miles"),
|
||||
("Unitless", "Unitless"),
|
||||
];
|
||||
|
||||
/// The unit last chosen with the Unit option; it stays the default.
|
||||
static LAST_UNIT: Mutex<Option<usize>> = Mutex::new(None);
|
||||
|
||||
/// The Unit option's index for the drawing's INSUNITS.
|
||||
fn unit_for_insunits(insunits: i16) -> usize {
|
||||
match insunits {
|
||||
4 => 0,
|
||||
5 => 1,
|
||||
6 => 2,
|
||||
7 => 3,
|
||||
1 => 4,
|
||||
2 => 5,
|
||||
10 => 6,
|
||||
3 => 7,
|
||||
_ => 8,
|
||||
}
|
||||
}
|
||||
|
||||
fn unit_from_keyword(text: &str) -> Option<usize> {
|
||||
let t = text.trim().to_ascii_uppercase();
|
||||
let short = match t.as_str() {
|
||||
"MM" => Some(0),
|
||||
"C" => Some(1),
|
||||
"M" => Some(2),
|
||||
"K" => Some(3),
|
||||
"I" => Some(4),
|
||||
"F" => Some(5),
|
||||
"Y" => Some(6),
|
||||
"MIL" => Some(7),
|
||||
"U" => Some(8),
|
||||
_ => None,
|
||||
};
|
||||
short.or_else(|| {
|
||||
UNITS.iter().position(|(keyword, _)| {
|
||||
let k = keyword.to_ascii_uppercase();
|
||||
t.len() >= 2 && k.starts_with(&t)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Step {
|
||||
Path,
|
||||
Page,
|
||||
ListPages,
|
||||
Insertion,
|
||||
Scale,
|
||||
Unit,
|
||||
Rotation,
|
||||
}
|
||||
|
||||
pub struct PdfAttachCommand {
|
||||
definition_handle: Handle,
|
||||
step: Step,
|
||||
path: String,
|
||||
page: String,
|
||||
page_count: usize,
|
||||
page_size: (f64, f64),
|
||||
insertion: DVec3,
|
||||
scale: f64,
|
||||
unit: usize,
|
||||
plane: WorkingPlane,
|
||||
}
|
||||
|
||||
impl PdfAttachCommand {
|
||||
pub fn new(definition_handle: Handle) -> Self {
|
||||
/// `-PDFATTACH`: starts by asking for the file.
|
||||
pub fn new(insunits: i16) -> Self {
|
||||
let unit = LAST_UNIT
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|last| *last)
|
||||
.unwrap_or_else(|| unit_for_insunits(insunits));
|
||||
Self {
|
||||
definition_handle,
|
||||
step: Step::Path,
|
||||
path: String::new(),
|
||||
page: "1".to_string(),
|
||||
page_count: 0,
|
||||
page_size: (0.0, 0.0),
|
||||
insertion: DVec3::ZERO,
|
||||
scale: 1.0,
|
||||
unit,
|
||||
plane: WorkingPlane::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// PDFATTACH with a file already chosen: starts at the page prompt.
|
||||
pub fn with_file(path: &str, insunits: i16) -> Self {
|
||||
let mut command = Self::new(insunits);
|
||||
if let Some(count) = crate::scene::model::pdf_raster::page_count(path) {
|
||||
command.path = path.to_string();
|
||||
command.page_count = count;
|
||||
command.step = Step::Page;
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
fn base_image_size(&self) -> String {
|
||||
format!(
|
||||
"Base image size: Width: {:.4}, Height: {:.4}, {}",
|
||||
self.page_size.0, self.page_size.1, UNITS[self.unit].1
|
||||
)
|
||||
}
|
||||
|
||||
fn accept_path(&mut self, text: &str) -> CmdResult {
|
||||
let mut path = text.trim().trim_matches('"').to_string();
|
||||
if path.is_empty() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if std::path::Path::new(&path).extension().is_none() {
|
||||
path.push_str(".pdf");
|
||||
}
|
||||
match crate::scene::model::pdf_raster::page_count(&path) {
|
||||
Some(count) if count > 0 => {
|
||||
self.path = path;
|
||||
self.page_count = count;
|
||||
self.step = Step::Page;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::ReportError(format!(
|
||||
"{} not found.",
|
||||
crate::entities::underlay::display_path(&path)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_page(&mut self, text: &str) -> CmdResult {
|
||||
let text = text.trim();
|
||||
if text == "?" {
|
||||
self.step = Step::ListPages;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
let page = if text.is_empty() { "1" } else { text };
|
||||
match page.parse::<usize>() {
|
||||
Ok(n) if n >= 1 && n <= self.page_count => {
|
||||
self.page = n.to_string();
|
||||
self.page_size = crate::scene::model::pdf_raster::page_size_inches(&self.path, &self.page)
|
||||
.unwrap_or((0.0, 0.0));
|
||||
self.step = Step::Insertion;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::ReportError(format!(
|
||||
"There is no {page} in {}.",
|
||||
crate::entities::underlay::display_path(&self.path)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_pages(&mut self, pattern: &str) -> CmdResult {
|
||||
let pattern = if pattern.trim().is_empty() { "*" } else { pattern.trim() };
|
||||
let lines: Vec<String> = (1..=self.page_count)
|
||||
.map(|n| n.to_string())
|
||||
.filter(|n| crate::io::xref_model::wildcard_match(n, pattern))
|
||||
.collect();
|
||||
self.step = Step::Page;
|
||||
CmdResult::ReportMeasurement(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn accept_scale(&mut self, text: &str) -> CmdResult {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
self.step = Step::Rotation;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if text.eq_ignore_ascii_case("U") || text.eq_ignore_ascii_case("UNIT") {
|
||||
self.step = Step::Unit;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
match crate::entities::common::parse_f64(text) {
|
||||
Some(v) if v > 0.0 => {
|
||||
self.scale = v;
|
||||
self.step = Step::Rotation;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Some(_) => CmdResult::ReportError("Value must be positive and nonzero.".to_string()),
|
||||
None => CmdResult::ReportError("Requires numeric value or option keyword.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_unit(&mut self, text: &str) -> CmdResult {
|
||||
let unit = if text.trim().is_empty() {
|
||||
Some(self.unit)
|
||||
} else {
|
||||
unit_from_keyword(text)
|
||||
};
|
||||
let Some(unit) = unit else {
|
||||
return CmdResult::ReportError("Invalid option keyword.".to_string());
|
||||
};
|
||||
self.unit = unit;
|
||||
if let Ok(mut last) = LAST_UNIT.lock() {
|
||||
*last = Some(unit);
|
||||
}
|
||||
self.step = Step::Scale;
|
||||
CmdResult::ReportMeasurement(self.base_image_size())
|
||||
}
|
||||
|
||||
fn accept_rotation(&mut self, text: &str) -> CmdResult {
|
||||
let text = text.trim();
|
||||
let degrees = if text.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
match crate::entities::common::parse_f64(text) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return CmdResult::ReportError(
|
||||
"Requires numeric angle or second point.".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
self.commit(degrees.to_radians())
|
||||
}
|
||||
|
||||
fn commit(&mut self, rotation: f64) -> CmdResult {
|
||||
let point = self.plane.to_local(self.insertion);
|
||||
let mut underlay = Underlay::pdf();
|
||||
underlay.insertion_point = Vector3::new(point.x, point.y, point.z);
|
||||
underlay.set_scale(self.scale);
|
||||
underlay.rotation = rotation;
|
||||
// On, clipped by its boundary and colour-adjusted for the background,
|
||||
// as the reference creates an underlay.
|
||||
underlay.flags = UnderlayDisplayFlags::ON
|
||||
| UnderlayDisplayFlags::CLIPPING
|
||||
| UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND;
|
||||
CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Underlay(underlay)))
|
||||
}
|
||||
|
||||
fn frame_at(&self, pt: DVec3) -> Vec<[f32; 3]> {
|
||||
let (w, h) = (self.page_size.0 * self.scale, self.page_size.1 * self.scale);
|
||||
let x = self.plane.vector_to_world(DVec3::new(w, 0.0, 0.0));
|
||||
let y = self.plane.vector_to_world(DVec3::new(0.0, h, 0.0));
|
||||
let corners = [pt, pt + x, pt + x + y, pt + y, pt];
|
||||
corners
|
||||
.iter()
|
||||
.map(|p| [p.x as f32, p.y as f32, p.z as f32])
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PdfAttachCommand {
|
||||
@@ -43,27 +295,165 @@ impl CadCommand for PdfAttachCommand {
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
t!("PDFATTACH Specify insertion point:").into_owned()
|
||||
match self.step {
|
||||
Step::Path => "Path to PDF file to attach:".to_string(),
|
||||
Step::Page => "Enter page number or [?] <1>:".to_string(),
|
||||
Step::ListPages => "Enter page(s) to list <*>:".to_string(),
|
||||
Step::Insertion => "Specify insertion point:".to_string(),
|
||||
Step::Scale => "Specify scale factor or [Unit] <1>:".to_string(),
|
||||
Step::Unit => format!(
|
||||
"Enter unit [MM/Centimeter/Meter/Kilometer/Inch/Foot/Yard/MILe/Unitless] <{}>:",
|
||||
UNITS[self.unit].0
|
||||
),
|
||||
Step::Rotation => "Specify rotation <0>:".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.step {
|
||||
Step::Page => vec![CmdOption::new("?", "?")],
|
||||
Step::Scale => vec![CmdOption::new("Unit", "U")],
|
||||
Step::Unit => vec![
|
||||
CmdOption::new("MM", "MM"),
|
||||
CmdOption::new("Centimeter", "C"),
|
||||
CmdOption::new("Meter", "M"),
|
||||
CmdOption::new("Kilometer", "K"),
|
||||
CmdOption::new("Inch", "I"),
|
||||
CmdOption::new("Foot", "F"),
|
||||
CmdOption::new("Yard", "Y"),
|
||||
CmdOption::new("MILe", "MIL"),
|
||||
CmdOption::new("Unitless", "U"),
|
||||
],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
match self.step {
|
||||
Step::Path => InputKind::FreeText,
|
||||
Step::Insertion => InputKind::Point,
|
||||
_ => InputKind::SingleToken,
|
||||
}
|
||||
}
|
||||
|
||||
fn pdf_attach_source(&self) -> Option<(String, String)> {
|
||||
(!self.path.is_empty()).then(|| (self.path.clone(), self.page.clone()))
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
let point = self.plane.to_local(pt);
|
||||
if self.step != Step::Insertion {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
self.insertion = pt;
|
||||
self.step = Step::Scale;
|
||||
CmdResult::ReportMeasurement(self.base_image_size())
|
||||
}
|
||||
|
||||
let mut underlay = Underlay::pdf();
|
||||
underlay.definition_handle = self.definition_handle;
|
||||
underlay.insertion_point = Vector3::new(point.x, point.y, point.z);
|
||||
|
||||
CmdResult::CommitAndExit(
|
||||
self.plane
|
||||
.place_entity(EntityType::Underlay(underlay)),
|
||||
)
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
Some(match self.step {
|
||||
Step::Path => self.accept_path(text),
|
||||
Step::Page => self.accept_page(text),
|
||||
Step::ListPages => self.list_pages(text),
|
||||
Step::Insertion => return None,
|
||||
Step::Scale => self.accept_scale(text),
|
||||
Step::Unit => self.accept_unit(text),
|
||||
Step::Rotation => self.accept_rotation(text),
|
||||
})
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
match self.step {
|
||||
Step::Path => CmdResult::Cancel,
|
||||
Step::Page => self.accept_page(""),
|
||||
Step::ListPages => self.list_pages("*"),
|
||||
Step::Insertion => {
|
||||
CmdResult::ReportError("Point or option keyword required.".to_string())
|
||||
}
|
||||
Step::Scale => self.accept_scale(""),
|
||||
Step::Unit => self.accept_unit(""),
|
||||
Step::Rotation => self.accept_rotation(""),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||||
if self.step != Step::Insertion || self.page_size.0 <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(WireModel::solid(
|
||||
"pdf_attach_frame".into(),
|
||||
self.frame_at(pt),
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ACAD_PDFDEFINITIONS` dictionary under the named-objects root,
|
||||
/// created when the drawing has none.
|
||||
fn ensure_pdf_dictionary(document: &mut CadDocument) -> Option<Handle> {
|
||||
let root = document.header.named_objects_dict_handle;
|
||||
let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get(&root) else {
|
||||
return None;
|
||||
};
|
||||
if let Some(existing) = root_dictionary.get("ACAD_PDFDEFINITIONS") {
|
||||
if matches!(document.objects.get(&existing), Some(ObjectType::Dictionary(_))) {
|
||||
return Some(existing);
|
||||
}
|
||||
}
|
||||
let handle = document.allocate_handle();
|
||||
let mut dictionary = Dictionary::new();
|
||||
dictionary.handle = handle;
|
||||
dictionary.owner = root;
|
||||
document.objects.insert(handle, ObjectType::Dictionary(dictionary));
|
||||
if let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get_mut(&root) {
|
||||
root_dictionary.add_entry("ACAD_PDFDEFINITIONS", handle);
|
||||
}
|
||||
Some(handle)
|
||||
}
|
||||
|
||||
fn same_path(a: &str, b: &str) -> bool {
|
||||
let norm = |p: &str| p.replace('\\', "/").to_lowercase();
|
||||
norm(a) == norm(b)
|
||||
}
|
||||
|
||||
/// The PDF definition for a file and page: the existing one, or a new one
|
||||
/// registered in ACAD_PDFDEFINITIONS as "<file> - <page>".
|
||||
pub fn ensure_pdf_definition(document: &mut CadDocument, path: &str, page: &str) -> Handle {
|
||||
let existing = document.objects.iter().find_map(|(handle, object)| match object {
|
||||
ObjectType::UnderlayDefinition(def)
|
||||
if matches!(def.underlay_type, acadrust::entities::UnderlayType::Pdf)
|
||||
&& same_path(&def.file_path, path)
|
||||
&& crate::entities::underlay::page_of(def) == page =>
|
||||
{
|
||||
Some(*handle)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some(handle) = existing {
|
||||
return handle;
|
||||
}
|
||||
let handle = document.allocate_handle();
|
||||
let mut definition = UnderlayDefinition::pdf(path, page);
|
||||
definition.handle = handle;
|
||||
let base = crate::entities::underlay::definition_display_name(&definition);
|
||||
if let Some(dictionary) = ensure_pdf_dictionary(document) {
|
||||
definition.owner_handle = dictionary;
|
||||
if let Some(ObjectType::Dictionary(entries)) = document.objects.get_mut(&dictionary) {
|
||||
let mut key = base.clone();
|
||||
let mut suffix = 1;
|
||||
while entries.get(&key).is_some() {
|
||||
suffix += 1;
|
||||
key = format!("{base}({suffix})");
|
||||
}
|
||||
entries.add_entry(key, handle);
|
||||
}
|
||||
}
|
||||
document
|
||||
.objects
|
||||
.insert(handle, ObjectType::UnderlayDefinition(definition));
|
||||
handle
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["PDFATTACH"]
|
||||
names: &["PDFATTACH", "-PDFATTACH"]
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
// PDFCLIP / CLIP — clip a PDF underlay to a boundary.
|
||||
//
|
||||
// Select PDF to clip: (CLIP: Select Object to clip:)
|
||||
// Enter PDF clipping option [ON/OFF/Delete/New boundary] <New boundary>:
|
||||
// Delete old boundary? [Yes/No] <Yes>: (when one exists)
|
||||
// Outside mode - Objects outside boundary will be hidden.
|
||||
// Specify clipping boundary or select invert option:
|
||||
// [Select polyline/Polygonal/Rectangular/Invert clip] <Rectangular>:
|
||||
//
|
||||
// The boundary is stored in underlay units; the clip-inside bit follows the
|
||||
// Invert choice.
|
||||
|
||||
use acadrust::entities::{Underlay, UnderlayDisplayFlags, UnderlayType};
|
||||
use acadrust::types::{Handle, Vector2};
|
||||
use acadrust::EntityType;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Step {
|
||||
Select,
|
||||
Option,
|
||||
DeleteOld,
|
||||
Mode,
|
||||
RectFirst,
|
||||
RectSecond,
|
||||
PolyPoints,
|
||||
Polyline,
|
||||
}
|
||||
|
||||
pub struct PdfClipCommand {
|
||||
/// CLIP asks for any object; PDFCLIP for a PDF.
|
||||
generic: bool,
|
||||
step: Step,
|
||||
handle: Handle,
|
||||
underlay: Option<Underlay>,
|
||||
picked: Option<EntityType>,
|
||||
inverted: bool,
|
||||
points: Vec<DVec3>,
|
||||
}
|
||||
|
||||
const OUTSIDE: &str = "Outside mode - Objects outside boundary will be hidden.";
|
||||
const INSIDE: &str = "Inside mode - Objects inside boundary will be hidden.";
|
||||
const BOUNDARY: &str = "Specify clipping boundary or select invert option:";
|
||||
|
||||
impl PdfClipCommand {
|
||||
pub fn new(generic: bool) -> Self {
|
||||
Self {
|
||||
generic,
|
||||
step: Step::Select,
|
||||
handle: Handle::NULL,
|
||||
underlay: None,
|
||||
picked: None,
|
||||
inverted: false,
|
||||
points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self, underlay: Underlay) -> CmdResult {
|
||||
CmdResult::UpdateEntityAndFinish {
|
||||
handle: self.handle,
|
||||
entity: EntityType::Underlay(underlay),
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_mode(&mut self) -> CmdResult {
|
||||
self.step = Step::Mode;
|
||||
let mode = if self.inverted { INSIDE } else { OUTSIDE };
|
||||
CmdResult::ReportMeasurement(format!("{mode}\n{BOUNDARY}"))
|
||||
}
|
||||
|
||||
fn to_local(u: &Underlay, p: DVec3) -> Vector2 {
|
||||
let (c, s) = (u.rotation.cos(), u.rotation.sin());
|
||||
let dx = p.x - u.insertion_point.x;
|
||||
let dy = p.y - u.insertion_point.y;
|
||||
let sx = if u.x_scale.abs() > 1e-12 { u.x_scale } else { 1.0 };
|
||||
let sy = if u.y_scale.abs() > 1e-12 { u.y_scale } else { 1.0 };
|
||||
Vector2::new((dx * c + dy * s) / sx, (-dx * s + dy * c) / sy)
|
||||
}
|
||||
|
||||
fn apply_boundary(&self, world: &[DVec3]) -> CmdResult {
|
||||
let Some(mut underlay) = self.underlay.clone() else {
|
||||
return CmdResult::Cancel;
|
||||
};
|
||||
underlay.clip_boundary_vertices = world.iter().map(|p| Self::to_local(&underlay, *p)).collect();
|
||||
underlay.flags |= UnderlayDisplayFlags::CLIPPING;
|
||||
underlay.clip_inverted = self.inverted;
|
||||
self.finish(underlay)
|
||||
}
|
||||
|
||||
fn option(&mut self, text: &str) -> CmdResult {
|
||||
let Some(mut underlay) = self.underlay.clone() else {
|
||||
return CmdResult::Cancel;
|
||||
};
|
||||
match text.trim().to_ascii_uppercase().as_str() {
|
||||
"ON" => {
|
||||
underlay.flags |= UnderlayDisplayFlags::CLIPPING;
|
||||
self.finish(underlay)
|
||||
}
|
||||
"OFF" => {
|
||||
underlay.flags -= UnderlayDisplayFlags::CLIPPING;
|
||||
self.finish(underlay)
|
||||
}
|
||||
"D" | "DELETE" => {
|
||||
underlay.clip_boundary_vertices.clear();
|
||||
underlay.flags -= UnderlayDisplayFlags::CLIPPING;
|
||||
self.finish(underlay)
|
||||
}
|
||||
"" | "N" | "NEW" | "NEW BOUNDARY" => {
|
||||
if underlay.clip_boundary_vertices.is_empty() {
|
||||
self.enter_mode()
|
||||
} else {
|
||||
self.step = Step::DeleteOld;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
_ => CmdResult::ReportError("Invalid option keyword.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn mode(&mut self, text: &str) -> CmdResult {
|
||||
match text.trim().to_ascii_uppercase().as_str() {
|
||||
"" | "R" | "RECTANGULAR" => {
|
||||
self.step = Step::RectFirst;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"P" | "POLYGONAL" => {
|
||||
self.points.clear();
|
||||
self.step = Step::PolyPoints;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"S" | "SELECT" | "SELECT POLYLINE" => {
|
||||
self.step = Step::Polyline;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"I" | "INVERT" | "INVERT CLIP" => {
|
||||
self.inverted = !self.inverted;
|
||||
self.enter_mode()
|
||||
}
|
||||
_ => CmdResult::ReportError("Invalid option keyword.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn rectangle(a: DVec3, b: DVec3) -> Vec<DVec3> {
|
||||
vec![
|
||||
a,
|
||||
DVec3::new(b.x, a.y, a.z),
|
||||
b,
|
||||
DVec3::new(a.x, b.y, a.z),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PdfClipCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.generic { "CLIP" } else { "PDFCLIP" }
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
Step::Select if self.generic => "Select Object to clip:".to_string(),
|
||||
Step::Select => "Select PDF to clip:".to_string(),
|
||||
Step::Option => {
|
||||
"Enter PDF clipping option [ON/OFF/Delete/New boundary] <New boundary>:".to_string()
|
||||
}
|
||||
Step::DeleteOld => "Delete old boundary? [Yes/No] <Yes>:".to_string(),
|
||||
Step::Mode => {
|
||||
"[Select polyline/Polygonal/Rectangular/Invert clip] <Rectangular>:".to_string()
|
||||
}
|
||||
Step::RectFirst => "Specify first corner point:".to_string(),
|
||||
Step::RectSecond => "Specify opposite corner point:".to_string(),
|
||||
Step::PolyPoints => match self.points.len() {
|
||||
0 => "Specify first point:".to_string(),
|
||||
1 | 2 => "Specify next point or [Undo]:".to_string(),
|
||||
_ => "Specify next point or [Close/Undo]:".to_string(),
|
||||
},
|
||||
Step::Polyline => "Select polyline:".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.step {
|
||||
Step::Option => vec![
|
||||
CmdOption::new("ON", "ON"),
|
||||
CmdOption::new("OFF", "OFF"),
|
||||
CmdOption::new("Delete", "D"),
|
||||
CmdOption::new("New boundary", "N"),
|
||||
],
|
||||
Step::DeleteOld => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")],
|
||||
Step::Mode => vec![
|
||||
CmdOption::new("Select polyline", "S"),
|
||||
CmdOption::new("Polygonal", "P"),
|
||||
CmdOption::new("Rectangular", "R"),
|
||||
CmdOption::new("Invert clip", "I"),
|
||||
],
|
||||
Step::PolyPoints if self.points.len() >= 3 => {
|
||||
vec![CmdOption::new("Close", "C"), CmdOption::new("Undo", "U")]
|
||||
}
|
||||
Step::PolyPoints if !self.points.is_empty() => vec![CmdOption::new("Undo", "U")],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
match self.step {
|
||||
Step::Option | Step::DeleteOld | Step::Mode => InputKind::SingleToken,
|
||||
_ => InputKind::Point,
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
self.step == Step::PolyPoints
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
matches!(self.step, Step::Select | Step::Polyline)
|
||||
}
|
||||
|
||||
fn inject_before_entity_pick(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) {
|
||||
self.picked = Some(entity);
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult {
|
||||
let picked = self.picked.take();
|
||||
match self.step {
|
||||
Step::Select => match picked {
|
||||
Some(EntityType::Underlay(u)) if u.underlay_type == UnderlayType::Pdf => {
|
||||
self.handle = handle;
|
||||
self.underlay = Some(u);
|
||||
self.step = Step::Option;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::ReportError("Object selected was not a PDF underlay.".to_string()),
|
||||
},
|
||||
Step::Polyline => match picked {
|
||||
Some(EntityType::LwPolyline(pl)) if pl.vertices.len() >= 3 => {
|
||||
let z = pl.elevation;
|
||||
let world: Vec<DVec3> = pl
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|v| DVec3::new(v.location.x, v.location.y, z))
|
||||
.collect();
|
||||
self.apply_boundary(&world)
|
||||
}
|
||||
_ => CmdResult::ReportError("Invalid object selected.".to_string()),
|
||||
},
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
match self.step {
|
||||
Step::RectFirst => {
|
||||
self.points = vec![pt];
|
||||
self.step = Step::RectSecond;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::RectSecond => {
|
||||
let first = self.points[0];
|
||||
self.apply_boundary(&Self::rectangle(first, pt))
|
||||
}
|
||||
Step::PolyPoints => {
|
||||
self.points.push(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
Some(match self.step {
|
||||
Step::Option => self.option(text),
|
||||
Step::DeleteOld => match text.trim().to_ascii_uppercase().as_str() {
|
||||
"" | "Y" | "YES" => self.enter_mode(),
|
||||
"N" | "NO" => match self.underlay.clone() {
|
||||
Some(underlay) => self.finish(underlay),
|
||||
None => CmdResult::Cancel,
|
||||
},
|
||||
_ => CmdResult::ReportError("Invalid option keyword.".to_string()),
|
||||
},
|
||||
Step::Mode => self.mode(text),
|
||||
Step::PolyPoints => match text.trim().to_ascii_uppercase().as_str() {
|
||||
"U" | "UNDO" => {
|
||||
self.points.pop();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"C" | "CLOSE" if self.points.len() >= 3 => {
|
||||
let points = self.points.clone();
|
||||
self.apply_boundary(&points)
|
||||
}
|
||||
_ => return None,
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match self.step {
|
||||
Step::Option => self.option(""),
|
||||
Step::DeleteOld => self.enter_mode(),
|
||||
Step::Mode => self.mode(""),
|
||||
Step::PolyPoints if self.points.len() >= 3 => {
|
||||
let points = self.points.clone();
|
||||
self.apply_boundary(&points)
|
||||
}
|
||||
Step::PolyPoints => CmdResult::NeedPoint,
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||||
let ring: Vec<DVec3> = match self.step {
|
||||
Step::RectSecond => Self::rectangle(self.points[0], pt),
|
||||
Step::PolyPoints if !self.points.is_empty() => {
|
||||
let mut ring = self.points.clone();
|
||||
ring.push(pt);
|
||||
ring
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let mut points: Vec<[f32; 3]> = ring.iter().map(|p| [p.x as f32, p.y as f32, p.z as f32]).collect();
|
||||
points.push(points[0]);
|
||||
Some(WireModel::solid("pdf_clip_boundary".into(), points, WireModel::CYAN, false))
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["PDFCLIP", "CLIP"]
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
// PDFIMPORT — turn the vector content of a PDF underlay into drawing objects.
|
||||
//
|
||||
// Select PDF underlay or [File] <File>:
|
||||
// Specify first corner of area to import or [Polygonal/All/Settings] <All>:
|
||||
// Specify opposite corner:
|
||||
// Keep, Detach or Unload PDF underlay? [Keep/Detach/Unload] <Unload>:
|
||||
// Binding PDF file <path>, page <n> ...
|
||||
//
|
||||
// Geometry goes to PDF_Geometry and text to PDF_Text (the PDF has no layers
|
||||
// of its own to use): chains of straight segments become polylines, a closed
|
||||
// four-arc Bézier loop that is a circle becomes a CIRCLE, other curves become
|
||||
// splines, filled areas solid hatches and text runs MTEXT in a "PDF <font>"
|
||||
// text style.
|
||||
|
||||
use acadrust::entities::{
|
||||
AttachmentPoint, BoundaryEdge, BoundaryPath, Circle, Hatch, LwPolyline, MText, PolylineEdge,
|
||||
Spline, Underlay, UnderlayType,
|
||||
};
|
||||
use acadrust::types::{Color, Handle, LineWeight, Vector2, Vector3};
|
||||
use acadrust::EntityType;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind};
|
||||
use crate::scene::model::pdf_vector::{PageVectors, PdfPath, Segment, SubPath};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub const GEOMETRY_LAYER: &str = "PDF_Geometry";
|
||||
pub const TEXT_LAYER: &str = "PDF_Text";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ImportArea {
|
||||
All,
|
||||
Polygon(Vec<DVec3>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UnderlayMode {
|
||||
Keep,
|
||||
Detach,
|
||||
Unload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PdfImportRequest {
|
||||
pub underlay: Handle,
|
||||
pub area: ImportArea,
|
||||
pub mode: UnderlayMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Step {
|
||||
Select,
|
||||
Area,
|
||||
Opposite,
|
||||
Polygon,
|
||||
Mode,
|
||||
}
|
||||
|
||||
pub struct PdfImportCommand {
|
||||
step: Step,
|
||||
handle: Handle,
|
||||
picked: Option<EntityType>,
|
||||
points: Vec<DVec3>,
|
||||
area: ImportArea,
|
||||
}
|
||||
|
||||
impl PdfImportCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: Step::Select,
|
||||
handle: Handle::NULL,
|
||||
picked: None,
|
||||
points: Vec::new(),
|
||||
area: ImportArea::All,
|
||||
}
|
||||
}
|
||||
|
||||
fn area_choice(&mut self, text: &str) -> CmdResult {
|
||||
match text.trim().to_ascii_uppercase().as_str() {
|
||||
"" | "A" | "ALL" => {
|
||||
self.area = ImportArea::All;
|
||||
self.step = Step::Mode;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"P" | "POLYGONAL" => {
|
||||
self.points.clear();
|
||||
self.step = Step::Polygon;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"S" | "SETTINGS" => {
|
||||
CmdResult::ReportError("PDF import settings are not available.".to_string())
|
||||
}
|
||||
_ => CmdResult::ReportError("Invalid option keyword.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_choice(&mut self, text: &str) -> CmdResult {
|
||||
let mode = match text.trim().to_ascii_uppercase().as_str() {
|
||||
"" | "U" | "UNLOAD" => UnderlayMode::Unload,
|
||||
"K" | "KEEP" => UnderlayMode::Keep,
|
||||
"D" | "DETACH" => UnderlayMode::Detach,
|
||||
_ => return CmdResult::ReportError("Invalid option keyword.".to_string()),
|
||||
};
|
||||
CmdResult::PdfImport(PdfImportRequest {
|
||||
underlay: self.handle,
|
||||
area: self.area.clone(),
|
||||
mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PdfImportCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"PDFIMPORT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
Step::Select => "Select PDF underlay or [File] <File>:".to_string(),
|
||||
Step::Area => {
|
||||
"Specify first corner of area to import or [Polygonal/All/Settings] <All>:".to_string()
|
||||
}
|
||||
Step::Opposite => "Specify opposite corner:".to_string(),
|
||||
Step::Polygon if self.points.is_empty() => "Specify first point:".to_string(),
|
||||
Step::Polygon => "Specify next point or [Undo]:".to_string(),
|
||||
Step::Mode => {
|
||||
"Keep, Detach or Unload PDF underlay? [Keep/Detach/Unload] <Unload>:".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.step {
|
||||
Step::Select => vec![CmdOption::new("File", "F")],
|
||||
Step::Area => vec![
|
||||
CmdOption::new("Polygonal", "P"),
|
||||
CmdOption::new("All", "A"),
|
||||
CmdOption::new("Settings", "S"),
|
||||
],
|
||||
Step::Polygon if !self.points.is_empty() => vec![CmdOption::new("Undo", "U")],
|
||||
Step::Mode => vec![
|
||||
CmdOption::new("Keep", "K"),
|
||||
CmdOption::new("Detach", "D"),
|
||||
CmdOption::new("Unload", "U"),
|
||||
],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
match self.step {
|
||||
Step::Mode => InputKind::SingleToken,
|
||||
_ => InputKind::Point,
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
matches!(self.step, Step::Select | Step::Area | Step::Polygon)
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
self.step == Step::Select
|
||||
}
|
||||
|
||||
fn inject_before_entity_pick(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) {
|
||||
self.picked = Some(entity);
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult {
|
||||
match self.picked.take() {
|
||||
Some(EntityType::Underlay(u)) if u.underlay_type == UnderlayType::Pdf => {
|
||||
self.handle = handle;
|
||||
self.step = Step::Area;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::ReportError("Object selected was not a PDF underlay.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
match self.step {
|
||||
Step::Area => {
|
||||
self.points = vec![pt];
|
||||
self.step = Step::Opposite;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Opposite => {
|
||||
let a = self.points[0];
|
||||
self.area = ImportArea::Polygon(vec![
|
||||
a,
|
||||
DVec3::new(pt.x, a.y, a.z),
|
||||
pt,
|
||||
DVec3::new(a.x, pt.y, a.z),
|
||||
]);
|
||||
self.step = Step::Mode;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Polygon => {
|
||||
self.points.push(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let token = text.trim().to_ascii_uppercase();
|
||||
Some(match self.step {
|
||||
Step::Select if token == "F" || token == "FILE" => {
|
||||
CmdResult::Dispatch("_PDFIMPORTFILE".to_string())
|
||||
}
|
||||
Step::Area => self.area_choice(&token),
|
||||
Step::Polygon if token == "U" || token == "UNDO" => {
|
||||
self.points.pop();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Mode => self.mode_choice(&token),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match self.step {
|
||||
Step::Select => CmdResult::Dispatch("_PDFIMPORTFILE".to_string()),
|
||||
Step::Area => self.area_choice(""),
|
||||
Step::Polygon if self.points.len() >= 3 => {
|
||||
self.area = ImportArea::Polygon(self.points.clone());
|
||||
self.step = Step::Mode;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Polygon => CmdResult::NeedPoint,
|
||||
Step::Mode => self.mode_choice(""),
|
||||
Step::Opposite => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||||
let ring: Vec<DVec3> = match self.step {
|
||||
Step::Opposite => {
|
||||
let a = self.points[0];
|
||||
vec![a, DVec3::new(pt.x, a.y, a.z), pt, DVec3::new(a.x, pt.y, a.z)]
|
||||
}
|
||||
Step::Polygon if !self.points.is_empty() => {
|
||||
let mut ring = self.points.clone();
|
||||
ring.push(pt);
|
||||
ring
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let mut points: Vec<[f32; 3]> =
|
||||
ring.iter().map(|p| [p.x as f32, p.y as f32, p.z as f32]).collect();
|
||||
points.push(points[0]);
|
||||
Some(WireModel::solid("pdf_import_area".into(), points, WireModel::CYAN, false))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Conversion ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// What an import adds: objects, and the layers and text styles they use
|
||||
/// (name, colour / font file), created when missing.
|
||||
#[derive(Default)]
|
||||
pub struct ImportResult {
|
||||
pub entities: Vec<EntityType>,
|
||||
pub layers: Vec<(String, Color)>,
|
||||
pub text_styles: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// A PDF colour as an index colour when it is one of the basic seven,
|
||||
/// otherwise as a true colour.
|
||||
fn color_of(rgb: [u8; 3]) -> Color {
|
||||
match rgb {
|
||||
[255, 0, 0] => Color::Index(1),
|
||||
[255, 255, 0] => Color::Index(2),
|
||||
[0, 255, 0] => Color::Index(3),
|
||||
[0, 255, 255] => Color::Index(4),
|
||||
[0, 0, 255] => Color::Index(5),
|
||||
[255, 0, 255] => Color::Index(6),
|
||||
[0, 0, 0] | [255, 255, 255] => Color::Index(7),
|
||||
[r, g, b] => Color::Rgb { r, g, b },
|
||||
}
|
||||
}
|
||||
|
||||
/// Stroke width in points → the nearest standard lineweight.
|
||||
fn lineweight_of(width_pt: f64) -> LineWeight {
|
||||
const STANDARD: [i16; 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,
|
||||
];
|
||||
if width_pt <= 0.0 {
|
||||
return LineWeight::ByLayer;
|
||||
}
|
||||
let hundredths = width_pt * 25.4 / 72.0 * 100.0;
|
||||
let best = STANDARD
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by(|a, b| {
|
||||
(*a as f64 - hundredths)
|
||||
.abs()
|
||||
.total_cmp(&(*b as f64 - hundredths).abs())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
LineWeight::Value(best)
|
||||
}
|
||||
|
||||
fn font_file_for(font: &str) -> String {
|
||||
let f = font.to_ascii_lowercase();
|
||||
if f.contains("times") {
|
||||
"times.ttf"
|
||||
} else if f.contains("courier") {
|
||||
"cour.ttf"
|
||||
} else {
|
||||
"arial.ttf"
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn bezier(seg: &Segment, t: f64) -> [f64; 2] {
|
||||
match seg {
|
||||
Segment::Line(a, b) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
|
||||
Segment::Cubic(p0, p1, p2, p3) => {
|
||||
let u = 1.0 - t;
|
||||
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
[
|
||||
a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0],
|
||||
a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1],
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Centre and radius when a closed loop of cubics traces a circle.
|
||||
fn as_circle(sp: &SubPath) -> Option<([f64; 2], f64)> {
|
||||
if !sp.closed || sp.segments.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let cubics: Vec<&Segment> = sp
|
||||
.segments
|
||||
.iter()
|
||||
.filter(|s| matches!(s, Segment::Cubic(..)))
|
||||
.collect();
|
||||
if cubics.len() != sp.segments.len() && sp.segments.len() - cubics.len() > 1 {
|
||||
return None;
|
||||
}
|
||||
let starts: Vec<[f64; 2]> = cubics.iter().map(|s| s.start()).collect();
|
||||
let n = starts.len() as f64;
|
||||
let c = [
|
||||
starts.iter().map(|p| p[0]).sum::<f64>() / n,
|
||||
starts.iter().map(|p| p[1]).sum::<f64>() / n,
|
||||
];
|
||||
let dist = |p: [f64; 2]| ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt();
|
||||
let r = starts.iter().map(|p| dist(*p)).sum::<f64>() / n;
|
||||
if r <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let tol = r * 2e-3;
|
||||
let on_circle = cubics.iter().all(|s| {
|
||||
[0.0, 0.25, 0.5, 0.75].iter().all(|t| (dist(bezier(s, *t)) - r).abs() <= tol)
|
||||
});
|
||||
on_circle.then_some((c, r))
|
||||
}
|
||||
|
||||
fn inside(poly: &[[f64; 2]], p: [f64; 2]) -> bool {
|
||||
let mut odd = false;
|
||||
let mut j = poly.len() - 1;
|
||||
for i in 0..poly.len() {
|
||||
let (a, b) = (poly[i], poly[j]);
|
||||
if (a[1] > p[1]) != (b[1] > p[1])
|
||||
&& p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0]
|
||||
{
|
||||
odd = !odd;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
odd
|
||||
}
|
||||
|
||||
/// Whether a path reaches into the area: any sampled point inside it.
|
||||
fn path_in_area(path: &PdfPath, area: Option<&[[f64; 2]]>) -> bool {
|
||||
let Some(area) = area else { return true };
|
||||
path.subpaths.iter().any(|sp| {
|
||||
sp.segments
|
||||
.iter()
|
||||
.any(|s| [0.0, 0.5, 1.0].iter().any(|t| inside(area, bezier(s, *t))))
|
||||
})
|
||||
}
|
||||
|
||||
/// The objects an import of `page` creates for an underlay, with the
|
||||
/// area in world coordinates.
|
||||
pub fn convert(page: &PageVectors, underlay: &Underlay, area: &ImportArea) -> ImportResult {
|
||||
let world = |p: [f64; 2]| crate::entities::underlay::local_to_world(underlay, p);
|
||||
let world2 = |p: [f64; 2]| {
|
||||
let w = world(p);
|
||||
Vector2::new(w[0], w[1])
|
||||
};
|
||||
let z = underlay.insertion_point.z;
|
||||
let scale = underlay.x_scale.abs();
|
||||
// The area is tested in page space, where the page content lives.
|
||||
let area_local: Option<Vec<[f64; 2]>> = match area {
|
||||
ImportArea::All => None,
|
||||
ImportArea::Polygon(points) => Some(
|
||||
points
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (c, s) = (underlay.rotation.cos(), underlay.rotation.sin());
|
||||
let dx = p.x - underlay.insertion_point.x;
|
||||
let dy = p.y - underlay.insertion_point.y;
|
||||
[
|
||||
(dx * c + dy * s) / underlay.x_scale,
|
||||
(-dx * s + dy * c) / underlay.y_scale,
|
||||
]
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
|
||||
let mut result = ImportResult::default();
|
||||
let mut geometry_color: Option<Color> = None;
|
||||
let mut text_color: Option<Color> = None;
|
||||
// The layer takes the colour of its first object; objects of that
|
||||
// colour then follow the layer.
|
||||
fn push(
|
||||
result: &mut ImportResult,
|
||||
mut entity: EntityType,
|
||||
color: Color,
|
||||
layer: &str,
|
||||
layer_color: &mut Option<Color>,
|
||||
) {
|
||||
let layer_color = *layer_color.get_or_insert(color);
|
||||
let common = entity.common_mut();
|
||||
common.layer = layer.to_string();
|
||||
common.color = if color == layer_color { Color::ByLayer } else { color };
|
||||
result.entities.push(entity);
|
||||
}
|
||||
|
||||
for path in &page.paths {
|
||||
if !path_in_area(path, area_local.as_deref()) {
|
||||
continue;
|
||||
}
|
||||
let (color, weight) = match (path.stroke, path.fill) {
|
||||
(Some((rgb, width)), _) => (color_of(rgb), lineweight_of(width)),
|
||||
(None, Some(rgb)) => (color_of(rgb), LineWeight::ByLayer),
|
||||
(None, None) => continue,
|
||||
};
|
||||
if path.stroke.is_none() {
|
||||
// A filled area: one solid hatch over its loops.
|
||||
let mut hatch = Hatch::solid();
|
||||
for sp in &path.subpaths {
|
||||
let mut ring: Vec<Vector2> = Vec::new();
|
||||
for seg in &sp.segments {
|
||||
let steps = if matches!(seg, Segment::Cubic(..)) { 8 } else { 1 };
|
||||
for k in 0..steps {
|
||||
ring.push(world2(bezier(seg, k as f64 / steps as f64)));
|
||||
}
|
||||
}
|
||||
if ring.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let mut boundary = BoundaryPath::external();
|
||||
boundary.flags.set_polyline(true);
|
||||
boundary.edges.push(BoundaryEdge::Polyline(PolylineEdge::new(ring, true)));
|
||||
hatch.add_path(boundary);
|
||||
}
|
||||
if hatch.paths.is_empty() {
|
||||
continue;
|
||||
}
|
||||
hatch.elevation = z;
|
||||
push(&mut result, EntityType::Hatch(hatch), color, GEOMETRY_LAYER, &mut geometry_color);
|
||||
continue;
|
||||
}
|
||||
for sp in &path.subpaths {
|
||||
let mut entity = if let Some((c, r)) = as_circle(sp) {
|
||||
let w = world(c);
|
||||
EntityType::Circle(Circle::from_center_radius(
|
||||
Vector3::new(w[0], w[1], z),
|
||||
r * scale,
|
||||
))
|
||||
} else if sp.segments.iter().all(|s| matches!(s, Segment::Line(..))) {
|
||||
let mut points: Vec<Vector2> = vec![world2(sp.segments[0].start())];
|
||||
points.extend(sp.segments.iter().map(|s| world2(s.end())));
|
||||
if sp.closed && points.len() > 2 {
|
||||
points.pop();
|
||||
}
|
||||
let mut pl = LwPolyline::from_points(points);
|
||||
pl.is_closed = sp.closed;
|
||||
pl.elevation = z;
|
||||
EntityType::LwPolyline(pl)
|
||||
} else {
|
||||
// Mixed or curved: one cubic B-spline through the Bézier
|
||||
// control points (lines as degree-elevated cubics).
|
||||
let mut spline = Spline::new();
|
||||
spline.degree = 3;
|
||||
let mut controls: Vec<Vector3> = Vec::new();
|
||||
for (i, seg) in sp.segments.iter().enumerate() {
|
||||
let (p0, p1, p2, p3) = match seg {
|
||||
Segment::Cubic(p0, p1, p2, p3) => (*p0, *p1, *p2, *p3),
|
||||
Segment::Line(a, b) => (
|
||||
*a,
|
||||
[a[0] + (b[0] - a[0]) / 3.0, a[1] + (b[1] - a[1]) / 3.0],
|
||||
[a[0] + (b[0] - a[0]) * 2.0 / 3.0, a[1] + (b[1] - a[1]) * 2.0 / 3.0],
|
||||
*b,
|
||||
),
|
||||
};
|
||||
let pts = if i == 0 { vec![p0, p1, p2, p3] } else { vec![p1, p2, p3] };
|
||||
for p in pts {
|
||||
let w = world(p);
|
||||
controls.push(Vector3::new(w[0], w[1], z));
|
||||
}
|
||||
}
|
||||
let spans = sp.segments.len();
|
||||
let mut knots = vec![0.0; 4];
|
||||
for k in 1..spans {
|
||||
knots.extend([k as f64; 3]);
|
||||
}
|
||||
knots.extend([spans as f64; 4]);
|
||||
spline.control_points = controls;
|
||||
spline.weights = Vec::new();
|
||||
spline.knots = knots;
|
||||
spline.flags.closed = sp.closed;
|
||||
EntityType::Spline(spline)
|
||||
};
|
||||
entity.common_mut().line_weight = weight;
|
||||
push(&mut result, entity, color, GEOMETRY_LAYER, &mut geometry_color);
|
||||
}
|
||||
}
|
||||
|
||||
for text in &page.texts {
|
||||
let cap = if text.cap_height > 0.0 { text.cap_height } else { 0.0 };
|
||||
let probe = [text.origin[0] + text.width / 2.0, text.origin[1] + cap / 2.0];
|
||||
if let Some(area) = &area_local {
|
||||
if !inside(area, probe) && !inside(area, text.origin) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let style = format!("PDF {}", text.font);
|
||||
if !result.text_styles.iter().any(|(name, _)| *name == style) {
|
||||
result.text_styles.push((style.clone(), font_file_for(&text.font)));
|
||||
}
|
||||
let (dx, dy) = (-text.rotation.sin(), text.rotation.cos());
|
||||
let mid = [text.origin[0] + dx * cap / 2.0, text.origin[1] + dy * cap / 2.0];
|
||||
let w = world(mid);
|
||||
let mut mtext = MText::new();
|
||||
mtext.value = text.text.trim_end().to_string();
|
||||
mtext.insertion_point = Vector3::new(w[0], w[1], z);
|
||||
mtext.height = cap * scale;
|
||||
mtext.rectangle_width = 0.0;
|
||||
mtext.rotation = text.rotation + underlay.rotation;
|
||||
mtext.style = style;
|
||||
mtext.attachment_point = AttachmentPoint::MiddleLeft;
|
||||
push(&mut result, EntityType::MText(mtext), color_of(text.color), TEXT_LAYER, &mut text_color);
|
||||
}
|
||||
|
||||
if let Some(color) = geometry_color {
|
||||
result.layers.push((GEOMETRY_LAYER.to_string(), color));
|
||||
}
|
||||
if let Some(color) = text_color {
|
||||
result.layers.push((TEXT_LAYER.to_string(), color));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["PDFIMPORT"]
|
||||
});
|
||||
Reference in new issue
Block a user