Skip to main content

blockworx_editor/render/
path.rs

1use crate::{
2    grid::{GRID_SIZE, px_point},
3    presentation::{Crossing, RouteDirection},
4    theme::{RoleStroke, Style},
5};
6use blockworx_geom::{Pos2, vec2};
7use blockworx_paint::Renderer;
8
9pub enum RenderSegment {
10    Edge { from: Pos2, to: Pos2 },
11    Chamfer { from: Pos2, to: Pos2 },
12}
13
14pub struct RenderedPath {
15    pub segments: Vec<RenderSegment>,
16}
17
18impl From<Vec<RenderSegment>> for RenderedPath {
19    fn from(segments: Vec<RenderSegment>) -> Self {
20        Self { segments }
21    }
22}
23
24impl RenderedPath {
25    pub fn render(&self, painter: &mut Style<'_, impl Renderer>, stroke: impl Into<RoleStroke>) {
26        self.render_with_hops(painter, stroke, &[]);
27    }
28
29    /// Like `render`, but splices a small semicircular "hop" into the line at
30    /// each crossing so the wire visibly jumps over the one it crosses.
31    pub fn render_with_hops(
32        &self,
33        painter: &mut Style<'_, impl Renderer>,
34        stroke: impl Into<RoleStroke>,
35        crossings: &[Crossing],
36    ) {
37        let stroke = stroke.into();
38        let mut points = vec![];
39        for segment in &self.segments {
40            let (RenderSegment::Edge { from, to } | RenderSegment::Chamfer { from, to }) = segment;
41            let (from, to) = (*from, *to);
42            if points.last() != Some(&from) {
43                points.push(from);
44            }
45            points.push(to);
46        }
47        if !crossings.is_empty() {
48            points = splice_hops(&points, crossings);
49        }
50        painter.line(points, stroke);
51    }
52}
53
54/// Height a hop rises above the wire, in world units.
55const HOP_HEIGHT: f32 = GRID_SIZE * 0.2;
56/// Width of the hop's flat top, in world units.
57const HOP_FLAT: f32 = GRID_SIZE * 0.2;
58/// Half the on-wire footprint of a hop. The 45° sides span `HOP_HEIGHT`
59/// horizontally (rise == run), plus half the flat top.
60const HOP_HALF_BASE: f32 = HOP_HEIGHT + HOP_FLAT * 0.5;
61
62/// Splice a small flat-topped bump into `points` at each crossing that lies on
63/// an axis-aligned run: two 45° segments with a flat top, bulging perpendicular
64/// to the run (up for a horizontal wire, right for a vertical one). Diagonal
65/// chamfer runs never match a crossing and pass through untouched.
66fn splice_hops(points: &[Pos2], crossings: &[Crossing]) -> Vec<Pos2> {
67    let Some(&first) = points.first() else {
68        return Vec::new();
69    };
70    let mut out: Vec<Pos2> = Vec::with_capacity(points.len() + crossings.len() * 4);
71    out.push(first);
72    for w in points.windows(2) {
73        let (a, b) = (w[0], w[1]);
74        let run = b - a;
75        let run_len = run.length();
76        let is_h = (a.y - b.y).abs() < 0.1;
77        let is_v = (a.x - b.x).abs() < 0.1;
78        if run_len > 0.0 && (is_h ^ is_v) {
79            let dir = run / run_len;
80            let (orient, normal) = if is_h {
81                (RouteDirection::Horizontal, vec2(0.0, -1.0)) // bump up
82            } else {
83                (RouteDirection::Vertical, vec2(1.0, 0.0)) // bump right
84            };
85            // Crossings that sit on this run, with their distance along it.
86            let mut hops: Vec<f32> = crossings
87                .iter()
88                .filter(|c| c.orientation == orient)
89                .filter_map(|c| {
90                    let p = px_point(c.pos);
91                    let on_line = if is_h {
92                        (p.y - a.y).abs() < 0.1
93                    } else {
94                        (p.x - a.x).abs() < 0.1
95                    };
96                    let s = (p - a).dot(dir);
97                    // Keep clear of the run ends so the bump never overruns a corner.
98                    (on_line && s >= HOP_HALF_BASE && s <= run_len - HOP_HALF_BASE).then_some(s)
99                })
100                .collect();
101            hops.sort_by(f32::total_cmp);
102            for s in hops {
103                let p = a + dir * s;
104                let top = normal * HOP_HEIGHT;
105                out.push(p - dir * HOP_HALF_BASE); // entry, on the wire
106                out.push(p - dir * (HOP_FLAT * 0.5) + top); // up the 45° rise to the flat top
107                out.push(p + dir * (HOP_FLAT * 0.5) + top); // across the flat top
108                out.push(p + dir * HOP_HALF_BASE); // down the 45° fall to the wire
109            }
110        }
111        out.push(b);
112    }
113    out
114}
115
116pub fn render_path_with_chamfered_corners(points: &[Pos2]) -> RenderedPath {
117    let start = points.first().copied().unwrap_or_default();
118    let end = points.last().copied().unwrap_or_default();
119    let mut rendered_segments: Vec<RenderSegment> = Vec::new();
120    let mut last = start;
121    for window in points.windows(3) {
122        let [prev, current, next] = [window[0], window[1], window[2]];
123        let v1 = (current - prev).normalized();
124        let v2 = (next - current).normalized();
125        let angle = v1.dot(v2);
126        if angle.abs() < 0.1 {
127            let chamfer_length = GRID_SIZE / 4.0;
128            let chamfer_point1 = current - v1 * chamfer_length;
129            let chamfer_point2 = current + v2 * chamfer_length;
130            rendered_segments.push(RenderSegment::Edge {
131                from: last,
132                to: chamfer_point1,
133            });
134            rendered_segments.push(RenderSegment::Chamfer {
135                from: chamfer_point1,
136                to: chamfer_point2,
137            });
138            last = chamfer_point2;
139        } else {
140            rendered_segments.push(RenderSegment::Edge {
141                from: last,
142                to: current,
143            });
144            last = current;
145        }
146    }
147    rendered_segments.push(RenderSegment::Edge {
148        from: last,
149        to: end,
150    });
151    rendered_segments.into()
152}
153
154#[cfg(test)]
155mod hop_tests {
156    use super::*;
157    use blockworx_doc::geometry::GridPoint;
158    use blockworx_geom::pos2;
159
160    #[test]
161    fn no_crossings_leaves_path_unchanged() {
162        let pts = vec![pos2(0.0, 0.0), pos2(30.0, 0.0)];
163        assert_eq!(splice_hops(&pts, &[]), pts);
164    }
165
166    #[test]
167    fn orientation_mismatch_is_ignored() {
168        // A Vertical-oriented crossing must not bump a horizontal run.
169        let gp = GridPoint { x: 1, y: 0 };
170        let p = px_point(gp);
171        let pts = vec![pos2(p.x - 50.0, p.y), pos2(p.x + 50.0, p.y)];
172        let c = Crossing {
173            pos: gp,
174            orientation: RouteDirection::Vertical,
175        };
176        assert_eq!(splice_hops(&pts, &[c]), pts);
177    }
178
179    #[test]
180    fn horizontal_hop_is_flat_topped_45deg_bump() {
181        let gp = GridPoint { x: 1, y: 0 };
182        let p = px_point(gp);
183        let pts = vec![pos2(p.x - 50.0, p.y), pos2(p.x + 50.0, p.y)];
184        let c = Crossing {
185            pos: gp,
186            orientation: RouteDirection::Horizontal,
187        };
188        let out = splice_hops(&pts, &[c]);
189        // Endpoints are preserved.
190        assert_eq!(out.first(), Some(&pts[0]));
191        assert_eq!(out.last(), Some(&pts[1]));
192        // The flat top is two points one HOP_HEIGHT "up" (smaller y).
193        let mut top: Vec<Pos2> = out
194            .iter()
195            .copied()
196            .filter(|q| (q.y - (p.y - HOP_HEIGHT)).abs() < 1e-3)
197            .collect();
198        top.sort_by(|a, b| a.x.total_cmp(&b.x));
199        assert_eq!(top.len(), 2, "flat top has two corners");
200        assert!(
201            (top[1].x - top[0].x - HOP_FLAT).abs() < 1e-3,
202            "flat top width == HOP_FLAT"
203        );
204        assert!(
205            (f32::midpoint(top[0].x, top[1].x) - p.x).abs() < 1e-3,
206            "flat top centered on the crossing"
207        );
208        // Entry/exit return to the wire, half-base either side of the crossing.
209        let entry = pos2(p.x - HOP_HALF_BASE, p.y);
210        let exit = pos2(p.x + HOP_HALF_BASE, p.y);
211        assert!(out.iter().any(|q| q.distance(entry) < 1e-3));
212        assert!(out.iter().any(|q| q.distance(exit) < 1e-3));
213        // The rising side is 45° (|dx| == |dy|).
214        let dx = (top[0].x - entry.x).abs();
215        let dy = (top[0].y - entry.y).abs();
216        assert!((dx - dy).abs() < 1e-3, "side is 45 degrees");
217    }
218}