forked from 1b-agent/opencadstudio-upstream
Merge remote-tracking branch 'origin/main' into codex/attdef-parity
# Conflicts: # Cargo.lock # src/modules/draw/draw/attdef.rs
This commit is contained in:
commit
53f2983e9c
77 files changed
+4034
-243
No files matched your search
@@ -965,16 +965,16 @@ mod thicken_tests {
|
||||
)
|
||||
.unwrap();
|
||||
let source = app.add_surface_model(
|
||||
codec::EntityType::Surface(Surface::new(SurfaceKind::Plane)),
|
||||
codec::EntityType::Surface(Box::new(Surface::new(SurfaceKind::Plane))),
|
||||
body,
|
||||
);
|
||||
assert!(!source.is_null());
|
||||
let i = app.active_tab;
|
||||
let invalid = app.tabs[i]
|
||||
.scene
|
||||
.add_entity(codec::EntityType::Surface(Surface::new(
|
||||
.add_entity(codec::EntityType::Surface(Box::new(Surface::new(
|
||||
SurfaceKind::Generic,
|
||||
)));
|
||||
))));
|
||||
let original =
|
||||
serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap();
|
||||
let before = app.tabs[i].history.undo_stack.len();
|
||||
@@ -1060,7 +1060,7 @@ mod thicken_tests {
|
||||
kernel::brep::extrude_surface(Plane::XY, &[Curve::Nurbs(profile)], [0.0, 0.0, 2.0])
|
||||
.unwrap();
|
||||
let source = app.add_surface_model(
|
||||
codec::EntityType::Surface(Surface::new(SurfaceKind::Generic)),
|
||||
codec::EntityType::Surface(Box::new(Surface::new(SurfaceKind::Generic))),
|
||||
body,
|
||||
);
|
||||
let i = app.active_tab;
|
||||
|
||||
@@ -322,7 +322,7 @@ impl OpenCADStudio {
|
||||
"_PCCROPCIRC" => "C",
|
||||
_ => "",
|
||||
};
|
||||
let command = crate::modules::insert::pc_crop::PointCloudCropCommand::for_cloud(handle, cloud, option);
|
||||
let command = crate::modules::insert::pc_crop::PointCloudCropCommand::for_cloud(handle, *cloud, option);
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
}
|
||||
|
||||
@@ -163,10 +163,10 @@ impl OpenCADStudio {
|
||||
elevation_as_gradient: false,
|
||||
croppings: Vec::new(),
|
||||
};
|
||||
let entity = codec::EntityType::Extended(codec::entities::ExtendedEntity {
|
||||
let entity = codec::EntityType::Extended(Box::new(codec::entities::ExtendedEntity {
|
||||
common: codec::entities::EntityCommon::new(),
|
||||
data: codec::entities::ExtendedEntityData::PointCloudEx(data),
|
||||
});
|
||||
}));
|
||||
if let Some(cloud) = self.commit_entity_handle(entity) {
|
||||
// The reactor, owned by the cloud, tells the definition who uses it.
|
||||
let document = &mut self.tabs[i].scene.document;
|
||||
|
||||
@@ -116,20 +116,73 @@ fn embed_point(req: &Value) -> Result<codec::types::Vector3, Value> {
|
||||
}
|
||||
Ok(codec::types::Vector3::new(p[0], p[1], p[2]))
|
||||
}
|
||||
fn parse_point_2d(val: &Value) -> Result<[f64; 2], Value> {
|
||||
let arr = val
|
||||
.as_array()
|
||||
.filter(|v| v.len() >= 2)
|
||||
.ok_or_else(|| failure("invalid_point", "Expected [x, y]"))?;
|
||||
let x = arr[0]
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite())
|
||||
.ok_or_else(|| failure("invalid_point", "Expected finite coordinates"))?;
|
||||
let y = arr[1]
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite())
|
||||
.ok_or_else(|| failure("invalid_point", "Expected finite coordinates"))?;
|
||||
Ok([x, y])
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// `embed_image` — pack the picture at `path` into an OLE2FRAME placed
|
||||
/// with its lower-left corner at `at` (default width ≈ pixel_width/100,
|
||||
/// like the interactive command's Enter answer) and commit it as one
|
||||
/// undo step. The drawing stays self-contained: no external file to lose.
|
||||
/// Supports automatic 2-point calibration via `source_points` and `target_points`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn control_embed_image(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
||||
let i = self.active_tab;
|
||||
let path = string(req, "path")?;
|
||||
let image = crate::io::ole_embed::EmbeddedImage::from_file(std::path::Path::new(&path))
|
||||
.map_err(|e| failure("embed_failed", e))?;
|
||||
let at = embed_point(req)?;
|
||||
let default_width = (image.pixel_width as f64 / 100.0).max(1.0);
|
||||
let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width);
|
||||
let (at, width) = if let (Some(src_pts), Some(tgt_pts)) = (
|
||||
req.get("source_points").and_then(Value::as_array),
|
||||
req.get("target_points").and_then(Value::as_array),
|
||||
) {
|
||||
if src_pts.len() >= 2 && tgt_pts.len() >= 2 {
|
||||
let p_s1 = parse_point_2d(&src_pts[0])?;
|
||||
let p_s2 = parse_point_2d(&src_pts[1])?;
|
||||
let p_t1 = parse_point_2d(&tgt_pts[0])?;
|
||||
let p_t2 = parse_point_2d(&tgt_pts[1])?;
|
||||
let d_px = ((p_s2[0] - p_s1[0]).powi(2) + (p_s2[1] - p_s1[1]).powi(2)).sqrt();
|
||||
let d_cad = ((p_t2[0] - p_t1[0]).powi(2) + (p_t2[1] - p_t1[1]).powi(2)).sqrt();
|
||||
if d_px <= 1e-6 {
|
||||
return Err(failure(
|
||||
"invalid_points",
|
||||
"Source calibration points must be distinct",
|
||||
));
|
||||
}
|
||||
let scale = d_cad / d_px;
|
||||
let w = image.pixel_width as f64 * scale;
|
||||
// In image pixel coords, y is 0 at top and pixel_height at bottom.
|
||||
// In CAD coordinates, lower-left is (x_ll, y_ll).
|
||||
// x_target = x_ll + px * scale => x_ll = x_target - px * scale
|
||||
// y_target = y_ll + (pixel_height - py) * scale => y_ll = y_target - (pixel_height - py) * scale
|
||||
let x_ll = p_t1[0] - p_s1[0] * scale;
|
||||
let y_ll = p_t1[1] - (image.pixel_height as f64 - p_s1[1]) * scale;
|
||||
let calc_at = codec::types::Vector3::new(x_ll, y_ll, 0.0);
|
||||
let final_at = embed_point(req).unwrap_or(calc_at);
|
||||
let final_w = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(w);
|
||||
(final_at, final_w)
|
||||
} else {
|
||||
let default_width = (image.pixel_width as f64 / 100.0).max(1.0);
|
||||
let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width);
|
||||
(embed_point(req)?, width)
|
||||
}
|
||||
} else {
|
||||
let default_width = (image.pixel_width as f64 / 100.0).max(1.0);
|
||||
let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width);
|
||||
(embed_point(req)?, width)
|
||||
};
|
||||
if req["linked"].as_bool().unwrap_or(false) {
|
||||
// Path-linked RasterImage + ImageDefinition: the drawing stores
|
||||
// only the file path, so the picture file must travel with the
|
||||
@@ -168,6 +221,7 @@ impl OpenCADStudio {
|
||||
"handle": format!("{:X}", handle.value()),
|
||||
"kind": "RasterImage",
|
||||
"path": path,
|
||||
"at": [at.x, at.y, at.z],
|
||||
"width": width,
|
||||
"height": height,
|
||||
"linked": true,
|
||||
@@ -187,6 +241,7 @@ impl OpenCADStudio {
|
||||
"handle": format!("{:X}", handle.value()),
|
||||
"kind": "Ole2Frame",
|
||||
"path": path,
|
||||
"at": [at.x, at.y, at.z],
|
||||
"width": width,
|
||||
}));
|
||||
Ok(Task::none())
|
||||
|
||||
+184
-10
@@ -464,6 +464,7 @@ impl OpenCADStudio {
|
||||
| "records"
|
||||
| "record_schema"
|
||||
| "capabilities"
|
||||
| "tools"
|
||||
| "entities"
|
||||
| "layers"
|
||||
| "header"
|
||||
@@ -527,6 +528,17 @@ impl OpenCADStudio {
|
||||
Task::none(),
|
||||
);
|
||||
}
|
||||
if op == "tools" {
|
||||
return (
|
||||
json!({
|
||||
"ok": true,
|
||||
"status": "completed",
|
||||
"tools": crate::mcp::tool_definitions(),
|
||||
"instructions": crate::mcp::INSTRUCTIONS
|
||||
}),
|
||||
Task::none(),
|
||||
);
|
||||
}
|
||||
if op == "commands" {
|
||||
let mut names = crate::command::all_registered_command_names();
|
||||
let mut names: Vec<String> = names.drain(..).map(str::to_owned).collect();
|
||||
@@ -665,7 +677,7 @@ impl OpenCADStudio {
|
||||
);
|
||||
}
|
||||
if self.tabs[self.active_tab].id != id
|
||||
&& !matches!(op, "activate" | "close" | "entities_copy_to")
|
||||
&& !matches!(op, "activate" | "switch_document" | "close" | "entities_copy_to")
|
||||
{
|
||||
return (
|
||||
failure(
|
||||
@@ -743,7 +755,7 @@ impl OpenCADStudio {
|
||||
let client = req["client_id"].as_str().unwrap_or("default").to_owned();
|
||||
if tab.active_cmd.is_some()
|
||||
&& (self.control.owner.as_ref() != Some(&(tab.id, client.clone()))
|
||||
|| matches!(op, "run" | "start" | "new" | "open" | "activate"))
|
||||
|| matches!(op, "run" | "start" | "new" | "open" | "activate" | "switch_document"))
|
||||
&& op != "cancel"
|
||||
{
|
||||
return (
|
||||
@@ -774,7 +786,7 @@ impl OpenCADStudio {
|
||||
);
|
||||
}
|
||||
}
|
||||
let doc = if matches!(op, "new" | "open" | "activate") {
|
||||
let doc = if matches!(op, "new" | "open" | "activate" | "switch_document") {
|
||||
None
|
||||
} else {
|
||||
Some(tab.id)
|
||||
@@ -803,7 +815,7 @@ impl OpenCADStudio {
|
||||
if self.tabs[self.active_tab].active_cmd.is_some() {
|
||||
self.control.owner = Some((self.tabs[self.active_tab].id, client));
|
||||
}
|
||||
if matches!(op, "new" | "activate")
|
||||
if matches!(op, "new" | "activate" | "switch_document")
|
||||
&& self
|
||||
.control
|
||||
.pending
|
||||
@@ -876,7 +888,7 @@ impl OpenCADStudio {
|
||||
"open" => self.update(Message::OpenExternal(std::path::PathBuf::from(string(
|
||||
req, "path",
|
||||
)?))),
|
||||
"activate" => {
|
||||
"activate" | "switch_document" => {
|
||||
let i = self
|
||||
.tabs
|
||||
.iter()
|
||||
@@ -1058,6 +1070,58 @@ impl OpenCADStudio {
|
||||
.main_window
|
||||
.ok_or_else(|| failure("gui_required", "Capture requires a GUI window"))?;
|
||||
let path = string(req, "path")?.to_owned();
|
||||
crate::sys::restore_window_if_minimized();
|
||||
|
||||
// Optional framing / selection adjustments prior to snapshot:
|
||||
if let Some(handles) = req.get("highlight_handles").and_then(Value::as_array) {
|
||||
let hex_handles: Vec<codec::Handle> = handles
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter_map(|v| u64::from_str_radix(v.trim_start_matches("0x"), 16).ok())
|
||||
.map(codec::Handle::new)
|
||||
.collect();
|
||||
if !hex_handles.is_empty() {
|
||||
let scene = &mut self.tabs[self.active_tab].scene;
|
||||
scene.deselect_all();
|
||||
for h in hex_handles {
|
||||
scene.select_entity(h, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(focus) = req.get("focus_handles").and_then(Value::as_array) {
|
||||
let hex_handles: Vec<codec::Handle> = focus
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter_map(|v| u64::from_str_radix(v.trim_start_matches("0x"), 16).ok())
|
||||
.map(codec::Handle::new)
|
||||
.collect();
|
||||
if !hex_handles.is_empty() {
|
||||
self.tabs[self.active_tab].scene.zoom_to_entities(&hex_handles);
|
||||
}
|
||||
} else if let Some(bounds) = req.get("bounds").and_then(Value::as_array) {
|
||||
if bounds.len() >= 4 {
|
||||
if let (Some(x0), Some(y0), Some(x1), Some(y1)) = (
|
||||
bounds[0].as_f64(),
|
||||
bounds[1].as_f64(),
|
||||
bounds[2].as_f64(),
|
||||
bounds[3].as_f64(),
|
||||
) {
|
||||
let p1 = glam::Vec3::new(x0 as f32, y0 as f32, 0.0);
|
||||
let p2 = glam::Vec3::new(x1 as f32, y1 as f32, 0.0);
|
||||
self.tabs[self.active_tab].scene.zoom_to_window(p1, p2);
|
||||
}
|
||||
}
|
||||
} else if let Some(view) = req.get("view").and_then(Value::as_str) {
|
||||
if view == "extents" {
|
||||
self.tabs[self.active_tab].scene.fit_all();
|
||||
} else if view == "selection" {
|
||||
let selected = self.tabs[self.active_tab].scene.selected_handles_in_order();
|
||||
if !selected.is_empty() {
|
||||
self.tabs[self.active_tab].scene.zoom_to_entities(&selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A minimized window has a 0x0 surface and the renderer
|
||||
// panics reading it back, so report instead of capturing.
|
||||
iced::window::size(window).then(move |size| {
|
||||
@@ -1118,7 +1182,7 @@ impl OpenCADStudio {
|
||||
if pending.document_id.is_none()
|
||||
&& matches!(
|
||||
pending.request["op"].as_str(),
|
||||
Some("new" | "open" | "activate")
|
||||
Some("new" | "open" | "activate" | "switch_document")
|
||||
)
|
||||
&& pending.origin_document != self.tabs[self.active_tab].id
|
||||
{
|
||||
@@ -1270,7 +1334,7 @@ impl OpenCADStudio {
|
||||
.pending
|
||||
.as_ref()
|
||||
.and_then(|pending| pending.request["scope"].as_str())
|
||||
.unwrap_or("window");
|
||||
.unwrap_or("viewport");
|
||||
let max_dimension = self
|
||||
.control
|
||||
.pending
|
||||
@@ -1278,10 +1342,22 @@ impl OpenCADStudio {
|
||||
.and_then(|pending| pending.request["max_dimension"].as_u64())
|
||||
.unwrap_or(1600)
|
||||
.clamp(256, 4096) as u32;
|
||||
let annotate = self
|
||||
.control
|
||||
.pending
|
||||
.as_ref()
|
||||
.and_then(|pending| pending.request["annotate"].as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut image =
|
||||
image::RgbaImage::from_raw(s.size.width, s.size.height, s.rgba.to_vec())
|
||||
.ok_or("Renderer returned malformed image data")?;
|
||||
let mut actual_scope = "window";
|
||||
|
||||
let (vp_w, vp_h) = self.tabs[self.active_tab].scene.selection.borrow().vp_size;
|
||||
let mut vp_logical_width = if vp_w > 0.0 { vp_w } else { image.width() as f32 / s.scale_factor };
|
||||
let mut vp_logical_height = if vp_h > 0.0 { vp_h } else { image.height() as f32 / s.scale_factor };
|
||||
|
||||
if requested_scope == "viewport" {
|
||||
if let Some(bounds) = crate::ui::wrap_bar::dropdown_bounds(
|
||||
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
|
||||
@@ -1305,6 +1381,8 @@ impl OpenCADStudio {
|
||||
)
|
||||
.to_image();
|
||||
actual_scope = "viewport";
|
||||
vp_logical_width = bounds.width;
|
||||
vp_logical_height = bounds.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1320,12 +1398,77 @@ impl OpenCADStudio {
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
}
|
||||
|
||||
// Spatial grounding: camera, world bounds, and visible entity centroids
|
||||
let grounding = vision::compute_grounding(
|
||||
self,
|
||||
vp_logical_width,
|
||||
vp_logical_height,
|
||||
image.width(),
|
||||
image.height(),
|
||||
);
|
||||
|
||||
// Optional Set-of-Marks visual overlay badges
|
||||
if annotate {
|
||||
for ent_val in &grounding.visible_entities {
|
||||
if let (Some(tag), Some(px_arr), Some(selected)) = (
|
||||
ent_val["tag"].as_u64(),
|
||||
ent_val["screen_pixel"].as_array(),
|
||||
ent_val["selected"].as_bool(),
|
||||
) {
|
||||
if px_arr.len() == 2 {
|
||||
if let (Some(x), Some(y)) = (px_arr[0].as_i64(), px_arr[1].as_i64()) {
|
||||
vision::draw_som_badge(
|
||||
&mut image,
|
||||
x as i32,
|
||||
y as i32,
|
||||
tag as usize,
|
||||
selected,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
image
|
||||
.save_with_format(&path, image::ImageFormat::Png)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(
|
||||
json!({"path":path,"scope":actual_scope,"width":image.width(),"height":image.height(),"scale_factor":s.scale_factor,"document_id":self.tabs[self.active_tab].id,"revision":self.tabs[self.active_tab].edit_revision,"camera_revision":self.tabs[self.active_tab].scene.camera_generation}),
|
||||
)
|
||||
|
||||
let doc_unit = crate::app::properties::insunits_name(
|
||||
self.tabs[self.active_tab].scene.document.header.insertion_units,
|
||||
);
|
||||
|
||||
let spatial_obj = json!({
|
||||
"crs": "CAD_WCS",
|
||||
"unit": doc_unit,
|
||||
"pixel_resolution": [image.width(), image.height()],
|
||||
"world_bounds": grounding.viewport_world_bounds,
|
||||
"pixel_to_world_matrix": grounding.pixel_to_world_matrix,
|
||||
"world_to_pixel_matrix": grounding.world_to_pixel_matrix,
|
||||
"camera": grounding.camera,
|
||||
"target_plane": grounding.target_plane,
|
||||
"annotations": grounding.visible_entities.clone(),
|
||||
});
|
||||
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"scope": actual_scope,
|
||||
"width": image.width(),
|
||||
"height": image.height(),
|
||||
"scale_factor": s.scale_factor,
|
||||
"document_id": self.tabs[self.active_tab].id,
|
||||
"revision": self.tabs[self.active_tab].edit_revision,
|
||||
"camera_revision": self.tabs[self.active_tab].scene.camera_generation,
|
||||
"_spatial": spatial_obj,
|
||||
"pixel_to_world_matrix": grounding.pixel_to_world_matrix,
|
||||
"world_to_pixel_matrix": grounding.world_to_pixel_matrix,
|
||||
"viewport_world_bounds": grounding.viewport_world_bounds,
|
||||
"camera": grounding.camera,
|
||||
"annotated": annotate,
|
||||
"annotations": grounding.visible_entities.clone(),
|
||||
"visible_entities": grounding.visible_entities,
|
||||
}))
|
||||
})();
|
||||
match result {
|
||||
Ok(v) => {
|
||||
@@ -1337,6 +1480,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) mod vision;
|
||||
mod actions;
|
||||
pub(crate) use actions::property_json;
|
||||
mod entities;
|
||||
@@ -1763,6 +1907,36 @@ mod tests {
|
||||
let _ = handle;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_image_calibrates_from_source_and_target_points() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
request(&mut app, json!({"op":"new"}));
|
||||
let img = image::RgbaImage::from_pixel(100, 100, image::Rgba([12, 12, 12, 255]));
|
||||
let path = std::env::temp_dir().join(format!("ocs-embed-calib-{}.png", session_id()));
|
||||
img.save(&path).unwrap();
|
||||
|
||||
// 2 points on image: bottom-left (0, 100) and bottom-right (100, 100)
|
||||
// Target CAD points: (10.0, 20.0) and (210.0, 20.0) -> scale = 200/100 = 2.0
|
||||
let response = request(
|
||||
&mut app,
|
||||
json!({
|
||||
"op": "embed_image",
|
||||
"path": path.to_string_lossy(),
|
||||
"linked": true,
|
||||
"source_points": [[0, 100], [100, 100]],
|
||||
"target_points": [[10.0, 20.0], [210.0, 20.0]]
|
||||
}),
|
||||
);
|
||||
assert_eq!(response["status"], "completed", "{response}");
|
||||
assert_eq!(response["result"]["kind"], "RasterImage");
|
||||
assert_eq!(response["result"]["width"], 200.0);
|
||||
assert_eq!(response["result"]["height"], 200.0);
|
||||
let at = response["result"]["at"].as_array().unwrap();
|
||||
assert_eq!(at[0], 10.0);
|
||||
assert_eq!(at[1], 20.0);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
fn user_select_pick(app: &mut OpenCADStudio, kind: &str) -> String {
|
||||
let handle = app.automation_op(
|
||||
format!(r#"{{"op":"query","type":"{kind}","detail":"summary"}}"#).as_str(),
|
||||
|
||||
@@ -53,6 +53,7 @@ fn listen(sender: mpsc::Sender<Envelope>) -> std::io::Result<()> {
|
||||
let mut file = options.open(&path)?;
|
||||
write!(file, "{descriptor}")?;
|
||||
drop(file);
|
||||
let _ = crate::mcp::sync_agent_tool_schemas();
|
||||
let clients = Arc::new(AtomicUsize::new(0));
|
||||
for stream in listener.incoming().flatten() {
|
||||
if clients.fetch_add(1, Ordering::SeqCst) >= 8 {
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
use crate::app::OpenCADStudio;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 5x7 bitmapped font glyphs for digits '0' through '9'
|
||||
const DIGITS_5X7: [[u8; 7]; 10] = [
|
||||
[0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], // 0
|
||||
[0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], // 1
|
||||
[0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111], // 2
|
||||
[0b11110, 0b00001, 0b00001, 0b01110, 0b00001, 0b00001, 0b11110], // 3
|
||||
[0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], // 4
|
||||
[0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110], // 5
|
||||
[0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110], // 6
|
||||
[0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000], // 7
|
||||
[0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110], // 8
|
||||
[0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100], // 9
|
||||
];
|
||||
|
||||
/// Draw a Set-of-Marks badge at `(center_x, center_y)` onto `image`.
|
||||
pub(crate) fn draw_som_badge(
|
||||
image: &mut image::RgbaImage,
|
||||
center_x: i32,
|
||||
center_y: i32,
|
||||
tag: usize,
|
||||
selected: bool,
|
||||
) {
|
||||
let tag_str = tag.to_string();
|
||||
let num_digits = tag_str.len();
|
||||
if num_digits == 0 {
|
||||
return;
|
||||
}
|
||||
let text_w = num_digits * 5 + (num_digits - 1) * 1;
|
||||
let badge_w = (text_w + 8) as i32;
|
||||
let badge_h = 13i32;
|
||||
|
||||
let img_w = image.width() as i32;
|
||||
let img_h = image.height() as i32;
|
||||
|
||||
// Anchor badge slightly above the center anchor point if possible
|
||||
let x0 = (center_x - badge_w / 2).clamp(1, (img_w - badge_w - 1).max(1));
|
||||
let y0 = (center_y - badge_h - 2).clamp(1, (img_h - badge_h - 1).max(1));
|
||||
|
||||
let (bg, border, text_col) = if selected {
|
||||
(
|
||||
image::Rgba([0, 230, 255, 245]), // Electric cyan
|
||||
image::Rgba([0, 60, 80, 255]),
|
||||
image::Rgba([0, 0, 0, 255]),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
image::Rgba([255, 220, 0, 245]), // Vibrant yellow
|
||||
image::Rgba([40, 30, 0, 255]),
|
||||
image::Rgba([0, 0, 0, 255]),
|
||||
)
|
||||
};
|
||||
|
||||
// Fill badge background and border
|
||||
for y in y0..(y0 + badge_h) {
|
||||
for x in x0..(x0 + badge_w) {
|
||||
if x < 0 || x >= img_w || y < 0 || y >= img_h {
|
||||
continue;
|
||||
}
|
||||
let is_border = x == x0 || x == x0 + badge_w - 1 || y == y0 || y == y0 + badge_h - 1;
|
||||
let col = if is_border { border } else { bg };
|
||||
image.put_pixel(x as u32, y as u32, col);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw digits
|
||||
let mut cur_x = x0 + 4;
|
||||
let cur_y = y0 + 3;
|
||||
for ch in tag_str.chars() {
|
||||
if let Some(digit) = ch.to_digit(10) {
|
||||
let d = digit as usize;
|
||||
if d < 10 {
|
||||
for r in 0..7 {
|
||||
let row_bits = DIGITS_5X7[d][r];
|
||||
for c in 0..5 {
|
||||
if (row_bits & (1 << (4 - c))) != 0 {
|
||||
let px = cur_x + c as i32;
|
||||
let py = cur_y + r as i32;
|
||||
if px >= 0 && px < img_w && py >= 0 && py < img_h {
|
||||
image.put_pixel(px as u32, py as u32, text_col);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cur_x += 6;
|
||||
}
|
||||
|
||||
// Small 3x3 indicator dot at the center anchor
|
||||
let dot_col = if selected {
|
||||
image::Rgba([0, 230, 255, 255])
|
||||
} else {
|
||||
image::Rgba([255, 60, 0, 255])
|
||||
};
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let px = center_x + dx;
|
||||
let py = center_y + dy;
|
||||
if px >= 0 && px < img_w && py >= 0 && py < img_h {
|
||||
image.put_pixel(px as u32, py as u32, dot_col);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct VisionGrounding {
|
||||
pub viewport_world_bounds: Value,
|
||||
pub camera: Value,
|
||||
pub target_plane: Value,
|
||||
pub pixel_to_world_matrix: [f64; 6],
|
||||
pub world_to_pixel_matrix: [f64; 6],
|
||||
pub visible_entities: Vec<Value>,
|
||||
}
|
||||
|
||||
pub(crate) fn compute_grounding(
|
||||
app: &OpenCADStudio,
|
||||
vp_logical_width: f32,
|
||||
vp_logical_height: f32,
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
) -> VisionGrounding {
|
||||
let tab = &app.tabs[app.active_tab];
|
||||
let c = tab.scene.camera.borrow();
|
||||
let vp_rect = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: vp_logical_width.max(1.0),
|
||||
height: vp_logical_height.max(1.0),
|
||||
};
|
||||
|
||||
let p_tl = c.pick_on_target_plane(iced::Point::new(0.0, 0.0), vp_rect);
|
||||
let p_tr = c.pick_on_target_plane(iced::Point::new(vp_rect.width, 0.0), vp_rect);
|
||||
let p_bl = c.pick_on_target_plane(iced::Point::new(0.0, vp_rect.height), vp_rect);
|
||||
let p_br = c.pick_on_target_plane(iced::Point::new(vp_rect.width, vp_rect.height), vp_rect);
|
||||
|
||||
let min_x = p_tl.x.min(p_tr.x).min(p_bl.x).min(p_br.x);
|
||||
let max_x = p_tl.x.max(p_tr.x).max(p_bl.x).max(p_br.x);
|
||||
let min_y = p_tl.y.min(p_tr.y).min(p_bl.y).min(p_br.y);
|
||||
let max_y = p_tl.y.max(p_tr.y).max(p_bl.y).max(p_br.y);
|
||||
|
||||
let viewport_world_bounds = json!({
|
||||
"min": [min_x, min_y],
|
||||
"max": [max_x, max_y]
|
||||
});
|
||||
|
||||
let eye = c.eye();
|
||||
let target = c.target;
|
||||
|
||||
let normal = (c.rotation * glam::Vec3::NEG_Z).normalize_or(glam::Vec3::Z);
|
||||
let target_plane = json!({
|
||||
"normal": [normal.x, normal.y, normal.z],
|
||||
"origin": [target.x, target.y, target.z],
|
||||
});
|
||||
|
||||
let proj_name = match c.projection {
|
||||
crate::scene::view::camera::Projection::Orthographic => "Orthographic",
|
||||
crate::scene::view::camera::Projection::Perspective => "Perspective",
|
||||
};
|
||||
let camera_info = json!({
|
||||
"eye": [eye.x, eye.y, eye.z],
|
||||
"target": [target.x, target.y, target.z],
|
||||
"distance": c.distance,
|
||||
"ortho_size": c.ortho_size(),
|
||||
"projection": proj_name,
|
||||
"fov_y": c.fov_y,
|
||||
});
|
||||
|
||||
let p_origin = p_tl;
|
||||
let p_right = p_tr;
|
||||
let p_bottom = p_bl;
|
||||
|
||||
let d_x = (p_right - p_origin) / (image_width as f64).max(1.0);
|
||||
let d_y = (p_bottom - p_origin) / (image_height as f64).max(1.0);
|
||||
|
||||
let m00 = d_x.x;
|
||||
let m01 = d_y.x;
|
||||
let tx = p_origin.x;
|
||||
let m10 = d_x.y;
|
||||
let m11 = d_y.y;
|
||||
let ty = p_origin.y;
|
||||
|
||||
let det = m00 * m11 - m01 * m10;
|
||||
let (inv_m00, inv_m01, inv_m10, inv_m11, inv_tx, inv_ty) = if det.abs() > 1e-12 {
|
||||
let inv_m00 = m11 / det;
|
||||
let inv_m01 = -m01 / det;
|
||||
let inv_m10 = -m10 / det;
|
||||
let inv_m11 = m00 / det;
|
||||
let inv_tx = -(inv_m00 * tx + inv_m01 * ty);
|
||||
let inv_ty = -(inv_m10 * tx + inv_m11 * ty);
|
||||
(inv_m00, inv_m01, inv_m10, inv_m11, inv_tx, inv_ty)
|
||||
} else {
|
||||
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
};
|
||||
|
||||
let pixel_to_world_matrix = [m00, m01, m10, m11, tx, ty];
|
||||
let world_to_pixel_matrix = [inv_m00, inv_m01, inv_m10, inv_m11, inv_tx, inv_ty];
|
||||
|
||||
let img_w = image_width as f32;
|
||||
let img_h = image_height as f32;
|
||||
|
||||
struct Candidate {
|
||||
handle: codec::Handle,
|
||||
entity_type: &'static str,
|
||||
layer: String,
|
||||
screen_pixel: [i32; 2],
|
||||
screen_bounds: [i32; 4],
|
||||
world_center: [f64; 3],
|
||||
selected: bool,
|
||||
area: f32,
|
||||
}
|
||||
|
||||
let selected_set: std::collections::HashSet<codec::Handle> =
|
||||
tab.scene.selected_handles_in_order().into_iter().collect();
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
for entity in tab.scene.document.entities() {
|
||||
let handle = entity.common().handle;
|
||||
let (b_min, b_max) = crate::scene::convert::tess::entity_bounds_in(&tab.scene.document, entity);
|
||||
let min_dvec = glam::DVec3::from(b_min);
|
||||
let max_dvec = glam::DVec3::from(b_max);
|
||||
if !min_dvec.is_finite() || !max_dvec.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let center = (min_dvec + max_dvec) * 0.5;
|
||||
if let Some(screen_pt) = c.project(center, vp_rect) {
|
||||
if screen_pt.x >= 0.0
|
||||
&& screen_pt.x <= vp_rect.width
|
||||
&& screen_pt.y >= 0.0
|
||||
&& screen_pt.y <= vp_rect.height
|
||||
{
|
||||
let px = (screen_pt.x / vp_rect.width * img_w).round() as i32;
|
||||
let py = (screen_pt.y / vp_rect.height * img_h).round() as i32;
|
||||
|
||||
let mut s_min_x = px;
|
||||
let mut s_max_x = px;
|
||||
let mut s_min_y = py;
|
||||
let mut s_max_y = py;
|
||||
|
||||
if let (Some(p0), Some(p1)) =
|
||||
(c.project(min_dvec, vp_rect), c.project(max_dvec, vp_rect))
|
||||
{
|
||||
let x0 = (p0.x / vp_rect.width * img_w).round() as i32;
|
||||
let y0 = (p0.y / vp_rect.height * img_h).round() as i32;
|
||||
let x1 = (p1.x / vp_rect.width * img_w).round() as i32;
|
||||
let y1 = (p1.y / vp_rect.height * img_h).round() as i32;
|
||||
s_min_x = x0.min(x1);
|
||||
s_max_x = x0.max(x1);
|
||||
s_min_y = y0.min(y1);
|
||||
s_max_y = y0.max(y1);
|
||||
}
|
||||
|
||||
// An unbounded entity (XLINE, RAY) projects to saturated i32
|
||||
// corners, whose difference overflows i32.
|
||||
let width = (f64::from(s_max_x) - f64::from(s_min_x)).abs();
|
||||
let area = (width * (f64::from(s_max_y) - f64::from(s_min_y)).abs()) as f32;
|
||||
let selected = selected_set.contains(&handle);
|
||||
let layer = entity.common().layer.clone();
|
||||
|
||||
candidates.push(Candidate {
|
||||
handle,
|
||||
entity_type: crate::entities::names::ui_name(entity),
|
||||
layer,
|
||||
screen_pixel: [px, py],
|
||||
screen_bounds: [s_min_x, s_min_y, s_max_x, s_max_y],
|
||||
world_center: [center.x, center.y, center.z],
|
||||
selected,
|
||||
area,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selected entities prioritized, then larger visual area first
|
||||
candidates.sort_by(|a, b| {
|
||||
b.selected
|
||||
.cmp(&a.selected)
|
||||
.then_with(|| b.area.partial_cmp(&a.area).unwrap_or(std::cmp::Ordering::Equal))
|
||||
});
|
||||
|
||||
candidates.truncate(64);
|
||||
|
||||
let visible_entities = candidates
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, cand)| {
|
||||
json!({
|
||||
"tag": idx + 1,
|
||||
"handle": format!("{:X}", cand.handle.value()),
|
||||
"type": cand.entity_type,
|
||||
"layer": cand.layer,
|
||||
"screen_pixel": cand.screen_pixel,
|
||||
"screen_bounds": cand.screen_bounds,
|
||||
"world_center": cand.world_center,
|
||||
"selected": cand.selected
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
VisionGrounding {
|
||||
viewport_world_bounds,
|
||||
camera: camera_info,
|
||||
target_plane,
|
||||
pixel_to_world_matrix,
|
||||
world_to_pixel_matrix,
|
||||
visible_entities,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct VisualDiffResult {
|
||||
pub changed: bool,
|
||||
pub change_percentage: f64,
|
||||
pub changed_pixels: usize,
|
||||
pub dirty_pixel_bounds: Option<[u32; 4]>, // [min_x, min_y, max_x, max_y]
|
||||
pub dirty_world_bounds: Option<[f64; 4]>, // [min_wx, min_wy, max_wx, max_wy]
|
||||
pub patch_resolution: Option<[u32; 2]>,
|
||||
pub cropped_patch: Option<image::RgbaImage>,
|
||||
pub diff_overlay: image::RgbaImage,
|
||||
}
|
||||
|
||||
pub(crate) fn compute_visual_diff(
|
||||
baseline: &image::RgbaImage,
|
||||
current: &image::RgbaImage,
|
||||
pixel_to_world_matrix: &[f64; 6],
|
||||
) -> VisualDiffResult {
|
||||
let (w, h) = (current.width(), current.height());
|
||||
let mut diff_overlay = image::RgbaImage::new(w, h);
|
||||
|
||||
if baseline.width() != w || baseline.height() != h {
|
||||
return VisualDiffResult {
|
||||
changed: true,
|
||||
change_percentage: 100.0,
|
||||
changed_pixels: (w * h) as usize,
|
||||
dirty_pixel_bounds: Some([0, 0, w.saturating_sub(1), h.saturating_sub(1)]),
|
||||
dirty_world_bounds: None,
|
||||
patch_resolution: Some([w, h]),
|
||||
cropped_patch: Some(current.clone()),
|
||||
diff_overlay: current.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut min_x = u32::MAX;
|
||||
let mut min_y = u32::MAX;
|
||||
let mut max_x = 0u32;
|
||||
let mut max_y = 0u32;
|
||||
let mut changed_pixels = 0usize;
|
||||
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let p1 = baseline.get_pixel(x, y);
|
||||
let p2 = current.get_pixel(x, y);
|
||||
|
||||
let dr = (p1[0] as i32 - p2[0] as i32).abs();
|
||||
let dg = (p1[1] as i32 - p2[1] as i32).abs();
|
||||
let db = (p1[2] as i32 - p2[2] as i32).abs();
|
||||
let da = (p1[3] as i32 - p2[3] as i32).abs();
|
||||
|
||||
let is_diff = (dr + dg + db + da) > 20;
|
||||
|
||||
if is_diff {
|
||||
changed_pixels += 1;
|
||||
min_x = min_x.min(x);
|
||||
min_y = min_y.min(y);
|
||||
max_x = max_x.max(x);
|
||||
max_y = max_y.max(y);
|
||||
|
||||
// Highlight changed pixel in vibrant green
|
||||
diff_overlay.put_pixel(x, y, image::Rgba([0, 255, 128, 255]));
|
||||
} else {
|
||||
// Dim unchanged background: grayscale attenuated to 35%
|
||||
let gray = ((p2[0] as f32 * 0.299 + p2[1] as f32 * 0.587 + p2[2] as f32 * 0.114) * 0.35) as u8;
|
||||
diff_overlay.put_pixel(x, y, image::Rgba([gray, gray, gray, 200]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total_pixels = (w * h) as f64;
|
||||
let change_percentage = if total_pixels > 0.0 {
|
||||
(changed_pixels as f64 / total_pixels) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if changed_pixels == 0 {
|
||||
return VisualDiffResult {
|
||||
changed: false,
|
||||
change_percentage: 0.0,
|
||||
changed_pixels: 0,
|
||||
dirty_pixel_bounds: None,
|
||||
dirty_world_bounds: None,
|
||||
patch_resolution: None,
|
||||
cropped_patch: None,
|
||||
diff_overlay,
|
||||
};
|
||||
}
|
||||
|
||||
let pad = 16u32;
|
||||
let b_min_x = min_x.saturating_sub(pad);
|
||||
let b_min_y = min_y.saturating_sub(pad);
|
||||
let b_max_x = (max_x + pad).min(w.saturating_sub(1));
|
||||
let b_max_y = (max_y + pad).min(h.saturating_sub(1));
|
||||
|
||||
let patch_w = (b_max_x - b_min_x + 1).max(1);
|
||||
let patch_h = (b_max_y - b_min_y + 1).max(1);
|
||||
|
||||
let m = pixel_to_world_matrix;
|
||||
let px0 = b_min_x as f64;
|
||||
let py0 = b_min_y as f64;
|
||||
let px1 = b_max_x as f64;
|
||||
let py1 = b_max_y as f64;
|
||||
|
||||
let p00_x = m[0] * px0 + m[1] * py0 + m[4];
|
||||
let p00_y = m[2] * px0 + m[3] * py0 + m[5];
|
||||
let p10_x = m[0] * px1 + m[1] * py0 + m[4];
|
||||
let p10_y = m[2] * px1 + m[3] * py0 + m[5];
|
||||
let p01_x = m[0] * px0 + m[1] * py1 + m[4];
|
||||
let p01_y = m[2] * px0 + m[3] * py1 + m[5];
|
||||
let p11_x = m[0] * px1 + m[1] * py1 + m[4];
|
||||
let p11_y = m[2] * px1 + m[3] * py1 + m[5];
|
||||
|
||||
let w_min_x = p00_x.min(p10_x).min(p01_x).min(p11_x);
|
||||
let w_max_x = p00_x.max(p10_x).max(p01_x).max(p11_x);
|
||||
let w_min_y = p00_y.min(p10_y).min(p01_y).min(p11_y);
|
||||
let w_max_y = p00_y.max(p10_y).max(p01_y).max(p11_y);
|
||||
|
||||
let cropped = image::imageops::crop_imm(current, b_min_x, b_min_y, patch_w, patch_h).to_image();
|
||||
|
||||
VisualDiffResult {
|
||||
changed: true,
|
||||
change_percentage,
|
||||
changed_pixels,
|
||||
dirty_pixel_bounds: Some([b_min_x, b_min_y, b_max_x, b_max_y]),
|
||||
dirty_world_bounds: Some([w_min_x, w_min_y, w_max_x, w_max_y]),
|
||||
patch_resolution: Some([patch_w, patch_h]),
|
||||
cropped_patch: Some(cropped),
|
||||
diff_overlay,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode_image_png(img: &image::RgbaImage) -> Result<Vec<u8>, String> {
|
||||
use std::io::Cursor;
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Failed to encode PNG: {e}"))?;
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PyramidLevelInfo {
|
||||
pub level: u32,
|
||||
pub grid: [u32; 2],
|
||||
pub tile_world_span: [f64; 2],
|
||||
pub tiles_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PyramidManifest {
|
||||
pub crs: String,
|
||||
pub unit: String,
|
||||
pub world_bounds: [f64; 4],
|
||||
pub max_level: u32,
|
||||
pub tile_size: u32,
|
||||
pub tile_uri_template: String,
|
||||
pub levels: Vec<PyramidLevelInfo>,
|
||||
}
|
||||
|
||||
pub fn compute_pyramid_manifest(
|
||||
session_id: &str,
|
||||
unit: &str,
|
||||
world_bounds: [f64; 4],
|
||||
max_level: u32,
|
||||
tile_size: u32,
|
||||
) -> PyramidManifest {
|
||||
let min_x = world_bounds[0];
|
||||
let min_y = world_bounds[1];
|
||||
let max_x = world_bounds[2];
|
||||
let max_y = world_bounds[3];
|
||||
|
||||
let w = (max_x - min_x).abs().max(1e-4);
|
||||
let h = (max_y - min_y).abs().max(1e-4);
|
||||
|
||||
let max_lvl = max_level.clamp(1, 6);
|
||||
let mut levels = Vec::new();
|
||||
|
||||
for level in 0..=max_lvl {
|
||||
let cols = 1u32 << level;
|
||||
let rows = 1u32 << level;
|
||||
let tile_w = w / cols as f64;
|
||||
let tile_h = h / rows as f64;
|
||||
levels.push(PyramidLevelInfo {
|
||||
level,
|
||||
grid: [cols, rows],
|
||||
tile_world_span: [tile_w, tile_h],
|
||||
tiles_count: cols * rows,
|
||||
});
|
||||
}
|
||||
|
||||
PyramidManifest {
|
||||
crs: "CAD_WCS".to_string(),
|
||||
unit: unit.to_string(),
|
||||
world_bounds: [min_x, min_y, max_x, max_y],
|
||||
max_level: max_lvl,
|
||||
tile_size,
|
||||
tile_uri_template: format!("cad://session/{session_id}/tile/{{level}}/{{x}}/{{y}}.png"),
|
||||
levels,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_tile_bounds(
|
||||
manifest_world_bounds: [f64; 4],
|
||||
level: u32,
|
||||
x: u32,
|
||||
y: u32,
|
||||
) -> Result<[f64; 4], String> {
|
||||
let cols = 1u32 << level;
|
||||
let rows = 1u32 << level;
|
||||
|
||||
if x >= cols || y >= rows {
|
||||
return Err(format!(
|
||||
"Tile coordinates ({x}, {y}) out of range for level {level} (grid is {cols}x{rows})"
|
||||
));
|
||||
}
|
||||
|
||||
let min_x = manifest_world_bounds[0];
|
||||
let min_y = manifest_world_bounds[1];
|
||||
let max_x = manifest_world_bounds[2];
|
||||
let max_y = manifest_world_bounds[3];
|
||||
|
||||
let w = (max_x - min_x).abs().max(1e-4);
|
||||
let h = (max_y - min_y).abs().max(1e-4);
|
||||
|
||||
let tile_w = w / cols as f64;
|
||||
let tile_h = h / rows as f64;
|
||||
|
||||
let t_min_x = min_x + (x as f64) * tile_w;
|
||||
let t_max_x = t_min_x + tile_w;
|
||||
let t_max_y = max_y - (y as f64) * tile_h;
|
||||
let t_min_y = t_max_y - tile_h;
|
||||
|
||||
Ok([t_min_x, t_min_y, t_max_x, t_max_y])
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn point_to_tile(
|
||||
manifest_world_bounds: [f64; 4],
|
||||
level: u32,
|
||||
point_x: f64,
|
||||
point_y: f64,
|
||||
) -> (u32, u32) {
|
||||
let cols = 1u32 << level;
|
||||
let rows = 1u32 << level;
|
||||
|
||||
let min_x = manifest_world_bounds[0];
|
||||
let min_y = manifest_world_bounds[1];
|
||||
let max_x = manifest_world_bounds[2];
|
||||
let max_y = manifest_world_bounds[3];
|
||||
|
||||
let w = (max_x - min_x).abs().max(1e-4);
|
||||
let h = (max_y - min_y).abs().max(1e-4);
|
||||
|
||||
let tile_w = w / cols as f64;
|
||||
let tile_h = h / rows as f64;
|
||||
|
||||
let tx = (((point_x - min_x) / tile_w).floor() as i64).clamp(0, (cols - 1) as i64) as u32;
|
||||
let ty = (((max_y - point_y) / tile_h).floor() as i64).clamp(0, (rows - 1) as i64) as u32;
|
||||
|
||||
(tx, ty)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_draw_som_badge_renders_without_panics() {
|
||||
let mut img = image::RgbaImage::from_pixel(120, 100, image::Rgba([40, 40, 40, 255]));
|
||||
|
||||
// Render normal badge
|
||||
draw_som_badge(&mut img, 50, 50, 1, false);
|
||||
|
||||
// Center dot should be drawn
|
||||
let dot = img.get_pixel(50, 50);
|
||||
assert_eq!(*dot, image::Rgba([255, 60, 0, 255]));
|
||||
|
||||
// Render selected badge with multi-digit tag near edge (clamped)
|
||||
draw_som_badge(&mut img, 2, 2, 42, true);
|
||||
let selected_dot = img.get_pixel(2, 2);
|
||||
assert_eq!(*selected_dot, image::Rgba([0, 230, 255, 255]));
|
||||
|
||||
// Right edge clamp
|
||||
draw_som_badge(&mut img, 118, 98, 99, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_grounding_identifies_visible_entities() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 5"}"#);
|
||||
app.automation_op(r#"{"op":"run","cmd":"LINE -10,-10 10,10"}"#);
|
||||
|
||||
// Set test canvas viewport size
|
||||
app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0);
|
||||
app.tabs[0].scene.fit_all();
|
||||
|
||||
let grounding = compute_grounding(&app, 800.0, 600.0, 800, 600);
|
||||
|
||||
// Viewport world bounds should be valid
|
||||
assert!(grounding.viewport_world_bounds["min"].is_array());
|
||||
assert!(grounding.viewport_world_bounds["max"].is_array());
|
||||
|
||||
// Camera info should be populated with projection
|
||||
assert!(grounding.camera["eye"].is_array());
|
||||
assert!(grounding.camera["target"].is_array());
|
||||
assert_eq!(grounding.camera["projection"], "Orthographic");
|
||||
|
||||
// Target plane normal and origin
|
||||
assert!(grounding.target_plane["normal"].is_array());
|
||||
assert!(grounding.target_plane["origin"].is_array());
|
||||
|
||||
// Affine matrices: test forward and inverse round-trip
|
||||
let p2w = grounding.pixel_to_world_matrix;
|
||||
let w2p = grounding.world_to_pixel_matrix;
|
||||
|
||||
// Center pixel (400, 300) should project to camera target (within 1e-4)
|
||||
let cx = 400.0;
|
||||
let cy = 300.0;
|
||||
let wx = p2w[0] * cx + p2w[1] * cy + p2w[4];
|
||||
let wy = p2w[2] * cx + p2w[3] * cy + p2w[5];
|
||||
let target_x = grounding.camera["target"][0].as_f64().unwrap();
|
||||
let target_y = grounding.camera["target"][1].as_f64().unwrap();
|
||||
assert!((wx - target_x).abs() < 1e-3, "wx={wx}, target_x={target_x}");
|
||||
assert!((wy - target_y).abs() < 1e-3, "wy={wy}, target_y={target_y}");
|
||||
|
||||
// Inverse mapping of (wx, wy) should return (400, 300)
|
||||
let inv_px = w2p[0] * wx + w2p[1] * wy + w2p[4];
|
||||
let inv_py = w2p[2] * wx + w2p[3] * wy + w2p[5];
|
||||
assert!((inv_px - cx).abs() < 1e-3, "inv_px={inv_px}, cx={cx}");
|
||||
assert!((inv_py - cy).abs() < 1e-3, "inv_py={inv_py}, cy={cy}");
|
||||
|
||||
// Both entities should be visible and tagged
|
||||
assert_eq!(grounding.visible_entities.len(), 2);
|
||||
|
||||
let first = &grounding.visible_entities[0];
|
||||
assert_eq!(first["tag"], 1);
|
||||
assert!(first["handle"].is_string());
|
||||
assert!(first["type"].is_string());
|
||||
assert!(first["screen_pixel"].is_array());
|
||||
assert!(first["screen_bounds"].is_array());
|
||||
assert!(first["world_center"].is_array());
|
||||
|
||||
let second = &grounding.visible_entities[1];
|
||||
assert_eq!(second["tag"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_visual_diff() {
|
||||
let w = 200;
|
||||
let h = 150;
|
||||
let base = image::RgbaImage::from_pixel(w, h, image::Rgba([30, 30, 30, 255]));
|
||||
let p2w = [0.1, 0.0, 0.0, -0.1, -10.0, 7.5];
|
||||
|
||||
// 1. Identical images -> changed: false
|
||||
let diff_same = compute_visual_diff(&base, &base, &p2w);
|
||||
assert_eq!(diff_same.changed, false);
|
||||
assert_eq!(diff_same.change_percentage, 0.0);
|
||||
assert_eq!(diff_same.changed_pixels, 0);
|
||||
assert!(diff_same.dirty_pixel_bounds.is_none());
|
||||
|
||||
// 2. Modify a 20x20 block from (50, 40) to (69, 59)
|
||||
let mut curr = base.clone();
|
||||
for y in 40..60 {
|
||||
for x in 50..70 {
|
||||
curr.put_pixel(x, y, image::Rgba([255, 255, 255, 255]));
|
||||
}
|
||||
}
|
||||
|
||||
let diff_mod = compute_visual_diff(&base, &curr, &p2w);
|
||||
assert_eq!(diff_mod.changed, true);
|
||||
assert_eq!(diff_mod.changed_pixels, 400);
|
||||
assert!(diff_mod.change_percentage > 1.0);
|
||||
|
||||
let bounds = diff_mod.dirty_pixel_bounds.unwrap();
|
||||
// With 16px pad: min_x <= 50, max_x >= 69, min_y <= 40, max_y >= 59
|
||||
assert!(bounds[0] <= 50);
|
||||
assert!(bounds[1] <= 40);
|
||||
assert!(bounds[2] >= 69);
|
||||
assert!(bounds[3] >= 59);
|
||||
|
||||
// World bounds should be populated
|
||||
assert!(diff_mod.dirty_world_bounds.is_some());
|
||||
let wb = diff_mod.dirty_world_bounds.unwrap();
|
||||
assert!(wb[0] < wb[2]); // min_x < max_x
|
||||
assert!(wb[1] < wb[3]); // min_y < max_y
|
||||
|
||||
// Cropped patch and overlay
|
||||
assert!(diff_mod.cropped_patch.is_some());
|
||||
let patch = diff_mod.cropped_patch.unwrap();
|
||||
assert!(patch.width() >= 20);
|
||||
assert!(patch.height() >= 20);
|
||||
|
||||
// PNG encoding roundtrip
|
||||
let png_bytes = encode_image_png(&patch).expect("png encode succeeds");
|
||||
assert!(!png_bytes.is_empty());
|
||||
assert_eq!(&png_bytes[1..4], b"PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pyramid_tiling_manifest_and_bounds() {
|
||||
let wb = [0.0, 0.0, 200.0, 100.0];
|
||||
let manifest = compute_pyramid_manifest("sess_test", "Millimeters", wb, 3, 512);
|
||||
|
||||
assert_eq!(manifest.crs, "CAD_WCS");
|
||||
assert_eq!(manifest.unit, "Millimeters");
|
||||
assert_eq!(manifest.world_bounds, wb);
|
||||
assert_eq!(manifest.max_level, 3);
|
||||
assert_eq!(manifest.tile_size, 512);
|
||||
assert_eq!(manifest.levels.len(), 4); // 0, 1, 2, 3
|
||||
|
||||
// Level 0: 1x1 tile covering full world bounds
|
||||
assert_eq!(manifest.levels[0].grid, [1, 1]);
|
||||
assert_eq!(manifest.levels[0].tiles_count, 1);
|
||||
assert_eq!(manifest.levels[0].tile_world_span, [200.0, 100.0]);
|
||||
let t00 = compute_tile_bounds(wb, 0, 0, 0).unwrap();
|
||||
assert_eq!(t00, [0.0, 0.0, 200.0, 100.0]);
|
||||
|
||||
// Level 1: 2x2 grid (4 tiles, 100x50 span each)
|
||||
assert_eq!(manifest.levels[1].grid, [2, 2]);
|
||||
assert_eq!(manifest.levels[1].tiles_count, 4);
|
||||
assert_eq!(manifest.levels[1].tile_world_span, [100.0, 50.0]);
|
||||
|
||||
// (0, 0): top-left
|
||||
let t1_00 = compute_tile_bounds(wb, 1, 0, 0).unwrap();
|
||||
assert_eq!(t1_00, [0.0, 50.0, 100.0, 100.0]);
|
||||
|
||||
// (1, 0): top-right
|
||||
let t1_10 = compute_tile_bounds(wb, 1, 1, 0).unwrap();
|
||||
assert_eq!(t1_10, [100.0, 50.0, 200.0, 100.0]);
|
||||
|
||||
// (0, 1): bottom-left
|
||||
let t1_01 = compute_tile_bounds(wb, 1, 0, 1).unwrap();
|
||||
assert_eq!(t1_01, [0.0, 0.0, 100.0, 50.0]);
|
||||
|
||||
// (1, 1): bottom-right
|
||||
let t1_11 = compute_tile_bounds(wb, 1, 1, 1).unwrap();
|
||||
assert_eq!(t1_11, [100.0, 0.0, 200.0, 50.0]);
|
||||
|
||||
// Out-of-range checks
|
||||
assert!(compute_tile_bounds(wb, 1, 2, 0).is_err());
|
||||
assert!(compute_tile_bounds(wb, 1, 0, 2).is_err());
|
||||
|
||||
// Point-to-tile mapping
|
||||
assert_eq!(point_to_tile(wb, 1, 25.0, 75.0), (0, 0));
|
||||
assert_eq!(point_to_tile(wb, 1, 175.0, 75.0), (1, 0));
|
||||
assert_eq!(point_to_tile(wb, 1, 25.0, 25.0), (0, 1));
|
||||
assert_eq!(point_to_tile(wb, 1, 175.0, 25.0), (1, 1));
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -685,15 +685,15 @@ impl OpenCADStudio {
|
||||
/// Add is delta-safe when its layer and XData application IDs already exist
|
||||
/// and it creates no block records.
|
||||
pub(super) fn delta_add_safe(&self, i: usize, entity: &EntityType) -> bool {
|
||||
if matches!(
|
||||
entity,
|
||||
EntityType::Block(_)
|
||||
| EntityType::BlockEnd(_)
|
||||
| EntityType::Extended(codec::entities::ExtendedEntity {
|
||||
data: codec::entities::ExtendedEntityData::SectionObject(_),
|
||||
..
|
||||
})
|
||||
) {
|
||||
if matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_))
|
||||
|| matches!(
|
||||
entity,
|
||||
EntityType::Extended(extended) if matches!(
|
||||
extended.data,
|
||||
codec::entities::ExtendedEntityData::SectionObject(_)
|
||||
)
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let layer = entity.common().layer.clone();
|
||||
|
||||
@@ -1354,7 +1354,7 @@ impl super::OpenCADStudio {
|
||||
let mut entity = Surface::new(SurfaceKind::Generic);
|
||||
entity.common = group.common;
|
||||
self.add_surface_model_preserving_style(
|
||||
EntityType::Surface(entity),
|
||||
EntityType::Surface(Box::new(entity)),
|
||||
group.body,
|
||||
)
|
||||
}
|
||||
@@ -2316,7 +2316,7 @@ impl super::OpenCADStudio {
|
||||
}
|
||||
let n = surfaces.len();
|
||||
for surf in surfaces {
|
||||
self.tabs[i].scene.add_entity(EntityType::Surface(surf));
|
||||
self.tabs[i].scene.add_entity(EntityType::Surface(Box::new(surf)));
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
|
||||
@@ -198,14 +198,14 @@ impl<'a> HostSession<'a> {
|
||||
}
|
||||
if let EntityType::Helix(new) = entity {
|
||||
let old = match old {
|
||||
Some(EntityType::Helix(old)) => Some(old),
|
||||
Some(EntityType::Helix(old)) => Some(&**old),
|
||||
_ => None,
|
||||
};
|
||||
crate::entities::helix::normalize_scripted_helix(old, new)?;
|
||||
}
|
||||
if let EntityType::Table(new) = entity {
|
||||
let old = match old {
|
||||
Some(EntityType::Table(old)) => Some(old),
|
||||
Some(EntityType::Table(old)) => Some(&**old),
|
||||
_ => None,
|
||||
};
|
||||
crate::entities::table::normalize_scripted_table(old, new);
|
||||
@@ -758,7 +758,7 @@ impl<'a> HostSession<'a> {
|
||||
surface.wires = model::edge_wires(&body);
|
||||
surface.acis_data = codec::entities::AcisData::from_sat(&sat.to_sat_string());
|
||||
surface.common.layer = layer;
|
||||
self.commit_profile_result(EntityType::Surface(surface), source, delete_source, "Create surface")
|
||||
self.commit_profile_result(EntityType::Surface(Box::new(surface)), source, delete_source, "Create surface")
|
||||
}
|
||||
SolidOperation::Extrude { source, direction, layer, delete_source } => {
|
||||
if direction.iter().any(|v| !v.is_finite()) {
|
||||
@@ -788,7 +788,7 @@ impl<'a> HostSession<'a> {
|
||||
surface.wires = model::edge_wires(&body);
|
||||
surface.acis_data = codec::entities::AcisData::from_sat(&sat.to_sat_string());
|
||||
surface.common.layer = layer;
|
||||
EntityType::Surface(surface)
|
||||
EntityType::Surface(Box::new(surface))
|
||||
};
|
||||
self.commit_profile_result(result, source, delete_source, "Extrude profile")
|
||||
}
|
||||
@@ -7201,7 +7201,7 @@ mod tests {
|
||||
let unliftable = host.add_entity(EntityType::Solid3D(codec::entities::Solid3D::new()));
|
||||
let mut plane = codec::entities::Surface::new(codec::entities::SurfaceKind::Plane);
|
||||
plane.acis_data = codec::entities::AcisData::new();
|
||||
let surface = host.add_entity(EntityType::Surface(plane));
|
||||
let surface = host.add_entity(EntityType::Surface(Box::new(plane)));
|
||||
let far = { dispatch(&mut host, "PY_EVAL ocs.active_document.solids.box(center=(500, 500, 500), size=(1, 1, 1)).handle");
|
||||
*solids_in(host.document()).last().unwrap() };
|
||||
let counts = (host.document().entities().count(), host.app.tabs[0].history.undo_stack.len());
|
||||
|
||||
@@ -140,7 +140,7 @@ impl super::OpenCADStudio {
|
||||
surface.surface_data = solid_history::extrusion_surface_data(record)
|
||||
.ok_or("PRESSPULL: the surface parameters could not be retained.")?;
|
||||
}
|
||||
EntityType::Surface(surface)
|
||||
EntityType::Surface(Box::new(surface))
|
||||
};
|
||||
(body, history, entity)
|
||||
}
|
||||
|
||||
@@ -10938,7 +10938,7 @@ mod free_text_entry_tests {
|
||||
let handle = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.add_entity(EntityType::Table(table.clone()))
|
||||
.add_entity(EntityType::Table(Box::new(table.clone())))
|
||||
.unwrap();
|
||||
app.set_active_command(0, Box::new(TableCellEditCommand::new(handle, &table, 0, 0)));
|
||||
(app, handle)
|
||||
|
||||
@@ -2865,7 +2865,9 @@ impl OpenCADStudio {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
};
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
previews.push(guide);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,11 @@ pub struct Cli {
|
||||
#[arg(help = crate::t!("Connect AI clients to the running editor over MCP stdio.").into_owned(), long_help = None)]
|
||||
pub mcp: bool,
|
||||
|
||||
/// Export and synchronize AI agent MCP tool schemas to ~/.gemini/antigravity/mcp/opencadstudio/ and exit.
|
||||
#[arg(long = "sync-mcp-schemas")]
|
||||
#[arg(help = crate::t!("Export and synchronize AI agent MCP tool schemas to ~/.gemini/antigravity/mcp/opencadstudio/ and exit.").into_owned(), long_help = None)]
|
||||
pub sync_mcp_schemas: bool,
|
||||
|
||||
/// TCP port for --serve (defaults to stdin/stdout).
|
||||
#[arg(long, value_name = "PORT")]
|
||||
#[arg(help = crate::t!("TCP port for --serve (defaults to stdin/stdout).").into_owned(), long_help = None)]
|
||||
|
||||
@@ -4051,7 +4051,9 @@ fn tessellate_dimension_inner(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if !ext2.is_empty() {
|
||||
wires.push(WireModel {
|
||||
@@ -4088,7 +4090,9 @@ fn tessellate_dimension_inner(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
wires.push(WireModel {
|
||||
@@ -4125,7 +4129,9 @@ fn tessellate_dimension_inner(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4168,7 +4174,9 @@ fn tessellate_dimension_inner(
|
||||
// — not a crash. Follow-up: double-single-split via points_to_ds to
|
||||
// match emit_wire's paired fill path.
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
// Arrowheads and ticks: the dimension line's colour and weight, never
|
||||
// its dashes.
|
||||
if !geom.arrow_lines.is_empty() {
|
||||
@@ -4224,7 +4232,9 @@ fn tessellate_dimension_inner(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// DIMTFILL: 0=none, 1=drawing background (mask), 2=DIMTFILLCLR.
|
||||
@@ -4279,7 +4289,9 @@ fn tessellate_dimension_inner(
|
||||
// (sub-metre error at UTM scale) — not a crash. Follow-up:
|
||||
// double-single-split via points_to_ds to match emit_wire.
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4326,7 +4338,9 @@ fn tessellate_dimension_inner(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5855,6 +5869,15 @@ fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) {
|
||||
&wire.text_verts,
|
||||
map,
|
||||
);
|
||||
// OCS→WCS is rigid: unit height scale, no in-plane rotation change.
|
||||
wire.map_searchable_runs(
|
||||
&|p| {
|
||||
let mapped = map(p[0], p[1], p[2]);
|
||||
[mapped.0, mapped.1, mapped.2]
|
||||
},
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
for (point, _) in &mut wire.snap_pts {
|
||||
let mapped = map(point.x, point.y, point.z);
|
||||
*point = DVec3::new(mapped.0, mapped.1, mapped.2);
|
||||
|
||||
@@ -705,6 +705,7 @@ impl LeaderTess for Leader {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -887,6 +888,7 @@ impl LeaderTess for Leader {
|
||||
plinegen: true,
|
||||
fill_tris,
|
||||
fill_tris_low,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-16
@@ -2106,9 +2106,11 @@ impl MultiLeaderTess for MultiLeader {
|
||||
// error at UTM scale) — not a crash. Follow-up: double-single-split
|
||||
// via points_to_ds to match emit_wire's paired fill path.
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let host = codec::EntityType::MultiLeader(ml.clone());
|
||||
let host = codec::EntityType::MultiLeader(Box::new(ml.clone()));
|
||||
for block_use in crate::scene::render_graph::entity_render_block_uses(document, &host, 1.0)
|
||||
.into_iter()
|
||||
.filter(|block_use| {
|
||||
@@ -2299,6 +2301,8 @@ impl MultiLeaderTess for MultiLeader {
|
||||
// cluster-position data.
|
||||
let mut deco_pts: Vec<[f32; 3]> = Vec::new();
|
||||
let mut deco_fill: Vec<[f32; 3]> = Vec::new();
|
||||
let mut searchable: Vec<crate::scene::model::wire_model::SearchableTextRun> =
|
||||
Vec::new();
|
||||
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
||||
for ts in &layout.strokes {
|
||||
let is_shaped = ts
|
||||
@@ -2331,17 +2335,18 @@ impl MultiLeaderTess for MultiLeader {
|
||||
continue;
|
||||
}
|
||||
let run = ts.run.as_ref().unwrap();
|
||||
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
|
||||
&mut atlas,
|
||||
run.height,
|
||||
run.rotation,
|
||||
run.width_factor,
|
||||
run.oblique,
|
||||
run.tracking,
|
||||
&run.font,
|
||||
run.bold,
|
||||
&run.text,
|
||||
);
|
||||
let (quads, pen_adv) =
|
||||
crate::scene::text::glyph_quads::layout_glyph_quads(
|
||||
&mut atlas,
|
||||
run.height,
|
||||
run.rotation,
|
||||
run.width_factor,
|
||||
run.oblique,
|
||||
run.tracking,
|
||||
&run.font,
|
||||
run.bold,
|
||||
&run.text,
|
||||
);
|
||||
// Inline `\C` / `\c` colour wins over the entity text
|
||||
// colour, matching the top-level Text arm.
|
||||
let gcolor = ts
|
||||
@@ -2356,6 +2361,24 @@ impl MultiLeaderTess for MultiLeader {
|
||||
gcolor,
|
||||
0.0,
|
||||
);
|
||||
{
|
||||
use crate::scene::model::wire_model::{
|
||||
clean_searchable_text, SearchableTextRun,
|
||||
};
|
||||
let visible = clean_searchable_text(&run.text);
|
||||
if !visible.is_empty() {
|
||||
searchable.push(SearchableTextRun {
|
||||
text: visible,
|
||||
origin: [ts.origin[0], ts.origin[1], z as f64],
|
||||
height: run.height,
|
||||
rotation: run.rotation,
|
||||
color: gcolor,
|
||||
bold: run.bold,
|
||||
font: run.font.clone(),
|
||||
adv_width: pen_adv,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sdf_verts.is_empty() || !deco_pts.is_empty() || !deco_fill.is_empty() {
|
||||
@@ -2400,6 +2423,7 @@ impl MultiLeaderTess for MultiLeader {
|
||||
dash_from_start: false,
|
||||
dash_align_end: None,
|
||||
text_verts: sdf_verts,
|
||||
searchable_text: searchable,
|
||||
name: name.clone(),
|
||||
points: deco_pts,
|
||||
points_low: Vec::new(),
|
||||
@@ -2492,7 +2516,9 @@ impl MultiLeaderTess for MultiLeader {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2583,7 +2609,9 @@ impl MultiLeaderTess for MultiLeader {
|
||||
plinegen: true,
|
||||
fill_tris,
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// Text frame — closed rectangle, matches text color.
|
||||
@@ -2629,7 +2657,9 @@ impl MultiLeaderTess for MultiLeader {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
-12
@@ -1488,6 +1488,7 @@ pub fn tessellate_table(
|
||||
// SDF cell text: glyph quads (per-vertex coloured) collected across all
|
||||
// cells; emitted as one text-carrying wire at the end.
|
||||
let mut text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
|
||||
let mut searchable: Vec<crate::scene::model::wire_model::SearchableTextRun> = Vec::new();
|
||||
let mut borders: HashMap<([u8; 4], u32), ([f32; 4], f32, Vec<[f32; 3]>)> = HashMap::default();
|
||||
let mut emitted: rustc_hash::FxHashSet<(i32, i32, i32, i32, i32, i32)> =
|
||||
rustc_hash::FxHashSet::default();
|
||||
@@ -2016,17 +2017,18 @@ pub fn tessellate_table(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
|
||||
&mut atlas,
|
||||
run.height,
|
||||
run.rotation,
|
||||
run.width_factor,
|
||||
run.oblique,
|
||||
run.tracking,
|
||||
&run.font,
|
||||
run.bold,
|
||||
&run.text,
|
||||
);
|
||||
let (quads, pen_adv) =
|
||||
crate::scene::text::glyph_quads::layout_glyph_quads(
|
||||
&mut atlas,
|
||||
run.height,
|
||||
run.rotation,
|
||||
run.width_factor,
|
||||
run.oblique,
|
||||
run.tracking,
|
||||
&run.font,
|
||||
run.bold,
|
||||
&run.text,
|
||||
);
|
||||
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
||||
&mut text_verts,
|
||||
&quads,
|
||||
@@ -2035,6 +2037,24 @@ pub fn tessellate_table(
|
||||
tcol,
|
||||
0.0,
|
||||
);
|
||||
{
|
||||
use crate::scene::model::wire_model::{
|
||||
clean_searchable_text, SearchableTextRun,
|
||||
};
|
||||
let visible = clean_searchable_text(&run.text);
|
||||
if !visible.is_empty() {
|
||||
searchable.push(SearchableTextRun {
|
||||
text: visible,
|
||||
origin: [stroke.origin[0], stroke.origin[1], to.z as f64],
|
||||
height: run.height,
|
||||
rotation: run.rotation,
|
||||
color: tcol,
|
||||
bold: run.bold,
|
||||
font: run.font.clone(),
|
||||
adv_width: pen_adv,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2084,7 +2104,9 @@ pub fn tessellate_table(
|
||||
// (sub-metre error at UTM scale) — not a crash. Follow-up:
|
||||
// double-single-split via points_to_ds to match emit_wire.
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
let mut out: Vec<WireModel> = Vec::new();
|
||||
@@ -2115,6 +2137,7 @@ pub fn tessellate_table(
|
||||
let mut w = mk(entity_color, vec![], vec![], line_weight_px);
|
||||
w.aabb = [nx as f32, ny as f32, xx as f32, xy as f32];
|
||||
w.text_verts = text_verts;
|
||||
w.searchable_text = searchable;
|
||||
out.push(w);
|
||||
}
|
||||
out
|
||||
|
||||
@@ -81,6 +81,63 @@ pub trait Transformable {
|
||||
fn apply_transform(&mut self, t: &EntityTransform);
|
||||
}
|
||||
|
||||
// The codec boxes its rare wide entity kinds (Surface, Table, MultiLeader,
|
||||
// Helix, Extended) so every other entity is stored narrow; the boxed ones
|
||||
// answer through their contents.
|
||||
impl<T: RenderConvertible + ?Sized> RenderConvertible for Box<T> {
|
||||
fn to_render(&self, document: &CadDocument) -> Option<RenderEntity> {
|
||||
(**self).to_render(document)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Grippable + ?Sized> Grippable for Box<T> {
|
||||
fn grips(&self) -> Vec<GripDef> {
|
||||
(**self).grips()
|
||||
}
|
||||
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
|
||||
(**self).apply_grip(grip_id, apply)
|
||||
}
|
||||
fn grip_menu(&self, grip_id: usize) -> Vec<GripMenuItem> {
|
||||
(**self).grip_menu(grip_id)
|
||||
}
|
||||
fn apply_grip_menu(&mut self, grip_id: usize, action: GripMenuAction) {
|
||||
(**self).apply_grip_menu(grip_id, action)
|
||||
}
|
||||
fn apply_grip_menu_value(&mut self, grip_id: usize, action: GripMenuAction, value: f64) {
|
||||
(**self).apply_grip_menu_value(grip_id, action, value)
|
||||
}
|
||||
fn grip_menu_value_prompt(
|
||||
&self,
|
||||
grip_id: usize,
|
||||
action: GripMenuAction,
|
||||
) -> Option<&'static str> {
|
||||
(**self).grip_menu_value_prompt(grip_id, action)
|
||||
}
|
||||
fn grip_menu_point_value(
|
||||
&self,
|
||||
grip_id: usize,
|
||||
action: GripMenuAction,
|
||||
point: glam::DVec3,
|
||||
) -> Option<f64> {
|
||||
(**self).grip_menu_point_value(grip_id, action, point)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: PropertyEditable + ?Sized> PropertyEditable for Box<T> {
|
||||
fn geometry_properties(&self, text_style_names: &[String]) -> Vec<PropSection> {
|
||||
(**self).geometry_properties(text_style_names)
|
||||
}
|
||||
fn apply_geom_prop(&mut self, field: &str, value: &str) {
|
||||
(**self).apply_geom_prop(field, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Transformable + ?Sized> Transformable for Box<T> {
|
||||
fn apply_transform(&mut self, t: &EntityTransform) {
|
||||
(**self).apply_transform(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// Inquiry-time mass / area / perimeter properties for entities whose
|
||||
/// 2D footprint has a meaningful area or perimeter (Circle, Arc, Line,
|
||||
/// LwPolyline, Ellipse). Entities outside this set get `None` via the
|
||||
|
||||
+635
-4
@@ -15,11 +15,13 @@ use crate::scene::model::hatch_model::HatchPattern;
|
||||
use crate::scene::WireModel;
|
||||
use crate::scene::model::image_model::ImageModel;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::scene::model::wire_model::SearchableTextRun;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use printpdf::{
|
||||
BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line,
|
||||
LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode, PdfDocument,
|
||||
PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, Pt, Rgb, TextItem,
|
||||
WindingOrder,
|
||||
TextRenderingMode, WindingOrder,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -425,11 +427,14 @@ pub async fn pick_pdf_path_async(stem: String) -> Option<std::path::PathBuf> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Result<Vec<u8>, String> {
|
||||
let mut doc = PdfDocument::new("Open CAD Studio Export");
|
||||
// Subset-embed one TrueType font per used system family before any page
|
||||
// references them (fonts live on the document resource dictionary).
|
||||
let fonts = SearchableFontSet::build(&mut doc, pages);
|
||||
// Borrowing all pages keeps their pixel Arcs alive until this cache is dropped.
|
||||
// Allocation addresses cannot be reused by another source during this export.
|
||||
let mut image_resources = std::collections::HashMap::new();
|
||||
for (index, page) in pages.iter().enumerate() {
|
||||
append_pdf_page(&mut doc, &mut image_resources, page, plot_style)
|
||||
append_pdf_page(&mut doc, &mut image_resources, page, plot_style, &fonts)
|
||||
.map_err(|error| format!("Page {}: {error}", index + 1))?;
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
@@ -451,6 +456,7 @@ fn append_pdf_page(
|
||||
image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>,
|
||||
page: &PdfPageInput,
|
||||
fallback_plot_style: Option<&PlotStyleTable>,
|
||||
fonts: &SearchableFontSet,
|
||||
) -> Result<(), String> {
|
||||
let PlotContent { wires, hatches, wipeouts, images, group_splits } = &page.content;
|
||||
let (paper_w, paper_h) = (page.paper_w as f32, page.paper_h as f32);
|
||||
@@ -590,7 +596,7 @@ fn append_pdf_page(
|
||||
}
|
||||
draw_items.push((wire.draw_depth, 2u8, sequence, DrawItem::Wire(wire)));
|
||||
sequence += 1;
|
||||
if !wire.text_verts.is_empty() {
|
||||
if !wire.text_verts.is_empty() || !wire.searchable_text.is_empty() {
|
||||
draw_items.push((wire.draw_depth, 3u8, sequence, DrawItem::Text(wire)));
|
||||
sequence += 1;
|
||||
}
|
||||
@@ -653,6 +659,13 @@ fn append_pdf_page(
|
||||
continue;
|
||||
}
|
||||
DrawItem::Text(wire) => {
|
||||
// Searchable layer + outlines coexist: the vector outlines
|
||||
// always draw (pixel-identical plots, decorations, stroke
|
||||
// fonts, shaped/mixed scripts all keep working) and the
|
||||
// preserved runs add an *invisible* (`Tr 3`) text layer on
|
||||
// top for searching, copying and screen readers. Zero
|
||||
// visual change by construction — a gap in the text path
|
||||
// can only affect extractability, never the rendered sheet.
|
||||
emit_text(
|
||||
&mut ops,
|
||||
std::slice::from_ref(&wire.wire),
|
||||
@@ -662,6 +675,16 @@ fn append_pdf_page(
|
||||
plot_style,
|
||||
options,
|
||||
);
|
||||
emit_searchable_text(
|
||||
&mut ops,
|
||||
std::slice::from_ref(&wire.wire),
|
||||
ox,
|
||||
oy,
|
||||
clip,
|
||||
plot_style,
|
||||
options,
|
||||
fonts,
|
||||
);
|
||||
last_color = None;
|
||||
last_lw = None;
|
||||
last_dash = None;
|
||||
@@ -1506,6 +1529,378 @@ fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Invisible searchable text layer for laid-out runs (`Tr 3`).
|
||||
///
|
||||
/// Each `SearchableTextRun` carries the visible string plus its final
|
||||
/// world-space baseline origin, cap height, advance width and rotation — all
|
||||
/// baked by the layout stage (MTEXT `\P` newlines arrive as separate per-line
|
||||
/// runs, `attachment_point`/width wrapping and block placement already
|
||||
/// applied), so the exporter only places a `BT … Tj ET` with an
|
||||
/// arbitrary-rotation text matrix (`Tm`), honouring the MTEXT traps by
|
||||
/// construction.
|
||||
///
|
||||
/// Design (review V2): the vector outlines are **always** drawn — the plot
|
||||
/// looks pixel-identical with or without this layer — and the runs add an
|
||||
/// invisible (`Tr 3`) text layer for searching, copying and screen readers.
|
||||
/// A gap here can only affect extractability, never the rendered sheet.
|
||||
/// Decorations (underline/overline/strike), stroke fonts, shaped and mixed
|
||||
/// scripts keep working because their outlines are untouched.
|
||||
///
|
||||
/// Font choice per run (review V3 — "why Helvetica, not the DWG font?"):
|
||||
/// - A run whose style resolves to an installed TrueType family is set in a
|
||||
/// **subset CIDFontType2 of that exact family** (+ `/ToUnicode`), cut down
|
||||
/// by `allsorts` to the glyphs the plot uses. Bold runs resolve the bold
|
||||
/// face via fontdb weight matching.
|
||||
/// - SHX/LFF stroke fonts have no embeddable font program (there is no TrueType
|
||||
/// behind `txt`/`romans` — only pen strokes), unresolvable names and CFF/
|
||||
/// collection edge cases fall back to Base-14 Helvetica/Helvetica-Bold.
|
||||
/// A Type3 font synthesized from the SDF atlas would cover those exactly and
|
||||
/// is the natural follow-up; it needs no new layout data.
|
||||
/// - `Tf` takes an *em* size but `run.height` is a *cap* height: the em size
|
||||
/// is `height / cap_ratio` (per-font OS/2 sCapHeight, 0.72 for Helvetica) so
|
||||
/// the invisible run aligns with the drawn cap height.
|
||||
/// - Width-matching (`Tz`, baked into the `Tm` x-basis — equivalent, and the
|
||||
/// only horizontal scaling printpdf exposes): `k = laid_advance /
|
||||
/// natural_advance` from the run's stored pen advance and the font's hmtx
|
||||
/// advances, so centered/right-aligned runs highlight where they draw.
|
||||
/// - Scaling: the font size is converted world-mm → points via `MM_TO_PT`;
|
||||
/// the page CTM (plot scale/rotation/clip) applies on top inside the same
|
||||
/// graphics state as the wire geometry, so text and outlines scale together.
|
||||
/// - Runs fully outside the export-window clip are skipped (rect overlap on
|
||||
/// the run rect — same space as the clip polygon).
|
||||
///
|
||||
/// Returns the number of runs emitted.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn emit_searchable_text(
|
||||
ops: &mut Vec<Op>,
|
||||
wires: &[WireModel],
|
||||
ox: f64,
|
||||
oy: f64,
|
||||
clip: Option<(f32, f32, f32, f32)>,
|
||||
plot_style: Option<&PlotStyleTable>,
|
||||
options: PdfPlotOptions,
|
||||
fonts: &SearchableFontSet,
|
||||
) -> usize {
|
||||
use crate::scene::model::wire_model::run_rect_overlap;
|
||||
/// Helvetica cap height ≈ 0.72 em.
|
||||
const HELVETICA_CAP_RATIO: f32 = 0.72;
|
||||
let mut emitted = 0;
|
||||
for wire in wires {
|
||||
if wire.searchable_text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut ctb_color: Option<[f32; 3]> = None;
|
||||
let mut screening = 1.0;
|
||||
if let Some(ctb) = plot_style {
|
||||
if wire.aci > 0 {
|
||||
ctb_color = ctb.resolve_color(wire.aci);
|
||||
screening = ctb.resolve_screening(wire.aci);
|
||||
}
|
||||
}
|
||||
for run in &wire.searchable_text {
|
||||
if run.text.is_empty() || run.height <= 0.0 || !run.height.is_finite() {
|
||||
continue;
|
||||
}
|
||||
// Export-window clip culls by run rect (origin + advance × cap
|
||||
// height box); the survivors inherit the clip path anyway.
|
||||
if let Some((cx, cy, cw, ch)) = clip {
|
||||
let px = run.origin[0] + ox;
|
||||
let py = run.origin[1] + oy;
|
||||
if !run_rect_overlap(
|
||||
[px, py],
|
||||
run.rotation,
|
||||
run.adv_width as f64,
|
||||
run.height as f64,
|
||||
[cx as f64, cy as f64, (cx + cw) as f64, (cy + ch) as f64],
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Per-run font choice: the DWG style's own family when a subset
|
||||
// is embedded, else the WinAnsi-gated Helvetica fallback.
|
||||
enum RunFont<'a> {
|
||||
Subset(&'a EmbeddedSubset),
|
||||
Helvetica,
|
||||
}
|
||||
let choice = match crate::scene::text::font_face::Face::resolve(&run.font) {
|
||||
crate::scene::text::font_face::Face::Ttf { family, .. } => fonts
|
||||
.find(&family, run.bold)
|
||||
.map(RunFont::Subset)
|
||||
.unwrap_or(RunFont::Helvetica),
|
||||
_ => RunFont::Helvetica,
|
||||
};
|
||||
if matches!(choice, RunFont::Helvetica) && !is_winansi_encodable(&run.text) {
|
||||
continue;
|
||||
}
|
||||
let rgb = ctb_color.unwrap_or_else(|| adapt_text_color([run.color[0], run.color[1], run.color[2]]));
|
||||
let [r, g, b] = plotted_color(rgb, run.color[3], screening, options);
|
||||
// World mm → page points; the page CTM (plot scale/rotation/clip)
|
||||
// applies on top, exactly as for the wire geometry.
|
||||
let x_pt = ((run.origin[0] + ox) * MM_TO_PT as f64) as f32;
|
||||
let y_pt = ((run.origin[1] + oy) * MM_TO_PT as f64) as f32;
|
||||
if !x_pt.is_finite() || !y_pt.is_finite() {
|
||||
continue;
|
||||
}
|
||||
// Em size from cap height, and width-match factor baked into the
|
||||
// Tm x-basis (= Tz, the only horizontal scaling printpdf exposes).
|
||||
let (font_handle, size_pt, width_k) = match choice {
|
||||
RunFont::Subset(sub) => {
|
||||
let size = (run.height * MM_TO_PT / sub.cap_ratio).max(0.1);
|
||||
(PdfFontHandle::External(sub.font_id.clone()), size, sub.width_factor(run))
|
||||
}
|
||||
RunFont::Helvetica => {
|
||||
let size = (run.height * MM_TO_PT / HELVETICA_CAP_RATIO).max(0.1);
|
||||
let handle = PdfFontHandle::Builtin(if run.bold {
|
||||
BuiltinFont::HelveticaBold
|
||||
} else {
|
||||
BuiltinFont::Helvetica
|
||||
});
|
||||
(handle, size, 1.0)
|
||||
}
|
||||
};
|
||||
if !size_pt.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let (sin_r, cos_r) = run.rotation.sin_cos();
|
||||
ops.push(Op::StartTextSection);
|
||||
ops.push(Op::SetFont { font: font_handle, size: Pt(size_pt) });
|
||||
ops.push(Op::SetTextRenderingMode {
|
||||
mode: TextRenderingMode::Invisible,
|
||||
});
|
||||
ops.push(Op::SetTextMatrix {
|
||||
matrix: printpdf::TextMatrix::Raw([
|
||||
cos_r * width_k,
|
||||
sin_r * width_k,
|
||||
-sin_r,
|
||||
cos_r,
|
||||
x_pt,
|
||||
y_pt,
|
||||
]),
|
||||
});
|
||||
ops.push(Op::SetFillColor {
|
||||
col: Color::Rgb(Rgb { r, g, b, icc_profile: None }),
|
||||
});
|
||||
ops.push(Op::ShowText {
|
||||
items: vec![TextItem::Text(run.text.clone())],
|
||||
});
|
||||
ops.push(Op::EndTextSection);
|
||||
emitted += 1;
|
||||
}
|
||||
}
|
||||
emitted
|
||||
}
|
||||
|
||||
/// Whether every char in `text` survives Base-14 WinAnsi encoding.
|
||||
///
|
||||
/// ASCII + Latin-1 incl. French accents and the `°±Ø` DXF specials — but NOT
|
||||
/// the C1 control range U+0080–U+009F (unassigned in WinAnsi; emitting them
|
||||
/// would mojibake). Anything else is rejected per-run so the exporter never
|
||||
/// emits a wrong `Tj` — those runs keep their outlines and stay unsearchable.
|
||||
/// (Runs set in an embedded subset bypass this gate: Identity-H covers any
|
||||
/// glyph the subset carries.)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_winansi_encodable(text: &str) -> bool {
|
||||
text.chars().all(|c| {
|
||||
let cp = c as u32;
|
||||
cp <= 0xFF && !(0x80..=0x9F).contains(&cp)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Embedded subset fonts (one system family × weight, cut to used glyphs) ──
|
||||
|
||||
/// One embedded subset TrueType font for the search layer: a single system
|
||||
/// family at one weight, cut by `allsorts` to the glyphs the plot uses.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct EmbeddedSubset {
|
||||
family: String,
|
||||
bold: bool,
|
||||
font_id: printpdf::FontId,
|
||||
/// Subset-space GID per char, re-parsed from the subset bytes (never
|
||||
/// assumed from allsorts' assignment order).
|
||||
gid_of: std::collections::BTreeMap<char, u16>,
|
||||
/// Raw advance per subset GID, in font units (hmtx, for Tz).
|
||||
width_of: std::collections::BTreeMap<u16, u16>,
|
||||
upem: u16,
|
||||
/// Cap-height fraction of em (OS/2 sCapHeight, else 0.7).
|
||||
cap_ratio: f32,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl EmbeddedSubset {
|
||||
/// Width-match factor for a run: laid advance ÷ natural advance at the
|
||||
/// run's cap height. 1.0 when the widths are unknown (never explodes a
|
||||
/// selection highlight: clamped to a sane band).
|
||||
fn width_factor(&self, run: &SearchableTextRun) -> f32 {
|
||||
let natural: u32 = run
|
||||
.text
|
||||
.chars()
|
||||
.filter_map(|c| self.gid_of.get(&c))
|
||||
.filter_map(|g| self.width_of.get(g))
|
||||
.map(|w| *w as u32)
|
||||
.sum();
|
||||
if natural == 0 || run.adv_width <= 0.0 || run.height <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
// laid (drawing mm) vs natural at cap height, both in em fractions:
|
||||
// k = adv / (height * Σw / (upem * cap)).
|
||||
let k = run.adv_width * self.upem as f32 * self.cap_ratio
|
||||
/ (run.height * natural as f32);
|
||||
if !k.is_finite() {
|
||||
1.0
|
||||
} else {
|
||||
k.clamp(0.25, 4.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Embedded subsets for one export (Helvetica fallback needs no entry).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Default)]
|
||||
struct SearchableFontSet {
|
||||
subsets: Vec<EmbeddedSubset>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl SearchableFontSet {
|
||||
/// Collect every TTF-resolvable run's chars per (family, bold), subset
|
||||
/// each bucket with `allsorts`, and register the subsets on the document.
|
||||
/// Anything unresolvable (SHX/LFF names, big-font pairs, CFF outlines,
|
||||
/// missing files) simply gets no entry — those runs take Helvetica.
|
||||
fn build(doc: &mut PdfDocument, pages: &[PdfPageInput]) -> Self {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let mut buckets: BTreeMap<(String, bool), BTreeSet<char>> = BTreeMap::new();
|
||||
for page in pages {
|
||||
for wire in page.content.wires.iter() {
|
||||
for run in &wire.searchable_text {
|
||||
if run.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let crate::scene::text::font_face::Face::Ttf { family, .. } =
|
||||
crate::scene::text::font_face::Face::resolve(&run.font)
|
||||
{
|
||||
buckets
|
||||
.entry((family, run.bold))
|
||||
.or_default()
|
||||
.extend(run.text.chars());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Self::default();
|
||||
for ((family, bold), chars) in &buckets {
|
||||
if let Some(subset) = subset_family(doc, family, *bold, chars) {
|
||||
out.subsets.push(subset);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn find(&self, family: &str, bold: bool) -> Option<&EmbeddedSubset> {
|
||||
self.subsets
|
||||
.iter()
|
||||
.find(|s| s.family == family && s.bold == bold)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subset one system family to `chars`, embed the subset, and register it.
|
||||
/// Returns `None` whenever embedding is impossible or pointless (no file, CFF
|
||||
/// outlines the stub can't describe, subset failure, nothing mappable) — the
|
||||
/// caller falls back to Helvetica, never to an invalid font dict.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn subset_family(
|
||||
doc: &mut PdfDocument,
|
||||
family: &str,
|
||||
bold: bool,
|
||||
chars: &std::collections::BTreeSet<char>,
|
||||
) -> Option<EmbeddedSubset> {
|
||||
use allsorts::{binary::read::ReadScope, font_data::FontData, tables::FontTableProvider};
|
||||
let weight = if bold {
|
||||
fontdb::Weight::BOLD
|
||||
} else {
|
||||
fontdb::Weight::NORMAL
|
||||
};
|
||||
let (bytes, index) = crate::scene::text::sysfont::with_face_data_weighted(
|
||||
family,
|
||||
weight,
|
||||
|data, index| (data.to_vec(), index),
|
||||
)?;
|
||||
// Original GIDs for the used chars (+ .notdef, mandatory for subsetting).
|
||||
let orig = ttf_parser::Face::parse(&bytes, index).ok()?;
|
||||
let mut ids: Vec<u16> = vec![0];
|
||||
for c in chars {
|
||||
if let Some(gid) = orig.glyph_index(*c) {
|
||||
if !ids.contains(&gid.0) {
|
||||
ids.push(gid.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ids.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
let scope = ReadScope::new(&bytes);
|
||||
let font_file = scope.read::<FontData<'_>>().ok()?;
|
||||
let provider = font_file.table_provider(index as usize).ok()?;
|
||||
// The no-text_layout ParsedFont stub can only describe TrueType
|
||||
// (CIDFontType2 + FontFile2) — never mislabel CFF outlines as TrueType.
|
||||
if provider.has_table(allsorts::tag::CFF) || provider.has_table(allsorts::tag::CFF2) {
|
||||
return None;
|
||||
}
|
||||
let subset_bytes = allsorts::subset::subset(
|
||||
&provider,
|
||||
&ids,
|
||||
&allsorts::subset::SubsetProfile::Pdf,
|
||||
allsorts::subset::CmapTarget::Unicode,
|
||||
)
|
||||
.ok()?;
|
||||
// Re-parse the SUBSET: GID assignment is allsorts' business, and the maps
|
||||
// below must be in subset space. Anything unmappable stays out.
|
||||
let sub = ttf_parser::Face::parse(&subset_bytes, 0).ok()?;
|
||||
let upem = sub.units_per_em().max(1);
|
||||
let cap_ratio = sub
|
||||
.capital_height()
|
||||
.filter(|c| *c > 0)
|
||||
.map(|c| c as f32 / upem as f32)
|
||||
.filter(|r| (0.2..=1.0).contains(r))
|
||||
.unwrap_or(0.7);
|
||||
let mut gid_chars: std::collections::BTreeMap<char, u16> = std::collections::BTreeMap::new();
|
||||
let mut width_of: std::collections::BTreeMap<u16, u16> = std::collections::BTreeMap::new();
|
||||
for c in chars {
|
||||
if let Some(gid) = sub.glyph_index(*c) {
|
||||
gid_chars.insert(*c, gid.0);
|
||||
width_of.insert(gid.0, sub.glyph_hor_advance(gid).unwrap_or(0));
|
||||
}
|
||||
}
|
||||
if gid_chars.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// printpdf (no text_layout) embeds `original_bytes` verbatim as the
|
||||
// CIDFontType2 stream and derives ToUnicode/widths from these maps — so
|
||||
// handing it the SUBSET bytes + subset-space maps yields a true subset
|
||||
// embed with no lopdf surgery. (Metrics out before the bytes move.)
|
||||
let (ascent, descent) = (sub.ascender(), sub.descender());
|
||||
let cp_map: std::collections::BTreeMap<u32, u16> =
|
||||
gid_chars.iter().map(|(c, g)| (*c as u32, *g)).collect();
|
||||
let parsed = printpdf::ParsedFont::with_glyph_data(
|
||||
subset_bytes,
|
||||
0,
|
||||
Some(family.to_string()),
|
||||
cp_map,
|
||||
width_of.clone(),
|
||||
upem,
|
||||
printpdf::FontMetrics { ascent, descent },
|
||||
);
|
||||
let font_id = doc.add_font(&parsed);
|
||||
Some(EmbeddedSubset {
|
||||
family: family.to_string(),
|
||||
bold,
|
||||
font_id,
|
||||
gid_of: gid_chars,
|
||||
width_of,
|
||||
upem,
|
||||
cap_ratio,
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-emit every wire's SDF text as vector geometry.
|
||||
///
|
||||
/// Each visible glyph rides on `wire.text_verts` as one 6-vertex quad (two
|
||||
@@ -1514,6 +1909,10 @@ 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.
|
||||
///
|
||||
/// Always drawn: the visible glyph rendering (strokes for LFF/SHX pen fonts,
|
||||
/// fills for TrueType outlines, solid bars for decorations). The invisible
|
||||
/// searchable layer above adds extractability; it never replaces this.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn emit_text(
|
||||
ops: &mut Vec<Op>,
|
||||
@@ -1905,7 +2304,7 @@ mod tests {
|
||||
fn text_wire(text: &str, origin: [f64; 3]) -> PlotWire {
|
||||
use crate::scene::pipeline::text_gpu::push_glyph_vertices;
|
||||
use crate::scene::text::{glyph_quads::layout_glyph_quads, sdf_atlas};
|
||||
let quads = {
|
||||
let (quads, _) = {
|
||||
let mut atlas = sdf_atlas::text_atlas().lock().unwrap();
|
||||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, text)
|
||||
};
|
||||
@@ -1939,4 +2338,236 @@ mod tests {
|
||||
no_text.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn searchable_wire(text: &str, origin: [f64; 3]) -> PlotWire {
|
||||
use crate::scene::model::wire_model::SearchableTextRun;
|
||||
PlotWire {
|
||||
wire: WireModel {
|
||||
searchable_text: vec![SearchableTextRun {
|
||||
text: text.to_string(),
|
||||
origin,
|
||||
height: 10.0,
|
||||
rotation: 0.0,
|
||||
color: [0.0, 0.0, 0.0, 1.0],
|
||||
bold: false,
|
||||
font: "txt".into(),
|
||||
adv_width: 10.0 * text.chars().count().max(1) as f32,
|
||||
}],
|
||||
..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false)
|
||||
},
|
||||
draw_depth: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
// Outline-only export exposes zero BT operators and no extractable text;
|
||||
// a preserved run must emit a real invisible (`Tr 3`) `BT … Tj ET` with
|
||||
// the string, on top of the unchanged outlines.
|
||||
#[test]
|
||||
fn searchable_text_emits_bt_tj_with_string() {
|
||||
let wire = searchable_wire("CMa-03", [20.0, 30.0, 0.0]);
|
||||
let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF"), "not a PDF");
|
||||
let stream = pdf_stream_text(&bytes);
|
||||
assert!(stream.contains("BT"), "no text object (BT) in searchable export");
|
||||
assert!(
|
||||
stream.contains("CMa-03"),
|
||||
"searchable string missing from content stream"
|
||||
);
|
||||
assert!(
|
||||
stream.contains("/Helvetica"),
|
||||
"searchable export should reference the text font resource"
|
||||
);
|
||||
assert!(
|
||||
stream.contains("Tj") || stream.contains("TJ"),
|
||||
"no text-showing operator (Tj/TJ)"
|
||||
);
|
||||
assert!(
|
||||
stream.contains("Tr"),
|
||||
"searchable layer must be invisible (Tr rendering mode)"
|
||||
);
|
||||
}
|
||||
|
||||
// Invisible layer coexists with outlines: the searchable file carries the
|
||||
// string while the outlines-only file does not, and the outlines are
|
||||
// identical in both (zero visual change — the layer only adds bytes).
|
||||
#[test]
|
||||
fn searchable_layer_coexists_with_outlines() {
|
||||
let mut outline_only = text_wire("HELLO", [20.0, 20.0, 0.0]);
|
||||
outline_only.wire.searchable_text.clear();
|
||||
let mut searchable = text_wire("HELLO", [20.0, 20.0, 0.0]);
|
||||
searchable.wire.searchable_text =
|
||||
searchable_wire("HELLO", [20.0, 20.0, 0.0]).wire.searchable_text;
|
||||
|
||||
// Outlines are never suppressed: both wires keep their glyph quads.
|
||||
assert!(
|
||||
!searchable.wire.text_verts.is_empty(),
|
||||
"searchable wire must keep its outline quads"
|
||||
);
|
||||
|
||||
let outline_bytes = build_pdf_pages(&[test_page(vec![outline_only])], None).unwrap();
|
||||
let searchable_bytes = build_pdf_pages(&[test_page(vec![searchable])], None).unwrap();
|
||||
let outline_stream = pdf_stream_text(&outline_bytes);
|
||||
let searchable_stream = pdf_stream_text(&searchable_bytes);
|
||||
|
||||
assert!(
|
||||
!outline_stream.contains("HELLO"),
|
||||
"outline path must not leak extractable text"
|
||||
);
|
||||
assert!(
|
||||
searchable_stream.contains("HELLO") && searchable_stream.contains("BT"),
|
||||
"searchable path must carry extractable BT/Tj text"
|
||||
);
|
||||
assert!(
|
||||
searchable_stream.contains("Tr"),
|
||||
"searchable text must render invisibly (Tr)"
|
||||
);
|
||||
assert!(
|
||||
searchable_bytes.len() > outline_bytes.len(),
|
||||
"invisible layer adds bytes ({} > {}); visuals come from the kept outlines",
|
||||
searchable_bytes.len(),
|
||||
outline_bytes.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Per-run fallback: a non-WinAnsi run (Greek/Cyrillic/CJK/symbols) emits
|
||||
// no `Tj` — its outlines still draw, so nothing renders as missing glyphs.
|
||||
#[test]
|
||||
fn searchable_text_skips_non_winansi_runs() {
|
||||
let wire = searchable_wire("ΩΩ", [20.0, 20.0, 0.0]);
|
||||
let stream = pdf_stream_text(&build_pdf_pages(&[test_page(vec![wire])], None).unwrap());
|
||||
assert!(
|
||||
!stream.contains("BT"),
|
||||
"non-encodable run must not emit a text object"
|
||||
);
|
||||
}
|
||||
|
||||
// Rotation must ride a text matrix (Tm), not just an angle — a 90° run
|
||||
// keeps its string and emits a rotated Tm.
|
||||
#[test]
|
||||
fn searchable_text_rotation_uses_text_matrix() {
|
||||
let mut wire = searchable_wire("AB", [20.0, 20.0, 0.0]);
|
||||
wire.wire.searchable_text[0].rotation = std::f32::consts::FRAC_PI_2;
|
||||
let stream = pdf_stream_text(&build_pdf_pages(&[test_page(vec![wire])], None).unwrap());
|
||||
assert!(stream.contains("AB"), "rotated run lost its string");
|
||||
assert!(stream.contains("Tm"), "rotated run needs an explicit text matrix (Tm)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winansi_filter_covers_accents_and_rejects_cjk() {
|
||||
assert!(is_winansi_encodable("CMa-03"));
|
||||
assert!(is_winansi_encodable("crème °±Ø"));
|
||||
assert!(!is_winansi_encodable("Ω"));
|
||||
assert!(!is_winansi_encodable("中"));
|
||||
// C1 controls are unassigned in WinAnsi: emitting them would mojibake
|
||||
// (explicit \u escapes — a literal control char would not survive
|
||||
// editors, and NBSP U+00A0 next door *is* encodable).
|
||||
assert!(is_winansi_encodable("a\u{a0}b")); // NBSP is WinAnsi 0xA0
|
||||
assert!(!is_winansi_encodable("a\u{80}b"));
|
||||
assert!(!is_winansi_encodable("a\u{9f}b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_factor_matches_laid_to_natural_advance() {
|
||||
use std::collections::BTreeMap;
|
||||
let sub = EmbeddedSubset {
|
||||
family: "Test".into(),
|
||||
bold: false,
|
||||
font_id: printpdf::FontId::new(),
|
||||
gid_of: [('A', 1), ('B', 2)].into_iter().collect::<BTreeMap<_, _>>(),
|
||||
width_of: [(1, 600), (2, 620)].into_iter().collect::<BTreeMap<_, _>>(),
|
||||
upem: 1000,
|
||||
cap_ratio: 0.7,
|
||||
};
|
||||
let run = SearchableTextRun {
|
||||
text: "AB".into(),
|
||||
origin: [0.0, 0.0, 0.0],
|
||||
height: 10.0,
|
||||
rotation: 0.0,
|
||||
color: [0.0, 0.0, 0.0, 1.0],
|
||||
bold: false,
|
||||
font: "Test".into(),
|
||||
// Laid advance exactly equals natural at cap height, so k = 1:
|
||||
// adv = height * Σw / (upem * cap) = 10*1220/(1000*0.7).
|
||||
adv_width: 10.0 * 1220.0 / (1000.0 * 0.7),
|
||||
};
|
||||
assert!((sub.width_factor(&run) - 1.0).abs() < 1e-4);
|
||||
let mut wide = run.clone();
|
||||
wide.adv_width *= 2.0;
|
||||
assert!((sub.width_factor(&wide) - 2.0).abs() < 1e-4);
|
||||
let mut empty = run;
|
||||
empty.adv_width = 0.0;
|
||||
assert_eq!(sub.width_factor(&empty), 1.0);
|
||||
}
|
||||
|
||||
// A TTF-backed run embeds a subset of its own family with ToUnicode —
|
||||
// the DWG font, not Helvetica. Skips where no test font is installed.
|
||||
#[test]
|
||||
fn subset_embeds_the_runs_own_family_with_tounicode() {
|
||||
let family = ["DejaVu Sans", "Liberation Sans", "Arial"]
|
||||
.into_iter()
|
||||
.find(|f| {
|
||||
crate::scene::text::sysfont::with_face_data_weighted(
|
||||
f,
|
||||
fontdb::Weight::NORMAL,
|
||||
|_, _| true,
|
||||
)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let Some(family) = family else {
|
||||
eprintln!("no test TTF installed; skipping subset test");
|
||||
return;
|
||||
};
|
||||
let full_len =
|
||||
crate::scene::text::sysfont::with_face_data_weighted(family, fontdb::Weight::NORMAL, |data, _| {
|
||||
data.len()
|
||||
})
|
||||
.unwrap();
|
||||
let mut wire = searchable_wire("CMa-03 pincé", [20.0, 30.0, 0.0]);
|
||||
wire.wire.searchable_text[0].font = family.to_string();
|
||||
// Unit level: the subset is built for the run's family and maps the
|
||||
// used chars (extractability source of truth — the content stream
|
||||
// carries hex GIDs, resolved via ToUnicode, not literal text).
|
||||
let mut probe = printpdf::PdfDocument::new("subset-probe");
|
||||
let fonts =
|
||||
SearchableFontSet::build(&mut probe, &[test_page(vec![wire.clone()])]);
|
||||
let sub = fonts
|
||||
.find(family, false)
|
||||
.expect("subset embedded for the run's own family");
|
||||
assert!(sub.gid_of.contains_key(&'C'), "used chars must be mapped");
|
||||
assert!(sub.gid_of.contains_key(&'é'), "accents must survive subsetting");
|
||||
// PDF level: ToUnicode + embedded bytes, and no Helvetica fallback.
|
||||
let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap();
|
||||
let stream = pdf_stream_text(&bytes);
|
||||
assert!(
|
||||
stream.contains("ToUnicode"),
|
||||
"subset font must carry a ToUnicode CMap"
|
||||
);
|
||||
assert!(
|
||||
stream.contains("FontFile2"),
|
||||
"subset font must embed its (TrueType) bytes"
|
||||
);
|
||||
assert!(
|
||||
!stream.contains("/Helvetica"),
|
||||
"own-family run must not fall back to Helvetica"
|
||||
);
|
||||
assert!(
|
||||
bytes.len() < full_len,
|
||||
"subset PDF ({} bytes) must be lighter than the full font ({} bytes)",
|
||||
bytes.len(),
|
||||
full_len
|
||||
);
|
||||
}
|
||||
|
||||
// Runs fully outside the export window never reach the content stream.
|
||||
#[test]
|
||||
fn clip_culls_runs_outside_the_export_window() {
|
||||
let inside = searchable_wire("IN", [20.0, 20.0, 0.0]);
|
||||
let outside = searchable_wire("OUT", [500.0, 500.0, 0.0]);
|
||||
// Same page, windowed clip around the origin run only.
|
||||
let mut page = test_page(vec![inside, outside]);
|
||||
page.clip = Some((0.0, 0.0, 100.0, 100.0));
|
||||
let stream = pdf_stream_text(&build_pdf_pages(&[page], None).unwrap());
|
||||
assert!(stream.contains("IN"), "inside run must survive the clip");
|
||||
assert!(!stream.contains("OUT"), "outside run must be culled by the clip");
|
||||
}
|
||||
}
|
||||
+10
@@ -67,6 +67,16 @@ fn main() -> iced::Result {
|
||||
std::process::exit(if ok { 0 } else { 1 });
|
||||
}
|
||||
|
||||
if args.sync_mcp_schemas {
|
||||
let synced = mcp::sync_agent_tool_schemas();
|
||||
if synced {
|
||||
println!("Successfully synchronized OpenCADStudio MCP schemas to ~/.gemini/antigravity/mcp/opencadstudio/");
|
||||
} else {
|
||||
eprintln!("Antigravity MCP directory ~/.gemini/antigravity/mcp/ not found; skipped sync.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// MCP is a client-neutral local entry point. It uses only stdin,
|
||||
// stdout and the authenticated GUI bridge, so it must run before any
|
||||
// logging or graphics setup can write to the protocol stream.
|
||||
|
||||
+1290
-17
File diff suppressed because it is too large.
Load diff
@@ -116,6 +116,17 @@ pub static ACTIVATE: OpDef = OpDef {
|
||||
example: r#"{"op":"activate","document_id":1}"#,
|
||||
};
|
||||
|
||||
pub static SWITCH_DOCUMENT: OpDef = OpDef {
|
||||
name: "switch_document",
|
||||
doc: "Alias for activate. Switch the active drawing tab to another open document.",
|
||||
batchable: true,
|
||||
params: &[
|
||||
req("document_id", Ty::Int, "Target document ID to activate."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"switch_document","document_id":1}"#,
|
||||
};
|
||||
|
||||
pub static RUN: OpDef = OpDef {
|
||||
name: "run",
|
||||
doc: "Execute a command string with prompt answers separated by spaces (e.g. 'LINE 0,0 10,10').",
|
||||
@@ -245,6 +256,8 @@ pub static EMBED_IMAGE: OpDef = OpDef {
|
||||
opt("at", Ty::Point, "Lower-left corner [x, y] or [x, y, z]."),
|
||||
opt("width", Ty::Num, "World width of the image."),
|
||||
opt("linked", Ty::Bool, "Store as path-linked RasterImage instead of embedded OLE2FRAME."),
|
||||
opt("source_points", Ty::ArrayOf(&Ty::Point), "Pixel points in image [[px1,py1],[px2,py2]] for 2-point alignment."),
|
||||
opt("target_points", Ty::ArrayOf(&Ty::Point), "CAD world points [[x1,y1],[x2,y2]] for 2-point alignment."),
|
||||
],
|
||||
rules: &[],
|
||||
example: r#"{"op":"embed_image","path":"logo.png","at":[0,0]}"#,
|
||||
@@ -610,6 +623,7 @@ pub static OPS: &[&OpDef] = &[
|
||||
&NEW,
|
||||
&OPEN,
|
||||
&ACTIVATE,
|
||||
&SWITCH_DOCUMENT,
|
||||
&RUN,
|
||||
&START,
|
||||
&INPUT,
|
||||
|
||||
@@ -366,7 +366,9 @@ impl CadCommand for AlignedDimensionCommand {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
})
|
||||
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||||
@@ -482,7 +484,9 @@ fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1275,7 +1275,9 @@ fn preview_wire(points: Vec<DVec3>) -> WireModel {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["DIMANGULAR"] });
|
||||
@@ -134,7 +134,9 @@ impl DataLinkPlaceCommand {
|
||||
plinegen: true,
|
||||
fill_tris: Vec::new(),
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +162,7 @@ impl CadCommand for DataLinkPlaceCommand {
|
||||
self.table.insertion_point = codec::types::Vector3::new(point.x, point.y, point.z);
|
||||
CmdResult::CommitAndExit(
|
||||
self.plane
|
||||
.place_entity(EntityType::Table(self.table.clone())),
|
||||
.place_entity(EntityType::Table(Box::new(self.table.clone()))),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -364,7 +364,9 @@ fn preview_line(far_chord: Vec3, chord: Vec3, text: Vec3) -> WireModel {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["DIMDIAMETER"] });
|
||||
@@ -755,7 +755,9 @@ fn preview_for_dimension(dimension: &Dimension) -> WireModel {
|
||||
plinegen: true,
|
||||
fill_tris: Vec::new(),
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn linear_preview(
|
||||
|
||||
@@ -506,7 +506,9 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
|
||||
plinegen: true,
|
||||
fill_tris: Vec::new(),
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
|
||||
@@ -470,7 +470,9 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arrowhead_wings(tip: Vec3, next: Vec3, size: f32) -> [Vec3; 2] {
|
||||
|
||||
@@ -590,7 +590,9 @@ fn preview_wire(points: Vec<DVec3>) -> WireModel {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Project the two extension origins onto the dimension line, which passes
|
||||
|
||||
@@ -269,7 +269,7 @@ impl MLeaderCommand {
|
||||
ml.text_style_handle = style_handle;
|
||||
ml.context.text_style_handle = style_handle;
|
||||
}
|
||||
let entity = self.plane.place_entity(EntityType::MultiLeader(ml));
|
||||
let entity = self.plane.place_entity(EntityType::MultiLeader(Box::new(ml)));
|
||||
if self.content_type == LeaderContentType::MText && self.text.is_empty() {
|
||||
CmdResult::CommitAndEditText(entity)
|
||||
} else {
|
||||
@@ -799,7 +799,9 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn arrowhead_wings(tip: Vec3, next: Vec3, size: f32) -> [Vec3; 2] {
|
||||
|
||||
@@ -523,7 +523,9 @@ fn preview_wire(pts: &[Vec3]) -> WireModel {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn point_segment_distance_xy(point: Vector3, start: Vector3, end: Vector3) -> f64 {
|
||||
|
||||
@@ -315,7 +315,9 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -376,7 +376,9 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["DIMRADIUS"] });
|
||||
@@ -271,7 +271,7 @@ impl TableCommand {
|
||||
row.style = Some(styles[row_index.min(2)].clone());
|
||||
}
|
||||
}
|
||||
self.plane.place_entity(EntityType::Table(table))
|
||||
self.plane.place_entity(EntityType::Table(Box::new(table)))
|
||||
}
|
||||
|
||||
fn preview_grid(
|
||||
@@ -341,7 +341,9 @@ impl TableCommand {
|
||||
plinegen: true,
|
||||
fill_tris: Vec::new(),
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,7 +797,7 @@ impl CadCommand for TableCellEditCommand {
|
||||
cell.set_text(&formatted);
|
||||
self.table.block_record_handle = None;
|
||||
Some(CmdResult::ReplaceMany(
|
||||
vec![(self.handle, vec![EntityType::Table(self.table.clone())])],
|
||||
vec![(self.handle, vec![EntityType::Table(Box::new(self.table.clone()))])],
|
||||
Vec::new(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -114,7 +114,9 @@ impl CadCommand for ToleranceCommand {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
})
|
||||
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ impl HelixCommand {
|
||||
helix.turn_height = if curve.turns == 0.0 { 0.0 } else { height / curve.turns };
|
||||
helix.handedness = self.counter_clockwise;
|
||||
helix.constraint = self.constraint;
|
||||
Some(EntityType::Helix(helix))
|
||||
Some(EntityType::Helix(Box::new(helix)))
|
||||
}
|
||||
|
||||
fn commit(&mut self, height: f64, axis: DVec3) -> CmdResult {
|
||||
|
||||
@@ -213,7 +213,9 @@ impl MlineCommand {
|
||||
plinegen: true,
|
||||
fill_tris,
|
||||
fill_tris_low,
|
||||
})
|
||||
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,9 @@ impl CadCommand for ImageCommand {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
})
|
||||
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,9 @@ impl CadCommand for RayCommand {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
})
|
||||
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +410,9 @@ impl CadCommand for XLineCommand {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
})
|
||||
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +401,9 @@ impl CadCommand for AreaCommand {
|
||||
plinegen: true,
|
||||
fill_tris: Vec::new(),
|
||||
fill_tris_low: Vec::new(),
|
||||
})
|
||||
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn area_preview_regions(&self) -> Option<Vec<AreaPreviewRegion>> {
|
||||
|
||||
@@ -124,7 +124,9 @@ impl CadCommand for DistCommand {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
})
|
||||
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,9 @@ impl MeasureGeomCommand {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract (radius) from a Circle or Arc; `None` for anything else.
|
||||
|
||||
@@ -75,7 +75,8 @@ impl ReverseCommand {
|
||||
}
|
||||
EntityType::Spline(sp) => Some(EntityType::Spline(reverse_spline(sp))),
|
||||
EntityType::Helix(helix) => {
|
||||
crate::entities::helix::reversed(helix).map(EntityType::Helix)
|
||||
crate::entities::helix::reversed(helix)
|
||||
.map(|helix| EntityType::Helix(Box::new(helix)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -4870,7 +4870,9 @@ fn preview_wire(points: Vec<[f32; 3]>, color: [f32; 4], name: &str) -> WireModel
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert the exact boundary crossings between consecutive samples so a
|
||||
|
||||
@@ -117,7 +117,10 @@ impl PointCloudCropCommand {
|
||||
if let ExtendedEntityData::PointCloudEx(data) = &mut cloud.data {
|
||||
change(data);
|
||||
}
|
||||
CmdResult::UpdateEntityAndFinish { handle: self.handle, entity: EntityType::Extended(cloud) }
|
||||
CmdResult::UpdateEntityAndFinish {
|
||||
handle: self.handle,
|
||||
entity: EntityType::Extended(Box::new(cloud)),
|
||||
}
|
||||
}
|
||||
|
||||
fn axes(data: &PointCloudExData) -> glam::DMat3 {
|
||||
@@ -296,7 +299,7 @@ impl CadCommand for PointCloudCropCommand {
|
||||
match self.picked.take() {
|
||||
Some(EntityType::Extended(cloud)) if matches!(cloud.data, ExtendedEntityData::PointCloudEx(_)) => {
|
||||
self.handle = handle;
|
||||
self.cloud = Some(cloud);
|
||||
self.cloud = Some(*cloud);
|
||||
if self.uncrop {
|
||||
return self.finish(|data| data.croppings.clear());
|
||||
}
|
||||
|
||||
@@ -2793,7 +2793,7 @@ pub fn empty_extruded_surface(direction: DVec3, taper_angle: f64) -> EntityType
|
||||
options.is_solid = false;
|
||||
*sweep_vector = Vector3::new(direction.x, direction.y, direction.z);
|
||||
}
|
||||
EntityType::Surface(surface)
|
||||
EntityType::Surface(Box::new(surface))
|
||||
}
|
||||
|
||||
pub fn empty_revolved_surface(
|
||||
@@ -2832,7 +2832,7 @@ pub fn empty_revolved_surface(
|
||||
*stored_start = start_angle;
|
||||
*solid = false;
|
||||
}
|
||||
EntityType::Surface(surface)
|
||||
EntityType::Surface(Box::new(surface))
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
@@ -2909,7 +2909,7 @@ mod thicken_command_tests {
|
||||
let handle = Handle::new(42);
|
||||
let mut command = ThickenCommand::new(vec![
|
||||
(Handle::new(1), EntityType::Line(codec::entities::Line::default())),
|
||||
(handle, EntityType::Surface(codec::entities::Surface::new(codec::entities::SurfaceKind::Plane))),
|
||||
(handle, EntityType::Surface(Box::new(codec::entities::Surface::new(codec::entities::SurfaceKind::Plane)))),
|
||||
]);
|
||||
assert!(!command.is_selection_gathering());
|
||||
assert_eq!(command.handles, vec![handle]);
|
||||
|
||||
@@ -3710,5 +3710,7 @@ fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ impl SectionPlaneCommand {
|
||||
self.kind == SectionKind::Slice,
|
||||
depth,
|
||||
);
|
||||
EntityType::Extended(entity)
|
||||
EntityType::Extended(Box::new(entity))
|
||||
}
|
||||
|
||||
fn line_at(&self, point: DVec3, viewing: DVec3, vertical_hint: DVec3) -> (Vec<DVec3>, DVec3) {
|
||||
|
||||
@@ -96,7 +96,9 @@ impl CadCommand for PlotWindowCommand {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
})
|
||||
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,8 @@ impl CadCommand for ZoomWindowCommand {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
})
|
||||
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
+47
-7
@@ -54,6 +54,7 @@ pub struct LocalWire {
|
||||
/// expand-time transform (`emit_wire`) maps each vertex to world exactly
|
||||
/// like `points`, so block-instance text lands at the right place/scale.
|
||||
pub text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
|
||||
pub searchable_text: Vec<crate::scene::model::wire_model::SearchableTextRun>,
|
||||
pub key_vertices: Vec<[f64; 3]>,
|
||||
pub snap_pts: Vec<(glam::DVec3, SnapHint)>,
|
||||
pub tangent_geoms: Vec<TangentGeom>,
|
||||
@@ -893,6 +894,7 @@ fn tessellate_sub_local(
|
||||
is_point: matches!(sub, EntityType::Point(_)),
|
||||
point_marker: wire.point_marker,
|
||||
text_verts: wire.text_verts,
|
||||
searchable_text: wire.searchable_text,
|
||||
key_vertices: wire.key_vertices,
|
||||
snap_pts: wire.snap_pts,
|
||||
tangent_geoms: wire.tangent_geoms,
|
||||
@@ -1358,12 +1360,11 @@ fn translated_prototype_wire(
|
||||
}
|
||||
}
|
||||
}
|
||||
if !wire.text_verts.is_empty() {
|
||||
wire.text_verts =
|
||||
crate::scene::model::wire_model::map_text_verts(&wire.text_verts, |x, y, z| {
|
||||
(x + delta[0], y + delta[1], z + delta[2])
|
||||
});
|
||||
}
|
||||
wire.map_text_layout(
|
||||
&|p| [p[0] + delta[0], p[1] + delta[1], p[2] + delta[2]],
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
if wire.aabb != WireModel::UNBOUNDED_AABB {
|
||||
wire.aabb[0] += delta_f32[0];
|
||||
wire.aabb[1] += delta_f32[1];
|
||||
@@ -1595,6 +1596,7 @@ struct BatchEntry {
|
||||
pick_tris_low: Vec<[f32; 3]>,
|
||||
/// Accumulated SDF glyph quads (world space) for block-instance text.
|
||||
text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
|
||||
searchable_text: Vec<crate::scene::model::wire_model::SearchableTextRun>,
|
||||
min_x: f32,
|
||||
min_y: f32,
|
||||
max_x: f32,
|
||||
@@ -1779,6 +1781,7 @@ impl Batches {
|
||||
dash_from_start: false,
|
||||
dash_align_end: None,
|
||||
text_verts: b.text_verts,
|
||||
searchable_text: b.searchable_text,
|
||||
name: name.to_string(),
|
||||
points: b.points,
|
||||
points_low: b.points_low,
|
||||
@@ -2562,7 +2565,9 @@ fn emit_wire(
|
||||
plinegen: lw.plinegen,
|
||||
fill_tris: Vec::new(),
|
||||
fill_tris_low: Vec::new(),
|
||||
};
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
out.extra_wires.push(wire);
|
||||
return;
|
||||
}
|
||||
@@ -2918,6 +2923,41 @@ fn emit_wire(
|
||||
draw_depth: tv.draw_depth,
|
||||
});
|
||||
}
|
||||
// Searchable runs follow the same insert transform: origin via the full
|
||||
// matrix, cap height by the mean XY scale, rotation by the transformed
|
||||
// X-axis angle — so block-instance text plots searchable at its placed
|
||||
// size and angle, not its block-local one.
|
||||
//
|
||||
// Notes (review V2): `sx`/`sy` are axis *lengths* (always ≥ 0), so a
|
||||
// mirrored insert (`sx = -1`) yields length 1, not a zero mean — there is
|
||||
// no 0.1 pt collapse. Mirroring folds into `rotation += π` (readable
|
||||
// invisible text) rather than a reflected `Tm`: the visible outlines
|
||||
// carry the true mirror. Non-uniform scale, width factor, oblique and
|
||||
// tracking are approximated by the mean scale here; exact reproduction
|
||||
// needs `Tz`/`Tc`/skewed `Tm` (follow-up — selection-only mismatch on the
|
||||
// invisible layer, never a visual defect).
|
||||
if !lw.searchable_text.is_empty() {
|
||||
let x_axis = accum_xform.apply_rotation(Vector3::new(1.0, 0.0, 0.0));
|
||||
let y_axis = accum_xform.apply_rotation(Vector3::new(0.0, 1.0, 0.0));
|
||||
let sx = (x_axis.x * x_axis.x + x_axis.y * x_axis.y + x_axis.z * x_axis.z).sqrt();
|
||||
let sy = (y_axis.x * y_axis.x + y_axis.y * y_axis.y + y_axis.z * y_axis.z).sqrt();
|
||||
let mean_scale = ((sx + sy) * 0.5).max(1e-6);
|
||||
let rot_delta = (x_axis.y).atan2(x_axis.x);
|
||||
let base_len = entry.searchable_text.len();
|
||||
entry.searchable_text.extend(lw.searchable_text.iter().cloned());
|
||||
crate::scene::model::wire_model::map_searchable_runs(
|
||||
&mut entry.searchable_text[base_len..],
|
||||
&|p| {
|
||||
let v = accum_xform.apply(Vector3::new(p[0], p[1], p[2]));
|
||||
[v.x, v.y, v.z]
|
||||
},
|
||||
mean_scale,
|
||||
rot_delta as f32,
|
||||
);
|
||||
for run in &mut entry.searchable_text[base_len..] {
|
||||
run.color = final_color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_tangent(
|
||||
|
||||
@@ -961,7 +961,9 @@ fn tessellate_entity_inner(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}];
|
||||
|
||||
..Default::default()
|
||||
}];
|
||||
}
|
||||
|
||||
// Render non-annotative dimensions from their stored picture block.
|
||||
@@ -1207,7 +1209,9 @@ fn tessellate_entity_inner(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
};
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let expanded = expand_block_object(
|
||||
document,
|
||||
@@ -1592,7 +1596,9 @@ fn lod_stub_wire(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sub-pixel LOD stub for 3D entities. Emits the entity's 3D AABB as a
|
||||
@@ -1687,7 +1693,9 @@ fn lod_stub_wire_3d(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tessellate each visible AttributeEntity attached to an Insert and append
|
||||
|
||||
+115
-30
@@ -399,7 +399,9 @@ fn split_mixed_polyline(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +537,9 @@ fn split_mixed_polyline(
|
||||
plinegen,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
} else if let Some(first_arc) = out.first_mut() {
|
||||
first_arc.snap_pts = snap_pts;
|
||||
first_arc.key_vertices = key_vertices.to_vec();
|
||||
@@ -614,7 +618,9 @@ fn point_cloud_wires(
|
||||
plinegen: true,
|
||||
fill_tris: Vec::new(),
|
||||
fill_tris_low: Vec::new(),
|
||||
}];
|
||||
|
||||
..Default::default()
|
||||
}];
|
||||
let mode = crate::scene::frame::mode(
|
||||
document,
|
||||
crate::scene::frame::FrameKind::PointCloudClip,
|
||||
@@ -870,7 +876,9 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
fill_tris,
|
||||
fill_tris_low,
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut snap_attached = false;
|
||||
@@ -1039,7 +1047,9 @@ pub fn tessellate(
|
||||
plinegen: false,
|
||||
fill_tris: Vec::new(),
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// Snap / key vertices ride the first emitted wire only (they describe
|
||||
@@ -1254,8 +1264,13 @@ pub fn tessellate(
|
||||
// materialisation and (for block content) the block-expand
|
||||
// transform — no separate document-wide collector.
|
||||
let mut sdf_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
|
||||
let mut searchable: Vec<crate::scene::model::wire_model::SearchableTextRun> =
|
||||
Vec::new();
|
||||
{
|
||||
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
||||
use crate::scene::model::wire_model::{
|
||||
clean_searchable_text, SearchableTextRun,
|
||||
};
|
||||
// Selection tints the whole run; otherwise inline `\C`
|
||||
// colours (bin key) win, falling back to entity colour.
|
||||
for group in &stroke_groups {
|
||||
@@ -1263,6 +1278,11 @@ pub fn tessellate(
|
||||
if crate::scene::text::web_font::requires_shaping(&run.text) {
|
||||
continue;
|
||||
}
|
||||
// Searchable counterpart, cleaned once here and
|
||||
// pushed after layout below (one run per laid-out
|
||||
// group, so MTEXT wrap/attachment and rotation are
|
||||
// already baked into the origin — no re-layout).
|
||||
let visible = clean_searchable_text(&run.text);
|
||||
let slx_v = (group.origin[0] - ref_lx_v) * anno + ref_lx_v;
|
||||
let sly_v = (group.origin[1] - ref_ly_v) * anno + ref_ly_v;
|
||||
// Base colour only (inline `\C` wins). Selection /
|
||||
@@ -1279,17 +1299,18 @@ pub fn tessellate(
|
||||
gcolor,
|
||||
text_contrast_background(entity, bg_color),
|
||||
);
|
||||
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
|
||||
&mut atlas,
|
||||
run.height,
|
||||
run.rotation,
|
||||
run.width_factor,
|
||||
run.oblique,
|
||||
run.tracking,
|
||||
&run.font,
|
||||
run.bold,
|
||||
&run.text,
|
||||
);
|
||||
let (quads, pen_adv) =
|
||||
crate::scene::text::glyph_quads::layout_glyph_quads(
|
||||
&mut atlas,
|
||||
run.height,
|
||||
run.rotation,
|
||||
run.width_factor,
|
||||
run.oblique,
|
||||
run.tracking,
|
||||
&run.font,
|
||||
run.bold,
|
||||
&run.text,
|
||||
);
|
||||
if let Some(plane) = group.plane {
|
||||
let scaled_origin = [
|
||||
plane.scale_origin[0]
|
||||
@@ -1319,6 +1340,40 @@ pub fn tessellate(
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
// Searchable run: same adapted colour as the quads,
|
||||
// exact pen advance in world units (layout's final
|
||||
// pen position × annotation factor, like the quads).
|
||||
if !visible.is_empty() {
|
||||
let adv_world = (pen_adv as f64 * anno) as f32;
|
||||
let (origin, rotation) = if let Some(plane) = group.plane {
|
||||
(
|
||||
[
|
||||
plane.scale_origin[0]
|
||||
+ (plane.origin[0] - plane.scale_origin[0])
|
||||
* anno,
|
||||
plane.scale_origin[1]
|
||||
+ (plane.origin[1] - plane.scale_origin[1])
|
||||
* anno,
|
||||
plane.scale_origin[2]
|
||||
+ (plane.origin[2] - plane.scale_origin[2])
|
||||
* anno,
|
||||
],
|
||||
(plane.x_axis[1]).atan2(plane.x_axis[0]) as f32,
|
||||
)
|
||||
} else {
|
||||
([slx_v, sly_v, elev_v], run.rotation)
|
||||
};
|
||||
searchable.push(SearchableTextRun {
|
||||
text: visible,
|
||||
origin,
|
||||
height: (run.height as f64 * anno) as f32,
|
||||
rotation,
|
||||
color: gcolor,
|
||||
bold: run.bold,
|
||||
font: run.font.clone(),
|
||||
adv_width: adv_world,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1428,7 +1483,9 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
fill_tris: ft,
|
||||
fill_tris_low: ftl,
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
// Text frame — a closed rectangle in the text
|
||||
// colour around the same box.
|
||||
@@ -1486,7 +1543,9 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1541,7 +1600,9 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
wires.push(WireModel {
|
||||
bg_adapt: None,
|
||||
@@ -1561,6 +1622,7 @@ pub fn tessellate(
|
||||
dash_from_start: false,
|
||||
dash_align_end: None,
|
||||
text_verts: sdf_verts,
|
||||
searchable_text: searchable,
|
||||
name,
|
||||
points: Vec::new(),
|
||||
points_low: Vec::new(),
|
||||
@@ -1643,7 +1705,9 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
if !bin.fill_tris.is_empty() {
|
||||
@@ -1691,7 +1755,9 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
fill_tris: bin.fill_tris,
|
||||
fill_tris_low: bin.fill_tris_low,
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1720,6 +1786,7 @@ pub fn tessellate(
|
||||
dash_from_start: false,
|
||||
dash_align_end: None,
|
||||
text_verts: sdf_verts,
|
||||
searchable_text: searchable,
|
||||
name: name.clone(),
|
||||
points: Vec::new(),
|
||||
points_low: Vec::new(),
|
||||
@@ -1774,7 +1841,9 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1855,7 +1924,9 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}];
|
||||
|
||||
..Default::default()
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2006,7 +2077,9 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2054,7 +2127,9 @@ pub fn tessellate(
|
||||
display_visible: true,
|
||||
snap_only: false,
|
||||
plot_visible: true,
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
@@ -2092,7 +2167,9 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
});
|
||||
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
@@ -2152,7 +2229,9 @@ pub fn tessellate(
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
fill_tris: Vec::new(),
|
||||
fill_tris_low: Vec::new(),
|
||||
}];
|
||||
|
||||
..Default::default()
|
||||
}];
|
||||
}
|
||||
|
||||
RenderObject::SegmentedLines(points) => {
|
||||
@@ -2242,7 +2321,9 @@ pub fn tessellate(
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}];
|
||||
|
||||
..Default::default()
|
||||
}];
|
||||
}
|
||||
|
||||
RenderObject::TaperedLines(points, widths) => {
|
||||
@@ -2321,7 +2402,9 @@ pub fn tessellate(
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}];
|
||||
|
||||
..Default::default()
|
||||
}];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2431,7 +2514,9 @@ pub fn tessellate(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}]
|
||||
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
|
||||
+18
-20
@@ -3052,10 +3052,10 @@ impl Scene {
|
||||
let is_insert = matches!(entity, EntityType::Insert(_));
|
||||
let is_section = matches!(
|
||||
entity,
|
||||
EntityType::Extended(codec::entities::ExtendedEntity {
|
||||
data: codec::entities::ExtendedEntityData::SectionObject(_),
|
||||
..
|
||||
})
|
||||
EntityType::Extended(extended) if matches!(
|
||||
extended.data,
|
||||
codec::entities::ExtendedEntityData::SectionObject(_)
|
||||
)
|
||||
);
|
||||
if self.meshes.contains_key(&handle)
|
||||
|| self.block_meshes.contains_key(&handle)
|
||||
@@ -7691,16 +7691,14 @@ impl Scene {
|
||||
CACHE_CATEGORY_MESH,
|
||||
|h| {
|
||||
self.meshes.contains_key(&h)
|
||||
|| matches!(
|
||||
self.document.get_entity(h),
|
||||
Some(EntityType::Insert(_))
|
||||
| Some(EntityType::Extended(
|
||||
codec::entities::ExtendedEntity {
|
||||
data: codec::entities::ExtendedEntityData::SectionObject(_),
|
||||
..
|
||||
}
|
||||
))
|
||||
)
|
||||
|| match self.document.get_entity(h) {
|
||||
Some(EntityType::Insert(_)) => true,
|
||||
Some(EntityType::Extended(extended)) => matches!(
|
||||
extended.data,
|
||||
codec::entities::ExtendedEntityData::SectionObject(_)
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
},
|
||||
) =>
|
||||
{
|
||||
@@ -12153,15 +12151,15 @@ mod section_tests {
|
||||
#[test]
|
||||
fn adding_and_erasing_a_section_keeps_its_object_graph_consistent() {
|
||||
let mut scene = Scene::new();
|
||||
let handle = scene.add_entity(EntityType::Extended(ExtendedEntity {
|
||||
let handle = scene.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::new(),
|
||||
data: ExtendedEntityData::SectionObject(section(1, 0.0)),
|
||||
}));
|
||||
})));
|
||||
let settings_handle = match scene.document.get_entity(handle).unwrap() {
|
||||
EntityType::Extended(ExtendedEntity {
|
||||
data: ExtendedEntityData::SectionObject(data),
|
||||
..
|
||||
}) => data.settings_handle,
|
||||
EntityType::Extended(extended) => match &extended.data {
|
||||
ExtendedEntityData::SectionObject(data) => data.settings_handle,
|
||||
_ => unreachable!(),
|
||||
},
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(matches!(
|
||||
|
||||
@@ -91,5 +91,5 @@ pub fn surface_entity(record: &SolidHistoryLoft) -> EntityType {
|
||||
ruled_surface: settings.normals == 0, virtual_guide: false,
|
||||
cross_sections: Vec::new(), guide_curves: Vec::new(), path_curve: None,
|
||||
};
|
||||
EntityType::Surface(surface)
|
||||
EntityType::Surface(Box::new(surface))
|
||||
}
|
||||
@@ -109,5 +109,5 @@ pub fn swept_surface_entity(record: &SolidHistorySweep) -> EntityType {
|
||||
..SurfaceSweepOptions::default()
|
||||
},
|
||||
};
|
||||
EntityType::Surface(surface)
|
||||
EntityType::Surface(Box::new(surface))
|
||||
}
|
||||
@@ -364,6 +364,202 @@ impl PointMarker {
|
||||
/// resolution pass must leave it alone. Only `Batches::finalize` fills it in.
|
||||
pub type BgAdapt = Option<Box<BgAdaptInputs>>;
|
||||
|
||||
/// One laid-out text run preserved for searchable PDF export.
|
||||
///
|
||||
/// `origin` is the final world-space baseline start (drawing units, f64 —
|
||||
/// annotation scale and block placement already applied), `height` is the
|
||||
/// world cap height (run.height × anno), `rotation` the run angle in radians.
|
||||
/// `text` holds the visible characters only (DXF controls stripped, `%%`
|
||||
/// specials decoded, `\P` → space — multi-line MTEXT arrives as one run per
|
||||
/// visual line from the layout stage, so no re-wrapping is needed).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SearchableTextRun {
|
||||
pub text: String,
|
||||
pub origin: [f64; 3],
|
||||
pub height: f32,
|
||||
pub rotation: f32,
|
||||
pub color: [f32; 4],
|
||||
pub bold: bool,
|
||||
/// Style font name as laid out (`GlyphRun.font`: TTF family, LFF/SHX
|
||||
/// name, …). Lets the PDF exporter resolve the run's *actual* font and
|
||||
/// embed a subset instead of always falling back to Helvetica.
|
||||
pub font: String,
|
||||
/// Laid-out advance width in drawing units (pen advance incl. spacing,
|
||||
/// annotation scale applied). Drives `Tz` width-matching and run-level
|
||||
/// culling at export; scaled alongside `height` by every transform.
|
||||
pub adv_width: f32,
|
||||
}
|
||||
|
||||
impl SearchableTextRun {
|
||||
/// World-space origin translated by `delta` (preview drags, block moves).
|
||||
pub fn translated(&self, delta: [f64; 3]) -> Self {
|
||||
let mut out = self.clone();
|
||||
out.origin[0] += delta[0];
|
||||
out.origin[1] += delta[1];
|
||||
out.origin[2] += delta[2];
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean a laid-out run's raw string into visible searchable characters.
|
||||
///
|
||||
/// The input is the exact string passed to `layout_glyph_quads` for the run,
|
||||
/// so the extracted text cannot diverge from the laid-out glyph sequence —
|
||||
/// cleaning only strips controls. Traps paid for at the format level:
|
||||
/// - `\P` is a real newline → space here (runs are already per-line).
|
||||
/// - Decoration toggles (`\L…\l`, `\O…\o`, `\K…\k`) and other backslash
|
||||
/// controls are stripped, not searched.
|
||||
/// - `\Snum^den;`/stacking → `num/den`; `\U+XXXX` → the char; `{…}` grouping
|
||||
/// braces are dropped; `\M+…` paragraph controls are stripped.
|
||||
/// - DXF `%%d`/`%%p`/`%%c` decode to `°`/`±`/`Ø`; `%%` + 3 digits → the char.
|
||||
/// - Literal `\\` → `\`, `\~` → non-breaking space.
|
||||
pub fn clean_searchable_text(raw: &str) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let bytes = raw.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
if b == b'\\' && i + 1 < bytes.len() {
|
||||
let n = bytes[i + 1];
|
||||
match n {
|
||||
b'P' | b'p' => {
|
||||
out.push(' ');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
b'\\' => {
|
||||
out.push('\\');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
b'~' => {
|
||||
out.push('\u{a0}');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// Stacking: `\Snum^den;` → `num/den` (searchable fraction).
|
||||
b'S' | b's' => {
|
||||
if let Some(semi) = raw[i + 2..].find(';') {
|
||||
let body = &raw[i + 2..i + 2 + semi];
|
||||
if let Some(caret) = body.find('^') {
|
||||
out.push_str(&body[..caret]);
|
||||
out.push('/');
|
||||
out.push_str(&body[caret + 1..]);
|
||||
} else {
|
||||
out.push_str(body);
|
||||
}
|
||||
i += 2 + semi + 1;
|
||||
} else {
|
||||
i += 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Unicode escape `\U+XXXX` (4–6 hex digits) → the char.
|
||||
// Anything else (bad length, surrogate, > U+10FFFF) leaves
|
||||
// the digits to be copied literally below — visible
|
||||
// garbage-in beats silently dropped text.
|
||||
b'U' | b'u' => {
|
||||
let rest = &raw[i + 2..];
|
||||
let after_plus = rest.strip_prefix('+').unwrap_or(rest);
|
||||
let digits: String = after_plus
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_hexdigit())
|
||||
.collect();
|
||||
let mut pushed = false;
|
||||
if (4..=6).contains(&digits.len()) {
|
||||
if let Ok(cp) = u32::from_str_radix(&digits, 16) {
|
||||
if let Some(ch) = char::from_u32(cp) {
|
||||
out.push(ch);
|
||||
pushed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 2 + (rest.len() - after_plus.len())
|
||||
+ if pushed { digits.len() } else { 0 };
|
||||
continue;
|
||||
}
|
||||
// Decoration / format toggles: drop both the opener and closer.
|
||||
b'L' | b'l' | b'O' | b'o' | b'K' | b'k' | b'C' | b'c' | b'F'
|
||||
| b'f' | b'H' | b'h' | b'Q' | b'q' | b'T'
|
||||
| b't' | b'W' | b'w' | b'A' | b'a' | b'M' | b'm' => {
|
||||
// `\C1;`, `\Farial;` etc. run to the next `;` — skip it.
|
||||
if let Some(semi) = raw[i + 2..].find(';') {
|
||||
i += 2 + semi + 1;
|
||||
} else {
|
||||
i += 2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else if b == b'%' && i + 2 < bytes.len() && bytes[i + 1] == b'%' {
|
||||
let c = bytes[i + 2];
|
||||
match c {
|
||||
b'd' | b'D' => out.push('°'),
|
||||
b'p' | b'P' => out.push('±'),
|
||||
b'c' | b'C' => out.push('Ø'),
|
||||
b'%' => out.push('%'),
|
||||
_ if c.is_ascii_digit()
|
||||
&& i + 4 < bytes.len()
|
||||
&& bytes[i + 3].is_ascii_digit()
|
||||
&& bytes[i + 4].is_ascii_digit() =>
|
||||
{
|
||||
let digits =
|
||||
std::str::from_utf8(&bytes[i + 2..i + 5]).unwrap_or("");
|
||||
if let Ok(code) = digits.parse::<u32>() {
|
||||
out.push(char::from_u32(code).unwrap_or('?'));
|
||||
} else {
|
||||
out.push_str("%%");
|
||||
out.push_str(digits);
|
||||
}
|
||||
i += 5;
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
out.push('%');
|
||||
out.push('%');
|
||||
out.push(c as char);
|
||||
}
|
||||
}
|
||||
i += 3;
|
||||
continue;
|
||||
} else if b == b'{' || b == b'}' {
|
||||
// MTEXT grouping braces: structure only, not content.
|
||||
i += 1;
|
||||
continue;
|
||||
} else {
|
||||
// Copy one UTF-8 char.
|
||||
if let Some(ch) = raw[i..].chars().next() {
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Collapse whitespace (MTEXT padding) but keep single spaces.
|
||||
let mut collapsed = String::with_capacity(out.len());
|
||||
let mut prev_space = true; // trim leading
|
||||
for ch in out.chars() {
|
||||
if ch.is_whitespace() {
|
||||
if !prev_space {
|
||||
collapsed.push(' ');
|
||||
prev_space = true;
|
||||
}
|
||||
} else {
|
||||
collapsed.push(ch);
|
||||
prev_space = false;
|
||||
}
|
||||
}
|
||||
if collapsed.ends_with(' ') {
|
||||
collapsed.pop();
|
||||
}
|
||||
collapsed
|
||||
}
|
||||
|
||||
/// The inputs `Batches::finalize` consumes when it resolves a colour.
|
||||
///
|
||||
/// The flags are independent — a wire can paint the background itself while
|
||||
@@ -496,6 +692,17 @@ pub struct WireModel {
|
||||
/// exactly like `points` — no separate collector pass. The renderer
|
||||
/// gathers these across all wires into the text vertex buffer.
|
||||
pub text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
|
||||
/// Searchable text runs for PDF export (real text, not outlines).
|
||||
///
|
||||
/// The SDF `text_verts` above carry only tessellated quads — the source
|
||||
/// string is lost by the time the PDF exporter runs, so searchable text
|
||||
/// was impossible without re-layout. Each entry preserves one laid-out
|
||||
/// run (already positioned: MTEXT attachment/width/wrap, rotation and
|
||||
/// block placement are baked into `origin`/`rotation`/`height`), letting
|
||||
/// `io::pdf_export` emit `BT … Tj ET` with an embedded font instead of
|
||||
/// vector outlines. Empty on non-text wires; transformed alongside
|
||||
/// `text_verts` by the block-expand loop and preview clones.
|
||||
pub searchable_text: Vec<SearchableTextRun>,
|
||||
/// Block-local composed draw-order offset in (-1, 1) for a wire that must
|
||||
/// order against its *siblings inside the block* — currently wide-polyline
|
||||
/// bands, whose solid area would otherwise cover later-drawn siblings.
|
||||
@@ -597,6 +804,7 @@ impl WireModel {
|
||||
pick_tris: Vec::new(),
|
||||
pick_tris_low: Vec::new(),
|
||||
text_verts: Vec::new(),
|
||||
searchable_text: Vec::new(),
|
||||
name,
|
||||
points,
|
||||
points_low: Vec::new(),
|
||||
@@ -630,10 +838,9 @@ impl WireModel {
|
||||
if let Some(marker) = &mut out.point_marker {
|
||||
marker.origin += delta.as_dvec3();
|
||||
}
|
||||
if !out.text_verts.is_empty() {
|
||||
{
|
||||
let (dx, dy, dz) = (delta.x as f64, delta.y as f64, delta.z as f64);
|
||||
out.text_verts =
|
||||
map_text_verts(&self.text_verts, |x, y, z| (x + dx, y + dy, z + dz));
|
||||
out.map_text_layout(&|p| [p[0] + dx, p[1] + dy, p[2] + dz], 1.0, 0.0);
|
||||
}
|
||||
out.tangent_geoms = self
|
||||
.tangent_geoms
|
||||
@@ -661,6 +868,9 @@ impl WireModel {
|
||||
(p.x, p.y, p.z)
|
||||
});
|
||||
}
|
||||
// Preview-only clone: searchable export runs are rebuilt from source
|
||||
// geometry, never carried on a rubber-band preview.
|
||||
out.searchable_text.clear();
|
||||
out.tangent_geoms.clear();
|
||||
out
|
||||
}
|
||||
@@ -719,6 +929,7 @@ impl WireModel {
|
||||
.iter()
|
||||
.map(|tg| tg.rotated(center.as_dvec3(), axis.as_dvec3(), angle_rad as f64))
|
||||
.collect();
|
||||
out.searchable_text.clear();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -758,6 +969,7 @@ impl WireModel {
|
||||
.iter()
|
||||
.map(|tg| tg.scaled(center.as_dvec3(), factor as f64))
|
||||
.collect();
|
||||
out.searchable_text.clear();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -834,6 +1046,7 @@ pub fn stretched_windows(
|
||||
}
|
||||
});
|
||||
}
|
||||
out.searchable_text.clear();
|
||||
|
||||
out
|
||||
}
|
||||
@@ -903,6 +1116,7 @@ pub fn stretched_windows(
|
||||
.iter()
|
||||
.map(|tg| tg.mirrored(p1.as_dvec3(), plane_normal.as_dvec3()))
|
||||
.collect();
|
||||
out.searchable_text.clear();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -959,6 +1173,117 @@ fn map_points(
|
||||
}
|
||||
}
|
||||
|
||||
impl WireModel {
|
||||
/// Remap every text channel geometrically in ONE call.
|
||||
///
|
||||
/// The SDF quads and the searchable runs describe the same placed text;
|
||||
/// transforming one without the other strands invisible text away from its
|
||||
/// outlines. Every geometric remap of text (block expand, OCS→WCS, paper
|
||||
/// projection, preview drags) MUST go through here so the rule — origins
|
||||
/// via `map`, cap height and advance scaled by `height_scale`, rotation
|
||||
/// shifted by `rot_delta` — lives in exactly one place instead of being
|
||||
/// re-implemented (and drifted) per call site.
|
||||
pub fn map_text_layout(
|
||||
&mut self,
|
||||
map: &dyn Fn([f64; 3]) -> [f64; 3],
|
||||
height_scale: f64,
|
||||
rot_delta: f32,
|
||||
) {
|
||||
if !self.text_verts.is_empty() {
|
||||
self.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
|
||||
let p = map([x, y, z]);
|
||||
(p[0], p[1], p[2])
|
||||
});
|
||||
}
|
||||
self.map_searchable_runs(map, height_scale, rot_delta);
|
||||
}
|
||||
|
||||
/// Remap only the searchable runs (origin, cap height, advance, rotation).
|
||||
/// Used where the quads need custom handling (per-vertex colour, per-quad
|
||||
/// culling) but the runs must follow the same geometry — same rule as
|
||||
/// [`Self::map_text_layout`], runs half.
|
||||
pub fn map_searchable_runs(
|
||||
&mut self,
|
||||
map: &dyn Fn([f64; 3]) -> [f64; 3],
|
||||
height_scale: f64,
|
||||
rot_delta: f32,
|
||||
) {
|
||||
map_searchable_runs(&mut self.searchable_text, map, height_scale, rot_delta);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remap a run slice geometrically: origins via `map`, cap height and advance
|
||||
/// scaled by `height_scale`, rotation shifted by `rot_delta`. The single rule
|
||||
/// behind [`WireModel::map_text_layout`] and [`WireModel::map_searchable_runs`];
|
||||
/// batch code (block expand) maps runs it appends to shared entries through
|
||||
/// this so the geometry can never drift from the method pair.
|
||||
pub fn map_searchable_runs(
|
||||
runs: &mut [SearchableTextRun],
|
||||
map: &dyn Fn([f64; 3]) -> [f64; 3],
|
||||
height_scale: f64,
|
||||
rot_delta: f32,
|
||||
) {
|
||||
for run in runs {
|
||||
run.origin = map(run.origin);
|
||||
run.height = (run.height as f64 * height_scale) as f32;
|
||||
run.adv_width = (run.adv_width as f64 * height_scale) as f32;
|
||||
run.rotation += rot_delta;
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlap of a text run's rect with an axis-aligned rect (SAT on both axes).
|
||||
///
|
||||
/// The run occupies `adv_width` along its baseline direction and roughly
|
||||
/// `height` above (cap) plus a descender allowance below: corners span
|
||||
/// `[-0.3h, +1.0h]` across the baseline. `rect` is `[x0, y0, x1, y1]`.
|
||||
/// Conservative by design (it only gates searchability, never drawing).
|
||||
pub fn run_rect_overlap(
|
||||
origin: [f64; 2],
|
||||
rotation: f32,
|
||||
adv_width: f64,
|
||||
height: f64,
|
||||
rect: [f64; 4],
|
||||
) -> bool {
|
||||
if !adv_width.is_finite() || !height.is_finite() || adv_width <= 0.0 || height <= 0.0 {
|
||||
return true;
|
||||
}
|
||||
let (s, c) = rotation.sin_cos();
|
||||
let (dx, dy) = (c as f64, s as f64);
|
||||
let (nx, ny) = (-dy, dx);
|
||||
let corners = [
|
||||
[origin[0] + nx * -0.3 * height, origin[1] + ny * -0.3 * height],
|
||||
[
|
||||
origin[0] + dx * adv_width + nx * -0.3 * height,
|
||||
origin[1] + dy * adv_width + ny * -0.3 * height,
|
||||
],
|
||||
[
|
||||
origin[0] + dx * adv_width + nx * height,
|
||||
origin[1] + dy * adv_width + ny * height,
|
||||
],
|
||||
[origin[0] + nx * height, origin[1] + ny * height],
|
||||
];
|
||||
// Separating-axis test on the OBB axes plus world x/y.
|
||||
for (ax, ay) in [(dx, dy), (nx, ny), (1.0, 0.0), (0.0, 1.0)] {
|
||||
let mut lo = f64::INFINITY;
|
||||
let mut hi = f64::NEG_INFINITY;
|
||||
for p in &corners {
|
||||
let t = p[0] * ax + p[1] * ay;
|
||||
lo = lo.min(t);
|
||||
hi = hi.max(t);
|
||||
}
|
||||
let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for p in [[rect[0], rect[1]], [rect[2], rect[1]], [rect[2], rect[3]], [rect[0], rect[3]]] {
|
||||
let t = p[0] * ax + p[1] * ay;
|
||||
rlo = rlo.min(t);
|
||||
rhi = rhi.max(t);
|
||||
}
|
||||
if hi < rlo || rhi < lo {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Map every glyph vertex's double-single world position through `f`, re-
|
||||
/// splitting the result. The preview transforms above move `points`, but SDF
|
||||
/// glyph quads live in `text_verts` (absolute-world double-single) — so a text
|
||||
@@ -995,6 +1320,7 @@ impl Default for WireModel {
|
||||
Self {
|
||||
point_marker: None,
|
||||
text_verts: Vec::new(),
|
||||
searchable_text: Vec::new(),
|
||||
name: String::new(),
|
||||
points: Vec::new(),
|
||||
points_low: Vec::new(),
|
||||
@@ -1129,4 +1455,64 @@ mod tests {
|
||||
panic!("Expected PlanarCircle");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_text_layout_moves_quads_and_runs_together() {
|
||||
let mut wire = WireModel::default();
|
||||
wire.searchable_text.push(SearchableTextRun {
|
||||
text: "AB".into(),
|
||||
origin: [10.0, 20.0, 0.0],
|
||||
height: 5.0,
|
||||
rotation: 0.0,
|
||||
color: WireModel::WHITE,
|
||||
bold: false,
|
||||
font: "txt".into(),
|
||||
adv_width: 8.0,
|
||||
});
|
||||
// Uniform scale ×2 about the origin plus translation: quads and runs
|
||||
// must agree afterwards (the lockstep this helper exists to enforce).
|
||||
wire.map_text_layout(&|p| [p[0] * 2.0 + 1.0, p[1] * 2.0, p[2]], 2.0, 0.5);
|
||||
let run = &wire.searchable_text[0];
|
||||
assert_eq!(run.origin, [21.0, 40.0, 0.0]);
|
||||
assert!((run.height - 10.0).abs() < 1e-4);
|
||||
assert!((run.adv_width - 16.0).abs() < 1e-4);
|
||||
assert!((run.rotation - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rect_overlap_keeps_straddlers_drops_outsiders() {
|
||||
assert!(run_rect_overlap([10.0, 20.0], 0.0, 8.0, 5.0, [0.0, 0.0, 30.0, 30.0]));
|
||||
assert!(!run_rect_overlap([10.0, 20.0], 0.0, 8.0, 5.0, [100.0, 100.0, 130.0, 130.0]));
|
||||
// A 90° run crossing the rect edge still counts (origin-only culling
|
||||
// would have dropped it).
|
||||
assert!(run_rect_overlap(
|
||||
[28.0, 20.0],
|
||||
std::f32::consts::FRAC_PI_2,
|
||||
8.0,
|
||||
5.0,
|
||||
[0.0, 0.0, 30.0, 30.0]
|
||||
));
|
||||
// Degenerate runs never cull (conservative: searchability only).
|
||||
assert!(run_rect_overlap([500.0, 500.0], 0.0, 0.0, 5.0, [0.0, 0.0, 30.0, 30.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn searchable_text_cleaning_handles_mtext_traps() {
|
||||
// `\P` is a real newline → space (runs arrive per-line).
|
||||
assert_eq!(clean_searchable_text("A\\PB"), "A B");
|
||||
// Decoration toggles are stripped, not searched.
|
||||
assert_eq!(clean_searchable_text("\\LAB\\l"), "AB");
|
||||
// DXF specials decode (incl. %%nnn codes).
|
||||
assert_eq!(clean_searchable_text("%%d"), "°");
|
||||
assert_eq!(clean_searchable_text("%%p"), "±");
|
||||
assert_eq!(clean_searchable_text("%%c"), "Ø");
|
||||
assert_eq!(clean_searchable_text("%%065"), "A");
|
||||
// Stacking, unicode escapes and grouping braces.
|
||||
assert_eq!(clean_searchable_text("\\S1^2;"), "1/2");
|
||||
assert_eq!(clean_searchable_text("\\U+00E9"), "é");
|
||||
assert_eq!(clean_searchable_text("{AB}"), "AB");
|
||||
// Plain text survives verbatim (drawing codes stay searchable).
|
||||
assert_eq!(clean_searchable_text("CMa-03"), "CMa-03");
|
||||
assert_eq!(clean_searchable_text(""), "");
|
||||
}
|
||||
}
|
||||
@@ -639,7 +639,9 @@ pub fn frame_wire(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip a hatch fill boundary to `poly`.
|
||||
@@ -1116,7 +1118,7 @@ mod tests {
|
||||
text_verts: Vec::new(),
|
||||
points: vec![[5.0, 5.0, 0.0], [15.0, 5.0, 0.0]],
|
||||
..Default::default()
|
||||
}];
|
||||
}];
|
||||
clip_wires(&mut wires, &poly);
|
||||
|
||||
assert_eq!(wires.len(), 1);
|
||||
|
||||
@@ -347,6 +347,29 @@ impl Scene {
|
||||
out.pattern_stations.clear();
|
||||
}
|
||||
out.text_verts = projected_text;
|
||||
// Searchable runs ride the same projection through the shared
|
||||
// helper (origin map + height/advance scale), then cull by
|
||||
// run-rect overlap — a run straddling the viewport edge is
|
||||
// kept, where origin-only culling would drop visible text.
|
||||
out.searchable_text = wire.searchable_text.clone();
|
||||
out.map_searchable_runs(
|
||||
&|p| {
|
||||
let q = proj_abs(p[0], p[1], p[2]);
|
||||
[q[0] as f64, q[1] as f64, q[2] as f64]
|
||||
},
|
||||
scale as f64,
|
||||
0.0,
|
||||
);
|
||||
out.searchable_text.retain(|run| {
|
||||
run.origin.iter().all(|v| v.is_finite())
|
||||
&& crate::scene::model::wire_model::run_rect_overlap(
|
||||
[run.origin[0], run.origin[1]],
|
||||
run.rotation,
|
||||
run.adv_width as f64,
|
||||
run.height as f64,
|
||||
[vp_x0 as f64, vp_y0 as f64, vp_x1 as f64, vp_y1 as f64],
|
||||
)
|
||||
});
|
||||
// Paper coordinates are small sheet units — no relative-to-eye
|
||||
// residual is needed, and keeping the model wire's points_low
|
||||
// here would add a model-scale offset to the paper points.
|
||||
|
||||
@@ -1002,10 +1002,14 @@ impl Scene {
|
||||
continue;
|
||||
}
|
||||
let settings_handle = self.document.get_entity(h).and_then(|entity| match entity {
|
||||
EntityType::Extended(codec::entities::ExtendedEntity {
|
||||
data: codec::entities::ExtendedEntityData::SectionObject(data),
|
||||
..
|
||||
}) if !data.settings_handle.is_null() => Some(data.settings_handle),
|
||||
EntityType::Extended(extended) => match &extended.data {
|
||||
codec::entities::ExtendedEntityData::SectionObject(data)
|
||||
if !data.settings_handle.is_null() =>
|
||||
{
|
||||
Some(data.settings_handle)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
});
|
||||
let section_managers: Vec<Handle> = managers_by_section
|
||||
|
||||
@@ -147,6 +147,7 @@ pub fn apply_along(
|
||||
// Embedded linetype text (LtSeg::Text) renders as SDF glyph quads collected
|
||||
// here; emitted as one extra text-carrying WireModel at the end.
|
||||
let mut text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
|
||||
let mut searchable: Vec<crate::scene::model::wire_model::SearchableTextRun> = Vec::new();
|
||||
|
||||
let mut elem_idx: usize = 0;
|
||||
let mut elem_consumed: f32 = 0.0;
|
||||
@@ -269,7 +270,7 @@ pub fn apply_along(
|
||||
// SDF: glyph quads at the insert point (rotation baked in by
|
||||
// layout_glyph_quads), collected for the text wire.
|
||||
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
||||
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
|
||||
let (quads, pen_adv) = crate::scene::text::glyph_quads::layout_glyph_quads(
|
||||
&mut atlas,
|
||||
*tx_scale,
|
||||
fwd_angle,
|
||||
@@ -291,6 +292,28 @@ pub fn apply_along(
|
||||
color,
|
||||
0.0,
|
||||
);
|
||||
{
|
||||
use crate::scene::model::wire_model::{
|
||||
clean_searchable_text, SearchableTextRun,
|
||||
};
|
||||
let visible = clean_searchable_text(&resolved);
|
||||
if !visible.is_empty() {
|
||||
searchable.push(SearchableTextRun {
|
||||
text: visible,
|
||||
origin: [
|
||||
insert[0] as f64,
|
||||
insert[1] as f64,
|
||||
insert[2] as f64,
|
||||
],
|
||||
height: *tx_scale,
|
||||
rotation: fwd_angle,
|
||||
color,
|
||||
bold: false,
|
||||
font: style.to_string(),
|
||||
adv_width: pen_adv,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
elem_idx += 1;
|
||||
@@ -342,7 +365,9 @@ pub fn apply_along(
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
})
|
||||
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Embedded linetype text (SDF): one extra wire carrying the glyph quads,
|
||||
@@ -376,6 +401,7 @@ pub fn apply_along(
|
||||
dash_from_start: false,
|
||||
dash_align_end: None,
|
||||
text_verts,
|
||||
searchable_text: searchable,
|
||||
name: name.to_string(),
|
||||
points: Vec::new(),
|
||||
points_low: Vec::new(),
|
||||
|
||||
@@ -34,6 +34,13 @@ pub struct GlyphQuad {
|
||||
/// Lay `text` out as per-glyph quads. Mirrors `tessellate_text_run`'s transform
|
||||
/// and per-character advance so the quads land exactly where the strokes would.
|
||||
/// Whitespace and glyphs with no ink advance the pen but emit no quad.
|
||||
///
|
||||
/// Returns the quads plus the final pen advance in run-local units
|
||||
/// (`cursor × scale × |width_factor|`, always ≥ 0): the exact laid-out width
|
||||
/// including spacing/tracking, so exporters can width-match (`Tz`) and cull
|
||||
/// runs without re-deriving advances from a second layout pass. Mirrored
|
||||
/// (negative width factor) runs collapse to a positive extent — the search
|
||||
/// layer reads LTR while the outlines keep the true mirror.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn layout_glyph_quads(
|
||||
atlas: &mut GlyphAtlas,
|
||||
@@ -45,9 +52,9 @@ pub fn layout_glyph_quads(
|
||||
font_name: &str,
|
||||
bold: bool,
|
||||
text: &str,
|
||||
) -> Vec<GlyphQuad> {
|
||||
) -> (Vec<GlyphQuad>, f32) {
|
||||
if text.is_empty() || height <= 0.0 {
|
||||
return vec![];
|
||||
return (vec![], 0.0);
|
||||
}
|
||||
|
||||
let scale = height / 9.0;
|
||||
@@ -155,6 +162,7 @@ pub fn layout_glyph_quads(
|
||||
}
|
||||
}
|
||||
|
||||
let pen_advance = (cursor_x * scale * wf).abs();
|
||||
// Any decoration still open at the end runs to the final pen position.
|
||||
for (slot, y) in [(under, UNDER_Y), (over, OVER_Y), (strike, STRIKE_Y)] {
|
||||
if let Some(s) = slot {
|
||||
@@ -176,7 +184,7 @@ pub fn layout_glyph_quads(
|
||||
});
|
||||
}
|
||||
|
||||
quads
|
||||
(quads, pen_advance)
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
@@ -198,7 +206,8 @@ mod tests {
|
||||
#[test]
|
||||
fn glyphs_advance_rightward() {
|
||||
let mut atlas = GlyphAtlas::new(512, 512);
|
||||
let quads = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA");
|
||||
let (quads, _) =
|
||||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA");
|
||||
assert_eq!(quads.len(), 2, "two inked glyphs -> two quads");
|
||||
assert!(
|
||||
center(&quads[1])[0] > center(&quads[0])[0],
|
||||
@@ -216,8 +225,10 @@ mod tests {
|
||||
#[test]
|
||||
fn space_emits_no_quad_but_advances() {
|
||||
let mut atlas = GlyphAtlas::new(512, 512);
|
||||
let ab = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA");
|
||||
let a_sp_a = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A A");
|
||||
let (ab, _) =
|
||||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA");
|
||||
let (a_sp_a, _) =
|
||||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A A");
|
||||
assert_eq!(a_sp_a.len(), 2, "space produces no quad");
|
||||
assert!(
|
||||
center(&a_sp_a[1])[0] > center(&ab[1])[0],
|
||||
@@ -231,8 +242,9 @@ mod tests {
|
||||
// Use the baseline advance direction (glyph0 -> glyph1), which is +x when
|
||||
// flat and swings to +y after a 90° turn — independent of where a single
|
||||
// glyph's box centre sits within the cap height.
|
||||
let flat = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA");
|
||||
let turned = layout_glyph_quads(
|
||||
let (flat, _) =
|
||||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA");
|
||||
let (turned, _) = layout_glyph_quads(
|
||||
&mut atlas,
|
||||
10.0,
|
||||
std::f32::consts::FRAC_PI_2,
|
||||
@@ -256,8 +268,10 @@ mod tests {
|
||||
#[test]
|
||||
fn taller_text_makes_bigger_quads() {
|
||||
let mut atlas = GlyphAtlas::new(512, 512);
|
||||
let small = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A");
|
||||
let big = layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A");
|
||||
let (small, _) =
|
||||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A");
|
||||
let (big, _) =
|
||||
layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A");
|
||||
let extent = |q: &GlyphQuad| {
|
||||
let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);
|
||||
for p in &q.corners {
|
||||
@@ -278,13 +292,44 @@ mod tests {
|
||||
#[test]
|
||||
fn underline_emits_an_extra_solid_quad() {
|
||||
let mut atlas = GlyphAtlas::new(512, 512);
|
||||
let plain = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB");
|
||||
let (plain, plain_adv) =
|
||||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB");
|
||||
// `\L…\l` underlines the run — same glyph quads plus one decoration quad.
|
||||
let under = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "\\LAB\\l");
|
||||
let (under, under_adv) =
|
||||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "\\LAB\\l");
|
||||
assert_eq!(under.len(), plain.len() + 1, "underline adds one quad");
|
||||
// The decoration quad samples the atlas's solid texel (degenerate UV).
|
||||
let deco = under.last().unwrap();
|
||||
assert_eq!(deco.uv_min, deco.uv_max, "decoration quad uses the solid UV");
|
||||
assert_eq!(deco.uv_min, atlas.solid_uv());
|
||||
// Decorations don't move the pen: same advance either way, and the
|
||||
// advance covers both glyphs (positive, wider than one glyph).
|
||||
assert!(
|
||||
(under_adv - plain_adv).abs() < 1e-6,
|
||||
"decoration must not move the pen: {under_adv} vs {plain_adv}"
|
||||
);
|
||||
assert!(plain_adv > 0.0, "two glyphs advance the pen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_advance_scales_with_height_and_width_factor() {
|
||||
let mut atlas = GlyphAtlas::new(512, 512);
|
||||
let (_, base) =
|
||||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB");
|
||||
let (_, tall) =
|
||||
layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB");
|
||||
assert!(
|
||||
(tall / base - 2.0).abs() < 0.05,
|
||||
"double height ~ double advance: {tall} vs {base}"
|
||||
);
|
||||
let (_, wide) =
|
||||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 2.0, 0.0, 1.0, "txt", false, "AB");
|
||||
assert!(
|
||||
(wide / base - 2.0).abs() < 0.05,
|
||||
"double width factor ~ double advance: {wide} vs {base}"
|
||||
);
|
||||
let (_, empty) =
|
||||
layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "");
|
||||
assert_eq!(empty, 0.0, "empty text advances nothing");
|
||||
}
|
||||
}
|
||||
@@ -139,10 +139,18 @@ fn canonical_family_name_uncached(family: &str) -> Option<String> {
|
||||
|
||||
/// Resolve a family name to a concrete face id (regular weight/style).
|
||||
fn face_id(family: &str) -> Option<fontdb::ID> {
|
||||
face_id_weighted(family, fontdb::Weight::NORMAL)
|
||||
}
|
||||
|
||||
/// Resolve a family name to a concrete face id at the requested weight
|
||||
/// (e.g. bold runs). Falls back through fontdb's fuzzy matching, so a
|
||||
/// family without a bold face still resolves — to its regular face.
|
||||
fn face_id_weighted(family: &str, weight: fontdb::Weight) -> Option<fontdb::ID> {
|
||||
let db = &fonts().db;
|
||||
let canonical = canonical_family_name(family)?;
|
||||
let query = fontdb::Query {
|
||||
families: &[fontdb::Family::Name(&canonical)],
|
||||
weight,
|
||||
..Default::default()
|
||||
};
|
||||
db.query(&query)
|
||||
@@ -157,6 +165,18 @@ pub fn with_face_data<T>(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Optio
|
||||
fonts().db.with_face_data(id, f)
|
||||
}
|
||||
|
||||
/// Borrow the raw face bytes for `family` at `weight` (see
|
||||
/// [`face_id_weighted`]). The PDF exporter uses this to embed the bold face
|
||||
/// for bold runs instead of faux-stroking the regular one.
|
||||
pub fn with_face_data_weighted<T>(
|
||||
family: &str,
|
||||
weight: fontdb::Weight,
|
||||
f: impl FnOnce(&[u8], u32) -> T,
|
||||
) -> Option<T> {
|
||||
let id = face_id_weighted(family, weight)?;
|
||||
fonts().db.with_face_data(id, f)
|
||||
}
|
||||
|
||||
/// The family of the installed face whose file is named `name` — a bare file
|
||||
/// name or any path to one, matched case-insensitively.
|
||||
fn family_for_file(name: &str) -> Option<String> {
|
||||
|
||||
+1
-1
@@ -3801,7 +3801,7 @@ mod ext_tests {
|
||||
text_verts: Vec::new(),
|
||||
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
|
||||
..Default::default()
|
||||
};
|
||||
};
|
||||
let wires = [wire];
|
||||
|
||||
// Extension-driven acquisition (endpoints_only): a midpoint — like an
|
||||
|
||||
+32
@@ -194,6 +194,38 @@ pub fn reveal_in_file_manager(path: &std::path::Path) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// If the main application window is minimized, restore it so that viewport captures succeed.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn restore_window_if_minimized() {
|
||||
use windows_sys::Win32::Foundation::{HWND, LPARAM};
|
||||
use windows_sys::Win32::System::Threading::GetCurrentProcessId;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
EnumWindows, GetWindowThreadProcessId, IsIconic, IsWindowVisible, SetForegroundWindow,
|
||||
ShowWindow, SW_RESTORE,
|
||||
};
|
||||
type BOOL = i32;
|
||||
|
||||
unsafe {
|
||||
let current_pid = GetCurrentProcessId();
|
||||
unsafe extern "system" fn enum_wnd(hwnd: HWND, lparam: LPARAM) -> BOOL {
|
||||
let target_pid = lparam as u32;
|
||||
let mut wnd_pid: u32 = 0;
|
||||
GetWindowThreadProcessId(hwnd, &mut wnd_pid);
|
||||
if wnd_pid == target_pid && IsWindowVisible(hwnd) != 0 {
|
||||
if IsIconic(hwnd) != 0 {
|
||||
ShowWindow(hwnd, SW_RESTORE);
|
||||
SetForegroundWindow(hwnd);
|
||||
}
|
||||
}
|
||||
1
|
||||
}
|
||||
EnumWindows(Some(enum_wnd), current_pid as LPARAM);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn restore_window_if_minimized() {}
|
||||
|
||||
/// Copy the rendered web canvas during the frame callback, before the browser
|
||||
/// clears its drawing buffer. Canvas readback avoids Iced's synchronous GPU map.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
Reference in new issue
Block a user