Skip to main content

ling/convert/
mod.rs

1//! `ling convert <asset> [-o out.ling] [--no-compression]`
2//!
3//! Detects an asset's type by extension and transcodes it into a **self-contained,
4//! importable `.ling` file** that reconstructs the asset with the engine's own
5//! builtins — preserving geometry, names, and structure. Bulk numeric data is
6//! embedded **losslessly**: by default deflate-compressed + base64 behind the
7//! `blob_f32` / `blob_i32` builtins; `--no-compression` emits plain `.ling` arrays.
8//!
9//! Supported: `.gltf`/`.glb` (meshes + node names/transforms), `.wav`/`.ogg`/`.flac`
10//! (PCM), `.mid` (note events), `.svg` (paths/primitives → vector strokes).
11//! `.blend` is detected and routed through Blender's glTF exporter when available.
12
13use std::io::Write;
14use std::path::Path;
15
16use base64::Engine as _;
17
18// ── public entry ────────────────────────────────────────────────────────────
19
20pub fn run(args: &[String]) -> i32 {
21    // args = ["convert", <input>, ...flags]
22    let input = match args.get(1) {
23        Some(s) if !s.starts_with('-') => s.clone(),
24        _ => {
25            eprintln!("Usage: ling convert <file.(gltf|glb|wav|ogg|flac|mid|svg|blend)> [-o out.ling] [--no-compression]");
26            return 1;
27        }
28    };
29    let compress = !args.iter().any(|a| a == "--no-compression");
30    let out = flag_value(args, "-o")
31        .or_else(|| flag_value(args, "--out"))
32        .unwrap_or_else(|| default_out(&input));
33
34    match convert(&input, &out, compress) {
35        Ok(bytes) => {
36            eprintln!(
37                "[convert] {} → {}  ({} KB, {})",
38                input, out, bytes / 1024,
39                if compress { "deflate+base64 lossless" } else { "uncompressed" }
40            );
41            0
42        }
43        Err(e) => {
44            eprintln!("[convert] error: {e}");
45            1
46        }
47    }
48}
49
50fn default_out(input: &str) -> String {
51    let p = Path::new(input);
52    p.with_extension("ling").to_string_lossy().into_owned()
53}
54
55fn flag_value(args: &[String], flag: &str) -> Option<String> {
56    args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned())
57}
58
59/// Dispatch on extension. Returns the number of bytes written.
60pub fn convert(input: &str, output: &str, compress: bool) -> Result<usize, String> {
61    let ext = Path::new(input)
62        .extension()
63        .map(|e| e.to_string_lossy().to_lowercase())
64        .unwrap_or_default();
65    let stem = Path::new(input)
66        .file_stem()
67        .map(|s| s.to_string_lossy().into_owned())
68        .unwrap_or_else(|| "asset".into());
69    let name = sanitize(&stem);
70
71    let ling = match ext.as_str() {
72        "gltf" | "glb" => conv_gltf(input, &name, compress)?,
73        "wav" | "ogg" | "flac" | "mp3" => conv_audio(input, &name, compress)?,
74        "mid" | "midi" => conv_midi(input, &name, compress)?,
75        "svg" => conv_svg(input, &name, compress)?,
76        "blend" => conv_blend(input, output, compress)?,
77        other => return Err(format!("unsupported extension '.{other}'")),
78    };
79
80    let mut f = std::fs::File::create(output).map_err(|e| format!("{output}: {e}"))?;
81    f.write_all(ling.as_bytes()).map_err(|e| e.to_string())?;
82    Ok(ling.len())
83}
84
85// ── shared emitters ───────────────────────────────────────────────────────────
86
87fn deflate(bytes: &[u8]) -> Vec<u8> {
88    use flate2::{write::ZlibEncoder, Compression};
89    let mut e = ZlibEncoder::new(Vec::new(), Compression::best());
90    let _ = e.write_all(bytes);
91    e.finish().unwrap_or_default()
92}
93
94fn b64(bytes: &[u8]) -> String {
95    base64::engine::general_purpose::STANDARD.encode(bytes)
96}
97
98/// Emit `bind <name> = …` for a float array — compressed blob or plain list.
99fn emit_f32(name: &str, data: &[f32], compress: bool) -> String {
100    if compress && data.len() > 8 {
101        let mut bytes = Vec::with_capacity(data.len() * 4);
102        for v in data { bytes.extend_from_slice(&v.to_le_bytes()); }
103        format!("bind {name} = blob_f32(\"{}\")\n", b64(&deflate(&bytes)))
104    } else {
105        let body: Vec<String> = data.iter().map(|v| fmt_f32(*v)).collect();
106        format!("bind {name} = [{}]\n", body.join(", "))
107    }
108}
109
110fn emit_i32(name: &str, data: &[u32], compress: bool) -> String {
111    if compress && data.len() > 8 {
112        let mut bytes = Vec::with_capacity(data.len() * 4);
113        for v in data { bytes.extend_from_slice(&(*v as i32).to_le_bytes()); }
114        format!("bind {name} = blob_i32(\"{}\")\n", b64(&deflate(&bytes)))
115    } else {
116        let body: Vec<String> = data.iter().map(|v| v.to_string()).collect();
117        format!("bind {name} = [{}]\n", body.join(", "))
118    }
119}
120
121fn fmt_f32(v: f32) -> String {
122    if v == v.trunc() && v.abs() < 1e7 { format!("{:.1}", v) } else { format!("{}", v) }
123}
124
125fn sanitize(s: &str) -> String {
126    let mut out: String = s.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();
127    if out.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(true) { out.insert(0, '_'); }
128    out
129}
130
131fn header(kind: &str, src: &str) -> String {
132    format!(
133        "# ───────────────────────────────────────────────────────────────────────────\n\
134         # Auto-generated by `ling convert` — {kind}\n\
135         # source: {src}\n\
136         # Lossless: bulk data is deflate+base64 behind blob_f32/blob_i32 (or plain\n\
137         # arrays with --no-compression). Import this file and call its draw/play fn.\n\
138         # ───────────────────────────────────────────────────────────────────────────\n\n"
139    )
140}
141
142// ── glTF / GLB → mesh data + node hierarchy ───────────────────────────────────
143
144fn conv_gltf(input: &str, name: &str, compress: bool) -> Result<String, String> {
145    let model = ling_physics::gltf::GltfModel::load(input)?;
146    let mut s = header("glTF model (geometry + nodes)", input);
147
148    // node hierarchy + names + transforms, preserved as data
149    s.push_str("# ── nodes (name, mesh index, world-ish transform rows) ──\n");
150    for (i, n) in model.nodes.iter().enumerate() {
151        let m = n.transform.to_cols_array();
152        s.push_str(&format!(
153            "# node[{i}] \"{}\" mesh={:?} T=[{:.3},{:.3},{:.3},{:.3} / {:.3},{:.3},{:.3},{:.3} / {:.3},{:.3},{:.3},{:.3} / {:.3},{:.3},{:.3},{:.3}]\n",
154            n.name, n.mesh_idx,
155            m[0],m[1],m[2],m[3], m[4],m[5],m[6],m[7], m[8],m[9],m[10],m[11], m[12],m[13],m[14],m[15],
156        ));
157    }
158    s.push('\n');
159
160    // per-mesh: positions (3/vert), normals (3/vert), uvs (2/vert), indices, + a draw fn
161    let mut draw_calls = Vec::new();
162    for (mi, mesh) in model.meshes.iter().enumerate() {
163        let raw_name = if mesh.name.is_empty() { format!("mesh{mi}") } else { mesh.name.clone() };
164        let mname = sanitize(&raw_name);
165        let mut pos = Vec::with_capacity(mesh.verts.len() * 3);
166        let mut nrm = Vec::with_capacity(mesh.verts.len() * 3);
167        let mut uv = Vec::with_capacity(mesh.verts.len() * 2);
168        for v in &mesh.verts {
169            pos.extend_from_slice(&[v.pos.x, v.pos.y, v.pos.z]);
170            nrm.extend_from_slice(&[v.normal.x, v.normal.y, v.normal.z]);
171            uv.extend_from_slice(&[v.uv.x, v.uv.y]);
172        }
173        s.push_str(&format!("# mesh \"{}\" — {} verts, {} tris, material={:?}\n",
174            mesh.name, mesh.verts.len(), mesh.indices.len() / 3, mesh.mat_idx));
175        s.push_str(&emit_f32(&format!("{name}_{mname}_pos"), &pos, compress));
176        s.push_str(&emit_f32(&format!("{name}_{mname}_nrm"), &nrm, compress));
177        s.push_str(&emit_f32(&format!("{name}_{mname}_uv"), &uv, compress));
178        s.push_str(&emit_i32(&format!("{name}_{mname}_idx"), &mesh.indices, compress));
179        // wireframe draw: each triangle's 3 edges via draw_line_3d
180        s.push_str(&format!(
181            "\nฟังก์ชัน draw_{name}_{mname}(ox, oy, oz, scale) {{\n\
182             \x20   bind P = {name}_{mname}_pos  bind I = {name}_{mname}_idx\n\
183             \x20   bind n = len(I)\n\
184             \x20   bind k = 0\n\
185             \x20   while k + 2 < n + 1 {{\n\
186             \x20       bind a = list_get(I, k) * 3  bind b = list_get(I, k+1) * 3  bind c = list_get(I, k+2) * 3\n\
187             \x20       bind ax = ox + list_get(P,a)*scale    bind ay = oy + list_get(P,a+1)*scale  bind az = oz + list_get(P,a+2)*scale\n\
188             \x20       bind bx = ox + list_get(P,b)*scale    bind by = oy + list_get(P,b+1)*scale  bind bz = oz + list_get(P,b+2)*scale\n\
189             \x20       bind cx = ox + list_get(P,c)*scale    bind cy = oy + list_get(P,c+1)*scale  bind cz = oz + list_get(P,c+2)*scale\n\
190             \x20       draw_line_3d(ax,ay,az, bx,by,bz)  draw_line_3d(bx,by,bz, cx,cy,cz)  draw_line_3d(cx,cy,cz, ax,ay,az)\n\
191             \x20       bind k = k + 3\n\
192             \x20   }}\n\
193             }}\n\n"
194        ));
195        draw_calls.push(format!("draw_{name}_{mname}(ox, oy, oz, scale)"));
196    }
197
198    // a convenience that draws the whole model
199    s.push_str(&format!("ฟังก์ชัน draw_{name}(ox, oy, oz, scale) {{\n"));
200    for c in &draw_calls { s.push_str(&format!("    {c}\n")); }
201    s.push_str("}\n\n");
202    s.push_str(&format!(
203        "# Example:\n# ใช้ \"{name}.ling\"\n# … inside your loop: draw_{name}(0,0,0, 1.0)  flush_3d()\n"
204    ));
205    Ok(s)
206}
207
208// ── audio (wav/ogg/flac/mp3) → PCM + a play function ──────────────────────────
209
210#[cfg(not(target_arch = "wasm32"))]
211fn conv_audio(input: &str, name: &str, compress: bool) -> Result<String, String> {
212    let a = ling_music::decode::load(input)?;
213    let mut s = header("audio (PCM samples)", input);
214    s.push_str(&format!(
215        "# rate={} Hz, channels={}, duration={:.3}s, mono samples={}\n",
216        a.rate, a.channels, a.duration, a.mono.len()
217    ));
218    s.push_str(&format!("bind {name}_rate = {}.0\n", a.rate));
219    s.push_str(&format!("bind {name}_dur = {}\n", fmt_f32(a.duration)));
220    // mono downmix is enough to reconstruct/play losslessly at the source rate
221    s.push_str(&emit_f32(&format!("{name}_pcm"), &a.mono, compress));
222    s.push_str(&format!(
223        "\n# {name}_pcm holds the lossless mono PCM at {name}_rate.\n\
224         # Feed it to your audio path (e.g. a sample-playback builtin) or analyse it.\n"
225    ));
226    Ok(s)
227}
228
229#[cfg(target_arch = "wasm32")]
230fn conv_audio(_: &str, _: &str, _: bool) -> Result<String, String> {
231    Err("audio conversion is unavailable on wasm".into())
232}
233
234// ── MIDI → note events ────────────────────────────────────────────────────────
235
236#[cfg(not(target_arch = "wasm32"))]
237fn conv_midi(input: &str, name: &str, compress: bool) -> Result<String, String> {
238    let song = ling_music::midi::load(input)?;
239    let mut s = header("MIDI song (note events)", input);
240    s.push_str(&format!("# {} notes, duration {:.3}s\n", song.notes.len(), song.duration));
241    s.push_str(&format!("bind {name}_dur = {}\n", fmt_f32(song.duration)));
242    // flat note list: [time, dur, midi, vel, channel] per note
243    let mut flat = Vec::with_capacity(song.notes.len() * 5);
244    for n in &song.notes {
245        flat.push(n.time); flat.push(n.dur);
246        flat.push(n.midi as f32); flat.push(n.vel as f32); flat.push(n.channel as f32);
247    }
248    s.push_str(&emit_f32(&format!("{name}_notes"), &flat, compress));
249    s.push_str(&format!(
250        "\n# {name}_notes is flat [time,dur,midi,vel,channel] × {} — step it against a\n\
251         # clock and trigger tones (e.g. music_note / audio_tone) per event.\n",
252        song.notes.len()
253    ));
254    Ok(s)
255}
256
257#[cfg(target_arch = "wasm32")]
258fn conv_midi(_: &str, _: &str, _: bool) -> Result<String, String> {
259    Err("MIDI conversion is unavailable on wasm".into())
260}
261
262// ── SVG → vector strokes (paths + basic primitives) ──────────────────────────
263
264fn conv_svg(input: &str, name: &str, compress: bool) -> Result<String, String> {
265    let xml = std::fs::read_to_string(input).map_err(|e| format!("{input}: {e}"))?;
266    let mut polylines: Vec<Vec<[f32; 2]>> = Vec::new();
267    for d in attr_values(&xml, "path", "d") {
268        polylines.extend(svg_path_to_polylines(&d));
269    }
270    // primitives → polylines
271    for r in elements(&xml, "line") {
272        if let (Some(x1), Some(y1), Some(x2), Some(y2)) =
273            (num(&r, "x1"), num(&r, "y1"), num(&r, "x2"), num(&r, "y2")) {
274            polylines.push(vec![[x1, y1], [x2, y2]]);
275        }
276    }
277    for r in elements(&xml, "rect") {
278        if let (Some(x), Some(y), Some(w), Some(h)) =
279            (num(&r, "x"), num(&r, "y"), num(&r, "width"), num(&r, "height")) {
280            polylines.push(vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h], [x, y]]);
281        }
282    }
283    for r in elements(&xml, "polyline").into_iter().chain(elements(&xml, "polygon")) {
284        if let Some(pts) = attr(&r, "points") {
285            let nums: Vec<f32> = pts.split(|c: char| c == ',' || c.is_whitespace())
286                .filter_map(|t| t.trim().parse().ok()).collect();
287            let pl: Vec<[f32; 2]> = nums.chunks_exact(2).map(|c| [c[0], c[1]]).collect();
288            if pl.len() >= 2 { polylines.push(pl); }
289        }
290    }
291    if polylines.is_empty() {
292        return Err("no <path>/line/rect/poly geometry found in SVG".into());
293    }
294    // flatten to a single coordinate stream + per-polyline lengths
295    let mut coords: Vec<f32> = Vec::new();
296    let mut lens: Vec<u32> = Vec::new();
297    for pl in &polylines {
298        lens.push(pl.len() as u32);
299        for p in pl { coords.push(p[0]); coords.push(p[1]); }
300    }
301    let mut s = header("SVG vector art (polylines)", input);
302    s.push_str(&format!("# {} polylines, {} points\n", polylines.len(), coords.len() / 2));
303    s.push_str(&emit_f32(&format!("{name}_xy"), &coords, compress));
304    s.push_str(&emit_i32(&format!("{name}_lens"), &lens, compress));
305    s.push_str(&format!(
306        "\nฟังก์ชัน draw_{name}(ox, oy, scale) {{\n\
307         \x20   bind L = {name}_lens  bind P = {name}_xy\n\
308         \x20   bind li = 0  bind base = 0\n\
309         \x20   while li < len(L) {{\n\
310         \x20       bind cnt = list_get(L, li)  bind j = 0\n\
311         \x20       while j + 1 < cnt {{\n\
312         \x20           bind a = (base + j) * 2  bind b = (base + j + 1) * 2\n\
313         \x20           draw_line(ox + list_get(P,a)*scale, oy + list_get(P,a+1)*scale, ox + list_get(P,b)*scale, oy + list_get(P,b+1)*scale)\n\
314         \x20           bind j = j + 1\n\
315         \x20       }}\n\
316         \x20       bind base = base + cnt  bind li = li + 1\n\
317         \x20   }}\n\
318         }}\n"
319    ));
320    Ok(s)
321}
322
323// ── .blend → route through Blender's glTF exporter when available ─────────────
324
325fn conv_blend(input: &str, output: &str, compress: bool) -> Result<String, String> {
326    // Try a headless Blender export to glTF, then convert that.
327    let tmp = std::env::temp_dir().join("ling_blend_export.glb");
328    let tmp_s = tmp.to_string_lossy().to_string();
329    let script = format!(
330        "import bpy; bpy.ops.export_scene.gltf(filepath=r'{}', export_format='GLB')",
331        tmp_s
332    );
333    let blender = which_blender();
334    match blender {
335        Some(bin) => {
336            let status = std::process::Command::new(&bin)
337                .args(["-b", input, "--python-expr", &script])
338                .status()
339                .map_err(|e| format!("failed to run Blender ({bin}): {e}"))?;
340            if !status.success() || !tmp.exists() {
341                return Err("Blender ran but produced no glTF export".into());
342            }
343            let name = sanitize(
344                &Path::new(input).file_stem().map(|s| s.to_string_lossy().into_owned())
345                    .unwrap_or_else(|| "asset".into()),
346            );
347            let s = conv_gltf(&tmp_s, &name, compress)?;
348            let _ = std::fs::remove_file(&tmp);
349            let _ = output;
350            Ok(s)
351        }
352        None => Err(
353            ".blend needs Blender on PATH (set $BLENDER or install it). \
354             Or export the model to .glb/.gltf in Blender and run `ling convert model.glb`."
355                .into(),
356        ),
357    }
358}
359
360fn which_blender() -> Option<String> {
361    if let Ok(b) = std::env::var("BLENDER") {
362        if !b.is_empty() { return Some(b); }
363    }
364    for cand in ["blender", "blender.exe"] {
365        if std::process::Command::new(cand).arg("--version").output().map(|o| o.status.success()).unwrap_or(false) {
366            return Some(cand.to_string());
367        }
368    }
369    None
370}
371
372// ── tiny SVG helpers ──────────────────────────────────────────────────────────
373
374/// Collect the value of `attr` from every `<tag …>` element.
375fn attr_values(xml: &str, tag: &str, attr_name: &str) -> Vec<String> {
376    elements(xml, tag).iter().filter_map(|e| attr(e, attr_name)).collect()
377}
378
379/// Return the raw attribute-region string of each `<tag …>` occurrence.
380fn elements(xml: &str, tag: &str) -> Vec<String> {
381    let needle = format!("<{tag}");
382    let mut out = Vec::new();
383    let mut i = 0;
384    while let Some(p) = xml[i..].find(&needle) {
385        let start = i + p + needle.len();
386        if let Some(end) = xml[start..].find('>') {
387            out.push(xml[start..start + end].to_string());
388            i = start + end;
389        } else { break; }
390    }
391    out
392}
393
394fn attr(el: &str, key: &str) -> Option<String> {
395    let pat = format!("{key}=\"");
396    let p = el.find(&pat)? + pat.len();
397    let end = el[p..].find('"')? + p;
398    Some(el[p..end].to_string())
399}
400
401fn num(el: &str, key: &str) -> Option<f32> {
402    attr(el, key).and_then(|v| v.trim().trim_end_matches(|c: char| !c.is_ascii_digit() && c != '.' && c != '-').parse().ok())
403}
404
405/// Minimal SVG path → polylines. Handles M/L/H/V/Z (abs+rel) and flattens
406/// C/Q curves coarsely; good enough to preserve the shape of typical vector art.
407fn svg_path_to_polylines(d: &str) -> Vec<Vec<[f32; 2]>> {
408    let mut polys: Vec<Vec<[f32; 2]>> = Vec::new();
409    let mut cur: Vec<[f32; 2]> = Vec::new();
410    let (mut x, mut y) = (0.0f32, 0.0f32);
411    let (mut start_x, mut start_y) = (0.0f32, 0.0f32);
412    let mut toks = tokenize_path(d);
413    let mut i = 0;
414    let mut cmd = ' ';
415    while i < toks.len() {
416        if let Tok::Cmd(c) = toks[i] { cmd = c; i += 1; }
417        let rel = cmd.is_ascii_lowercase();
418        let uc = cmd.to_ascii_uppercase();
419        let mut next = |i: &mut usize| -> f32 {
420            while *i < toks.len() { if let Tok::Num(n) = toks[*i] { *i += 1; return n; } else { break; } }
421            0.0
422        };
423        match uc {
424            'M' => {
425                if !cur.is_empty() { polys.push(std::mem::take(&mut cur)); }
426                let (nx, ny) = (next(&mut i), next(&mut i));
427                x = if rel { x + nx } else { nx }; y = if rel { y + ny } else { ny };
428                start_x = x; start_y = y; cur.push([x, y]); cmd = if rel { 'l' } else { 'L' };
429            }
430            'L' => { let (nx, ny) = (next(&mut i), next(&mut i));
431                x = if rel { x + nx } else { nx }; y = if rel { y + ny } else { ny }; cur.push([x, y]); }
432            'H' => { let nx = next(&mut i); x = if rel { x + nx } else { nx }; cur.push([x, y]); }
433            'V' => { let ny = next(&mut i); y = if rel { y + ny } else { ny }; cur.push([x, y]); }
434            'C' => {
435                let (x1, y1) = (next(&mut i), next(&mut i));
436                let (x2, y2) = (next(&mut i), next(&mut i));
437                let (ex, ey) = (next(&mut i), next(&mut i));
438                let (p0, p1, p2, p3);
439                if rel { p1 = [x + x1, y + y1]; p2 = [x + x2, y + y2]; p3 = [x + ex, y + ey]; }
440                else   { p1 = [x1, y1]; p2 = [x2, y2]; p3 = [ex, ey]; }
441                p0 = [x, y];
442                flatten_cubic(p0, p1, p2, p3, &mut cur);
443                x = p3[0]; y = p3[1];
444            }
445            'Q' => {
446                let (x1, y1) = (next(&mut i), next(&mut i));
447                let (ex, ey) = (next(&mut i), next(&mut i));
448                let (p0, p1, p2);
449                if rel { p1 = [x + x1, y + y1]; p2 = [x + ex, y + ey]; }
450                else   { p1 = [x1, y1]; p2 = [ex, ey]; }
451                p0 = [x, y];
452                flatten_quad(p0, p1, p2, &mut cur);
453                x = p2[0]; y = p2[1];
454            }
455            'Z' => { cur.push([start_x, start_y]); x = start_x; y = start_y;
456                if !cur.is_empty() { polys.push(std::mem::take(&mut cur)); } }
457            _ => { i += 1; }
458        }
459    }
460    if cur.len() >= 2 { polys.push(cur); }
461    polys
462}
463
464#[derive(Clone, Copy)]
465enum Tok { Cmd(char), Num(f32) }
466
467fn tokenize_path(d: &str) -> Vec<Tok> {
468    let mut out = Vec::new();
469    let mut numbuf = String::new();
470    let flush = |b: &mut String, o: &mut Vec<Tok>| {
471        if !b.is_empty() { if let Ok(n) = b.parse::<f32>() { o.push(Tok::Num(n)); } b.clear(); }
472    };
473    for c in d.chars() {
474        if c.is_ascii_alphabetic() {
475            flush(&mut numbuf, &mut out);
476            out.push(Tok::Cmd(c));
477        } else if c == '-' && !numbuf.is_empty() && !numbuf.ends_with('e') && !numbuf.ends_with('E') {
478            flush(&mut numbuf, &mut out);
479            numbuf.push(c);
480        } else if c == ',' || c.is_whitespace() {
481            flush(&mut numbuf, &mut out);
482        } else {
483            numbuf.push(c);
484        }
485    }
486    flush(&mut numbuf, &mut out);
487    out
488}
489
490fn flatten_cubic(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], out: &mut Vec<[f32; 2]>) {
491    let steps = 16;
492    for s in 1..=steps {
493        let t = s as f32 / steps as f32; let u = 1.0 - t;
494        let b = [
495            u*u*u*p0[0] + 3.0*u*u*t*p1[0] + 3.0*u*t*t*p2[0] + t*t*t*p3[0],
496            u*u*u*p0[1] + 3.0*u*u*t*p1[1] + 3.0*u*t*t*p2[1] + t*t*t*p3[1],
497        ];
498        out.push(b);
499    }
500}
501
502fn flatten_quad(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], out: &mut Vec<[f32; 2]>) {
503    let steps = 12;
504    for s in 1..=steps {
505        let t = s as f32 / steps as f32; let u = 1.0 - t;
506        out.push([u*u*p0[0] + 2.0*u*t*p1[0] + t*t*p2[0], u*u*p0[1] + 2.0*u*t*p1[1] + t*t*p2[1]]);
507    }
508}