From 5a36ecda74d722b93b91900dcfdfe65b6c53879b Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Thu, 24 Sep 2026 14:26:25 +0800 Subject: [PATCH 01/13] fix(text): hint TrueType fonts that need the interpreter (DFKai-SB, MingLiU) before outlining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DFKai-SB (kaiu.ttf), MingLiU and other early DynaLab fonts assemble each ideograph from stroke components that only the TrueType instructions move into place. ttf-parser's `outline_glyph` returns the raw, unhinted `glyf` outline, so these fonts rendered with missing or displaced strokes ('自' drawn as '目'; 範, 直, 啟, 動 broken). FreeType calls them "tricky" and always runs the interpreter for them. Every non-test `outline_glyph` call site in ttf_glyph.rs (`glyph`, `outline_char` - shared by both native fallback paths -, the web `build_fallback`, and both `build_shaped` variants) now goes through `outline_glyph_into`; `outline_char` takes the font data and face index for that. When skrifa's `require_interpreter()` says the face is hint-reliant, the glyph is hinted with the TrueType interpreter (Target::Mono) at 512 ppem and scaled back to font units (x upem / 512); every other font keeps the exact ttf-parser outline. The decision and the HintingInstance are cached once per face, keyed by (byte length, face index, head.checkSumAdjustment) because fontdb may hand out a new buffer per call. The hinted path is recorded first and replayed only on success, so a failed draw falls back to ttf-parser without a half-drawn glyph. skrifa 0.42.1 is already in the lockfile (via hayro / glifo). Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + Cargo.toml | 4 + src/scene/text/ttf_glyph.rs | 378 +++++++++++++++++++++++++++++++++++- 3 files changed, 373 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1509149d..5d4effe97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "skrifa 0.42.1", "ttf-parser", "unicode-bidi", "ureq", diff --git a/Cargo.toml b/Cargo.toml index 6368e8499..a9534f7d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,10 @@ rustc-hash = "2" sha2 = "0.10" fontdb = "0.23" ttf-parser = "0.25" +# TrueType 指令解譯器:標楷體/細明體等「筆畫拼字」字型要先 hinting 才有正確輪廓。 +# 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" diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index 8e324b037..56fb0ce32 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -134,6 +134,197 @@ impl ttf_parser::OutlineBuilder for OutlineFlattener { } } +// ── Hint-reliant ("tricky") fonts ─────────────────────────────────────────── +// +// 標楷體(DFKai-SB)、細明體(MingLiU)等早期華康字型是「筆畫拼字」:每個字由 +// 筆畫部件組成,部件要靠 TrueType 指令才會移到正確位置。直接讀 `glyf` 原始輪廓 +// 會缺筆畫(「自」變「目」、範/直/啟/動缺筆)。FreeType 稱這類字型為 tricky。 +// 對這些字型改用 skrifa 的 TrueType 解譯器在大字級 hinting,再換回字型單位。 +// +// 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. +/// 解譯器 hinting 用的字級:夠大,格點捨入只有 1/512 em,輪廓貼近原設計。 +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. +/// 不雜湊整個字型就能辨識一個字面;fontdb 每次可能給新緩衝區,指標不可靠。 +#[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. +/// 需要解譯器的字型存 `Some(hinting 實例)`,其他存 `None`。 +type HintCache = HashMap>>; + +fn hint_cache() -> &'static Mutex { + static CACHE: OnceLock> = 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. +/// 取得該字型的 hinting 實例;不需要解譯器時回 `None`。 +/// `require_interpreter` 會讀 name 表、可能算 checksum,很慢,所以每個字面快取一次。 +fn hinter_for( + font: &skrifa::FontRef, + key: FaceKey, +) -> Option> { + 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. / FreeType 對 tricky 字型的做法:解譯器+單色模式。 + 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. +/// 把 hinting 後的像素座標換回字型單位先記下來;畫到一半失敗也不會污染真正的 builder。 +struct RecordingPen { + /// Pixel → font-unit factor (`upem / HINT_PPEM`). / 像素→字型單位係數。 + k: f32, + cmds: Vec, +} + +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. +/// 回傳字型單位的 hinting 輪廓;字型不需要解譯器(或 hinting 失敗)時回 `None`。 +fn hinted_outline(data: &[u8], index: u32, gid: ttf_parser::GlyphId) -> Option> { + 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. +/// 把字形輪廓畫進 `builder`(字型單位、y 向上),取代本檔所有 `face.outline_glyph`; +/// 筆畫拼字字型先經 TrueType 解譯器 hinting。 +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>>; @@ -160,7 +351,7 @@ pub fn glyph(family: &str, ch: char) -> Option> { 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 +597,7 @@ fn build_fallback(ch: char) -> Option> { /// 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 { +fn outline_char(data: &[u8], index: u32, face: &ttf_parser::Face, ch: char) -> Option { let gid = face.glyph_index(ch)?; let k = if is_full_width(ch) { em_scale(face) @@ -415,7 +606,7 @@ fn outline_char(face: &ttf_parser::Face, ch: char) -> Option { }; 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 +642,7 @@ fn outline_from_fallback_face(ch: char) -> Option> { 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 +666,7 @@ fn installed_family_glyph(ch: char) -> Option> { .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 +684,15 @@ fn build_fallback(ch: char) -> Option> { 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 +773,10 @@ fn build_shaped(_family: &str, text: &str) -> Option { 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 +879,13 @@ fn build_shaped(family: &str, text: &str) -> Option { 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 +1003,162 @@ 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. + /// 讀 Windows 字型資料夾的字型;沒有就回 `None`(測試跳過)。 + fn windows_font(file: &str) -> Option> { + 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`. + /// 取字型單位輪廓:直接 ttf-parser,或走 `outline_glyph_into`。 + fn contours(data: &[u8], index: u32, ch: char, new_path: bool) -> Vec> { + 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. + // 標楷體在 tricky 名單上、微軟正黑體不在;快取後的判斷也要一致。 + 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. + // 標楷體原始輪廓缺筆(自→目);新路徑輸出要不同,且是字型單位、落在 em 內。 + 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. + // 忘了像素→字型單位換算的話字會縮一半;「自」窄(約 0.47 em)但高約 0.8 em。 + 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. + // 對外的 `glyph()` 也要走 hinting 路徑。 + 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. + // 不需要解譯器的字型,輸出與 ttf-parser 完全相同。 + 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" + ); + } + } } From 29a252de81c25a579ca98cdbe1f512b89e543a4d Mon Sep 17 00:00:00 2001 From: ysdirector Date: Mon, 28 Sep 2026 00:33:27 +0800 Subject: [PATCH 02/13] fix(io): R13/R14 layers come back plottable so plots are not blank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LAYER record only grew a plot flag in R2000: R13/R14 carry frozen, off, frozen-in-new-viewport and locked as four separate bits and nothing else, so the reader has no bit to read and leaves `is_plottable` at the `false` it starts from. Every layer of such a drawing then read as "don't plot", the plot pass dropped every wire, and PLOT, the preview and QUICKPRINT all rendered a blank sheet while the drawing still looked right on screen — display and plot visibility are separate flags, so nothing about the drawing itself looked wrong. Restore what the format means by default — a layer plots unless a file says otherwise — for the two versions that carry the four-bit record, on both load entry points (bytes and path). R2000 and later keep the flag the file stores, so a genuinely unplottable layer (DEFPOINTS) stays unplottable. Tests: an R14 DWG written and read back keeps every layer plottable, the path-based entry point does the same for R13, a modern file keeps the flag it stored, and an R14 drawing tessellating to wires that are all plot_visible locks the whole chain that used to blank the page. --- src/io/mod.rs | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/src/io/mod.rs b/src/io/mod.rs index 8bafc3090..5f826c95f 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -757,6 +757,8 @@ pub fn load_bytes(name: &str, bytes: Vec) -> Result { let mut doc = DwgReader::from_stream(Cursor::new(bytes)) .read() .map_err(|e| e.to_string())?; + let version = doc.version; + fix_pre_r2000_layer_plot_flags(&mut doc, version); fix_viewport_status_flags(&mut doc); fix_current_style_names(&mut doc); Ok(doc) @@ -1027,6 +1029,10 @@ fn finalize_loaded_outcome( mut outcome: codec::ReadOutcome, ) -> Result { let doc = &mut outcome.document; + if outcome.stats.source_format == Some(codec::SourceFormat::Dwg) { + let version = doc.version; + fix_pre_r2000_layer_plot_flags(doc, version); + } normalize_block_origins(doc); normalize_knotless_splines(doc); if outcome.stats.source_format == Some(codec::SourceFormat::Dxf) { @@ -2388,6 +2394,31 @@ pub fn purge_corrupt_entities(doc: &mut CadDocument) -> usize { n } +/// An R13 / R14 drawing cannot mark a layer as "don't plot" — the LAYER +/// record only carries frozen, off, frozen-in-new-viewport and locked as four +/// separate bits. The plot flag (and the lineweight, in the same packed +/// bit-short) arrived with R2000, so the reader has no bit to read for the +/// older versions and leaves the field at the `false` it starts from. +/// +/// That default then marks every layer of such a drawing as "don't plot": +/// display and plot visibility are separate flags, so the drawing looked right +/// on screen while PLOT, the plot preview and QUICKPRINT all rendered a blank +/// page. What the older format means by default is what a freshly created +/// layer gets — `is_plottable: true` — so restore it here. +fn fix_pre_r2000_layer_plot_flags(doc: &mut CadDocument, version: codec::types::DxfVersion) { + use codec::types::DxfVersion; + // AC1012 is R13 and AC1014 is R14, the two versions with the four-bit LAYER + // record. From AC1015 (R2000) on, the file's own flag is authoritative and + // is read, so nothing is touched there. `Unknown` is left alone too: a + // version that could not be identified is the wrong thing to reason from. + if !matches!(version, DxfVersion::AC1012 | DxfVersion::AC1014) { + return; + } + for layer in doc.layers.iter_mut() { + layer.is_plottable = true; + } +} + /// opencadcodec's ViewportStatusFlags::from_bits() maps bit 0 → is_on and bit 15 → locked, /// but the real DXF/DWG spec uses bit 15 (0x8000) → viewport on and bit 14 (0x4000) → locked. /// Files from AutoCAD and other tools always set bit 15 for active viewports, leaving bit 0 @@ -2894,3 +2925,121 @@ 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 + } + + fn saved_path(version: DxfVersion) -> std::path::PathBuf { + std::env::temp_dir().join(format!("ocs_plotflag_{}.dwg", version.as_str())) + } + + /// 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(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::>() + ); + } + + /// 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(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(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(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() + ); + } +} From df2778f466d7a712977b60988a34d0fc12abfbf1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 28 Sep 2026 10:13:04 +0300 Subject: [PATCH 03/13] chore: pin opencadcodec a35f43e (R13/R14 layers plottable) Brings in bounded object storage on DWG import (#62) and R13/R14 LAYER records reading as plottable. Pins opencadcodec a35f43e, opencadkernel ee41029, opencadgraph 8b8c7f3. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 ++++---- Cargo.toml | 8 ++++---- crates/ocs_plugin_api/Cargo.toml | 4 ++-- crates/ocs_web_worker/Cargo.toml | 2 +- plugins/opencad-python/Cargo.lock | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34a35a3b0..5f688677b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4201,7 +4201,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=a35f43e#a35f43e8a42ddeec9decd759f1482a2bcfa08aa4" dependencies = [ "ahash 0.8.12", "anyhow", @@ -4224,7 +4224,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=8b8c7f3#8b8c7f3610a5f5eb7758096ce201deed14d59cc1" dependencies = [ "opencadkernel", "serde_json", @@ -4233,7 +4233,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=ee41029#ee41029c26607b26ed0004034d808b8318f941b3" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4244,7 +4244,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=ee41029#ee41029c26607b26ed0004034d808b8318f941b3" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 25d637e0a..00f709935 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "a35f43e", features = ["serde"] } +kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "ee41029", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "ee41029" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "8b8c7f3" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 50be8c52f..c220edad4 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -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 = "a35f43e", 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 = "a35f43e", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index bbe7960fc..50bc84df7 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -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 = "a35f43e", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index 3f46f7a33..d37ba7a1e 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -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=a35f43e#a35f43e8a42ddeec9decd759f1482a2bcfa08aa4" dependencies = [ "ahash 0.8.12", "anyhow", From 8c9b485b6cbc46711bba7b5acd869a51ebae408f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 28 Sep 2026 10:16:19 +0300 Subject: [PATCH 04/13] refactor(io): R13/R14 plot flags come from the codec now opencadcodec a35f43e reads R13/R14 LAYER records as plottable, the root cause #1507 worked around on load; drop the host-side correction. Its tests stay and now cover the codec fix end to end. They wrote one temp file per version and ran in parallel, so each test now gets its own. Co-Authored-By: Claude Opus 5.5 --- src/io/mod.rs | 48 +++++++++++------------------------------------- 1 file changed, 11 insertions(+), 37 deletions(-) diff --git a/src/io/mod.rs b/src/io/mod.rs index 5f826c95f..e3d248857 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -757,8 +757,6 @@ pub fn load_bytes(name: &str, bytes: Vec) -> Result { let mut doc = DwgReader::from_stream(Cursor::new(bytes)) .read() .map_err(|e| e.to_string())?; - let version = doc.version; - fix_pre_r2000_layer_plot_flags(&mut doc, version); fix_viewport_status_flags(&mut doc); fix_current_style_names(&mut doc); Ok(doc) @@ -1029,10 +1027,6 @@ fn finalize_loaded_outcome( mut outcome: codec::ReadOutcome, ) -> Result { let doc = &mut outcome.document; - if outcome.stats.source_format == Some(codec::SourceFormat::Dwg) { - let version = doc.version; - fix_pre_r2000_layer_plot_flags(doc, version); - } normalize_block_origins(doc); normalize_knotless_splines(doc); if outcome.stats.source_format == Some(codec::SourceFormat::Dxf) { @@ -2394,31 +2388,6 @@ pub fn purge_corrupt_entities(doc: &mut CadDocument) -> usize { n } -/// An R13 / R14 drawing cannot mark a layer as "don't plot" — the LAYER -/// record only carries frozen, off, frozen-in-new-viewport and locked as four -/// separate bits. The plot flag (and the lineweight, in the same packed -/// bit-short) arrived with R2000, so the reader has no bit to read for the -/// older versions and leaves the field at the `false` it starts from. -/// -/// That default then marks every layer of such a drawing as "don't plot": -/// display and plot visibility are separate flags, so the drawing looked right -/// on screen while PLOT, the plot preview and QUICKPRINT all rendered a blank -/// page. What the older format means by default is what a freshly created -/// layer gets — `is_plottable: true` — so restore it here. -fn fix_pre_r2000_layer_plot_flags(doc: &mut CadDocument, version: codec::types::DxfVersion) { - use codec::types::DxfVersion; - // AC1012 is R13 and AC1014 is R14, the two versions with the four-bit LAYER - // record. From AC1015 (R2000) on, the file's own flag is authoritative and - // is read, so nothing is touched there. `Unknown` is left alone too: a - // version that could not be identified is the wrong thing to reason from. - if !matches!(version, DxfVersion::AC1012 | DxfVersion::AC1014) { - return; - } - for layer in doc.layers.iter_mut() { - layer.is_plottable = true; - } -} - /// opencadcodec's ViewportStatusFlags::from_bits() maps bit 0 → is_on and bit 15 → locked, /// but the real DXF/DWG spec uses bit 15 (0x8000) → viewport on and bit 14 (0x4000) → locked. /// Files from AutoCAD and other tools always set bit 15 for active viewports, leaving bit 0 @@ -2943,8 +2912,13 @@ mod pre_r2000_layer_plot_tests { doc } - fn saved_path(version: DxfVersion) -> std::path::PathBuf { - std::env::temp_dir().join(format!("ocs_plotflag_{}.dwg", version.as_str())) + /// 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 @@ -2955,7 +2929,7 @@ mod pre_r2000_layer_plot_tests { #[test] fn an_r14_drawing_reads_back_with_every_layer_plottable() { let doc = drawing_with_layers(); - let path = saved_path(DxfVersion::AC1014); + 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); @@ -2981,7 +2955,7 @@ mod pre_r2000_layer_plot_tests { #[test] fn an_r13_drawing_reads_back_with_every_layer_plottable() { let doc = drawing_with_layers(); - let path = saved_path(DxfVersion::AC1012); + 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); @@ -2997,7 +2971,7 @@ mod pre_r2000_layer_plot_tests { 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(DxfVersion::AC1032); + 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); @@ -3025,7 +2999,7 @@ mod pre_r2000_layer_plot_tests { line.common.layer = layer.to_string(); doc.add_entity(EntityType::Line(line)).expect("add line"); } - let path = saved_path(DxfVersion::AC1014); + 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); From 5ca7becd97219a3a7a5da446554e5e5cd2321517 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:23:19 +0300 Subject: [PATCH 05/13] Raster images: transparency off draws pixels opaque; TRANSPARENCY command An image's transparency flag decides whether transparent pixels show what is behind the image or draw in their stored colour: the image pass and PDF plots now follow it (it was ignored, so every PNG alpha showed through). TRANSPARENCY selects images and asks for the mode, defaulting to the first image's current one. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 3 + locales/bg-BG/opencadstudio.ftl | 3 + locales/cs-CZ/opencadstudio.ftl | 3 + locales/de-DE/opencadstudio.ftl | 3 + locales/el-GR/opencadstudio.ftl | 3 + locales/en-US/opencadstudio.ftl | 3 + locales/es-ES/opencadstudio.ftl | 3 + locales/fi-FI/opencadstudio.ftl | 3 + locales/fr-FR/opencadstudio.ftl | 3 + locales/hi-IN/opencadstudio.ftl | 3 + locales/hu-HU/opencadstudio.ftl | 3 + locales/it-IT/opencadstudio.ftl | 3 + locales/ja-JP/opencadstudio.ftl | 3 + locales/ko-KR/opencadstudio.ftl | 3 + locales/nl-NL/opencadstudio.ftl | 3 + locales/pl-PL/opencadstudio.ftl | 3 + locales/pt-BR/opencadstudio.ftl | 3 + locales/ru-RU/opencadstudio.ftl | 3 + locales/tr-TR/opencadstudio.ftl | 3 + locales/zh-CN/opencadstudio.ftl | 3 + locales/zh-TW/opencadstudio.ftl | 3 + src/app/commands/display.rs | 49 +++++++++++++ src/app/commands/mod.rs | 1 + src/io/pdf_export.rs | 13 +++- src/locale_catalog.rs | 3 + src/modules/insert/image_transparency.rs | 93 ++++++++++++++++++++++++ src/modules/insert/mod.rs | 1 + src/scene/model/image_model.rs | 8 ++ src/scene/pipeline/image_gpu.rs | 7 +- src/shaders/image.wgsl | 5 +- 30 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 src/modules/insert/image_transparency.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 8b7dd9902..7a4c6b081 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -3667,6 +3667,9 @@ insert = .frames-print = الإطارات والطباعة .frames-off = إيقاف الإطارات .frames-on = تشغيل الإطارات + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT أدخل اسم الكتلة:__ocs_arg_hint__ .insert-filling-attributes = INSERT يجري ملء السمات... diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 3d88014b0..a8777696d 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -3654,6 +3654,9 @@ insert = .frames-print = Рамки и печат .frames-off = Рамки изкл. .frames-on = Рамки вкл. + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Въведи име на блок:__ocs_arg_hint__ .insert-filling-attributes = INSERT Попълване на атрибути... diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 67aaf37cf..e859bd0ad 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -3652,6 +3652,9 @@ insert = .frames-print = Rámy a tisk .frames-off = Rámy vypnuty .frames-on = Rámy zapnuty + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Název bloku Enter:__ocs_arg_hint__ .insert-filling-attributes = INSERT Vyplňování atributů... diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index f32c9ba95..1c1e2a699 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -3655,6 +3655,9 @@ insert = .frames-print = Rahmen und Drucken .frames-off = Rahmen aus .frames-on = Rahmen ein + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Blockname eingeben:__ocs_arg_hint__ .insert-filling-attributes = INSERT Attribute ausfüllen... diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 1729d534a..090f5e925 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -3662,6 +3662,9 @@ insert = .frames-print = Πλαίσια και εκτύπωση .frames-off = Πλαίσια ανενεργά .frames-on = Πλαίσια ενεργά + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Δώστε όνομα μπλοκ:__ocs_arg_hint__ .insert-filling-attributes = INSERT Συμπλήρωση χαρακτηριστικών... diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 05e319474..c53231dd3 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3656,6 +3656,9 @@ insert = .frames-print = Frames & Print .frames-off = Frames Off .frames-on = Frames On + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Enter block name:__ocs_arg_hint__ .insert-filling-attributes = INSERT Filling attributes... diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 92fd93eff..455bef6c5 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -3655,6 +3655,9 @@ insert = .frames-print = Marcos e impresión .frames-off = Marcos desactivados .frames-on = Marcos activados + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Introduzca el nombre del bloque:__ocs_arg_hint__ .insert-filling-attributes = INSERT Rellenando atributos... diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 058b1a6a5..167a404e3 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -3654,6 +3654,9 @@ insert = .frames-print = Kehykset ja painatus .frames-off = Kehykset pois päältä .frames-on = Kehykset päällä + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Enter -lohkon nimi:__ocs_arg_hint__ .insert-filling-attributes = INSERT Täytetään määritteitä... diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 7607d15b3..f89c504bc 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -3655,6 +3655,9 @@ insert = .frames-print = Cadres & Imprimer .frames-off = Cadres désactivés .frames-on = Cadres en marche + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Saisissez le nom du bloc:__ocs_arg_hint__ .insert-filling-attributes = INSERT Attributs de remplissage... diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 4929071d4..766f615dd 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3643,6 +3643,9 @@ insert = .frames-print = फ्रेम्स एंड प्रिंट .frames-off = फ़्रेम बंद .frames-on = फ्रेम्स ऑन + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT ब्लॉक का नाम दर्ज करें:__ocs_arg_hint__ .insert-filling-attributes = INSERT गुण भरना ... diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 08ce8b3d0..ff6b3f133 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -3654,6 +3654,9 @@ insert = .frames-print = Keretek és nyomtatás .frames-off = Keretek ki .frames-on = Keretek bekapcsolva + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Enter blokknév: __ocs_arg_hint__ .insert-filling-attributes = INSERT Attribútumok kitöltése... diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 1994c37d3..23b347ebe 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -3654,6 +3654,9 @@ insert = .frames-print = Cornici e stampa .frames-off = Cornici disattivate .frames-on = Cornici attivate + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Enter nome del blocco:__ocs_arg_hint__ .insert-filling-attributes = INSERT Attributi di riempimento... diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index d9daa920a..fe7568e4e 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3643,6 +3643,9 @@ insert = .frames-print = フレームと印刷 .frames-off = フレームをオフ .frames-on = フレームをオン + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT ブロック名を入力:__ocs_arg_hint__ .insert-filling-attributes = INSERT 属性を入力中... diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 8a537142a..7e3a9eaf8 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -3654,6 +3654,9 @@ insert = .frames-print = 프레임 및 인쇄 .frames-off = 프레임 끄기 .frames-on = 프레임 켜짐 + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Enter 블록 이름:__ocs_arg_hint__ .insert-filling-attributes = INSERT 채우기 속성... diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 314a83d35..47d3f4ec9 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -3653,6 +3653,9 @@ insert = .frames-print = Frames & afdrukken .frames-off = Frames uitgeschakeld .frames-on = Frames aan + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Geef symboolnaam in:__ocs_arg_hint__ .insert-filling-attributes = INSERT Attributen vullen... diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index ef384bbc5..5efb47bec 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -3654,6 +3654,9 @@ insert = .frames-print = Ramki i druk .frames-off = Ramki wyłączone .frames-on = Ramki włączone + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Enter nazwa bloku: __ocs_arg_hint__ .insert-filling-attributes = INSERT Wypełnianie atrybutów... diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index cb5ea8a3e..1936fdc7c 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -3655,6 +3655,9 @@ insert = .frames-print = Molduras e impressão .frames-off = Molduras desativadas .frames-on = Molduras ativadas + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Insira o nome do bloco:__ocs_arg_hint__ .insert-filling-attributes = INSERT Preenchendo atributos... diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 42a3d5334..f1df6f44e 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -3656,6 +3656,9 @@ insert = .frames-print = Frames & Print (недоступная ссылка) .frames-off = Снятие рам .frames-on = В кадрах + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Введите имя блока: __ocs_arg_hint__ .insert-filling-attributes = INSERT Наполнение атрибутов... diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index eb92c4bcb..27cd07e41 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3625,6 +3625,9 @@ insert = .frames-print = Çerçeveler ve Yazdırma .frames-off = Çerçeveler Kapalı .frames-on = Çerçeveler Açık + .select-images = Görüntüleri seçin: + .transparency-mode-on = Saydamlık modunu girin [ON/OFF] : + .transparency-mode-off = Saydamlık modunu girin [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Blok adını girin:__ocs_arg_hint__ .insert-filling-attributes = INSERT Öznitelikler dolduruluyor... diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 828fc7429..cc91fac16 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3640,6 +3640,9 @@ insert = .frames-print = 边框与打印 .frames-off = 边框关 .frames-on = 边框开 + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT 输入块名:__ocs_arg_hint__ .insert-filling-attributes = INSERT 正在填充属性... diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index bbf1928ca..30a164535 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -3652,6 +3652,9 @@ insert = .frames-print = 邊框與列印 .frames-off = 邊框關 .frames-on = 邊框開 + .select-images = Select image(s): + .transparency-mode-on = Enter transparency mode [ON/OFF] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT 輸入圖塊名:__ocs_arg_hint__ .insert-filling-attributes = INSERT 正在填充屬性... diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 7fddf031d..2f00501d6 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -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 = 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> diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index eb9a7a4b1..4f819ae37 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -714,6 +714,7 @@ inventory::submit!(crate::command::CommandRegistration { "IM", "IMAGE", "IMAGEATTACH", + "TRANSPARENCY", "IMAGEEMBED", "IMPORTOBJ", "ISOLATEOBJECTS", diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 7e9d44269..3f1810a68 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -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, diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 92edc4613..0b3a4a473 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1197,6 +1197,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Frames & Print" => Some(("insert", "frames-print")), "Frames Off" => Some(("insert", "frames-off")), "Frames On" => Some(("insert", "frames-on")), + "Select image(s):" => Some(("insert", "select-images")), + "Enter transparency mode [ON/OFF] :" => Some(("insert", "transparency-mode-on")), + "Enter transparency mode [ON/OFF] :" => Some(("insert", "transparency-mode-off")), "Frames On, Not Plotted" => Some(("draw", "frames-on-not-plotted")), "Freeze" => Some(("layers", "freeze")), "From DXF" => Some(("properties", "from-dxf")), diff --git a/src/modules/insert/image_transparency.rs b/src/modules/insert/image_transparency.rs new file mode 100644 index 000000000..cdf22a30c --- /dev/null +++ b/src/modules/insert/image_transparency.rs @@ -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] : (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, + /// `None` while images are being selected; then the default mode. + default_on: Option, +} + +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, 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] :").into_owned(), + Some(false) => crate::t!("Enter transparency mode [ON/OFF] :").into_owned(), + } + } + + fn options(&self) -> Vec { + 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) -> CmdResult { + self.handles = handles; + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + 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), + } + } +} diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index e157971ca..0e3393464 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -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; diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 5fbd58802..1e312100e 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -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), }) } } @@ -274,6 +280,7 @@ impl ImageModel { draw_depth: 0.0, verts, pixelated: false, + use_alpha: true, }) } } @@ -355,6 +362,7 @@ impl ImageModel { draw_depth: 0.0, verts, pixelated: false, + use_alpha: true, }) } } diff --git a/src/scene/pipeline/image_gpu.rs b/src/scene/pipeline/image_gpu.rs index 196c3f830..4a6ecd3f3 100644 --- a/src/scene/pipeline/image_gpu.rs +++ b/src/scene/pipeline/image_gpu.rs @@ -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, diff --git a/src/shaders/image.wgsl b/src/shaders/image.wgsl index ca7860aa7..5bab2a647 100644 --- a/src/shaders/image.wgsl +++ b/src/shaders/image.wgsl @@ -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 img_params: ImageParams; @@ -63,5 +63,6 @@ fn vs_main(in: VertIn) -> VertOut { @fragment fn fs_main(in: VertOut) -> @location(0) vec4 { let col = textureSample(img_texture, img_sampler, in.uv); - return vec4(col.rgb, col.a * img_params.opacity); + let alpha = select(1.0, col.a, img_params.use_alpha > 0.5); + return vec4(col.rgb, alpha * img_params.opacity); } From 92fa6610651e0d5142674bff18824fcc09514d36 Mon Sep 17 00:00:00 2001 From: yztink Date: Mon, 28 Sep 2026 01:56:03 -0700 Subject: [PATCH 06/13] feat(draw): add fluid tangent arc, circle, and line construction with ellipse support --- src/app/update/viewport.rs | 95 ++- src/app/update/viewport_snap.rs | 2 +- src/command.rs | 19 +- src/modules/draw/draw/apollonius.rs | 332 +++++++ src/modules/draw/draw/arc.rs | 1234 +++++++++++++++++++++++++-- src/modules/draw/draw/circle.rs | 599 ++++++++++++- src/modules/draw/draw/line.rs | 500 +++++++++-- src/modules/draw/draw/mod.rs | 1 + src/modules/model/cylinder_cmd.rs | 11 +- src/modules/model/primitive_cmd.rs | 45 +- src/snap.rs | 124 ++- 11 files changed, 2750 insertions(+), 212 deletions(-) create mode 100644 src/modules/draw/draw/apollonius.rs diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index bb34d867c..6c4165f77 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1415,7 +1415,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 = @@ -2135,10 +2135,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 @@ -2554,8 +2553,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 { @@ -2691,10 +2697,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 @@ -2940,7 +2953,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 @@ -3781,7 +3794,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 @@ -6995,6 +7008,70 @@ mod selection_preview_tests { ); } + #[test] + fn test_coincident_line_and_arc_grip_move() { + use codec::{types::Vector3, EntityType}; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + let pt = glam::DVec3::new(-2.8146711761917635, 12.22681563180158, 0.0); + + // Line ending at pt + let mut line = codec::entities::Line::new(); + line.start = Vector3::new(-20.0, 12.22681563180158, 0.0); + line.end = Vector3::new(pt.x, pt.y, pt.z); + let line_h = app.tabs[i].scene.add_entity(EntityType::Line(line)); + + // Arc starting at pt + let mut arc = codec::entities::Arc::new(); + arc.center = Vector3::new(0.0, 0.0, 0.0); + arc.radius = (pt.x * pt.x + pt.y * pt.y).sqrt(); + let sa = (pt.y).atan2(pt.x); + arc.start_angle = sa; + arc.end_angle = sa + 1.0; + let arc_h = app.tabs[i].scene.add_entity(EntityType::Arc(arc)); + + app.tabs[i].scene.selected.insert(line_h); + app.tabs[i].scene.selected.insert(arc_h); + app.refresh_selected_grips(); + + println!("selected grips count: {}", app.tabs[i].selected_grips.len()); + for (g_idx, g) in app.tabs[i].selected_grips.iter().enumerate() { + let h = app.tabs[i].selected_grip_handles[g_idx]; + println!("grip idx {g_idx}: handle {:?}, id {}, world {:?}", h, g.id, g.world); + } + + let bounds = iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)); + let cursor = app.tabs[i].scene.camera.borrow().project(pt, bounds).unwrap(); + app.tabs[i].scene.selection.borrow_mut().last_move_pos = + Some(iced::Point::new(cursor.x, cursor.y)); + let _ = app.on_viewport_left_press(); + + let active = app.tabs[i].active_grip.as_ref().expect("grip must engage"); + println!("active grip targets len: {}", active.targets.len()); + for t in &active.targets { + println!("target: handle {:?}, grip_id {}, last_world {:?}", t.handle, t.grip_id, t.last_world); + } + + // Drag cursor by (5.0, 5.0) + let new_target = pt + glam::DVec3::new(5.0, 5.0, 0.0); + let new_cursor = app.tabs[i].scene.camera.borrow().project(new_target, bounds).unwrap(); + let _ = app.on_viewport_move(iced::Point::new(new_cursor.x, new_cursor.y)); + + let EntityType::Line(moved_line) = app.tabs[i].scene.document.get_entity(line_h).unwrap() else { panic!() }; + let EntityType::Arc(moved_arc) = app.tabs[i].scene.document.get_entity(arc_h).unwrap() else { panic!() }; + + println!("moved line end: {:?}", moved_line.end); + let arc_start_moved = glam::DVec3::new( + moved_arc.center.x + moved_arc.radius * moved_arc.start_angle.cos(), + moved_arc.center.y + moved_arc.radius * moved_arc.start_angle.sin(), + 0.0, + ); + println!("moved arc start: {:?}", arc_start_moved); + } + #[test] fn constraint_glyph_tooltip_appears_after_hover_dwell() { use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs index 0070c54b3..c9dc920f2 100644 --- a/src/app/update/viewport_snap.rs +++ b/src/app/update/viewport_snap.rs @@ -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, diff --git a/src/command.rs b/src/command.rs index e15c4c0ac..0adeb7463 100644 --- a/src/command.rs +++ b/src/command.rs @@ -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. @@ -2728,6 +2735,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, + ) -> Vec { + 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. diff --git a/src/modules/draw/draw/apollonius.rs b/src/modules/draw/draw/apollonius.rs new file mode 100644 index 000000000..1e2688bef --- /dev/null +++ b/src/modules/draw/draw/apollonius.rs @@ -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, +} + +/// 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/modules/draw/draw/arc.rs b/src/modules/draw/draw/arc.rs index e3623b6b1..5f835fef2 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -5,10 +5,11 @@ use codec::{Arc as CadArc, EntityType}; use crate::t; use kernel::geom2d::{self, Curve as KernelCurve}; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdResult, TangentObject, WorkingPlane}; use crate::modules::IconKind; use crate::scene::model::wire_model::{TangentGeom, WireModel}; -use glam::DVec3; +use super::apollonius::{self, Circle2D}; +use glam::{DVec2, DVec3}; const TAU: f64 = std::f64::consts::TAU; @@ -272,6 +273,753 @@ fn end_angle_from_chord_len(start_angle: f64, chord: f64, r: f64) -> Option geom2d::arc_sweep_from_chord(r, chord).map(|sweep| start_angle + sweep) } +/// An input point or a fluid tangent snap constraint for arc construction. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ArcPick { + Point(DVec3), + Tangent { + target: TangentObject, + hit: DVec3, + }, +} + +impl ArcPick { + pub fn point(&self) -> DVec3 { + match *self { + ArcPick::Point(pt) => pt, + ArcPick::Tangent { hit, .. } => hit, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EllipseGeom { + pub center: DVec3, + pub major_axis: DVec3, + pub normal: DVec3, + pub minor_axis_ratio: f64, +} + +impl EllipseGeom { + pub fn new(center: DVec3, major_axis: DVec3, normal: DVec3, minor_axis_ratio: f64) -> Self { + Self { + center, + major_axis, + normal, + minor_axis_ratio, + } + } + + pub fn a(&self) -> f64 { + self.major_axis.length() + } + + pub fn b(&self) -> f64 { + (self.a() * self.minor_axis_ratio).max(1e-9) + } + + pub fn u_hat(&self) -> DVec3 { + let a = self.a(); + if a > 1e-9 { + self.major_axis / a + } else { + DVec3::X + } + } + + pub fn n_hat(&self) -> DVec3 { + self.normal.normalize_or_zero() + } + + pub fn v_hat(&self) -> DVec3 { + self.n_hat().cross(self.u_hat()).normalize_or_zero() + } + + pub fn param_at(&self, pt: DVec3) -> f64 { + let d = pt - self.center; + let u_proj = d.dot(self.u_hat()); + let v_proj = d.dot(self.v_hat()); + let a = self.a().max(1e-9); + let b = self.b(); + (v_proj / b).atan2(u_proj / a) + } + + pub fn point_at(&self, t: f64) -> DVec3 { + self.center + self.u_hat() * (self.a() * t.cos()) + self.v_hat() * (self.b() * t.sin()) + } + + pub fn normal_at(&self, t: f64) -> DVec3 { + let a = self.a(); + let b = self.b(); + (self.u_hat() * (b * t.cos()) + self.v_hat() * (a * t.sin())).normalize_or_zero() + } + + pub fn osculating_circle_at(&self, t: f64) -> (DVec3, f64) { + let a = self.a(); + let b = self.b(); + let pt = self.point_at(t); + let n = self.normal_at(t); + let num = (a * a * t.sin() * t.sin() + b * b * t.cos() * t.cos()).powf(1.5); + let rho = (num / (a * b)).clamp(1e-3, 1e7); + let osc_center = pt - n * rho; + (osc_center, rho) + } + + pub fn project_point(&self, pt: DVec3) -> DVec3 { + let t = self.param_at(pt); + self.point_at(t) + } +} + +fn fluid_anchor_point(pick: ArcPick, cursor: DVec3, plane: WorkingPlane) -> DVec3 { + match pick { + ArcPick::Point(pt) => pt, + ArcPick::Tangent { target, hit } => match target { + TangentObject::Circle { center, radius } => { + let loc_center = plane.to_local(center); + let loc_pt = plane.to_local(cursor); + let d = (DVec2::new(loc_pt.x, loc_pt.y) - DVec2::new(loc_center.x, loc_center.y)).length(); + if d > 1e-6 { + let dir = (DVec2::new(loc_pt.x, loc_pt.y) - DVec2::new(loc_center.x, loc_center.y)).normalize(); + plane.to_world(DVec3::new(loc_center.x + dir.x * radius, loc_center.y + dir.y * radius, loc_center.z)) + } else { + hit + } + } + TangentObject::Ellipse { + center, + major_axis, + normal, + minor_axis_ratio, + } => { + let ell = EllipseGeom::new(center, major_axis, normal, minor_axis_ratio); + ell.project_point(cursor) + } + _ => hit, + }, + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Ellipse2D { + pub center: DVec2, + pub major_dir: DVec2, + pub minor_dir: DVec2, + pub a: f64, + pub b: f64, +} + +impl Ellipse2D { + pub fn new(center: DVec2, major_axis: DVec2, normal_z: f64, minor_ratio: f64) -> Self { + let a = major_axis.length().max(1e-9); + let b = (a * minor_ratio).max(1e-9); + let major_dir = major_axis / a; + let sign = if normal_z < 0.0 { -1.0 } else { 1.0 }; + let minor_dir = DVec2::new(-major_dir.y, major_dir.x) * sign; + Self { + center, + major_dir, + minor_dir, + a, + b, + } + } + + pub fn point_at(&self, t: f64) -> DVec2 { + self.center + self.major_dir * (self.a * t.cos()) + self.minor_dir * (self.b * t.sin()) + } + + pub fn normal_at(&self, t: f64) -> DVec2 { + (self.major_dir * (self.b * t.cos()) + self.minor_dir * (self.a * t.sin())).normalize_or_zero() + } + + pub fn param_at(&self, pt: DVec2) -> f64 { + let d = pt - self.center; + let u_proj = d.dot(self.major_dir); + let v_proj = d.dot(self.minor_dir); + (v_proj / self.b).atan2(u_proj / self.a) + } + + pub fn project_point(&self, pt: DVec2) -> DVec2 { + let t = self.param_at(pt); + self.point_at(t) + } + + /// Returns all points on the ellipse whose normal lines pass through `center`. + /// For any such point P, (P - center) is collinear with the ellipse normal at P. + pub fn normal_points_for_center(&self, center: DVec2) -> Vec { + let d = center - self.center; + let uc = d.dot(self.major_dir); + let vc = d.dot(self.minor_dir); + let a = self.a; + let b = self.b; + let a2_b2 = a * a - b * b; + + // Normal equation: g(t) = (a^2 - b^2) * sin(t)*cos(t) - a*uc*sin(t) + b*vc*cos(t) = 0 + let g = |t: f64| -> f64 { + 0.5 * a2_b2 * (2.0 * t).sin() - a * uc * t.sin() + b * vc * t.cos() + }; + let g_prime = |t: f64| -> f64 { + a2_b2 * (2.0 * t).cos() - a * uc * t.cos() - b * vc * t.sin() + }; + + let n_slices = 16; + let dt = std::f64::consts::TAU / (n_slices as f64); + let mut roots = Vec::with_capacity(4); + + for i in 0..n_slices { + let t0 = i as f64 * dt; + let t1 = (i + 1) as f64 * dt; + let g0 = g(t0); + let g1 = g(t1); + + if g0.abs() < 1e-12 { + let norm = t0.rem_euclid(std::f64::consts::TAU); + if !roots.iter().any(|&r: &f64| (r - norm).abs() < 1e-4) { + roots.push(norm); + } + continue; + } + + if g0.signum() != g1.signum() { + let mut lo = t0; + let mut hi = t1; + for _ in 0..8 { + let mid = 0.5 * (lo + hi); + let gm = g(mid); + if gm.signum() == g0.signum() { + lo = mid; + } else { + hi = mid; + } + } + let mut root = 0.5 * (lo + hi); + for _ in 0..6 { + let val = g(root); + let der = g_prime(root); + if der.abs() > 1e-12 { + let step = val / der; + root -= step; + if step.abs() < 1e-12 { + break; + } + } + } + let norm = root.rem_euclid(std::f64::consts::TAU); + if !roots.iter().any(|&r: &f64| (r - norm).abs() < 1e-4) { + roots.push(norm); + } + } + } + + // Also check Newton iteration starting from radial guess + let t_guess = (vc / b).atan2(uc / a); + let mut root_guess = t_guess; + for _ in 0..6 { + let val = g(root_guess); + let der = g_prime(root_guess); + if der.abs() > 1e-12 { + let step = val / der; + root_guess -= step; + if step.abs() < 1e-12 { + break; + } + } + } + if g(root_guess).abs() < 1e-6 { + let norm = root_guess.rem_euclid(std::f64::consts::TAU); + if !roots.iter().any(|&r: &f64| (r - norm).abs() < 1e-4) { + roots.push(norm); + } + } + + roots.into_iter().map(|t| self.point_at(t)).collect() + } +} + +fn target_to_ellipse2d(target: TangentObject, plane: WorkingPlane) -> Option { + match target { + TangentObject::Ellipse { + center, + major_axis, + normal, + minor_axis_ratio, + } => { + let loc_center = plane.to_local(center); + let loc_major = plane.vector_to_local(major_axis); + let loc_normal = plane.vector_to_local(normal); + Some(Ellipse2D::new( + DVec2::new(loc_center.x, loc_center.y), + DVec2::new(loc_major.x, loc_major.y), + loc_normal.z, + minor_axis_ratio, + )) + } + _ => None, + } +} + +fn target_to_circle2d_pure(target: TangentObject, plane: WorkingPlane) -> Option { + match target { + TangentObject::Circle { center, radius } => { + let loc = plane.to_local(center); + Some(Circle2D { + center: DVec2::new(loc.x, loc.y), + radius, + }) + } + _ => None, + } +} + +/// Solves PPC (Point, Point, Curve) where Curve is an Ellipse. +/// Scans the full perimeter t ∈ [0, 2π) to find all valid tangent circles passing through p1 and p2, +/// and selects the one closest to `hint`. +pub fn solve_ellipse_ppc( + ell: &Ellipse2D, + p1: DVec2, + p2: DVec2, + hint: DVec2, +) -> Option { + let d_vec = p2 - p1; + let d_len = d_vec.length(); + if d_len < 1e-9 { + return None; + } + let m = (p1 + p2) * 0.5; + let n_bisect = DVec2::new(-d_vec.y, d_vec.x) / d_len; + + let eval_t = |t: f64| -> Option<(f64, DVec2, f64, DVec2)> { + let pt = ell.point_at(t); + let n_hat = ell.normal_at(t); + let delta = m - pt; + let det = n_hat.y * n_bisect.x - n_hat.x * n_bisect.y; + if det.abs() < 1e-6 { + return None; + } + let lambda = (-delta.x * n_bisect.y + delta.y * n_bisect.x) / det; + let center = pt + n_hat * lambda; + let r_p = (center - p1).length(); + let r_t = lambda.abs(); + let res = r_t - r_p; + Some((res, pt, lambda.abs(), center)) + }; + + let n_steps = 144; + let dt = std::f64::consts::TAU / (n_steps as f64); + let mut candidates = Vec::new(); + + for i in 0..n_steps { + let t_a = i as f64 * dt; + let t_b = (i + 1) as f64 * dt; + + let Some((res_a, _, _, _)) = eval_t(t_a) else { continue; }; + let Some((res_b, _, _, _)) = eval_t(t_b) else { continue; }; + + let n_hat_a = ell.normal_at(t_a); + let det_a = n_hat_a.y * n_bisect.x - n_hat_a.x * n_bisect.y; + let n_hat_b = ell.normal_at(t_b); + let det_b = n_hat_b.y * n_bisect.x - n_hat_b.x * n_bisect.y; + if det_a.signum() != det_b.signum() { + continue; + } + + if res_a.signum() != res_b.signum() { + let mut lo = t_a; + let mut hi = t_b; + let mut best_t = (lo + hi) * 0.5; + for _ in 0..16 { + let mid = (lo + hi) * 0.5; + let Some((res_m, _, _, _)) = eval_t(mid) else { break; }; + best_t = mid; + if res_m.abs() < 1e-8 { + break; + } + if res_m.signum() == res_a.signum() { + lo = mid; + } else { + hi = mid; + } + } + if let Some((res, pt, r, center)) = eval_t(best_t) { + if res.abs() < 1e-3 && r.is_finite() && r > 1e-4 { + candidates.push((pt, r, center)); + } + } + } + } + + candidates + .into_iter() + .min_by(|(pt_a, r_a, _), (pt_b, r_b, _)| { + let dist_a = (*pt_a - hint).length_squared() + if *r_a > 50000.0 { 1e8 } else { 0.0 }; + let dist_b = (*pt_b - hint).length_squared() + if *r_b > 50000.0 { 1e8 } else { 0.0 }; + dist_a.total_cmp(&dist_b) + }) + .map(|(pt, _, _)| pt) +} + +/// Solves CCP (Curve, Curve, Point) where Curve 1 is an Ellipse and Curve 2 is a Circle. +pub fn solve_ellipse_circle_ccp( + ell: &Ellipse2D, + c2: Circle2D, + p: DVec2, + hint_ell: DVec2, + hint_c2: DVec2, +) -> Option<(DVec2, DVec2)> { + let n_steps = 144; + let dt = std::f64::consts::TAU / (n_steps as f64); + let mut candidates = Vec::new(); + + let eval_t = |t: f64| -> Option<(DVec2, DVec2, f64, f64, f64)> { + let pt = ell.point_at(t); + let n_hat = ell.normal_at(t); + let w = pt - p; + let w_dot_n = w.dot(n_hat); + if w_dot_n.abs() < 1e-6 { + return None; + } + let lambda = -w.length_squared() / (2.0 * w_dot_n); + let r = lambda.abs(); + if r < 1e-4 || r > 1e7 { + return None; + } + let center = pt + n_hat * lambda; + let d2 = (center - c2.center).length(); + let res_ext = d2 - (r + c2.radius); + let res_int = d2 - (r - c2.radius).abs(); + Some((pt, center, r, res_ext, res_int)) + }; + + let mut check_mode = |is_ext: bool| { + for i in 0..n_steps { + let t_a = i as f64 * dt; + let t_b = (i + 1) as f64 * dt; + + let Some((_, _, _, res_ext_a, res_int_a)) = eval_t(t_a) else { continue; }; + let Some((_, _, _, res_ext_b, res_int_b)) = eval_t(t_b) else { continue; }; + + let pt_a = ell.point_at(t_a); + let n_hat_a = ell.normal_at(t_a); + let w_a = pt_a - p; + let pt_b = ell.point_at(t_b); + let n_hat_b = ell.normal_at(t_b); + let w_b = pt_b - p; + if (w_a.dot(n_hat_a)).signum() != (w_b.dot(n_hat_b)).signum() { + continue; + } + + let (ra, rb) = if is_ext { (res_ext_a, res_ext_b) } else { (res_int_a, res_int_b) }; + if ra.signum() != rb.signum() { + let mut lo = t_a; + let mut hi = t_b; + let mut best_t = (lo + hi) * 0.5; + for _ in 0..16 { + let mid = (lo + hi) * 0.5; + let Some((_, _, _, res_ext_m, res_int_m)) = eval_t(mid) else { break; }; + let rm = if is_ext { res_ext_m } else { res_int_m }; + best_t = mid; + if rm.abs() < 1e-8 { + break; + } + if rm.signum() == ra.signum() { + lo = mid; + } else { + hi = mid; + } + } + if let Some((pt1, center, r, res_ext_final, res_int_final)) = eval_t(best_t) { + let res_final = if is_ext { res_ext_final } else { res_int_final }; + if res_final.abs() < 1e-3 { + let d2_vec = center - c2.center; + let d2_len = d2_vec.length(); + if d2_len > 1e-9 { + let dir = d2_vec / d2_len; + let pt2 = if is_ext { + c2.center + dir * c2.radius + } else if r > c2.radius { + c2.center - dir * c2.radius + } else { + c2.center + dir * c2.radius + }; + candidates.push((pt1, pt2, r)); + } + } + } + } + } + }; + + check_mode(true); + check_mode(false); + + candidates + .into_iter() + .min_by(|(pt1_a, pt2_a, r_a), (pt1_b, pt2_b, r_b)| { + let dist_a = (*pt1_a - hint_ell).length_squared() + (*pt2_a - hint_c2).length_squared() + if *r_a > 50000.0 { 1e8 } else { 0.0 }; + let dist_b = (*pt1_b - hint_ell).length_squared() + (*pt2_b - hint_c2).length_squared() + if *r_b > 50000.0 { 1e8 } else { 0.0 }; + dist_a.total_cmp(&dist_b) + }) + .map(|(pt1, pt2, _)| (pt1, pt2)) +} + +/// Solves CCP (Curve, Curve, Point) where both Curves are Ellipses. +pub fn solve_ellipse_ellipse_ccp( + ell1: &Ellipse2D, + ell2: &Ellipse2D, + p: DVec2, + hint1: DVec2, + hint2: DVec2, +) -> Option<(DVec2, DVec2)> { + let n_steps = 144; + let dt = std::f64::consts::TAU / (n_steps as f64); + let mut candidates = Vec::new(); + + let eval_t = |t: f64| -> Option<(DVec2, DVec2, f64, f64)> { + let pt1 = ell1.point_at(t); + let n_hat = ell1.normal_at(t); + let w = pt1 - p; + let w_dot_n = w.dot(n_hat); + if w_dot_n.abs() < 1e-6 { + return None; + } + let lambda = -w.length_squared() / (2.0 * w_dot_n); + let r = lambda.abs(); + if r < 1e-4 || r > 1e7 { + return None; + } + let center = pt1 + n_hat * lambda; + let normal_pts = ell2.normal_points_for_center(center); + let pt2 = normal_pts + .into_iter() + .min_by(|a, b| (*a - hint2).length_squared().total_cmp(&(*b - hint2).length_squared()))?; + let d2 = (center - pt2).length(); + let res = d2 - r; + Some((pt1, pt2, r, res)) + }; + + for i in 0..n_steps { + let t_a = i as f64 * dt; + let t_b = (i + 1) as f64 * dt; + + let Some((_, _, _, res_a)) = eval_t(t_a) else { continue; }; + let Some((_, _, _, res_b)) = eval_t(t_b) else { continue; }; + + let pt_a = ell1.point_at(t_a); + let n_hat_a = ell1.normal_at(t_a); + let w_a = pt_a - p; + let pt_b = ell1.point_at(t_b); + let n_hat_b = ell1.normal_at(t_b); + let w_b = pt_b - p; + if (w_a.dot(n_hat_a)).signum() != (w_b.dot(n_hat_b)).signum() { + continue; + } + + if res_a.signum() != res_b.signum() { + let mut lo = t_a; + let mut hi = t_b; + let mut best_t = (lo + hi) * 0.5; + for _ in 0..16 { + let mid = (lo + hi) * 0.5; + let Some((_, _, _, res_m)) = eval_t(mid) else { break; }; + best_t = mid; + if res_m.abs() < 1e-8 { + break; + } + if res_m.signum() == res_a.signum() { + lo = mid; + } else { + hi = mid; + } + } + if let Some((pt1, pt2, r, res_final)) = eval_t(best_t) { + if res_final.abs() < 1e-3 { + candidates.push((pt1, pt2, r)); + } + } + } + } + + candidates + .into_iter() + .min_by(|(pt1_a, pt2_a, r_a), (pt1_b, pt2_b, r_b)| { + let dist_a = (*pt1_a - hint1).length_squared() + (*pt2_a - hint2).length_squared() + if *r_a > 50000.0 { 1e8 } else { 0.0 }; + let dist_b = (*pt1_b - hint1).length_squared() + (*pt2_b - hint2).length_squared() + if *r_b > 50000.0 { 1e8 } else { 0.0 }; + dist_a.total_cmp(&dist_b) + }) + .map(|(pt1, pt2, _)| (pt1, pt2)) +} + +fn solve_tangent_ccp( + t1: TangentObject, + h1: DVec3, + t2: TangentObject, + h2: DVec3, + mid: DVec3, + plane: WorkingPlane, +) -> Option<(DVec3, DVec3)> { + let z = plane.to_local(mid).z; + let p = DVec2::new(plane.to_local(mid).x, plane.to_local(mid).y); + let hint1 = DVec2::new(plane.to_local(h1).x, plane.to_local(h1).y); + let hint2 = DVec2::new(plane.to_local(h2).x, plane.to_local(h2).y); + + let ell1 = target_to_ellipse2d(t1, plane); + let ell2 = target_to_ellipse2d(t2, plane); + let c1 = target_to_circle2d_pure(t1, plane); + let c2 = target_to_circle2d_pure(t2, plane); + + if let (Some(ell1), Some(c2)) = (ell1, c2) { + let (w1, w2) = solve_ellipse_circle_ccp(&ell1, c2, p, hint1, hint2)?; + return Some(( + plane.to_world(DVec3::new(w1.x, w1.y, z)), + plane.to_world(DVec3::new(w2.x, w2.y, z)), + )); + } + + if let (Some(c1), Some(ell2)) = (c1, ell2) { + let (w2, w1) = solve_ellipse_circle_ccp(&ell2, c1, p, hint2, hint1)?; + return Some(( + plane.to_world(DVec3::new(w1.x, w1.y, z)), + plane.to_world(DVec3::new(w2.x, w2.y, z)), + )); + } + + if let (Some(ell1), Some(ell2)) = (ell1, ell2) { + let (w1, w2) = solve_ellipse_ellipse_ccp(&ell1, &ell2, p, hint1, hint2)?; + return Some(( + plane.to_world(DVec3::new(w1.x, w1.y, z)), + plane.to_world(DVec3::new(w2.x, w2.y, z)), + )); + } + + if let (Some(c1), Some(c2)) = (c1, c2) { + let sols = apollonius::solve_ccp(c1, c2, p); + if let Some(best) = apollonius::best_ccp(&sols, hint1, hint2) { + let w1 = plane.to_world(DVec3::new(best.tangent1.x, best.tangent1.y, z)); + let tan2 = best.tangent2.unwrap_or(best.tangent1); + let w2 = plane.to_world(DVec3::new(tan2.x, tan2.y, z)); + return Some((w1, w2)); + } + } + + None +} + +fn solve_tangent_ppc( + t1: TangentObject, + h1: DVec3, + p1: DVec3, + p2: DVec3, + plane: WorkingPlane, +) -> Option { + let z = plane.to_local(p1).z; + let pt1 = DVec2::new(plane.to_local(p1).x, plane.to_local(p1).y); + let pt2 = DVec2::new(plane.to_local(p2).x, plane.to_local(p2).y); + let hint1 = DVec2::new(plane.to_local(h1).x, plane.to_local(h1).y); + + if let Some(ell) = target_to_ellipse2d(t1, plane) { + if let Some(w1_2d) = solve_ellipse_ppc(&ell, pt1, pt2, hint1) { + return Some(plane.to_world(DVec3::new(w1_2d.x, w1_2d.y, z))); + } + return None; + } + + if let Some(c1) = target_to_circle2d_pure(t1, plane) { + let sols = apollonius::solve_ppc(c1, pt1, pt2); + if let Some(best) = apollonius::best_ppc(&sols, hint1) { + return Some(plane.to_world(DVec3::new(best.tangent1.x, best.tangent1.y, z))); + } + } + + None +} + +/// Solves an arc from 3 inputs, solving fluid tangencies (Apollonius CCP / PPC) +/// when one or more inputs are Tangent constraints. +pub(crate) fn solve_arc_3_inputs( + p1: ArcPick, + p2: ArcPick, + p3: ArcPick, + plane: WorkingPlane, +) -> Option<(DVec3, f64, f64, f64)> { + match (p1, p2, p3) { + // Case 1: Two tangents and one point (Apollonius CCP) + // (Tangent, Point, Tangent) -> bridging arc through middle point! + ( + ArcPick::Tangent { target: t1, hit: h1 }, + ArcPick::Point(mid), + ArcPick::Tangent { target: t2, hit: h2 }, + ) => { + if let Some((w1, w2)) = solve_tangent_ccp(t1, h1, t2, h2, mid, plane) { + return arc_through_points(w1, mid, w2, plane); + } + } + // (Tangent, Tangent, Point) + ( + ArcPick::Tangent { target: t1, hit: h1 }, + ArcPick::Tangent { target: t2, hit: h2 }, + ArcPick::Point(end), + ) => { + if let Some((w1, w2)) = solve_tangent_ccp(t1, h1, t2, h2, end, plane) { + return arc_through_points(w1, end, w2, plane); + } + } + // (Point, Tangent, Tangent) + ( + ArcPick::Point(start), + ArcPick::Tangent { target: t1, hit: h1 }, + ArcPick::Tangent { target: t2, hit: h2 }, + ) => { + if let Some((w1, w2)) = solve_tangent_ccp(t1, h1, t2, h2, start, plane) { + return arc_through_points(w1, start, w2, plane); + } + } + + // Case 2: One tangent and two points (Apollonius PPC) + // (Tangent, Point, Point) + ( + ArcPick::Tangent { target: t1, hit: h1 }, + ArcPick::Point(mid), + ArcPick::Point(end), + ) => { + if let Some(w1) = solve_tangent_ppc(t1, h1, mid, end, plane) { + return arc_through_points(w1, mid, end, plane); + } + } + // (Point, Point, Tangent) + ( + ArcPick::Point(start), + ArcPick::Point(mid), + ArcPick::Tangent { target: t1, hit: h1 }, + ) => { + if let Some(w1) = solve_tangent_ppc(t1, h1, start, mid, plane) { + return arc_through_points(start, mid, w1, plane); + } + } + // (Point, Tangent, Point) + ( + ArcPick::Point(start), + ArcPick::Tangent { target: t1, hit: h1 }, + ArcPick::Point(end), + ) => { + if let Some(w1) = solve_tangent_ppc(t1, h1, start, end, plane) { + return arc_through_points(start, w1, end, plane); + } + } + + _ => {} + } + + let has_tangent = matches!(p1, ArcPick::Tangent { .. }) + || matches!(p2, ArcPick::Tangent { .. }) + || matches!(p3, ArcPick::Tangent { .. }); + if has_tangent { + None + } else { + arc_through_points(p1.point(), p2.point(), p3.point(), plane) + } +} + // ── Command 1: Center, Start, End ───────────────────────────────────────── pub struct ArcCSECommand { @@ -426,8 +1174,8 @@ impl CadCommand for ArcCSECommand { #[derive(Clone, Copy, Debug, PartialEq)] enum ArcStep { Start, - Second { s: DVec3 }, - End3 { s: DVec3, m: DVec3 }, + Second { s: ArcPick }, + End3 { s: ArcPick, m: ArcPick }, CenterFirst, StartAfterCenter { c: DVec3 }, CenterAfterStart { s: DVec3 }, @@ -594,53 +1342,65 @@ impl CadCommand for ArcCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + self.on_point_with_tangent(pt, None).unwrap_or(CmdResult::NeedPoint) + } + + fn on_point_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Option { + let pick = match tangent { + Some(target) => ArcPick::Tangent { target, hit: pt }, + None => ArcPick::Point(pt), + }; match self.step { ArcStep::Start => { - self.step = ArcStep::Second { s: pt }; - CmdResult::NeedPoint + self.step = ArcStep::Second { s: pick }; + Some(CmdResult::NeedPoint) } ArcStep::Second { s } => { - self.step = ArcStep::End3 { s, m: pt }; - CmdResult::NeedPoint + self.step = ArcStep::End3 { s, m: pick }; + Some(CmdResult::NeedPoint) } - ArcStep::End3 { s, m } => match arc_through_points(s, m, pt, self.plane) { - Some((c, r, sa, ea)) => arc_result(c, r, sa, ea, self.plane), - None => CmdResult::NeedPoint, + ArcStep::End3 { s, m } => match solve_arc_3_inputs(s, m, pick, self.plane) { + Some((c, r, sa, ea)) => Some(arc_result(c, r, sa, ea, self.plane)), + None => Some(CmdResult::NeedPoint), }, ArcStep::CenterFirst => { self.step = ArcStep::StartAfterCenter { c: pt }; - CmdResult::NeedPoint + Some(CmdResult::NeedPoint) } ArcStep::StartAfterCenter { c } => { self.step = ArcStep::EndSC { s: pt, c }; - CmdResult::NeedPoint + Some(CmdResult::NeedPoint) } ArcStep::CenterAfterStart { s } => { self.step = ArcStep::EndSC { s, c: pt }; - CmdResult::NeedPoint + Some(CmdResult::NeedPoint) } - ArcStep::EndSC { s, c } => self.commit(self.sc_end(s, c, pt)), + ArcStep::EndSC { s, c } => Some(self.commit(self.sc_end(s, c, pt))), // At a typed prompt a pick reads as commercial solutions do: the cursor's // direction from the centre (angle) or its distance (length, // radius), the cursor itself for a direction / sagitta. ArcStep::AngleSC { s, c } => { let span = self.cursor_span(s, c, pt); - self.commit(self.sc_angle(s, c, span)) + Some(self.commit(self.sc_angle(s, c, span))) } - ArcStep::LengthSC { s, c } => self.commit(self.sc_length(s, c, s.distance(pt))), + ArcStep::LengthSC { s, c } => Some(self.commit(self.sc_length(s, c, s.distance(pt)))), ArcStep::EndAfterStart { s } => { self.step = ArcStep::CenterSE { s, e: pt }; - CmdResult::NeedPoint + Some(CmdResult::NeedPoint) } - ArcStep::CenterSE { s, e } => self.commit(self.sc_end(s, pt, e)), + ArcStep::CenterSE { s, e } => Some(self.commit(self.sc_end(s, pt, e))), ArcStep::AngleSE { s, e } => { - self.commit(arc_from_sagitta(s, e, pt, self.cw, self.plane)) + Some(self.commit(arc_from_sagitta(s, e, pt, self.cw, self.plane))) } ArcStep::DirectionSE { s, e } => { - self.commit(arc_continue(s, pt - s, e, self.cw, self.plane)) + Some(self.commit(arc_continue(s, pt - s, e, self.cw, self.plane))) } ArcStep::RadiusSE { s, e } => { - self.commit(arc_from_se_radius(s, e, pt, self.cw, self.plane)) + Some(self.commit(arc_from_se_radius(s, e, pt, self.cw, self.plane))) } } } @@ -657,11 +1417,11 @@ impl CadCommand for ArcCommand { }, ArcStep::Second { s } => match upper.as_str() { "C" | "CE" | "CENTER" | "CENTRE" => { - self.step = ArcStep::CenterAfterStart { s }; + self.step = ArcStep::CenterAfterStart { s: s.point() }; Some(CmdResult::NeedPoint) } "E" | "END" => { - self.step = ArcStep::EndAfterStart { s }; + self.step = ArcStep::EndAfterStart { s: s.point() }; Some(CmdResult::NeedPoint) } _ => None, @@ -811,38 +1571,58 @@ impl CadCommand for ArcCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { + self.on_preview_wires_with_tangent(pt, None).into_iter().next() + } + + fn on_preview_wires_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Vec { match self.step { - ArcStep::Start | ArcStep::CenterFirst => None, - ArcStep::Second { s } | ArcStep::EndAfterStart { s } | ArcStep::CenterAfterStart { s } => { - Some(line_wire(s, pt)) + ArcStep::Start | ArcStep::CenterFirst => Vec::new(), + ArcStep::Second { s } => { + let start = fluid_anchor_point(s, pt, self.plane); + vec![line_wire(start, pt)] } - ArcStep::StartAfterCenter { c } => Some(line_wire(c, pt)), + ArcStep::EndAfterStart { s } | ArcStep::CenterAfterStart { s } => { + vec![line_wire(s, pt)] + } + ArcStep::StartAfterCenter { c } => vec![line_wire(c, pt)], ArcStep::End3 { s, m } => { + let p3 = match tangent { + Some(target) => ArcPick::Tangent { target, hit: pt }, + None => ArcPick::Point(pt), + }; let fallback = WireModel::solid_f64( "rubber_band".into(), - vec![[s.x, s.y, s.z], [m.x, m.y, m.z], [pt.x, pt.y, pt.z]], + vec![ + [s.point().x, s.point().y, s.point().z], + [m.point().x, m.point().y, m.point().z], + [pt.x, pt.y, pt.z], + ], WireModel::CYAN, false, ); - self.preview(arc_through_points(s, m, pt, self.plane), fallback) + self.preview(solve_arc_3_inputs(s, m, p3, self.plane), fallback).into_iter().collect() } - ArcStep::EndSC { s, c } => self.preview(self.sc_end(s, c, pt), line_wire(c, pt)), + ArcStep::EndSC { s, c } => self.preview(self.sc_end(s, c, pt), line_wire(c, pt)).into_iter().collect(), ArcStep::AngleSC { s, c } => { let span = self.cursor_span(s, c, pt); - self.preview(self.sc_angle(s, c, span), line_wire(c, pt)) + self.preview(self.sc_angle(s, c, span), line_wire(c, pt)).into_iter().collect() } ArcStep::LengthSC { s, c } => { - self.preview(self.sc_length(s, c, s.distance(pt)), line_wire(s, pt)) + self.preview(self.sc_length(s, c, s.distance(pt)), line_wire(s, pt)).into_iter().collect() } - ArcStep::CenterSE { s, e } => self.preview(self.sc_end(s, pt, e), line_wire(s, e)), + ArcStep::CenterSE { s, e } => self.preview(self.sc_end(s, pt, e), line_wire(s, e)).into_iter().collect(), ArcStep::AngleSE { s, e } => { - self.preview(arc_from_sagitta(s, e, pt, self.cw, self.plane), line_wire(s, e)) + self.preview(arc_from_sagitta(s, e, pt, self.cw, self.plane), line_wire(s, e)).into_iter().collect() } ArcStep::DirectionSE { s, e } => { - self.preview(arc_continue(s, pt - s, e, self.cw, self.plane), line_wire(s, e)) + self.preview(arc_continue(s, pt - s, e, self.cw, self.plane), line_wire(s, e)).into_iter().collect() } ArcStep::RadiusSE { s, e } => { - self.preview(arc_from_se_radius(s, e, pt, self.cw, self.plane), line_wire(s, e)) + self.preview(arc_from_se_radius(s, e, pt, self.cw, self.plane), line_wire(s, e)).into_iter().collect() } } } @@ -851,14 +1631,14 @@ impl CadCommand for ArcCommand { // ── Command 2: 3-Point (ARC_3P) ────────────────────────────────────────── pub struct Arc3PCommand { - pts: Vec, + picks: Vec, plane: WorkingPlane, } impl Arc3PCommand { pub fn new() -> Self { Self { - pts: Vec::new(), + picks: Vec::new(), plane: WorkingPlane::default(), } } @@ -873,30 +1653,43 @@ impl CadCommand for Arc3PCommand { "ARC_3P" } fn prompt(&self) -> String { - match self.pts.len() { + match self.picks.len() { 0 => t!("ARC 3P Specify start point:").into_owned(), 1 => t!("ARC 3P Specify second point on arc:").into_owned(), _ => t!("ARC 3P Specify end point:").into_owned(), } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - self.pts.push(pt); - if self.pts.len() < 3 { - return CmdResult::NeedPoint; + self.on_point_with_tangent(pt, None).unwrap_or(CmdResult::NeedPoint) + } + + fn on_point_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Option { + let pick = match tangent { + Some(target) => ArcPick::Tangent { target, hit: pt }, + None => ArcPick::Point(pt), + }; + self.picks.push(pick); + if self.picks.len() < 3 { + return Some(CmdResult::NeedPoint); } - let (p1, p2, p3) = (self.pts[0], self.pts[1], self.pts[2]); - match arc_through_points(p1, p2, p3, self.plane) { + let (p1, p2, p3) = (self.picks[0], self.picks[1], self.picks[2]); + match solve_arc_3_inputs(p1, p2, p3, self.plane) { None => { - self.pts.pop(); - CmdResult::NeedPoint - } // collinear — retry + self.picks.pop(); + Some(CmdResult::NeedPoint) + } // collinear or no solution — retry Some((center, radius, start, end)) => { - arc_result(center, radius, start, end, self.plane) + Some(arc_result(center, radius, start, end, self.plane)) } } } + fn on_enter(&mut self) -> CmdResult { - if self.pts.is_empty() { + if self.picks.is_empty() { CmdResult::Dispatch("ARC_CONT".into()) } else { CmdResult::Cancel @@ -909,26 +1702,49 @@ impl CadCommand for Arc3PCommand { CmdResult::Cancel } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - match self.pts.len() { - 0 => None, - 1 => Some(line_wire(self.pts[0], pt)), + self.on_preview_wires_with_tangent(pt, None).into_iter().next() + } + + fn on_preview_wires_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Vec { + match self.picks.len() { + 0 => Vec::new(), + 1 => { + let start = fluid_anchor_point(self.picks[0], pt, self.plane); + vec![line_wire(start, pt)] + } _ => { - let (p1, p2) = (self.pts[0], self.pts[1]); + let (p1, p2) = (self.picks[0], self.picks[1]); + let p3 = match tangent { + Some(target) => ArcPick::Tangent { target, hit: pt }, + None => ArcPick::Point(pt), + }; if let Some((center, radius, start, end)) = - arc_through_points(p1, p2, pt, self.plane) + solve_arc_3_inputs(p1, p2, p3, self.plane) { - arc_preview(center, radius, start, end, self.plane) + arc_preview(center, radius, start, end, self.plane).into_iter().collect() } else { - Some(WireModel::solid_f64( + vec![WireModel::solid_f64( "rubber_band".into(), - vec![[p1.x, p1.y, p1.z], [p2.x, p2.y, p2.z], [pt.x, pt.y, pt.z]], + vec![ + [p1.point().x, p1.point().y, p1.point().z], + [p2.point().x, p2.point().y, p2.point().z], + [pt.x, pt.y, pt.z], + ], WireModel::CYAN, false, - )) + )] } } } } + + fn resolved_anchor(&self) -> Option { + self.picks.last().map(|p| p.point()) + } } // ── Command 3: Start, Center, End (ARC_SCE) ────────────────────────────── @@ -2098,4 +2914,304 @@ mod acad_flow_tests { let mut cmd = ArcCommand::new(); assert!(matches!(cmd.on_enter(), CmdResult::Dispatch(c) if c == "ARC_CONT")); } + + #[test] + fn arc_3p_fluid_tangent_between_two_circles() { + let mut cmd = Arc3PCommand::new(); + 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, + }; + // Pick 1: Tangent on circle 1 + let r1 = cmd.on_point_with_tangent(DVec3::new(-20.0, 10.0, 0.0), Some(c1)); + assert!(matches!(r1, Some(CmdResult::NeedPoint))); + + // Pick 2: Middle pass-through point + let r2 = cmd.on_point_with_tangent(DVec3::new(0.0, 10.0, 0.0), None); + assert!(matches!(r2, Some(CmdResult::NeedPoint))); + + // Live preview with tangent on circle 2 + let preview = cmd.on_preview_wires_with_tangent(DVec3::new(20.0, 10.0, 0.0), Some(c2)); + assert!(!preview.is_empty(), "Live preview should generate arc wire"); + + // Pick 3: Tangent on circle 2 + let r3 = cmd.on_point_with_tangent(DVec3::new(20.0, 10.0, 0.0), Some(c2)); + let arc = committed_arc(r3.expect("command completed")); + // The arc must pass through (0, 10) + let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z); + let dist_to_mid = (center - DVec3::new(0.0, 10.0, 0.0)).length(); + assert!((dist_to_mid - arc.radius).abs() < 1e-3); + // The arc must be tangent to c1: |center - c1.center| = |arc.radius +- 10| + let dist_to_c1 = (center - DVec3::new(-20.0, 0.0, 0.0)).length(); + assert!((dist_to_c1 - (arc.radius + 10.0)).abs() < 1e-3 || (dist_to_c1 - (arc.radius - 10.0).abs()).abs() < 1e-3); + } + + #[test] + fn arc_3p_fluid_tangent_to_one_circle_and_two_points() { + let mut cmd = Arc3PCommand::new(); + let c1 = TangentObject::Circle { + center: DVec3::new(0.0, 0.0, 0.0), + radius: 10.0, + }; + // Pick 1: Point 1 + cmd.on_point_with_tangent(DVec3::new(0.0, 20.0, 0.0), None); + // Pick 2: Point 2 + cmd.on_point_with_tangent(DVec3::new(20.0, 20.0, 0.0), None); + // Pick 3: Tangent on circle 1 + let r3 = cmd.on_point_with_tangent(DVec3::new(0.0, 10.0, 0.0), Some(c1)); + let arc = committed_arc(r3.expect("command completed")); + let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z); + // Passes through p1 and p2 + assert!(((center - DVec3::new(0.0, 20.0, 0.0)).length() - arc.radius).abs() < 1e-3); + assert!(((center - DVec3::new(20.0, 20.0, 0.0)).length() - arc.radius).abs() < 1e-3); + } + + #[test] + fn arc_3p_fluid_tangent_ellipse_and_circle() { + let mut cmd = Arc3PCommand::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 + cmd.on_point_with_tangent(DVec3::new(-30.0, 10.0, 0.0), Some(e1)); + // Pick 2: Middle point + cmd.on_point_with_tangent(DVec3::new(0.0, 15.0, 0.0), None); + // 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!(!preview.is_empty()); + // Pick 3: Tangent on circle 2 + let r3 = cmd.on_point_with_tangent(DVec3::new(30.0, 10.0, 0.0), Some(c2)); + let arc = committed_arc(r3.expect("command completed")); + + // Verify the arc's start point lies exactly on the ellipse! + let arc_start = DVec3::new( + arc.center.x + arc.radius * arc.start_angle.cos(), + arc.center.y + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let arc_end = DVec3::new( + arc.center.x + arc.radius * arc.end_angle.cos(), + arc.center.y + arc.radius * arc.end_angle.sin(), + 0.0, + ); + + // One of the endpoints must lie on the ellipse (x/20)^2 + (y/10)^2 = 1 (relative to center -30,0) + let check_on_ellipse = |pt: DVec3| { + let dx = pt.x - (-30.0); + let dy = pt.y - 0.0; + ((dx / 20.0).powi(2) + (dy / 10.0).powi(2) - 1.0).abs() + }; + let d_start = check_on_ellipse(arc_start); + let d_end = check_on_ellipse(arc_end); + assert!(d_start < 1e-3 || d_end < 1e-3, "One arc endpoint must lie on the ellipse: d_start={d_start}, d_end={d_end}"); + } + + #[test] + fn arc_3p_fluid_tangent_ellipse_two_points_wrong_click_point() { + let mut cmd = Arc3PCommand::new(); + let e1 = TangentObject::Ellipse { + center: DVec3::new(0.0, 0.0, 0.0), + major_axis: DVec3::new(20.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, + }; + // Point 1 and Point 2 + cmd.on_point_with_tangent(DVec3::new(-30.0, 30.0, 0.0), None); + cmd.on_point_with_tangent(DVec3::new(30.0, 30.0, 0.0), None); + + // Pick 3: Tangent on Ellipse 1 at the "wrong" place (e.g. bottom vertex (0, -10)) + // The real tangent solution closest to the points is near the top (0, 10). + let r3 = cmd.on_point_with_tangent(DVec3::new(0.0, -10.0, 0.0), Some(e1)); + let arc = committed_arc(r3.expect("command should find valid tangent arc")); + + // Verify the arc endpoints or contact point is tangent to the ellipse + let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z); + // Must pass through p1 and p2 + assert!(((center - DVec3::new(-30.0, 30.0, 0.0)).length() - arc.radius).abs() < 1e-3); + assert!(((center - DVec3::new(30.0, 30.0, 0.0)).length() - arc.radius).abs() < 1e-3); + + // One of the arc endpoints must touch the ellipse (x/20)^2 + (y/10)^2 = 1 + let arc_start = DVec3::new( + arc.center.x + arc.radius * arc.start_angle.cos(), + arc.center.y + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let arc_end = DVec3::new( + arc.center.x + arc.radius * arc.end_angle.cos(), + arc.center.y + arc.radius * arc.end_angle.sin(), + 0.0, + ); + let check_on_ellipse = |pt: DVec3| { + ((pt.x / 20.0).powi(2) + (pt.y / 10.0).powi(2) - 1.0).abs() + }; + let d_start = check_on_ellipse(arc_start); + let d_end = check_on_ellipse(arc_end); + assert!(d_start < 1e-3 || d_end < 1e-3, "Arc must terminate exactly on ellipse: d_start={d_start}, d_end={d_end}"); + } + + #[test] + fn arc_3p_fluid_tangent_ellipse_and_circle_arbitrary_click() { + let mut cmd = Arc3PCommand::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: User clicks on the FAR side of the ellipse (-50, 0) + cmd.on_point_with_tangent(DVec3::new(-50.0, 0.0, 0.0), Some(e1)); + // Pick 2: Middle point (0, 15) + cmd.on_point_with_tangent(DVec3::new(0.0, 15.0, 0.0), None); + // Pick 3: Circle (30, 10) + let r3 = cmd.on_point_with_tangent(DVec3::new(30.0, 10.0, 0.0), Some(c2)); + let arc = committed_arc(r3.expect("must find closest valid tangent arc")); + + let arc_start = DVec3::new( + arc.center.x + arc.radius * arc.start_angle.cos(), + arc.center.y + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let arc_end = DVec3::new( + arc.center.x + arc.radius * arc.end_angle.cos(), + arc.center.y + arc.radius * arc.end_angle.sin(), + 0.0, + ); + let check_on_ellipse = |pt: DVec3| { + let dx = pt.x - (-30.0); + let dy = pt.y - 0.0; + ((dx / 20.0).powi(2) + (dy / 10.0).powi(2) - 1.0).abs() + }; + let d_start = check_on_ellipse(arc_start); + let d_end = check_on_ellipse(arc_end); + assert!(d_start < 1e-3 || d_end < 1e-3, "Arc must terminate exactly on ellipse: d_start={d_start}, d_end={d_end}"); + } + + #[test] + fn arc_3p_fluid_tangent_two_tangents_then_point_all_endpoints_tangent() { + let mut cmd = Arc3PCommand::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 + cmd.on_point_with_tangent(DVec3::new(-30.0, 10.0, 0.0), Some(e1)); + // Pick 2: Tangent on Circle 2 + cmd.on_point_with_tangent(DVec3::new(30.0, 10.0, 0.0), Some(c2)); + // Pick 3: Point in between (0, 15) + let r3 = cmd.on_point_with_tangent(DVec3::new(0.0, 15.0, 0.0), None); + let arc = committed_arc(r3.expect("command completed")); + + let arc_start = DVec3::new( + arc.center.x + arc.radius * arc.start_angle.cos(), + arc.center.y + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let arc_end = DVec3::new( + arc.center.x + arc.radius * arc.end_angle.cos(), + arc.center.y + arc.radius * arc.end_angle.sin(), + 0.0, + ); + + let check_on_ellipse = |pt: DVec3| { + let dx = pt.x - (-30.0); + let dy = pt.y - 0.0; + ((dx / 20.0).powi(2) + (dy / 10.0).powi(2) - 1.0).abs() + }; + let check_on_circle = |pt: DVec3| { + ((pt - DVec3::new(30.0, 0.0, 0.0)).length() - 10.0).abs() + }; + + // BOTH endpoints must be tangent: one on ellipse 1, one on circle 2! + let start_on_e1 = check_on_ellipse(arc_start) < 1e-3; + let end_on_e1 = check_on_ellipse(arc_end) < 1e-3; + let start_on_c2 = check_on_circle(arc_start) < 1e-3; + let end_on_c2 = check_on_circle(arc_end) < 1e-3; + + assert!( + (start_on_e1 && end_on_c2) || (start_on_c2 && end_on_e1), + "Both arc endpoints must terminate tangentially on the respective curves! start: {arc_start:?}, end: {arc_end:?}" + ); + } + + #[test] + fn arc_3p_fluid_tangent_two_ellipses() { + let mut cmd = Arc3PCommand::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 e2 = TangentObject::Ellipse { + center: DVec3::new(30.0, 0.0, 0.0), + major_axis: DVec3::new(15.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.6, + }; + + // Pick 1: Tangent on Ellipse 1 + cmd.on_point_with_tangent(DVec3::new(-30.0, 10.0, 0.0), Some(e1)); + // Pick 2: Tangent on Ellipse 2 + cmd.on_point_with_tangent(DVec3::new(30.0, 9.0, 0.0), Some(e2)); + // Pick 3: Point in between (0, 15) + let r3 = cmd.on_point_with_tangent(DVec3::new(0.0, 15.0, 0.0), None); + let arc = committed_arc(r3.expect("command completed for two ellipses")); + + let arc_start = DVec3::new( + arc.center.x + arc.radius * arc.start_angle.cos(), + arc.center.y + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let arc_end = DVec3::new( + arc.center.x + arc.radius * arc.end_angle.cos(), + arc.center.y + arc.radius * arc.end_angle.sin(), + 0.0, + ); + + let check_on_e1 = |pt: DVec3| { + let dx = pt.x - (-30.0); + let dy = pt.y - 0.0; + ((dx / 20.0).powi(2) + (dy / 10.0).powi(2) - 1.0).abs() + }; + let check_on_e2 = |pt: DVec3| { + let dx = pt.x - 30.0; + let dy = pt.y - 0.0; + ((dx / 15.0).powi(2) + (dy / 9.0).powi(2) - 1.0).abs() + }; + + let start_on_e1 = check_on_e1(arc_start) < 1e-3; + let end_on_e1 = check_on_e1(arc_end) < 1e-3; + let start_on_e2 = check_on_e2(arc_start) < 1e-3; + let end_on_e2 = check_on_e2(arc_end) < 1e-3; + + assert!( + (start_on_e1 && end_on_e2) || (start_on_e2 && end_on_e1), + "Both endpoints must terminate tangentially on the respective ellipses! start: {arc_start:?}, end: {arc_end:?}" + ); + } } + diff --git a/src/modules/draw/draw/circle.rs b/src/modules/draw/draw/circle.rs index cd2da0c22..cdc7ce80e 100644 --- a/src/modules/draw/draw/circle.rs +++ b/src/modules/draw/draw/circle.rs @@ -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 { vec![(-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a)] } +#[allow(dead_code)] pub(crate) fn best_of(candidates: &[DVec3], hint: DVec3) -> Option { candidates.iter().copied().min_by(|a, b| { a.distance(hint) @@ -609,6 +622,169 @@ pub(crate) fn best_of(candidates: &[DVec3], hint: DVec3) -> Option { }) } +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, +) -> 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, +) -> Option { + 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 { + 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, +) -> Vec { + let dt = TAU / (n_steps as f64); + let mut samples: Vec<(f64, Option)> = 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 { + 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 { + 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 { + 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 { + 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 { + 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, } 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) -> 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:?}" + ); + } + } } diff --git a/src/modules/draw/draw/line.rs b/src/modules/draw/draw/line.rs index 5403b7372..87f7fb9d8 100644 --- a/src/modules/draw/draw/line.rs +++ b/src/modules/draw/draw/line.rs @@ -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, ) -> Option { - 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 { - 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, + ) -> Vec { + 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 { + 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(_)))); + } } diff --git a/src/modules/draw/draw/mod.rs b/src/modules/draw/draw/mod.rs index acf5e2de7..bbcd86720 100644 --- a/src/modules/draw/draw/mod.rs +++ b/src/modules/draw/draw/mod.rs @@ -1,3 +1,4 @@ +pub mod apollonius; pub mod arc; pub mod attdef; pub mod centerline; diff --git a/src/modules/model/cylinder_cmd.rs b/src/modules/model/cylinder_cmd.rs index efed69a4e..6b1b11df4 100644 --- a/src/modules/model/cylinder_cmd.rs +++ b/src/modules/model/cylinder_cmd.rs @@ -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)) } diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index c707b9531..c99d8d9f1 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -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)) } diff --git a/src/snap.rs b/src/snap.rs index 1bbdc57f8..243d1feae 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -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, + pub from_point: Option, /// 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, From c827b87cf9b9a8b008d6ec2a379b957067385028 Mon Sep 17 00:00:00 2001 From: yztink Date: Mon, 28 Sep 2026 01:56:47 -0700 Subject: [PATCH 07/13] fix(trim): snap trim cut parameters to exact boundary contact points --- src/modules/draw/modify/geom.rs | 101 +++- src/modules/draw/modify/trim.rs | 789 ++++++++++++++++++++++++++++++-- 2 files changed, 841 insertions(+), 49 deletions(-) diff --git a/src/modules/draw/modify/geom.rs b/src/modules/draw/modify/geom.rs index 61a1d59b3..f5bb524b9 100644 --- a/src/modules/draw/modify/geom.rs +++ b/src/modules/draw/modify/geom.rs @@ -15,7 +15,106 @@ 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::{lerp, normalize_angle}; + +/// Where `angle` sits along the arc from `start` to `end`, as `0.0..=1.0`. +/// +/// Unlike `kernel::geom2d::arc_parameter` which clamps any angle outside the arc to 1.0, +/// this clamps angles in the gap outside the arc to 0.0 or 1.0 depending on which endpoint +/// they are closer to. This prevents cursor jitter near `start` from snapping to 1.0. +pub fn arc_parameter(angle: f64, start: f64, end: f64) -> f64 { + let span = kernel::geom2d::arc_span(start, end); + if span < 1e-12 { + return 0.0; + } + let norm_angle = kernel::geom2d::normalize_angle(angle); + let norm_start = kernel::geom2d::normalize_angle(start); + let delta = (norm_angle - norm_start).rem_euclid(std::f64::consts::TAU); + if delta <= span { + (delta / span).clamp(0.0, 1.0) + } else { + let gap = std::f64::consts::TAU - span; + let past_end = delta - span; + if past_end <= gap * 0.5 { + 1.0 + } else { + 0.0 + } + } +} + +/// Finds the closest parametric parameter `t` on an origin-centered ellipse +/// `(x/a)^2 + (y/b)^2 = 1` for an arbitrary query point `(u, v)` in local coordinates, +/// using David Eberly's exact orthogonal projection algorithm. +/// +/// Unlike radial projection `(v/b).atan2(u/a)` which deviates by up to 20-40 degrees +/// when the query point is slightly off the ellipse perimeter (e.g. within screen pick box), +/// this finds the true orthogonal projection on the ellipse regardless of zoom level. +pub fn ellipse_closest_parameter(a: f64, b: f64, u: f64, v: f64) -> f64 { + if a < 1e-12 || b < 1e-12 { + return 0.0; + } + if (a - b).abs() < 1e-9 { + return v.atan2(u); + } + if a < b { + let t_swapped = ellipse_closest_parameter(b, a, v, u); + return std::f64::consts::FRAC_PI_2 - t_swapped; + } + + let sign_x = if u >= 0.0 { 1.0 } else { -1.0 }; + let sign_y = if v >= 0.0 { 1.0 } else { -1.0 }; + let u_abs = u.abs(); + let v_abs = v.abs(); + + if v_abs < 1e-12 { + let x = if u_abs < (a * a - b * b) / a { + a * a * u_abs / (a * a - b * b) + } else { + a + }; + let y = b * (1.0 - (x / a).powi(2)).max(0.0).sqrt(); + return (sign_y * y / b).atan2(sign_x * x / a); + } + + if u_abs < 1e-12 { + return (sign_y * 1.0).atan2(0.0); + } + + let a2 = a * a; + let b2 = b * b; + let mut t0 = -b2 + b * v_abs; + let mut t1 = -b2 + (a * u_abs).hypot(b * v_abs); + let mut t = t0; + + for _ in 0..25 { + let t_a = t + a2; + let t_b = t + b2; + if t_a.abs() < 1e-12 || t_b.abs() < 1e-12 { + break; + } + let f = (a * u_abs / t_a).powi(2) + (b * v_abs / t_b).powi(2) - 1.0; + if f.abs() < 1e-12 { + break; + } + let df = -2.0 * (a2 * u_abs * u_abs / (t_a.powi(3)) + b2 * v_abs * v_abs / (t_b.powi(3))); + let t_next = t - f / df; + if t_next > t0 && t_next < t1 { + t = t_next; + } else { + if f > 0.0 { + t0 = t; + } else { + t1 = t; + } + t = 0.5 * (t0 + t1); + } + } + + let x = a2 * u_abs / (t + a2); + let y = b2 * v_abs / (t + b2); + (sign_y * y / b).atan2(sign_x * x / a) +} /// Preview geometry keeps the density the commands have always used. const SEGMENTS_PER_RADIAN: f64 = geom2d::DEFAULT_SEGMENTS_PER_RADIAN; diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index baf068f25..17a9e971a 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -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 { .collect::>() }) .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 } 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 { 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"); + } + } } // ══════════════════════════════════════════════════════════════════════════ From d40e6c8d1ded0dfc7e6d00d953b83ef88cb8fb4d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 28 Sep 2026 11:58:38 +0300 Subject: [PATCH 08/13] chore(text): English-only comments for the hint-reliant font path #1506 paired each comment with a Chinese line; the codebase comments in English. Code and test data are unchanged. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 1 - src/scene/text/ttf_glyph.rs | 29 +++-------------------------- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 135afe1a0..b77cfd2a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,6 @@ rustc-hash = "2" sha2 = "0.10" fontdb = "0.23" ttf-parser = "0.25" -# TrueType 指令解譯器:標楷體/細明體等「筆畫拼字」字型要先 hinting 才有正確輪廓。 # 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" diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index 56fb0ce32..cb24878b3 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -136,10 +136,6 @@ impl ttf_parser::OutlineBuilder for OutlineFlattener { // ── Hint-reliant ("tricky") fonts ─────────────────────────────────────────── // -// 標楷體(DFKai-SB)、細明體(MingLiU)等早期華康字型是「筆畫拼字」:每個字由 -// 筆畫部件組成,部件要靠 TrueType 指令才會移到正確位置。直接讀 `glyf` 原始輪廓 -// 會缺筆畫(「自」變「目」、範/直/啟/動缺筆)。FreeType 稱這類字型為 tricky。 -// 對這些字型改用 skrifa 的 TrueType 解譯器在大字級 hinting,再換回字型單位。 // // DFKai-SB, MingLiU and other early DynaLab fonts build each ideograph from // stroke components that only the TrueType instructions move into place, so @@ -149,12 +145,10 @@ impl ttf_parser::OutlineBuilder for OutlineFlattener { /// ppem the interpreter hints at: large enough that grid rounding is only /// 1/512 em, so the hinted outline stays close to the design. -/// 解譯器 hinting 用的字級:夠大,格點捨入只有 1/512 em,輪廓貼近原設計。 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. -/// 不雜湊整個字型就能辨識一個字面;fontdb 每次可能給新緩衝區,指標不可靠。 #[derive(Clone, Copy, PartialEq, Eq, Hash)] struct FaceKey { len: usize, @@ -163,7 +157,6 @@ struct FaceKey { } /// `Some(instance)` for a font that needs the interpreter, `None` otherwise. -/// 需要解譯器的字型存 `Some(hinting 實例)`,其他存 `None`。 type HintCache = HashMap>>; fn hint_cache() -> &'static Mutex { @@ -174,8 +167,6 @@ fn hint_cache() -> &'static Mutex { /// 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. -/// 取得該字型的 hinting 實例;不需要解譯器時回 `None`。 -/// `require_interpreter` 會讀 name 表、可能算 checksum,很慢,所以每個字面快取一次。 fn hinter_for( font: &skrifa::FontRef, key: FaceKey, @@ -190,7 +181,7 @@ fn hinter_for( let outlines = font.outline_glyphs(); let built = if outlines.require_interpreter() { // FreeType's recipe for tricky fonts: the bytecode interpreter in - // monochrome mode. / FreeType 對 tricky 字型的做法:解譯器+單色模式。 + // monochrome mode. let options = HintingOptions { engine: Engine::Interpreter, target: Target::Mono, @@ -205,7 +196,6 @@ fn hinter_for( 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:?}" ); @@ -220,7 +210,6 @@ fn hinter_for( } /// One recorded path command, already scaled to font units. -/// 一筆已換回字型單位的路徑指令。 enum PathCmd { Move(f32, f32), Line(f32, f32), @@ -231,9 +220,8 @@ enum PathCmd { /// 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. -/// 把 hinting 後的像素座標換回字型單位先記下來;畫到一半失敗也不會污染真正的 builder。 struct RecordingPen { - /// Pixel → font-unit factor (`upem / HINT_PPEM`). / 像素→字型單位係數。 + /// Pixel → font-unit factor (`upem / HINT_PPEM`). k: f32, cmds: Vec, } @@ -271,7 +259,6 @@ impl skrifa::outline::OutlinePen for RecordingPen { /// 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. -/// 回傳字型單位的 hinting 輪廓;字型不需要解譯器(或 hinting 失敗)時回 `None`。 fn hinted_outline(data: &[u8], index: u32, gid: ttf_parser::GlyphId) -> Option> { use skrifa::outline::DrawSettings; use skrifa::raw::TableProvider; @@ -301,8 +288,6 @@ fn hinted_outline(data: &[u8], index: u32, gid: ttf_parser::GlyphId) -> Option Option> { let dir = std::env::var_os("WINDIR")?; std::fs::read(std::path::Path::new(&dir).join("Fonts").join(file)).ok() @@ -1015,7 +999,6 @@ mod tests { /// Contours of `ch` in font units (flattener scale 1), either straight from /// ttf-parser or through `outline_glyph_into`. - /// 取字型單位輪廓:直接 ttf-parser,或走 `outline_glyph_into`。 fn contours(data: &[u8], index: u32, ch: char, new_path: bool) -> Vec> { let face = ttf_parser::Face::parse(data, index).expect("face"); let gid = face.glyph_index(ch).expect("glyph"); @@ -1041,7 +1024,6 @@ mod tests { 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. - // 標楷體在 tricky 名單上、微軟正黑體不在;快取後的判斷也要一致。 if let Some(kaiu) = windows_font("kaiu.ttf") { assert!( require_interpreter(&kaiu, 0), @@ -1076,7 +1058,6 @@ mod tests { } 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" @@ -1089,7 +1070,6 @@ mod tests { // 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. - // 標楷體原始輪廓缺筆(自→目);新路徑輸出要不同,且是字型單位、落在 em 內。 let Some(kaiu) = windows_font("kaiu.ttf") else { eprintln!("kaiu.ttf not installed; skipping"); return; @@ -1125,12 +1105,10 @@ mod tests { ); // 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. - // 忘了像素→字型單位換算的話字會縮一半;「自」窄(約 0.47 em)但高約 0.8 em。 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. - // 對外的 `glyph()` 也要走 hinting 路徑。 if let Some(g) = glyph("DFKai-SB", '自') { let gid = face.glyph_index('自').unwrap(); let mut fl = OutlineFlattener::new(cap_scale(&face)); @@ -1148,7 +1126,6 @@ mod tests { #[test] fn non_tricky_font_outlines_are_unchanged() { // Fonts that don't need the interpreter keep the exact ttf-parser outline. - // 不需要解譯器的字型,輸出與 ttf-parser 完全相同。 let Some(msjh) = windows_font("msjh.ttc") else { eprintln!("msjh.ttc not installed; skipping"); return; From e9e05d89f3580b43b5c57e81e010a40e026649c6 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:58:24 +0300 Subject: [PATCH 09/13] Locale keys of the TRANSPARENCY prompts next to the other image strings Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 6 +++--- locales/bg-BG/opencadstudio.ftl | 6 +++--- locales/cs-CZ/opencadstudio.ftl | 6 +++--- locales/de-DE/opencadstudio.ftl | 6 +++--- locales/el-GR/opencadstudio.ftl | 6 +++--- locales/en-US/opencadstudio.ftl | 6 +++--- locales/es-ES/opencadstudio.ftl | 6 +++--- locales/fi-FI/opencadstudio.ftl | 6 +++--- locales/fr-FR/opencadstudio.ftl | 6 +++--- locales/hi-IN/opencadstudio.ftl | 6 +++--- locales/hu-HU/opencadstudio.ftl | 6 +++--- locales/it-IT/opencadstudio.ftl | 6 +++--- locales/ja-JP/opencadstudio.ftl | 6 +++--- locales/ko-KR/opencadstudio.ftl | 6 +++--- locales/nl-NL/opencadstudio.ftl | 6 +++--- locales/pl-PL/opencadstudio.ftl | 6 +++--- locales/pt-BR/opencadstudio.ftl | 6 +++--- locales/ru-RU/opencadstudio.ftl | 6 +++--- locales/tr-TR/opencadstudio.ftl | 6 +++--- locales/zh-CN/opencadstudio.ftl | 6 +++--- locales/zh-TW/opencadstudio.ftl | 6 +++--- src/locale_catalog.rs | 6 +++--- 22 files changed, 66 insertions(+), 66 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 7a4c6b081..7add46a5d 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -3667,9 +3667,6 @@ insert = .frames-print = الإطارات والطباعة .frames-off = إيقاف الإطارات .frames-on = تشغيل الإطارات - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT أدخل اسم الكتلة:__ocs_arg_hint__ .insert-filling-attributes = INSERT يجري ملء السمات... @@ -3806,6 +3803,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index a8777696d..c7fb5e456 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -3654,9 +3654,6 @@ insert = .frames-print = Рамки и печат .frames-off = Рамки изкл. .frames-on = Рамки вкл. - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Въведи име на блок:__ocs_arg_hint__ .insert-filling-attributes = INSERT Попълване на атрибути... @@ -3793,6 +3790,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index e859bd0ad..c585c046b 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -3652,9 +3652,6 @@ insert = .frames-print = Rámy a tisk .frames-off = Rámy vypnuty .frames-on = Rámy zapnuty - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Název bloku Enter:__ocs_arg_hint__ .insert-filling-attributes = INSERT Vyplňování atributů... @@ -3791,6 +3788,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 1c1e2a699..3b2fe629e 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -3655,9 +3655,6 @@ insert = .frames-print = Rahmen und Drucken .frames-off = Rahmen aus .frames-on = Rahmen ein - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Blockname eingeben:__ocs_arg_hint__ .insert-filling-attributes = INSERT Attribute ausfüllen... @@ -3792,6 +3789,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 090f5e925..e3cb58071 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -3662,9 +3662,6 @@ insert = .frames-print = Πλαίσια και εκτύπωση .frames-off = Πλαίσια ανενεργά .frames-on = Πλαίσια ενεργά - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Δώστε όνομα μπλοκ:__ocs_arg_hint__ .insert-filling-attributes = INSERT Συμπλήρωση χαρακτηριστικών... @@ -3804,6 +3801,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index c53231dd3..8fba57187 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3656,9 +3656,6 @@ insert = .frames-print = Frames & Print .frames-off = Frames Off .frames-on = Frames On - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Enter block name:__ocs_arg_hint__ .insert-filling-attributes = INSERT Filling attributes... @@ -3805,6 +3802,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 455bef6c5..6d5500dae 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -3655,9 +3655,6 @@ insert = .frames-print = Marcos e impresión .frames-off = Marcos desactivados .frames-on = Marcos activados - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Introduzca el nombre del bloque:__ocs_arg_hint__ .insert-filling-attributes = INSERT Rellenando atributos... @@ -3794,6 +3791,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 167a404e3..da2de7a81 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -3654,9 +3654,6 @@ insert = .frames-print = Kehykset ja painatus .frames-off = Kehykset pois päältä .frames-on = Kehykset päällä - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Enter -lohkon nimi:__ocs_arg_hint__ .insert-filling-attributes = INSERT Täytetään määritteitä... @@ -3793,6 +3790,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index f89c504bc..899c9b245 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -3655,9 +3655,6 @@ insert = .frames-print = Cadres & Imprimer .frames-off = Cadres désactivés .frames-on = Cadres en marche - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Saisissez le nom du bloc:__ocs_arg_hint__ .insert-filling-attributes = INSERT Attributs de remplissage... @@ -3794,6 +3791,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 766f615dd..ea5c4c076 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3643,9 +3643,6 @@ insert = .frames-print = फ्रेम्स एंड प्रिंट .frames-off = फ़्रेम बंद .frames-on = फ्रेम्स ऑन - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT ब्लॉक का नाम दर्ज करें:__ocs_arg_hint__ .insert-filling-attributes = INSERT गुण भरना ... @@ -3782,6 +3779,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index ff6b3f133..d59408b22 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -3654,9 +3654,6 @@ insert = .frames-print = Keretek és nyomtatás .frames-off = Keretek ki .frames-on = Keretek bekapcsolva - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Enter blokknév: __ocs_arg_hint__ .insert-filling-attributes = INSERT Attribútumok kitöltése... @@ -3793,6 +3790,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 23b347ebe..c66383d3f 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -3654,9 +3654,6 @@ insert = .frames-print = Cornici e stampa .frames-off = Cornici disattivate .frames-on = Cornici attivate - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Enter nome del blocco:__ocs_arg_hint__ .insert-filling-attributes = INSERT Attributi di riempimento... @@ -3793,6 +3790,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index fe7568e4e..2cbbdba5b 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3643,9 +3643,6 @@ insert = .frames-print = フレームと印刷 .frames-off = フレームをオフ .frames-on = フレームをオン - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT ブロック名を入力:__ocs_arg_hint__ .insert-filling-attributes = INSERT 属性を入力中... @@ -3782,6 +3779,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 7e3a9eaf8..fcb1ba020 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -3654,9 +3654,6 @@ insert = .frames-print = 프레임 및 인쇄 .frames-off = 프레임 끄기 .frames-on = 프레임 켜짐 - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Enter 블록 이름:__ocs_arg_hint__ .insert-filling-attributes = INSERT 채우기 속성... @@ -3793,6 +3790,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 47d3f4ec9..bbfb0bbb2 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -3653,9 +3653,6 @@ insert = .frames-print = Frames & afdrukken .frames-off = Frames uitgeschakeld .frames-on = Frames aan - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Geef symboolnaam in:__ocs_arg_hint__ .insert-filling-attributes = INSERT Attributen vullen... @@ -3792,6 +3789,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 5efb47bec..5f625c4de 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -3654,9 +3654,6 @@ insert = .frames-print = Ramki i druk .frames-off = Ramki wyłączone .frames-on = Ramki włączone - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Enter nazwa bloku: __ocs_arg_hint__ .insert-filling-attributes = INSERT Wypełnianie atrybutów... @@ -3793,6 +3790,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 1936fdc7c..d6ecda397 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -3655,9 +3655,6 @@ insert = .frames-print = Molduras e impressão .frames-off = Molduras desativadas .frames-on = Molduras ativadas - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Insira o nome do bloco:__ocs_arg_hint__ .insert-filling-attributes = INSERT Preenchendo atributos... @@ -3795,6 +3792,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index f1df6f44e..9235e6a4d 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -3656,9 +3656,6 @@ insert = .frames-print = Frames & Print (недоступная ссылка) .frames-off = Снятие рам .frames-on = В кадрах - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Введите имя блока: __ocs_arg_hint__ .insert-filling-attributes = INSERT Наполнение атрибутов... @@ -3795,6 +3792,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 27cd07e41..49c6d8870 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3625,9 +3625,6 @@ insert = .frames-print = Çerçeveler ve Yazdırma .frames-off = Çerçeveler Kapalı .frames-on = Çerçeveler Açık - .select-images = Görüntüleri seçin: - .transparency-mode-on = Saydamlık modunu girin [ON/OFF] : - .transparency-mode-off = Saydamlık modunu girin [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT Blok adını girin:__ocs_arg_hint__ .insert-filling-attributes = INSERT Öznitelikler dolduruluyor... @@ -3761,6 +3758,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] : + .transparency-mode-off = Saydamlık modunu girin [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index cc91fac16..6780264a2 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3640,9 +3640,6 @@ insert = .frames-print = 边框与打印 .frames-off = 边框关 .frames-on = 边框开 - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT 输入块名:__ocs_arg_hint__ .insert-filling-attributes = INSERT 正在填充属性... @@ -3777,6 +3774,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 30a164535..810b88978 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -3652,9 +3652,6 @@ insert = .frames-print = 邊框與列印 .frames-off = 邊框關 .frames-on = 邊框開 - .select-images = Select image(s): - .transparency-mode-on = Enter transparency mode [ON/OFF] : - .transparency-mode-off = Enter transparency mode [ON/OFF] : .insert-prompt-hint = INSERT __ocs_arg_prompt____ocs_arg_hint__: .insert-enter-block-name-hint = INSERT 輸入圖塊名:__ocs_arg_hint__ .insert-filling-attributes = INSERT 正在填充屬性... @@ -3789,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] : + .transparency-mode-off = Enter transparency mode [ON/OFF] : .xref-attach-menu = Attach... .xref-change-path-type = Change Path Type .xref-find-and-replace-menu = Find and Replace... diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 0b3a4a473..d2150f076 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1197,9 +1197,6 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Frames & Print" => Some(("insert", "frames-print")), "Frames Off" => Some(("insert", "frames-off")), "Frames On" => Some(("insert", "frames-on")), - "Select image(s):" => Some(("insert", "select-images")), - "Enter transparency mode [ON/OFF] :" => Some(("insert", "transparency-mode-on")), - "Enter transparency mode [ON/OFF] :" => Some(("insert", "transparency-mode-off")), "Frames On, Not Plotted" => Some(("draw", "frames-on-not-plotted")), "Freeze" => Some(("layers", "freeze")), "From DXF" => Some(("properties", "from-dxf")), @@ -3306,6 +3303,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] :" => Some(("insert", "transparency-mode-on")), + "Enter transparency mode [ON/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")), From 5de0ce069fe17e53d09de73eb668397c6264f343 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:17:31 +0300 Subject: [PATCH 10/13] Image shader: fully transparent pixels are discarded A transparent pixel wrote depth, hiding lines and objects under the image that were drawn after it. Co-Authored-By: Claude Opus 5.5 --- src/shaders/image.wgsl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/shaders/image.wgsl b/src/shaders/image.wgsl index 5bab2a647..b8af298c4 100644 --- a/src/shaders/image.wgsl +++ b/src/shaders/image.wgsl @@ -64,5 +64,10 @@ fn vs_main(in: VertIn) -> VertOut { fn fs_main(in: VertOut) -> @location(0) vec4 { let col = textureSample(img_texture, img_sampler, in.uv); 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(col.rgb, alpha * img_params.opacity); } From 2e47e1fa95f86a9ffb36f23b83998e11518638f5 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 28 Sep 2026 13:45:11 +0300 Subject: [PATCH 11/13] chore: pin opencadcodec ad16215, opencadkernel 1e02630 Brings in the DWFFRAME/DGNFRAME header variables (codec #63) and robust 2D tangency plus ellipse-circle intersection (kernel #68). Pins opencadcodec ad16215, opencadkernel 1e02630, opencadgraph 7ef5ae4. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 ++++---- Cargo.toml | 8 ++++---- crates/ocs_plugin_api/Cargo.toml | 4 ++-- crates/ocs_web_worker/Cargo.toml | 2 +- plugins/opencad-python/Cargo.lock | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dbfce924f..db628fe75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4202,7 +4202,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=a35f43e#a35f43e8a42ddeec9decd759f1482a2bcfa08aa4" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=ad16215#ad16215faa2b625d52346bdb1f147596567f70f7" dependencies = [ "ahash 0.8.12", "anyhow", @@ -4225,7 +4225,7 @@ dependencies = [ [[package]] name = "opencadgraph" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=8b8c7f3#8b8c7f3610a5f5eb7758096ce201deed14d59cc1" +source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=7ef5ae4#7ef5ae4a283ee0f5e0c08fde71d3c35e3455c418" dependencies = [ "opencadkernel", "serde_json", @@ -4234,7 +4234,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=ee41029#ee41029c26607b26ed0004034d808b8318f941b3" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=1e02630#1e02630af0cc0e79a27fc0c4d4e874a84fb168ad" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4245,7 +4245,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=ee41029#ee41029c26607b26ed0004034d808b8318f941b3" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=1e02630#1e02630af0cc0e79a27fc0c4d4e874a84fb168ad" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index b77cfd2a8..80c0ecd21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = "a35f43e", features = ["serde"] } -kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "ee41029", features = ["acis", "offset"] } -kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "ee41029" } -graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "8b8c7f3" } +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 = "1e02630", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "1e02630" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "7ef5ae4" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index c220edad4..7056ce6d0 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -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 = "a35f43e", 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 = "a35f43e", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "ad16215", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 50bc84df7..38b1579b5 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "a35f43e", 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" diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index d37ba7a1e..14f6b3db9 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -1265,7 +1265,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=a35f43e#a35f43e8a42ddeec9decd759f1482a2bcfa08aa4" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=ad16215#ad16215faa2b625d52346bdb1f147596567f70f7" dependencies = [ "ahash 0.8.12", "anyhow", From eac19348d5ef05912fd3e21c70621025077842c3 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 28 Sep 2026 14:08:36 +0300 Subject: [PATCH 12/13] refactor(draw): TRIM geometry helpers come from the kernel; drop a probe test #1511 added an orthogonal ellipse projection and a nearest-end arc_parameter to draw/modify/geom.rs, the latter shadowing the kernel function of the same name. Both now live in opencadkernel 237827e and are re-exported here, so every kernel caller gets them too. test_coincident_line_and_arc_grip_move only printed values and asserted nothing; removed. Pins opencadkernel 237827e, opencadgraph fe36957. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 6 +- Cargo.toml | 6 +- src/app/update/viewport.rs | 64 -------------------- src/modules/draw/modify/geom.rs | 101 +------------------------------- 4 files changed, 7 insertions(+), 170 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db628fe75..e0ef979bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4225,7 +4225,7 @@ dependencies = [ [[package]] name = "opencadgraph" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=7ef5ae4#7ef5ae4a283ee0f5e0c08fde71d3c35e3455c418" +source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=fe36957#fe3695774499c0faca16e93b2c85d1751fe13475" dependencies = [ "opencadkernel", "serde_json", @@ -4234,7 +4234,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=1e02630#1e02630af0cc0e79a27fc0c4d4e874a84fb168ad" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=237827e#237827ef22b013487cf157dcb35730c52ae0c763" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4245,7 +4245,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=1e02630#1e02630af0cc0e79a27fc0c4d4e874a84fb168ad" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=237827e#237827ef22b013487cf157dcb35730c52ae0c763" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 80c0ecd21..a9d31c7fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,9 +34,9 @@ clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" 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 = "1e02630", features = ["acis", "offset"] } -kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "1e02630" } -graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "7ef5ae4" } +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"] } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index f189ea0b0..50a31730d 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -7009,70 +7009,6 @@ mod selection_preview_tests { ); } - #[test] - fn test_coincident_line_and_arc_grip_move() { - use codec::{types::Vector3, EntityType}; - let mut app = OpenCADStudio::new_for_test(); - app.automation_op(r#"{"op":"new"}"#); - let i = app.active_tab; - app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); - - let pt = glam::DVec3::new(-2.8146711761917635, 12.22681563180158, 0.0); - - // Line ending at pt - let mut line = codec::entities::Line::new(); - line.start = Vector3::new(-20.0, 12.22681563180158, 0.0); - line.end = Vector3::new(pt.x, pt.y, pt.z); - let line_h = app.tabs[i].scene.add_entity(EntityType::Line(line)); - - // Arc starting at pt - let mut arc = codec::entities::Arc::new(); - arc.center = Vector3::new(0.0, 0.0, 0.0); - arc.radius = (pt.x * pt.x + pt.y * pt.y).sqrt(); - let sa = (pt.y).atan2(pt.x); - arc.start_angle = sa; - arc.end_angle = sa + 1.0; - let arc_h = app.tabs[i].scene.add_entity(EntityType::Arc(arc)); - - app.tabs[i].scene.selected.insert(line_h); - app.tabs[i].scene.selected.insert(arc_h); - app.refresh_selected_grips(); - - println!("selected grips count: {}", app.tabs[i].selected_grips.len()); - for (g_idx, g) in app.tabs[i].selected_grips.iter().enumerate() { - let h = app.tabs[i].selected_grip_handles[g_idx]; - println!("grip idx {g_idx}: handle {:?}, id {}, world {:?}", h, g.id, g.world); - } - - let bounds = iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)); - let cursor = app.tabs[i].scene.camera.borrow().project(pt, bounds).unwrap(); - app.tabs[i].scene.selection.borrow_mut().last_move_pos = - Some(iced::Point::new(cursor.x, cursor.y)); - let _ = app.on_viewport_left_press(); - - let active = app.tabs[i].active_grip.as_ref().expect("grip must engage"); - println!("active grip targets len: {}", active.targets.len()); - for t in &active.targets { - println!("target: handle {:?}, grip_id {}, last_world {:?}", t.handle, t.grip_id, t.last_world); - } - - // Drag cursor by (5.0, 5.0) - let new_target = pt + glam::DVec3::new(5.0, 5.0, 0.0); - let new_cursor = app.tabs[i].scene.camera.borrow().project(new_target, bounds).unwrap(); - let _ = app.on_viewport_move(iced::Point::new(new_cursor.x, new_cursor.y)); - - let EntityType::Line(moved_line) = app.tabs[i].scene.document.get_entity(line_h).unwrap() else { panic!() }; - let EntityType::Arc(moved_arc) = app.tabs[i].scene.document.get_entity(arc_h).unwrap() else { panic!() }; - - println!("moved line end: {:?}", moved_line.end); - let arc_start_moved = glam::DVec3::new( - moved_arc.center.x + moved_arc.radius * moved_arc.start_angle.cos(), - moved_arc.center.y + moved_arc.radius * moved_arc.start_angle.sin(), - 0.0, - ); - println!("moved arc start: {:?}", arc_start_moved); - } - #[test] fn constraint_glyph_tooltip_appears_after_hover_dwell() { use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; diff --git a/src/modules/draw/modify/geom.rs b/src/modules/draw/modify/geom.rs index f5bb524b9..cd7a78721 100644 --- a/src/modules/draw/modify/geom.rs +++ b/src/modules/draw/modify/geom.rs @@ -15,106 +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::{lerp, normalize_angle}; - -/// Where `angle` sits along the arc from `start` to `end`, as `0.0..=1.0`. -/// -/// Unlike `kernel::geom2d::arc_parameter` which clamps any angle outside the arc to 1.0, -/// this clamps angles in the gap outside the arc to 0.0 or 1.0 depending on which endpoint -/// they are closer to. This prevents cursor jitter near `start` from snapping to 1.0. -pub fn arc_parameter(angle: f64, start: f64, end: f64) -> f64 { - let span = kernel::geom2d::arc_span(start, end); - if span < 1e-12 { - return 0.0; - } - let norm_angle = kernel::geom2d::normalize_angle(angle); - let norm_start = kernel::geom2d::normalize_angle(start); - let delta = (norm_angle - norm_start).rem_euclid(std::f64::consts::TAU); - if delta <= span { - (delta / span).clamp(0.0, 1.0) - } else { - let gap = std::f64::consts::TAU - span; - let past_end = delta - span; - if past_end <= gap * 0.5 { - 1.0 - } else { - 0.0 - } - } -} - -/// Finds the closest parametric parameter `t` on an origin-centered ellipse -/// `(x/a)^2 + (y/b)^2 = 1` for an arbitrary query point `(u, v)` in local coordinates, -/// using David Eberly's exact orthogonal projection algorithm. -/// -/// Unlike radial projection `(v/b).atan2(u/a)` which deviates by up to 20-40 degrees -/// when the query point is slightly off the ellipse perimeter (e.g. within screen pick box), -/// this finds the true orthogonal projection on the ellipse regardless of zoom level. -pub fn ellipse_closest_parameter(a: f64, b: f64, u: f64, v: f64) -> f64 { - if a < 1e-12 || b < 1e-12 { - return 0.0; - } - if (a - b).abs() < 1e-9 { - return v.atan2(u); - } - if a < b { - let t_swapped = ellipse_closest_parameter(b, a, v, u); - return std::f64::consts::FRAC_PI_2 - t_swapped; - } - - let sign_x = if u >= 0.0 { 1.0 } else { -1.0 }; - let sign_y = if v >= 0.0 { 1.0 } else { -1.0 }; - let u_abs = u.abs(); - let v_abs = v.abs(); - - if v_abs < 1e-12 { - let x = if u_abs < (a * a - b * b) / a { - a * a * u_abs / (a * a - b * b) - } else { - a - }; - let y = b * (1.0 - (x / a).powi(2)).max(0.0).sqrt(); - return (sign_y * y / b).atan2(sign_x * x / a); - } - - if u_abs < 1e-12 { - return (sign_y * 1.0).atan2(0.0); - } - - let a2 = a * a; - let b2 = b * b; - let mut t0 = -b2 + b * v_abs; - let mut t1 = -b2 + (a * u_abs).hypot(b * v_abs); - let mut t = t0; - - for _ in 0..25 { - let t_a = t + a2; - let t_b = t + b2; - if t_a.abs() < 1e-12 || t_b.abs() < 1e-12 { - break; - } - let f = (a * u_abs / t_a).powi(2) + (b * v_abs / t_b).powi(2) - 1.0; - if f.abs() < 1e-12 { - break; - } - let df = -2.0 * (a2 * u_abs * u_abs / (t_a.powi(3)) + b2 * v_abs * v_abs / (t_b.powi(3))); - let t_next = t - f / df; - if t_next > t0 && t_next < t1 { - t = t_next; - } else { - if f > 0.0 { - t0 = t; - } else { - t1 = t; - } - t = 0.5 * (t0 + t1); - } - } - - let x = a2 * u_abs / (t + a2); - let y = b2 * v_abs / (t + b2); - (sign_y * y / b).atan2(sign_x * x / a) -} +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; From 63230e6a4e85a6374d0cb540928c6aaf2fd64b6f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 28 Sep 2026 14:14:34 +0300 Subject: [PATCH 13/13] test(pdf-export): build ImageModel with use_alpha #1513 added the field; this integration test's struct literal was not updated, so the test target no longer compiled. Co-Authored-By: Claude Opus 5.5 --- tests/pdf_export_images_check.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/pdf_export_images_check.rs b/tests/pdf_export_images_check.rs index 569134af6..6859e6a1c 100644 --- a/tests/pdf_export_images_check.rs +++ b/tests/pdf_export_images_check.rs @@ -146,6 +146,7 @@ fn image() -> PlotImage { }) .collect(), pixelated: true, + use_alpha: true, }, clips: Vec::new(), }