#nullable enable using System.Runtime.CompilerServices; using ImGuiNET; using T3.Core.Animation; using T3.Core.DataTypes.DataSet; using T3.Core.DataTypes.Vector; using T3.Core.Operator; using T3.Core.Operator.Interfaces; using T3.Core.Operator.Slots; using T3.Core.Resource.Assets; using T3.Editor.Gui.Styling; using T3.IoServices; namespace T3.Editor.Gui.Windows.TimeLine.TimeClips; /// /// Renders per-channel tick / density-rect overlays inside -output /// TimeClip bodies. Each channel gets its own horizontal track within the body, and /// events are chunked into either individual ticks (sparse) or faded rects (dense) /// based on inter-event pixel spacing. /// /// /// Reads the slot's cached value when populated; falls back to loading the file directly /// via when no downstream consumer pulls on the op; falls /// back further to the in-progress recording's live when this clip /// is the target of an active . All three paths feed the /// same per-channel renderer. /// internal static class DataClipBodyRenderer { public static void TryDraw(Instance instance, TimeClip timeClip, Vector2 bodyMin, Vector2 bodyMax, float viewMinX, float viewMaxX, ImDrawListPtr drawList) { // Op-type gate: only proceed if this op publishes a Slot. Quick reject // for the 99% case (any other TimeClip op). Slot? dataSlot = null; foreach (var slot in instance.Outputs) { if (slot is Slot ds) { dataSlot = ds; break; } } if (dataSlot == null) return; if (!TryGetDataSetAndMapping(instance, dataSlot, timeClip, out var dataSet, out var mapping) || dataSet == null) return; var bodyWidth = bodyMax.X - bodyMin.X; var bodyHeight = bodyMax.Y - bodyMin.Y; if (bodyWidth < 3 || bodyHeight < 4) return; // Filter to channels that have events. Empty channels don't get a track so the // layout stays compact. _visibleChannelsScratch.Clear(); foreach (var channel in dataSet.Channels) { if (channel.Events.Count > 0) _visibleChannelsScratch.Add(channel); } if (_visibleChannelsScratch.Count == 0) return; // Sort by joined path so the per-track ordering matches the channel list in the // DataSet output canvas (DataSetViewCanvas iterates `OrderBy(c => string.Join(".", // c.Path))`). Without this the clip body shows tracks in DataSet.Channels insertion // order — which is recorder-arrival order, hardware-dependent, and unrelated to // what the user sees in the output window. Comparer cached statically to skip the // per-frame string concatenation cost. _visibleChannelsScratch.Sort(_channelByPathComparer); // Track layout: 2 px tracks with 1 px gap when the body is tall enough, 1 px // tracks with no gap when tight, fall back to a single overlay rect when even // 1 px per channel doesn't fit. const float topPadding = 2f; const float bottomPadding = 2f; var usableHeight = bodyHeight - topPadding - bottomPadding; var n = _visibleChannelsScratch.Count; float trackHeight; float gap; var visibleTrackCount = n; if (usableHeight >= n * 2f + (n - 1) * 1f) { trackHeight = 2f; gap = 1f; } else if (usableHeight >= n * 1f) { trackHeight = 1f; gap = 0f; } else { // More channels than fit even at 1 px each — render as many real tracks as // fit and clip the rest at the bottom. A truncated set of per-channel tracks // reads better than collapsing everything into one featureless block. trackHeight = 1f; gap = 0f; visibleTrackCount = Math.Max(1, (int)usableHeight); } // Each channel gets its own fair slice of the per-clip budget so a heavy channel // can't starve later channels. A channel that uses less than its share doesn't // donate it — keeps the allocation predictable across frames. const int perClipBudget = 1500; var perChannelBudget = Math.Max(40, perClipBudget / visibleTrackCount); var trackColor = UiColors.ForegroundFull.Fade(0.55f); for (var i = 0; i < visibleTrackCount; i++) { var trackTopY = bodyMin.Y + topPadding + i * (trackHeight + gap); var trackBottomY = trackTopY + trackHeight; DrawChannelTrack(_visibleChannelsScratch[i], mapping, bodyMin.X, bodyMax.X, viewMinX, viewMaxX, trackTopY, trackBottomY, perChannelBudget, trackColor, drawList); } } /// /// Single horizontal track for one channel. Dispatches between tick (point-in-time) /// rendering and interval rendering based on the channel's /// ; both honour the per-channel budget. /// private static int DrawChannelTrack(DataChannel channel, TimeRangeMapping mapping, float bodyMinX, float bodyMaxX, float viewMinX, float viewMaxX, float trackTopY, float trackBottomY, int budget, Color tickColor, ImDrawListPtr drawList) { if (channel.DurationType == ChannelDurationTypes.Interval) { return DrawIntervalChannelTrack(channel, mapping, bodyMinX, bodyMaxX, trackTopY, trackBottomY, budget, tickColor, drawList); } return DrawTickChannelTrack(channel, mapping, bodyMinX, bodyMaxX, viewMinX, viewMaxX, trackTopY, trackBottomY, budget, tickColor, drawList); } /// /// Tick rendering for point-in-time channels (MIDI CC, OSC, telemetry). Walks events /// in order, groups consecutive ones within into runs, and /// renders each run as either individual ticks (small / sparse runs) or a single /// density-rect (larger / denser runs). Returns the number of draw commands emitted. /// private static int DrawTickChannelTrack(DataChannel channel, TimeRangeMapping mapping, float bodyMinX, float bodyMaxX, float viewMinX, float viewMaxX, float trackTopY, float trackBottomY, int budget, Color tickColor, ImDrawListPtr drawList) { // Channel.Events may be appended on a background thread during recording. Snapshot // the count once; any later additions appear on the next frame. var eventCount = channel.Events.Count; if (eventCount == 0 || budget <= 0) return 0; var rangeStart = mapping.TimeRange.Start; var rangeEnd = mapping.TimeRange.End; var rangeSpan = rangeEnd - rangeStart; if (rangeSpan < 0.0001) return 0; var bodySpan = bodyMaxX - bodyMinX; if (bodySpan < 0.0001) return 0; // Restrict the walk to events whose source time maps into the visible slice of the // body. For a long clip zoomed in (the "thousands of CC events" case), the bulk of // the list sits off-screen; events are stored in ascending source-time order, so a // binary search on the visible window's edges bounds the loop instead of stepping // across the whole channel every frame. Both window edges are converted to source // time and min/max'd so a time-reversed clip (SourceRange running backwards) still // searches a valid ascending range. var visMinX = MathF.Max(bodyMinX, viewMinX); var visMaxX = MathF.Min(bodyMaxX, viewMaxX); if (visMaxX < visMinX) return 0; var winStartBars = rangeStart + (visMinX - bodyMinX) / bodySpan * rangeSpan; var winEndBars = rangeStart + (visMaxX - bodyMinX) / bodySpan * rangeSpan; var secsA = mapping.LocalBarsToSourceSecs(winStartBars); var secsB = mapping.LocalBarsToSourceSecs(winEndBars); var minSecs = Math.Min(secsA, secsB); var maxSecs = Math.Max(secsA, secsB); // Widen by one event each side so a run beginning just outside the window still // contributes its leading / trailing tick. var firstIndex = Math.Max(0, channel.FindIndexForTime(minSecs) - 1); var lastIndex = Math.Min(eventCount - 1, channel.FindIndexForTime(maxSecs) + 1); if (lastIndex < firstIndex) return 0; var visibleCount = lastIndex - firstIndex + 1; // Even-decimation step when the worst case (every visible event its own tick) would // blow the budget. Better than front-loading: spreading the omissions evenly // preserves the visual rhythm of activity across the track instead of fading after // the first N events. var step = Math.Max(1, visibleCount / Math.Max(1, budget)); var commands = 0; // Run state. We walk events and either: // - extend the current run if the new event is within MinTickGapPx of the previous // - flush the run (as ticks or rect, depending on count) and start a new one var runStartX = float.NaN; var runEndX = float.NaN; var runEventCount = 0; var prevX = float.NegativeInfinity; for (var i = firstIndex; i <= lastIndex; i += step) { if (commands >= budget) break; DataEvent? ev; try { ev = channel.Events[i]; } catch (ArgumentOutOfRangeException) { break; } // list shrank under us if (ev == null) continue; var localBars = mapping.SourceSecsToLocalBars(ev.Time); if (localBars < rangeStart || localBars > rangeEnd) continue; var t = (localBars - rangeStart) / rangeSpan; // Snap to integer pixels — sub-pixel float positions get rasterised with // anti-aliasing, and a fractional change frame-to-frame (e.g. body width // edging from 132.4 → 132.5 px while dragging an edge) shows up as a 1 px // tick jitter even though the absolute timeline position barely moved. var x = MathF.Floor((float)(bodyMinX + t * bodySpan)); if (float.IsNaN(runStartX)) { runStartX = x; runEndX = x; runEventCount = 1; prevX = x; continue; } if (x - prevX <= MinTickGapPx) { runEndX = x; runEventCount++; prevX = x; } else { commands += FlushRun(runStartX, runEndX, runEventCount, trackTopY, trackBottomY, tickColor, drawList); runStartX = x; runEndX = x; runEventCount = 1; prevX = x; } } if (!float.IsNaN(runStartX) && commands < budget) { commands += FlushRun(runStartX, runEndX, runEventCount, trackTopY, trackBottomY, tickColor, drawList); } return commands; } /// /// Interval rendering for span-in-time channels (MIDI notes today; subtitles / /// regions later). Draws each event as a filled bar from its /// to its , clipped to the body bounds. /// Unfinished intervals (live-recording note-ons before the note-off arrives) stretch /// to the right edge of the body so the user sees the held note grow. /// /// /// Run-collapsing isn't applied here — every interval is a distinct event with its own /// duration, so the tick-density heuristic that works for point streams would smear /// the timing information. The per-channel budget still caps draw commands; once it's /// reached the loop bails out, leaving the leading interval(s) visible. /// private static int DrawIntervalChannelTrack(DataChannel channel, TimeRangeMapping mapping, float bodyMinX, float bodyMaxX, float trackTopY, float trackBottomY, int budget, Color tickColor, ImDrawListPtr drawList) { var eventCount = channel.Events.Count; if (eventCount == 0 || budget <= 0) return 0; var rangeStart = mapping.TimeRange.Start; var rangeEnd = mapping.TimeRange.End; var rangeSpan = rangeEnd - rangeStart; if (rangeSpan < 0.0001) return 0; var bodySpan = bodyMaxX - bodyMinX; var step = Math.Max(1, eventCount / Math.Max(1, budget)); var commands = 0; for (var i = 0; i < eventCount; i += step) { if (commands >= budget) break; DataEvent? ev; try { ev = channel.Events[i]; } catch (ArgumentOutOfRangeException) { break; } if (ev is not DataIntervalEvent interval) continue; var startLocalBars = mapping.SourceSecsToLocalBars(interval.Time); // Unfinished interval (recorder hasn't seen note-off yet) — visualise it // running to the end of the clip so the user sees it grow as they hold the // note rather than vanishing because EndTime is +∞. var endLocalBars = double.IsInfinity(interval.EndTime) ? rangeEnd : mapping.SourceSecsToLocalBars(interval.EndTime); // Cull intervals fully outside the clip's TimeRange. if (endLocalBars < rangeStart || startLocalBars > rangeEnd) continue; // Clip the visible span to the body so a long interval that overlaps the // edge stops at the edge instead of drawing off-clip. var clippedStart = Math.Max(startLocalBars, rangeStart); var clippedEnd = Math.Min(endLocalBars, rangeEnd); var t1 = (clippedStart - rangeStart) / rangeSpan; var t2 = (clippedEnd - rangeStart) / rangeSpan; var x1 = MathF.Floor((float)(bodyMinX + t1 * bodySpan)); var x2 = MathF.Floor((float)(bodyMinX + t2 * bodySpan)); // Even a zero-duration interval should be visible — same TickWidthPx the // point-event branch uses for parity at high zoom. if (x2 < x1 + TickWidthPx) x2 = x1 + TickWidthPx; drawList.AddRectFilled(new Vector2(x1, trackTopY), new Vector2(x2, trackBottomY), tickColor); commands++; } return commands; } /// /// Emits draw commands for one run of consecutive close-together events. Small runs /// (≤ ) render as individual ticks; larger runs collapse /// into a single density-rect with alpha scaled by events-per-pixel. /// private static int FlushRun(float startX, float endX, int eventCount, float trackTopY, float trackBottomY, Color tickColor, ImDrawListPtr drawList) { if (eventCount == 1) { drawList.AddRectFilled(new Vector2(startX, trackTopY), new Vector2(startX + TickWidthPx, trackBottomY), tickColor); return 1; } if (eventCount <= RunSizeForTicks) { // Sparse run — N ticks across the run, evenly spaced. (Intermediate event // positions weren't recorded; interpolation matches the original spacing at // sub-pixel precision.) for (var i = 0; i < eventCount; i++) { var t = eventCount == 1 ? 0f : i / (float)(eventCount - 1); // Floor the interpolated tick position too — startX/endX are already // pixel-aligned at this point, but t * (endX - startX) reintroduces // sub-pixel offsets that cause the same jitter the outer loop avoids. var x = MathF.Floor(startX + t * (endX - startX)); drawList.AddRectFilled(new Vector2(x, trackTopY), new Vector2(x + TickWidthPx, trackBottomY), tickColor); } return eventCount; } // Dense run — single rect with alpha scaled by density. var width = MathF.Max(1f, endX - startX); var density = eventCount / width; var alpha = MathF.Min(0.7f, 0.25f + density * 0.4f); drawList.AddRectFilled(new Vector2(startX, trackTopY), new Vector2(endX + TickWidthPx, trackBottomY), tickColor.Fade(alpha)); return 1; } /// /// Resolves the + for the /// current clip. source priority order: /// (1) the op's output slot value when it's been evaluated, /// (2) the live from an active /// when this clip is the recording target — lets the timeline show events /// streaming in during capture, /// (3) the file on disk via the shared resolver when the op is /// unwired and not being recorded into. /// /// /// The mapping is always rebuilt from the live via /// , never read from slot.Value.Mapping. The slot /// has DirtyFlagTrigger.Animated, so its cached value is refreshed only when /// playback time advances — drag-mutating or /// doesn't fire Update, and the cached /// mapping would otherwise drift one or more frames behind the body bounds until the /// user scrubbed. Reading from the live is allocation-free and /// keeps the visualisation in lockstep with the drag. /// private static bool TryGetDataSetAndMapping(Instance instance, Slot dataSlot, TimeClip timeClip, out DataSet? dataSet, out TimeRangeMapping mapping) { mapping = default; var clipValue = dataSlot.Value; if (clipValue?.Set != null) { dataSet = clipValue.Set; return TryBuildMapping(timeClip, out mapping); } // Live-recording fallback: if this clip is the active session's target, use the // in-flight DataSet. if (RecordingSession.TryGetLiveDataSet(instance.SymbolChildId, out var liveSet) && liveSet != null) { dataSet = liveSet; return TryBuildMapping(timeClip, out mapping); } // File fallback: read the path from the op's IDescriptiveFilename interface and // look up the parsed DataSet from the shared cache. dataSet = null; if (instance is not IDescriptiveFilename descriptive) return false; var path = descriptive.SourcePathSlot.TypedInputValue.Value; if (string.IsNullOrEmpty(path)) return false; if (!AssetRegistry.TryResolveAddress(path, instance, out var absolutePath, out _)) return false; if (!DataClipFiles.TryGetDataSetForFile(path, absolutePath, out dataSet, out _)) return false; return TryBuildMapping(timeClip, out mapping); } /// /// Builds the source-content-relative the same way /// LoadDataClip.Update does — direct identity from /// and . SourceRange is in file-time (recording /// anchored at 0); cut / start-handle trim adjust it in that space. Keeps the /// renderer's culling and tick placement consistent with what SimulateIoData /// uses for dispatch. /// private static bool TryBuildMapping(TimeClip timeClip, out TimeRangeMapping mapping) { mapping = default; var playback = Playback.Current; if (playback == null) return false; mapping = new TimeRangeMapping(timeClip.TimeRange, timeClip.SourceRange, playback.Bpm); return true; } /// Width of a single tick in pixels — 2 keeps marks visible at all zooms. private const float TickWidthPx = 2f; /// /// Min gap in pixels between consecutive events for them to remain visually distinct /// ticks rather than collapse into a density-rect. Sub-pixel resolution events become /// rects automatically. /// private const float MinTickGapPx = 2.5f; /// /// Threshold for ticks-vs-rect within a run: runs with this many events or fewer /// render as individual ticks, larger runs collapse to a single density-rect. /// private const int RunSizeForTicks = 5; // Scratch buffer reused across frames to avoid per-frame allocation when filtering // channels by event count. private static readonly List _visibleChannelsScratch = new(); // Joined-path comparer for the per-clip track order. Joins on '.' so the sort matches // the DataSet output canvas's `OrderBy(c => string.Join(".", c.Path))`. The joined key // is stable (Path is init-only), so it's cached per channel — the sort runs once per // clip per frame and the naive `string.Join` in the comparer otherwise allocated two // strings per comparison, churning the GC across every visible DataClip. The weak table // lets the keys collect with their channels across project reloads. private static readonly ConditionalWeakTable _channelPathKeys = new(); private static string GetChannelPathKey(DataChannel channel) => _channelPathKeys.GetValue(channel, static c => string.Join('.', c.Path)); private static readonly Comparison _channelByPathComparer = (a, b) => string.CompareOrdinal(GetChannelPathKey(a), GetChannelPathKey(b)); }