| 1 | //! Port of `rig.js`: the mark is the rig. Profile contours are partitioned |
| 2 | //! from the official mark's cubic anchors; every pose keeps each named |
| 3 | //! part's command topology. Only move, cubic and close commands exist. |
| 4 | |
| 5 | use super::data::{Pose, p, tables}; |
| 6 | use super::math::{D2R, clamp, hypot, hypot2, lerp, round, smooth}; |
| 7 | use std::collections::HashMap; |
| 8 | use std::sync::{Arc, Mutex, OnceLock}; |
| 9 | |
| 10 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 11 | pub enum Cmd { |
| 12 | M([f64; 2]), |
| 13 | C([f64; 6]), |
| 14 | Z, |
| 15 | } |
| 16 | |
| 17 | impl Cmd { |
| 18 | pub fn coords(&self) -> &[f64] { |
| 19 | match self { |
| 20 | Cmd::M(c) => c, |
| 21 | Cmd::C(c) => c, |
| 22 | Cmd::Z => &[], |
| 23 | } |
| 24 | } |
| 25 | pub fn coords_mut(&mut self) -> &mut [f64] { |
| 26 | match self { |
| 27 | Cmd::M(c) => c, |
| 28 | Cmd::C(c) => c, |
| 29 | Cmd::Z => &mut [], |
| 30 | } |
| 31 | } |
| 32 | /// `c.slice(-2)`: the command's end point. |
| 33 | pub fn end(&self) -> [f64; 2] { |
| 34 | let c = self.coords(); |
| 35 | if c.is_empty() { |
| 36 | return [0., 0.]; |
| 37 | } |
| 38 | [c[c.len() - 2], c[c.len() - 1]] |
| 39 | } |
| 40 | /// The same command with every coordinate mapped; `i` is the index |
| 41 | /// within the command's coordinates, so `i % 2` picks x or y. |
| 42 | pub fn map(&self, mut f: impl FnMut(usize, f64) -> f64) -> Cmd { |
| 43 | let mut out = *self; |
| 44 | for (i, v) in out.coords_mut().iter_mut().enumerate() { |
| 45 | *v = f(i, *v); |
| 46 | } |
| 47 | out |
| 48 | } |
| 49 | /// Rebuild a command of this kind from coordinates. |
| 50 | fn with(&self, coords: &[f64]) -> Cmd { |
| 51 | match self { |
| 52 | Cmd::M(_) => Cmd::M([coords[0], coords[1]]), |
| 53 | Cmd::C(_) => Cmd::C([ |
| 54 | coords[0], coords[1], coords[2], coords[3], coords[4], coords[5], |
| 55 | ]), |
| 56 | Cmd::Z => Cmd::Z, |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | pub type Path = Vec<Cmd>; |
| 62 | |
| 63 | /// `parse()`: `M`, `C` and `Z` with plain decimal numbers. |
| 64 | pub fn parse(d: &str) -> Path { |
| 65 | let bytes = d.as_bytes(); |
| 66 | let mut tokens: Vec<Result<u8, f64>> = Vec::new(); |
| 67 | let mut i = 0; |
| 68 | while i < bytes.len() { |
| 69 | let b = bytes[i]; |
| 70 | if matches!(b, b'M' | b'C' | b'Z') { |
| 71 | tokens.push(Ok(b)); |
| 72 | i += 1; |
| 73 | } else if b == b'-' || b == b'.' || b.is_ascii_digit() { |
| 74 | let start = i; |
| 75 | i += 1; |
| 76 | while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { |
| 77 | i += 1; |
| 78 | } |
| 79 | let Ok(number) = d[start..i].parse::<f64>() else { |
| 80 | return Vec::new(); |
| 81 | }; |
| 82 | if !number.is_finite() { |
| 83 | return Vec::new(); |
| 84 | } |
| 85 | tokens.push(Err(number)); |
| 86 | } else { |
| 87 | i += 1; |
| 88 | } |
| 89 | } |
| 90 | let mut path = Vec::new(); |
| 91 | let mut i = 0; |
| 92 | while i < tokens.len() { |
| 93 | let Ok(op) = tokens[i] else { |
| 94 | return Vec::new(); |
| 95 | }; |
| 96 | i += 1; |
| 97 | let n = match op { |
| 98 | b'M' => 2, |
| 99 | b'C' => 6, |
| 100 | _ => 0, |
| 101 | }; |
| 102 | let Some(coords) = tokens.get(i..i + n) else { |
| 103 | return Vec::new(); |
| 104 | }; |
| 105 | let Some(nums) = coords.iter().map(|t| t.err()).collect::<Option<Vec<_>>>() else { |
| 106 | return Vec::new(); |
| 107 | }; |
| 108 | i += n; |
| 109 | path.push(match op { |
| 110 | b'M' => Cmd::M([nums[0], nums[1]]), |
| 111 | b'C' => Cmd::C([nums[0], nums[1], nums[2], nums[3], nums[4], nums[5]]), |
| 112 | _ => Cmd::Z, |
| 113 | }); |
| 114 | } |
| 115 | path |
| 116 | } |
| 117 | |
| 118 | pub fn ellipse(x: f64, y: f64, rx: f64, ry: f64) -> Path { |
| 119 | let k = 0.55228475; |
| 120 | vec![ |
| 121 | Cmd::M([x + rx, y]), |
| 122 | Cmd::C([x + rx, y + ry * k, x + rx * k, y + ry, x, y + ry]), |
| 123 | Cmd::C([x - rx * k, y + ry, x - rx, y + ry * k, x - rx, y]), |
| 124 | Cmd::C([x - rx, y - ry * k, x - rx * k, y - ry, x, y - ry]), |
| 125 | Cmd::C([x + rx * k, y - ry, x + rx, y - ry * k, x + rx, y]), |
| 126 | Cmd::Z, |
| 127 | ] |
| 128 | } |
| 129 | |
| 130 | fn bezier(a: [f64; 2], c: &[f64; 6], t: f64) -> [f64; 2] { |
| 131 | let q = 1. - t; |
| 132 | [ |
| 133 | q * q * q * a[0] + 3. * q * q * t * c[0] + 3. * q * t * t * c[2] + t * t * t * c[4], |
| 134 | q * q * q * a[1] + 3. * q * q * t * c[1] + 3. * q * t * t * c[3] + t * t * t * c[5], |
| 135 | ] |
| 136 | } |
| 137 | |
| 138 | fn points(path: &Path) -> Vec<[f64; 2]> { |
| 139 | let mut out = Vec::new(); |
| 140 | let mut last = [0., 0.]; |
| 141 | for c in path { |
| 142 | match c { |
| 143 | Cmd::M(m) => { |
| 144 | last = *m; |
| 145 | out.push(last); |
| 146 | } |
| 147 | Cmd::C(c) => { |
| 148 | for j in 1..=10 { |
| 149 | out.push(bezier(last, c, j as f64 / 10.)); |
| 150 | } |
| 151 | last = [c[4], c[5]]; |
| 152 | } |
| 153 | Cmd::Z => {} |
| 154 | } |
| 155 | } |
| 156 | out |
| 157 | } |
| 158 | |
| 159 | pub fn signed_area(path: &Path) -> f64 { |
| 160 | let p = points(path); |
| 161 | let mut sum = 0.; |
| 162 | for (i, a) in p.iter().enumerate() { |
| 163 | let b = p[(i + 1) % p.len()]; |
| 164 | sum += a[0] * b[1] - b[0] * a[1]; |
| 165 | } |
| 166 | sum / 2. |
| 167 | } |
| 168 | |
| 169 | struct Sample { |
| 170 | l: f64, |
| 171 | p: [f64; 2], |
| 172 | d: [f64; 2], |
| 173 | } |
| 174 | struct Knot { |
| 175 | l0: f64, |
| 176 | l1: f64, |
| 177 | h0: f64, |
| 178 | h1: f64, |
| 179 | } |
| 180 | struct Measure { |
| 181 | length: f64, |
| 182 | knots: Vec<Knot>, |
| 183 | samples: Vec<Sample>, |
| 184 | } |
| 185 | |
| 186 | fn or_one(v: f64) -> f64 { |
| 187 | if v == 0. || v.is_nan() { 1. } else { v } |
| 188 | } |
| 189 | |
| 190 | fn measure(path: &Path) -> Measure { |
| 191 | let (mut last, mut length) = ([0., 0.], 0.); |
| 192 | let (mut knots, mut samples) = (Vec::new(), Vec::new()); |
| 193 | for c in path { |
| 194 | match c { |
| 195 | Cmd::M(m) => { |
| 196 | last = *m; |
| 197 | samples.push(Sample { |
| 198 | l: 0., |
| 199 | p: last, |
| 200 | d: [1., 0.], |
| 201 | }); |
| 202 | } |
| 203 | Cmd::C(c) => { |
| 204 | let a = last; |
| 205 | let l0 = length; |
| 206 | let mut prev = a; |
| 207 | for j in 1..=30 { |
| 208 | let t = j as f64 / 30.; |
| 209 | let q = 1. - t; |
| 210 | let p = bezier(a, c, t); |
| 211 | let d = [ |
| 212 | 3. * q * q * (c[0] - a[0]) |
| 213 | + 6. * q * t * (c[2] - c[0]) |
| 214 | + 3. * t * t * (c[4] - c[2]), |
| 215 | 3. * q * q * (c[1] - a[1]) |
| 216 | + 6. * q * t * (c[3] - c[1]) |
| 217 | + 3. * t * t * (c[5] - c[3]), |
| 218 | ]; |
| 219 | length += hypot2(p[0] - prev[0], p[1] - prev[1]); |
| 220 | samples.push(Sample { l: length, p, d }); |
| 221 | prev = p; |
| 222 | } |
| 223 | let seg = length - l0; |
| 224 | knots.push(Knot { |
| 225 | l0, |
| 226 | l1: length, |
| 227 | h0: hypot2(c[0] - a[0], c[1] - a[1]) / or_one(seg), |
| 228 | h1: hypot2(c[2] - c[4], c[3] - c[5]) / or_one(seg), |
| 229 | }); |
| 230 | last = [c[4], c[5]]; |
| 231 | } |
| 232 | Cmd::Z => {} |
| 233 | } |
| 234 | } |
| 235 | samples[0].d = samples[1].d; |
| 236 | Measure { |
| 237 | length, |
| 238 | knots, |
| 239 | samples, |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | /// `match()`: resample `target` onto `source`'s command topology. |
| 244 | fn fit(source: &Path, target: &Path) -> Path { |
| 245 | let src = measure(source); |
| 246 | let dst = measure(target); |
| 247 | let at = |u: f64| { |
| 248 | let l = clamp(u, 0., 1.) * dst.length; |
| 249 | let mut i = 1; |
| 250 | while i < dst.samples.len() - 1 && dst.samples[i].l < l { |
| 251 | i += 1; |
| 252 | } |
| 253 | let (a, b) = (&dst.samples[i - 1], &dst.samples[i]); |
| 254 | let t = (l - a.l) / or_one(b.l - a.l); |
| 255 | let d = [lerp(a.d[0], b.d[0], t), lerp(a.d[1], b.d[1], t)]; |
| 256 | let n = or_one(hypot(&d)); |
| 257 | ( |
| 258 | [lerp(a.p[0], b.p[0], t), lerp(a.p[1], b.p[1], t)], |
| 259 | [d[0] / n, d[1] / n], |
| 260 | ) |
| 261 | }; |
| 262 | let mut out = vec![Cmd::M(at(0.).0)]; |
| 263 | for k in &src.knots { |
| 264 | let (ap, ad) = at(k.l0 / src.length); |
| 265 | let (bp, bd) = at(k.l1 / src.length); |
| 266 | let span = (k.l1 - k.l0) / src.length * dst.length; |
| 267 | let h0 = span * k.h0.min(0.6); |
| 268 | let h1 = span * k.h1.min(0.6); |
| 269 | out.push(Cmd::C([ |
| 270 | ap[0] + ad[0] * h0, |
| 271 | ap[1] + ad[1] * h0, |
| 272 | bp[0] - bd[0] * h1, |
| 273 | bp[1] - bd[1] * h1, |
| 274 | bp[0], |
| 275 | bp[1], |
| 276 | ])); |
| 277 | } |
| 278 | out.push(Cmd::Z); |
| 279 | out |
| 280 | } |
| 281 | |
| 282 | fn orient_like(source: &Path, path: &Path) -> Path { |
| 283 | if signed_area(source) * signed_area(path) >= 0. { |
| 284 | return path.clone(); |
| 285 | } |
| 286 | let start = path[0].end(); |
| 287 | let mut at = start; |
| 288 | let mut segments: Vec<([f64; 2], [f64; 6])> = Vec::new(); |
| 289 | for c in path { |
| 290 | if let Cmd::C(c) = c { |
| 291 | segments.push((at, *c)); |
| 292 | at = [c[4], c[5]]; |
| 293 | } |
| 294 | } |
| 295 | if hypot2(at[0] - start[0], at[1] - start[1]) > 0.01 { |
| 296 | segments.push((at, [at[0], at[1], start[0], start[1], start[0], start[1]])); |
| 297 | } |
| 298 | let mut out = vec![Cmd::M(start)]; |
| 299 | for (at, c) in segments.iter().rev() { |
| 300 | out.push(Cmd::C([c[2], c[3], c[0], c[1], at[0], at[1]])); |
| 301 | } |
| 302 | out.push(Cmd::Z); |
| 303 | out |
| 304 | } |
| 305 | |
| 306 | /// Match anatomical landmarks section by section. |
| 307 | fn pieces(source: &Path, stops: &[usize], targets: &[Path]) -> Path { |
| 308 | let mut begin = 0; |
| 309 | let mut result: Path = Vec::new(); |
| 310 | for (i, &last) in stops.iter().enumerate() { |
| 311 | let section: Path = if begin == 0 { |
| 312 | source[..=last].to_vec() |
| 313 | } else { |
| 314 | let mut section = vec![Cmd::M(source[begin].end())]; |
| 315 | section.extend_from_slice(&source[begin + 1..=last]); |
| 316 | section |
| 317 | }; |
| 318 | let fitted = fit(§ion, &targets[i]); |
| 319 | let from = if i > 0 { 1 } else { 0 }; |
| 320 | result.extend_from_slice(&fitted[from..fitted.len() - 1]); |
| 321 | begin = last; |
| 322 | } |
| 323 | result.push(Cmd::Z); |
| 324 | result |
| 325 | } |
| 326 | |
| 327 | pub const PART_IDS: [&str; 10] = [ |
| 328 | "body", "pouch", "pleat-1", "pleat-2", "jaw-band", "eye", "eyelid", "flipper", "flukes", |
| 329 | "eye-far", |
| 330 | ]; |
| 331 | |
| 332 | pub struct Rig { |
| 333 | pub src: HashMap<&'static str, Path>, |
| 334 | pub front: HashMap<&'static str, Path>, |
| 335 | small_grid: HashMap<&'static str, Path>, |
| 336 | small: Mutex<HashMap<u64, Arc<HashMap<&'static str, Path>>>>, |
| 337 | } |
| 338 | |
| 339 | const MARK: &str = include_str!("../../assets/whale-motion/mark-data.js"); |
| 340 | |
| 341 | fn mark_contours() -> Vec<Path> { |
| 342 | let start = MARK.find("root.WhaleMark=").expect("mark data") + "root.WhaleMark=".len(); |
| 343 | let end = MARK.rfind(";})").expect("mark data end"); |
| 344 | let value: serde_json::Value = serde_json::from_str(&MARK[start..end]).expect("mark json"); |
| 345 | value["contours"] |
| 346 | .as_array() |
| 347 | .unwrap() |
| 348 | .iter() |
| 349 | .map(|contour| { |
| 350 | contour |
| 351 | .as_array() |
| 352 | .unwrap() |
| 353 | .iter() |
| 354 | .map(|c| { |
| 355 | let nums: Vec<f64> = c.as_array().unwrap()[1..] |
| 356 | .iter() |
| 357 | .map(|n| n.as_f64().unwrap()) |
| 358 | .collect(); |
| 359 | match c[0].as_str().unwrap() { |
| 360 | "M" => Cmd::M([nums[0], nums[1]]), |
| 361 | "C" => Cmd::C([nums[0], nums[1], nums[2], nums[3], nums[4], nums[5]]), |
| 362 | _ => Cmd::Z, |
| 363 | } |
| 364 | }) |
| 365 | .collect() |
| 366 | }) |
| 367 | .collect() |
| 368 | } |
| 369 | |
| 370 | fn index_at(path: &Path, x: f64, y: f64) -> usize { |
| 371 | path.iter() |
| 372 | .position(|c| !matches!(c, Cmd::Z) && c.end() == [x, y]) |
| 373 | .expect("landmark") |
| 374 | } |
| 375 | |
| 376 | pub fn rig() -> &'static Rig { |
| 377 | static RIG: OnceLock<Rig> = OnceLock::new(); |
| 378 | RIG.get_or_init(|| { |
| 379 | let contours = mark_contours(); |
| 380 | let (outer, mut pouch, eye, pleat1, pleat2) = ( |
| 381 | contours[0].clone(), |
| 382 | contours[1].clone(), |
| 383 | contours[2].clone(), |
| 384 | contours[3].clone(), |
| 385 | contours[4].clone(), |
| 386 | ); |
| 387 | let mut body = outer[..125].to_vec(); |
| 388 | body.push(Cmd::C([215., 92., 218., 59., 215.77, 40.96])); |
| 389 | body.push(Cmd::Z); |
| 390 | let mut flipper = vec![Cmd::M([228.68, 388.82])]; |
| 391 | flipper.extend_from_slice(&outer[48..54]); |
| 392 | flipper.push(Cmd::C([273., 445., 250., 405., 228.68, 388.82])); |
| 393 | flipper.push(Cmd::Z); |
| 394 | let mut flukes = vec![Cmd::M([203.32, 110.65])]; |
| 395 | flukes.extend_from_slice(&outer[125..outer.len() - 1]); |
| 396 | flukes.push(Cmd::Z); |
| 397 | let ji = index_at(&outer, 141.12, 447.47); |
| 398 | let je = index_at(&outer, 496., 236.55); |
| 399 | let pe = index_at(&pouch, 238.49, 455.19); |
| 400 | let mut jaw = vec![Cmd::M([141.12, 447.47])]; |
| 401 | jaw.extend_from_slice(&outer[ji + 1..=je]); |
| 402 | jaw.push(Cmd::C([497., 249., 486., 265., 483.42, 270.06])); |
| 403 | jaw.extend_from_slice(&pouch[3..=pe]); |
| 404 | jaw.push(Cmd::C([209., 463., 169., 458., 141.12, 447.47])); |
| 405 | jaw.push(Cmd::Z); |
| 406 | let front_body = parse("M244 70 C147 65 78 114 76 228 C75 279 76 303 94 342 C112 370 127 380 151 378 C172 450 250 475 286 465 C384 453 436 399 442 341 C442 320 440 310 431 310 C353 269 270 274 215 288 C176 300 138 314 121 294 C101 269 112 222 127 192 C153 145 203 127 244 128 C244 108 244 87 244 70 Z"); |
| 407 | let front_pouch = parse("M416 353 C405 398 362 431 288 443 C234 448 184 410 155 352 C204 368 241 374 281 374 C328 374 375 365 416 353 Z"); |
| 408 | let front_flukes = parse("M244 128 C270 130 274 154 302 151 C324 149 343 126 346 108 C321 107 314 94 302 100 C279 95 263 97 250 96 C265 86 282 67 285 52 C267 49 249 64 244 70 C244 96 244 116 244 128 Z"); |
| 409 | let front_fin = parse("M165 360 C147 376 139 397 124 404 C147 408 163 402 180 390 C183 378 174 367 165 360 Z"); |
| 410 | let front_jaw = parse("M155 352 C184 410 234 448 288 443 C362 431 405 398 416 353 C424 354 435 350 442 341 C436 399 384 453 286 465 C230 470 180 435 151 378 C150 368 152 359 155 352 Z"); |
| 411 | let front_pleat1 = parse("M330 384 C329 405 315 424 294 434 C314 430 336 405 330 384 Z"); |
| 412 | let front_pleat2 = parse("M247 386 C247 404 261 423 277 433 C257 426 242 403 247 386 Z"); |
| 413 | // SRC in the JavaScript key order; pouch is the mark contour itself. |
| 414 | let mut src_list: Vec<(&'static str, Path)> = Vec::new(); |
| 415 | src_list.push(("body", body)); |
| 416 | // Jaw was built from the pouch before the seam pass mutates it. |
| 417 | let jaw_band = jaw; |
| 418 | pouch = pouch.clone(); |
| 419 | src_list.push(("pouch", pouch)); |
| 420 | src_list.push(("pleat-1", pleat1)); |
| 421 | src_list.push(("pleat-2", pleat2)); |
| 422 | src_list.push(("jaw-band", jaw_band)); |
| 423 | src_list.push(("eye", eye.clone())); |
| 424 | src_list.push(("eyelid", ellipse(294., 337., 18., 15.))); |
| 425 | src_list.push(("flipper", flipper)); |
| 426 | src_list.push(("flukes", flukes)); |
| 427 | src_list.push(("eye-far", eye)); |
| 428 | let target: HashMap<&str, Path> = HashMap::from([ |
| 429 | ("body", front_body), |
| 430 | ("pouch", front_pouch), |
| 431 | ("pleat-1", front_pleat1), |
| 432 | ("pleat-2", front_pleat2), |
| 433 | ("jaw-band", front_jaw), |
| 434 | ("eye", ellipse(200., 330., 13., 8.)), |
| 435 | ("eyelid", ellipse(200., 330., 18., 15.)), |
| 436 | ("flipper", front_fin), |
| 437 | ("flukes", front_flukes.clone()), |
| 438 | ("eye-far", ellipse(365., 330., 13., 8.)), |
| 439 | ]); |
| 440 | // Close any open seam with a straight cubic. |
| 441 | for (_, path) in src_list.iter_mut() { |
| 442 | let first = path[0].end(); |
| 443 | let last = path[path.len() - 2].end(); |
| 444 | if hypot2(first[0] - last[0], first[1] - last[1]) > 0.01 { |
| 445 | let at = path.len() - 1; |
| 446 | path.insert( |
| 447 | at, |
| 448 | Cmd::C([ |
| 449 | lerp(last[0], first[0], 1. / 3.), |
| 450 | lerp(last[1], first[1], 1. / 3.), |
| 451 | lerp(last[0], first[0], 2. / 3.), |
| 452 | lerp(last[1], first[1], 2. / 3.), |
| 453 | first[0], |
| 454 | first[1], |
| 455 | ]), |
| 456 | ); |
| 457 | } |
| 458 | } |
| 459 | let mut src: HashMap<&'static str, Path> = src_list.into_iter().collect(); |
| 460 | let mut front: HashMap<&'static str, Path> = HashMap::new(); |
| 461 | for id in PART_IDS { |
| 462 | front.insert(id, fit(&src[id], &orient_like(&src[id], &target[id]))); |
| 463 | } |
| 464 | front.insert( |
| 465 | "body", |
| 466 | pieces( |
| 467 | &src["body"], |
| 468 | &[47, 53, 80, 124, 125], |
| 469 | &[ |
| 470 | parse("M244 70 C147 65 78 114 76 228 C75 279 98 325 128 335 C142 347 144 363 151 378"), |
| 471 | parse("M151 378 C164 410 187 434 210 449 C231 462 261 468 286 465"), |
| 472 | parse("M286 465 C384 453 436 399 442 341 C442 320 440 310 431 310"), |
| 473 | parse("M431 310 C353 269 270 274 215 288 C176 300 138 314 121 294 C101 269 112 222 127 192 C153 145 203 127 244 128"), |
| 474 | parse("M244 128 C244 108 244 87 244 70"), |
| 475 | ], |
| 476 | ), |
| 477 | ); |
| 478 | // Keep the tapered throat and parallel pleats in the attentive turn. |
| 479 | for id in ["pouch", "pleat-1", "pleat-2"] { |
| 480 | let scaled = src[id] |
| 481 | .iter() |
| 482 | .map(|c| { |
| 483 | c.map(|i, v| { |
| 484 | if i % 2 == 1 { |
| 485 | 355. + (v - 355.) * 0.65 |
| 486 | } else { |
| 487 | 300. + (v - 300.) * 0.9 |
| 488 | } |
| 489 | }) |
| 490 | }) |
| 491 | .collect(); |
| 492 | front.insert(id, scaled); |
| 493 | } |
| 494 | front.insert( |
| 495 | "flipper", |
| 496 | pieces( |
| 497 | &src["flipper"], |
| 498 | &[4, 7], |
| 499 | &[ |
| 500 | parse("M165 360 C147 376 139 397 124 404"), |
| 501 | parse("M124 404 C147 408 163 402 180 390 C183 378 174 367 165 360"), |
| 502 | ], |
| 503 | ), |
| 504 | ); |
| 505 | let flukes_src = &src["flukes"]; |
| 506 | let mut open_src = flukes_src[..flukes_src.len() - 2].to_vec(); |
| 507 | open_src.push(Cmd::Z); |
| 508 | let mut open_target = front_flukes[..front_flukes.len() - 2].to_vec(); |
| 509 | open_target.push(Cmd::Z); |
| 510 | let tail_open = fit(&open_src, &open_target); |
| 511 | let mut front_flukes_fit = tail_open[..tail_open.len() - 1].to_vec(); |
| 512 | front_flukes_fit.extend_from_slice(&front_flukes[front_flukes.len() - 2..]); |
| 513 | front.insert("flukes", front_flukes_fit); |
| 514 | // Overlap only the internal closing seam. |
| 515 | for path in [src.get_mut("flukes").unwrap(), front.get_mut("flukes").unwrap()] { |
| 516 | let at = path.len() - 2; |
| 517 | // `path.at(-2)[1]` and `[3]` in `['C', x1, y1, x2, y2, x, y]`: |
| 518 | // the two control-point x coordinates. |
| 519 | let c = path[at].coords_mut(); |
| 520 | c[0] -= 2.; |
| 521 | c[2] -= 2.; |
| 522 | } |
| 523 | let small_grid = HashMap::from([ |
| 524 | ("body", parse("M10.5 3.5 C6 3.5 3 7 3 12 C3 16.5 5 18.5 8 18.5 C9.5 18.5 10.5 18 11 17 C10.5 18.5 9 19.5 8 19.5 C9 20.5 11 20.5 12.5 20 C17 20 21 16 21.5 11.5 C22 8.5 15 10.5 12 12 C6.5 15 5.5 13 6 10 C6 8 8 6.5 10 6.5 C10.5 5.5 10.5 4.5 10.5 3.5 Z")), |
| 525 | ("flukes", parse("M10 6.5 C11.5 6.5 11.5 8 13.5 8 C15 8 16.5 7 17 6.5 C14 6 13 5 12 6 C13 5.5 14 4.5 14.5 4 C13 3.5 12 3.5 10.5 3.5 C10.5 4.5 10.5 5.5 10 6.5 Z")), |
| 526 | ("pouch", parse("M20.5 12.5 C20 17 16 20 11.5 19.5 C15 18.5 15.5 13 20.5 12.5 Z")), |
| 527 | ("flipper", parse("M11 17 C10.5 18.5 9 19.5 8 19.5 C9 20.5 11 20.5 12.5 20 C13 19.5 12 18 11 17 Z")), |
| 528 | ("jaw-band", parse("M11 20 C17 20 21 16 22 12 C21 17 17 21 11 21 C10 21 10 20 11 20 Z")), |
| 529 | ("eye", ellipse(14., 15., 0.65, 0.46)), |
| 530 | ("eye-far", ellipse(14., 15., 0.65, 0.46)), |
| 531 | ("eyelid", ellipse(14., 15., 1., 0.7)), |
| 532 | ]); |
| 533 | Rig { |
| 534 | src, |
| 535 | front, |
| 536 | small_grid, |
| 537 | small: Mutex::new(HashMap::new()), |
| 538 | } |
| 539 | }) |
| 540 | } |
| 541 | |
| 542 | impl Rig { |
| 543 | /// Optical-fit sources for a 16/24/32-cell icon; authored on a 24-cell |
| 544 | /// grid, not downsampled from the hero. |
| 545 | fn small_source(&self, size: f64) -> Arc<HashMap<&'static str, Path>> { |
| 546 | let mut cache = self.small.lock().unwrap(); |
| 547 | if let Some(found) = cache.get(&size.to_bits()) { |
| 548 | return found.clone(); |
| 549 | } |
| 550 | let mut out = HashMap::new(); |
| 551 | for id in PART_IDS { |
| 552 | let Some(grid) = self.small_grid.get(id) else { |
| 553 | out.insert(id, self.src[id].clone()); |
| 554 | continue; |
| 555 | }; |
| 556 | let fitted: Path = grid |
| 557 | .iter() |
| 558 | .map(|c| { |
| 559 | c.map(|_, v| { |
| 560 | let cell = round(v / 24. * size * 2.) / 2.; |
| 561 | (cell / size - 0.5) * 634.88 + 256. |
| 562 | }) |
| 563 | }) |
| 564 | .collect(); |
| 565 | out.insert(id, fit(&self.src[id], &orient_like(&self.src[id], &fitted))); |
| 566 | } |
| 567 | let out = Arc::new(out); |
| 568 | // Arbitrary public optical sizes must not create an unbounded cache. |
| 569 | // Eviction changes no evaluated geometry; existing readers own Arcs. |
| 570 | if cache.len() >= 64 { |
| 571 | cache.clear(); |
| 572 | } |
| 573 | cache.insert(size.to_bits(), out.clone()); |
| 574 | out |
| 575 | } |
| 576 | } |
| 577 | |
| 578 | fn rotate(x: f64, y: f64, cx: f64, cy: f64, a: f64) -> [f64; 2] { |
| 579 | let (c, s) = (a.cos(), a.sin()); |
| 580 | [ |
| 581 | cx + (x - cx) * c - (y - cy) * s, |
| 582 | cy + (x - cx) * s + (y - cy) * c, |
| 583 | ] |
| 584 | } |
| 585 | |
| 586 | /// Morph a blade in its shoulder-to-tip frame. |
| 587 | fn morph_blade(a: &Path, b: &Path, t: f64) -> Path { |
| 588 | let aa = a[0].end(); |
| 589 | let bb = b[0].end(); |
| 590 | let at = a[4].end(); |
| 591 | let bt = b[4].end(); |
| 592 | let mut angle = (bt[1] - bb[1]).atan2(bt[0] - bb[0]) - (at[1] - aa[1]).atan2(at[0] - aa[0]); |
| 593 | angle = angle.sin().atan2(angle.cos()); |
| 594 | a.iter() |
| 595 | .enumerate() |
| 596 | .map(|(i, c)| { |
| 597 | let src = c.coords(); |
| 598 | let dst = b[i].coords(); |
| 599 | let mut out = Vec::with_capacity(src.len()); |
| 600 | let mut j = 0; |
| 601 | while j < src.len() { |
| 602 | let target = rotate(dst[j] - bb[0], dst[j + 1] - bb[1], 0., 0., -angle); |
| 603 | let p = rotate( |
| 604 | lerp(src[j] - aa[0], target[0], t), |
| 605 | lerp(src[j + 1] - aa[1], target[1], t), |
| 606 | 0., |
| 607 | 0., |
| 608 | angle * t, |
| 609 | ); |
| 610 | out.push(p[0] + lerp(aa[0], bb[0], t)); |
| 611 | out.push(p[1] + lerp(aa[1], bb[1], t)); |
| 612 | j += 2; |
| 613 | } |
| 614 | c.with(&out) |
| 615 | }) |
| 616 | .collect() |
| 617 | } |
| 618 | |
| 619 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 620 | pub struct Direction { |
| 621 | pub uncurl: f64, |
| 622 | pub front_width: f64, |
| 623 | } |
| 624 | |
| 625 | /// `01 / Emblem`, the product direction. |
| 626 | pub const MARK_DIRECTION: Direction = Direction { |
| 627 | uncurl: 0., |
| 628 | front_width: 1., |
| 629 | }; |
| 630 | /// `02 / Cruise` and `03 / Open C`, the reference's alternate studies. |
| 631 | pub const CRUISE_DIRECTION: Direction = Direction { |
| 632 | uncurl: 0.56, |
| 633 | front_width: 1.08, |
| 634 | }; |
| 635 | pub const OPEN_DIRECTION: Direction = Direction { |
| 636 | uncurl: 0.25, |
| 637 | front_width: 0.94, |
| 638 | }; |
| 639 | |
| 640 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 641 | pub enum Role { |
| 642 | Body, |
| 643 | Hole, |
| 644 | Cutout, |
| 645 | Tool, |
| 646 | Accent, |
| 647 | Pointer, |
| 648 | Water, |
| 649 | } |
| 650 | |
| 651 | #[derive(Clone, Debug)] |
| 652 | pub struct Shape { |
| 653 | pub id: String, |
| 654 | pub path: Path, |
| 655 | pub role: Role, |
| 656 | pub opacity: f64, |
| 657 | } |
| 658 | |
| 659 | #[derive(Clone, Copy, Debug, Default, PartialEq)] |
| 660 | pub struct Point { |
| 661 | pub x: f64, |
| 662 | pub y: f64, |
| 663 | } |
| 664 | |
| 665 | #[derive(Clone, Copy, Debug, Default)] |
| 666 | pub struct Anchors { |
| 667 | pub blowhole: Point, |
| 668 | pub spout: Point, |
| 669 | pub mouth: Point, |
| 670 | pub chin: Point, |
| 671 | pub eye: Point, |
| 672 | pub fin_tip: Point, |
| 673 | pub tail: Point, |
| 674 | pub tail_ang: f64, |
| 675 | pub fin_ang: f64, |
| 676 | pub head_ang: f64, |
| 677 | } |
| 678 | |
| 679 | #[derive(Clone, Debug, Default)] |
| 680 | pub struct Parts { |
| 681 | pub shapes: Vec<Shape>, |
| 682 | pub anchors: Anchors, |
| 683 | pub small: bool, |
| 684 | pub unit_scale: f64, |
| 685 | } |
| 686 | |
| 687 | /// The rig's own defaults (`POSE`) with props at zero — what a partial pose |
| 688 | /// such as a calf's resolves to. |
| 689 | pub fn rig_pose() -> Pose { |
| 690 | tables().defaults |
| 691 | } |
| 692 | |
| 693 | /// `build()`: the named contour parts and anchors for one pose. |
| 694 | pub fn build(dir: Direction, pose: &Pose, lod: u8, size: f64, dpr: f64) -> Parts { |
| 695 | if !size.is_finite() |
| 696 | || size <= 0. |
| 697 | || !dpr.is_finite() |
| 698 | || dpr <= 0. |
| 699 | || size > 16_384. |
| 700 | || dpr > 16. |
| 701 | || !dir.uncurl.is_finite() |
| 702 | || !dir.front_width.is_finite() |
| 703 | || dir.front_width <= 0. |
| 704 | || pose.iter().any(|v| !v.is_finite()) |
| 705 | { |
| 706 | return Parts::default(); |
| 707 | } |
| 708 | let rig = rig(); |
| 709 | let q = pose; |
| 710 | let yaw = clamp(q[p::yaw], 0., 1.); |
| 711 | let head_yaw = smooth(0., 0.82, yaw) * 0.18; |
| 712 | let tail_yaw = smooth(0.24, 1., yaw) * 0.12; |
| 713 | let uncurl = dir.uncurl * (1. - clamp(q[p::curl], 0., 1.)) + (-q[p::curl]).max(0.) * 0.9 |
| 714 | - q[p::curl].max(0.) * 0.08; |
| 715 | let small = lod >= 2; |
| 716 | let tool_weight = clamp(q[p::page] + q[p::pencil] + q[p::lens] + q[p::glass], 0., 1.); |
| 717 | let targets = [ |
| 718 | (p::page, 392., 222., 132.), |
| 719 | (p::pencil, 394., 218., 118.), |
| 720 | (p::lens, 380., 240., 168.), |
| 721 | (p::glass, 401., 255., 122.), |
| 722 | ]; |
| 723 | let mut tx = 335. * (1. - tool_weight); |
| 724 | let mut ty = 386. * (1. - tool_weight); |
| 725 | let mut total = 0.; |
| 726 | for (key, x, y, neutral) in targets { |
| 727 | let weight = q[key]; |
| 728 | total += weight; |
| 729 | tx += (x + (q[p::fin] - neutral) * 0.38) * weight; |
| 730 | ty += (y + (q[p::fin] - neutral) * 0.18) * weight; |
| 731 | } |
| 732 | if total > 1. { |
| 733 | tx /= total; |
| 734 | ty /= total; |
| 735 | } |
| 736 | let lift = smooth(30., 98., q[p::fin]); |
| 737 | let attention = smooth(0.65, 1., yaw) * (1. - tool_weight); |
| 738 | let tw = tool_weight; |
| 739 | let held_fin = pieces( |
| 740 | &rig.src["flipper"], |
| 741 | &[4, 7], |
| 742 | &[ |
| 743 | vec![ |
| 744 | Cmd::M([230., 398.]), |
| 745 | Cmd::C([ |
| 746 | 238., |
| 747 | 408., |
| 748 | lerp(252., 250., tw), |
| 749 | lerp(427., 425., tw), |
| 750 | lerp(262., 255., tw), |
| 751 | lerp(438., 435., tw), |
| 752 | ]), |
| 753 | Cmd::C([ |
| 754 | lerp(290., 240., tw), |
| 755 | lerp(416., 350., tw), |
| 756 | tx - lerp(10., 35., tw), |
| 757 | ty + lerp(18., 5., tw), |
| 758 | tx, |
| 759 | ty, |
| 760 | ]), |
| 761 | ], |
| 762 | vec![ |
| 763 | Cmd::M([tx, ty]), |
| 764 | Cmd::C([tx - 66., ty + 20., 230., 357., 230., 398.]), |
| 765 | ], |
| 766 | ], |
| 767 | ); |
| 768 | let small_src = small.then(|| rig.small_source(size)); |
| 769 | let source = |id: &str| -> &Path { |
| 770 | match &small_src { |
| 771 | Some(s) => &s[id], |
| 772 | None => &rig.src[id], |
| 773 | } |
| 774 | }; |
| 775 | let profile_fin = morph_blade(source("flipper"), &held_fin, lift); |
| 776 | let posed_fin = morph_blade(&profile_fin, &rig.front["flipper"], head_yaw); |
| 777 | let transform = |id: &str, x: f64, y: f64| -> [f64; 2] { |
| 778 | let (mut x, mut y) = (x, y); |
| 779 | let w = (1. - smooth(165., 295., y)) * smooth(315., 135., x); |
| 780 | x -= uncurl * 48. * w; |
| 781 | y += uncurl * 60. * w; |
| 782 | if id == "flukes" { |
| 783 | let hinge = lerp(220., 246., tail_yaw); |
| 784 | let weight = smooth(hinge, hinge + 75., x); |
| 785 | [x, y] = rotate(x, y, hinge, 110., (q[p::fluke] - 6.) * 0.48 * D2R * weight); |
| 786 | } |
| 787 | if y > 285. { |
| 788 | let jaw_weight = smooth(320., 470., y); |
| 789 | y += q[p::mouth] * 22. * jaw_weight; |
| 790 | if id == "pouch" { |
| 791 | y += (q[p::smile] - 0.4) * 7. * ((x - 230.) / 260. * std::f64::consts::PI).sin(); |
| 792 | y += q[p::mouthSide] * 10. * smooth(280., 480., x) * (1. - smooth(400., 460., y)); |
| 793 | } |
| 794 | } |
| 795 | let head_weight = smooth(185., 470., x) * smooth(150., 300., y); |
| 796 | [x, y] = rotate(x, y, 290., 305., -q[p::head] * 0.34 * D2R * head_weight); |
| 797 | x = (x - 256.) / 5.12; |
| 798 | y = (y - 256.) / 5.12; |
| 799 | x *= q[p::scale] * (1. + q[p::squash] * 0.5); |
| 800 | y *= q[p::scale] * (1. - q[p::squash]); |
| 801 | [x, y] = rotate(x, y, 0., 0., (q[p::rot] + q[p::tilt] * 0.28) * D2R); |
| 802 | if small { |
| 803 | x *= 0.9; |
| 804 | y *= 0.9; |
| 805 | } |
| 806 | [x + q[p::x], y + q[p::y]] |
| 807 | }; |
| 808 | let mut shapes: Vec<Shape> = Vec::with_capacity(12); |
| 809 | for id in [ |
| 810 | "flukes", "body", "jaw-band", "pouch", "pleat-1", "pleat-2", "flipper", "eye", "eye-far", |
| 811 | "eyelid", |
| 812 | ] { |
| 813 | let a = source(id); |
| 814 | let b = &rig.front[id]; |
| 815 | let t = if id == "flukes" { tail_yaw } else { head_yaw }; |
| 816 | let mut opacity = 1.; |
| 817 | if id == "flipper" { |
| 818 | opacity = lift * (1. - attention); |
| 819 | } |
| 820 | if id == "jaw-band" { |
| 821 | opacity = 0.; |
| 822 | } |
| 823 | if id.starts_with("pleat") && small { |
| 824 | opacity = 0.; |
| 825 | } |
| 826 | if id.starts_with("eye") && small && 26. / 512. * size * dpr < 2. { |
| 827 | opacity = 0.; |
| 828 | } |
| 829 | if id == "eyelid" || id == "eye-far" { |
| 830 | opacity = 0.; |
| 831 | } |
| 832 | let path: Path = a |
| 833 | .iter() |
| 834 | .enumerate() |
| 835 | .map(|(i, c)| { |
| 836 | if matches!(c, Cmd::Z) { |
| 837 | return Cmd::Z; |
| 838 | } |
| 839 | let src = c.coords(); |
| 840 | let dst = b[i].coords(); |
| 841 | let mut out = Vec::with_capacity(src.len()); |
| 842 | let mut j = 0; |
| 843 | while j < src.len() { |
| 844 | let blend = if id == "body" { |
| 845 | lerp(head_yaw, tail_yaw, 1. - smooth(140., 260., src[j + 1])) |
| 846 | } else { |
| 847 | t |
| 848 | }; |
| 849 | let mut x = lerp(src[j], dst[j], blend); |
| 850 | let mut y = lerp(src[j + 1], dst[j + 1], blend); |
| 851 | if id == "body" { |
| 852 | x -= 8. |
| 853 | * (yaw * std::f64::consts::PI).sin() |
| 854 | * smooth(90., 0., hypot2(src[j] - 160., src[j + 1] - 442.)); |
| 855 | } |
| 856 | if id == "flipper" { |
| 857 | let fin = posed_fin[i].coords(); |
| 858 | x = fin[j]; |
| 859 | y = fin[j + 1]; |
| 860 | } |
| 861 | if yaw > 0. { |
| 862 | x = 256. + (x - 256.) * lerp(1., dir.front_width, head_yaw); |
| 863 | } |
| 864 | if id == "eye" || id == "eye-far" { |
| 865 | let cx = if id == "eye" { |
| 866 | lerp(294., 200., head_yaw) |
| 867 | } else { |
| 868 | lerp(294., 365., head_yaw) |
| 869 | }; |
| 870 | let cy = lerp(337., 330., head_yaw); |
| 871 | let visible = if id == "eye-far" { 0. } else { 1. }; |
| 872 | x = cx + (x - cx) * q[p::eyeScale] * visible; |
| 873 | y = cy |
| 874 | + (y - cy) |
| 875 | * q[p::eyeScale] |
| 876 | * (1. - q[p::lid] * 0.97 - q[p::lidLow] * 0.64) |
| 877 | * visible; |
| 878 | y += q[p::lidLow] * 3. * (((x - cx) / 13.).powf(2.) - 1.); |
| 879 | x += q[p::lookX] * 2.; |
| 880 | y += q[p::lookY] * 2.; |
| 881 | } |
| 882 | let xy = transform(id, x, y); |
| 883 | let grid = 124. / (size * dpr); |
| 884 | if small { |
| 885 | out.push(round(xy[0] / grid * 2.) / 2. * grid); |
| 886 | out.push(round(xy[1] / grid * 2.) / 2. * grid); |
| 887 | } else { |
| 888 | out.extend_from_slice(&xy); |
| 889 | } |
| 890 | j += 2; |
| 891 | } |
| 892 | c.with(&out) |
| 893 | }) |
| 894 | .collect(); |
| 895 | let role = if matches!(id, "pouch" | "eye" | "eye-far") { |
| 896 | Role::Hole |
| 897 | } else { |
| 898 | Role::Body |
| 899 | }; |
| 900 | shapes.push(Shape { |
| 901 | id: id.to_string(), |
| 902 | path, |
| 903 | role, |
| 904 | opacity, |
| 905 | }); |
| 906 | } |
| 907 | // The legacy named lid geometry; closing the eye narrows its aperture. |
| 908 | let cy = lerp(337., 328., head_yaw); |
| 909 | let lid_path: Path = rig.src["eyelid"] |
| 910 | .iter() |
| 911 | .enumerate() |
| 912 | .map(|(i, c)| { |
| 913 | if matches!(c, Cmd::Z) { |
| 914 | return Cmd::Z; |
| 915 | } |
| 916 | let src = c.coords(); |
| 917 | let dst = rig.front["eyelid"][i].coords(); |
| 918 | let mut out = Vec::new(); |
| 919 | let mut j = 0; |
| 920 | while j < src.len() { |
| 921 | let x = lerp(src[j], dst[j], head_yaw); |
| 922 | let y = lerp(src[j + 1], dst[j + 1], head_yaw); |
| 923 | out.extend_from_slice(&transform( |
| 924 | "eye", |
| 925 | x, |
| 926 | cy - 13. + (y - cy + 15.) * q[p::lid] * 0.40, |
| 927 | )); |
| 928 | j += 2; |
| 929 | } |
| 930 | c.with(&out) |
| 931 | }) |
| 932 | .collect(); |
| 933 | let lid = shapes.iter_mut().find(|s| s.id == "eyelid").unwrap(); |
| 934 | lid.opacity = 0.; |
| 935 | lid.path = lid_path; |
| 936 | let anchor = |x: f64, y: f64| { |
| 937 | let z = transform("body", x, y); |
| 938 | Point { x: z[0], y: z[1] } |
| 939 | }; |
| 940 | let tip = shapes.iter().find(|s| s.id == "flipper").unwrap().path[4].end(); |
| 941 | // The mark's own flipper separation, carried up with the arm. |
| 942 | let left = [[230., 398.], [230., 357.], [tx - 66., ty + 20.], [tx, ty]]; |
| 943 | let mix = |a: [f64; 2], b: [f64; 2], t: f64| [lerp(a[0], b[0], t), lerp(a[1], b[1], t)]; |
| 944 | let q1 = mix(left[0], left[1], 0.42); |
| 945 | let mid = mix(left[1], left[2], 0.42); |
| 946 | let q2 = mix(q1, mid, 0.42); |
| 947 | let q3 = mix(q2, mix(mid, mix(left[2], left[3], 0.42), 0.42), 0.42); |
| 948 | let point = |pt: [f64; 2], dx: f64, dy: f64| transform("body", pt[0] + dx, pt[1] + dy); |
| 949 | let cat = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| [a[0], a[1], b[0], b[1], c[0], c[1]]; |
| 950 | let start = point(left[0], -6., -4.); |
| 951 | let gap = Shape { |
| 952 | id: "flipper-gap".into(), |
| 953 | path: vec![ |
| 954 | Cmd::M(start), |
| 955 | Cmd::C(cat( |
| 956 | point(q1, -7., -2.), |
| 957 | point(q2, -5., -1.), |
| 958 | point(q3, 0., 0.), |
| 959 | )), |
| 960 | Cmd::C(cat( |
| 961 | point(q2, 0., 0.), |
| 962 | point(q1, 0., 0.), |
| 963 | point(left[0], -6., -4.), |
| 964 | )), |
| 965 | Cmd::Z, |
| 966 | ], |
| 967 | role: Role::Hole, |
| 968 | opacity: lift * (1. - head_yaw) * (1. - attention), |
| 969 | }; |
| 970 | let at = shapes.iter().position(|s| s.id == "flipper").unwrap(); |
| 971 | shapes.insert(at, gap); |
| 972 | let tail = transform( |
| 973 | "flukes", |
| 974 | lerp(289., 296., tail_yaw), |
| 975 | lerp(113., 121., tail_yaw), |
| 976 | ); |
| 977 | let anchors = Anchors { |
| 978 | blowhole: anchor(lerp(325., 278., head_yaw), lerp(234., 278., head_yaw)), |
| 979 | spout: anchor(410., 222.), |
| 980 | mouth: anchor(lerp(480., 426., head_yaw), lerp(300., 353., head_yaw)), |
| 981 | chin: anchor(lerp(397., 285., head_yaw), lerp(376., 425., head_yaw)), |
| 982 | eye: anchor(lerp(294., 200., head_yaw), lerp(337., 330., head_yaw)), |
| 983 | fin_tip: Point { |
| 984 | x: tip[0], |
| 985 | y: tip[1], |
| 986 | }, |
| 987 | tail: Point { |
| 988 | x: tail[0], |
| 989 | y: tail[1], |
| 990 | }, |
| 991 | tail_ang: (q[p::rot] + q[p::tilt] * 0.28 + (q[p::fluke] - 6.) * 0.35) * D2R, |
| 992 | fin_ang: (q[p::rot] + q[p::tilt] * 0.28) * D2R, |
| 993 | head_ang: (q[p::rot] + q[p::tilt] * 0.28 - q[p::head] * 0.34) * D2R, |
| 994 | }; |
| 995 | Parts { |
| 996 | shapes, |
| 997 | anchors, |
| 998 | small, |
| 999 | unit_scale: q[p::scale] * if small { 0.9 } else { 1. }, |
| 1000 | } |
| 1001 | } |
| 1002 | |
| 1003 | /// `holesFor()`: the compound holes painted with a shape. In one ink the |
| 1004 | /// body subtracts its apertures; designated prop details are always holes. |
| 1005 | pub fn holes_for(id: &str, shapes: &[Shape], mono: bool) -> Vec<Path> { |
| 1006 | let prefix = id.strip_suffix("body").unwrap_or(""); |
| 1007 | shapes |
| 1008 | .iter() |
| 1009 | .filter(|h| { |
| 1010 | (mono |
| 1011 | && id.ends_with("body") |
| 1012 | && h.role == Role::Hole |
| 1013 | && [ |
| 1014 | format!("{prefix}pouch"), |
| 1015 | format!("{prefix}eye"), |
| 1016 | format!("{prefix}eye-far"), |
| 1017 | ] |
| 1018 | .contains(&h.id)) |
| 1019 | || (mono && h.id == "flipper-gap" && id == "body") |
| 1020 | || (h.role == Role::Cutout |
| 1021 | && (id == "prop-page" || id == "prop-pad") |
| 1022 | && h.id.starts_with(&format!("{id}-"))) |
| 1023 | }) |
| 1024 | .filter_map(|h| { |
| 1025 | if !h.opacity.is_finite() { |
| 1026 | return None; |
| 1027 | } |
| 1028 | let origin = h.path.first()?.end(); |
| 1029 | let w = h.opacity; |
| 1030 | Some( |
| 1031 | h.path |
| 1032 | .iter() |
| 1033 | .map(|c| c.map(|i, v| lerp(origin[i % 2], v, w))) |
| 1034 | .collect(), |
| 1035 | ) |
| 1036 | }) |
| 1037 | .collect() |
| 1038 | } |
| 1039 | |
| 1040 | /// `pathString()`: the geometry receipt's exact number format. |
| 1041 | pub fn path_string(path: &Path) -> String { |
| 1042 | path.iter() |
| 1043 | .map(|c| { |
| 1044 | let op = match c { |
| 1045 | Cmd::M(_) => "M", |
| 1046 | Cmd::C(_) => "C", |
| 1047 | Cmd::Z => "Z", |
| 1048 | }; |
| 1049 | let nums: Vec<String> = c.coords().iter().map(|v| super::math::fixed3(*v)).collect(); |
| 1050 | format!("{op}{}", nums.join(" ")) |
| 1051 | }) |
| 1052 | .collect::<Vec<_>>() |
| 1053 | .join(" ") |
| 1054 | } |
| 1055 |