Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(document,render): symmetric relation

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (May 20, 2026, 5:10 PM +0300) commit ab204e57 parent 8cbe6569 change-id zrovxoqn
+380 -22
+44 -2
crates/bone-app/src/relation_tools.rs
··· 15 15 Equal, 16 16 Concentric, 17 17 Midpoint, 18 + Symmetric, 18 19 Fix, 19 20 } 20 21 ··· 42 43 Self::Equal, 43 44 Self::Concentric, 44 45 Self::Midpoint, 46 + Self::Symmetric, 45 47 Self::Fix, 46 48 ]; 47 49 ··· 91 93 key: "rel.midpoint", 92 94 label: strings::TOOL_MIDPOINT, 93 95 check: midpoint, 96 + }, 97 + Self::Symmetric => RelationDescriptor { 98 + key: "rel.symmetric", 99 + label: strings::TOOL_SYMMETRIC, 100 + check: symmetric, 94 101 }, 95 102 Self::Fix => RelationDescriptor { 96 103 key: "rel.fix", ··· 229 236 ) 230 237 } 231 238 239 + fn symmetric(sketch: &Sketch, selection: &[SketchEntityId]) -> Eligibility { 240 + let disabled = || Eligibility::Disabled(strings::REL_HINT_SYMMETRIC); 241 + if selection.len() != 3 { 242 + return disabled(); 243 + } 244 + let kinds: Vec<(SketchEntityId, SketchEntityKind)> = selection 245 + .iter() 246 + .filter_map(|id| sketch.entities().get(*id).map(|e| (*id, e.kind()))) 247 + .collect(); 248 + if kinds.len() != 3 { 249 + return disabled(); 250 + } 251 + let points: Vec<SketchEntityId> = kinds 252 + .iter() 253 + .filter(|(_, k)| *k == SketchEntityKind::Point) 254 + .map(|(id, _)| *id) 255 + .collect(); 256 + let lines: Vec<SketchEntityId> = kinds 257 + .iter() 258 + .filter(|(_, k)| *k == SketchEntityKind::Line) 259 + .map(|(id, _)| *id) 260 + .collect(); 261 + let [a, b] = points.as_slice() else { 262 + return disabled(); 263 + }; 264 + let [axis] = lines.as_slice() else { 265 + return disabled(); 266 + }; 267 + Eligibility::Eligible(SketchRelation::Symmetric { 268 + a: *a, 269 + b: *b, 270 + axis: *axis, 271 + }) 272 + } 273 + 232 274 fn pair_eligibility( 233 275 sketch: &Sketch, 234 276 selection: &[SketchEntityId], ··· 307 349 308 350 #[test] 309 351 fn relation_kind_all_matches_descriptor_arms() { 310 - assert_eq!(RelationKind::ALL.len(), 10); 352 + assert_eq!(RelationKind::ALL.len(), 11); 311 353 let keys: std::collections::BTreeSet<_> = 312 354 RelationKind::ALL.iter().map(|k| k.key()).collect(); 313 - assert_eq!(keys.len(), 10, "every kind has a unique widget key"); 355 + assert_eq!(keys.len(), 11, "every kind has a unique widget key"); 314 356 } 315 357 316 358 #[test]
+110 -12
crates/bone-app/src/strings.rs
··· 38 38 pub const TOOL_CONCENTRIC: StringKey = StringKey::new("tool.concentric"); 39 39 pub const TOOL_FIX: StringKey = StringKey::new("tool.fix"); 40 40 pub const TOOL_MIDPOINT: StringKey = StringKey::new("tool.midpoint"); 41 + pub const TOOL_SYMMETRIC: StringKey = StringKey::new("tool.symmetric"); 41 42 42 43 pub const REL_HINT_ONE_LINE: StringKey = StringKey::new("rel.hint.one_line"); 43 44 pub const REL_HINT_TWO_LINES: StringKey = StringKey::new("rel.hint.two_lines"); ··· 47 48 pub const REL_HINT_EQUAL: StringKey = StringKey::new("rel.hint.equal"); 48 49 pub const REL_HINT_MIDPOINT: StringKey = StringKey::new("rel.hint.midpoint"); 49 50 pub const REL_HINT_ENTITY: StringKey = StringKey::new("rel.hint.entity"); 51 + pub const REL_HINT_SYMMETRIC: StringKey = StringKey::new("rel.hint.symmetric"); 52 + pub const PROPERTY_ROW_AXIS: StringKey = StringKey::new("property.row.axis"); 50 53 51 54 pub const TOOL_SMART_DIMENSION: StringKey = StringKey::new("tool.smart_dimension"); 52 55 pub const TOOL_RADIUS: StringKey = StringKey::new("tool.radius"); ··· 130 133 pub const MENU_VIEW_ZOOM_FIT: StringKey = StringKey::new("menu.view.zoom_fit"); 131 134 pub const MENU_PLACEHOLDER_COMING_SOON: StringKey = StringKey::new("menu.placeholder.coming_soon"); 132 135 pub const MENU_TOOLS_OPTIONS: StringKey = StringKey::new("menu.tools.options"); 136 + pub const MENU_TOOLS_KEYBOARD: StringKey = StringKey::new("menu.tools.keyboard"); 137 + pub const KEYBOARD_DIALOG_TITLE: StringKey = StringKey::new("keyboard.dialog.title"); 133 138 pub const SETTINGS_DIALOG_TITLE: StringKey = StringKey::new("settings.dialog.title"); 134 139 pub const SETTINGS_PICK_APERTURE_LABEL: StringKey = StringKey::new("settings.pick_aperture.label"); 135 140 pub const SETTINGS_PICK_APERTURE_HINT: StringKey = StringKey::new("settings.pick_aperture.hint"); 136 141 pub const SETTINGS_RESET: StringKey = StringKey::new("settings.reset"); 137 142 pub const SETTINGS_CLOSE: StringKey = StringKey::new("settings.close"); 138 - pub const SHORTCUT_QUIT: StringKey = StringKey::new("shortcut.quit"); 139 - pub const SHORTCUT_UNDO: StringKey = StringKey::new("shortcut.undo"); 140 - pub const SHORTCUT_REDO: StringKey = StringKey::new("shortcut.redo"); 141 - pub const SHORTCUT_ZOOM_FIT: StringKey = StringKey::new("shortcut.zoom_fit"); 143 + pub const HOTKEY_SECTION_HEADING: StringKey = StringKey::new("hotkey.section.heading"); 144 + pub const HOTKEY_RECORDING_HINT: StringKey = StringKey::new("hotkey.recording.hint"); 145 + pub const HOTKEY_RECORDING_PROMPT: StringKey = StringKey::new("hotkey.recording.prompt"); 146 + pub const HOTKEY_UNBOUND_LABEL: StringKey = StringKey::new("hotkey.unbound.label"); 147 + pub const HOTKEY_LABEL_SKETCH: StringKey = StringKey::new("hotkey.label.sketch"); 148 + pub const HOTKEY_LABEL_ESCAPE: StringKey = StringKey::new("hotkey.label.escape"); 149 + pub const HOTKEY_LABEL_SMART_DIMENSION: StringKey = StringKey::new("hotkey.label.smart_dimension"); 150 + pub const HOTKEY_LABEL_TRIM: StringKey = StringKey::new("hotkey.label.trim"); 151 + pub const HOTKEY_LABEL_EXTEND: StringKey = StringKey::new("hotkey.label.extend"); 152 + pub const HOTKEY_LABEL_MIRROR: StringKey = StringKey::new("hotkey.label.mirror"); 153 + pub const HOTKEY_LABEL_CONSTRUCTION_TOGGLE: StringKey = 154 + StringKey::new("hotkey.label.construction_toggle"); 155 + pub const HOTKEY_LABEL_UNDO: StringKey = StringKey::new("hotkey.label.undo"); 156 + pub const HOTKEY_LABEL_REDO: StringKey = StringKey::new("hotkey.label.redo"); 157 + pub const HOTKEY_LABEL_NEW: StringKey = StringKey::new("hotkey.label.new"); 158 + pub const HOTKEY_LABEL_OPEN: StringKey = StringKey::new("hotkey.label.open"); 159 + pub const HOTKEY_LABEL_SAVE: StringKey = StringKey::new("hotkey.label.save"); 160 + pub const HOTKEY_LABEL_SELECT_ALL: StringKey = StringKey::new("hotkey.label.select_all"); 161 + pub const HOTKEY_LABEL_DELETE_SELECTION: StringKey = 162 + StringKey::new("hotkey.label.delete_selection"); 163 + pub const HOTKEY_LABEL_ZOOM_FIT: StringKey = StringKey::new("hotkey.label.zoom_fit"); 164 + pub const HOTKEY_LABEL_SHORTCUT_BAR: StringKey = StringKey::new("hotkey.label.shortcut_bar"); 165 + pub const HOTKEY_LABEL_QUIT: StringKey = StringKey::new("hotkey.label.quit"); 166 + pub const NOTIFY_COMING_SOON: StringKey = StringKey::new("notify.coming_soon"); 167 + pub const NOTIFY_MIRROR_SELECTION_HINT: StringKey = StringKey::new("notify.mirror.selection_hint"); 168 + pub const NOTIFY_HOTKEY_CONFLICT: StringKey = StringKey::new("notify.hotkey.conflict"); 169 + pub const SHORTCUT_BAR_TITLE: StringKey = StringKey::new("shortcut_bar.title"); 142 170 143 171 pub const PROPERTY_PANE_NO_SELECTION: StringKey = StringKey::new("property.no_selection"); 144 172 pub const PROPERTY_ROW_KIND: StringKey = StringKey::new("property.row.kind"); ··· 227 255 (TOOL_CONCENTRIC, "Concentric"), 228 256 (TOOL_FIX, "Fix"), 229 257 (TOOL_MIDPOINT, "Midpoint"), 258 + (TOOL_SYMMETRIC, "Symmetric"), 230 259 (REL_HINT_ONE_LINE, "Select a line"), 231 260 (REL_HINT_TWO_LINES, "Select two lines"), 232 261 (REL_HINT_COINCIDENT, "Select a point and another entity"), ··· 235 264 (REL_HINT_EQUAL, "Select two lines or two curves"), 236 265 (REL_HINT_MIDPOINT, "Select a point and a line"), 237 266 (REL_HINT_ENTITY, "Select an entity"), 267 + (REL_HINT_SYMMETRIC, "Select two points and a line"), 268 + (PROPERTY_ROW_AXIS, "Axis"), 238 269 (TOOL_SMART_DIMENSION, "Smart Dimension"), 239 270 (TOOL_RADIUS, "Radius"), 240 271 (TOOL_DIAMETER, "Diameter"), ··· 320 351 (MENU_VIEW_ZOOM_FIT, "Zoom to Fit"), 321 352 (MENU_PLACEHOLDER_COMING_SOON, "Coming Soon"), 322 353 (MENU_TOOLS_OPTIONS, "Options..."), 354 + (MENU_TOOLS_KEYBOARD, "Customize Keyboard..."), 355 + (KEYBOARD_DIALOG_TITLE, "Customize Keyboard"), 323 356 (SETTINGS_DIALOG_TITLE, "Selection options"), 324 357 (SETTINGS_PICK_APERTURE_LABEL, "Pick aperture"), 325 358 ( ··· 328 361 ), 329 362 (SETTINGS_RESET, "Reset"), 330 363 (SETTINGS_CLOSE, "Close"), 331 - (SHORTCUT_QUIT, "Ctrl+Q"), 332 - (SHORTCUT_UNDO, "Ctrl+Z"), 333 - (SHORTCUT_REDO, "Ctrl+Y"), 334 - (SHORTCUT_ZOOM_FIT, "F"), 364 + (HOTKEY_SECTION_HEADING, "Keyboard shortcuts"), 365 + (HOTKEY_RECORDING_HINT, "Click to record, Esc to cancel"), 366 + (HOTKEY_RECORDING_PROMPT, "Press a key..."), 367 + (HOTKEY_UNBOUND_LABEL, "Unbound"), 368 + (HOTKEY_LABEL_SKETCH, "Sketch"), 369 + (HOTKEY_LABEL_ESCAPE, "Cancel / Exit Sketch"), 370 + (HOTKEY_LABEL_SMART_DIMENSION, "Smart Dimension"), 371 + (HOTKEY_LABEL_TRIM, "Trim Entities"), 372 + (HOTKEY_LABEL_EXTEND, "Extend Entities"), 373 + (HOTKEY_LABEL_MIRROR, "Mirror Entities"), 374 + (HOTKEY_LABEL_CONSTRUCTION_TOGGLE, "Toggle Construction"), 375 + (HOTKEY_LABEL_UNDO, "Undo"), 376 + (HOTKEY_LABEL_REDO, "Redo"), 377 + (HOTKEY_LABEL_NEW, "New"), 378 + (HOTKEY_LABEL_OPEN, "Open"), 379 + (HOTKEY_LABEL_SAVE, "Save"), 380 + (HOTKEY_LABEL_SELECT_ALL, "Select All"), 381 + (HOTKEY_LABEL_DELETE_SELECTION, "Delete"), 382 + (HOTKEY_LABEL_ZOOM_FIT, "Zoom to Fit"), 383 + (HOTKEY_LABEL_SHORTCUT_BAR, "Shortcut Bar"), 384 + (HOTKEY_LABEL_QUIT, "Quit"), 385 + (NOTIFY_COMING_SOON, "Coming soon"), 386 + ( 387 + NOTIFY_MIRROR_SELECTION_HINT, 388 + "Select entities to mirror plus one axis line.", 389 + ), 390 + ( 391 + NOTIFY_HOTKEY_CONFLICT, 392 + "Shortcut already used by another command.", 393 + ), 394 + (SHORTCUT_BAR_TITLE, "Shortcut Bar"), 335 395 (PROPERTY_PANE_NO_SELECTION, "Nothing selected"), 336 396 (PROPERTY_ROW_KIND, "Type"), 337 397 (PROPERTY_ROW_X, "X"), ··· 407 467 (TOOL_CONCENTRIC, "[!! Concêntric !!]"), 408 468 (TOOL_FIX, "[!! Fîx !!]"), 409 469 (TOOL_MIDPOINT, "[!! Mîdpoint !!]"), 470 + (TOOL_SYMMETRIC, "[!! Symmêtric !!]"), 410 471 (REL_HINT_ONE_LINE, "[!! Sêlect a lîne !!]"), 411 472 (REL_HINT_TWO_LINES, "[!! Sêlect twô lînes !!]"), 412 473 ( ··· 421 482 (REL_HINT_EQUAL, "[!! Sêlect twô lînes ôr twô cûrves !!]"), 422 483 (REL_HINT_MIDPOINT, "[!! Sêlect a pôint ând a lîne !!]"), 423 484 (REL_HINT_ENTITY, "[!! Sêlect an êntity !!]"), 485 + (REL_HINT_SYMMETRIC, "[!! Sêlect twô pôints ând a lîne !!]"), 486 + (PROPERTY_ROW_AXIS, "[!! Âxis !!]"), 424 487 (TOOL_SMART_DIMENSION, "[!! Smârt Dimensiôn !!]"), 425 488 (TOOL_RADIUS, "[!! Râdius !!]"), 426 489 (TOOL_DIAMETER, "[!! Dîameter !!]"), ··· 509 572 (MENU_VIEW_ZOOM_FIT, "[!! Zôom to Fît !!]"), 510 573 (MENU_PLACEHOLDER_COMING_SOON, "[!! Côming Sôon !!]"), 511 574 (MENU_TOOLS_OPTIONS, "[!! Ôptions... !!]"), 575 + (MENU_TOOLS_KEYBOARD, "[!! Custômize Kêyboard... !!]"), 576 + (KEYBOARD_DIALOG_TITLE, "[!! Custômize Kêyboard !!]"), 512 577 (SETTINGS_DIALOG_TITLE, "[!! Sêlection ôptions !!]"), 513 578 (SETTINGS_PICK_APERTURE_LABEL, "[!! Pîck âperture !!]"), 514 579 ( ··· 517 582 ), 518 583 (SETTINGS_RESET, "[!! Resêt !!]"), 519 584 (SETTINGS_CLOSE, "[!! Clôse !!]"), 520 - (SHORTCUT_QUIT, "Ctrl+Q"), 521 - (SHORTCUT_UNDO, "Ctrl+Z"), 522 - (SHORTCUT_REDO, "Ctrl+Y"), 523 - (SHORTCUT_ZOOM_FIT, "F"), 585 + (HOTKEY_SECTION_HEADING, "[!! Kêyboard shortcûts !!]"), 586 + ( 587 + HOTKEY_RECORDING_HINT, 588 + "[!! Clîck to recôrd, Êsc to cancêl !!]", 589 + ), 590 + (HOTKEY_RECORDING_PROMPT, "[!! Prêss a kêy... !!]"), 591 + (HOTKEY_UNBOUND_LABEL, "[!! Unbôund !!]"), 592 + (HOTKEY_LABEL_SKETCH, "[!! Skêtch !!]"), 593 + (HOTKEY_LABEL_ESCAPE, "[!! Cancêl / Êxit Skêtch !!]"), 594 + (HOTKEY_LABEL_SMART_DIMENSION, "[!! Smârt Dîmension !!]"), 595 + (HOTKEY_LABEL_TRIM, "[!! Trîm Êntities !!]"), 596 + (HOTKEY_LABEL_EXTEND, "[!! Extênd Êntities !!]"), 597 + (HOTKEY_LABEL_MIRROR, "[!! Mîrror Êntities !!]"), 598 + ( 599 + HOTKEY_LABEL_CONSTRUCTION_TOGGLE, 600 + "[!! Tôggle Constrûction !!]", 601 + ), 602 + (HOTKEY_LABEL_UNDO, "[!! Undô !!]"), 603 + (HOTKEY_LABEL_REDO, "[!! Redô !!]"), 604 + (HOTKEY_LABEL_NEW, "[!! Néw !!]"), 605 + (HOTKEY_LABEL_OPEN, "[!! Ôpen !!]"), 606 + (HOTKEY_LABEL_SAVE, "[!! Sâve !!]"), 607 + (HOTKEY_LABEL_SELECT_ALL, "[!! Sêlect Âll !!]"), 608 + (HOTKEY_LABEL_DELETE_SELECTION, "[!! Delête !!]"), 609 + (HOTKEY_LABEL_ZOOM_FIT, "[!! Zôom to Fît !!]"), 610 + (HOTKEY_LABEL_SHORTCUT_BAR, "[!! Shortcût Bâr !!]"), 611 + (HOTKEY_LABEL_QUIT, "[!! Quît !!]"), 612 + (NOTIFY_COMING_SOON, "[!! Côming sôon !!]"), 613 + ( 614 + NOTIFY_MIRROR_SELECTION_HINT, 615 + "[!! Sêlect entîties tô mîrror plûs ône âxis lîne. !!]", 616 + ), 617 + ( 618 + NOTIFY_HOTKEY_CONFLICT, 619 + "[!! Shortcût alrêady ûsed by anôther commând. !!]", 620 + ), 621 + (SHORTCUT_BAR_TITLE, "[!! Shortcût Bâr !!]"), 524 622 (PROPERTY_PANE_NO_SELECTION, "[!! Nôthing sêlected !!]"), 525 623 (PROPERTY_ROW_KIND, "[!! Týpe !!]"), 526 624 (PROPERTY_ROW_X, "X"),
+6
crates/bone-document/src/sketch/mod.rs
··· 276 276 } 277 277 _ => false, 278 278 }, 279 + SketchRelation::Symmetric { a, b, axis } => { 280 + self.kind_of(a)? == K::Point 281 + && self.kind_of(b)? == K::Point 282 + && self.kind_of(axis)? == K::Line 283 + && a != b 284 + } 279 285 SketchRelation::Fix(a) => { 280 286 self.kind_of(a)?; 281 287 true
+14 -6
crates/bone-document/src/sketch/relation.rs
··· 15 15 point: SketchEntityId, 16 16 line: SketchEntityId, 17 17 }, 18 + Symmetric { 19 + a: SketchEntityId, 20 + b: SketchEntityId, 21 + axis: SketchEntityId, 22 + }, 18 23 Fix(SketchEntityId), 19 24 } 20 25 ··· 27 32 | Self::Perpendicular(a, b) 28 33 | Self::Tangent(a, b) 29 34 | Self::Equal(a, b) 30 - | Self::Concentric(a, b) => RelationRefs([Some(a), Some(b)]), 31 - Self::Midpoint { point, line } => RelationRefs([Some(point), Some(line)]), 32 - Self::Horizontal(a) | Self::Vertical(a) | Self::Fix(a) => RelationRefs([Some(a), None]), 35 + | Self::Concentric(a, b) => RelationRefs([Some(a), Some(b), None]), 36 + Self::Midpoint { point, line } => RelationRefs([Some(point), Some(line), None]), 37 + Self::Symmetric { a, b, axis } => RelationRefs([Some(a), Some(b), Some(axis)]), 38 + Self::Horizontal(a) | Self::Vertical(a) | Self::Fix(a) => { 39 + RelationRefs([Some(a), None, None]) 40 + } 33 41 } 34 42 } 35 43 ··· 43 51 | Self::Equal(a, b) 44 52 | Self::Concentric(a, b) => Some((a, b)), 45 53 Self::Midpoint { point, line } => Some((point, line)), 46 - Self::Horizontal(_) | Self::Vertical(_) | Self::Fix(_) => None, 54 + Self::Symmetric { .. } | Self::Horizontal(_) | Self::Vertical(_) | Self::Fix(_) => None, 47 55 } 48 56 } 49 57 } 50 58 51 59 #[derive(Copy, Clone, Debug, PartialEq)] 52 - pub struct RelationRefs([Option<SketchEntityId>; 2]); 60 + pub struct RelationRefs([Option<SketchEntityId>; 3]); 53 61 54 62 impl IntoIterator for RelationRefs { 55 63 type Item = SketchEntityId; 56 - type IntoIter = core::iter::Flatten<core::array::IntoIter<Option<SketchEntityId>, 2>>; 64 + type IntoIter = core::iter::Flatten<core::array::IntoIter<Option<SketchEntityId>, 3>>; 57 65 58 66 fn into_iter(self) -> Self::IntoIter { 59 67 self.0.into_iter().flatten()
+22
crates/bone-document/src/sketch/solve.rs
··· 418 418 SketchRelation::Equal(a, b) => lower_equal(sketch, a, b, points, radii), 419 419 SketchRelation::Concentric(a, b) => lower_concentric(sketch, a, b, points), 420 420 SketchRelation::Midpoint { point, line } => lower_midpoint(sketch, point, line, points), 421 + SketchRelation::Symmetric { a, b, axis } => lower_symmetric(sketch, a, b, axis, points), 421 422 SketchRelation::Fix(id) => lower_fix(sketch, id, points, radii, parameters), 422 423 } 424 + } 425 + 426 + fn lower_symmetric( 427 + sketch: &Sketch, 428 + a: SketchEntityId, 429 + b: SketchEntityId, 430 + axis: SketchEntityId, 431 + points: &BTreeMap<SketchEntityId, PointHandle>, 432 + ) -> Vec<Residual> { 433 + let (Some(a_handle), Some(b_handle), Some(line)) = ( 434 + points.get(&a).copied(), 435 + points.get(&b).copied(), 436 + line_handle(sketch, axis, points), 437 + ) else { 438 + return Vec::new(); 439 + }; 440 + vec![Residual::Symmetric { 441 + a: a_handle, 442 + b: b_handle, 443 + axis: line, 444 + }] 423 445 } 424 446 425 447 fn lower_coincident(
crates/bone-render/assets/relation_glyphs.png

This is a binary file and will not be displayed.

+4
crates/bone-render/src/pipelines/glyph.rs
··· 409 409 segment(x, y, 6.0, 16.0, 26.0, 16.0, 1.8), 410 410 segment(x, y, 16.0, 11.0, 16.0, 21.0, 1.8), 411 411 ), 412 + RelationGlyphKind::Symmetric => combine( 413 + segment(x, y, 16.0, 4.0, 16.0, 28.0, 1.4), 414 + combine(disc(x, y, 9.0, 16.0, 3.5), disc(x, y, 23.0, 16.0, 3.5)), 415 + ), 412 416 } 413 417 } 414 418
+5 -1
crates/bone-render/src/scene.rs
··· 149 149 Concentric = 7, 150 150 Fix = 8, 151 151 Midpoint = 9, 152 + Symmetric = 10, 152 153 } 153 154 154 155 impl RelationGlyphKind { ··· 170 171 7 => Some(Self::Concentric), 171 172 8 => Some(Self::Fix), 172 173 9 => Some(Self::Midpoint), 174 + 10 => Some(Self::Symmetric), 173 175 _ => None, 174 176 } 175 177 } ··· 186 188 SketchRelation::Equal(_, _) => Self::Equal, 187 189 SketchRelation::Concentric(_, _) => Self::Concentric, 188 190 SketchRelation::Midpoint { .. } => Self::Midpoint, 191 + SketchRelation::Symmetric { .. } => Self::Symmetric, 189 192 SketchRelation::Fix(_) => Self::Fix, 190 193 } 191 194 } 192 195 193 196 #[must_use] 194 - pub const fn all() -> [Self; 10] { 197 + pub const fn all() -> [Self; 11] { 195 198 [ 196 199 Self::Coincident, 197 200 Self::Horizontal, ··· 203 206 Self::Concentric, 204 207 Self::Fix, 205 208 Self::Midpoint, 209 + Self::Symmetric, 206 210 ] 207 211 } 208 212 }
crates/bone-render/tests/goldens/relations_256.png

