Sample a half-open range, and prepare 0.4.0 - #56
Conversation
Sampling by rate returned one more sample than the duration and rate imply: 4 seconds at 30 Hz gave 121 values rather than 120. The count came from evaluate_range, which spreads a sample count across a closed range and so divides by num_samples - 1. Passing duration * rate + 1 was what made that spacing come out at 1 / sample_rate, so the extra sample was load-bearing rather than a stray increment. Sample times are now derived from the sample index instead, which lets the range be half-open. end_time is no longer sampled, so a span of n periods gives n samples, and the count is the product callers expect. evaluate_range keeps its closed-range meaning for callers who want the end included. This also fixes two faults in the old count. A range that was not a whole number of periods was rounded up and then compressed to fit the closed range, so 1.05 seconds at 30 Hz came back as 33 values 0.0328 apart rather than at 30 Hz at all. And because the count used ceil, a product landing a few ulps above a whole number added a sample spanning a fraction of a period. The rounding rule is shared between Channel and Animation rather than written out at each site, so the two cannot drift.
evaluate_range returned a single sample whenever start_time equalled end_time, no matter what count was asked for, which silently broke any caller sizing a buffer from the count it passed in. The step is zero for an empty range, not undefined, so the general path already handles it; only a count of one needs a special case, to avoid dividing by zero. The range was also validated after the early return for counts of one or less, so evaluate_range(10, 0, 0) returned a value while the same reversed range with a count of 2 threw. The range is now checked first, for every count. A count of zero returns nothing rather than one sample, and a negative count is rejected rather than quietly treated as one. Animation::num_samples returns size_t, matching Channel::num_samples. Both count the same thing and had no reason to differ.
|
Picked up both follow-ups, plus one more in the same family.
Validation moved ahead of the early returns, so a reversed range is rejected
73/73 tests pass. One lead I chased and dropped
It is not a problem. Measured over 1M samples, seed choice changes the result by |
Both range methods sampled a half-open range, which is right for a timeline, where a sample covers the interval that follows it and the end is an edge. It is wrong for the cases that treat samples as points on the curve: plotting a curve, building an interpolation lookup table, and integrating numerically all need the last point to land on the end rather than one step short. The bundled viewer shows the cost of not having it, sampling by hand and then appending the end point to stop the plotted line falling short of the last keyframe. RangeEnd is a trailing argument on both range methods and both num_samples overloads, defaulting to Exclusive so the timeline reading stays the default. Making the choice explicit also keeps the two methods from disagreeing silently, which is what made the old sample count wrong: the count-based method spanned a closed range, the rate-based one assumed otherwise, and a + 1 bridged them. evaluate_range is half-open by default now, so evaluate_range_by_rate can delegate to it again rather than running its own loop. It passes the span the samples actually cover instead of the requested end, since the two differ when the range is not a whole number of periods, and hands over the same range end so the divisor matches. Measured against evaluating start + i / rate directly, sample times are off by at most 1.4e-14, and not at all for ranges starting far from zero.
The viewer sampled each curve by accumulating t += eval_step and then appended the end point, because the loop's last step generally lands short of the last keyframe and the plotted line would stop before it. That fixup is what the closed range now covers. Sampling by rate would not do, even though eval_step is a step: a closed rate-based range only lands on the end time when the span is a whole number of steps, which an arbitrary keyframe layout will not be. The count-based overload derives its step from the span, so the last sample is on the end time whatever the duration. Over the example's own curves the two agree to within 2e-12, the difference being the drift the accumulated loop picked up over 3200 steps, and the final point is now exactly the end time rather than a step away from it. Also corrects two changelog entries that the RangeEnd work had made stale, one of which claimed evaluate_range still spans a closed range.
Sampling returns values without times, so a caller that needs them, such as anything plotting against a time axis, has to restate the step. That arithmetic is not quite trivial: the divisor is the sample count for a half-open range and one less for a closed one, so it is easy to pair with the wrong range end, which is the same confusion that made the old sample count wrong. Returning a second vector would answer it badly. The times carry no information beyond a start, a step and a count, so storing them doubles the memory of a bake to memoise a closed form, and leaves two buffers that can disagree. SampleTimes holds the three numbers instead and computes a time from an index: 24 bytes, nothing allocated, and indexing it measures the same as writing out the multiply and add, over 50 million samples. The methods live on Animation, whose start and end are the time base a whole animation is sampled over, so every channel shares one set of times whatever its own keyframes do. Free functions cover any other range. Both derive the step the way the matching evaluate_range call does rather than restating the rule, so they cannot drift from it. Channel gets no equivalent, and its num_samples goes with them. Those sampled the channel's keyframe extent, which is an editing concept -- where a curve's data happens to lie, not the range a host samples over. Where channels are bound to a timeline, counting over the keyframe span gives the wrong buffer size with nothing to indicate it, and a channel holds no reference to its animation to answer otherwise. Adding one would not settle it either, since start_time() and end_time() would still report the keyframe extent, leaving one class describing two ranges depending on which method was called. Every other sampling entry point already names its range; these were the only ones guessing. The viewer takes its x axis from sample_times, passing the keyframe range explicitly, which is what prompted this: it was the caller restating the step arithmetic.
472c849 to
e1fa702
Compare
The Contributor Covenant lays out a moderation and enforcement process -- warnings, temporary bans, permanent bans, an appeals path -- that reads as a description of how this project is governed, and it is not. CONTRIBUTING already covers how to take part, and security reports have their own private channel in SECURITY. Also corrects the sampling entry to describe removing Channel::num_samples alone. The channel sample_times methods it also named were squashed out of this release rather than added and withdrawn within it, so nobody reading these notes will have seen them exist.
Close the [Unreleased] section as [0.4.0] and bump the project version, which is the single source for the installed find_package config.
Sampling by rate returned one more sample than the duration and rate imply:
4 seconds at 30 Hz gave 121 values rather than 120. Fixing that turned into a
review of the whole sampling surface, so this is the 0.4.0 release.
The
+ 1was load-bearingevaluate_rangedivided bynum_samples - 1, spanning a closed range, andevaluate_range_by_ratedelegated to it. Passingduration * rate + 1was theonly count that made the spacing come out at
1 / sample_rate. Deleting the+ 1alone would have returned 120 samples of wrong data:Sample times are now derived from the sample index, which lets the range be
half-open:
end_timeis not sampled, a span of n periods gives n samples, andthe count is the product callers expect.
Two further faults the old count hid
rounded up and then compressed to fit the closed range. 1.05 seconds at 30 Hz
came back as 33 values 0.0328 apart — not 30 Hz at all.
ceilon a product landing a few ulpsabove a whole number added a sample spanning a fraction of a period.
nextafter(4.0, 5.0)at 30 Hz returned 122.Measured against the built library, all cases now on-rate with the end excluded:
nextafter(4.0, 5.0)RangeEndHalf-open is right for a timeline, where a sample covers the interval that
follows it. It is wrong for the cases that treat samples as points on a curve:
plotting, interpolation lookup tables, and numerical integration all need the
last point on the end rather than one step short. The viewer showed the cost of
not having it, sampling by hand and appending the end point so the plotted line
would reach the last keyframe.
RangeEndis a trailing argument on both range methods andnum_samples,defaulting to
Exclusive. For a rate, the two ends differ only when the span isa whole number of periods, since that is the only case where a sample lands on
the end at all.
SampleTimesSampling returns values without times, so a caller needing them had to restate
the step — and its divisor depends on the
RangeEnd, which is the sameconfusion that made the original count wrong.
Returning a second vector would answer that badly: the times carry no
information beyond a start, a step and a count, so storing them doubles the
memory of a bake to memoise a closed form.
SampleTimesholds those threenumbers and computes a time from an index. Measured over 50 million indexings it
is 42.73 ms against 42.74 ms for the arithmetic written out — the same code.
The methods live on
Animation, whose start and end are the time base a wholeanimation is sampled over, so every channel shares one set of times whatever its
own keyframes do. Free functions cover any other range.
Channel::num_samplesremovedIt counted over the channel's keyframe extent, which is an editing concept —
where a curve's data happens to lie, not the range a host samples over. A
channel bound to a timeline was counted over the wrong span with nothing to
indicate it, and a channel holds no reference to the animation that owns it to
answer otherwise. Every other sampling entry point already names its range.
Also in this release
Channel::evaluate_rangereturns exactly the requested count in every case.It previously collapsed to a single sample when the range was empty, breaking
any caller sizing a buffer from the count it passed in.
rejected for every count rather than only for large ones.
Animation::num_samplesreturnssize_trather thanint.evaluate_rangecall and takes its x axis fromsample_times, replacing an accumulating loop and its endpoint fixup. Thepoints differ by at most 2e-12 and the last lands on the end time exactly.
CODE_OF_CONDUCT.mdis dropped: the Contributor Covenant describes amoderation and enforcement process that overstates how this project is run.
Verification
75 tests pass. The viewer was built from a clean checkout with no
imgui.ini,launched, and driven — both windows lay out correctly, all five curves render
and reach the last keyframe at t = 32, and expanding a curve in the editor shows
its keyframes. Release notes extraction was dry-run for 0.4.0 (6359 bytes, all
four sections, no leak into 0.3.0) and for a nonexistent version, which still
yields nothing so the workflow's empty-notes guard holds.
After merge
Tag
v0.4.0on the merge commit and push, which publishes the release from thechangelog entry. Anything pinning this branch needs repointing, since the squash
rewrites the commit.