···1515pub mod relation;
1616pub mod solve;
17171818+pub use bone_solver::SolverError;
1819pub use dimension::{
1920 DimensionKind, DimensionRefs, DimensionValue, DimensionValueMismatch, SketchDimension,
2021};
+242-27
crates/bone-document/src/sketch/solve.rs
···247247 target: Point2,
248248 budget: BudgetCeiling,
249249 ) -> Result<Sketch, DragError> {
250250+ self.solve_with_drag_pins(&[(dragged, target)], budget)
251251+ }
252252+253253+ pub fn solve_with_drag_pins(
254254+ &self,
255255+ pins: &[(SketchEntityId, Point2)],
256256+ budget: BudgetCeiling,
257257+ ) -> Result<Sketch, DragError> {
250258 let started = Instant::now();
251251- let entity = self
252252- .entities()
253253- .get(dragged)
259259+ pins.iter().try_for_each(|(id, _)| {
260260+ let entity = self
261261+ .entities()
262262+ .get(*id)
263263+ .copied()
264264+ .ok_or(DragError::NotFound(*id))?;
265265+ if matches!(entity, SketchEntity::Point(_)) {
266266+ Ok(())
267267+ } else {
268268+ Err(DragError::NotAPoint(*id))
269269+ }
270270+ })?;
271271+ let deduped: Vec<(SketchEntityId, Point2)> = pins
272272+ .iter()
254273 .copied()
255255- .ok_or(DragError::NotFound(dragged))?;
256256- if !matches!(entity, SketchEntity::Point(_)) {
257257- return Err(DragError::NotAPoint(dragged));
258258- }
259259- let warm = self
260260- .clone()
261261- .with_point_positions(&HashMap::from([(dragged, target)]));
274274+ .enumerate()
275275+ .filter(|(i, (id, _))| pins[..*i].iter().all(|(prior, _)| prior != id))
276276+ .map(|(_, pair)| pair)
277277+ .collect();
278278+ let updates: HashMap<SketchEntityId, Point2> = deduped.iter().copied().collect();
279279+ let warm = self.clone().with_point_positions(&updates);
262280 let (system, mapping) = warm.lower();
263263- let Some(handle) = mapping.point(dragged) else {
264264- unreachable!("Point entity must lower to a PointHandle in the mapping")
265265- };
266266- let (tx, ty) = target.coords_mm();
267267- let system = system.with_extra_residuals(vec![
268268- Residual::Pin {
269269- param: handle.x,
270270- target: tx,
271271- },
272272- Residual::Pin {
273273- param: handle.y,
274274- target: ty,
275275- },
276276- ]);
281281+ let extra: Vec<Residual> = deduped
282282+ .iter()
283283+ .flat_map(|(id, target)| {
284284+ let Some(handle) = mapping.point(*id) else {
285285+ unreachable!("Point entity must lower to a PointHandle in the mapping")
286286+ };
287287+ let (tx, ty) = target.coords_mm();
288288+ [
289289+ Residual::Pin {
290290+ param: handle.x,
291291+ target: tx,
292292+ },
293293+ Residual::Pin {
294294+ param: handle.y,
295295+ target: ty,
296296+ },
297297+ ]
298298+ })
299299+ .collect();
300300+ let system = system.with_extra_residuals(extra);
277301 let decomposition = decompose(&system);
278302 let remaining = BudgetCeiling::new(budget.duration().saturating_sub(started.elapsed()));
279303 let cfg = NewtonConfig {
···293317 Err(_) => analyze_dof(&system, DofConfig::DEFAULT),
294318 };
295319 SketchDofReport::from_solver_report(&report, &mapping)
320320+ }
321321+322322+ #[must_use]
323323+ pub fn measure(&self, dim: SketchDimension) -> Option<DimensionValue> {
324324+ measure_dimension(self, dim)
296325 }
297326298327 #[must_use]
···745774 let lb = line_endpoints(sketch, b)?;
746775 let (d1x, d1y) = (la.1.0 - la.0.0, la.1.1 - la.0.1);
747776 let (d2x, d2y) = (lb.1.0 - lb.0.0, lb.1.1 - lb.0.1);
748748- let cross = d1x * d2y - d1y * d2x;
749749- let dot = d1x * d2x + d1y * d2y;
750750- Some(cross.atan2(dot))
777777+ let mag = ((d1x * d1x + d1y * d1y) * (d2x * d2x + d2y * d2y)).sqrt();
778778+ if mag <= 0.0 {
779779+ return None;
780780+ }
781781+ let cos = (d1x * d2x + d1y * d2y) / mag;
782782+ Some(cos.abs().clamp(0.0, 1.0).acos())
751783}
752784753785fn line_endpoints(sketch: &Sketch, id: SketchEntityId) -> Option<((f64, f64), (f64, f64))> {
···12881320 }
1289132112901322 #[test]
13231323+ fn rectangle_with_conflicting_parallel_edge_dims_reports_over_defined() {
13241324+ let (rect, corners) = rectangle_sketch();
13251325+ let [corner_0, corner_1, corner_2, corner_3] = corners;
13261326+ let (with_edges, out) = apply(
13271327+ &rect,
13281328+ vec![
13291329+ SketchEdit::AddEntity(SketchEntity::line(corner_0, corner_1, false)),
13301330+ SketchEdit::AddEntity(SketchEntity::line(corner_1, corner_2, false)),
13311331+ SketchEdit::AddEntity(SketchEntity::line(corner_2, corner_3, false)),
13321332+ SketchEdit::AddEntity(SketchEntity::line(corner_3, corner_0, false)),
13331333+ ],
13341334+ );
13351335+ let [bottom, right, top, left] = [0_usize, 1, 2, 3].map(|i| entity_id(&out[i]));
13361336+ let (sketch, _) = apply(
13371337+ &with_edges,
13381338+ vec![
13391339+ SketchEdit::AddRelation(SketchRelation::Horizontal(bottom)),
13401340+ SketchEdit::AddRelation(SketchRelation::Horizontal(top)),
13411341+ SketchEdit::AddRelation(SketchRelation::Vertical(right)),
13421342+ SketchEdit::AddRelation(SketchRelation::Vertical(left)),
13431343+ SketchEdit::AddRelation(SketchRelation::Fix(corner_0)),
13441344+ SketchEdit::AddDimension(SketchDimension::Linear {
13451345+ a: corner_0,
13461346+ b: corner_1,
13471347+ value: mm_val(10.0),
13481348+ kind: DimensionKind::Driving,
13491349+ }),
13501350+ SketchEdit::AddDimension(SketchDimension::Linear {
13511351+ a: corner_2,
13521352+ b: corner_3,
13531353+ value: mm_val(8.0),
13541354+ kind: DimensionKind::Driving,
13551355+ }),
13561356+ ],
13571357+ );
13581358+ let Err(err) = sketch.solve() else {
13591359+ panic!("rectangle horizontals plus two unequal parallel-edge dims must conflict");
13601360+ };
13611361+ assert!(
13621362+ matches!(err, SolverError::OverDefined { .. }),
13631363+ "expected OverDefined, got {err:?}",
13641364+ );
13651365+ }
13661366+13671367+ #[test]
13681368+ fn measure_linear_returns_geometric_distance() {
13691369+ let base = Sketch::new(xy_plane());
13701370+ let (sketch, outs) = apply(
13711371+ &base,
13721372+ vec![
13731373+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
13741374+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(3.0, 4.0))),
13751375+ ],
13761376+ );
13771377+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
13781378+ let Some(value) = sketch.measure(SketchDimension::Linear {
13791379+ a,
13801380+ b,
13811381+ value: mm_val(0.0),
13821382+ kind: DimensionKind::Driving,
13831383+ }) else {
13841384+ panic!("measure linear yielded None");
13851385+ };
13861386+ let DimensionValue::Length(length) = value else {
13871387+ panic!("linear measure must yield Length");
13881388+ };
13891389+ assert!((length.get::<millimeter>() - 5.0).abs() < 1e-9);
13901390+ }
13911391+13921392+ #[test]
12911393 fn redundant_dimension_does_not_block_solve() {
12921394 let base = Sketch::new(xy_plane());
12931395 let (with_points, outs) = apply(
···16371739 "Fix on a holds: ({ax}, {ay})",
16381740 );
16391741 let _ = a;
17421742+ }
17431743+17441744+ #[test]
17451745+ fn solve_with_drag_pins_translates_line_endpoints() {
17461746+ let base = Sketch::new(xy_plane());
17471747+ let (with_points, outs) = apply(
17481748+ &base,
17491749+ vec![
17501750+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
17511751+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(4.0, 0.0))),
17521752+ ],
17531753+ );
17541754+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
17551755+ let (sketch, _line_outs) = apply(
17561756+ &with_points,
17571757+ vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))],
17581758+ );
17591759+ let pins = [
17601760+ (a, Point2::from_mm(3.0, 2.0)),
17611761+ (b, Point2::from_mm(7.0, 2.0)),
17621762+ ];
17631763+ let Ok(dragged) = sketch.solve_with_drag_pins(&pins, generous_budget()) else {
17641764+ panic!("translating both endpoints by a constant delta must converge");
17651765+ };
17661766+ let coords = point_coords(&dragged);
17671767+ let (ax, ay) = coords[0];
17681768+ let (bx, by) = coords[1];
17691769+ assert!(
17701770+ (ax - 3.0).abs() < 1e-9 && (ay - 2.0).abs() < 1e-9,
17711771+ "endpoint a lands at pin: ({ax}, {ay})",
17721772+ );
17731773+ assert!(
17741774+ (bx - 7.0).abs() < 1e-9 && (by - 2.0).abs() < 1e-9,
17751775+ "endpoint b lands at pin: ({bx}, {by})",
17761776+ );
17771777+ }
17781778+17791779+ #[test]
17801780+ fn solve_with_drag_pins_dedupes_repeated_id() {
17811781+ let base = Sketch::new(xy_plane());
17821782+ let (sketch, outs) = apply(
17831783+ &base,
17841784+ vec![SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(
17851785+ 0.0, 0.0,
17861786+ )))],
17871787+ );
17881788+ let p = entity_id(&outs[0]);
17891789+ let target = Point2::from_mm(2.0, 3.0);
17901790+ let pins = [(p, target), (p, target)];
17911791+ let Ok(dragged) = sketch.solve_with_drag_pins(&pins, generous_budget()) else {
17921792+ panic!("duplicate pins for the same point must dedupe and converge");
17931793+ };
17941794+ let (x, y) = point_coords(&dragged)[0];
17951795+ assert!(
17961796+ (x - 2.0).abs() < 1e-9 && (y - 3.0).abs() < 1e-9,
17971797+ "deduped pin lands on target: ({x}, {y})",
17981798+ );
17991799+ }
18001800+18011801+ #[test]
18021802+ fn measure_angle_returns_acute_for_obtuse_lines() {
18031803+ let base = Sketch::new(xy_plane());
18041804+ let (with_points, outs) = apply(
18051805+ &base,
18061806+ vec![
18071807+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
18081808+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(1.0, 0.0))),
18091809+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
18101810+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(-1.0, 0.0))),
18111811+ ],
18121812+ );
18131813+ let [p0, p1, p2, p3] = [0_usize, 1, 2, 3].map(|i| entity_id(&outs[i]));
18141814+ let (sketch, line_outs) = apply(
18151815+ &with_points,
18161816+ vec![
18171817+ SketchEdit::AddEntity(SketchEntity::line(p0, p1, false)),
18181818+ SketchEdit::AddEntity(SketchEntity::line(p2, p3, false)),
18191819+ ],
18201820+ );
18211821+ let [l1, l2] = [0_usize, 1].map(|i| entity_id(&line_outs[i]));
18221822+ let Some(value) = sketch.measure(SketchDimension::Angular {
18231823+ a: l1,
18241824+ b: l2,
18251825+ value: Angle::new::<radian>(0.0),
18261826+ kind: DimensionKind::Driving,
18271827+ }) else {
18281828+ panic!("angular measure yielded None");
18291829+ };
18301830+ let DimensionValue::Angle(angle) = value else {
18311831+ panic!("angular measure must yield Angle");
18321832+ };
18331833+ assert!(angle.get::<radian>().abs() < 1e-9);
18341834+ }
18351835+18361836+ #[test]
18371837+ fn solve_with_drag_pins_rejects_non_point_target() {
18381838+ let base = Sketch::new(xy_plane());
18391839+ let (with_points, outs) = apply(
18401840+ &base,
18411841+ vec![
18421842+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
18431843+ SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(1.0, 0.0))),
18441844+ ],
18451845+ );
18461846+ let [a, b] = [0_usize, 1].map(|i| entity_id(&outs[i]));
18471847+ let (sketch, line_outs) = apply(
18481848+ &with_points,
18491849+ vec![SketchEdit::AddEntity(SketchEntity::line(a, b, false))],
18501850+ );
18511851+ let line = entity_id(&line_outs[0]);
18521852+ let result =
18531853+ sketch.solve_with_drag_pins(&[(line, Point2::from_mm(0.0, 0.0))], generous_budget());
18541854+ assert!(matches!(result, Err(DragError::NotAPoint(id)) if id == line));
16401855 }
1641185616421857 #[test]
+5-1
crates/bone-render/src/scene.rs
···559559}
560560561561fn format_dimension(dim: SketchDimension) -> String {
562562- match (dim, dim.value()) {
562562+ let body = match (dim, dim.value()) {
563563 (SketchDimension::Linear { .. }, DimensionValue::Length(len)) => {
564564 format!("{:.2} mm", len.get::<millimeter>())
565565 }
···575575 _ => unreachable!(
576576 "SketchDimension::value pins Length for Linear/Radius/Diameter and Angle for Angular"
577577 ),
578578+ };
579579+ match dim.kind() {
580580+ bone_document::DimensionKind::Driving => body,
581581+ bone_document::DimensionKind::Driven => format!("({body})"),
578582 }
579583}
580584