Skip to main content

energyml_section/paint/
mod.rs

1//! What the front end's scene paints along an object, laid on a section
2//! (`View::paints`, `View::joints`) - the resolver the front end supplies; without
3//! a paint, `layout` draws as before (the default resolver).
4//!
5//! Contract of 05/10, filled by the agents of the same day: `well_pieces` here
6//! (wells, joints), `surfaces::trace_pieces` (the cut of a painted surface).
7
8pub mod surfaces;
9
10use crate::model::{Blend, Joint, JointKind, Paint};
11
12/// A stretch of one colour, in pixels of the drawing.
13#[derive(Clone, Debug, PartialEq)]
14pub struct Piece {
15    /// The stretch, pixels.
16    pub points: Vec<[f64; 2]>,
17    /// `#rrggbb`.
18    pub colour: String,
19    /// 0 to 1.
20    pub opacity: f64,
21}
22
23/// A smooth stretch is cut into sub-pieces about this long, pixels: two pixels
24/// is below what the eye resolves as a step on a gradient, and cheap enough
25/// (one `<polyline>` per sub-piece) for a well of a few hundred stretches.
26pub const SMOOTH_STEP_PX: f64 = 2.0;
27
28/// The most sub-pieces one smooth stretch is cut into: past it a colour
29/// channel changes by at most a few levels per piece, so more pieces would only
30/// make the SVG larger.
31pub const SMOOTH_MOST: usize = 64;
32
33/// Alpha below this is "no value" - the 3D view's threshold (`fragmentAt`:
34/// alpha under one half is discarded), so the section and the scene agree on
35/// which stretches are absent. A buffer's alpha is 0 or 255 in practice; the
36/// midpoint only decides what a stray byte means.
37const ABSENT_BELOW: u8 = 128;
38
39type Rgba = [u8; 4];
40
41/// `#rrggbbaa` (or `#rrggbb`, opaque) as four bytes.
42fn parse_colour(text: &str) -> Option<Rgba> {
43    let hex = text.strip_prefix('#')?;
44    if !(hex.len() == 6 || hex.len() == 8) || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
45        return None;
46    }
47    let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
48    Some([
49        byte(0)?,
50        byte(2)?,
51        byte(4)?,
52        if hex.len() == 8 { byte(6)? } else { 255 },
53    ])
54}
55
56fn hex_of(rgb: [f64; 3]) -> String {
57    let c = |v: f64| v.round().clamp(0.0, 255.0) as u8;
58    format!("#{:02x}{:02x}{:02x}", c(rgb[0]), c(rgb[1]), c(rgb[2]))
59}
60
61fn mix(a: Rgba, b: Rgba, t: f64) -> [f64; 3] {
62    [0, 1, 2].map(|i| a[i] as f64 + (b[i] as f64 - a[i] as f64) * t)
63}
64
65/// What a paint and its joints say, read.
66struct Read {
67    nodes: Vec<Rgba>,
68    joint: Rgba,
69}
70
71fn read(nodes: usize, paint: &Paint, joints: &Joint) -> Result<Read, String> {
72    if paint.nodes.len() != nodes {
73        return Err(format!(
74            "the paint holds {} node colours but the well has {nodes} nodes - they must match one to one",
75            paint.nodes.len()
76        ));
77    }
78    let mut colours = Vec::with_capacity(nodes);
79    for (k, text) in paint.nodes.iter().enumerate() {
80        colours
81            .push(parse_colour(text).ok_or_else(|| format!("node {k}'s colour {text:?} is not #rrggbbaa"))?);
82    }
83    let mut joint = [0, 0, 0, 255];
84    if joints.kind != JointKind::None {
85        joint = parse_colour(&joints.colour)
86            .ok_or_else(|| format!("the joint colour {:?} is not #rrggbb", joints.colour))?;
87        if !joints.px.is_finite() || joints.px < 0.0 {
88            return Err(format!("the joint length {} px is not a length", joints.px));
89        }
90        if !joints.alpha.is_finite() {
91            return Err(format!("the joint alpha {} is not a number", joints.alpha));
92        }
93    }
94    Ok(Read {
95        nodes: colours,
96        joint,
97    })
98}
99
100/// Whether a paint fits a well of `nodes` nodes and its colours read: the
101/// refusal [`well_pieces`] gives for those two reasons, before any pixel is
102/// laid - `layout` says it before it reserves the room for its notes.
103pub fn check(nodes: usize, paint: &Paint, joints: &Joint) -> Result<(), String> {
104    read(nodes, paint, joints).map(|_| ())
105}
106
107/// A point of segment `a`-`b` at `t`, exact at both ends: two stretches that
108/// meet at a node meet on the very same point, which is what merging compares.
109fn at(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] {
110    if t <= 0.0 {
111        a
112    } else if t >= 1.0 {
113        b
114    } else {
115        [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
116    }
117}
118
119/// A well's path, already laid in pixels (one point per node, in node order),
120/// painted: the pieces to draw, joints included, or why the paint does not fit
121/// this well - said by name, and the well is then drawn as before.
122///
123/// The reading is a web 3D view's (`fragmentAt`), transcribed on the path in pixels:
124///
125/// * **Net**: node `k`'s colour over the stretch from node `k` to node `k+1`;
126///   **Smooth**: from node `k`'s colour to node `k+1`'s - except that an absent
127///   neighbour stops the blend (the stretch is then flat in node `k`'s colour),
128///   because an interpolated absent colour is a dark fringe, not a value. Each
129///   smooth stretch is cut into sub-pieces about [`SMOOTH_STEP_PX`] long (at
130///   most [`SMOOTH_MOST`]), each in the colour at its middle. The last node's
131///   colour is never seen in the net reading: no stretch follows it.
132/// * a node whose alpha is under one half is absent: its stretch is not drawn.
133///   A gap, never a colour.
134/// * joints (`jointFlags`): never at the two ends; `Nodes` at every inner
135///   node, `Changes` where the node's bytes differ from the previous node's
136///   (alpha included - absent next to a colour is a change). A joint is
137///   `joints.px` long, centred on the node: each half is **drawn only if the
138///   run on its side is at least two joints long** (`jointsFit` - below that a
139///   run would be all joint, a gap that looks like missing data), the run being
140///   from the flagged node (or the end of the well) before to the one after:
141///   a stretch in `Nodes`, a stretch of one colour in `Changes` (05/10), so a
142///   finely sampled well keeps its changes. A half lies on whatever stretches it
143///   covers, and only where they are drawn. Alpha 0 leaves the joint's span empty;
144///   otherwise the joint colour is composited over the stretch's colour **at
145///   the node** (`rgb + (joint − rgb)·alpha`, the 3D rule - on a smooth stretch
146///   the underlying colour moves a little across half a joint, and the node's
147///   is used), precomputed: the pieces always carry opacity 1.
148///
149/// Consecutive pieces of one colour that touch are one piece.
150pub fn well_pieces(path_px: &[[f64; 2]], paint: &Paint, joints: &Joint) -> Result<Vec<Piece>, String> {
151    let Read {
152        nodes: colours,
153        joint,
154    } = read(path_px.len(), paint, joints)?;
155    let count = path_px.len();
156    if count < 2 {
157        return Err(format!("a well of {count} node(s) has no stretch to paint"));
158    }
159    if let Some(k) = path_px
160        .iter()
161        .position(|p| !(p[0].is_finite() && p[1].is_finite()))
162    {
163        return Err(format!("node {k} of the well has no position on the drawing"));
164    }
165    let absent = |k: usize| colours[k][3] < ABSENT_BELOW;
166    // The 3D `jointFlags`, one per node.
167    let flagged = |k: usize| -> bool {
168        if k == 0 || k + 1 >= count {
169            return false;
170        }
171        match joints.kind {
172            JointKind::None => false,
173            JointKind::Nodes => true,
174            JointKind::Changes => colours[k] != colours[k - 1],
175        }
176    };
177    let half = joints.px / 2.0;
178    let alpha = joints.alpha.clamp(0.0, 1.0);
179
180    // The abscissa of each node along the path, in pixels.
181    let mut arc = vec![0.0; count];
182    for k in 1..count {
183        let (a, b) = (path_px[k - 1], path_px[k]);
184        arc[k] = arc[k - 1] + ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
185    }
186    // Where the joints are, as spans of abscissa. A joint is judged on the
187    // **run** its half lies in - from the flagged node (or the end of the
188    // well) before it to the flagged node (or the end) after it - never on the
189    // stretch between two nodes: in `Changes` a run is a stretch of one colour,
190    // which on a finely sampled well (a node every metre) holds many
191    // nodes; in `Nodes` every inner node is flagged and a run is a stretch, the
192    // old rule exactly (in "at value change" mode the computation is done between
193    // the changes, not between every available MD). a front end's `fragmentAt` says the same.
194    let mut spans: Vec<(f64, f64)> = Vec::new();
195    if joints.px > 0.0 {
196        let flags: Vec<usize> = (0..count).filter(|&k| flagged(k)).collect();
197        for (at, &c) in flags.iter().enumerate() {
198            let before = if at == 0 { 0 } else { flags[at - 1] };
199            let after = flags.get(at + 1).copied().unwrap_or(count - 1);
200            if arc[c] - arc[before] >= 2.0 * joints.px {
201                spans.push((arc[c] - half, arc[c]));
202            }
203            if arc[after] - arc[c] >= 2.0 * joints.px {
204                spans.push((arc[c], arc[c] + half));
205            }
206        }
207    }
208    let in_joint = |s: f64| spans.iter().any(|&(lo, hi)| s > lo && s < hi);
209
210    let mut out: Vec<Piece> = Vec::new();
211    let mut push = |points: Vec<[f64; 2]>, colour: String| {
212        if let Some(last) = out.last_mut()
213            && last.colour == colour
214            && last.points.last() == points.first()
215        {
216            last.points.extend(points.into_iter().skip(1));
217        } else {
218            out.push(Piece {
219                points,
220                colour,
221                opacity: 1.0,
222            });
223        }
224    };
225
226    for k in 0..count - 1 {
227        if absent(k) {
228            continue;
229        }
230        let (a, b) = (path_px[k], path_px[k + 1]);
231        let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
232        if len <= 1e-9 {
233            continue;
234        }
235        let from = colours[k];
236        let to = if paint.blend == Blend::Smooth && !absent(k + 1) {
237            colours[k + 1]
238        } else {
239            from
240        };
241        // The stretch cut where a joint span begins or ends inside it, each
242        // part either joint or paint.
243        let (s0, s1) = (arc[k], arc[k + 1]);
244        let mut cuts = vec![s0, s1];
245        for &(lo, hi) in &spans {
246            for edge in [lo, hi] {
247                if edge > s0 && edge < s1 {
248                    cuts.push(edge);
249                }
250            }
251        }
252        cuts.sort_by(f64::total_cmp);
253        cuts.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
254        for pair in cuts.windows(2) {
255            let (u0, u1) = ((pair[0] - s0) / len, (pair[1] - s0) / len);
256            if in_joint((pair[0] + pair[1]) / 2.0) {
257                // Alpha 0 is a gap. Otherwise the joint over the paint at the
258                // node the joint stands on - the end of the stretch it is near.
259                if alpha > 0.0 {
260                    let under = if (u0 + u1) / 2.0 < 0.5 { from } else { to };
261                    push(vec![at(a, b, u0), at(a, b, u1)], hex_of(mix(under, joint, alpha)));
262                }
263            } else if to == from {
264                push(vec![at(a, b, u0), at(a, b, u1)], hex_of(mix(from, from, 0.0)));
265            } else {
266                let n = (((u1 - u0) * len / SMOOTH_STEP_PX).ceil() as usize).clamp(1, SMOOTH_MOST);
267                for j in 0..n {
268                    let (v0, v1) = (
269                        u0 + (u1 - u0) * j as f64 / n as f64,
270                        u0 + (u1 - u0) * (j + 1) as f64 / n as f64,
271                    );
272                    // The colour at the middle of the sub-piece, along the whole stretch.
273                    push(
274                        vec![at(a, b, v0), at(a, b, v1)],
275                        hex_of(mix(from, to, (v0 + v1) / 2.0)),
276                    );
277                }
278            }
279        }
280    }
281    Ok(out)
282}
283
284/// `colour` at `opacity` composited over `under` (both `#rrggbb`): the one
285/// colour the SVG writer can say, which has no opacity on a line. A colour
286/// that does not read is returned as it came.
287pub fn over(colour: &str, opacity: f64, under: &str) -> String {
288    if opacity >= 1.0 {
289        return colour.to_string();
290    }
291    match (parse_colour(colour), parse_colour(under)) {
292        (Some(top), Some(bottom)) => hex_of(mix(bottom, top, opacity.clamp(0.0, 1.0))),
293        _ => colour.to_string(),
294    }
295}