makepad/libs/score_render/tests/ink_weight.rs
Admin 145d0b1fe2 score: the notation suite — engraving, layout, playback, midi and musicxml import, the physical piano model, and the score app
Squashed from work:
- score: a headless music engraving, playback and notation engine
- score: the notation app — pianist mode, editing, playback
- piano_model: it was a plucked string by construction, and 20 voicings
- score: one document you can pan, zoom and navigate
- piano_model: the body tap was a click, and the objective was rewarding noise
- score: add the sound panel and library modules
- piano_model: a second engine, and the attack that finally sounded right
- score: two instruments, reverb and brightness — and the rest of the panel gone
- score model: a note remembers how it was struck, and the score remembers the pedal
- score import: keep the velocities and the pedal the file was carrying
- score playback: play the performance, not a flattened copy of it
- score ui: the music list moves to the sidebar, and the view stops fighting itself
- score: the application ships its font and eight performances
- piano_model: a limiter that rides the music, so the knee stops shaping chords
- piano_model: the forte bell was the treble's dynamic slope, and the bass was dying at its own prompt rate
- piano_model: the bridge decides each partial's decay, and a fixed multiplier cannot say that
- piano_model: each partial gets its own two coupled modes, from the eigen algebra
- piano_model: a median that fell between the peaks made every bass partial a drain
- score-ai: LocalBroker — the seam's in-process implementation over the session engine (aicore P8)
- client + chat dispatcher: the dead wire comes out (aicore P7/P8)
- score_pdf: the score model grew a pedal map — the pdf importer initialises it
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
- score app: the shipped-piece test speaks the PERFORMANCES table
- zero-warning sweep, round three — the model lanes and the deep examples
2026-09-01 16:46:32 +02:00

406 lines
15 KiB
Rust