This is a binary file and will not be displayed.

+14
crates/bone-render/tests/relations.rs
··· 132 132 }, 133 133 ); 134 134 135 + let (s, sym_a0) = add_point(s, 6.5, -2.5); 136 + let (s, sym_a1) = add_point(s, 9.0, -2.5); 137 + let (s, sym_axis) = add_line(s, sym_a0, sym_a1); 138 + let (s, sym_p) = add_point(s, 7.5, -1.5); 139 + let (s, sym_q) = add_point(s, 7.5, -3.5); 140 + let s = add_relation( 141 + s, 142 + SketchRelation::Symmetric { 143 + a: sym_p, 144 + b: sym_q, 145 + axis: sym_axis, 146 + }, 147 + ); 148 + 135 149 let Ok(scene) = SketchScene::extract(&s) else { 136 150 panic!("scene extract"); 137 151 };
+63 -1
crates/bone-solver/src/residual.rs
··· 80 80 curve: CurveRadius, 81 81 value_mm: f64, 82 82 }, 83 + Symmetric { 84 + a: PointHandle, 85 + b: PointHandle, 86 + axis: LineHandle, 87 + }, 83 88 } 84 89 85 90 pub type Triplet = (ResidualIndex, ParameterIndex, f64); ··· 88 93 #[must_use] 89 94 pub fn rows(&self) -> usize { 90 95 match self { 91 - Self::CoincidentPointPoint(..) | Self::MidpointPointLine { .. } => 2, 96 + Self::CoincidentPointPoint(..) 97 + | Self::MidpointPointLine { .. } 98 + | Self::Symmetric { .. } => 2, 92 99 _ => 1, 93 100 } 94 101 } ··· 128 135 line_params(a).into_iter().chain(line_params(b)).collect() 129 136 } 130 137 Self::RadiusCurve { curve, .. } => curve_params(curve), 138 + Self::Symmetric { a, b, axis } => point_params(a) 139 + .into_iter() 140 + .chain(point_params(b)) 141 + .chain(line_params(axis)) 142 + .collect(), 131 143 } 132 144 } 133 145 ··· 219 231 Self::RadiusCurve { curve, value_mm } => { 220 232 out[0] = radius_squared(params, curve) - value_mm * value_mm; 221 233 } 234 + Self::Symmetric { a, b, axis } => { 235 + let (ax, ay) = (get(params, a.x), get(params, a.y)); 236 + let (bx, by) = (get(params, b.x), get(params, b.y)); 237 + let (dx, dy) = delta(params, axis); 238 + let lax = get(params, axis.a.x); 239 + let lay = get(params, axis.a.y); 240 + out[0] = (bx - ax) * dx + (by - ay) * dy; 241 + let mx = 0.5 * (ax + bx) - lax; 242 + let my = 0.5 * (ay + by) - lay; 243 + out[1] = mx * dy - my * dx; 244 + } 222 245 } 223 246 } 224 247 ··· 277 300 Self::RadiusCurve { curve, .. } => { 278 301 add_radius_squared_jacobian(params, row, curve, sink); 279 302 } 303 + Self::Symmetric { a, b, axis } => { 304 + jacobian_symmetric(params, row, a, b, axis, sink); 305 + } 280 306 } 281 307 } 308 + } 309 + 310 + fn jacobian_symmetric( 311 + params: &[f64], 312 + row: ResidualIndex, 313 + a: PointHandle, 314 + b: PointHandle, 315 + axis: LineHandle, 316 + sink: &mut Vec<Triplet>, 317 + ) { 318 + let (dx, dy) = delta(params, axis); 319 + let (ax, ay) = (get(params, a.x), get(params, a.y)); 320 + let (bx, by) = (get(params, b.x), get(params, b.y)); 321 + let lax = get(params, axis.a.x); 322 + let lay = get(params, axis.a.y); 323 + let mx = 0.5 * (ax + bx) - lax; 324 + let my = 0.5 * (ay + by) - lay; 325 + let bxa_x = bx - ax; 326 + let bxa_y = by - ay; 327 + sink.push((row, a.x, -dx)); 328 + sink.push((row, a.y, -dy)); 329 + sink.push((row, b.x, dx)); 330 + sink.push((row, b.y, dy)); 331 + sink.push((row, axis.a.x, -bxa_x)); 332 + sink.push((row, axis.a.y, -bxa_y)); 333 + sink.push((row, axis.b.x, bxa_x)); 334 + sink.push((row, axis.b.y, bxa_y)); 335 + let row2 = row.next(); 336 + sink.push((row2, a.x, 0.5 * dy)); 337 + sink.push((row2, a.y, -0.5 * dx)); 338 + sink.push((row2, b.x, 0.5 * dy)); 339 + sink.push((row2, b.y, -0.5 * dx)); 340 + sink.push((row2, axis.a.x, -dy + my)); 341 + sink.push((row2, axis.a.y, -mx + dx)); 342 + sink.push((row2, axis.b.x, -my)); 343 + sink.push((row2, axis.b.y, mx)); 282 344 } 283 345 284 346 fn jacobian_parallel(
+5
crates/bone-solver/src/system.rs
··· 254 254 curve: curve(c), 255 255 value_mm, 256 256 }, 257 + Residual::Symmetric { a, b, axis } => Residual::Symmetric { 258 + a: point(a), 259 + b: point(b), 260 + axis: line(axis), 261 + }, 257 262 } 258 263 }
+93
crates/bone-solver/tests/core.rs
··· 175 175 } 176 176 177 177 #[test] 178 + fn symmetric_residual_is_zero_when_b_is_reflection_of_a() { 179 + let system = ConstraintSystem::new( 180 + parameters(&[0.0, 1.0, 0.0, -1.0, -2.0, 0.0, 2.0, 0.0]), 181 + vec![Residual::Symmetric { 182 + a: point(0, 1), 183 + b: point(2, 3), 184 + axis: LineHandle { 185 + a: point(4, 5), 186 + b: point(6, 7), 187 + }, 188 + }], 189 + ); 190 + assert_eq!(system.row_count(), 2); 191 + let values = evaluate_residuals(&system, &[0.0, 1.0, 0.0, -1.0, -2.0, 0.0, 2.0, 0.0]); 192 + assert!(values[0].abs() < 1e-15, "perpendicularity: {}", values[0]); 193 + assert!(values[1].abs() < 1e-15, "midpoint-on-axis: {}", values[1]); 194 + } 195 + 196 + #[test] 197 + fn symmetric_residual_is_nonzero_when_b_drifts_off_reflection() { 198 + let system = ConstraintSystem::new( 199 + parameters(&[0.0, 1.0, 0.5, -1.0, -2.0, 0.0, 2.0, 0.0]), 200 + vec![Residual::Symmetric { 201 + a: point(0, 1), 202 + b: point(2, 3), 203 + axis: LineHandle { 204 + a: point(4, 5), 205 + b: point(6, 7), 206 + }, 207 + }], 208 + ); 209 + let values = evaluate_residuals(&system, &[0.0, 1.0, 0.5, -1.0, -2.0, 0.0, 2.0, 0.0]); 210 + assert!( 211 + values[0].abs() > 0.0 || values[1].abs() > 0.0, 212 + "drift must surface in at least one residual", 213 + ); 214 + } 215 + 216 + #[test] 217 + fn symmetric_solves_to_b_as_reflection_of_a_about_x_axis() { 218 + let system = ConstraintSystem::new( 219 + parameters(&[0.0, 1.0, 0.5, 1.5, -2.0, 0.0, 2.0, 0.0]), 220 + vec![ 221 + Residual::Pin { 222 + param: p(0), 223 + target: 0.0, 224 + }, 225 + Residual::Pin { 226 + param: p(1), 227 + target: 1.0, 228 + }, 229 + Residual::Pin { 230 + param: p(4), 231 + target: -2.0, 232 + }, 233 + Residual::Pin { 234 + param: p(5), 235 + target: 0.0, 236 + }, 237 + Residual::Pin { 238 + param: p(6), 239 + target: 2.0, 240 + }, 241 + Residual::Pin { 242 + param: p(7), 243 + target: 0.0, 244 + }, 245 + Residual::Symmetric { 246 + a: point(0, 1), 247 + b: point(2, 3), 248 + axis: LineHandle { 249 + a: point(4, 5), 250 + b: point(6, 7), 251 + }, 252 + }, 253 + ], 254 + ); 255 + let Ok(out) = solve_newton(&system, NewtonConfig::DEFAULT) else { 256 + panic!("symmetric over fixed axis must converge"); 257 + }; 258 + assert!( 259 + (out[2].value() - 0.0).abs() < 1e-6, 260 + "b.x = a.x for reflection over x-axis: got {}", 261 + out[2].value() 262 + ); 263 + assert!( 264 + (out[3].value() - -1.0).abs() < 1e-6, 265 + "b.y = -a.y for reflection over x-axis: got {}", 266 + out[3].value() 267 + ); 268 + } 269 + 270 + #[test] 178 271 fn assemble_jacobian_and_triplets_agree_on_dense_shape() { 179 272 let system = ConstraintSystem::new( 180 273 parameters(&[0.0, 0.0, 2.0, 0.0]),