From a6a66d99fdf453fde0f32daa50935f1e2e476c1a Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Sun, 2 Mar 2025 19:56:38 +0200 Subject: [PATCH] add basic markers to the sides of a channel view --- src/app.zig | 504 +++++++++++++++++++++++++++++++------------------- src/main.zig | 2 +- src/ui.zig | 23 ++- src/utils.zig | 8 + 4 files changed, 342 insertions(+), 195 deletions(-) diff --git a/src/app.zig b/src/app.zig index 45a3a0e..5927741 100644 --- a/src/app.zig +++ b/src/app.zig @@ -8,9 +8,10 @@ const Graph = @import("./graph.zig"); const NIDaq = @import("ni-daq/root.zig"); const rect_utils = @import("./rect-utils.zig"); const TaskPool = @import("ni-daq/task-pool.zig"); +const utils = @import("./utils.zig"); -const remap = @import("./utils.zig").remap; -const lerpColor = @import("./utils.zig").lerpColor; +const remap = utils.remap; +const lerpColor = utils.lerpColor; const log = std.log.scoped(.app); const assert = std.debug.assert; const clamp = std.math.clamp; @@ -53,7 +54,7 @@ const ChannelView = struct { view_rect: Graph.ViewOptions, follow: bool = false, - height: f32 = 200, + height: f32 = 300, default_from: f32, default_to: f32, @@ -498,7 +499,7 @@ fn showChannelViewSlider(self: *App, view_rect: *Graph.ViewOptions, sample_count )); } -fn showChannelView(self: *App, channel_view: *ChannelView) !void { +fn showChannelViewGraph(self: *App, channel_view: *ChannelView) !void { const source = self.getChannelSource(channel_view) orelse return; const samples = source.samples(); source.lockSamples(); @@ -506,11 +507,218 @@ fn showChannelView(self: *App, channel_view: *ChannelView) !void { var channel_rect_opts: *Graph.ViewOptions = &channel_view.view_rect; + const graph_box = self.ui.newBoxFromString("Graph"); + graph_box.flags.insert(.clickable); + graph_box.flags.insert(.draggable); + graph_box.background = srcery.black; + graph_box.size.x = UI.Size.percent(1, 0); + graph_box.size.y = UI.Size.pixels(channel_view.height, 1); + self.ui.pushParent(graph_box); + defer self.ui.popParent(); + + const graph_rect = graph_box.computedRect(); + + const signal = self.ui.signalFromBox(graph_box); + + var axis = UI.Axis.X; + var zooming: bool = false; + var start_sample: ?f64 = null; + var stop_sample: ?f64 = null; + + if (signal.hot) { + if (signal.shift_modifier) { + axis = UI.Axis.Y; + } else { + axis = UI.Axis.X; + } + + if (self.graph_start_sample) |graph_start_sample| { + axis = graph_start_sample.axis; + zooming = true; + } + + var mouse_sample: f64 = undefined; + if (axis == .X) { + const mouse_sample_index = channel_rect_opts.mapSampleXToIndex(0, graph_rect.width, signal.relative_mouse.x); + mouse_sample = mouse_sample_index; + } else if (axis == .Y) { + const mouse_sample_value = channel_rect_opts.mapSampleYToValue(0, graph_rect.height, signal.relative_mouse.y); + mouse_sample = mouse_sample_value; + } + + start_sample = mouse_sample; + + if (signal.flags.contains(.right_pressed)) { + self.graph_start_sample = .{ + .value = mouse_sample, + .axis = axis + }; + } + + if (self.graph_start_sample) |graph_start_sample| { + start_sample = graph_start_sample.value; + stop_sample = mouse_sample; + zooming = true; + } + } + + if (zooming) { + if (axis == .X) { + graph_box.active_cursor = .mouse_cursor_resize_ew; + } else { + graph_box.active_cursor = .mouse_cursor_resize_ns; + } + + if (signal.flags.contains(.right_released)) { + self.graph_start_sample = null; + + if (start_sample != null and stop_sample != null) { + const lower_sample: f64 = @min(start_sample.?, stop_sample.?); + const higher_sample: f64 = @max(start_sample.?, stop_sample.?); + + if (axis == .X) { + if (higher_sample - lower_sample > 1) { + channel_rect_opts.from = @floatCast(lower_sample); + channel_rect_opts.to = @floatCast(higher_sample); + } else { + // TODO: Show error message that selected range is too small + } + } else if (axis == .Y) { + if (higher_sample - lower_sample > 0.001) { + channel_rect_opts.min_value = lower_sample; + channel_rect_opts.max_value = higher_sample; + } else { + // TODO: Show error message that selected range is too small + } + } + } + + start_sample = null; + stop_sample = null; + } + + if (start_sample != null and stop_sample != null) { + + const fill = self.ui.newBox(UI.Key.initNil()); + fill.background = srcery.green.alpha(0.5); + + if (axis == .X) { + const start_x = channel_rect_opts.mapSampleIndexToX(graph_rect.x, graph_rect.width, start_sample.?); + const stop_x = channel_rect_opts.mapSampleIndexToX(graph_rect.x, graph_rect.width, stop_sample.?); + + fill.setFixedRect(.{ + .x = @floatCast(@min(start_x, stop_x)), + .y = graph_rect.y, + .width = @floatCast(@abs(start_x - stop_x)), + .height = graph_rect.height + }); + } else if (axis == .Y) { + const start_y = channel_rect_opts.mapSampleValueToY(graph_rect.y, graph_rect.height, start_sample.?); + const stop_y = channel_rect_opts.mapSampleValueToY(graph_rect.y, graph_rect.height, stop_sample.?); + + fill.setFixedRect(.{ + .x = graph_rect.x, + .y = @floatCast(@min(start_y, stop_y)), + .width = graph_rect.width, + .height = @floatCast(@abs(start_y - stop_y)), + }); + } + } + + if (start_sample) |sample| { + const marker = self.ui.newBox(UI.Key.initNil()); + marker.background = srcery.green; + + if (axis == .X) { + const value = samples[@intFromFloat(sample)]; + marker.setFmtText(.text, "{d:0.2} | {d:0.6}", .{sample, value}); + marker.setFixedRect(UI.Rect{ + .x = @floatCast(channel_rect_opts.mapSampleIndexToX(graph_rect.x, graph_rect.width, sample)), + .y = graph_rect.y, + .width = 1, + .height = graph_rect.height + }); + + } else if (axis == .Y) { + marker.setFmtText(.text, "{d:0.2}", .{sample}); + marker.setFixedRect(UI.Rect{ + .x = graph_rect.x, + .y = @floatCast(channel_rect_opts.mapSampleValueToY(graph_rect.y, graph_rect.height, sample)), + .width = graph_rect.width, + .height = 1 + }); + } + } + + if (stop_sample) |sample| { + const marker = self.ui.newBox(UI.Key.initNil()); + marker.background = srcery.green; + + marker.setFmtText(.text, "{d:0.2}", .{sample}); + if (axis == .X) { + marker.setFixedRect(.{ + .x = @floatCast(channel_rect_opts.mapSampleIndexToX(graph_rect.x, graph_rect.width, sample)), + .y = graph_rect.y, + .width = 1, + .height = graph_rect.height + }); + } else if (axis == .Y) { + marker.setFixedRect(.{ + .x = graph_rect.x, + .y = @floatCast(channel_rect_opts.mapSampleValueToY(graph_rect.y, graph_rect.height, sample)), + .width = graph_rect.width, + .height = 1 + }); + } + } + } else { + if (signal.dragged()) { + const middle_mouse_drag = signal.flags.contains(.middle_dragging); + var x_offset: f64 = 0; + var y_offset: f64 = 0; + + if (signal.shift_modifier or middle_mouse_drag) { + y_offset = remap( + f64, + 0, graph_rect.height, + 0, channel_rect_opts.max_value - channel_rect_opts.min_value, + signal.drag.y + ); + } + + if (!signal.shift_modifier or middle_mouse_drag) { + x_offset = remap( + f64, + 0, graph_rect.width, + 0, channel_rect_opts.to - channel_rect_opts.from, + signal.drag.x + ); + } + + channel_rect_opts.from -= @floatCast(x_offset); + channel_rect_opts.to -= @floatCast(x_offset); + channel_rect_opts.max_value += @floatCast(y_offset); + channel_rect_opts.min_value += @floatCast(y_offset); + } + } + + Graph.drawCached(&channel_view.view_cache, graph_box.persistent.size, channel_rect_opts.*, samples); + if (channel_view.view_cache.texture) |texture| { + graph_box.texture = texture.texture; + } + +} + +fn showChannelView(self: *App, channel_view: *ChannelView) !void { + const source = self.getChannelSource(channel_view) orelse return; + + var channel_rect_opts: *Graph.ViewOptions = &channel_view.view_rect; + const channel_box = self.ui.newBoxFromPtr(channel_view); channel_box.background = rl.Color.blue; channel_box.layout_axis = .Y; channel_box.size.x = UI.Size.percent(1, 0); - channel_box.size.y = UI.Size.childrenSum(1); + channel_box.size.y = UI.Size.pixels(channel_view.height, 1); self.ui.pushParent(channel_box); defer self.ui.popParent(); @@ -589,207 +797,127 @@ fn showChannelView(self: *App, channel_view: *ChannelView) !void { } } + // const channel_box = self.ui.newBox(UI.Key.initNil()); + // channel_box.layout_axis = .Y; + // channel_box.size.x = UI.Size.percent(1, 0); + // channel_box.size.y = UI.Size.childrenSum(1); + // self.ui.pushParent(channel_box); + // defer self.ui.popParent(); + + var y_axis_markers: *UI.Box = undefined; + var x_axis_markers: *UI.Box = undefined; + { - const graph_box = self.ui.newBoxFromString("Graph"); - graph_box.flags.insert(.clickable); - graph_box.flags.insert(.draggable); - graph_box.background = srcery.black; - graph_box.size.x = UI.Size.percent(1, 0); - graph_box.size.y = UI.Size.pixels(channel_view.height, 1); - self.ui.pushParent(graph_box); + const y_axis_container = self.ui.newBox(UI.Key.initNil()); + y_axis_container.layout_axis = .X; + y_axis_container.size.x = UI.Size.percent(1, 0); + y_axis_container.size.y = UI.Size.percent(1, 0); + self.ui.pushParent(y_axis_container); defer self.ui.popParent(); - const graph_rect = graph_box.computedRect(); + y_axis_markers = self.ui.newBoxFromString("Y axis markers"); + y_axis_markers.background = rl.Color.blue; + y_axis_markers.size.x = UI.Size.pixels(64, 1); + y_axis_markers.size.y = UI.Size.percent(1, 0); - const signal = self.ui.signalFromBox(graph_box); + try self.showChannelViewGraph(channel_view); + } - var axis = UI.Axis.X; - var zooming: bool = false; - var start_sample: ?f64 = null; - var stop_sample: ?f64 = null; - if (signal.hot) { - if (signal.shift_modifier) { - axis = UI.Axis.Y; - } else { - axis = UI.Axis.X; - } + { + const horizontal_container = self.ui.newBox(UI.Key.initNil()); + horizontal_container.layout_axis = .X; + horizontal_container.size.x = UI.Size.childrenSum(0); + horizontal_container.size.y = UI.Size.childrenSum(1); + self.ui.pushParent(horizontal_container); + defer self.ui.popParent(); - if (self.graph_start_sample) |graph_start_sample| { - axis = graph_start_sample.axis; - zooming = true; - } + const fullscreen_button = self.ui.newBoxFromString("Fullscreen"); + fullscreen_button.background = rl.Color.red; + fullscreen_button.size.x = UI.Size.pixels(y_axis_markers.computedRect().width, 1); + fullscreen_button.size.y = UI.Size.pixels(32, 1); - var mouse_sample: f64 = undefined; - if (axis == .X) { - const mouse_sample_index = channel_rect_opts.mapSampleXToIndex(0, graph_rect.width, signal.relative_mouse.x); - mouse_sample = mouse_sample_index; - } else if (axis == .Y) { - const mouse_sample_value = channel_rect_opts.mapSampleYToValue(0, graph_rect.height, signal.relative_mouse.y); - mouse_sample = mouse_sample_value; - } + x_axis_markers = self.ui.newBoxFromString("X axis markers"); + x_axis_markers.background = rl.Color.pink; + x_axis_markers.size.x = UI.Size.percent(1, 0); + x_axis_markers.size.y = UI.Size.pixels(32, 1); + self.ui.pushParent(x_axis_markers); + defer self.ui.popParent(); + } - start_sample = mouse_sample; + { + self.ui.pushParent(y_axis_markers); + defer self.ui.popParent(); - if (signal.flags.contains(.right_pressed)) { - self.graph_start_sample = .{ - .value = mouse_sample, - .axis = axis - }; - } + const y_axis_rect = y_axis_markers.computedRect(); - if (self.graph_start_sample) |graph_start_sample| { - start_sample = graph_start_sample.value; - stop_sample = mouse_sample; - zooming = true; - } - } + const min_gap_between_markers = 8; - if (zooming) { - if (axis == .X) { - graph_box.active_cursor = .mouse_cursor_resize_ew; - } else { - graph_box.active_cursor = .mouse_cursor_resize_ns; - } + const y_range = channel_rect_opts.max_value - channel_rect_opts.min_value; - if (signal.flags.contains(.right_released)) { - self.graph_start_sample = null; + var axis_marker_size = min_gap_between_markers / y_axis_rect.height * y_range; + axis_marker_size = @ceil(axis_marker_size); - if (start_sample != null and stop_sample != null) { - const lower_sample: f64 = @min(start_sample.?, stop_sample.?); - const higher_sample: f64 = @max(start_sample.?, stop_sample.?); + var marker = utils.roundNearestUp(f64, @max(channel_rect_opts.min_value, channel_view.default_min_value), axis_marker_size); + while (marker < @min(channel_rect_opts.max_value, channel_view.default_max_value)) : (marker += axis_marker_size) { + const marker_box = self.ui.newBox(UI.Key.initNil()); + marker_box.background = rl.Color.yellow; + marker_box.setFixedRect(.{ + .width = y_axis_rect.width/5, + .height = 1, + .x = y_axis_rect.x + y_axis_rect.width/5*4, + .y = @floatCast(remap( + f64, + channel_rect_opts.max_value, channel_rect_opts.min_value, + y_axis_rect.y, y_axis_rect.y + y_axis_rect.height, + marker + )) + }); - if (axis == .X) { - if (higher_sample - lower_sample > 1) { - channel_rect_opts.from = @floatCast(lower_sample); - channel_rect_opts.to = @floatCast(higher_sample); - } else { - // TODO: Show error message that selected range is too small - } - } else if (axis == .Y) { - if (higher_sample - lower_sample > 0.01) { - channel_rect_opts.min_value = lower_sample; - channel_rect_opts.max_value = higher_sample; - } else { - // TODO: Show error message that selected range is too small - } - } - } - - start_sample = null; - stop_sample = null; - } - - if (start_sample != null and stop_sample != null) { - - const fill = self.ui.newBox(UI.Key.initNil()); - fill.background = srcery.green.alpha(0.5); - - if (axis == .X) { - const start_x = channel_rect_opts.mapSampleIndexToX(graph_rect.x, graph_rect.width, start_sample.?); - const stop_x = channel_rect_opts.mapSampleIndexToX(graph_rect.x, graph_rect.width, stop_sample.?); - - fill.setFixedRect(.{ - .x = @floatCast(@min(start_x, stop_x)), - .y = graph_rect.y, - .width = @floatCast(@abs(start_x - stop_x)), - .height = graph_rect.height - }); - } else if (axis == .Y) { - const start_y = channel_rect_opts.mapSampleValueToY(graph_rect.y, graph_rect.height, start_sample.?); - const stop_y = channel_rect_opts.mapSampleValueToY(graph_rect.y, graph_rect.height, stop_sample.?); - - fill.setFixedRect(.{ - .x = graph_rect.x, - .y = @floatCast(@min(start_y, stop_y)), - .width = graph_rect.width, - .height = @floatCast(@abs(start_y - stop_y)), - }); - } - } - - if (start_sample) |sample| { - const marker = self.ui.newBox(UI.Key.initNil()); - marker.background = srcery.green; - - if (axis == .X) { - const value = samples[@intFromFloat(sample)]; - marker.setFmtText(.text, "{d:0.2} | {d:0.6}", .{sample, value}); - marker.setFixedRect(UI.Rect{ - .x = @floatCast(channel_rect_opts.mapSampleIndexToX(graph_rect.x, graph_rect.width, sample)), - .y = graph_rect.y, - .width = 1, - .height = graph_rect.height - }); - - } else if (axis == .Y) { - marker.setFmtText(.text, "{d:0.2}", .{sample}); - marker.setFixedRect(UI.Rect{ - .x = graph_rect.x, - .y = @floatCast(channel_rect_opts.mapSampleValueToY(graph_rect.y, graph_rect.height, sample)), - .width = graph_rect.width, - .height = 1 - }); - } - } - - if (stop_sample) |sample| { - const marker = self.ui.newBox(UI.Key.initNil()); - marker.background = srcery.green; - - marker.setFmtText(.text, "{d:0.2}", .{sample}); - if (axis == .X) { - marker.setFixedRect(.{ - .x = @floatCast(channel_rect_opts.mapSampleIndexToX(graph_rect.x, graph_rect.width, sample)), - .y = graph_rect.y, - .width = 1, - .height = graph_rect.height - }); - } else if (axis == .Y) { - marker.setFixedRect(.{ - .x = graph_rect.x, - .y = @floatCast(channel_rect_opts.mapSampleValueToY(graph_rect.y, graph_rect.height, sample)), - .width = graph_rect.width, - .height = 1 - }); - } - } - } else { - if (signal.dragged()) { - if (signal.shift_modifier) { - const drag_offset = remap( - f64, - 0, graph_rect.height, - 0, channel_rect_opts.max_value - channel_rect_opts.min_value, - signal.drag.y - ); - - channel_rect_opts.max_value += @floatCast(drag_offset); - channel_rect_opts.min_value += @floatCast(drag_offset); - } else { - const drag_offset = remap( - f64, - 0, graph_rect.width, - 0, channel_rect_opts.to - channel_rect_opts.from, - signal.drag.x - ); - - channel_rect_opts.from -= @floatCast(drag_offset); - channel_rect_opts.to -= @floatCast(drag_offset); - } - } - } - - Graph.drawCached(&channel_view.view_cache, graph_box.persistent.size, channel_rect_opts.*, samples); - if (channel_view.view_cache.texture) |texture| { - graph_box.texture = texture.texture; + const label_box = self.ui.newBox(UI.Key.initNil()); + label_box.setFmtText(.text, "{d:.03}", .{marker}); + label_box.size.x = UI.Size.text(0, 1); + label_box.size.y = UI.Size.text(0, 1); + label_box.flags.insert(.text_left_align); + label_box.setFixedRect(.{ + .width = y_axis_rect.width, + .height = 1, + .x = y_axis_rect.x, + .y = @floatCast(remap( + f64, + channel_rect_opts.max_value, channel_rect_opts.min_value, + y_axis_rect.y, y_axis_rect.y + y_axis_rect.height, + marker + )) + }); } } - self.showChannelViewSlider( - &channel_view.view_rect, - @floatFromInt(samples.len) - ); + { + self.ui.pushParent(x_axis_markers); + defer self.ui.popParent(); + + const y_axis_rect = x_axis_markers.computedRect(); + + const axis_marker_size = 100000; + + var marker = utils.roundNearestUp(f64, @max(channel_rect_opts.from, channel_view.default_from), axis_marker_size); + while (marker < @min(channel_rect_opts.to, channel_view.default_to)) : (marker += axis_marker_size) { + const marker_box = self.ui.newBox(UI.Key.initNil()); + marker_box.background = rl.Color.yellow; + marker_box.setFixedRect(.{ + .width = 1, + .height = y_axis_rect.height/2, + .x = @floatCast(remap( + f64, + channel_rect_opts.from, channel_rect_opts.to, + y_axis_rect.x, y_axis_rect.x + y_axis_rect.width, + marker + )), + .y = y_axis_rect.y, + }); + } + } } fn showChannelsWindow(self: *App) !void { diff --git a/src/main.zig b/src/main.zig index 124ab7d..2b1f607 100644 --- a/src/main.zig +++ b/src/main.zig @@ -158,7 +158,7 @@ pub fn main() !void { if (builtin.mode == .Debug) { // try app.appendChannelFromDevice("Dev1/ai0"); try app.appendChannelFromFile("samples/HeLa Cx37_ 40nM GFX + 35uM Propofol_18-Sep-2024_0003_I.bin"); - try app.appendChannelFromFile("samples/HeLa Cx37_ 40nM GFX + 35uM Propofol_18-Sep-2024_0003_IjStim.bin"); + // try app.appendChannelFromFile("samples/HeLa Cx37_ 40nM GFX + 35uM Propofol_18-Sep-2024_0003_IjStim.bin"); // try app.appendChannelFromFile("samples/HeLa Cx37_ 40nM GFX + 35uM Propofol_18-Sep-2024_0003_IjStim.bin"); // try app.appendChannelFromFile("samples/HeLa Cx37_ 40nM GFX + 35uM Propofol_18-Sep-2024_0003_IjStim.bin"); } diff --git a/src/ui.zig b/src/ui.zig index 16af59c..2fdc62a 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -16,7 +16,7 @@ const clamp = std.math.clamp; const UI = @This(); const debug = false; -const max_boxes = 512; +const max_boxes = 5120; const max_events = 256; const RectFormatted = struct { @@ -176,15 +176,19 @@ pub const Event = union(enum) { pub const Signal = struct { pub const Flag = enum { left_pressed, + middle_pressed, right_pressed, left_released, + middle_released, right_released, left_clicked, + middle_clicked, right_clicked, left_dragging, + middle_dragging, right_dragging, scrolled @@ -199,11 +203,11 @@ pub const Signal = struct { shift_modifier: bool = false, pub fn clicked(self: Signal) bool { - return self.flags.contains(.left_clicked) or self.flags.contains(.right_clicked); + return self.flags.contains(.left_clicked) or self.flags.contains(.right_clicked) or self.flags.contains(.middle_clicked); } pub fn dragged(self: Signal) bool { - return self.flags.contains(.left_dragging) or self.flags.contains(.right_dragging); + return self.flags.contains(.left_dragging) or self.flags.contains(.right_dragging) or self.flags.contains(.middle_dragging); } pub fn scrolled(self: Signal) bool { @@ -215,6 +219,8 @@ pub const Signal = struct { self.flags.insert(.left_pressed); } else if (mouse_button == .mouse_button_right) { self.flags.insert(.right_pressed); + } else if (mouse_button == .mouse_button_middle) { + self.flags.insert(.middle_pressed); } } @@ -223,6 +229,8 @@ pub const Signal = struct { self.flags.insert(.left_released); } else if (mouse_button == .mouse_button_right) { self.flags.insert(.right_released); + } else if (mouse_button == .mouse_button_middle) { + self.flags.insert(.middle_released); } } @@ -231,6 +239,8 @@ pub const Signal = struct { self.flags.insert(.left_clicked); } else if (mouse_button == .mouse_button_right) { self.flags.insert(.right_clicked); + } else if (mouse_button == .mouse_button_middle) { + self.flags.insert(.middle_clicked); } } @@ -239,6 +249,8 @@ pub const Signal = struct { self.flags.insert(.left_dragging); } else if (mouse_button == .mouse_button_right) { self.flags.insert(.right_dragging); + } else if (mouse_button == .mouse_button_middle) { + self.flags.insert(.middle_dragging); } } }; @@ -856,8 +868,6 @@ fn drawBox(self: *UI, box: *Box) void { text_rect.y += (box_rect.height - text_size.y) / 2; } - // text_rect.y += box.persistent.active * text_rect.height*0.1; - const text_color = utils.shiftColorInHSV(text.color, value_shift); font.drawText(text.content, .{ .x = text_rect.x, .y = text_rect.y }, text_color); @@ -1387,7 +1397,8 @@ pub fn signalFromBox(self: *UI, box: *Box) Signal { } if (draggable and self.mouse_delta.equals(Vec2.zero()) == 0) { - inline for (.{ rl.MouseButton.mouse_button_left, rl.MouseButton.mouse_button_right }) |mouse_button| { + const mouse_buttons = [_]rl.MouseButton{ .mouse_button_left, .mouse_button_right, .mouse_button_middle }; + inline for (mouse_buttons) |mouse_button| { const active_box = self.active_box_keys.get(mouse_button); if (active_box != null and active_box.?.eql(key)) { diff --git a/src/utils.zig b/src/utils.zig index 01e37cc..a950951 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -53,3 +53,11 @@ pub fn remap(comptime T: type, from_min: T, from_max: T, to_min: T, to_max: T, v const t = (value - from_min) / (from_max - from_min); return std.math.lerp(to_min, to_max, t); } + +pub fn roundNearestUp(comptime T: type, value: T, multiple: T) T { + return @ceil(value / multiple) * multiple; +} + +pub fn roundNearestDown(comptime T: type, value: T, multiple: T) T { + return @floor(value / multiple) * multiple; +} \ No newline at end of file