Skip to content

Stop rebuilding the whole terrain array once per point while scrubbing - #26

Merged
RISCfuture merged 1 commit into
mainfrom
told/terrain-scrub-perf
Sep 5, 2026
Merged

Stop rebuilding the whole terrain array once per point while scrubbing#26
RISCfuture merged 1 commit into
mainfrom
told/terrain-scrub-perf

Conversation

@RISCfuture

Copy link
Copy Markdown
Contributor

Found while reviewing something else. Not a new bug, but scrubbing (#24) made it hot.

The quadratic

interceptsTerrain(at:) read filledTerrainFt — a computed property that maps every point in the path — and then took one element out of it. The impact overlay calls it twice per segment:

let isImpact = interceptsTerrain(at: index) || interceptsTerrain(at: nextIndex)

So a path of n points rebuilt an n-element array about 2n times, to read 2n values. Quadratic work to answer a linear question.

That was invisible until #24, which added chartXSelection writing @State selectedDistance on every frame of the drag — so the chart body, and this loop, now runs per frame rather than once per data change.

Scale

Paths are sampled every groundStepNM = 0.1 and capped at maxSteps = 1000 per leg, so a multi-leg procedure lands squarely in the range where this bites. Measured on a release build of the same access pattern:

points current hoisted
200 0.053 ms 0.0003 ms
500 0.267 ms 0.0005 ms
1000 1.184 ms 0.0008 ms
2000 7.148 ms 0.0031 ms

That's on this Mac; a phone is several times slower, and this loop is only part of what the body re-does each frame. Against a 16 ms budget it's the difference between a smooth scrub and a stuttering one.

The fix is small: terrainLayer already hoists the array for its area marks (let terrain = filledTerrainFt), so this passes that same array into interceptsTerrain instead of re-deriving it. Behavior is identical.

Also

Clears the selection when the distance unit changes. selectedDistance is held in the axis's own unit, so switching Settings to kilometres with the profile still on screen reinterpreted a stored 10 from NM to km and moved the rule to a sample the pilot never scrubbed to.

Verified

swift format lint --strict · swiftlint --strict · periphery scan --strict · warning-free build · xcodebuild docbuild (warnings-as-errors) · the SF50 Shared Unit Tests plan, 388 cases green.

No new tests: the change is a parameter pass-through with identical behavior, and the existing suite covers interceptsTerrain's logic through the generator tests. The performance claim is measured above rather than asserted in a test, since a timing assertion on CI runners would be flaky.

Two things I looked at and deliberately did not change

  • The callout hides Alt MSL when there's no terrain data, not just Alt AGL. MSL is known regardless of terrain, so this looks like a bug — but the type's doc comment says "only the distance is stated" and there's a #Preview("No terrain data") pinning it. That reads as deliberate. Worth a second opinion; happy to change it if the intent was only to suppress AGL.
  • AGL is measured from real terrain while the chart clamps its drawn baseline to field elevation. Where corridor terrain sits below the field, the stated AGL is larger than the visible gap. The number is the truthful one and the clamp is what keeps the area chart sensible, so I left both alone.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XeE5kdGjdXQim98Fi1kVyG

`interceptsTerrain(at:)` read `filledTerrainFt`, a computed property that maps
every point in the path, and then took a single element out of it. The impact
overlay calls it twice per segment, so a path of n points rebuilt an n-element
array roughly 2n times to read 2n values — quadratic work to answer a linear
question.

That was latent until scrubbing landed. `chartXSelection` writes `@State` on
every frame of the drag, so the chart body — and this loop — now runs per frame
rather than once per data change. Paths are sampled every 0.1 NM and capped at
1000 steps a leg, so a multi-leg procedure lands squarely in the range where this
hurts. Measured on a release build, the loop alone: 0.27 ms at 500 points, 1.18 ms
at 1000, 7.1 ms at 2000 — against 0.0008 ms once the array is hoisted, which is
the difference between a smooth scrub and a stuttering one on a device several
times slower than the machine that measured it.

The array was already hoisted in `terrainLayer` for the area marks; this passes
that same array down instead of re-deriving it.

Also clears the selection when the distance unit changes. It is stored in the
axis's own unit, so switching to kilometres with the profile still on screen
reinterpreted 10 NM as 10 km and moved the rule to a sample nobody scrubbed to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeE5kdGjdXQim98Fi1kVyG
@RISCfuture
RISCfuture merged commit 322fe99 into main Sep 5, 2026
8 checks passed
@RISCfuture
RISCfuture deleted the told/terrain-scrub-perf branch September 5, 2026 22:34
RISCfuture added a commit that referenced this pull request Sep 6, 2026
#26)

`interceptsTerrain(at:)` read `filledTerrainFt`, a computed property that maps
every point in the path, and then took a single element out of it. The impact
overlay calls it twice per segment, so a path of n points rebuilt an n-element
array roughly 2n times to read 2n values — quadratic work to answer a linear
question.

That was latent until scrubbing landed. `chartXSelection` writes `@State` on
every frame of the drag, so the chart body — and this loop — now runs per frame
rather than once per data change. Paths are sampled every 0.1 NM and capped at
1000 steps a leg, so a multi-leg procedure lands squarely in the range where this
hurts. Measured on a release build, the loop alone: 0.27 ms at 500 points, 1.18 ms
at 1000, 7.1 ms at 2000 — against 0.0008 ms once the array is hoisted, which is
the difference between a smooth scrub and a stuttering one on a device several
times slower than the machine that measured it.

The array was already hoisted in `terrainLayer` for the area marks; this passes
that same array down instead of re-deriving it.

Claude-Session: https://claude.ai/code/session_01XeE5kdGjdXQim98Fi1kVyG

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant