Merge remote-tracking branch 'origin/main' into codex/dwf-dgn-underlays

# Conflicts:
#	Cargo.lock
This commit is contained in:
ramox81 committed 2026-09-28 16:13:14 +03:00
commit fecbcd8138
51 files changed
+4155 -294

No files matched your search

Generated
+5 -4
View File
@@ -60,6 +60,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.10.9",
"skrifa 0.42.1",
"tiny-skia",
"ttf-parser",
"unicode-bidi",
@@ -4204,7 +4205,7 @@ dependencies = [
[[package]]
name = "opencadcodec"
version = "0.5.5"
source = "git+https://github.com/ramox81/cadcodec.git?rev=ea5edaa#ea5edaaac269ae957805a2ff8a595db7fd7f4002"
source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=ad16215#ad16215faa2b625d52346bdb1f147596567f70f7"
dependencies = [
"ahash 0.8.12",
"anyhow",
@@ -4227,7 +4228,7 @@ dependencies = [
[[package]]
name = "opencadgraph"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=d89a9c5#d89a9c5967b8b94a89a106e1ec981542bfd66ae4"
source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=fe36957#fe3695774499c0faca16e93b2c85d1751fe13475"
dependencies = [
"opencadkernel",
"serde_json",
@@ -4236,7 +4237,7 @@ dependencies = [
[[package]]
name = "opencadkernel"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=4e67cd7#4e67cd7008a183dade51385416862a323ff425f0"
source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=237827e#237827ef22b013487cf157dcb35730c52ae0c763"
dependencies = [
"cavalier_contours",
"opencadcodec",
@@ -4247,7 +4248,7 @@ dependencies = [
[[package]]
name = "opencadkernel-constraints"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=4e67cd7#4e67cd7008a183dade51385416862a323ff425f0"
source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=237827e#237827ef22b013487cf157dcb35730c52ae0c763"
dependencies = [
"nalgebra",
]
+7 -9
View File
@@ -33,10 +33,10 @@ rfd = "0.17"
clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11"
log = "0.4"
codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "42b44d2", features = ["serde"] }
kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "4e67cd7", features = ["acis", "offset"] }
kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "4e67cd7" }
graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "d89a9c5" }
codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "ad16215", features = ["serde"] }
kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "237827e", features = ["acis", "offset"] }
kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "237827e" }
graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "fe36957" }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
@@ -58,6 +58,9 @@ rustc-hash = "2"
sha2 = "0.10"
fontdb = "0.23"
ttf-parser = "0.25"
# TrueType interpreter for hint-reliant ("tricky") fonts such as DFKai-SB and
# MingLiU, whose raw outlines are missing strokes. Already in the tree (hayro).
skrifa = "0.42.1"
cosmic-text = "0.15"
unicode-bidi = "0.3"
lyon_tessellation = "1.0.20"
@@ -86,11 +89,6 @@ objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSResponder", "
block2 = "0.5"
objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] }
# Frame header variables (DWFFRAME/DGNFRAME) until the codec change is released;
# every crate that pulls the codec must see the same build.
[patch."https://github.com/HakanSeven12/opencadcodec.git"]
opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "ea5edaa" }
[patch.crates-io]
iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
+2 -2
View File
@@ -15,7 +15,7 @@ serde_json = { version = "1", optional = true }
# Pulled in only by the `host` feature, which adds the `opencadcodec`-typed
# `HostApi` runtime surface. The default crate stays dependency-free so engine
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "42b44d2", optional = true, features = ["serde"] }
codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "ad16215", optional = true, features = ["serde"] }
# Runtime IPC and serialization (host feature only).
interprocess = { version = "2", optional = true }
@@ -39,7 +39,7 @@ serde_json = "1"
serde = { version = "1", features = ["derive"] }
cargo-lock = "11"
# opencadcodec is scanned at build time to generate the embedded type registry.
codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "42b44d2", features = ["serde"] }
codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "ad16215", features = ["serde"] }
[dev-dependencies]
serde_json = "1"
+1 -1
View File
@@ -8,7 +8,7 @@ publish = false
crate-type = ["cdylib"]
[dependencies]
codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "42b44d2", features = ["serde"] }
codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "ad16215", features = ["serde"] }
bincode = "1.3"
serde = { version = "1", features = ["derive"] }
console_error_panic_hook = "0.1"
+3
View File
@@ -3815,6 +3815,9 @@ insert =
.xref-old-path-prefix = بادئة المسار القديم
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3802,6 +3802,9 @@ insert =
.xref-old-path-prefix = стар префикс на път
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3800,6 +3800,9 @@ insert =
.xref-old-path-prefix = stará předpona cesty
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3801,6 +3801,9 @@ insert =
.xref-old-path-prefix = alte Pfadpräfix
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3813,6 +3813,9 @@ insert =
.xref-old-path-prefix = old path prefix
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3814,6 +3814,9 @@ insert =
.xref-old-path-prefix = old path prefix
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3803,6 +3803,9 @@ insert =
.xref-old-path-prefix = prefijo de ruta antiguo
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3802,6 +3802,9 @@ insert =
.xref-old-path-prefix = vanha polun etuliite
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3803,6 +3803,9 @@ insert =
.xref-old-path-prefix = ancien préfixe de chemin
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3791,6 +3791,9 @@ insert =
.xref-old-path-prefix = पुराना पथ उपसर्ग
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3802,6 +3802,9 @@ insert =
.xref-old-path-prefix = régi útvonal-előtag
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3802,6 +3802,9 @@ insert =
.xref-old-path-prefix = vecchio prefisso percorso
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3791,6 +3791,9 @@ insert =
.xref-old-path-prefix = 古いパスプレフィックス
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3802,6 +3802,9 @@ insert =
.xref-old-path-prefix = 이전 경로 접두어
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3801,6 +3801,9 @@ insert =
.xref-old-path-prefix = oud padprefix
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3802,6 +3802,9 @@ insert =
.xref-old-path-prefix = stary prefiks ścieżki
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3804,6 +3804,9 @@ insert =
.xref-old-path-prefix = prefixo de caminho antigo
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3804,6 +3804,9 @@ insert =
.xref-old-path-prefix = старый префикс пути
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3770,6 +3770,9 @@ insert =
.xref-old-path-prefix = eski yol öneki
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Görüntüleri seçin:
.transparency-mode-on = Saydamlık modunu girin [ON/OFF] <ON>:
.transparency-mode-off = Saydamlık modunu girin [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3786,6 +3786,9 @@ insert =
.xref-old-path-prefix = 旧路径前缀
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+3
View File
@@ -3798,6 +3798,9 @@ insert =
.xref-old-path-prefix = 舊路徑前綴
.xref-attach-dwg = Attach DWG
.xref-attach-image = Attach Image
.select-images = Select image(s):
.transparency-mode-on = Enter transparency mode [ON/OFF] <ON>:
.transparency-mode-off = Enter transparency mode [ON/OFF] <OFF>:
.xref-attach-menu = Attach...
.xref-change-path-type = Change Path Type
.xref-find-and-replace-menu = Find and Replace...
+1 -1
View File
@@ -1265,7 +1265,7 @@ dependencies = [
[[package]]
name = "opencadcodec"
version = "0.5.5"
source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=42b44d2#42b44d2343e9925cff8a0c9e94c1d1c5ae8541c7"
source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=ad16215#ad16215faa2b625d52346bdb1f147596567f70f7"
dependencies = [
"ahash 0.8.12",
"anyhow",
+49
View File
@@ -929,6 +929,55 @@ impl OpenCADStudio {
}
}
}
// TRANSPARENCY — raster images show transparent pixels (ON) or
// draw every pixel in its colour (OFF).
"TRANSPARENCY" => {
let c = crate::modules::insert::image_transparency::TransparencyCommand::new();
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
}
"TRANSPARENCY MODE" | "TRANSPARENCY ON" | "TRANSPARENCY OFF" => {
use codec::entities::ImageDisplayFlags;
let images: Vec<(codec::Handle, bool)> = self.tabs[i]
.scene
.selected_entities()
.iter()
.filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle))
.filter_map(|(handle, _)| match self.tabs[i].scene.document.get_entity(*handle) {
Some(codec::EntityType::RasterImage(image)) => {
Some((*handle, image.flags.contains(ImageDisplayFlags::TRANSPARENCY_ON)))
}
_ => None,
})
.collect();
let Some(&(_, first_on)) = images.first() else {
return Some(Task::none());
};
let handles: Vec<codec::Handle> = images.iter().map(|(handle, _)| *handle).collect();
if cmd == "TRANSPARENCY MODE" {
let c = crate::modules::insert::image_transparency::TransparencyCommand::mode(
handles, first_on,
);
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
} else {
let on = cmd == "TRANSPARENCY ON";
self.push_undo_snapshot(i, "TRANSPARENCY");
for handle in &handles {
if let Some(codec::EntityType::RasterImage(image)) =
self.tabs[i].scene.document.get_entity_mut(*handle)
{
image.flags.set(ImageDisplayFlags::TRANSPARENCY_ON, on);
}
}
let changes: Vec<_> = handles
.iter()
.map(|handle| (*handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].dirty = true;
}
}
// UNDERLAY — edit properties of selected PDF/DWF/DGN underlay entities.
// Usage:
// UNDERLAY FADE <0-80>
+1
View File
@@ -717,6 +717,7 @@ inventory::submit!(crate::command::CommandRegistration {
"IM",
"IMAGE",
"IMAGEATTACH",
"TRANSPARENCY",
"IMAGEEMBED",
"IMPORTOBJ",
"ISOLATEOBJECTS",
+22 -9
View File
@@ -1417,7 +1417,7 @@ impl OpenCADStudio {
// The engaged grip is the rubber-band origin. Perpendicular
// snapping must drop its foot from this point, including when a
// hot-grip set is moved by the same drag vector.
self.snapper.from_point = Some(grip.origin_world.as_vec3());
self.snapper.from_point = Some(grip.origin_world);
let (go, gr) = self.drafting_grid_basis(i);
let base = grip.origin_world;
let construction_ray =
@@ -2137,10 +2137,9 @@ impl OpenCADStudio {
)
} else {
let (go, gr) = self.drafting_grid_basis(i);
// The snapper is a screen-space (f32) engine; the f64
// base only matters for typed-input precision, so hand it
// the downcast point here.
self.snapper.from_point = self.last_point.map(|p| p.as_vec3());
// Construction anchor for perpendicular and tangent snaps
// in full f64 precision.
self.snapper.from_point = self.last_point;
let construction_ray = if is_window_corner {
None
@@ -2556,8 +2555,15 @@ impl OpenCADStudio {
.as_ref()
.map(|c| c.object_pick_hover_previews(&self.tabs[i].scene, effective))
.unwrap_or_default();
let live_tangent = self.tabs[i].snap_result.and_then(|s| {
if s.snap_type == crate::snap::SnapType::Tangent {
s.tangent_obj
} else {
None
}
});
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
p.extend(cmd.on_preview_wires(effective));
p.extend(cmd.on_preview_wires_with_tangent(effective, live_tangent));
}
p
} else if needs_entity && deferred_command_hover {
@@ -2693,10 +2699,17 @@ impl OpenCADStudio {
} else if let Some(wires) = self.dimension_preview_wires(i, effective) {
wires
} else {
let live_tangent = self.tabs[i].snap_result.and_then(|s| {
if s.snap_type == crate::snap::SnapType::Tangent {
s.tangent_obj
} else {
None
}
});
self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_preview_wires(effective))
.map(|c| c.on_preview_wires_with_tangent(effective, live_tangent))
.unwrap_or_default()
};
// Polar tracking guide line: dotted line from last_point along
@@ -2943,7 +2956,7 @@ impl OpenCADStudio {
// Perpendicular and tangent measure from `from`, else the running
// command's last point; the live cursor's base point is restored.
let live_from = self.snapper.from_point;
self.snapper.from_point = from.or(self.last_point).map(|p| p.as_vec3());
self.snapper.from_point = from.or(self.last_point);
let (go, gr) = self.drafting_grid_basis(i);
let hit = self
.snapper
@@ -3784,7 +3797,7 @@ impl OpenCADStudio {
)
} else {
let (go, gr) = self.drafting_grid_basis(i);
self.snapper.from_point = self.last_point.map(|p| p.as_vec3());
self.snapper.from_point = self.last_point;
let construction_ray = if is_window_corner {
None
+1 -1
View File
@@ -234,7 +234,7 @@ impl OpenCADStudio {
let saved_from = self.snapper.from_point;
self.snapper.grid_snap_on = false;
self.snapper.from_point =
saved_from.map(|p| frame.paper_to_model(p.as_dvec3()).as_vec3());
saved_from.map(|p| frame.paper_to_model(p));
let hit = self.snapper.snap(
model_cursor,
local,
+18 -1
View File
@@ -255,12 +255,19 @@ pub enum EntityTransform {
// ── Tangent object ─────────────────────────────────────────────────────────
/// Geometric representation of a tangent-snap target.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TangentObject {
/// Infinite line through two world-space XZ-plane points.
Line { p1: DVec3, p2: DVec3 },
/// Circle in the world XY plane.
Circle { center: DVec3, radius: f64 },
/// Planar ellipse in world space.
Ellipse {
center: DVec3,
major_axis: DVec3,
normal: DVec3,
minor_axis_ratio: f64,
},
}
/// One unit of input to the active command's step machine.
@@ -2729,6 +2736,16 @@ pub trait CadCommand: Send {
self.on_mouse_move(pt).into_iter().collect()
}
/// Called on every mouse-move with optional live tangent snap target under the cursor.
/// Default: forwards to `on_preview_wires(pt)`.
fn on_preview_wires_with_tangent(
&mut self,
pt: DVec3,
_tangent: Option<TangentObject>,
) -> Vec<WireModel> {
self.on_preview_wires(pt)
}
/// Source entities replaced by the current live preview. The host removes
/// these from the resident render until the command commits or cancels.
/// Commands such as COPY keep their sources visible and use the default.
+123
View File
@@ -2894,3 +2894,126 @@ mod corrupt_guard_tests {
assert!(!is_entity_corrupt(&EntityType::Insert(i)));
}
}
#[cfg(test)]
mod pre_r2000_layer_plot_tests {
use super::*;
use codec::tables::layer::Layer as DocLayer;
use codec::types::DxfVersion;
fn drawing_with_layers() -> CadDocument {
let mut doc = CadDocument::new();
crate::io::linetypes::populate_document(&mut doc);
for name in ["COARSE", "FINE"] {
let mut layer = DocLayer::new(name);
layer.handle = doc.allocate_handle();
doc.layers.add(layer).unwrap();
}
doc
}
/// Tests run in parallel, so each gets its own file.
fn saved_path(test: &str, version: DxfVersion) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"ocs_plotflag_{test}_{}_{}.dwg",
version.as_str(),
std::process::id()
))
}
/// The LAYER record only grew a plot flag in R2000. Reading an R14 drawing
/// must therefore leave every layer plottable — reading the missing bit as
/// "don't plot" blanked the whole sheet in PLOT, the preview and QUICKPRINT
/// while the drawing still looked right on screen (display and plot
/// visibility are separate flags).
#[test]
fn an_r14_drawing_reads_back_with_every_layer_plottable() {
let doc = drawing_with_layers();
let path = saved_path("r14_bytes", DxfVersion::AC1014);
save_as_version(&doc, &path, DxfVersion::AC1014).expect("save R14 DWG");
let bytes = std::fs::read(&path).expect("read R14 DWG");
let _ = std::fs::remove_file(&path);
assert_eq!(&bytes[..6], b"AC1014", "writer did not emit an R14 header");
let loaded = load_bytes("r14.dwg", bytes).expect("load R14 DWG");
assert_eq!(loaded.version, DxfVersion::AC1014);
assert!(
loaded.layers.iter().all(|layer| layer.is_plottable),
"an R14 layer came back unplottable: {:?}",
loaded
.layers
.iter()
.filter(|layer| !layer.is_plottable)
.map(|layer| layer.name.clone())
.collect::<Vec<_>>()
);
}
/// R13 carries the same four-bit LAYER record as R14, and a path-based open
/// runs its fixes through `finalize_loaded_outcome` rather than
/// `load_bytes`, so this covers that second entry point too.
#[test]
fn an_r13_drawing_reads_back_with_every_layer_plottable() {
let doc = drawing_with_layers();
let path = saved_path("r13_path", DxfVersion::AC1012);
save_as_version(&doc, &path, DxfVersion::AC1012).expect("save R13 DWG");
let loaded = load_file(&path).expect("load R13 DWG");
let _ = std::fs::remove_file(&path);
assert!(
loaded.layers.iter().all(|layer| layer.is_plottable),
"an R13 layer came back unplottable"
);
}
/// From R2000 on the flag is in the file, so what the file says wins — a
/// layer stored unplottable (DEFPOINTS, say) must stay unplottable.
#[test]
fn a_modern_drawing_keeps_the_plot_flag_it_stored() {
let mut doc = drawing_with_layers();
doc.layers.get_mut("FINE").unwrap().is_plottable = false;
let path = saved_path("modern", DxfVersion::AC1032);
save_as_version(&doc, &path, DxfVersion::AC1032).expect("save R2018 DWG");
let loaded = load_file(&path).expect("load R2018 DWG");
let _ = std::fs::remove_file(&path);
assert!(!loaded.layers.get("FINE").unwrap().is_plottable);
assert!(loaded.layers.get("COARSE").unwrap().is_plottable);
}
/// The plot pass keeps a wire only when it is marked plottable, so an R14
/// drawing whose layers came back unplottable plotted a blank sheet while
/// every entity still drew on screen. Lock the whole chain here: load an
/// R14 drawing and check the wires the plot paths consume.
#[test]
fn every_wire_of_an_r14_drawing_stays_plottable() {
use codec::entities::Line;
use codec::types::Vector3;
use codec::EntityType;
let mut doc = drawing_with_layers();
for (index, layer) in ["COARSE", "FINE"].into_iter().enumerate() {
let x = index as f64 * 10.0;
let mut line = Line::from_points(
Vector3::new(x, 0.0, 0.0),
Vector3::new(x + 5.0, 5.0, 0.0),
);
line.common.layer = layer.to_string();
doc.add_entity(EntityType::Line(line)).expect("add line");
}
let path = saved_path("r14_wires", DxfVersion::AC1014);
save_as_version(&doc, &path, DxfVersion::AC1014).expect("save R14 DWG");
let bytes = std::fs::read(&path).expect("read R14 DWG");
let _ = std::fs::remove_file(&path);
let mut scene = crate::scene::Scene::new();
scene.document = load_bytes("r14.dwg", bytes).expect("load R14 DWG");
let (wires, _) = scene.plot_wire_groups(None);
assert!(!wires.is_empty(), "the R14 drawing tessellated to no wires");
let dropped = wires.iter().filter(|wire| !wire.plot_visible).count();
assert_eq!(
dropped,
0,
"{dropped} of {} wires would be dropped from the plot",
wires.len()
);
}
}
+11 -2
View File
@@ -242,8 +242,10 @@ fn emit_image(
{
return Err("Cannot plot bitmap: invalid coordinates, clip boundary, or opacity.".into());
}
// The low bit (free: the pixel buffer is aligned) keeps the opaque copy
// of a picture apart from its transparent one.
let key = (
std::sync::Arc::as_ptr(&image.pixels) as usize,
std::sync::Arc::as_ptr(&image.pixels) as usize | usize::from(!image.use_alpha),
image.width,
image.height,
);
@@ -255,7 +257,14 @@ fn emit_image(
doc.resources.xobjects.map.insert(
id.clone(),
printpdf::XObject::Image(printpdf::RawImage {
pixels: printpdf::RawImageData::U8(image.pixels.as_ref().clone()),
pixels: printpdf::RawImageData::U8(if image.use_alpha {
image.pixels.as_ref().clone()
} else {
// Transparency off: every pixel opaque in its colour.
let mut opaque = image.pixels.as_ref().clone();
opaque.chunks_exact_mut(4).for_each(|px| px[3] = 255);
opaque
}),
width: image.width as usize,
height: image.height as usize,
data_format: printpdf::RawImageFormat::RGBA8,
+3
View File
@@ -3309,6 +3309,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"File References" => Some(("insert", "xref-file-references")),
"Attach DWG" => Some(("insert", "xref-attach-dwg")),
"Attach Image" => Some(("insert", "xref-attach-image")),
"Select image(s):" => Some(("insert", "select-images")),
"Enter transparency mode [ON/OFF] <ON>:" => Some(("insert", "transparency-mode-on")),
"Enter transparency mode [ON/OFF] <OFF>:" => Some(("insert", "transparency-mode-off")),
"Attach..." => Some(("insert", "xref-attach-menu")),
"Change Path Type" => Some(("insert", "xref-change-path-type")),
"Find and Replace..." => Some(("insert", "xref-find-and-replace-menu")),
+332
View File
@@ -0,0 +1,332 @@
// Apollonius Circle Solvers for Fluid Tangent Arcs.
//
// Solves:
// - CCP (Curve-Curve-Point): Circle passing through point P and tangent to two circles.
// - PPC (Point-Point-Curve): Circle passing through points P1, P2 and tangent to a circle.
//
// Both use Circle Inversion centered at the pass-through point, mapping circles to
// circles/lines and reducing the problem to basic bitangent or point-tangent lines,
// then inverting back. Closed-form, O(1), and numerically stable.
use glam::DVec2;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Circle2D {
pub center: DVec2,
pub radius: f64,
}
#[derive(Clone, Copy, Debug)]
pub struct ApolloniusSolution {
pub center: DVec2,
pub radius: f64,
/// Tangent contact point on the first curve.
pub tangent1: DVec2,
/// Tangent contact point on the second curve (for CCP) or None (for PPC).
pub tangent2: Option<DVec2>,
}
/// Invert a circle about origin with inversion radius k.
/// If circle does not pass through origin, returns inverted Circle2D.
fn invert_circle(c: Circle2D, k: f64) -> Option<Circle2D> {
let d2 = c.center.length_squared();
let denom = d2 - c.radius * c.radius;
if denom.abs() < 1e-9 {
// Circle passes through origin -> inverts to a straight line
return None;
}
let scale = (k * k) / denom;
Some(Circle2D {
center: c.center * scale,
radius: (scale * c.radius).abs(),
})
}
/// Invert a line `n.x * x + n.y * y + c = 0` (where |n| = 1) about origin with radius k.
/// Line inverts to a circle through origin.
fn invert_line_to_circle(n: DVec2, c: f64, k: f64) -> Option<Circle2D> {
if c.abs() < 1e-9 {
// Line passes through origin -> inverts to itself
return None;
}
let radius = (k * k) / (2.0 * c.abs());
let center = -n * (k * k / (2.0 * c));
Some(Circle2D { center, radius })
}
/// Find the contact point on `original` closest to tangency with `sol`.
fn compute_contact_point(original: Circle2D, sol: Circle2D) -> DVec2 {
let dir = sol.center - original.center;
let dist = dir.length();
if dist < 1e-9 {
return original.center + DVec2::new(original.radius, 0.0);
}
let u = dir / dist;
// Choose sign + or - depending on whether sol is external or enclosing
let p_plus = original.center + u * original.radius;
let p_minus = original.center - u * original.radius;
if ((p_plus - sol.center).length() - sol.radius).abs() < 1e-3 {
p_plus
} else {
p_minus
}
}
/// Bitangent lines between two circles: returns line normals `n` (|n| = 1) and constants `c`
/// such that `n . x + c = 0`.
fn bitangent_lines(c1: Circle2D, c2: Circle2D) -> Vec<(DVec2, f64)> {
let mut lines = Vec::with_capacity(4);
let d_vec = c2.center - c1.center;
let d = d_vec.length();
if d < 1e-9 {
return lines;
}
let u = d_vec / d;
let v = DVec2::new(-u.y, u.x);
// 1. External bitangents (r_diff = r1 - r2)
let r_diff = c1.radius - c2.radius;
if d >= r_diff.abs() - 1e-9 {
let sin_a = (r_diff / d).clamp(-1.0, 1.0);
let cos_a = (1.0 - sin_a * sin_a).max(0.0).sqrt();
for sign in [-1.0, 1.0] {
let n = u * sin_a + v * (cos_a * sign);
let c = -(n.dot(c1.center) + c1.radius);
lines.push((n, c));
}
}
// 2. Internal bitangents (r_sum = r1 + r2)
let r_sum = c1.radius + c2.radius;
if d >= r_sum - 1e-9 {
let sin_a = (r_sum / d).clamp(-1.0, 1.0);
let cos_a = (1.0 - sin_a * sin_a).max(0.0).sqrt();
for sign in [-1.0, 1.0] {
let n = u * sin_a + v * (cos_a * sign);
let c = -(n.dot(c1.center) + c1.radius);
lines.push((n, c));
}
}
lines
}
/// Tangent lines from a point to a circle: returns lines `n . x + c = 0`.
fn point_circle_tangent_lines(pt: DVec2, circle: Circle2D) -> Vec<(DVec2, f64)> {
let mut lines = Vec::with_capacity(2);
let d_vec = circle.center - pt;
let d = d_vec.length();
if d < circle.radius - 1e-9 {
return lines;
}
if d < 1e-9 {
return lines;
}
let u = d_vec / d;
let v = DVec2::new(-u.y, u.x);
let sin_a = (circle.radius / d).clamp(-1.0, 1.0);
let cos_a = (1.0 - sin_a * sin_a).max(0.0).sqrt();
for sign in [-1.0, 1.0] {
let n = u * sin_a + v * (cos_a * sign);
let c = -n.dot(pt);
lines.push((n, c));
}
lines
}
/// Solve Apollonius CCP: circle passing through point `p` and tangent to circles `c1` and `c2`.
pub fn solve_ccp(c1: Circle2D, c2: Circle2D, p: DVec2) -> Vec<ApolloniusSolution> {
let k = 100.0; // Inversion radius
// Translate origin to p
let c1_local = Circle2D {
center: c1.center - p,
radius: c1.radius,
};
let c2_local = Circle2D {
center: c2.center - p,
radius: c2.radius,
};
let Some(c1_inv) = invert_circle(c1_local, k) else {
return Vec::new();
};
let Some(c2_inv) = invert_circle(c2_local, k) else {
return Vec::new();
};
let lines = bitangent_lines(c1_inv, c2_inv);
let mut solutions = Vec::new();
for (n, c) in lines {
if let Some(sol_local) = invert_line_to_circle(n, c, k) {
let sol = Circle2D {
center: sol_local.center + p,
radius: sol_local.radius,
};
// Verify sol is actually tangent to c1 and c2 within tolerance
let d1 = (sol.center - c1.center).length();
let d2 = (sol.center - c2.center).length();
let tan1_valid = ((d1 - (sol.radius + c1.radius)).abs() < 1e-4)
|| ((d1 - (sol.radius - c1.radius).abs()).abs() < 1e-4);
let tan2_valid = ((d2 - (sol.radius + c2.radius)).abs() < 1e-4)
|| ((d2 - (sol.radius - c2.radius).abs()).abs() < 1e-4);
if tan1_valid && tan2_valid {
let tangent1 = compute_contact_point(c1, sol);
let tangent2 = compute_contact_point(c2, sol);
solutions.push(ApolloniusSolution {
center: sol.center,
radius: sol.radius,
tangent1,
tangent2: Some(tangent2),
});
}
}
}
solutions
}
/// Solve Apollonius PPC: circle passing through points `p1` and `p2`, and tangent to circle `c1`.
pub fn solve_ppc(c1: Circle2D, p1: DVec2, p2: DVec2) -> Vec<ApolloniusSolution> {
if (p1 - p2).length_squared() < 1e-12 {
return Vec::new();
}
let k = 100.0;
// Translate origin to p1
let c1_local = Circle2D {
center: c1.center - p1,
radius: c1.radius,
};
let p2_local = p2 - p1;
let Some(c1_inv) = invert_circle(c1_local, k) else {
return Vec::new();
};
let p2_inv_len2 = p2_local.length_squared();
if p2_inv_len2 < 1e-12 {
return Vec::new();
}
let p2_inv = p2_local * (k * k / p2_inv_len2);
let lines = point_circle_tangent_lines(p2_inv, c1_inv);
let mut solutions = Vec::new();
for (n, c) in lines {
if let Some(sol_local) = invert_line_to_circle(n, c, k) {
let sol = Circle2D {
center: sol_local.center + p1,
radius: sol_local.radius,
};
let d1 = (sol.center - c1.center).length();
let tan1_valid = ((d1 - (sol.radius + c1.radius)).abs() < 1e-4)
|| ((d1 - (sol.radius - c1.radius).abs()).abs() < 1e-4);
let passes_p2 = ((sol.center - p2).length() - sol.radius).abs() < 1e-4;
if tan1_valid && passes_p2 {
let tangent1 = compute_contact_point(c1, sol);
solutions.push(ApolloniusSolution {
center: sol.center,
radius: sol.radius,
tangent1,
tangent2: None,
});
}
}
}
solutions
}
/// Choose the best CCP candidate closest to pick hints `hint1` and `hint2`.
pub fn best_ccp(
candidates: &[ApolloniusSolution],
hint1: DVec2,
hint2: DVec2,
) -> Option<ApolloniusSolution> {
candidates
.iter()
.filter(|sol| sol.radius.is_finite() && sol.radius > 1e-4)
.min_by(|a, b| {
let score = |s: &ApolloniusSolution| -> f64 {
let d1 = (s.tangent1 - hint1).length();
let d2 = s.tangent2.map_or(0.0, |t| (t - hint2).length());
let r_pen = if s.radius > 50000.0 { 10000.0 } else { 0.0 };
d1 + d2 + r_pen
};
score(a).total_cmp(&score(b))
})
.copied()
}
/// Choose the best PPC candidate closest to pick hint `hint1`.
pub fn best_ppc(
candidates: &[ApolloniusSolution],
hint1: DVec2,
) -> Option<ApolloniusSolution> {
candidates
.iter()
.filter(|sol| sol.radius.is_finite() && sol.radius > 1e-4)
.min_by(|a, b| {
let score = |s: &ApolloniusSolution| -> f64 {
let d1 = (s.tangent1 - hint1).length();
let r_pen = if s.radius > 50000.0 { 10000.0 } else { 0.0 };
d1 + r_pen
};
score(a).total_cmp(&score(b))
})
.copied()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ccp_two_equal_circles() {
// Two circles of radius 10 at (-20, 0) and (20, 0).
// Middle point P at (0, 10).
// A circle through (0, 10) tangent to both circles at their top (+Y).
let c1 = Circle2D {
center: DVec2::new(-20.0, 0.0),
radius: 10.0,
};
let c2 = Circle2D {
center: DVec2::new(20.0, 0.0),
radius: 10.0,
};
let p = DVec2::new(0.0, 10.0);
let sols = solve_ccp(c1, c2, p);
assert!(!sols.is_empty(), "Should find CCP solutions");
let best = best_ccp(&sols, DVec2::new(-20.0, 10.0), DVec2::new(20.0, 10.0)).unwrap();
// Check that distance to c1 center is radius +- 10
let d1 = (best.center - c1.center).length();
assert!((d1 - (best.radius + c1.radius)).abs() < 1e-3 || (d1 - (best.radius - c1.radius).abs()).abs() < 1e-3);
// Check that point P is on the circle
assert!(((best.center - p).length() - best.radius).abs() < 1e-3);
}
#[test]
fn test_ppc_circle_and_two_points() {
// Circle of radius 10 at (0, 0).
// Points at (0, 20) and (20, 20).
let c = Circle2D {
center: DVec2::new(0.0, 0.0),
radius: 10.0,
};
let p1 = DVec2::new(0.0, 20.0);
let p2 = DVec2::new(20.0, 20.0);
let sols = solve_ppc(c, p1, p2);
assert!(!sols.is_empty(), "Should find PPC solutions");
let best = best_ppc(&sols, DVec2::new(0.0, 10.0)).unwrap();
assert!(((best.center - p1).length() - best.radius).abs() < 1e-3);
assert!(((best.center - p2).length() - best.radius).abs() < 1e-3);
}
}
File diff suppressed because it is too large. Load diff
+590 -9
View File
@@ -12,7 +12,8 @@ use crate::command::{CadCommand, CmdResult, DynField, TangentObject, WorkingPlan
use crate::modules::draw::defaults;
use crate::modules::IconKind;
use crate::scene::model::wire_model::{TangentGeom, WireModel};
use glam::DVec3;
use super::arc::Ellipse2D;
use glam::{DVec2, DVec3};
const TAU: f64 = std::f64::consts::TAU;
@@ -115,6 +116,17 @@ pub(crate) fn tangent_object_local(object: TangentObject, plane: WorkingPlane) -
center: plane.to_local(center),
radius,
},
TangentObject::Ellipse {
center,
major_axis,
normal,
minor_axis_ratio,
} => TangentObject::Ellipse {
center: plane.to_local(center),
major_axis: plane.vector_to_local(major_axis),
normal: plane.vector_to_local(normal),
minor_axis_ratio,
},
}
}
@@ -601,6 +613,7 @@ fn solve_quadratic(a: f64, b: f64, c: f64) -> Vec<f64> {
vec![(-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a)]
}
#[allow(dead_code)]
pub(crate) fn best_of(candidates: &[DVec3], hint: DVec3) -> Option<DVec3> {
candidates.iter().copied().min_by(|a, b| {
a.distance(hint)
@@ -609,6 +622,169 @@ pub(crate) fn best_of(candidates: &[DVec3], hint: DVec3) -> Option<DVec3> {
})
}
fn closest_point_on_obj(obj: &TangentObject, pt: DVec3, radius: f64) -> DVec3 {
match obj {
TangentObject::Line { p1, p2 } => {
let d = *p2 - *p1;
let len2 = d.x * d.x + d.y * d.y;
if len2 < 1e-12 {
*p1
} else {
let t = ((pt.x - p1.x) * d.x + (pt.y - p1.y) * d.y) / len2;
DVec3::new(p1.x + d.x * t, p1.y + d.y * t, 0.0)
}
}
TangentObject::Circle { center, radius: r_obj } => {
let dx = pt.x - center.x;
let dy = pt.y - center.y;
let len = dx.hypot(dy);
if len < 1e-9 {
DVec3::new(center.x + r_obj, center.y, 0.0)
} else {
let p_plus = DVec3::new(center.x + dx * (r_obj / len), center.y + dy * (r_obj / len), 0.0);
let p_minus = DVec3::new(center.x - dx * (r_obj / len), center.y - dy * (r_obj / len), 0.0);
if (p_plus.distance(pt) - radius).abs() < (p_minus.distance(pt) - radius).abs() {
p_plus
} else {
p_minus
}
}
}
TangentObject::Ellipse { center, major_axis, normal, minor_axis_ratio } => {
let ell = Ellipse2D::new(
DVec2::new(center.x, center.y),
DVec2::new(major_axis.x, major_axis.y),
normal.z,
*minor_axis_ratio,
);
let p_2d = DVec2::new(pt.x, pt.y);
let norm_pts = ell.normal_points_for_center(p_2d);
let closest_2d = norm_pts
.into_iter()
.min_by(|a, b| {
let da = ((*a - p_2d).length() - radius).abs();
let db = ((*b - p_2d).length() - radius).abs();
da.total_cmp(&db)
})
.unwrap_or_else(|| ell.project_point(p_2d));
DVec3::new(closest_2d.x, closest_2d.y, 0.0)
}
}
}
fn object_side_alignment(obj: &TangentObject, c: DVec3, target: DVec3) -> f64 {
match obj {
TangentObject::Line { p1, p2 } => {
let dx = p2.x - p1.x;
let dy = p2.y - p1.y;
let len = dx.hypot(dy);
if len < 1e-12 {
return 1.0;
}
let nx = -dy / len;
let ny = dx / len;
let h_c = (c.x - p1.x) * nx + (c.y - p1.y) * ny;
let h_t = (target.x - p1.x) * nx + (target.y - p1.y) * ny;
if h_t.abs() < 1e-9 {
1.0
} else {
(h_c * h_t).signum()
}
}
TangentObject::Circle { center, radius: r_obj } => {
let dc = DVec2::new(c.x - center.x, c.y - center.y);
let dt = DVec2::new(target.x - center.x, target.y - center.y);
let lc = dc.length();
let lt = dt.length();
if lc < 1e-9 || lt < 1e-9 {
return 1.0;
}
let dir_dot = dc.dot(dt) / (lc * lt);
let c_is_external = lc > *r_obj;
let t_is_external = lt > *r_obj;
if c_is_external == t_is_external {
dir_dot
} else {
-1.0
}
}
TangentObject::Ellipse { center, major_axis, normal, minor_axis_ratio } => {
let ell = Ellipse2D::new(
DVec2::new(center.x, center.y),
DVec2::new(major_axis.x, major_axis.y),
normal.z,
*minor_axis_ratio,
);
let c_2d = DVec2::new(c.x, c.y);
let t_2d = DVec2::new(target.x, target.y);
let p_c = ell.project_point(c_2d);
let n_c = (c_2d - p_c).normalize_or_zero();
let p_t = ell.project_point(t_2d);
let n_t = (t_2d - p_t).normalize_or_zero();
n_c.dot(n_t)
}
}
}
fn score_ttr_candidate(
c: DVec3,
obj1: &TangentObject,
obj2: &TangentObject,
radius: f64,
r_inv: f64,
hit1: DVec3,
hit2: DVec3,
cursor: Option<DVec3>,
) -> f64 {
let t1 = closest_point_on_obj(obj1, c, radius);
let t2 = closest_point_on_obj(obj2, c, radius);
let (s1, s2, d_cur) = if let Some(cur) = cursor {
let s1 = object_side_alignment(obj1, c, cur);
let s2 = object_side_alignment(obj2, c, cur);
let d_cur = ((cur - c).length() - radius).abs();
(s1, s2, d_cur)
} else {
// Without cursor, obj1's desired side is towards hit2, obj2's desired side is towards hit1
let s1 = object_side_alignment(obj1, c, hit2);
let s2 = object_side_alignment(obj2, c, hit1);
(s1, s2, 0.0)
};
let mismatch = (if s1 < 0.0 { 1.0 } else { 0.0 }) + (if s2 < 0.0 { 1.0 } else { 0.0 });
let d_pick = (t1 - hit1).length() + (t2 - hit2).length();
mismatch * 1000.0 - (s1 + s2) * 20.0 + (d_cur + d_pick) * r_inv
}
pub(crate) fn pick_best_ttr_candidate(
obj1: TangentObject,
obj2: TangentObject,
radius: f64,
hit1: DVec3,
hit2: DVec3,
cursor: Option<DVec3>,
) -> Option<DVec3> {
let candidates = ttr_candidates(obj1, obj2, radius);
if candidates.is_empty() {
return None;
}
if candidates.len() == 1 {
return Some(candidates[0]);
}
let r_inv = 1.0 / radius.max(1e-6);
candidates
.into_iter()
.min_by(|&c_a, &c_b| {
let score_a = score_ttr_candidate(c_a, &obj1, &obj2, radius, r_inv, hit1, hit2, cursor);
let score_b = score_ttr_candidate(c_b, &obj1, &obj2, radius, r_inv, hit1, hit2, cursor);
score_a.total_cmp(&score_b)
})
}
fn best_circle_of(candidates: &[(DVec3, f64)], hint: DVec3) -> Option<(DVec3, f64)> {
candidates
.iter()
@@ -631,10 +807,242 @@ fn tangent_curve(object: TangentObject) -> KernelCurve {
centre: [center.x, center.y],
radius,
}),
TangentObject::Ellipse {
center,
major_axis,
minor_axis_ratio,
..
} => {
let a = major_axis.length();
let major_axis_2d = if a > 1e-9 {
[major_axis.x / a, major_axis.y / a]
} else {
[1.0, 0.0]
};
KernelCurve::Ellipse(kernel::geom2d::EllipseArc {
ellipse: kernel::geom2d::Ellipse {
centre: [center.x, center.y],
major_radius: a,
minor_radius: a * minor_axis_ratio,
major_axis: major_axis_2d,
},
start_parameter: 0.0,
end_parameter: std::f64::consts::TAU,
})
}
}
}
fn to_ellipse2d(obj: TangentObject) -> Option<Ellipse2D> {
match obj {
TangentObject::Ellipse {
center,
major_axis,
normal,
minor_axis_ratio,
} => Some(Ellipse2D::new(
DVec2::new(center.x, center.y),
DVec2::new(major_axis.x, major_axis.y),
normal.z,
minor_axis_ratio,
)),
_ => None,
}
}
fn solve_1d_roots(
n_steps: usize,
mut eval: impl FnMut(f64) -> Option<f64>,
) -> Vec<f64> {
let dt = TAU / (n_steps as f64);
let mut samples: Vec<(f64, Option<f64>)> = Vec::with_capacity(n_steps);
for i in 0..n_steps {
let t = i as f64 * dt;
let val = eval(t);
samples.push((t, val));
}
let mut roots = Vec::new();
for i in 0..n_steps {
let next_i = (i + 1) % n_steps;
let (ta, fa_opt) = samples[i];
let (tb_raw, fb_opt) = samples[next_i];
let tb = if next_i == 0 { TAU } else { tb_raw };
let (Some(fa), Some(fb)) = (fa_opt, fb_opt) else { continue };
// 1. Sign crossing
if fa.signum() != fb.signum() {
let mut lo = ta;
let mut hi = tb;
let mut best_t = 0.5 * (lo + hi);
for _ in 0..24 {
let mid = 0.5 * (lo + hi);
let Some(fm) = eval(mid) else { break };
best_t = mid;
if fm.abs() < 1e-12 {
break;
}
if fm.signum() == fa.signum() {
lo = mid;
} else {
hi = mid;
}
}
if let Some(f_val) = eval(best_t) {
if f_val.abs() < 1e-3 {
roots.push(best_t.rem_euclid(TAU));
}
}
} else {
// 2. Local extremum touching zero (minimum or maximum)
let prev_i = if i == 0 { n_steps - 1 } else { i - 1 };
if let Some(f_prev) = samples[prev_i].1 {
if fa.abs() <= f_prev.abs() && fa.abs() <= fb.abs() && fa.abs() < 0.2 {
let mut a = ta - dt;
let mut b = tb;
let phi = (5.0f64.sqrt() - 1.0) * 0.5;
let mut c = b - phi * (b - a);
let mut d = a + phi * (b - a);
for _ in 0..24 {
let fc = eval(c).map(|v| v.abs()).unwrap_or(f64::MAX);
let fd = eval(d).map(|v| v.abs()).unwrap_or(f64::MAX);
if fc < fd {
b = d;
} else {
a = c;
}
c = b - phi * (b - a);
d = a + phi * (b - a);
}
let best_t = 0.5 * (a + b);
if let Some(f_val) = eval(best_t) {
if f_val.abs() < 1e-3 {
roots.push(best_t.rem_euclid(TAU));
}
}
}
}
}
}
roots
}
fn ttr_ellipse_candidates(
ell: &Ellipse2D,
other: TangentObject,
radius: f64,
) -> Vec<DVec3> {
let mut candidates = Vec::new();
let n_steps = 180;
let other_ell = to_ellipse2d(other);
// s = +1 (outer offset) and s = -1 (inner offset)
for &s in &[1.0, -1.0] {
let center_at = |t: f64| -> DVec2 {
let pt = ell.point_at(t);
let n = ell.normal_at(t);
pt + n * (s * radius)
};
match other {
TangentObject::Line { p1, p2 } => {
let dx = p2.x - p1.x;
let dy = p2.y - p1.y;
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-9 {
continue;
}
let n_line = DVec2::new(-dy / len, dx / len);
let p1_2d = DVec2::new(p1.x, p1.y);
for &line_side in &[1.0, -1.0] {
let eval = |t: f64| -> Option<f64> {
let c = center_at(t);
let h = n_line.dot(c - p1_2d);
Some(h - line_side * radius)
};
for t_root in solve_1d_roots(n_steps, eval) {
let c = center_at(t_root);
candidates.push(DVec3::new(c.x, c.y, 0.0));
}
}
}
TangentObject::Circle { center, radius: r2 } => {
let c2_2d = DVec2::new(center.x, center.y);
for &is_int in &[false, true] {
let target_dist = if is_int {
(radius - r2).abs()
} else {
radius + r2
};
if target_dist < 1e-6 {
continue;
}
let eval = |t: f64| -> Option<f64> {
let c = center_at(t);
Some((c - c2_2d).length() - target_dist)
};
for t_root in solve_1d_roots(n_steps, eval) {
let c = center_at(t_root);
candidates.push(DVec3::new(c.x, c.y, 0.0));
}
}
}
TangentObject::Ellipse { .. } => {
if let Some(ell2) = &other_ell {
for &is_enclosing in &[false, true] {
let eval = |t: f64| -> Option<f64> {
let c = center_at(t);
let norm_pts = ell2.normal_points_for_center(c);
let pt2 = if is_enclosing {
norm_pts
.into_iter()
.max_by(|a, b| (*a - c).length_squared().total_cmp(&(*b - c).length_squared()))?
} else {
norm_pts
.into_iter()
.min_by(|a, b| (*a - c).length_squared().total_cmp(&(*b - c).length_squared()))?
};
let d2 = (c - pt2).length();
Some(d2 - radius)
};
for t_root in solve_1d_roots(n_steps, eval) {
let c = center_at(t_root);
candidates.push(DVec3::new(c.x, c.y, 0.0));
}
}
}
}
}
}
let mut unique = Vec::new();
for c in candidates {
if !unique.iter().any(|u: &DVec3| (*u - c).length_squared() < 1e-4) {
unique.push(c);
}
}
unique
}
pub(crate) fn ttr_candidates(first: TangentObject, second: TangentObject, radius: f64) -> Vec<DVec3> {
if radius <= 1e-9 {
return Vec::new();
}
if let Some(ell1) = to_ellipse2d(first) {
return ttr_ellipse_candidates(&ell1, second, radius);
}
if let Some(ell2) = to_ellipse2d(second) {
return ttr_ellipse_candidates(&ell2, first, radius);
}
let first = tangent_curve(first);
let second = tangent_curve(second);
fillets_between(&first, &second, radius, Tolerance::default())
@@ -697,6 +1105,7 @@ fn ttt_solve_sign(objs: &[TangentObject; 3], eps: &[f64; 3]) -> Vec<(DVec3, f64)
TangentObject::Circle { .. } => {
circle_idx.push(i);
}
_ => {}
}
}
@@ -829,6 +1238,7 @@ pub struct CircleTTRCommand {
step: StepTTR,
default_r: f64,
plane: WorkingPlane,
last_cursor: Option<DVec3>,
}
enum StepTTR {
@@ -851,10 +1261,11 @@ impl CircleTTRCommand {
step: StepTTR::First,
default_r: defaults::get_circle_radius(),
plane: WorkingPlane::default(),
last_cursor: None,
}
}
fn result_for_radius(&self, radius: f64) -> CmdResult {
fn result_for_radius(&self, radius: f64, cursor: Option<DVec3>) -> CmdResult {
if radius <= 1.0e-9 {
return CmdResult::NeedPoint;
}
@@ -867,9 +1278,14 @@ impl CircleTTRCommand {
else {
return CmdResult::NeedPoint;
};
let hint = (*hit1 + *hit2) * 0.5;
let candidates = ttr_candidates(*obj1, *obj2, radius);
let Some(center) = best_of(&candidates, hint) else {
let Some(center) = pick_best_ttr_candidate(
*obj1,
*obj2,
radius,
*hit1,
*hit2,
cursor,
) else {
return CmdResult::NeedPoint;
};
defaults::set_circle_radius(radius);
@@ -927,8 +1343,10 @@ impl CadCommand for CircleTTRCommand {
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
let cursor_local = self.plane.to_local(pt);
self.last_cursor = Some(cursor_local);
self.radius_from_point(pt)
.map(|radius| self.result_for_radius(radius))
.map(|radius| self.result_for_radius(radius, Some(cursor_local)))
.unwrap_or(CmdResult::NeedPoint)
}
@@ -951,6 +1369,7 @@ impl CadCommand for CircleTTRCommand {
hit1: h1,
hit2: hit,
};
self.last_cursor = None;
CmdResult::NeedPoint
}
StepTTR::Radius { .. } => CmdResult::NeedPoint,
@@ -962,12 +1381,13 @@ impl CadCommand for CircleTTRCommand {
if r <= 0.0 {
return Some(CmdResult::NeedPoint);
}
matches!(self.step, StepTTR::Radius { .. }).then(|| self.result_for_radius(r))
matches!(self.step, StepTTR::Radius { .. })
.then(|| self.result_for_radius(r, self.last_cursor))
}
fn on_enter(&mut self) -> CmdResult {
if matches!(self.step, StepTTR::Radius { .. }) {
self.result_for_radius(self.default_r)
self.result_for_radius(self.default_r, self.last_cursor)
} else {
CmdResult::Cancel
}
@@ -986,7 +1406,16 @@ impl CadCommand for CircleTTRCommand {
else {
return None;
};
let center = best_of(&ttr_candidates(*obj1, *obj2, radius), (*hit1 + *hit2) * 0.5)?;
let cursor_local = self.plane.to_local(pt);
self.last_cursor = Some(cursor_local);
let center = pick_best_ttr_candidate(
*obj1,
*obj2,
radius,
*hit1,
*hit2,
Some(cursor_local),
)?;
Some(circle_wire(
self.plane.to_world(center),
radius,
@@ -1180,4 +1609,156 @@ mod tests {
assert!(ok, "no annulus candidate, got {cands:?}");
}
}
#[test]
fn ttr_ellipse_and_circle_test() {
let e1 = TangentObject::Ellipse {
center: DVec3::new(-30.0, 0.0, 0.0),
major_axis: DVec3::new(20.0, 0.0, 0.0),
normal: DVec3::Z,
minor_axis_ratio: 0.5,
};
let c2 = TangentObject::Circle {
center: DVec3::new(30.0, 0.0, 0.0),
radius: 10.0,
};
let cands = ttr_candidates(e1, c2, 20.0);
assert!(!cands.is_empty(), "ttr_candidates with ellipse + circle should find solutions");
for c in &cands {
let dist_to_circle_center = (c.truncate() - DVec2::new(30.0, 0.0)).length();
assert!(
(dist_to_circle_center - (20.0 + 10.0)).abs() < 1e-3
|| (dist_to_circle_center - 10.0).abs() < 1e-3,
"candidate center {c:?} must be tangent to circle"
);
}
// Test reverse order: circle first, ellipse second
let cands_rev = ttr_candidates(c2, e1, 20.0);
assert_eq!(cands.len(), cands_rev.len());
}
#[test]
fn ttr_ellipse_and_line_test() {
let e = TangentObject::Ellipse {
center: DVec3::ZERO,
major_axis: DVec3::new(20.0, 0.0, 0.0),
normal: DVec3::Z,
minor_axis_ratio: 0.5, // b = 10.0, bottom vertex is (0, -10, 0)
};
let line = TangentObject::Line {
p1: DVec3::new(-50.0, -20.0, 0.0),
p2: DVec3::new(50.0, -20.0, 0.0),
};
// Gap at bottom vertex (0, -10) to y = -20 is 10. Radius = 5 fits with center (0, -15, 0).
let cands = ttr_candidates(e, line, 5.0);
assert!(!cands.is_empty(), "ttr_candidates with ellipse + line should find solutions");
assert!(
cands.iter().any(|c| (c.x.abs() < 1e-3) && ((c.y - (-15.0)).abs() < 1e-3)),
"should contain bottom vertex solution (0, -15, 0), got: {cands:?}"
);
// Reverse order
let cands_rev = ttr_candidates(line, e, 5.0);
assert!(!cands_rev.is_empty());
}
#[test]
fn ttr_ellipse_and_ellipse_test() {
let e1 = TangentObject::Ellipse {
center: DVec3::new(-25.0, 0.0, 0.0),
major_axis: DVec3::new(15.0, 0.0, 0.0),
normal: DVec3::Z,
minor_axis_ratio: 0.6,
};
let e2 = TangentObject::Ellipse {
center: DVec3::new(25.0, 0.0, 0.0),
major_axis: DVec3::new(15.0, 0.0, 0.0),
normal: DVec3::Z,
minor_axis_ratio: 0.6,
};
// Gap between right vertex of e1 (-10, 0) and left vertex of e2 (10, 0) is 20.
// A circle with radius 10 fits at (0, 0, 0).
let cands_10 = ttr_candidates(e1, e2, 10.0);
assert!(!cands_10.is_empty(), "ttr_candidates with ellipse + ellipse should find solutions for R=10");
assert!(
cands_10.iter().any(|c| c.length() < 1e-2),
"should contain center solution at approx (0,0,0), got: {cands_10:?}"
);
// Radius 15 should yield symmetric solutions above and below x-axis
let cands_15 = ttr_candidates(e1, e2, 15.0);
assert!(!cands_15.is_empty(), "ttr_candidates with ellipse + ellipse should find solutions for R=15");
assert!(cands_15.iter().any(|c| c.y > 1.0));
assert!(cands_15.iter().any(|c| c.y < -1.0));
}
#[test]
fn ttr_large_radius_stays_on_cursor_side_acute_angle() {
let l1 = TangentObject::Line {
p1: DVec3::ZERO,
p2: DVec3::new(100.0, 0.0, 0.0),
};
let ang = 30.0f64.to_radians();
let l2 = TangentObject::Line {
p1: DVec3::ZERO,
p2: DVec3::new(100.0 * ang.cos(), 100.0 * ang.sin(), 0.0),
};
let hit1 = DVec3::new(20.0, 0.0, 0.0);
let hit2 = DVec3::new(20.0 * ang.cos(), 20.0 * ang.sin(), 0.0);
// Test across a wide range of radii from small to very large
for &r in &[5.0, 10.0, 30.0, 50.0, 100.0, 500.0, 2000.0] {
// Cursor in the acute 30-degree wedge (x > 0, y > 0)
let cursor = DVec3::new(r * 0.8, r * 0.2, 0.0);
let center = pick_best_ttr_candidate(l1, l2, r, hit1, hit2, Some(cursor))
.expect("should find candidate");
assert!(
center.x > 0.0 && center.y > 0.0,
"center should stay in acute wedge (x > 0, y > 0) for r={r}, but got {center:?}"
);
// Also test when cursor is None (using pick points): should also stay in acute wedge
let center_no_cur = pick_best_ttr_candidate(l1, l2, r, hit1, hit2, None)
.expect("should find candidate without cursor");
assert!(
center_no_cur.x > 0.0 && center_no_cur.y > 0.0,
"center without cursor should stay in acute wedge for r={r}, but got {center_no_cur:?}"
);
// If cursor intentionally moves into obtuse 150-degree wedge (x < 0, y > 0)
let cursor_obtuse = DVec3::new(-r * 0.5, r * 0.5, 0.0);
let center_obtuse = pick_best_ttr_candidate(l1, l2, r, hit1, hit2, Some(cursor_obtuse))
.expect("should find candidate");
assert!(
center_obtuse.x < 0.0 && center_obtuse.y > 0.0,
"center should follow cursor into obtuse wedge (x < 0, y > 0) for r={r}, got {center_obtuse:?}"
);
}
}
#[test]
fn ttr_two_circles_large_radius_stays_on_cursor_side() {
let c1 = TangentObject::Circle {
center: DVec3::new(-20.0, 0.0, 0.0),
radius: 10.0,
};
let c2 = TangentObject::Circle {
center: DVec3::new(20.0, 0.0, 0.0),
radius: 10.0,
};
let hit1 = DVec3::new(-20.0, 10.0, 0.0);
let hit2 = DVec3::new(20.0, 10.0, 0.0);
for &r in &[25.0, 35.0, 50.0, 100.0, 500.0, 2000.0] {
// Cursor above both circles
let cursor = DVec3::new(0.0, r + 10.0, 0.0);
let center = pick_best_ttr_candidate(c1, c2, r, hit1, hit2, Some(cursor))
.expect("should find candidate");
assert!(
center.y > 10.0,
"circle should stay externally tangent above circles for r={r}, but got {center:?}"
);
}
}
}
+410 -90
View File
@@ -13,7 +13,7 @@ use crate::t;
use crate::command::{CadCommand, CmdResult, TangentObject};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use glam::DVec3;
use glam::{DVec2, DVec3};
// ── Ribbon definition ─────────────────────────────────────────────────────
@@ -102,46 +102,41 @@ impl CadCommand for LineCommand {
pt: DVec3,
tangent: Option<TangentObject>,
) -> Option<CmdResult> {
use TangentObject::Circle;
match (self.points.last().copied(), self.deferred_tangent, tangent) {
// (A) The FIRST point is tangent to a circle: defer it. The tangent
// point depends on the line's direction, which the next pick gives.
// (A) The FIRST point is tangent to a circle or ellipse: defer it.
// The tangent point depends on the line's direction, which the next pick gives.
// Store a provisional point so the rubber band has an origin.
(None, _, Some(Circle { center, radius })) => {
(None, _, Some(target @ (TangentObject::Circle { .. } | TangentObject::Ellipse { .. }))) => {
self.points.push(pt);
self.deferred_tangent = Some((Circle { center, radius }, pt));
self.deferred_tangent = Some((target, pt));
Some(CmdResult::NeedPoint)
}
// (B) A deferred first tangent AND this point is also tangent to a
// circle: the line is the common tangent of the two circles. Pick
// the one of the (up to four) whose endpoints sit nearest the two
// cursor hits, so the user chooses inner/outer + side by where they
// hover.
// (B) A deferred first tangent AND this point is also tangent to a curve (circle or ellipse):
// The line is the common tangent of the two curves.
(
Some(_),
Some((Circle { center: c1, radius: r1 }, hit1)),
Some(Circle { center: c2, radius: r2 }),
Some((target1, hit1)),
Some(target2 @ (TangentObject::Circle { .. } | TangentObject::Ellipse { .. })),
) => {
self.deferred_tangent = None;
let (t1, t2) = circle_circle_tangents(c1, r1, c2, r2)
let (t1, t2) = common_curve_tangents(target1, target2)
.into_iter()
.min_by(|a, b| {
let da = (a.0 - hit1).length() + (a.1 - pt).length();
let db = (b.0 - hit1).length() + (b.1 - pt).length();
let da = (a.0 - hit1).length_squared() + (a.1 - pt).length_squared();
let db = (b.0 - hit1).length_squared() + (b.1 - pt).length_squared();
da.total_cmp(&db)
})
.unwrap_or((self.points[0], pt));
self.points = vec![t1, t2];
Some(CmdResult::CommitEntity(Self::line_between(t1, t2)))
}
// (C) A deferred first tangent, but this point is NOT a circle
// tangent: the line runs from this point, tangent to the deferred
// circle (the nearer of the two tangent points to the first hit).
(Some(_), Some((Circle { center: c1, radius: r1 }, hit1)), _) => {
// (C) A deferred first tangent, but this point is NOT a curve tangent pick:
// The line runs from the tangent point on the deferred curve to this point.
(Some(_), Some((target1, hit1)), _) => {
self.deferred_tangent = None;
let start = point_circle_tangents(pt, c1, r1)
let start = point_target_tangents(pt, target1)
.map(|(t0, t1)| {
if (t0 - hit1).length() <= (t1 - hit1).length() {
if (t0 - hit1).length_squared() <= (t1 - hit1).length_squared() {
t0
} else {
t1
@@ -151,9 +146,23 @@ impl CadCommand for LineCommand {
self.points = vec![start, pt];
Some(CmdResult::CommitEntity(Self::line_between(start, pt)))
}
// No deferred tangent (or a Line tangent, which needs no special
// handling — a line is tangent to itself everywhere): fall back to
// the plain point path.
// (D) First point was already placed (ordinary point or previous segment end),
// and this pick IS tangent to a circle or ellipse:
(Some(last), None, Some(target @ (TangentObject::Circle { .. } | TangentObject::Ellipse { .. }))) => {
let end = point_target_tangents(last, target)
.map(|(t0, t1)| {
if (t0 - pt).length_squared() <= (t1 - pt).length_squared() {
t0
} else {
t1
}
})
.unwrap_or(pt);
let line = Self::line_between(last, end);
self.points.push(end);
Some(CmdResult::CommitEntity(line))
}
// No deferred tangent (or a Line tangent): fall back to the plain point path.
_ => None,
}
}
@@ -171,6 +180,7 @@ impl CadCommand for LineCommand {
}
fn on_escape(&mut self) -> CmdResult {
self.deferred_tangent = None;
CmdResult::Cancel
}
@@ -212,6 +222,7 @@ impl CadCommand for LineCommand {
// Only the start is placed (nothing committed yet) — clear
// it so the next pick restarts the line.
self.points.clear();
self.deferred_tangent = None;
Some(CmdResult::NeedPoint)
} else {
Some(CmdResult::NeedPoint)
@@ -232,90 +243,262 @@ impl CadCommand for LineCommand {
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
let last = *self.points.last()?;
// With a deferred first tangent, slide the rubber band's start point
// around the circle so it stays tangent toward the moving cursor,
// instead of sticking at the provisional pick point. Keep the side the
// user first picked (nearest that hit) for a stable preview. (#274)
let start = match self.deferred_tangent {
Some((TangentObject::Circle { center, radius }, hit)) => {
point_circle_tangents(pt, center, radius)
self.on_preview_wires_with_tangent(pt, None).into_iter().next()
}
fn on_preview_wires_with_tangent(
&mut self,
pt: DVec3,
tangent: Option<TangentObject>,
) -> Vec<WireModel> {
let last = match self.points.last() {
Some(&p) => p,
None => return Vec::new(),
};
let (start, end) = match (self.deferred_tangent, tangent) {
(Some((target1, hit1)), Some(target2 @ (TangentObject::Circle { .. } | TangentObject::Ellipse { .. }))) => {
common_curve_tangents(target1, target2)
.into_iter()
.min_by(|a, b| {
let da = (a.0 - hit1).length_squared() + (a.1 - pt).length_squared();
let db = (b.0 - hit1).length_squared() + (b.1 - pt).length_squared();
da.total_cmp(&db)
})
.unwrap_or((last, pt))
}
(Some((target1, hit1)), _) => {
let start = point_target_tangents(pt, target1)
.map(|(t0, t1)| {
if (t0 - hit).length() <= (t1 - hit).length() {
if (t0 - hit1).length_squared() <= (t1 - hit1).length_squared() {
t0
} else {
t1
}
})
.unwrap_or(last)
.unwrap_or(last);
(start, pt)
}
_ => last,
(None, Some(target @ (TangentObject::Circle { .. } | TangentObject::Ellipse { .. }))) => {
let end = point_target_tangents(last, target)
.map(|(t0, t1)| {
if (t0 - pt).length_squared() <= (t1 - pt).length_squared() {
t0
} else {
t1
}
})
.unwrap_or(pt);
(last, end)
}
_ => (last, pt),
};
Some(WireModel::solid_f64(
vec![WireModel::solid_f64(
"rubber_band".to_string(),
vec![[start.x, start.y, start.z], [pt.x, pt.y, pt.z]],
vec![[start.x, start.y, start.z], [end.x, end.y, end.z]],
WireModel::CYAN,
false,
))
)]
}
}
// ── Tangent geometry (f64) ────────────────────────────────────────────────
/// The two points on a circle (`center`, `radius`, XY plane) where a line from
/// external point `p` touches tangentially, or `None` when `p` is inside/on the
/// circle. Each tangent point's radius is perpendicular to the line from `p`,
/// so it sits at the half-angle `acos(radius / |center→p|)` either side of the
/// center→p direction. (#274)
fn point_circle_tangents(p: DVec3, center: DVec3, radius: f64) -> Option<(DVec3, DVec3)> {
let vx = p.x - center.x;
let vy = p.y - center.y;
let d = (vx * vx + vy * vy).sqrt();
if d <= radius + 1e-9 {
return None;
}
let base = vy.atan2(vx);
let off = (radius / d).acos();
let at =
|a: f64| DVec3::new(center.x + radius * a.cos(), center.y + radius * a.sin(), center.z);
Some((at(base + off), at(base - off)))
#[derive(Clone, Copy, Debug)]
struct Curve3D {
center: DVec3,
axis_a: DVec3,
axis_b: DVec3,
}
/// Up to four common tangent lines of two circles (XY plane), each returned as
/// (tangent point on circle 1, tangent point on circle 2): two external (both
/// radii point the same way) and two internal (opposite ways, present only when
/// the circles are separated). For a unit normal `n` to the tangent line,
/// `n·(c2−c1)` equals `r2−r1` (external) or `−(r1+r2)` (internal); each tangent
/// point is its centre offset by `∓r·n`. (#274)
impl Curve3D {
fn from_tangent_object(obj: TangentObject) -> Option<Self> {
match obj {
TangentObject::Circle { center, radius } => {
if radius < 1e-9 {
return None;
}
Some(Self {
center,
axis_a: DVec3::new(radius, 0.0, 0.0),
axis_b: DVec3::new(0.0, radius, 0.0),
})
}
TangentObject::Ellipse {
center,
major_axis,
normal,
minor_axis_ratio,
} => {
let a = major_axis.length();
if a < 1e-9 {
return None;
}
let mut n = normal.normalize_or_zero();
if n.length_squared() < 0.5 {
n = DVec3::Z;
}
let u = major_axis / a;
let v = n.cross(u);
let b = a * minor_axis_ratio;
Some(Self {
center,
axis_a: major_axis,
axis_b: v * b,
})
}
TangentObject::Line { .. } => None,
}
}
}
/// The two points on a curve (Circle or Ellipse) where a line from external point `p`
/// touches tangentially, or `None` when `p` is inside or on the curve.
fn point_target_tangents(p: DVec3, target: TangentObject) -> Option<(DVec3, DVec3)> {
let curve = Curve3D::from_tangent_object(target)?;
let d = p - curve.center;
let a = curve.axis_a.length();
let b = curve.axis_b.length();
if a < 1e-9 || b < 1e-9 {
return None;
}
let u_dir = curve.axis_a / a;
let v_dir = curve.axis_b / b;
let u0 = d.dot(u_dir);
let v0 = d.dot(v_dir);
let u_norm = u0 / a;
let v_norm = v0 / b;
let dist = (u_norm * u_norm + v_norm * v_norm).sqrt();
if dist <= 1.0 + 1e-9 {
return None;
}
let theta0 = v_norm.atan2(u_norm);
let delta = (1.0 / dist).acos();
let t0 = theta0 + delta;
let t1 = theta0 - delta;
let pt0 = curve.center + curve.axis_a * t0.cos() + curve.axis_b * t0.sin();
let pt1 = curve.center + curve.axis_a * t1.cos() + curve.axis_b * t1.sin();
Some((pt0, pt1))
}
#[cfg(test)]
fn point_circle_tangents(p: DVec3, center: DVec3, radius: f64) -> Option<(DVec3, DVec3)> {
point_target_tangents(p, TangentObject::Circle { center, radius })
}
/// Common tangent lines between any two curves (Circle-Circle, Circle-Ellipse, Ellipse-Ellipse).
/// Each tangent is returned as (tangent point on curve 1, tangent point on curve 2).
fn common_curve_tangents(target1: TangentObject, target2: TangentObject) -> Vec<(DVec3, DVec3)> {
let c1 = match Curve3D::from_tangent_object(target1) {
Some(c) => c,
None => return Vec::new(),
};
let c2 = match Curve3D::from_tangent_object(target2) {
Some(c) => c,
None => return Vec::new(),
};
let c1_2d = DVec2::new(c1.center.x, c1.center.y);
let a1_2d = DVec2::new(c1.axis_a.x, c1.axis_a.y);
let b1_2d = DVec2::new(c1.axis_b.x, c1.axis_b.y);
let c2_2d = DVec2::new(c2.center.x, c2.center.y);
let a2_2d = DVec2::new(c2.axis_a.x, c2.axis_a.y);
let b2_2d = DVec2::new(c2.axis_b.x, c2.axis_b.y);
let delta_c = c2_2d - c1_2d;
if delta_c.length_squared() < 1e-9 {
return Vec::new();
}
let eval = |theta: f64, is_int: bool| -> (f64, DVec2, DVec2) {
let n = DVec2::new(theta.cos(), theta.sin());
let su1 = n.dot(a1_2d);
let sv1 = n.dot(b1_2d);
let r1 = (su1 * su1 + sv1 * sv1).sqrt().max(1e-9);
let su2 = n.dot(a2_2d);
let sv2 = n.dot(b2_2d);
let r2 = (su2 * su2 + sv2 * sv2).sqrt().max(1e-9);
let f = if is_int {
n.dot(delta_c) - r2 - r1
} else {
n.dot(delta_c) + r2 - r1
};
let t1 = c1_2d + (a1_2d * su1 + b1_2d * sv1) / r1;
let t2 = if is_int {
c2_2d - (a2_2d * su2 + b2_2d * sv2) / r2
} else {
c2_2d + (a2_2d * su2 + b2_2d * sv2) / r2
};
(f, t1, t2)
};
let mut results = Vec::new();
let n_steps = 72;
let dt = std::f64::consts::TAU / (n_steps as f64);
for &is_int in &[false, true] {
for i in 0..n_steps {
let t_a = i as f64 * dt;
let t_b = (i + 1) as f64 * dt;
let (fa, _, _) = eval(t_a, is_int);
let (fb, _, _) = eval(t_b, is_int);
let mut root = None;
if fa.abs() < 1e-12 {
root = Some(t_a);
} else if fa.signum() != fb.signum() {
let mut lo = t_a;
let mut hi = t_b;
let mut f_lo = fa;
let mut best_t = 0.5 * (lo + hi);
for _ in 0..28 {
let mid = 0.5 * (lo + hi);
let (fm, _, _) = eval(mid, is_int);
best_t = mid;
if fm.abs() < 1e-13 {
break;
}
if fm.signum() == f_lo.signum() {
lo = mid;
f_lo = fm;
} else {
hi = mid;
}
}
root = Some(best_t);
}
if let Some(r_angle) = root {
let (f_final, t1_2d, t2_2d) = eval(r_angle, is_int);
if f_final.abs() < 1e-6 {
let t1 = DVec3::new(t1_2d.x, t1_2d.y, c1.center.z);
let t2 = DVec3::new(t2_2d.x, t2_2d.y, c2.center.z);
if !results.iter().any(|(p1, p2): &(DVec3, DVec3)| {
(*p1 - t1).length_squared() < 1e-6 && (*p2 - t2).length_squared() < 1e-6
}) {
results.push((t1, t2));
}
}
}
}
}
results
}
#[cfg(test)]
fn circle_circle_tangents(c1: DVec3, r1: f64, c2: DVec3, r2: f64) -> Vec<(DVec3, DVec3)> {
let dx = c2.x - c1.x;
let dy = c2.y - c1.y;
let dist = (dx * dx + dy * dy).sqrt();
if dist < 1e-9 {
return Vec::new(); // concentric: no common tangent
}
let (ux, uy) = (dx / dist, dy / dist); // centre→centre unit
let (px, py) = (-uy, ux); // its perpendicular
let mut out = Vec::new();
for (num, internal) in [(r2 - r1, false), (-(r1 + r2), true)] {
let k = num / dist;
if k.abs() > 1.0 {
continue; // circles too close for this tangent family
}
let s = (1.0 - k * k).max(0.0).sqrt();
for pm in [1.0_f64, -1.0] {
let nx = k * ux + pm * s * px;
let ny = k * uy + pm * s * py;
let t1 = DVec3::new(c1.x - r1 * nx, c1.y - r1 * ny, c1.z);
let t2 = if internal {
DVec3::new(c2.x + r2 * nx, c2.y + r2 * ny, c2.z)
} else {
DVec3::new(c2.x - r2 * nx, c2.y - r2 * ny, c2.z)
};
out.push((t1, t2));
}
}
out
common_curve_tangents(
TangentObject::Circle { center: c1, radius: r1 },
TangentObject::Circle { center: c2, radius: r2 },
)
}
#[cfg(test)]
@@ -360,6 +543,143 @@ mod tangent_tests {
let tans = circle_circle_tangents(DVec3::ZERO, 2.0, DVec3::new(1.5, 0.0, 0.0), 2.0);
assert_eq!(tans.len(), 2, "overlap → the 2 internal tangents vanish");
}
#[test]
fn point_ellipse_tangents_are_tangent_to_ellipse() {
let e = TangentObject::Ellipse {
center: DVec3::ZERO,
major_axis: DVec3::new(20.0, 0.0, 0.0),
normal: DVec3::Z,
minor_axis_ratio: 0.5, // minor radius = 10
};
let p = DVec3::new(20.0, 20.0, 0.0);
let (t0, t1) = point_target_tangents(p, e).expect("point is outside ellipse");
for t in [t0, t1] {
// Must lie on ellipse (x/20)^2 + (y/10)^2 = 1
let on_ell = ((t.x / 20.0).powi(2) + (t.y / 10.0).powi(2) - 1.0).abs();
assert!(on_ell < 1e-6, "point {t:?} not on ellipse: {on_ell}");
// The line (p - t) must be perpendicular to the ellipse normal at t.
// For (x/a)^2 + (y/b)^2 = 1, gradient/normal is (x/a^2, y/b^2).
let grad = DVec3::new(t.x / (20.0 * 20.0), t.y / (10.0 * 10.0), 0.0);
let line_dir = p - t;
let dot = grad.dot(line_dir);
assert!(dot.abs() < 1e-6, "line is not tangent to ellipse: dot={dot}");
}
// A point inside the ellipse has no tangents
assert!(point_target_tangents(DVec3::new(5.0, 5.0, 0.0), e).is_none());
}
#[test]
fn separated_ellipse_and_circle_have_four_common_tangents() {
let e1 = TangentObject::Ellipse {
center: DVec3::new(-30.0, 0.0, 0.0),
major_axis: DVec3::new(20.0, 0.0, 0.0),
normal: DVec3::Z,
minor_axis_ratio: 0.5,
};
let c2 = TangentObject::Circle {
center: DVec3::new(30.0, 0.0, 0.0),
radius: 10.0,
};
let tans = common_curve_tangents(e1, c2);
assert_eq!(tans.len(), 4, "separated ellipse and circle should have 4 common tangents");
for (t1, t2) in &tans {
// t1 on ellipse
let dx1 = t1.x - (-30.0);
let dy1 = t1.y;
let on_e1 = ((dx1 / 20.0).powi(2) + (dy1 / 10.0).powi(2) - 1.0).abs();
assert!(on_e1 < 1e-4, "t1 not on ellipse: {on_e1}");
// t2 on circle
let on_c2 = ((*t2 - DVec3::new(30.0, 0.0, 0.0)).length() - 10.0).abs();
assert!(on_c2 < 1e-4, "t2 not on circle: {on_c2}");
// Line t1->t2 is tangent to ellipse at t1
let grad_e = DVec3::new(dx1 / 400.0, dy1 / 100.0, 0.0);
let line_dir = *t2 - *t1;
assert!(grad_e.dot(line_dir).abs() < 1e-4, "line not tangent to ellipse");
// Line t1->t2 is tangent to circle at t2 (perpendicular to radius)
let rad_c = *t2 - DVec3::new(30.0, 0.0, 0.0);
assert!(rad_c.dot(line_dir).abs() < 1e-4, "line not tangent to circle");
}
}
#[test]
fn separated_two_ellipses_have_four_common_tangents() {
let e1 = TangentObject::Ellipse {
center: DVec3::new(-35.0, 0.0, 0.0),
major_axis: DVec3::new(20.0, 0.0, 0.0),
normal: DVec3::Z,
minor_axis_ratio: 0.5,
};
let e2 = TangentObject::Ellipse {
center: DVec3::new(35.0, 0.0, 0.0),
major_axis: DVec3::new(15.0, 0.0, 0.0),
normal: DVec3::Z,
minor_axis_ratio: 0.6,
};
let tans = common_curve_tangents(e1, e2);
assert_eq!(tans.len(), 4, "separated two ellipses should have 4 common tangents");
for (t1, t2) in &tans {
let dx1 = t1.x - (-35.0);
let dy1 = t1.y;
let on_e1 = ((dx1 / 20.0).powi(2) + (dy1 / 10.0).powi(2) - 1.0).abs();
assert!(on_e1 < 1e-4, "t1 not on ellipse 1: {on_e1}");
let dx2 = t2.x - 35.0;
let dy2 = t2.y;
let on_e2 = ((dx2 / 15.0).powi(2) + (dy2 / 9.0).powi(2) - 1.0).abs();
assert!(on_e2 < 1e-4, "t2 not on ellipse 2: {on_e2}");
let line_dir = *t2 - *t1;
let grad1 = DVec3::new(dx1 / 400.0, dy1 / 100.0, 0.0);
assert!(grad1.dot(line_dir).abs() < 1e-4, "line not tangent to ellipse 1");
let grad2 = DVec3::new(dx2 / 225.0, dy2 / 81.0, 0.0);
assert!(grad2.dot(line_dir).abs() < 1e-4, "line not tangent to ellipse 2");
}
}
#[test]
fn line_command_tangent_to_ellipse_workflow() {
let mut cmd = LineCommand::new();
let e1 = TangentObject::Ellipse {
center: DVec3::new(-30.0, 0.0, 0.0),
major_axis: DVec3::new(20.0, 0.0, 0.0),
normal: DVec3::Z,
minor_axis_ratio: 0.5,
};
let c2 = TangentObject::Circle {
center: DVec3::new(30.0, 0.0, 0.0),
radius: 10.0,
};
// Pick 1: Tangent on Ellipse 1 (top vertex area)
let r1 = cmd.on_point_with_tangent(DVec3::new(-30.0, 10.0, 0.0), Some(e1));
assert!(matches!(r1, Some(CmdResult::NeedPoint)));
// Live preview with tangent on circle 2
let preview = cmd.on_preview_wires_with_tangent(DVec3::new(30.0, 10.0, 0.0), Some(c2));
assert_eq!(preview.len(), 1);
// Pick 2: Tangent on Circle 2
let r2 = cmd.on_point_with_tangent(DVec3::new(30.0, 10.0, 0.0), Some(c2));
assert!(matches!(r2, Some(CmdResult::CommitEntity(_))));
// Start a new line: Pick 1 as normal point, Pick 2 as tangent to ellipse
let mut cmd2 = LineCommand::new();
cmd2.on_point(DVec3::new(0.0, 30.0, 0.0));
let r_second = cmd2.on_point_with_tangent(DVec3::new(-30.0, 10.0, 0.0), Some(e1));
assert!(matches!(r_second, Some(CmdResult::CommitEntity(_))));
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod apollonius;
pub mod arc;
pub mod attdef;
pub mod centerline;
+1 -1
View File
@@ -15,7 +15,7 @@ use kernel::geom2d::{self, Ellipse};
/// Re-exported unchanged: these already speak in plain `f64`, so there is no
/// call-shape difference for this module to absorb.
pub use kernel::geom2d::{arc_parameter, lerp, normalize_angle};
pub use kernel::geom2d::{arc_parameter, ellipse_closest_parameter, lerp, normalize_angle};
/// Preview geometry keeps the density the commands have always used.
const SEGMENTS_PER_RADIAN: f64 = geom2d::DEFAULT_SEGMENTS_PER_RADIAN;
+741 -48
View File
@@ -14,8 +14,8 @@ use std::f64::consts::TAU;
// its call shapes to the loose scalars and f32 render vertices used here.
use super::geom;
use super::geom::{
arc_parameter as arc_t, arc_points as arc_pts, ellipse_points as ellipse_pts,
lerp as lerp2, normalize_angle as norm,
arc_parameter as arc_t, arc_points as arc_pts, ellipse_closest_parameter,
ellipse_points as ellipse_pts, lerp as lerp2, normalize_angle as norm,
};
use crate::modules::draw::fence::{crossing_box_preview, FencePick};
@@ -148,6 +148,37 @@ impl Geo {
| Self::Spline { handle, .. } => *handle,
}
}
fn endpoints(&self) -> Vec<[f64; 2]> {
match self {
Self::Line { p1, p2, .. } => vec![*p1, *p2],
Self::Arc { cx, cy, r, a0, a1, .. } => vec![
[cx + r * a0.cos(), cy + r * a0.sin()],
[cx + r * a1.cos(), cy + r * a1.sin()],
],
Self::Ray { bx, by, .. } => vec![[*bx, *by]],
Self::Ellipse { cx, cy, a, b, nx, ny, t0, t1, .. } => {
if (t1 - t0).abs() < TAU - 1e-6 {
let pt_at = |t: f64| {
let ct = t.cos();
let st = t.sin();
[
cx + nx * a * ct - ny * b * st,
cy + ny * a * ct + nx * b * st,
]
};
vec![pt_at(*t0), pt_at(*t1)]
} else {
vec![]
}
}
Self::Spline { curve, .. } => vec![
curve.point_at(0.0),
curve.point_at(1.0),
],
Self::Circle { .. } | Self::InfLine { .. } => vec![],
}
}
}
pub fn is_trim_boundary_entity(entity: &EntityType) -> bool {
matches!(
@@ -355,8 +386,98 @@ fn cut_params(target: &Curve, handle: Handle, geos: &[Geo]) -> Vec<f64> {
.collect::<Vec<_>>()
})
.collect();
// ── Endpoint contact detection ──
// When a boundary entity terminates on (or within snap tolerance of) the
// target curve, register that contact point as a cut parameter.
const ENDPOINT_TOLERANCE: f64 = CUT_TOLERANCE;
let mut contacts = Vec::new();
for geo in geos.iter().filter(|g| geo_handle(g) != handle) {
// 1. Boundary endpoints touching target
for pt in geo.endpoints() {
let t = target.parameter_at(pt);
let valid_t = if bounded {
let slack = CUT_TOLERANCE;
if (-slack..=1.0 + slack).contains(&t) {
Some(t.clamp(0.0, 1.0))
} else {
None
}
} else {
Some(t)
};
if let Some(t_cand) = valid_t {
let proj = target.point_at(t_cand);
let dx = pt[0] - proj[0];
let dy = pt[1] - proj[1];
if (dx * dx + dy * dy) <= ENDPOINT_TOLERANCE * ENDPOINT_TOLERANCE {
contacts.push(t_cand);
}
}
}
// 2. Target endpoints touching boundary
if bounded && !target.is_closed() {
if let Some(boundary_curve) = geo_to_curve(geo) {
let boundary_bounded = matches!(boundary_curve.extent(), KernelExtent::Bounded);
for &t_end in &[0.0, 1.0] {
let pt = target.point_at(t_end);
let u = boundary_curve.parameter_at(pt);
let valid_u = if boundary_bounded {
let slack = CUT_TOLERANCE;
if (-slack..=1.0 + slack).contains(&u) {
Some(u.clamp(0.0, 1.0))
} else {
None
}
} else {
Some(u)
};
if let Some(u_cand) = valid_u {
let proj = boundary_curve.point_at(u_cand);
let dx = pt[0] - proj[0];
let dy = pt[1] - proj[1];
if (dx * dx + dy * dy) <= ENDPOINT_TOLERANCE * ENDPOINT_TOLERANCE {
contacts.push(t_end);
}
}
}
}
}
}
// Prioritize exact endpoint contact parameters over raw ray intersections,
// ensuring trimmed endpoints match boundary endpoints down to machine precision.
let is_closed = target.is_closed();
for t_contact in contacts {
if let Some(pos) = ts.iter().position(|&t| {
let diff = (t - t_contact).abs();
let cyclic = if is_closed { diff.min(1.0 - diff) } else { diff };
cyclic < 1e-6
}) {
ts[pos] = t_contact;
} else {
ts.push(t_contact);
}
}
ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
ts.dedup_by(|a, b| {
let diff = (*a - *b).abs();
let cyclic = if is_closed { diff.min(1.0 - diff) } else { diff };
cyclic < 1e-6
});
// For closed curves, ensure the first and last cuts don't duplicate across the 0/1 seam
if is_closed && ts.len() >= 2 {
let seam_diff = (ts[ts.len() - 1] - (ts[0] + 1.0)).abs();
if seam_diff < 1e-6 {
ts.pop();
}
}
ts
}
@@ -517,7 +638,7 @@ fn trim_ellipse(orig: &EllipseEnt, ts: &[f64], t_click: f64) -> Vec<EntityType>
} else {
ts[0] + 1.0
};
let holds = |t: f64| t >= from - 1e-9 && t <= to + 1e-9;
let holds = |t: f64| t >= from && t < to;
(holds(click) || holds(click + 1.0)).then_some((from, to))
});
let Some((from, to)) = gap else {
@@ -853,9 +974,7 @@ fn trim_circle(orig: &CircleEnt, ts: &[f64], t_click: f64) -> Vec<EntityType> {
for i in 0..n {
let ta = ts[i];
let tb = if i + 1 < n { ts[i + 1] } else { ts[0] + 1.0 };
if (tc >= ta - 1e-9 && tc <= tb + 1e-9)
|| (tc + 1.0 >= ta - 1e-9 && tc + 1.0 <= tb + 1e-9)
{
if (tc >= ta && tc < tb) || (tc + 1.0 >= ta && tc + 1.0 < tb) {
removed = Some((ta, tb));
break;
}
@@ -1037,12 +1156,24 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option<
let b = seg_bulge(i);
let (d, u) = if let Some(ba) = BulgeArc::from_bulge(p0, p1, b) {
let angle = (cy - ba.center[1]).atan2(cx - ba.center[0]);
let travelled = if ba.sweep >= 0.0 {
(angle - ba.start_angle).rem_euclid(TAU)
let (travelled, sweep) = if ba.sweep >= 0.0 {
((angle - ba.start_angle).rem_euclid(TAU), ba.sweep)
} else {
-((ba.start_angle - angle).rem_euclid(TAU))
((ba.start_angle - angle).rem_euclid(TAU), -ba.sweep)
};
let u = if sweep < 1e-12 {
0.0
} else if travelled <= sweep {
(travelled / sweep).clamp(0.0, 1.0)
} else {
let gap = TAU - sweep;
let past_end = travelled - sweep;
if past_end <= gap * 0.5 {
1.0
} else {
0.0
}
};
let u = (travelled / ba.sweep).clamp(0.0, 1.0);
let pt = ba.sample(u);
let dist2 = (pt[0] - cx).powi(2) + (pt[1] - cy).powi(2);
(dist2, u)
@@ -1070,17 +1201,19 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option<
if cuts.len() < 2 {
return None;
}
let hi = cuts
.iter()
.cloned()
.find(|&c| c > t_click + 1e-9)
.unwrap_or(cuts[0] + total);
let lo = cuts
.iter()
.cloned()
.rev()
.find(|&c| c < t_click - 1e-9)
.unwrap_or(cuts[cuts.len() - 1] - total);
let n = cuts.len();
let mut removed = None;
for i in 0..n {
let a = cuts[i];
let b = if i + 1 < n { cuts[i + 1] } else { cuts[0] + total };
if (t_click >= a && t_click < b) || (t_click + total >= a && t_click + total < b) {
removed = Some((a, b));
break;
}
}
let Some((lo, hi)) = removed else {
return None;
};
let mut s1 = lo;
while s1 <= hi {
s1 += total;
@@ -1089,14 +1222,29 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option<
out.push(EntityType::LwPolyline(piece));
}
} else {
let lo = cuts.iter().cloned().rev().find(|&c| c < t_click - 1e-9);
let hi = cuts.iter().cloned().find(|&c| c > t_click + 1e-9);
if let Some(lo) = lo {
let mut bounds = vec![0.0];
bounds.extend(cuts.iter().copied());
bounds.push(total);
bounds.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
let n = bounds.len();
let mut removed = None;
for i in 0..n - 1 {
let a = bounds[i];
let b = bounds[i + 1];
if (t_click >= a && t_click < b) || (i + 2 == n && t_click <= b) {
removed = Some((a, b));
break;
}
}
let Some((lo, hi)) = removed else {
return None;
};
if lo > 1e-6 {
if let Some(piece) = extract_sub_polyline(poly, 0.0, lo) {
out.push(EntityType::LwPolyline(piece));
}
}
if let Some(hi) = hi {
if hi < total - 1e-6 {
if let Some(piece) = extract_sub_polyline(poly, hi, total) {
out.push(EntityType::LwPolyline(piece));
}
@@ -1699,6 +1847,9 @@ fn pick_trim_at(
return None;
}
let b = a * e.minor_axis_ratio;
if b < 1e-9 {
return None;
}
let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a);
let t0 = e.start_parameter;
let mut t1 = e.end_parameter;
@@ -1716,7 +1867,7 @@ fn pick_trim_at(
let ry = py - e.center.y;
let xl = rx * nx + ry * ny;
let yl = -rx * ny + ry * nx;
let t_ell = yl.atan2(xl);
let t_ell = ellipse_closest_parameter(a, b, xl, yl);
let t_click = arc_t(t_ell, t0, t1);
Some(trim_ellipse(e, &ts, t_click))
}
@@ -1781,12 +1932,17 @@ fn pick_extend_at(
if a < 1e-9 {
return None;
}
let b = a * e.minor_axis_ratio;
if b < 1e-9 {
return None;
}
let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a);
let rx = px - e.center.x;
let ry = py - e.center.y;
let xl = rx * nx + ry * ny;
let yl = -rx * ny + ry * nx;
let t_click = arc_t(yl.atan2(xl), t0, t1);
let t_ell = ellipse_closest_parameter(a, b, xl, yl);
let t_click = arc_t(t_ell, t0, t1);
let _ = span;
extend_ellipse(e, t_click, geos)
}
@@ -2841,6 +2997,9 @@ impl CadCommand for TrimCommand {
return vec![];
}
let b = a * e.minor_axis_ratio;
if b < 1e-9 {
return vec![];
}
let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a);
let t0 = e.start_parameter;
let mut t1 = e.end_parameter;
@@ -2857,7 +3016,8 @@ impl CadCommand for TrimCommand {
let ry = pt.y as f64 - e.center.y;
let xl = rx * nx + ry * ny;
let yl = -rx * ny + ry * nx;
let t_click = arc_t(yl.atan2(xl), t0, t1);
let t_ell = ellipse_closest_parameter(a, b, xl, yl);
let t_click = arc_t(t_ell, t0, t1);
let survivors = trim_ellipse(e, &ts, t_click);
let orig_pts =
ellipse_pts(e.center.x, e.center.y, a, b, nx, ny, t0, t1, e.center.z);
@@ -3343,25 +3503,29 @@ impl CadCommand for ExtendCommand {
Some(EntityType::Ellipse(e)) => {
let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt();
if a >= 1e-9 {
let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a);
let t0 = e.start_parameter;
let mut t1 = e.end_parameter;
if t1 <= t0 {
t1 += TAU;
}
let rx = pt.x as f64 - e.center.x;
let ry = pt.y as f64 - e.center.y;
let xl = rx * nx + ry * ny;
let yl = -rx * ny + ry * nx;
let t_click = arc_t(yl.atan2(xl), t0, t1);
if let Some(ext) = extend_ellipse(e, t_click, &self.geos) {
return extend_hover_wires(
&EntityType::Ellipse(e.clone()),
&ext,
&self.all_entities,
handle,
self.implied_edges,
);
let b = a * e.minor_axis_ratio;
if b >= 1e-9 {
let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a);
let t0 = e.start_parameter;
let mut t1 = e.end_parameter;
if t1 <= t0 {
t1 += TAU;
}
let rx = pt.x as f64 - e.center.x;
let ry = pt.y as f64 - e.center.y;
let xl = rx * nx + ry * ny;
let yl = -rx * ny + ry * nx;
let t_ell = ellipse_closest_parameter(a, b, xl, yl);
let t_click = arc_t(t_ell, t0, t1);
if let Some(ext) = extend_ellipse(e, t_click, &self.geos) {
return extend_hover_wires(
&EntityType::Ellipse(e.clone()),
&ext,
&self.all_entities,
handle,
self.implied_edges,
);
}
}
}
}
@@ -3876,6 +4040,535 @@ mod tests {
"edge-on arc segments are skipped instead of panicking: {pts:?}"
);
}
#[test]
fn trim_circle_with_tangent_arcs_touching_at_endpoints() {
use codec::entities::{Arc as ArcEnt, Circle as CircleEnt};
let mut c = CircleEnt::new();
c.common.handle = Handle::new(1);
c.center = Vector3::new(0.0, 0.0, 0.0);
c.radius = 10.0;
// Arc 1: center (20, 0), r = 10, from PI/2 to PI (endpoint at (10, 0))
let mut a1 = ArcEnt::new();
a1.common.handle = Handle::new(2);
a1.center = Vector3::new(20.0, 0.0, 0.0);
a1.radius = 10.0;
a1.start_angle = std::f64::consts::FRAC_PI_2;
a1.end_angle = std::f64::consts::PI;
// Arc 2: center (-20, 0), r = 10, from 0 to PI/2 (endpoint at (-10, 0))
let mut a2 = ArcEnt::new();
a2.common.handle = Handle::new(3);
a2.center = Vector3::new(-20.0, 0.0, 0.0);
a2.radius = 10.0;
a2.start_angle = 0.0;
a2.end_angle = std::f64::consts::FRAC_PI_2;
let all = vec![
EntityType::Circle(c),
EntityType::Arc(a1),
EntityType::Arc(a2),
];
let geos = build_geos(&all);
// Click on top of the circle at (0, 10)
let survivors = pick_trim_at(&all, &geos, Handle::new(1), 0.0, 10.0);
assert!(survivors.is_some(), "trimming circle between two tangent arc endpoints must succeed");
let pieces = survivors.unwrap();
assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece");
if let EntityType::Arc(arc) = &pieces[0] {
assert!((arc.radius - 10.0).abs() < 1e-6);
let span = {
let s = (arc.end_angle - arc.start_angle).rem_euclid(TAU);
if s == 0.0 { TAU } else { s }
};
let mid_ang = arc.start_angle + span * 0.5;
let mid_y = mid_ang.sin();
assert!(mid_y < 0.0, "bottom half should survive when clicking top half");
} else {
panic!("expected Arc survivor");
}
}
#[test]
fn test_trim_circle_between_two_tangent_lines() {
use codec::entities::{Circle as CircleEnt, Line as LineEnt};
let mut c = CircleEnt::new();
c.common.handle = Handle::new(1);
c.center = Vector3::new(0.0, 0.0, 0.0);
c.radius = 10.0;
// Line 1: tangent at top (0, 10), from (-20, 10) to (20, 10)
let mut l1 = LineEnt::new();
l1.common.handle = Handle::new(2);
l1.start = Vector3::new(-20.0, 10.0, 0.0);
l1.end = Vector3::new(20.0, 10.0, 0.0);
// Line 2: tangent at bottom (0, -10), from (-20, -10) to (20, -10)
let mut l2 = LineEnt::new();
l2.common.handle = Handle::new(3);
l2.start = Vector3::new(-20.0, -10.0, 0.0);
l2.end = Vector3::new(20.0, -10.0, 0.0);
let all = vec![
EntityType::Circle(c),
EntityType::Line(l1),
EntityType::Line(l2),
];
let geos = build_geos(&all);
// Trim circle by clicking right side at (10, 0)
let survivors = pick_trim_at(&all, &geos, Handle::new(1), 10.0, 0.0);
assert!(survivors.is_some(), "trimming circle between two tangent lines must succeed");
let pieces = survivors.unwrap();
assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece");
let EntityType::Arc(arc) = &pieces[0] else { panic!("expected Arc") };
let arc_start = glam::DVec3::new(
arc.radius * arc.start_angle.cos(),
arc.radius * arc.start_angle.sin(),
0.0,
);
let arc_end = glam::DVec3::new(
arc.radius * arc.end_angle.cos(),
arc.radius * arc.end_angle.sin(),
0.0,
);
println!("arc start: {:?}, end: {:?}", arc_start, arc_end);
// Now trim line 1 by clicking at (10, 10)
let l1_survivors = pick_trim_at(&all, &geos, Handle::new(2), 10.0, 10.0);
assert!(l1_survivors.is_some(), "trimming line 1 must succeed");
let l1_pieces = l1_survivors.unwrap();
assert_eq!(l1_pieces.len(), 1);
let EntityType::Line(trimmed_l1) = &l1_pieces[0] else { panic!("expected Line") };
println!("trimmed line 1 start: {:?}, end: {:?}", trimmed_l1.start, trimmed_l1.end);
// Check distance between trimmed line 1 end and arc start
let line_end = glam::DVec3::new(trimmed_l1.end.x, trimmed_l1.end.y, trimmed_l1.end.z);
let diff = (line_end - arc_start).length();
println!("Distance between line endpoint and arc endpoint: {:e}", diff);
// Now test an angled / non-axis-aligned case from external point
let c_center = glam::DVec3::new(123.45, 67.89, 0.0);
let c_radius = 25.0;
let p_ext1 = glam::DVec3::new(200.0, 150.0, 0.0);
let p_ext2 = glam::DVec3::new(50.0, -20.0, 0.0);
let get_tangents = |p: glam::DVec3| {
let k_curve = kernel::geom2d::Curve::Circle(kernel::geom2d::Circle {
centre: [c_center.x, c_center.y],
radius: c_radius,
});
let pts = kernel::geom2d::tangent_from(&k_curve, [p.x, p.y]);
(glam::DVec3::new(pts[0].point[0], pts[0].point[1], 0.0), glam::DVec3::new(pts[1].point[0], pts[1].point[1], 0.0))
};
let t1 = get_tangents(p_ext1).0;
let t2 = get_tangents(p_ext2).1;
let mut c_rot = CircleEnt::new();
c_rot.common.handle = Handle::new(10);
c_rot.center = Vector3::new(c_center.x, c_center.y, c_center.z);
c_rot.radius = c_radius;
// Line 1 from p_ext1 extending PAST t1 to make it a boundary line crossing tangent
let dir1 = (t1 - p_ext1).normalize();
let mut l_rot1 = LineEnt::new();
l_rot1.common.handle = Handle::new(11);
l_rot1.start = Vector3::new(p_ext1.x, p_ext1.y, 0.0);
l_rot1.end = Vector3::new((t1 + dir1 * 20.0).x, (t1 + dir1 * 20.0).y, 0.0);
let dir2 = (t2 - p_ext2).normalize();
let mut l_rot2 = LineEnt::new();
l_rot2.common.handle = Handle::new(12);
l_rot2.start = Vector3::new(p_ext2.x, p_ext2.y, 0.0);
l_rot2.end = Vector3::new((t2 + dir2 * 20.0).x, (t2 + dir2 * 20.0).y, 0.0);
let all_rot = vec![
EntityType::Circle(c_rot.clone()),
EntityType::Line(l_rot1.clone()),
EntityType::Line(l_rot2.clone()),
];
let geos_rot = build_geos(&all_rot);
// Click circle between them
let mid_click = c_center + (t1 + t2 - 2.0 * c_center).normalize() * c_radius;
let rot_survivors = pick_trim_at(&all_rot, &geos_rot, Handle::new(10), mid_click.x, mid_click.y);
assert!(rot_survivors.is_some(), "trimming circle between angled tangent lines must succeed");
let pieces = rot_survivors.unwrap();
assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece");
let EntityType::Arc(a) = &pieces[0] else { panic!("expected Arc") };
let a_start = c_center + glam::DVec3::new(a.radius * a.start_angle.cos(), a.radius * a.start_angle.sin(), 0.0);
let a_end = c_center + glam::DVec3::new(a.radius * a.end_angle.cos(), a.radius * a.end_angle.sin(), 0.0);
let err_t1 = (a_start - t1).length().min((a_end - t1).length());
let err_t2 = (a_start - t2).length().min((a_end - t2).length());
assert!(err_t1 < 1e-9, "cut point must land within grip tolerance of t1: got {err_t1:e}");
assert!(err_t2 < 1e-9, "cut point must land within grip tolerance of t2: got {err_t2:e}");
}
#[test]
fn trim_ellipse_with_tangent_arcs_touching_at_endpoints() {
use codec::entities::{Arc as ArcEnt, Ellipse as EllipseEnt};
let mut e = EllipseEnt::new();
e.common.handle = Handle::new(10);
e.center = Vector3::new(0.0, 0.0, 0.0);
e.major_axis = Vector3::new(20.0, 0.0, 0.0);
e.minor_axis_ratio = 0.5; // b = 10
e.start_parameter = 0.0;
e.end_parameter = std::f64::consts::TAU;
// Arc 1 touches right vertex at (20, 0): center (30, 0), r = 10, from PI/2 to PI
let mut a1 = ArcEnt::new();
a1.common.handle = Handle::new(11);
a1.center = Vector3::new(30.0, 0.0, 0.0);
a1.radius = 10.0;
a1.start_angle = std::f64::consts::FRAC_PI_2;
a1.end_angle = std::f64::consts::PI;
// Arc 2 touches left vertex at (-20, 0): center (-30, 0), r = 10, from 0 to PI/2
let mut a2 = ArcEnt::new();
a2.common.handle = Handle::new(12);
a2.center = Vector3::new(-30.0, 0.0, 0.0);
a2.radius = 10.0;
a2.start_angle = 0.0;
a2.end_angle = std::f64::consts::FRAC_PI_2;
let all = vec![
EntityType::Ellipse(e),
EntityType::Arc(a1),
EntityType::Arc(a2),
];
let geos = build_geos(&all);
// Click top of ellipse at (0, 10)
let survivors = pick_trim_at(&all, &geos, Handle::new(10), 0.0, 10.0);
assert!(survivors.is_some(), "trimming ellipse between two tangent arc endpoints must succeed");
let pieces = survivors.unwrap();
assert_eq!(pieces.len(), 1, "expected 1 surviving ellipse piece");
if let EntityType::Ellipse(ell) = &pieces[0] {
assert!((ell.major_axis.x - 20.0).abs() < 1e-6);
} else {
panic!("expected Ellipse survivor");
}
}
#[test]
fn trim_ellipse_selection_tracking_near_breakpoint() {
use codec::entities::{Ellipse as EllipseEnt, Line as LineEnt};
use std::f64::consts::TAU;
let mut e = EllipseEnt::new();
e.common.handle = Handle::new(10);
e.center = Vector3::new(0.0, 0.0, 0.0);
e.major_axis = Vector3::new(20.0, 0.0, 0.0);
e.minor_axis_ratio = 0.5; // a = 20, b = 10
e.start_parameter = 0.0;
e.end_parameter = TAU;
// Cutting line 1 crossing at t = PI/4 (45 degrees parametric):
// At t = PI/4: x = 20 * cos(PI/4) = 14.142, y = 10 * sin(PI/4) = 7.071
// (Polar angle is atan2(7.071, 14.142) = 26.56 degrees!)
let mut l1 = LineEnt::new();
l1.common.handle = Handle::new(11);
l1.start = Vector3::new(0.0, 0.0, 0.0);
l1.end = Vector3::new(30.0, 15.0, 0.0); // passes through (14.142, 7.071)
// Cutting line 2 crossing at t = 3*PI/4:
let mut l2 = LineEnt::new();
l2.common.handle = Handle::new(12);
l2.start = Vector3::new(0.0, 0.0, 0.0);
l2.end = Vector3::new(-30.0, 15.0, 0.0);
let all = vec![EntityType::Ellipse(e), EntityType::Line(l1), EntityType::Line(l2)];
let geos = build_geos(&all);
// Click 1: Click at t = 40 deg parametric (x = 20*cos(40°), y = 10*sin(40°))
// Polar angle is atan(0.5 * tan(40°)) ≈ 22.7°.
// This is before PI/4. Surviving piece should run between the cuts.
let t_before = 40.0_f64.to_radians();
let px_before = 20.0 * t_before.cos();
let py_before = 10.0 * t_before.sin();
let res_before = pick_trim_at(&all, &geos, Handle::new(10), px_before, py_before);
assert!(res_before.is_some(), "trimming at 40 deg must succeed");
let pieces_before = res_before.unwrap();
assert_eq!(pieces_before.len(), 1);
// Click 2: Click at t = 50 deg parametric (x = 20*cos(50°), y = 10*sin(50°))
// Polar angle is atan(0.5 * tan(50°)) ≈ 30.77°.
// Visually and parametrically, 50° is in the top segment (between PI/4 and 3*PI/4).
// Without the fix, 30.77° polar angle would be < 45° parametric, selecting the WRONG segment!
let t_after = 50.0_f64.to_radians();
let px_after = 20.0 * t_after.cos();
let py_after = 10.0 * t_after.sin();
let res_after = pick_trim_at(&all, &geos, Handle::new(10), px_after, py_after);
assert!(res_after.is_some(), "trimming at 50 deg must succeed");
let pieces_after = res_after.unwrap();
assert_eq!(pieces_after.len(), 1);
// Click 3 & 4: Click slightly INSIDE and OUTSIDE the ellipse near 50 deg.
// Even when displaced by 3 units (pick-box offset when zoomed out),
// orthogonal projection must still correctly identify the top segment!
let nx = 10.0 * t_after.cos();
let ny = 20.0 * t_after.sin();
let nlen = nx.hypot(ny);
let (unx, uny) = (nx / nlen, ny / nlen);
// Displaced outside:
let res_out = pick_trim_at(&all, &geos, Handle::new(10), px_after + 3.0 * unx, py_after + 3.0 * uny);
assert!(res_out.is_some(), "trimming at displaced outside point must succeed");
// Displaced inside:
let res_in = pick_trim_at(&all, &geos, Handle::new(10), px_after - 3.0 * unx, py_after - 3.0 * uny);
assert!(res_in.is_some(), "trimming at displaced inside point must succeed");
if let (EntityType::Ellipse(surv_after), EntityType::Ellipse(surv_out), EntityType::Ellipse(surv_in)) =
(&pieces_after[0], &res_out.unwrap()[0], &res_in.unwrap()[0])
{
assert!(
(surv_out.start_parameter - surv_after.start_parameter).abs() < 1e-4,
"displaced outside point should select the same segment as point on curve"
);
assert!(
(surv_in.start_parameter - surv_after.start_parameter).abs() < 1e-4,
"displaced inside point should select the same segment as point on curve"
);
}
}
#[test]
fn trim_arc_selection_tracking_near_start_endpoint() {
use codec::entities::{Arc as ArcEnt, Line as LineEnt};
// Arc from 0.5 to 2.0 rad (~28.6° to 114.6°), radius 10.0
let mut a = ArcEnt::new();
a.common.handle = Handle::new(20);
a.center = Vector3::new(0.0, 0.0, 0.0);
a.radius = 10.0;
a.start_angle = 0.5;
a.end_angle = 2.0;
// Boundary line crossing arc at midpoint ~ 1.25 rad
let mid = 1.25_f64;
let mut l = LineEnt::new();
l.common.handle = Handle::new(21);
l.start = Vector3::new(0.0, 0.0, 0.0);
l.end = Vector3::new(20.0 * mid.cos(), 20.0 * mid.sin(), 0.0);
let all = vec![EntityType::Arc(a), EntityType::Line(l)];
let geos = build_geos(&all);
// Hover slightly CW of start endpoint (e.g. angle = 0.49 rad, outside the arc by ~0.5 degree)
let click_ang = 0.49_f64;
let px = 10.0 * click_ang.cos();
let py = 10.0 * click_ang.sin();
let res = pick_trim_at(&all, &geos, Handle::new(20), px, py);
assert!(res.is_some(), "trim near start endpoint must succeed");
let pieces = res.unwrap();
assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece");
if let EntityType::Arc(surv) = &pieces[0] {
// It must remove the start-half [0.5, 1.25] and keep the end-half [1.25, 2.0].
// If the bug were present, it would snap to 1.0 (end-half removed, start-half kept).
assert!(
(surv.end_angle - 2.0).abs() < 0.05,
"survivor should be the end-half [1.25, 2.0], got start={} end={}",
surv.start_angle,
surv.end_angle
);
} else {
panic!("expected Arc survivor");
}
}
#[test]
fn test_tangent_circles_or_arcs_trim() {
use codec::entities::{Arc as ArcEnt, Circle as CircleEnt};
let mut c1 = CircleEnt::new();
c1.common.handle = Handle::new(1);
c1.center = Vector3::new(0.0, 0.0, 0.0);
c1.radius = 10.0;
let mut c2 = CircleEnt::new();
c2.common.handle = Handle::new(2);
c2.center = Vector3::new(20.0, 0.0, 0.0);
c2.radius = 10.0;
let all = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c2.clone())];
let geos = build_geos(&all);
let curve1 = entity_curve_xy(&EntityType::Circle(c1.clone())).unwrap();
let ts = cut_params(&curve1, Handle::new(1), &geos);
assert_eq!(ts, vec![0.0], "circle1 tangent to circle2 must have cut at t=0");
// Arc1 touching circle2 tangently
let mut a1 = ArcEnt::new();
a1.common.handle = Handle::new(3);
a1.center = Vector3::new(0.0, 0.0, 0.0);
a1.radius = 10.0;
a1.start_angle = -1.0;
a1.end_angle = 1.0;
let all_arc = vec![EntityType::Arc(a1.clone()), EntityType::Circle(c2.clone())];
let geos_arc = build_geos(&all_arc);
let curve_arc = entity_curve_xy(&EntityType::Arc(a1.clone())).unwrap();
let ts_arc = cut_params(&curve_arc, Handle::new(3), &geos_arc);
assert_eq!(ts_arc, vec![0.5], "arc1 tangent to circle2 at midpoint must have cut at t=0.5");
let trim_res = pick_trim_at(&all_arc, &geos_arc, Handle::new(3), 10.0, 5.0);
assert!(trim_res.is_some(), "trimming arc1 at tangent circle2 must succeed");
let pieces = trim_res.unwrap();
assert_eq!(pieces.len(), 1, "trimming half of arc should leave 1 survivor piece");
// 1. External tangency: Test across multiple angles and slight epsilons
for angle in [0.0f64, 0.5, 1.0, 1.57, 2.5, 3.14, 4.0, 5.0] {
for eps in [-1e-8, 0.0, 1e-8] {
let dist = 20.0 + eps;
let dx = dist * angle.cos();
let dy = dist * angle.sin();
let mut c_off = CircleEnt::new();
c_off.common.handle = Handle::new(4);
c_off.center = Vector3::new(dx, dy, 0.0);
c_off.radius = 10.0;
let all_off = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c_off)];
let geos_off = build_geos(&all_off);
let ts_off = cut_params(&curve1, Handle::new(1), &geos_off);
assert_eq!(ts_off.len(), 1, "must find exactly 1 tangency cut at angle {angle}, eps {eps}");
let expected_t = (angle / TAU).rem_euclid(1.0);
let actual_t = ts_off[0];
let diff = (actual_t - expected_t).abs();
let cyclic_diff = diff.min(1.0 - diff);
assert!(cyclic_diff < 1e-5, "tangency t must match angle: got {actual_t}, expected {expected_t}");
}
}
// 2. Internal tangency: c1 (r=10) and c_inner (r=4) at (6, 0)
let mut c_inner = CircleEnt::new();
c_inner.common.handle = Handle::new(5);
c_inner.center = Vector3::new(6.0, 0.0, 0.0);
c_inner.radius = 4.0;
let all_int = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c_inner.clone())];
let geos_int = build_geos(&all_int);
let ts_int = cut_params(&curve1, Handle::new(1), &geos_int);
assert_eq!(ts_int, vec![0.0], "internal tangency on c1 must have cut at t=0");
// And from c_inner's perspective (r=4 inside r=10):
let curve_inner = entity_curve_xy(&EntityType::Circle(c_inner.clone())).unwrap();
let ts_inner = cut_params(&curve_inner, Handle::new(5), &geos_int);
assert_eq!(ts_inner, vec![0.0], "internal tangency on c_inner must have cut at t=0");
// 3. Two arcs touching tangently at (10, 0)
// a_left: center (0, 0), radius 10, start=0, end=PI (passes through (10,0) at start t=0)
let mut a_left = ArcEnt::new();
a_left.common.handle = Handle::new(6);
a_left.center = Vector3::new(0.0, 0.0, 0.0);
a_left.radius = 10.0;
a_left.start_angle = 0.0;
a_left.end_angle = std::f64::consts::PI;
// a_right: center (20, 0), radius 10, start=PI/2, end=3*PI/2 (passes through (10,0) at angle PI, midpoint t=0.5)
let mut a_right = ArcEnt::new();
a_right.common.handle = Handle::new(7);
a_right.center = Vector3::new(20.0, 0.0, 0.0);
a_right.radius = 10.0;
a_right.start_angle = std::f64::consts::FRAC_PI_2;
a_right.end_angle = 3.0 * std::f64::consts::FRAC_PI_2;
let all_arcs = vec![EntityType::Arc(a_left.clone()), EntityType::Arc(a_right.clone())];
let geos_arcs = build_geos(&all_arcs);
let curve_right = entity_curve_xy(&EntityType::Arc(a_right.clone())).unwrap();
let ts_right = cut_params(&curve_right, Handle::new(7), &geos_arcs);
assert_eq!(ts_right, vec![0.5], "a_right must be cut at midpoint t=0.5 by tangent a_left");
let trim_right = pick_trim_at(&all_arcs, &geos_arcs, Handle::new(7), 15.0, 8.0);
assert!(trim_right.is_some(), "trimming a_right at tangent a_left must succeed");
// 4. Circle trimmed by 2 tangent circles
// c1 at (0, 0) r=10. c_right at (20, 0) r=10. c_left at (-20, 0) r=10.
let mut c_left = CircleEnt::new();
c_left.common.handle = Handle::new(8);
c_left.center = Vector3::new(-20.0, 0.0, 0.0);
c_left.radius = 10.0;
let all_3c = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c2.clone()), EntityType::Circle(c_left.clone())];
let geos_3c = build_geos(&all_3c);
let ts_3c = cut_params(&curve1, Handle::new(1), &geos_3c);
assert_eq!(ts_3c.len(), 2, "c1 touched by 2 tangent circles must have 2 cut points");
assert_eq!(ts_3c, vec![0.0, 0.5]);
let trim_3c = pick_trim_at(&all_3c, &geos_3c, Handle::new(1), 0.0, 10.0);
assert!(trim_3c.is_some(), "c1 with 2 tangent circles must be trimmable");
if let Some(surv) = trim_3c {
assert_eq!(surv.len(), 1, "trimming c1 should leave 1 arc survivor");
}
// 5. Extending an arc to meet a tangent circle
// a_short: center (0, 0), radius 10, start=0.5, end=1.5 (span ~ 1.0 rad)
// Tangent circle c2 is at (20, 0), tangent point is at (10, 0) which is angle 0.
// Clicking near start extends start CW towards 0.0.
let mut a_short = ArcEnt::new();
a_short.common.handle = Handle::new(9);
a_short.center = Vector3::new(0.0, 0.0, 0.0);
a_short.radius = 10.0;
a_short.start_angle = 0.5;
a_short.end_angle = 1.5;
let all_ext = vec![EntityType::Arc(a_short.clone()), EntityType::Circle(c2.clone())];
let geos_ext = build_geos(&all_ext);
let ext_res = pick_extend_at(&all_ext, &geos_ext, Handle::new(9), 10.0 * 0.6_f64.cos(), 10.0 * 0.6_f64.sin());
assert!(ext_res.is_some(), "extending arc towards tangent circle must succeed");
if let Some(EntityType::Arc(ext_arc)) = ext_res {
let start_norm = norm(ext_arc.start_angle);
assert!(start_norm < 1e-5 || (TAU - start_norm) < 1e-5, "extended arc start must reach 0.0 (tangent point)");
}
}
#[test]
fn test_skinny_ellipse_crossing_arc() {
use codec::entities::{Arc as ArcEnt, Ellipse as EllipseEnt};
let mut e = EllipseEnt::new();
e.common.handle = Handle::new(100);
e.center = Vector3::new(0.0, 0.0, 0.0);
e.major_axis = Vector3::new(100.0, 0.0, 0.0);
e.minor_axis_ratio = 0.05; // b = 5, very skinny!
e.start_parameter = 0.0;
e.end_parameter = std::f64::consts::TAU;
// Test arcs that physically cross the ellipse near its ends (x = 80, 90, 95, 98, 99)
for &r in &[80.0, 90.0, 95.0, 98.0, 99.0] {
let mut a = ArcEnt::new();
a.common.handle = Handle::new(101);
a.center = Vector3::new(0.0, 0.0, 0.0);
a.radius = r;
a.start_angle = -0.3;
a.end_angle = 0.3;
let all = vec![EntityType::Ellipse(e.clone()), EntityType::Arc(a)];
let geos = build_geos(&all);
// Trim the ellipse tip beyond the arc (near x = 99.5, y = 0)
let ell_res = pick_trim_at(&all, &geos, Handle::new(100), 99.9, 0.0);
// Trim the arc near y = 0 (x = r, y = 0)
let arc_res = pick_trim_at(&all, &geos, Handle::new(101), r, 0.0);
assert!(ell_res.is_some(), "ellipse trim should succeed for crossing arc at r={r}");
assert!(arc_res.is_some(), "arc trim should succeed for crossing arc at r={r}");
}
// Test off-axis arc crossing near the tip (center at (96, 20), radius 20, passing near (96, 0))
{
let mut a = ArcEnt::new();
a.common.handle = Handle::new(102);
a.center = Vector3::new(96.0, 20.0, 0.0);
a.radius = 20.0;
// Angle around -PI/2 (bottom point is (96, 0))
a.start_angle = -std::f64::consts::FRAC_PI_2 - 0.4;
a.end_angle = -std::f64::consts::FRAC_PI_2 + 0.4;
let all = vec![EntityType::Ellipse(e.clone()), EntityType::Arc(a)];
let geos = build_geos(&all);
let ell_res = pick_trim_at(&all, &geos, Handle::new(100), 99.0, 0.0);
let arc_res = pick_trim_at(&all, &geos, Handle::new(102), 96.0, 0.0);
assert!(ell_res.is_some(), "ellipse trim should succeed with off-axis crossing arc");
assert!(arc_res.is_some(), "arc trim should succeed with off-axis crossing arc");
}
}
}
// ══════════════════════════════════════════════════════════════════════════
+93
View File
@@ -0,0 +1,93 @@
// TRANSPARENCY — whether raster images show their transparent pixels as
// transparent or draw every pixel in its stored colour.
//
// Select image(s):
// Enter transparency mode [ON/OFF] <OFF>: (the default is the first
// image's current mode)
use codec::Handle;
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult};
pub struct TransparencyCommand {
handles: Vec<Handle>,
/// `None` while images are being selected; then the default mode.
default_on: Option<bool>,
}
impl TransparencyCommand {
pub fn new() -> Self {
Self { handles: Vec::new(), default_on: None }
}
/// The mode prompt for images already chosen.
pub fn mode(handles: Vec<Handle>, default_on: bool) -> Self {
Self { handles, default_on: Some(default_on) }
}
fn apply(&mut self, on: bool) -> CmdResult {
let command = if on { "TRANSPARENCY ON" } else { "TRANSPARENCY OFF" };
CmdResult::Relaunch(command.to_string(), std::mem::take(&mut self.handles))
}
}
impl CadCommand for TransparencyCommand {
fn name(&self) -> &'static str {
"TRANSPARENCY"
}
fn prompt(&self) -> String {
match self.default_on {
None => crate::t!("Select image(s):").into_owned(),
Some(true) => crate::t!("Enter transparency mode [ON/OFF] <ON>:").into_owned(),
Some(false) => crate::t!("Enter transparency mode [ON/OFF] <OFF>:").into_owned(),
}
}
fn options(&self) -> Vec<CmdOption> {
match self.default_on {
None => Vec::new(),
Some(_) => vec![CmdOption::new("ON", "ON"), CmdOption::new("OFF", "OFF")],
}
}
fn is_selection_gathering(&self) -> bool {
self.default_on.is_none()
}
fn wants_text_input(&self) -> bool {
self.default_on.is_some()
}
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
self.handles = handles;
CmdResult::NeedPoint
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let default_on = self.default_on?;
Some(match text.trim().to_uppercase().as_str() {
"" => self.apply(default_on),
"ON" => self.apply(true),
"OFF" => self.apply(false),
_ => CmdResult::ReportError("Invalid option keyword.".to_string()),
})
}
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
match self.default_on {
None if self.handles.is_empty() => CmdResult::Cancel,
// The app keeps only the images and asks for the mode.
None => CmdResult::Relaunch(
"TRANSPARENCY MODE".to_string(),
std::mem::take(&mut self.handles),
),
Some(on) => self.apply(on),
}
}
}
+1
View File
@@ -9,6 +9,7 @@ mod content_browser;
pub(crate) mod create_block;
mod design_center;
mod edit_block;
pub(crate) mod image_transparency;
pub(crate) mod insert_block;
mod landxml;
pub(crate) mod picker;
+7 -4
View File
@@ -248,10 +248,13 @@ impl CylinderCommand {
else {
return None;
};
let candidates = crate::modules::draw::draw::circle::ttr_candidates(first, second, radius);
let local = crate::modules::draw::draw::circle::best_of(
&candidates,
(first_hit + second_hit) * 0.5,
let local = crate::modules::draw::draw::circle::pick_best_ttr_candidate(
first,
second,
radius,
first_hit,
second_hit,
None,
)?;
Some(self.plane.to_world(local))
}
+41 -4
View File
@@ -272,6 +272,17 @@ fn sphere_tangent_local(object: TangentObject, plane: WorkingPlane) -> TangentOb
center: plane.to_local(center),
radius,
},
TangentObject::Ellipse {
center,
major_axis,
normal,
minor_axis_ratio,
} => TangentObject::Ellipse {
center: plane.to_local(center),
major_axis: plane.vector_to_local(major_axis),
normal: plane.vector_to_local(normal),
minor_axis_ratio,
},
}
}
@@ -285,6 +296,29 @@ fn sphere_tangent_curve(object: TangentObject) -> KernelCurve {
centre: [center.x, center.y],
radius,
}),
TangentObject::Ellipse {
center,
major_axis,
minor_axis_ratio,
..
} => {
let a = major_axis.length();
let major_axis_2d = if a > 1e-9 {
[major_axis.x / a, major_axis.y / a]
} else {
[1.0, 0.0]
};
KernelCurve::Ellipse(kernel::geom2d::EllipseArc {
ellipse: kernel::geom2d::Ellipse {
centre: [center.x, center.y],
major_radius: a,
minor_radius: a * minor_axis_ratio,
major_axis: major_axis_2d,
},
start_parameter: 0.0,
end_parameter: std::f64::consts::TAU,
})
}
}
}
@@ -757,10 +791,13 @@ impl PrimitiveCommand {
else {
return None;
};
let candidates = crate::modules::draw::draw::circle::ttr_candidates(first, second, radius);
let local = crate::modules::draw::draw::circle::best_of(
&candidates,
(first_hit + second_hit) * 0.5,
let local = crate::modules::draw::draw::circle::pick_best_ttr_candidate(
first,
second,
radius,
first_hit,
second_hit,
None,
)?;
Some(self.plane.to_world(local))
}
+8
View File
@@ -94,6 +94,9 @@ pub struct ImageModel {
/// Magnified pixels stay square (raster images); otherwise they blend
/// (PDF pages, OLE pictures).
pub pixelated: bool,
/// Pixel alpha applies. A raster image whose transparency is off draws
/// every pixel opaque in its stored colour.
pub use_alpha: bool,
}
impl ImageModel {
@@ -165,6 +168,9 @@ impl ImageModel {
draw_depth: 0.0,
verts,
pixelated: true,
use_alpha: img
.flags
.contains(codec::entities::ImageDisplayFlags::TRANSPARENCY_ON),
})
}
}
@@ -321,6 +327,7 @@ impl ImageModel {
draw_depth: 0.0,
verts,
pixelated: false,
use_alpha: true,
})
}
}
@@ -406,6 +413,7 @@ impl ImageModel {
draw_depth: 0.0,
verts,
pixelated: false,
use_alpha: true,
})
}
}
+5 -2
View File
@@ -79,7 +79,9 @@ struct ImageParams {
/// Signed draw-order depth (-1,1); applied as a clip-z bias in the shader
/// so the raster orders against other entity types. 0.0 = neutral.
draw_depth: f32,
_pad: [f32; 2],
/// 1.0 when pixel alpha applies, 0.0 when pixels draw opaque.
use_alpha: f32,
_pad: f32,
} // 16 bytes
// ── Per-image GPU handle ──────────────────────────────────────────────────
@@ -196,7 +198,8 @@ impl ImageGpu {
let params = ImageParams {
opacity: model.opacity.clamp(0.0, 1.0),
draw_depth: 0.0,
_pad: [0.0; 2],
use_alpha: if model.use_alpha { 1.0 } else { 0.0 },
_pad: 0.0,
};
let params_buf = Arc::new(super::gpu_upload::upload_buffer(
device,
+345 -10
View File
@@ -134,6 +134,182 @@ impl ttf_parser::OutlineBuilder for OutlineFlattener {
}
}
// ── Hint-reliant ("tricky") fonts ───────────────────────────────────────────
//
//
// DFKai-SB, MingLiU and other early DynaLab fonts build each ideograph from
// stroke components that only the TrueType instructions move into place, so
// the raw `glyf` outline is missing strokes. FreeType calls them "tricky".
// For those fonts we run skrifa's TrueType interpreter at a large ppem and
// scale the result back to font units; every other font keeps ttf-parser.
/// ppem the interpreter hints at: large enough that grid rounding is only
/// 1/512 em, so the hinted outline stays close to the design.
const HINT_PPEM: f32 = 512.0;
/// Identifies one face without hashing its bytes. fontdb may hand out a fresh
/// buffer on every `with_face_data`, so the slice address is not stable.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct FaceKey {
len: usize,
index: u32,
checksum_adjustment: u32,
}
/// `Some(instance)` for a font that needs the interpreter, `None` otherwise.
type HintCache = HashMap<FaceKey, Option<Arc<skrifa::outline::HintingInstance>>>;
fn hint_cache() -> &'static Mutex<HintCache> {
static CACHE: OnceLock<Mutex<HintCache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::default()))
}
/// Hinting instance for `font`, or `None` when it renders correctly unhinted.
/// `require_interpreter` reads the name table and may checksum tables, so the
/// answer (and the instance) is cached per face.
fn hinter_for(
font: &skrifa::FontRef,
key: FaceKey,
) -> Option<Arc<skrifa::outline::HintingInstance>> {
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{Engine, HintingInstance, HintingOptions, Target};
use skrifa::MetadataProvider;
if let Some(hit) = hint_cache().lock().unwrap().get(&key) {
return hit.clone();
}
let outlines = font.outline_glyphs();
let built = if outlines.require_interpreter() {
// FreeType's recipe for tricky fonts: the bytecode interpreter in
// monochrome mode.
let options = HintingOptions {
engine: Engine::Interpreter,
target: Target::Mono,
};
match HintingInstance::new(
&outlines,
Size::new(HINT_PPEM),
LocationRef::default(),
options,
) {
Ok(instance) => Some(Arc::new(instance)),
Err(e) => {
// Logged once per face (the `None` is cached): the font falls
// back to its raw outlines, which may be missing strokes.
log::warn!(
"TrueType hinting failed for a hint-reliant font; using raw outlines: {e:?}"
);
None
}
}
} else {
None
};
hint_cache().lock().unwrap().insert(key, built.clone());
built
}
/// One recorded path command, already scaled to font units.
enum PathCmd {
Move(f32, f32),
Line(f32, f32),
Quad(f32, f32, f32, f32),
Cubic(f32, f32, f32, f32, f32, f32),
Close,
}
/// skrifa pen that records hinted pixel coordinates as font units, so a draw
/// that fails halfway never leaves a partial glyph in the real builder.
struct RecordingPen {
/// Pixel → font-unit factor (`upem / HINT_PPEM`).
k: f32,
cmds: Vec<PathCmd>,
}
impl skrifa::outline::OutlinePen for RecordingPen {
fn move_to(&mut self, x: f32, y: f32) {
self.cmds.push(PathCmd::Move(x * self.k, y * self.k));
}
fn line_to(&mut self, x: f32, y: f32) {
self.cmds.push(PathCmd::Line(x * self.k, y * self.k));
}
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
let k = self.k;
self.cmds.push(PathCmd::Quad(cx * k, cy * k, x * k, y * k));
}
fn curve_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) {
let k = self.k;
self.cmds.push(PathCmd::Cubic(
c1x * k,
c1y * k,
c2x * k,
c2y * k,
x * k,
y * k,
));
}
fn close(&mut self) {
self.cmds.push(PathCmd::Close);
}
}
/// Hinted outline of `gid` in font units, or `None` when the font needs no
/// interpreter (or hinting failed) and the caller should use ttf-parser.
fn hinted_outline(data: &[u8], index: u32, gid: ttf_parser::GlyphId) -> Option<Vec<PathCmd>> {
use skrifa::outline::DrawSettings;
use skrifa::raw::TableProvider;
use skrifa::MetadataProvider;
let font = skrifa::FontRef::from_index(data, index).ok()?;
let head = font.head().ok()?;
let key = FaceKey {
len: data.len(),
index,
checksum_adjustment: head.checksum_adjustment(),
};
let hinter = hinter_for(&font, key)?;
let glyph = font
.outline_glyphs()
.get(skrifa::GlyphId::new(gid.0 as u32))?;
let mut pen = RecordingPen {
k: head.units_per_em().max(1) as f32 / HINT_PPEM,
cmds: Vec::new(),
};
glyph
.draw(DrawSettings::hinted(&hinter, false), &mut pen)
.ok()?;
Some(pen.cmds)
}
/// Outline glyph `gid` of face `index` in `data` into `builder` (font units,
/// y-up) — the drop-in replacement for `face.outline_glyph` used everywhere in
/// this file. Hint-reliant fonts go through the TrueType interpreter first.
fn outline_glyph_into(
data: &[u8],
index: u32,
face: &ttf_parser::Face,
gid: ttf_parser::GlyphId,
builder: &mut impl ttf_parser::OutlineBuilder,
) {
let Some(cmds) = hinted_outline(data, index, gid) else {
face.outline_glyph(gid, builder);
return;
};
for cmd in cmds {
match cmd {
PathCmd::Move(x, y) => builder.move_to(x, y),
PathCmd::Line(x, y) => builder.line_to(x, y),
PathCmd::Quad(cx, cy, x, y) => builder.quad_to(cx, cy, x, y),
PathCmd::Cubic(c1x, c1y, c2x, c2y, x, y) => builder.curve_to(c1x, c1y, c2x, c2y, x, y),
PathCmd::Close => builder.close(),
}
}
}
// ── Cache ────────────────────────────────────────────────────────────────────
type GlyphCache = HashMap<(String, char), Option<Arc<Glyph>>>;
@@ -160,7 +336,7 @@ pub fn glyph(family: &str, ch: char) -> Option<Arc<Glyph>> {
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
// A glyph with no outline (e.g. space) still has a valid advance.
face.outline_glyph(gid, &mut fl);
outline_glyph_into(data, index, &face, gid, &mut fl);
fl.flush();
let fill_tris = triangulate_contours(&fl.contours);
@@ -406,7 +582,7 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
/// text height — so size the substitute by its em box. Everything else keeps the
/// cap-height normalisation that lines it up with the Latin stroke glyphs.
#[cfg(not(target_arch = "wasm32"))]
fn outline_char(face: &ttf_parser::Face, ch: char) -> Option<Glyph> {
fn outline_char(data: &[u8], index: u32, face: &ttf_parser::Face, ch: char) -> Option<Glyph> {
let gid = face.glyph_index(ch)?;
let k = if is_full_width(ch) {
em_scale(face)
@@ -415,7 +591,7 @@ fn outline_char(face: &ttf_parser::Face, ch: char) -> Option<Glyph> {
};
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
face.outline_glyph(gid, &mut fl);
outline_glyph_into(data, index, face, gid, &mut fl);
fl.flush();
let fill_tris = triangulate_contours(&fl.contours);
Some(Glyph {
@@ -451,7 +627,7 @@ fn outline_from_fallback_face(ch: char) -> Option<Arc<Glyph>> {
let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0);
let font = fs.get_font(g.font_id, g.font_weight)?;
let face = ttf_parser::Face::parse(font.data(), face_index).ok()?;
let Some(glyph) = outline_char(&face, ch) else {
let Some(glyph) = outline_char(font.data(), face_index, &face, ch) else {
continue;
};
if glyph.strokes.is_empty() && glyph.fill_tris.is_empty() {
@@ -475,7 +651,7 @@ fn installed_family_glyph(ch: char) -> Option<Arc<Glyph>> {
.find_map(|family| {
let glyph = sysfont::with_face_data(family, |data, index| {
let face = ttf_parser::Face::parse(data, index).ok()?;
outline_char(&face, ch)
outline_char(data, index, &face, ch)
})
.flatten()?;
// A face that yields no outline (the system font, a space) is not a
@@ -493,15 +669,15 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
let script = crate::scene::text::web_font::script_of(ch)?;
let bytes = crate::scene::text::web_font::request(script)?;
let face_count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1);
let (face, gid) = (0..face_count).find_map(|face_index| {
let (face_index, face, gid) = (0..face_count).find_map(|face_index| {
let face = ttf_parser::Face::parse(&bytes, face_index).ok()?;
let gid = face.glyph_index(ch)?;
Some((face, gid))
Some((face_index, face, gid))
})?;
let k = cap_scale(&face);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
face.outline_glyph(gid, &mut fl);
outline_glyph_into(&bytes, face_index, &face, gid, &mut fl);
fl.flush();
if fl.contours.is_empty() {
return None;
@@ -582,7 +758,10 @@ fn build_shaped(_family: &str, text: &str) -> Option<ShapedRun> {
let pen_y = -(SHAPE_FS * glyph.y_offset) * px_to_9;
let mut flattener = OutlineFlattener::new(scale);
flattener.offset = [pen_x, pen_y];
face.outline_glyph(
outline_glyph_into(
font.data(),
face_index,
&face,
ttf_parser::GlyphId(glyph.glyph_id),
&mut flattener,
);
@@ -685,7 +864,13 @@ fn build_shaped(family: &str, text: &str) -> Option<ShapedRun> {
let mut fl = OutlineFlattener::new(scale_g);
fl.offset = [pen_x, pen_y];
face.outline_glyph(ttf_parser::GlyphId(g.glyph_id), &mut fl);
outline_glyph_into(
font.data(),
face_index,
&face,
ttf_parser::GlyphId(g.glyph_id),
&mut fl,
);
fl.flush();
if !fl.contours.is_empty() {
let fill_tris = triangulate_contours(&fl.contours);
@@ -803,4 +988,154 @@ mod tests {
}
assert!(ok, "no family produced an 'A' outline");
}
// ── Hint-reliant fonts ─────────────────────────────────
/// Bytes of a font in the Windows font folder; `None` (→ skip) elsewhere.
fn windows_font(file: &str) -> Option<Vec<u8>> {
let dir = std::env::var_os("WINDIR")?;
std::fs::read(std::path::Path::new(&dir).join("Fonts").join(file)).ok()
}
/// Contours of `ch` in font units (flattener scale 1), either straight from
/// ttf-parser or through `outline_glyph_into`.
fn contours(data: &[u8], index: u32, ch: char, new_path: bool) -> Vec<Vec<[f32; 2]>> {
let face = ttf_parser::Face::parse(data, index).expect("face");
let gid = face.glyph_index(ch).expect("glyph");
let mut fl = OutlineFlattener::new(1.0);
if new_path {
outline_glyph_into(data, index, &face, gid, &mut fl);
} else {
face.outline_glyph(gid, &mut fl);
}
fl.flush();
fl.contours
}
fn require_interpreter(data: &[u8], index: u32) -> bool {
use skrifa::MetadataProvider;
skrifa::FontRef::from_index(data, index)
.expect("font")
.outline_glyphs()
.require_interpreter()
}
#[test]
fn tricky_font_detection_kaiu_yes_msjh_no() {
// DFKai-SB is on FreeType's / skrifa's tricky list; Microsoft JhengHei
// is not. The cached decision (`hinted_outline`) must agree.
if let Some(kaiu) = windows_font("kaiu.ttf") {
assert!(
require_interpreter(&kaiu, 0),
"kaiu.ttf must need the interpreter"
);
let gid = ttf_parser::Face::parse(&kaiu, 0)
.unwrap()
.glyph_index('自')
.unwrap();
assert!(
hinted_outline(&kaiu, 0, gid).is_some(),
"kaiu must take the hinted path"
);
} else {
eprintln!("kaiu.ttf not installed; skipping");
}
if let Some(msjh) = windows_font("msjh.ttc") {
assert!(
!require_interpreter(&msjh, 0),
"msjh.ttc must not need the interpreter"
);
let gid = ttf_parser::Face::parse(&msjh, 0)
.unwrap()
.glyph_index('自')
.unwrap();
assert!(
hinted_outline(&msjh, 0, gid).is_none(),
"msjh must keep ttf-parser"
);
} else {
eprintln!("msjh.ttc not installed; skipping");
}
if let Some(mingliu) = windows_font("mingliu.ttc") {
// MingLiU / PMingLiU are on the same list (informational coverage).
assert!(
require_interpreter(&mingliu, 0),
"mingliu.ttc must need the interpreter"
);
}
}
#[test]
fn kaiu_zi_goes_through_the_interpreter_and_stays_in_the_em() {
// Raw DFKai-SB outlines drop strokes ('自' renders as '目'); the hinted
// path must produce a different outline, in font units (not 512-px
// pixels), filling most of the em square.
let Some(kaiu) = windows_font("kaiu.ttf") else {
eprintln!("kaiu.ttf not installed; skipping");
return;
};
let raw = contours(&kaiu, 0, '自', false);
let hinted = contours(&kaiu, 0, '自', true);
assert!(!hinted.is_empty(), "hinted '自' has no contours");
assert_ne!(raw, hinted, "hinted '自' must differ from the raw outline");
let face = ttf_parser::Face::parse(&kaiu, 0).unwrap();
let upem = face.units_per_em() as f32;
let (asc, desc) = (face.ascender() as f32, face.descender() as f32);
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for p in hinted.iter().flatten() {
x0 = x0.min(p[0]);
y0 = y0.min(p[1]);
x1 = x1.max(p[0]);
y1 = y1.max(p[1]);
}
eprintln!(
"kaiu '自': upem={upem} asc={asc} desc={desc} raw contours={} hinted contours={} bbox=({x0},{y0})-({x1},{y1})",
raw.len(),
hinted.len()
);
let tol = 0.02 * upem;
assert!(
x0 >= -tol && x1 <= upem + tol,
"x outside the em: {x0}..{x1}"
);
assert!(
y0 >= desc - tol && y1 <= asc + tol,
"y outside the em: {y0}..{y1}"
);
// A forgotten pixel → font-unit conversion would halve the ideograph
// (512 px vs 1024 upem). '自' is narrow (~0.47 em) but ~0.8 em tall.
assert!(y1 - y0 > 0.6 * upem, "ideograph too short: {}", y1 - y0);
assert!(x1 - x0 > 0.3 * upem, "ideograph too narrow: {}", x1 - x0);
// The public entry point must use the hinted path too.
if let Some(g) = glyph("DFKai-SB", '自') {
let gid = face.glyph_index('自').unwrap();
let mut fl = OutlineFlattener::new(cap_scale(&face));
face.outline_glyph(gid, &mut fl);
fl.flush();
assert_ne!(
g.strokes, fl.contours,
"glyph() still returns the raw outline"
);
} else {
eprintln!("DFKai-SB family not resolvable via sysfont; public-path check skipped");
}
}
#[test]
fn non_tricky_font_outlines_are_unchanged() {
// Fonts that don't need the interpreter keep the exact ttf-parser outline.
let Some(msjh) = windows_font("msjh.ttc") else {
eprintln!("msjh.ttc not installed; skipping");
return;
};
for ch in "自保持接觸器線圈AgQ1".chars() {
assert_eq!(
contours(&msjh, 0, ch, true),
contours(&msjh, 0, ch, false),
"msjh '{ch}' changed"
);
}
}
}
+8 -2
View File
@@ -28,7 +28,7 @@ struct Uniforms {
struct ImageParams {
opacity: f32,
draw_depth: f32, // signed (-1,1) draw-order bias; 0 = neutral
_pad1: f32,
use_alpha: f32, // 1 = pixel alpha applies, 0 = pixels draw opaque
_pad2: f32,
};
@group(1) @binding(2) var<uniform> img_params: ImageParams;
@@ -63,5 +63,11 @@ fn vs_main(in: VertIn) -> VertOut {
@fragment
fn fs_main(in: VertOut) -> @location(0) vec4<f32> {
let col = textureSample(img_texture, img_sampler, in.uv);
return vec4<f32>(col.rgb, col.a * img_params.opacity);
let alpha = select(1.0, col.a, img_params.use_alpha > 0.5);
// A fully transparent pixel leaves no depth behind, so what lies under
// the image (drawn before or after it) shows through.
if (alpha * img_params.opacity <= 0.0) {
discard;
}
return vec4<f32>(col.rgb, alpha * img_params.opacity);
}
+89 -35
View File
@@ -246,7 +246,7 @@ pub struct Snapper {
/// origin), if any. Perpendicular snap drops its foot from here so the new
/// segment is genuinely perpendicular to the target — without it, perp
/// would just give the nearest point on the line. Set before each `snap`.
pub from_point: Option<Vec3>,
pub from_point: Option<glam::DVec3>,
/// Parallel snap: the acquired reference as (unit direction, a point on the
/// line). When the cursor's direction from the command's start point runs
/// parallel to this, the point locks onto that parallel line; the point half
@@ -1601,7 +1601,7 @@ impl Snapper {
// ── Perpendicular — foot of perpendicular from the drawing base ──
// Perpendicular requires a drawing base; cursor fallback acts like Nearest. (#716)
if self.is_on(SnapType::Perpendicular) {
if let Some(q) = self.from_point.map(|v| v.as_dvec3()) {
if let Some(q) = self.from_point {
if let Some(segments) = &local_segments {
for seg in segments {
if let Some(foot) = perp_foot(q, seg.a, seg.b) {
@@ -2049,15 +2049,24 @@ impl Snapper {
let dc = dist2(cursor_screen, sc).sqrt();
let edge_d = (dc - sr).abs();
let w = if let Some(from) = self.from_point {
let Some((t0, t1)) = circle_tangent_points(from, cv, r) else {
continue;
};
let s0 = world_to_screen(t0.as_dvec3(), view_rot, eye, bounds);
let s1 = world_to_screen(t1.as_dvec3(), view_rot, eye, bounds);
if dist2(s0, cursor_screen) <= dist2(s1, cursor_screen) {
t0
if let Some((t0, t1)) = circle_tangent_points(from.as_vec3(), cv, r) {
let s0 = world_to_screen(t0.as_dvec3(), view_rot, eye, bounds);
let s1 = world_to_screen(t1.as_dvec3(), view_rot, eye, bounds);
if dist2(s0, cursor_screen) <= dist2(s1, cursor_screen) {
t0
} else {
t1
}
} else {
t1
let dx = cursor_screen.x - sc.x;
let dy = cursor_screen.y - sc.y;
let dl = (dx * dx + dy * dy).sqrt();
let (nx, ny) = if dl > 1e-6 {
(dx / dl, -dy / dl)
} else {
(1.0, 0.0)
};
Vec3::new(cv.x + r * nx, cv.y + r * ny, cv.z)
}
} else {
let dx = cursor_screen.x - sc.x;
@@ -2080,26 +2089,36 @@ impl Snapper {
start_angle,
end_angle,
} => {
let Some(from) = self.from_point else {
continue;
};
let Some(world) = arc_tangent_points(
from.as_dvec3(),
*center,
*axis_x,
*axis_y,
*radius,
*start_angle,
*end_angle,
)
.into_iter()
.min_by(|a, b| {
let sa = world_to_screen(*a, view_rot, eye, bounds);
let sb = world_to_screen(*b, view_rot, eye, bounds);
dist2(sa, cursor_screen).total_cmp(&dist2(sb, cursor_screen))
}) else {
continue;
let candidate = self.from_point.and_then(|from| {
arc_tangent_points(
from,
*center,
*axis_x,
*axis_y,
*radius,
*start_angle,
*end_angle,
)
.into_iter()
.min_by(|a, b| {
let sa = world_to_screen(*a, view_rot, eye, bounds);
let sb = world_to_screen(*b, view_rot, eye, bounds);
dist2(sa, cursor_screen).total_cmp(&dist2(sb, cursor_screen))
})
});
let fallback = || {
(0..wire.points.len())
.map(|index| wp_f64(wire, index))
.filter(|point| point.is_finite())
.min_by(|a, b| {
let sa = world_to_screen(*a, view_rot, eye, bounds);
let sb = world_to_screen(*b, view_rot, eye, bounds);
dist2(sa, cursor_screen)
.total_cmp(&dist2(sb, cursor_screen))
})
.unwrap_or_else(|| DVec3::from_array(*center))
};
let world = candidate.unwrap_or_else(fallback);
let edge_d2 = wire
.points
.windows(2)
@@ -2127,7 +2146,7 @@ impl Snapper {
let cv = DVec3::from_array(*center);
let candidate = self.from_point.and_then(|from| {
planar_circle_tangent_points(
from.as_dvec3(),
from,
*center,
*axis_x,
*axis_y,
@@ -2140,9 +2159,6 @@ impl Snapper {
dist2(sa, cursor_screen).total_cmp(&dist2(sb, cursor_screen))
})
});
if self.from_point.is_some() && candidate.is_none() {
continue;
}
let fallback = || {
(0..wire.points.len())
.map(|index| wp_f64(wire, index))
@@ -2174,7 +2190,34 @@ impl Snapper {
.fold(f32::INFINITY, f32::min);
(world, edge_d2)
}
TangentGeom::PlanarEllipse { .. } => continue,
TangentGeom::PlanarEllipse {
center,
major_axis,
normal,
minor_axis_ratio,
..
} => {
let c = DVec3::from_array(*center);
let u_vec = DVec3::from_array(*major_axis);
let a = u_vec.length();
if a > 1e-9 {
let u_hat = u_vec / a;
let n_hat = DVec3::from_array(*normal).normalize_or_zero();
let v_hat = n_hat.cross(u_hat).normalize_or_zero();
let b = (a * minor_axis_ratio).max(1e-9);
let d_vec = cursor_world - c;
let u_proj = d_vec.dot(u_hat);
let v_proj = d_vec.dot(v_hat);
let t = (v_proj / b).atan2(u_proj / a);
let world = c + u_hat * (a * t.cos()) + v_hat * (b * t.sin());
let screen_pt = world_to_screen(world, view_rot, eye, bounds);
let edge_d2 = dist2(screen_pt, cursor_screen);
(world, edge_d2)
} else {
(c, f32::INFINITY)
}
}
};
let (tier, sub) = (
snap_tier(SnapType::Tangent),
@@ -2211,7 +2254,18 @@ impl Snapper {
radius: *radius,
}
}
TangentGeom::PlanarEllipse { .. } => unreachable!(),
TangentGeom::PlanarEllipse {
center,
major_axis,
normal,
minor_axis_ratio,
..
} => TangentObject::Ellipse {
center: glam::DVec3::from_array(*center),
major_axis: glam::DVec3::from_array(*major_axis),
normal: glam::DVec3::from_array(*normal),
minor_axis_ratio: *minor_axis_ratio,
},
};
best = Some(SnapResult {
world: world_pt,
+1
View File
@@ -146,6 +146,7 @@ fn image() -> PlotImage {
})
.collect(),
pixelated: true,
use_alpha: true,
},
clips: Vec::new(),
}