//! A page drawn small must not gain ink.
//!
//! Engraved notation is mostly hairlines: a staff line is 0.13 staff spaces, a
//! stem 0.12, a beam 0.50. Zoomed out far enough every one of them falls under
//! a physical pixel, and a raster target cannot draw a mark thinner than that.
//! Widening each stroke to the pixel floor at full strength multiplies the
//! page's ink by whatever the shortfall was — four or five times over, once
//! five staff lines, a stem per note and two beams per pair are all rounded up
//! together — which is exactly how a readable score turns into a black mass.
//!
//! This measures it. The score's primitives are projected exactly as
//! [`MakepadScoreRenderer`](makepad_score_render::MakepadScoreRenderer) submits
//! them, then rasterised on the device pixel grid with Makepad's own vector
//! coverage model, and the dark fraction of a crop *fixed in page coordinates*
//! is compared across an eight-to-one range of scales. The same music at half
//! the size must read with the same weight, not double it.
//!
//! The coverage model matches `DrawVector`: a filled path paints
//! `clamp(signed_distance_inside + aa/2)` and a stroke
//! `clamp((width + aa)/2 - distance)`, both in device pixels, where `aa` is the
//! baked antialiasing fringe. `DrawVector` bakes that fringe in path-local
//! (logical) units, so the score asks for `1 / device_scale` to land it on one
//! physical pixel. Noteheads go through `DrawGlyph`, whose coverage is analytic
//! and needs no floor; they are modelled as exact area coverage.
use makepad_score_render::*;
/// A retina display: the case where a logical-unit floor costs the most.
const DEVICE_SCALE: f64 = 2.0;
/// Logical pixels per staff space at 100% zoom, for a page fitted to a laptop
/// window (238 sp tall in ~816 logical points).
const FIT_PX_PER_SP: f64 = 3.43;
const MARGIN_LEFT: f64 = 17.0;
const MARGIN_RIGHT: f64 = 154.0;
const STAFF_SPAN: f64 = 18.0;
// ---------------------------------------------------------------- rasteriser
#[derive(Clone, Debug)]
enum Shape {
Fill {
points: Vec<[f64; 2]>,
fringe: f64,
alpha: f64,
},
Stroke {
from: [f64; 2],
to: [f64; 2],
half_width: f64,
alpha: f64,
},
}
impl Shape {
fn alpha_at(&self, p: [f64; 2]) -> f64 {
match self {
Self::Fill {
points,
fringe,
alpha,
} => (convex_signed_distance(points, p) + fringe * 0.5).clamp(0.0, 1.0) * alpha,
Self::Stroke {
from,
to,
half_width,
alpha,
} => (half_width - segment_distance(*from, *to, p)).clamp(0.0, 1.0) * alpha,
}
}
fn bounds(&self) -> [f64; 4] {
match self {
Self::Fill { points, fringe, .. } => {
let mut bounds = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for point in points {
bounds[0] = bounds[0].min(point[0]);
bounds[1] = bounds[1].min(point[1]);
bounds[2] = bounds[2].max(point[0]);
bounds[3] = bounds[3].max(point[1]);
}
[
bounds[0] - fringe,
bounds[1] - fringe,
bounds[2] + fringe,
bounds[3] + fringe,
]
}
Self::Stroke {
from,
to,
half_width,
..
} => [
from[0].min(to[0]) - half_width,
from[1].min(to[1]) - half_width,
from[0].max(to[0]) + half_width,
from[1].max(to[1]) + half_width,
],
}
}
}
/// Signed distance into a convex polygon, positive inside, in the polygon's
/// own units. Winding is derived from the signed area, so page-order
/// (clockwise, y down) and mathematical order both work.
fn convex_signed_distance(points: &[[f64; 2]], p: [f64; 2]) -> f64 {
let count = points.len();
let mut twice_area = 0.0;
for index in 0..count {
let (a, b) = (points[index], points[(index + 1) % count]);
twice_area += a[0] * b[1] - b[0] * a[1];
}
let winding = if twice_area >= 0.0 { 1.0 } else { -1.0 };
let mut distance = f64::INFINITY;
for index in 0..count {
let (a, b) = (points[index], points[(index + 1) % count]);
let edge = [b[0] - a[0], b[1] - a[1]];
let length = (edge[0] * edge[0] + edge[1] * edge[1]).sqrt();
if length <= 1e-12 {
continue;
}
let cross = (edge[0] * (p[1] - a[1]) - edge[1] * (p[0] - a[0])) / length;
distance = distance.min(winding * cross);
}
distance
}
fn segment_distance(from: [f64; 2], to: [f64; 2], p: [f64; 2]) -> f64 {
let edge = [to[0] - from[0], to[1] - from[1]];
let length_squared = edge[0] * edge[0] + edge[1] * edge[1];
let t = if length_squared <= 1e-12 {
0.0
} else {
(((p[0] - from[0]) * edge[0] + (p[1] - from[1]) * edge[1]) / length_squared).clamp(0.0, 1.0)
};
let nearest = [from[0] + edge[0] * t, from[1] + edge[1] * t];
((p[0] - nearest[0]).powi(2) + (p[1] - nearest[1]).powi(2)).sqrt()
}
/// Mean composited ink over a crop given in device pixels. One sample per
/// device pixel, which is what a fragment shader evaluates.
fn ink_fraction(shapes: &[Shape], crop_px: [f64; 4]) -> f64 {
let bounded: Vec<_> = shapes.iter().map(|shape| (shape, shape.bounds())).collect();
let mut ink = 0.0;
let mut pixels = 0u64;
for y in crop_px[1].floor() as i64..crop_px[3].ceil() as i64 {
for x in crop_px[0].floor() as i64..crop_px[2].ceil() as i64 {
let p = [x as f64 + 0.5, y as f64 + 0.5];
let mut transmitted = 1.0f64;
for (shape, bounds) in &bounded {
if p[0] < bounds[0] || p[0] > bounds[2] || p[1] < bounds[1] || p[1] > bounds[3] {
continue;
}
let alpha = shape.alpha_at(p);
if alpha > 0.0 {
transmitted *= 1.0 - alpha;
}
}
ink += 1.0 - transmitted;
pixels += 1;
}
}
ink / pixels as f64
}
// ------------------------------------------------------------- page contents
/// Two staves of beamed sixteenths: the densest ink a page normally carries,
/// and the passage the complaint was about.
struct Passage {
staff_groups: Vec<Vec<Rect>>,
rules: Vec<Rect>,
beams: Vec<Beam>,
brackets: Vec<Primitive>,
/// Notehead centre and radii, in staff spaces.
heads: Vec<[f64; 4]>,
}
fn dense_passage() -> Passage {
let engraving = EngravingDefaults::default();
let mut passage = Passage {
staff_groups: Vec::new(),
rules: Vec::new(),
beams: Vec::new(),
brackets: Vec::new(),
heads: Vec::new(),
};
for staff in 0..2 {
let top = 20.0 + staff as f64 * STAFF_SPAN;
passage.staff_groups.push(
(0..5)
.map(|line| {
Rect::from_xywh(
MARGIN_LEFT,
top + line as f64 - engraving.staff_line_thickness * 0.5,
MARGIN_RIGHT - MARGIN_LEFT,
engraving.staff_line_thickness,
)
})
.collect(),
);
for bar in 0..5 {
let x = MARGIN_LEFT + bar as f64 * 34.0;
if x > MARGIN_RIGHT {
break;
}
passage.rules.push(Rect::from_xywh(
x,
top,
engraving.thin_barline_thickness,
4.0,
));
}
let mut x = MARGIN_LEFT + 5.0;
while x < MARGIN_RIGHT - 6.0 {
let stems: Vec<f64> = (0..4).map(|note| x + note as f64 * 2.4).collect();
let beam_y = top - 1.6;
for (note, stem) in stems.iter().enumerate() {
let head_y = top + 3.0 - (note as f64 % 3.0) * 0.5;
passage.heads.push([*stem, head_y, 0.62, 0.44]);
passage.rules.push(Rect::from_xywh(
stem + 0.58 - engraving.stem_thickness * 0.5,
beam_y,
engraving.stem_thickness,
head_y - beam_y,
));
}
for level in 0..2 {
let dy = level as f64 * (engraving.beam_thickness + engraving.beam_spacing)
+ engraving.beam_thickness * 0.5;
passage.beams.push(Beam {
start: Point::new(stems[0] + 0.52, beam_y + dy),
end: Point::new(stems[3] + 0.64, beam_y + dy + 0.35),
thickness: engraving.beam_thickness,
});
}
x += 4.0 * 2.4 + 1.4;
}
}
passage.brackets.push(Primitive::Bracket {
x: MARGIN_LEFT - 1.3,
top: 20.0,
bottom: 20.0 + STAFF_SPAN + 4.0,
thickness: EngravingDefaults::default().bracket_thickness * 0.5,
hook: 1.0,
});
passage
}
/// Exactly what `MakepadScoreRenderer::draw` submits: device-grid snapping for
/// rules, the hairline floor with its ink alpha, and a one-physical-pixel AA
/// fringe.
fn submitted(passage: &Passage, transform: Transform, device_scale: f64) -> Vec<Shape> {
let fringe = MIN_INK_DEVICE_PX;
let mut shapes = Vec::new();
let to_device = |point: Point| [point.x * device_scale, point.y * device_scale];
let rect_points = |rect: Rect| {
vec![
[rect.min.x * device_scale, rect.min.y * device_scale],
[rect.max.x * device_scale, rect.min.y * device_scale],
[rect.max.x * device_scale, rect.max.y * device_scale],
[rect.min.x * device_scale, rect.max.y * device_scale],
]
};
for group in &passage.staff_groups {
for rule in project_staff_rules_on_grid(group, transform, 1.0, device_scale) {
shapes.push(Shape::Fill {
points: rect_points(rule.rect_px),
fringe,
alpha: rule.ink_alpha as f64,
});
}
}
for rect in &passage.rules {
let rule = project_rule_on_grid(*rect, transform, 1.0, device_scale);
shapes.push(Shape::Fill {
points: rect_points(rule.rect_px),
fringe,
alpha: rule.ink_alpha as f64,
});
}
for beam in &passage.beams {
let ink = ink_floor(beam.thickness * transform.scale, device_scale);
let start = transform.point(beam.start);
let end = transform.point(beam.end);
let half = ink.width * 0.5;
shapes.push(Shape::Fill {
points: vec![
to_device(Point::new(start.x, start.y - half)),
to_device(Point::new(end.x, end.y - half)),
to_device(Point::new(end.x, end.y + half)),
to_device(Point::new(start.x, start.y + half)),
],
fringe,
alpha: ink.alpha as f64,
});
}
for bracket in &passage.brackets {
let Primitive::Bracket {
x,
top,
bottom,
thickness,
hook,
} = bracket
else {
continue;
};
let ink = ink_floor(thickness * transform.scale, device_scale);
// A stroke's painted half-extent is (width + fringe) / 2.
let half_width = (ink.width * device_scale + MIN_INK_DEVICE_PX) * 0.5;
let corners = [
Point::new(x + hook, *top),
Point::new(*x, *top),
Point::new(*x, *bottom),
Point::new(x + hook, *bottom),
]
.map(|point| to_device(transform.point(point)));
for pair in corners.windows(2) {
shapes.push(Shape::Stroke {
from: pair[0],
to: pair[1],
half_width,
alpha: ink.alpha as f64,
});
}
}
shapes.extend(notehead_shapes(passage, transform, device_scale));
shapes
}
/// `DrawGlyph` resolves an outline analytically at any size, so a notehead
/// needs no floor and keeps exact area coverage at every scale.
fn notehead_shapes(passage: &Passage, transform: Transform, device_scale: f64) -> Vec<Shape> {
passage
.heads
.iter()
.map(|head| {
let centre = transform.point(Point::new(head[0], head[1]));
let rx = head[2] * transform.scale * device_scale;
let ry = head[3] * transform.scale * device_scale;
Shape::Fill {
points: (0..24)
.map(|step| {
let angle = step as f64 / 24.0 * std::f64::consts::TAU;
[
centre.x * device_scale + rx * angle.cos(),
centre.y * device_scale + ry * angle.sin(),
]
})
.collect(),
fringe: MIN_INK_DEVICE_PX,
alpha: 1.0,
}
})
.collect()
}
// ------------------------------------------------------------------ the test
#[test]
fn a_page_drawn_small_keeps_its_engraved_weight() {
let passage = dense_passage();
// Fixed in page coordinates: the upper staff and its beamed sixteenths, so
// every scale measures the same music.
let crop_sp = Rect::from_xywh(MARGIN_LEFT, 16.0, 60.0, 20.0);
let mut measured = Vec::new();
println!("\nzoom device px/sp ink");
for zoom in [1.0, 0.5, 0.25, 0.12] {
let transform = Transform {
translation: Point::new(7.0, 11.0),
scale: FIT_PX_PER_SP * zoom,
};
let crop = transform.rect(crop_sp);
let ink = ink_fraction(
&submitted(&passage, transform, DEVICE_SCALE),
[
crop.min.x * DEVICE_SCALE,
crop.min.y * DEVICE_SCALE,
crop.max.x * DEVICE_SCALE,
crop.max.y * DEVICE_SCALE,
],
);
println!(
"{zoom:<6} {:<14.2} {ink:.4}",
transform.scale * DEVICE_SCALE
);
measured.push((zoom, ink));
}
let full_size = measured[0].1;
assert!(
full_size > 0.02,
"the passage should carry real ink at full size, got {full_size:.4}"
);
for (zoom, ink) in measured.iter().copied().skip(1) {
let ratio = ink / full_size;
assert!(
(0.80..=1.15).contains(&ratio),
"at zoom {zoom} the same music reads {ratio:.2}x as heavy as at full size \
({ink:.4} vs {full_size:.4}); a smaller page must not gain ink"
);
}
}