blockworx_editor/render/
path.rs1use 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 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
54const HOP_HEIGHT: f32 = GRID_SIZE * 0.2;
56const HOP_FLAT: f32 = GRID_SIZE * 0.2;
58const HOP_HALF_BASE: f32 = HOP_HEIGHT + HOP_FLAT * 0.5;
61
62fn 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)) } else {
83 (RouteDirection::Vertical, vec2(1.0, 0.0)) };
85 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 (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); out.push(p - dir * (HOP_FLAT * 0.5) + top); out.push(p + dir * (HOP_FLAT * 0.5) + top); out.push(p + dir * HOP_HALF_BASE); }
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 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 assert_eq!(out.first(), Some(&pts[0]));
191 assert_eq!(out.last(), Some(&pts[1]));
192 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 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 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}