use std::collections::HashMap;
use std::f64::consts::PI;
use serde_json::Value;
use super::dimensions::line_intersection;
use super::doc::{id_key, SketchConstraint, SketchDoc, SketchPoint};
use super::PlaneFrame;
use crate::style::SketchColors;
pub const OVERLAY_CONSTRAINT_GLYPHS: &str = "sketch-constraint-glyphs";
const GLYPH_PX: f64 = 7.0;
const OFFSET_FACTOR: f64 = 2.2;
fn get<'a>(by_id: &HashMap<String, &'a SketchPoint>, id: &Value) -> Option<[f64; 2]> {
by_id.get(&id_key(id)).map(|p| [p.x, p.y])
}
fn glyph_scale(wpp: f64) -> f64 {
(wpp * GLYPH_PX).max(0.05)
}
fn is_dimensional_or_temporary(c: &SketchConstraint) -> bool {
if c.temporary() {
return true;
}
matches!(c.ctype(), Some("⟺") | Some("↥") | Some("∠"))
}
fn stamp(
out: &mut Vec<([f64; 2], [f64; 2])>,
anchor: [f64; 2],
scale: f64,
rot: f64,
strokes: &[&[[f64; 2]]],
) {
let (c, s) = (rot.cos(), rot.sin());
let map = |p: [f64; 2]| {
let (x, y) = (p[0] * scale, p[1] * scale);
[anchor[0] + x * c - y * s, anchor[1] + x * s + y * c]
};
for stroke in strokes {
for w in stroke.windows(2) {
out.push((map(w[0]), map(w[1])));
}
}
}
const T_DASH_H: [[f64; 2]; 2] = [[-1.0, 0.0], [1.0, 0.0]]; const T_DASH_V: [[f64; 2]; 2] = [[0.0, -1.0], [0.0, 1.0]]; const T_CHEV: [[f64; 2]; 3] = [[-0.5, -0.8], [0.5, 0.0], [-0.5, 0.8]]; const T_EQ_A: [[f64; 2]; 2] = [[-0.8, -0.32], [0.8, -0.32]]; const T_EQ_B: [[f64; 2]; 2] = [[-0.8, 0.32], [0.8, 0.32]]; const T_SQUARE: [[f64; 2]; 5] = [
[-0.8, -0.8],
[0.8, -0.8],
[0.8, 0.8],
[-0.8, 0.8],
[-0.8, -0.8],
]; const T_DIAMOND: [[f64; 2]; 5] = [
[0.0, -1.0],
[1.0, 0.0],
[0.0, 1.0],
[-1.0, 0.0],
[0.0, -1.0],
]; const T_DOT_A: [[f64; 2]; 2] = [[-0.85, 0.0], [-0.6, 0.0]]; const T_DOT_B: [[f64; 2]; 2] = [[-0.12, 0.0], [0.12, 0.0]];
const T_DOT_C: [[f64; 2]; 2] = [[0.6, 0.0], [0.85, 0.0]];
const T_BOW_L: [[f64; 2]; 4] = [[-1.0, -0.8], [0.0, 0.0], [-1.0, 0.8], [-1.0, -0.8]]; const T_BOW_R: [[f64; 2]; 4] = [[1.0, -0.8], [0.0, 0.0], [1.0, 0.8], [1.0, -0.8]]; const T_GND_STEM: [[f64; 2]; 2] = [[0.0, 1.0], [0.0, 0.0]]; const T_GND_BASE: [[f64; 2]; 2] = [[-1.0, 0.0], [1.0, 0.0]]; const T_GND_H1: [[f64; 2]; 2] = [[-0.6, -0.5], [0.6, -0.5]]; const T_GND_H2: [[f64; 2]; 2] = [[-0.25, -1.0], [0.25, -1.0]];
fn octagon() -> Vec<[f64; 2]> {
(0..=8)
.map(|i| {
let t = (i as f64 / 8.0) * 2.0 * PI;
[t.cos(), t.sin()]
})
.collect()
}
fn arc_cup() -> Vec<[f64; 2]> {
(0..=8)
.map(|i| {
let t = PI * (0.15 + 0.7 * (i as f64 / 8.0)); [t.cos(), t.sin()]
})
.collect()
}
fn unit(a: [f64; 2], b: [f64; 2]) -> Option<[f64; 2]> {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l = dx.hypot(dy);
(l > 1e-9).then(|| [dx / l, dy / l])
}
fn midpoint(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
[(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]
}
fn glyph_segments(
c: &SketchConstraint,
by_id: &HashMap<String, &SketchPoint>,
wpp: f64,
) -> Option<Vec<([f64; 2], [f64; 2])>> {
if is_dimensional_or_temporary(c) {
return None;
}
let ctype = c.ctype()?;
let pts = c.points();
let s = glyph_scale(wpp);
let mut out: Vec<([f64; 2], [f64; 2])> = Vec::new();
match ctype {
"━" | "│" => {
let (a, b) = (get(by_id, pts.first()?)?, get(by_id, pts.get(1)?)?);
let mid = midpoint(a, b);
let dir = unit(a, b).unwrap_or([1.0, 0.0]);
let normal = [-dir[1], dir[0]];
let anchor = [mid[0] + normal[0] * s * OFFSET_FACTOR, mid[1] + normal[1] * s * OFFSET_FACTOR];
let tmpl: &[[f64; 2]] = if ctype == "━" { &T_DASH_H } else { &T_DASH_V };
stamp(&mut out, anchor, s, 0.0, &[tmpl]);
}
"∥" if pts.len() >= 4 => {
for (pa, pb) in [(pts[0].clone(), pts[1].clone()), (pts[2].clone(), pts[3].clone())] {
let (a, b) = (get(by_id, &pa)?, get(by_id, &pb)?);
let dir = unit(a, b)?;
let anchor = midpoint(a, b);
stamp(&mut out, anchor, s, dir[1].atan2(dir[0]), &[&T_CHEV]);
}
}
"⟂" if pts.len() >= 4 => {
let (l1a, l1b) = (get(by_id, &pts[0])?, get(by_id, &pts[1])?);
let (l2a, l2b) = (get(by_id, &pts[2])?, get(by_id, &pts[3])?);
let key = |p: [f64; 2]| (p[0].to_bits(), p[1].to_bits());
let shared = [l1a, l1b]
.into_iter()
.find(|&e| key(e) == key(l2a) || key(e) == key(l2b));
let (corner, d1, d2) = if let Some(corner) = shared {
let o1 = if key(corner) == key(l1a) { l1b } else { l1a };
let o2 = if key(corner) == key(l2a) { l2b } else { l2a };
(corner, unit(corner, o1)?, unit(corner, o2)?)
} else {
let corner = line_intersection(l1a, l1b, l2a, l2b);
(corner, unit(l1a, l1b)?, unit(l2a, l2b)?)
};
let p1 = [corner[0] + d1[0] * s, corner[1] + d1[1] * s];
let p3 = [corner[0] + d2[0] * s, corner[1] + d2[1] * s];
let p2 = [p1[0] + d2[0] * s, p1[1] + d2[1] * s];
out.push((p1, p2));
out.push((p2, p3));
}
"≡" => {
let anchor = get(by_id, pts.first()?)?;
stamp(&mut out, anchor, s, 0.0, &[&T_SQUARE]);
}
"⇌" if pts.len() >= 4 => {
for (pa, pb) in [(pts[0].clone(), pts[1].clone()), (pts[2].clone(), pts[3].clone())] {
let (a, b) = (get(by_id, &pa)?, get(by_id, &pb)?);
let dir = unit(a, b).unwrap_or([1.0, 0.0]);
stamp(&mut out, midpoint(a, b), s, dir[1].atan2(dir[0]), &[&T_EQ_A, &T_EQ_B]);
}
}
"⊜" if pts.len() >= 4 => {
for rim in [pts[1].clone(), pts[3].clone()] {
let anchor = get(by_id, &rim)?;
stamp(&mut out, anchor, s, 0.0, &[&T_EQ_A, &T_EQ_B]);
}
}
"◎" => {
let anchor = get(by_id, pts.first()?)?;
let ring = octagon();
stamp(&mut out, anchor, s, 0.0, &[&ring]);
stamp(&mut out, anchor, s * 0.45, 0.0, &[&ring]);
}
"⌒" => {
let anchor = centroid(by_id, pts)?;
let cup = arc_cup();
stamp(&mut out, anchor, s, 0.0, &[&cup]);
}
"⋰" if pts.len() >= 2 => {
let a = get(by_id, pts.first()?)?;
let b = get(by_id, pts.last()?)?;
let dir = unit(a, b).unwrap_or([1.0, 0.0]);
stamp(&mut out, midpoint(a, b), s, dir[1].atan2(dir[0]), &[&T_DOT_A, &T_DOT_B, &T_DOT_C]);
}
"⏛" if pts.len() >= 3 => {
let (la, lb) = (get(by_id, &pts[0])?, get(by_id, &pts[1])?);
let anchor = get(by_id, &pts[2])?;
let dir = unit(la, lb).unwrap_or([1.0, 0.0]);
let rot = dir[1].atan2(dir[0]);
stamp(&mut out, anchor, s, rot, &[&T_DASH_H]);
stamp(&mut out, anchor, s * 0.35, 0.0, &[&T_SQUARE]);
}
"⋯" if pts.len() >= 3 => {
let p: Vec<[f64; 2]> = pts.iter().take(3).map(|id| get(by_id, id)).collect::<Option<_>>()?;
let mut best = (f64::INFINITY, 0usize);
for k in 0..3 {
let m = midpoint(p[(k + 1) % 3], p[(k + 2) % 3]);
let d = (p[k][0] - m[0]).hypot(p[k][1] - m[1]);
if d < best.0 {
best = (d, k);
}
}
stamp(&mut out, p[best.1], s * 0.8, 0.0, &[&T_DIAMOND]);
}
"⋈" if pts.len() >= 4 => {
let (p1, p2) = (get(by_id, &pts[2])?, get(by_id, &pts[3])?);
stamp(&mut out, midpoint(p1, p2), s, 0.0, &[&T_BOW_L, &T_BOW_R]);
}
"⏚" => {
let p = get(by_id, pts.first()?)?;
let anchor = [p[0], p[1] - s * 0.4];
stamp(&mut out, anchor, s, 0.0, &[&T_GND_STEM, &T_GND_BASE, &T_GND_H1, &T_GND_H2]);
}
_ => return None,
}
(!out.is_empty()).then_some(out)
}
fn centroid(by_id: &HashMap<String, &SketchPoint>, pts: &[Value]) -> Option<[f64; 2]> {
let mut sum = [0.0, 0.0];
let mut n = 0.0;
for id in pts {
if let Some(p) = get(by_id, id) {
sum[0] += p[0];
sum[1] += p[1];
n += 1.0;
}
}
(n > 0.0).then(|| [sum[0] / n, sum[1] / n])
}
pub fn constraint_glyph_segments(
c: &SketchConstraint,
doc: &SketchDoc,
world_per_pixel: f64,
) -> Vec<([f64; 2], [f64; 2])> {
let by_id = super::dimensions::point_index(doc);
glyph_segments(c, &by_id, world_per_pixel).unwrap_or_default()
}
pub fn constraint_glyphs_buffers(
doc: &SketchDoc,
plane: &PlaneFrame,
world_per_pixel: f64,
colors: &SketchColors,
) -> (Vec<f32>, Vec<f32>) {
constraint_glyphs_buffers_with_state(doc, plane, world_per_pixel, colors, None, &[])
}
pub fn constraint_glyphs_buffers_with_state(
doc: &SketchDoc,
plane: &PlaneFrame,
world_per_pixel: f64,
colors: &SketchColors,
hovered: Option<&Value>,
selection: &[Value],
) -> (Vec<f32>, Vec<f32>) {
let by_id = super::dimensions::point_index(doc);
let mut positions: Vec<f32> = Vec::new();
let mut color_buf: Vec<f32> = Vec::new();
for c in &doc.constraints {
let Some(segments) = glyph_segments(c, &by_id, world_per_pixel) else {
continue;
};
let color = match c.raw.get("id") {
Some(id) => super::tessellate::interaction_color(
colors,
colors.constraint,
hovered,
selection,
"constraint",
id,
),
None => colors.constraint,
};
let rgb = super::tessellate::rgb(color);
for (a, b) in segments {
let wa = plane.to_world(a[0], a[1]);
let wb = plane.to_world(b[0], b[1]);
positions.extend_from_slice(&[
wa[0] as f32,
wa[1] as f32,
wa[2] as f32,
wb[0] as f32,
wb[1] as f32,
wb[2] as f32,
]);
color_buf.extend_from_slice(&[rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]]);
}
}
(positions, color_buf)
}
pub fn constraint_glyphs_overlay_json(
doc: &SketchDoc,
plane: &PlaneFrame,
world_per_pixel: f64,
colors: &SketchColors,
) -> String {
constraint_glyphs_overlay_json_with_state(doc, plane, world_per_pixel, colors, None, &[])
}
pub fn constraint_glyphs_overlay_json_with_state(
doc: &SketchDoc,
plane: &PlaneFrame,
world_per_pixel: f64,
colors: &SketchColors,
hovered: Option<&Value>,
selection: &[Value],
) -> String {
let (positions, color_buf) =
constraint_glyphs_buffers_with_state(doc, plane, world_per_pixel, colors, hovered, selection);
serde_json::json!({
"groups": [
{
"name": OVERLAY_CONSTRAINT_GLYPHS,
"renderOrder": 10004,
"lines": { "positions": positions, "colors": color_buf },
}
]
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn doc_from(value: Value) -> SketchDoc {
serde_json::from_value(value).expect("sketch doc")
}
fn segs_for(doc: &SketchDoc) -> Vec<([f64; 2], [f64; 2])> {
let by_id = super::super::dimensions::point_index(doc);
glyph_segments(&doc.constraints[0], &by_id, 0.05).unwrap_or_default()
}
#[test]
fn perpendicular_draws_a_right_angle_at_the_shared_corner() {
let doc = doc_from(json!({
"points": [
{ "id": 0, "x": 0.0, "y": 0.0 },
{ "id": 1, "x": 1.0, "y": 0.0 },
{ "id": 2, "x": 1.0, "y": 1.0 }
],
"geometries": [],
"constraints": [{ "id": 0, "type": "⟂", "points": [0, 1, 1, 2] }]
}));
let segs = segs_for(&doc);
assert_eq!(segs.len(), 2, "right-angle mark = 2 strokes");
let bound = 2.0 * glyph_scale(0.05);
let d = |p: [f64; 2]| (p[0] - 1.0).hypot(p[1] - 0.0);
assert!(
segs.iter().all(|(a, b)| d(*a) < bound && d(*b) < bound),
"mark clusters by the shared corner (1,0)"
);
assert!(segs.iter().all(|(a, b)| a.iter().chain(b).all(|f| f.is_finite())));
}
#[test]
fn horizontal_and_vertical_each_emit_a_dash() {
for ty in ["━", "│"] {
let doc = doc_from(json!({
"points": [{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 1, "x": 4.0, "y": 0.0 }],
"geometries": [],
"constraints": [{ "id": 0, "type": ty, "points": [0, 1] }]
}));
assert_eq!(segs_for(&doc).len(), 1, "{ty} → one dash stroke");
}
}
#[test]
fn parallel_and_equal_mark_both_edges() {
let par = doc_from(json!({
"points": [
{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 1, "x": 4.0, "y": 0.0 },
{ "id": 2, "x": 0.0, "y": 3.0 }, { "id": 3, "x": 4.0, "y": 3.0 }
],
"geometries": [],
"constraints": [{ "id": 0, "type": "∥", "points": [0, 1, 2, 3] }]
}));
assert_eq!(segs_for(&par).len(), 4, "chevron per line");
let eq = doc_from(json!({
"points": [
{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 1, "x": 4.0, "y": 0.0 },
{ "id": 2, "x": 0.0, "y": 3.0 }, { "id": 3, "x": 4.0, "y": 3.0 }
],
"geometries": [],
"constraints": [{ "id": 0, "type": "⇌", "points": [0, 1, 2, 3] }]
}));
assert_eq!(segs_for(&eq).len(), 4, "= per segment");
}
#[test]
fn coincident_and_ground_and_concentric_emit_marks() {
let coincident = doc_from(json!({
"points": [{ "id": 0, "x": 2.0, "y": 2.0 }, { "id": 1, "x": 2.0, "y": 2.0 }],
"geometries": [],
"constraints": [{ "id": 0, "type": "≡", "points": [0, 1] }]
}));
assert!(!segs_for(&coincident).is_empty(), "coincident square");
let ground = doc_from(json!({
"points": [{ "id": 0, "x": 0.0, "y": 0.0 }],
"geometries": [],
"constraints": [{ "id": 0, "type": "⏚", "points": [0] }]
}));
assert!(!segs_for(&ground).is_empty(), "ground mark");
let concentric = doc_from(json!({
"points": [{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 1, "x": 5.0, "y": 0.0 }],
"geometries": [],
"constraints": [{ "id": 0, "type": "◎", "points": [0, 1] }]
}));
assert!(!segs_for(&concentric).is_empty(), "concentric rings");
}
#[test]
fn dimensional_and_temporary_constraints_are_skipped() {
let by_id_of = |doc: &SketchDoc| -> bool {
let by_id = super::super::dimensions::point_index(doc);
glyph_segments(&doc.constraints[0], &by_id, 0.05).is_none()
};
let dim = doc_from(json!({
"points": [{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 1, "x": 4.0, "y": 0.0 }],
"geometries": [],
"constraints": [{ "id": 0, "type": "⟺", "points": [0, 1], "value": 4.0 }]
}));
assert!(by_id_of(&dim), "distance dim skipped");
let temp = doc_from(json!({
"points": [{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 1, "x": 4.0, "y": 0.0 }],
"geometries": [],
"constraints": [{ "id": 0, "type": "━", "points": [0, 1], "temporary": true }]
}));
assert!(by_id_of(&temp), "temporary constraint skipped");
}
#[test]
fn overlay_json_names_the_group_and_carries_a_marker_per_constraint() {
let doc = doc_from(json!({
"points": [
{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 1, "x": 4.0, "y": 0.0 },
{ "id": 2, "x": 4.0, "y": 3.0 }, { "id": 3, "x": 0.0, "y": 3.0 }
],
"geometries": [],
"constraints": [
{ "id": 0, "type": "⟂", "points": [0, 1, 1, 2] },
{ "id": 1, "type": "━", "points": [0, 1] },
{ "id": 2, "type": "⟺", "points": [0, 1], "value": 4.0 }
]
}));
let json: Value =
serde_json::from_str(&constraint_glyphs_overlay_json(&doc, &PlaneFrame::xy(), 0.05, &Default::default()))
.unwrap();
let group = &json["groups"][0];
assert_eq!(group["name"], OVERLAY_CONSTRAINT_GLYPHS);
assert_eq!(group["renderOrder"], 10004);
let positions = group["lines"]["positions"].as_array().unwrap();
assert!(!positions.is_empty(), "geometric constraints emit markers");
assert_eq!(positions.len() % 6, 0, "6 floats per segment");
assert!(positions.iter().all(|v| v.as_f64().unwrap().is_finite()));
}
#[test]
fn selected_constraint_glyph_paints_amber_else_green() {
let doc = doc_from(json!({
"points": [{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 1, "x": 4.0, "y": 0.0 }],
"geometries": [],
"constraints": [{ "id": 0, "type": "━", "points": [0, 1] }]
}));
let plane = PlaneFrame::xy();
let (_p, green) = constraint_glyphs_buffers_with_state(&doc, &plane, 0.05, &Default::default(), None, &[]);
assert!(!green.is_empty(), "geometric constraint emits a glyph");
assert!(
(green[0] - 0x4a as f32 / 255.0).abs() < 1e-3,
"unselected glyph not green: {:?}",
&green[..3]
);
let selection = vec![json!({ "kind": "constraint", "id": 0 })];
let (_p2, amber) =
constraint_glyphs_buffers_with_state(&doc, &plane, 0.05, &Default::default(), None, &selection);
assert!(
(amber[0] - 0xff as f32 / 255.0).abs() < 1e-3
&& (amber[1] - 0xa5 as f32 / 255.0).abs() < 1e-3,
"selected glyph not amber: {:?}",
&amber[..3]
);
}
#[test]
fn empty_doc_emits_an_empty_but_named_group() {
let doc = SketchDoc::default();
let json: Value =
serde_json::from_str(&constraint_glyphs_overlay_json(&doc, &PlaneFrame::xy(), 0.05, &Default::default()))
.unwrap();
let group = &json["groups"][0];
assert_eq!(group["name"], OVERLAY_CONSTRAINT_GLYPHS);
assert!(group["lines"]["positions"].as_array().unwrap().is_empty());
}
}