diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..880cd5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +zig-out +.zig-cache diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..fd04461 --- /dev/null +++ b/build.zig @@ -0,0 +1,37 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{ .preferred_optimize_mode = .ReleaseSmall }); + const exe = b.addExecutable(.{ + .name = "Zig_Units", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{}, + }), + }); + + b.installArtifact(exe); + const run_step = b.step("run", "Run the app"); + + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const exe_tests = b.addTest(.{ + .root_module = exe.root_module, + }); + + // A run step that will run the second test executable. + const run_exe_tests = b.addRunArtifact(exe_tests); + + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&run_exe_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..23777a2 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,81 @@ +.{ + // This is the default name used by packages depending on this one. For + // example, when a user runs `zig fetch --save `, this field is used + // as the key in the `dependencies` table. Although the user can choose a + // different name, most users will stick with this provided value. + // + // It is redundant to include "zig" in this name because it is already + // within the Zig package namespace. + .name = .Zig_Units, + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + // Together with name, this represents a globally unique package + // identifier. This field is generated by the Zig toolchain when the + // package is first created, and then *never changes*. This allows + // unambiguous detection of one package being an updated version of + // another. + // + // When forking a Zig project, this id should be regenerated (delete the + // field and run `zig build`) if the upstream project is still maintained. + // Otherwise, the fork is *hostile*, attempting to take control over the + // original project's identity. Thus it is recommended to leave the comment + // on the following line intact, so that it shows up in code reviews that + // modify the field. + .fingerprint = 0x934b00cf88f69b22, // Changing this has security and trust implications. + // Tracks the earliest Zig version that the package considers to be a + // supported use case. + .minimum_zig_version = "0.16.0", + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + // See `zig fetch --save ` for a command-line interface for adding dependencies. + //.example = .{ + // // When updating this field to a new URL, be sure to delete the corresponding + // // `hash`, otherwise you are communicating that you expect to find the old hash at + // // the new URL. If the contents of a URL change this will result in a hash mismatch + // // which will prevent zig from using it. + // .url = "https://example.com/foo.tar.gz", + // + // // This is computed from the file contents of the directory of files that is + // // obtained after fetching `url` and applying the inclusion rules given by + // // `paths`. + // // + // // This field is the source of truth; packages do not come from a `url`; they + // // come from a `hash`. `url` is just one of many possible mirrors for how to + // // obtain a package matching this `hash`. + // // + // // Uses the [multihash](https://multiformats.io/multihash/) format. + // .hash = "...", + // + // // When this is provided, the package is found in a directory relative to the + // // build root. In this case the package's hash is irrelevant and therefore not + // // computed. This field and `url` are mutually exclusive. + // .path = "foo", + // + // // When this is set to `true`, a package is declared to be lazily + // // fetched. This makes the dependency only get fetched if it is + // // actually used. + // .lazy = false, + //}, + }, + // Specifies the set of files and directories that are included in this package. + // Only files and directories listed here are included in the `hash` that + // is computed for this package. Only files listed here will remain on disk + // when using the zig package manager. As a rule of thumb, one should list + // files required for compilation plus any license(s). + // Paths are relative to the build root. Use the empty string (`""`) to refer to + // the build root itself. + // A directory listed here means that all files within, recursively, are included. + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + // For example... + //"LICENSE", + //"README.md", + }, +} diff --git a/src/Dimensions.zig b/src/Dimensions.zig new file mode 100644 index 0000000..65abf7b --- /dev/null +++ b/src/Dimensions.zig @@ -0,0 +1,90 @@ +const std = @import("std"); + +pub const Dimension = enum { + /// Length + L, + /// Mass + M, + /// Time + T, + /// Electric Current + I, + /// Temperature + Tp, + /// Amount of Substance + N, + /// Luminous Intensity + J, + + pub fn unit(self: @This()) []const u8 { + return switch (self) { + .L => "m", + .M => "g", + .T => "s", + .I => "A", + .Tp => "K", + .N => "mol", + .J => "cd", + }; + } +}; + +// --------- Dimensions struct --------- + +const Self = @This(); + +data: std.EnumArray(Dimension, i8), + +pub fn init(comptime init_val: anytype) Self { + var s = Self{ .data = std.EnumArray(Dimension, i8).initFill(0) }; + inline for (std.meta.fields(@TypeOf(init_val))) |f| + s.data.set(@field(Dimension, f.name), @field(init_val, f.name)); + return s; +} + +pub fn initFill(val: i8) Self { + return .{ .data = std.EnumArray(Dimension, i8).initFill(val) }; +} + +pub fn get(self: Self, key: Dimension) i8 { + return self.data.get(key); +} + +pub fn set(self: *Self, key: Dimension, val: i8) void { + self.data.set(key, val); +} + +pub fn add(comptime a: Self, comptime b: Self) Self { + var result = Self.initFill(0); + for (std.enums.values(Dimension)) |d| + result.set(d, a.get(d) + b.get(d)); + return result; +} + +pub fn sub(comptime a: Self, comptime b: Self) Self { + @setEvalBranchQuota(10_000); + var result = Self.initFill(0); + for (std.enums.values(Dimension)) |d| + result.set(d, a.get(d) - b.get(d)); + return result; +} + +pub fn eql(comptime a: Self, comptime b: Self) bool { + for (std.enums.values(Dimension)) |d| + if (a.get(d) != b.get(d)) return false; + return true; +} + +pub fn str(comptime a: Self) []const u8 { + var out: []const u8 = ""; + const dims = std.enums.values(Dimension); + + inline for (dims) |d| { + const val = a.get(d); + if (val != 0) { + out = out ++ @tagName(d) ++ std.fmt.comptimePrint("{d}", .{val}); + } + } + + return if (out.len == 0) "Dimensionless" else out; +} diff --git a/src/Scales.zig b/src/Scales.zig new file mode 100644 index 0000000..ad41399 --- /dev/null +++ b/src/Scales.zig @@ -0,0 +1,110 @@ +const std = @import("std"); +const hlp = @import("helper.zig"); +const Dimensions = @import("Dimensions.zig"); +const Dimension = @import("Dimensions.zig").Dimension; + +pub const UnitScale = enum(i32) { + P = 15, + T = 12, + G = 9, + M = 6, + k = 3, + h = 2, + da = 1, + none = 0, + d = -1, + c = -2, + m = -3, + u = -6, + n = -9, + p = -12, + f = -15, + + // Custom + min = 60, + hour = 3_600, + year = 31_536_000, + + // Undefined + _, + + pub fn str(self: @This()) []const u8 { + var buf: [16]u8 = undefined; + return switch (self) { + .none => "", + inline .P, .T, .G, .M, .k, .h, .da, .d, .c, .m, .u, .n, .p, .f, .min, .hour, .year => @tagName(self), + else => std.fmt.bufPrint(&buf, "[{d}]", .{@intFromEnum(self)}) catch "[]", + }; + } + + /// Helper to get the actual scaling factor + pub fn getFactor(self: @This()) f64 { + return switch (self) { + inline .P, .T, .G, .M, .k, .h, .da, .none, .d, .c, .m, .u, .n, .p, .f => std.math.pow(f64, 10.0, @floatFromInt(@intFromEnum(self))), + else => @floatFromInt(@intFromEnum(self)), + }; + } + + /// Helper to get the actual scaling factor in i32 + pub fn getFactorInt(self: @This()) i32 { + return switch (self) { + inline .P, .T, .G, .M, .k, .h, .da, .none, .d, .c, .m, .u, .n, .p, .f => std.math.powi(i32, 10.0, @intFromEnum(self)) catch 0, + else => @intFromEnum(self), + }; + } +}; + +const Scales = @This(); + +data: std.EnumArray(Dimension, UnitScale), + +pub fn init(comptime init_val: anytype) Scales { + var s = Scales{ .data = std.EnumArray(Dimension, UnitScale).initFill(.none) }; + inline for (std.meta.fields(@TypeOf(init_val))) |f| { + if (comptime hlp.isInt(@TypeOf(@field(init_val, f.name)))) + s.data.set(@field(Dimension, f.name), @enumFromInt(@field(init_val, f.name))) + else + s.data.set(@field(Dimension, f.name), @field(init_val, f.name)); + } + return s; +} + +pub fn initFill(val: UnitScale) Scales { + return .{ .data = std.EnumArray(Dimension, UnitScale).initFill(val) }; +} + +pub fn get(self: Scales, key: Dimension) UnitScale { + return self.data.get(key); +} + +pub fn set(self: *Scales, key: Dimension, val: UnitScale) void { + self.data.set(key, val); +} + +pub fn min(comptime s1: Scales, comptime s2: Scales) Scales { + var out = Scales.initFill(.none); + for (std.enums.values(Dimension)) |dim| + out.set(dim, if (s1.get(dim).getFactorInt() > s2.get(dim).getFactorInt()) s2.get(dim) else s1.get(dim)); + + return out; +} + +pub fn getFactor(comptime s: Scales, comptime d: Dimensions) f64 { + var factor: f64 = 1.0; + for (std.enums.values(Dimension)) |dim| { + const power = d.get(dim); + if (power == 0) continue; + + const base = s.get(dim).getFactor(); + + var i: i32 = 0; + const abs_power = if (power < 0) -power else power; + while (i < abs_power) : (i += 1) { + if (power > 0) + factor *= base + else + factor /= base; + } + } + return factor; +} diff --git a/src/helper.zig b/src/helper.zig new file mode 100644 index 0000000..7e6cb06 --- /dev/null +++ b/src/helper.zig @@ -0,0 +1,32 @@ +const std = @import("std"); + +pub fn isInt(comptime T: type) bool { + return @typeInfo(T) == .int or @typeInfo(T) == .comptime_int; +} + +pub fn printSuperscript(writer: *std.Io.Writer, n: i32) !void { + if (n == 0) return; + var val = n; + if (val < 0) { + try writer.writeAll("\u{207B}"); + val = -val; + } + var buf: [12]u8 = undefined; + const str = std.fmt.bufPrint(&buf, "{d}", .{val}) catch return; + for (str) |c| { + const s = switch (c) { + '0' => "\u{2070}", + '1' => "\u{00B9}", + '2' => "\u{00B2}", + '3' => "\u{00B3}", + '4' => "\u{2074}", + '5' => "\u{2075}", + '6' => "\u{2076}", + '7' => "\u{2077}", + '8' => "\u{2078}", + '9' => "\u{2079}", + else => unreachable, + }; + try writer.writeAll(s); + } +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..f0268fa --- /dev/null +++ b/src/main.zig @@ -0,0 +1,795 @@ +const std = @import("std"); +const hlp = @import("helper.zig"); + +const Scales = @import("Scales.zig"); +const UnitScale = Scales.UnitScale; +const Dimensions = @import("Dimensions.zig"); +const Dimension = Dimensions.Dimension; + +pub fn Quantity(T: type, d: Dimensions, s: Scales) type { + return struct { + value: T, + + const Self = @This(); + pub const Vec3: type = QuantityVec3(Self); + pub const ValueType: type = T; + + pub const dims: Dimensions = d; + pub const scales = s; + + /// Internal helper to convert any supported T to f64 for math + fn toF64(val: anytype) f64 { + const TIn = @TypeOf(val); + return switch (@typeInfo(TIn)) { + .int => @floatFromInt(val), + .float => @floatCast(val), + else => @compileError("Unsupported type for Quantity"), + }; + } + + /// Internal helper to convert f64 back to the target T + fn fromF64(val: f64) T { + return switch (@typeInfo(T)) { + .int => @intFromFloat(@round(val)), + .float => @floatCast(val), + else => unreachable, + }; + } + + /// Helper for integer power of 10 at comptime + fn pow10(comptime exp: i32) T { + var res: T = 1; + var i: i32 = 0; + const abs_exp = if (exp < 0) -exp else exp; + while (i < abs_exp) : (i += 1) res *= 10; + return res; + } + + pub fn add(self: Self, rhs: anytype) Self { + if (comptime !dims.eql(@TypeOf(rhs).dims)) + @compileError("Dimension mismatch in add"); + return .{ .value = self.value + rhs.to(Self).value }; + } + + pub fn sub(self: Self, rhs: anytype) Self { + if (comptime !dims.eql(@TypeOf(rhs).dims)) + @compileError("Dimension mismatch in sub"); + return .{ .value = self.value - rhs.to(Self).value }; + } + + pub fn mulBy(self: Self, rhs: anytype) Quantity( + T, + dims.add(@TypeOf(rhs).dims), + scales.min(@TypeOf(rhs).scales), + ) { + const self_ = self.to(Quantity(T, dims, scales.min(@TypeOf(rhs).scales))); + const rhs_ = rhs.to(Quantity(T, @TypeOf(rhs).dims, scales.min(@TypeOf(rhs).scales))); + return .{ .value = self_.value * rhs_.value }; + } + + pub fn divBy(self: Self, rhs: anytype) Quantity( + T, + dims.sub(@TypeOf(rhs).dims), + scales.min(@TypeOf(rhs).scales), + ) { + const self_ = self.to(Quantity(T, dims, scales.min(@TypeOf(rhs).scales))); + const rhs_ = rhs.to(Quantity(T, @TypeOf(rhs).dims, scales.min(@TypeOf(rhs).scales))); + return .{ .value = fromF64((toF64(self_.value) / toF64(rhs_.value))) }; + } + + pub fn scale(self: Self, sc: T) Self { + return .{ .value = self.value * sc }; + } + + pub fn to(self: Self, comptime Dest: type) Dest { + if (comptime !dims.eql(Dest.dims)) + @compileError("Dimension mismatch: " ++ dims.str() ++ " vs " ++ Dest.dims.str()); + if (comptime @TypeOf(self) == Dest) + return self; + + const source_factor = scales.getFactor(dims); + const dest_factor = Dest.scales.getFactor(Dest.dims); + const ratio = source_factor / dest_factor; + const result_f = toF64(self.value) * ratio; + + const DestT = Dest.ValueType; + return .{ .value = switch (@typeInfo(DestT)) { + .int => @intFromFloat(@round(result_f)), + .float => @floatCast(result_f), + else => unreachable, + } }; + } + + pub fn vec3(self: Self) Vec3 { + return .{ .x = self.value, .y = self.value, .z = self.value }; + } + + pub fn format( + self: Self, + writer: *std.Io.Writer, + ) !void { + try writer.print("{d}", .{self.value}); + var iter = std.EnumSet(Dimension).initFull().iterator(); + var first = true; + while (iter.next()) |bu| { + const v = dims.get(bu); + if (v == 0) continue; + if (!first) + try writer.writeAll("."); + + first = false; + + const uscale = scales.get(bu); + if (bu == .T and (uscale == .min or uscale == .hour or uscale == .year)) + try writer.print("{s}", .{uscale.str()}) + else + try writer.print("{s}{s}", .{ uscale.str(), bu.unit() }); + + if (v != 1) + try hlp.printSuperscript(writer, v); + } + } + }; +} + +pub fn QuantityVec3(Q: type) type { + const T = Q.ValueType; + const d: Dimensions = Q.dims; + const s: Scales = Q.scales; + + return struct { + x: T, + y: T, + z: T, + + const Self = @This(); + pub const QuantityType = Q; + pub const ValueType = T; + pub const dims: Dimensions = d; + pub const scales = s; + + pub const zero = Self{ .x = 0, .y = 0, .z = 0 }; + pub const one = Self{ .x = 1, .y = 1, .z = 1 }; + + pub fn initDefault(v: T) Self { + return .{ .x = v, .y = v, .z = v }; + } + + pub fn add(self: Self, rhs: anytype) Self { + const Tr = @TypeOf(rhs); + return .{ + .x = (Q{ .value = self.x }).add(Tr.QuantityType{ .value = rhs.x }).value, + .y = (Q{ .value = self.y }).add(Tr.QuantityType{ .value = rhs.y }).value, + .z = (Q{ .value = self.z }).add(Tr.QuantityType{ .value = rhs.z }).value, + }; + } + + pub fn sub(self: Self, rhs: anytype) Self { + const Tr = @TypeOf(rhs); + return .{ + .x = (Q{ .value = self.x }).sub(Tr.QuantityType{ .value = rhs.x }).value, + .y = (Q{ .value = self.y }).sub(Tr.QuantityType{ .value = rhs.y }).value, + .z = (Q{ .value = self.z }).sub(Tr.QuantityType{ .value = rhs.z }).value, + }; + } + + pub fn divBy( + self: Self, + rhs: anytype, + ) QuantityVec3(Quantity(T, d.sub(@TypeOf(rhs).dims), s.min(@TypeOf(rhs).scales))) { + const Tr = @TypeOf(rhs); + return .{ + .x = (Q{ .value = self.x }).divBy(Tr.QuantityType{ .value = rhs.x }).value, + .y = (Q{ .value = self.y }).divBy(Tr.QuantityType{ .value = rhs.y }).value, + .z = (Q{ .value = self.z }).divBy(Tr.QuantityType{ .value = rhs.z }).value, + }; + } + + pub fn mulBy( + self: Self, + rhs: anytype, + ) QuantityVec3(Quantity(T, d.sub(@TypeOf(rhs).dims), s.min(@TypeOf(rhs).scales))) { + const Tr = @TypeOf(rhs); + return .{ + .x = (Q{ .value = self.x }).mulBy(Tr.QuantityType{ .value = rhs.x }).value, + .y = (Q{ .value = self.y }).mulBy(Tr.QuantityType{ .value = rhs.y }).value, + .z = (Q{ .value = self.z }).mulBy(Tr.QuantityType{ .value = rhs.z }).value, + }; + } + + pub fn divByScalar( + self: Self, + scalar: anytype, + ) QuantityVec3(Quantity(T, d.sub(@TypeOf(scalar).dims), s.min(@TypeOf(scalar).scales))) { + const q_x = Q{ .value = self.x }; + const q_y = Q{ .value = self.y }; + const q_z = Q{ .value = self.z }; + + return .{ + .x = q_x.divBy(scalar).value, + .y = q_y.divBy(scalar).value, + .z = q_z.divBy(scalar).value, + }; + } + + pub fn mulByScalar( + self: Self, + scalar: anytype, + ) QuantityVec3(Quantity(T, d.add(@TypeOf(scalar).dims), s.min(@TypeOf(scalar).scales))) { + const q_x = Q{ .value = self.x }; + const q_y = Q{ .value = self.y }; + const q_z = Q{ .value = self.z }; + + return .{ + .x = q_x.mulBy(scalar).value, + .y = q_y.mulBy(scalar).value, + .z = q_z.mulBy(scalar).value, + }; + } + + pub fn negate(self: Self) Self { + return .{ .x = -self.x, .y = -self.y, .z = -self.z }; + } + + pub fn scale(self: Self, rhs: T) Self { + return .{ + .x = (Q{ .value = self.x }).scale(rhs).value, + .y = (Q{ .value = self.y }).scale(rhs).value, + .z = (Q{ .value = self.z }).scale(rhs).value, + }; + } + + pub fn to(self: Self, comptime DestQ: type) QuantityVec3(DestQ) { + return .{ + .x = (Q{ .value = self.x }).to(DestQ).value, + .y = (Q{ .value = self.y }).to(DestQ).value, + .z = (Q{ .value = self.z }).to(DestQ).value, + }; + } + + pub fn format(self: Self, writer: *std.Io.Writer) !void { + try writer.print("({d:.2}, {d:.2}, {d:.2})", .{ self.x, self.y, self.z }); + var iter = std.EnumSet(Dimension).initFull().iterator(); + var first = true; + while (iter.next()) |bu| { + const v = dims.get(bu); + if (v == 0) continue; + if (!first) try writer.writeAll("."); + first = false; + try writer.print("{s}{s}", .{ scales.get(bu).str(), bu.unit() }); + if (v != 1) try hlp.printSuperscript(writer, v); + } + } + + pub fn lengthSqr(self: Self) T { + return self.x * self.x + self.y * self.y + self.z * self.z; + } + + pub fn length(self: Self) T { + if (comptime hlp.isInt(T)) + return self.isqrt() + else + return @sqrt(self.x * self.x + self.y * self.y + self.z * self.z); + } + + fn isqrt(self: Self) T { + const squared_sum = (self.x * self.x) + (self.y * self.y) + (self.z * self.z); + if (squared_sum <= 0) return 0; + + var x = squared_sum; + var y = @divTrunc(x + 1, 2); + while (y < x) { + x = y; + y = @divTrunc(x + @divTrunc(squared_sum, x), 2); + } + return x; + } + }; +} + +pub fn main(_: std.process.Init) void {} + +test "Generate quantity" { + const Meter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{ .L = -3 })); + const Second = Quantity(f32, Dimensions.init(.{ .T = 1 }), Scales.init(.{ .T = .n })); + + const distance = Meter{ .value = 10 }; + const time = Second{ .value = 2 }; + + try std.testing.expectEqual(10, distance.value); + try std.testing.expectEqual(2, time.value); +} + +test "Add" { + const Meter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + + const distance = Meter{ .value = 10 }; + const distance2 = Meter{ .value = 20 }; + + const added = distance.add(distance2); + try std.testing.expectEqual(30, added.value); + try std.testing.expectEqual(1, @TypeOf(added).dims.get(.L)); + std.debug.print("KiloMeter {f} + {f} = {f} OK\n", .{ distance, distance2, added }); + + const KiloMeter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{ .L = .k })); + const distance3 = KiloMeter{ .value = 2 }; + const added2 = distance.add(distance3); + try std.testing.expectEqual(2010, added2.value); + try std.testing.expectEqual(1, @TypeOf(added2).dims.get(.L)); + std.debug.print("KiloMeter {f} + {f} = {f} OK\n", .{ distance, distance3, added2 }); + + const added3 = distance3.add(distance); + try std.testing.expectEqual(2, added3.value); + try std.testing.expectEqual(1, @TypeOf(added3).dims.get(.L)); + std.debug.print("KiloMeter {f} + {f} = {f} OK\n", .{ distance3, distance, added3 }); + + const KiloMeter_f = Quantity(f64, Dimensions.init(.{ .L = 1 }), Scales.init(.{ .L = .k })); + const distance4 = KiloMeter_f{ .value = 2 }; + const added4 = distance4.add(distance); + try std.testing.expectEqual(2.01, added4.value); + try std.testing.expectEqual(1, @TypeOf(added4).dims.get(.L)); + std.debug.print("KiloMeter_f {f} + {f} = {f} OK\n", .{ distance4, distance, added4 }); +} + +test "Sub" { + const Meter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + const KiloMeter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{ .L = .k })); + const KiloMeter_f = Quantity(f64, Dimensions.init(.{ .L = 1 }), Scales.init(.{ .L = .k })); + + const a = Meter{ .value = 500 }; + const b = Meter{ .value = 200 }; + const diff = a.sub(b); + try std.testing.expectEqual(300, diff.value); + std.debug.print("Sub: {f} - {f} = {f} OK\n", .{ a, b, diff }); + + const km = KiloMeter{ .value = 1 }; + const diff2 = a.sub(km); + std.debug.print("Sub cross-scale: {f} - {f} = {f}\n", .{ a, km, diff2 }); + + const km_f = KiloMeter_f{ .value = 2.5 }; + const m_f = Meter{ .value = 500 }; + const diff3 = km_f.sub(m_f); + try std.testing.expectApproxEqAbs(@as(f32, 2.0), diff3.value, 1e-4); + std.debug.print("Sub float cross-scale: {f} - {f} = {f} OK\n", .{ km_f, m_f, diff3 }); +} + +test "MulBy" { + const Meter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + const Second = Quantity(f32, Dimensions.init(.{ .T = 1 }), Scales.init(.{})); + + const d = Meter{ .value = 3.0 }; + const t = Second{ .value = 4.0 }; + + const area_time = d.mulBy(t); + try std.testing.expectEqual(12, area_time.value); + try std.testing.expectEqual(1, @TypeOf(area_time).dims.get(.L)); + try std.testing.expectEqual(1, @TypeOf(area_time).dims.get(.T)); + std.debug.print("MulBy: {f} * {f} = {f} OK\n", .{ d, t, area_time }); + + const d2 = Meter{ .value = 5.0 }; + const area = d.mulBy(d2); + try std.testing.expectEqual(15, area.value); + try std.testing.expectEqual(2, @TypeOf(area).dims.get(.L)); + try std.testing.expectEqual(0, @TypeOf(area).dims.get(.T)); + std.debug.print("MulBy: {f} * {f} = {f} OK\n", .{ d, d2, area }); +} + +test "MulBy with scale" { + const KiloMeter = Quantity(f32, Dimensions.init(.{ .L = 1 }), Scales.init(.{ .L = .k })); + const KiloGram = Quantity(f32, Dimensions.init(.{ .M = 1 }), Scales.init(.{ .M = .k })); + + const dist = KiloMeter{ .value = 2.0 }; + const mass = KiloGram{ .value = 3.0 }; + const prod = dist.mulBy(mass); + try std.testing.expectEqual(1, @TypeOf(prod).dims.get(.L)); + try std.testing.expectEqual(1, @TypeOf(prod).dims.get(.M)); + std.debug.print("MulBy scaled: {f} * {f} = {f} OK\n", .{ dist, mass, prod }); +} + +test "MulBy with type change" { + const Meter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{ .L = .k })); + const Second = Quantity(f32, Dimensions.init(.{ .T = 1 }), Scales.init(.{})); + const KmSec = Quantity(f32, Dimensions.init(.{ .L = 1, .T = 1 }), Scales.init(.{ .L = .k })); + + const d = Meter{ .value = 3.0 }; + const t = Second{ .value = 4.0 }; + + const area_time = d.mulBy(t).to(KmSec); + try std.testing.expectEqual(12, area_time.value); + try std.testing.expectEqual(1, @TypeOf(area_time).dims.get(.L)); + try std.testing.expectEqual(1, @TypeOf(area_time).dims.get(.T)); + std.debug.print("MulBy: {f} * {f} = {f} OK\n", .{ d, t, area_time }); +} + +test "MulBy small" { + const Meter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{ .L = .n })); + const Second = Quantity(f32, Dimensions.init(.{ .T = 1 }), Scales.init(.{})); + + const d = Meter{ .value = 3.0 }; + const t = Second{ .value = 4.0 }; + + const area_time = d.mulBy(t); + try std.testing.expectEqual(12, area_time.value); + try std.testing.expectEqual(1, @TypeOf(area_time).dims.get(.L)); + try std.testing.expectEqual(1, @TypeOf(area_time).dims.get(.T)); + std.debug.print("MulBy: {f} * {f} = {f} OK\n", .{ d, t, area_time }); +} + +test "Scale" { + const Meter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + const Second = Quantity(f32, Dimensions.init(.{ .T = 1 }), Scales.init(.{})); + + const d = Meter{ .value = 7 }; + const scaled = d.scale(3); + try std.testing.expectEqual(21, scaled.value); + try std.testing.expectEqual(1, @TypeOf(scaled).dims.get(.L)); + std.debug.print("Scale int: {f} * 3 = {f} OK\n", .{ d, scaled }); + + const t = Second{ .value = 1.5 }; + const scaled_f = t.scale(4.0); + try std.testing.expectApproxEqAbs(@as(f32, 6.0), scaled_f.value, 1e-4); + std.debug.print("Scale float: {f} * 4 = {f} OK\n", .{ t, scaled_f }); +} + +test "Chained: velocity and acceleration" { + const Meter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + const Second = Quantity(f32, Dimensions.init(.{ .T = 1 }), Scales.init(.{})); + + const dist = Meter{ .value = 100.0 }; + const t1 = Second{ .value = 5.0 }; + const velocity = dist.divBy(t1); + try std.testing.expectEqual(20, velocity.value); + try std.testing.expectEqual(1, @TypeOf(velocity).dims.get(.L)); + try std.testing.expectEqual(-1, @TypeOf(velocity).dims.get(.T)); + + const t2 = Second{ .value = 4.0 }; + const accel = velocity.divBy(t2); + try std.testing.expectEqual(5, accel.value); + try std.testing.expectEqual(1, @TypeOf(accel).dims.get(.L)); + try std.testing.expectEqual(-2, @TypeOf(accel).dims.get(.T)); + + std.debug.print("Velocity: {f}, Acceleration: {f} OK\n", .{ velocity, accel }); +} + +test "DivBy integer exact" { + const Meter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + const Second = Quantity(f32, Dimensions.init(.{ .T = 1 }), Scales.init(.{})); + + const dist = Meter{ .value = 120 }; + const time = Second{ .value = 4 }; + const vel = dist.divBy(time); + + try std.testing.expectEqual(30, vel.value); + try std.testing.expectEqual(1, @TypeOf(vel).dims.get(.L)); + try std.testing.expectEqual(-1, @TypeOf(vel).dims.get(.T)); + std.debug.print("DivBy int: {f} / {f} = {f} OK\n", .{ dist, time, vel }); +} + +test "Conversion chain: km -> m -> cm" { + const KiloMeter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{ .L = .k })); + const Meter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + const CentiMeter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{ .L = .c })); + + const km = KiloMeter{ .value = 15 }; + const m = km.to(Meter); + const cm = m.to(CentiMeter); + + try std.testing.expectEqual(15_000, m.value); + try std.testing.expectEqual(1_500_000, cm.value); + std.debug.print("Chain: {f} -> {f} -> {f} OK\n", .{ km, m, cm }); +} + +test "Conversion: hours -> minutes -> seconds" { + const Hour = Quantity(i128, Dimensions.init(.{ .T = 1 }), Scales.init(.{ .T = .hour })); + const Minute = Quantity(i128, Dimensions.init(.{ .T = 1 }), Scales.init(.{ .T = .min })); + const Second = Quantity(i128, Dimensions.init(.{ .T = 1 }), Scales.init(.{})); + + const h = Hour{ .value = 1.0 }; + const min = h.to(Minute); + const sec = min.to(Second); + + try std.testing.expectEqual(60, min.value); + try std.testing.expectEqual(3600, sec.value); + std.debug.print("Time chain: {f} -> {f} -> {f} OK\n", .{ h, min, sec }); +} + +test "Negative values" { + const Meter = Quantity(i128, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + + const a = Meter{ .value = 5 }; + const b = Meter{ .value = 20 }; + const diff = a.sub(b); + try std.testing.expectEqual(-15, diff.value); + std.debug.print("Negative sub: {f} - {f} = {f} OK\n", .{ a, b, diff }); +} + +test "Format Quantity" { + const MeterPerSecondSq = Quantity( + f32, + Dimensions.init(.{ .L = 1, .T = -2 }), + Scales.init(.{ .T = .n }), + ); + const KgMeterPerSecond = Quantity( + f32, + Dimensions.init(.{ .M = 1, .L = 1, .T = -1 }), + Scales.init(.{ .M = .k }), + ); + + const accel = MeterPerSecondSq{ .value = 9.81 }; + const momentum = KgMeterPerSecond{ .value = 42.0 }; + + std.debug.print("Acceleration: {f}\n", .{accel}); + std.debug.print("Momentum: {f}\n", .{momentum}); +} + +test "Format Vector3" { + const MeterPerSecondSq = Quantity( + f32, + Dimensions.init(.{ .L = 1, .T = -2 }), + Scales.init(.{ .T = .n }), + ); + const KgMeterPerSecond = Quantity( + f32, + Dimensions.init(.{ .M = 1, .L = 1, .T = -1 }), + Scales.init(.{ .M = .k }), + ); + + const accel = MeterPerSecondSq.Vec3.initDefault(9.81); + const momentum = KgMeterPerSecond.Vec3{ .x = 43, .y = 0, .z = 11 }; + + std.debug.print("Acceleration: {f}\n", .{accel}); + std.debug.print("Momentum: {f}\n", .{momentum}); +} + +test "Vec3 Init and Basic Arithmetic" { + const Meter = Quantity(i32, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + const Vec3M = Meter.Vec3; + + // Test zero, one, initDefault + const v_zero = Vec3M.zero; + try std.testing.expectEqual(0, v_zero.x); + + const v_one = Vec3M.one; + try std.testing.expectEqual(1, v_one.x); + + const v_def = Vec3M.initDefault(5); + try std.testing.expectEqual(5, v_def.x); + try std.testing.expectEqual(5, v_def.y); + try std.testing.expectEqual(5, v_def.z); + + // Test add and sub + const v1 = Vec3M{ .x = 10, .y = 20, .z = 30 }; + const v2 = Vec3M{ .x = 2, .y = 4, .z = 6 }; + + const added = v1.add(v2); + try std.testing.expectEqual(12, added.x); + try std.testing.expectEqual(24, added.y); + try std.testing.expectEqual(36, added.z); + + const subbed = v1.sub(v2); + try std.testing.expectEqual(8, subbed.x); + try std.testing.expectEqual(16, subbed.y); + try std.testing.expectEqual(24, subbed.z); + + // Test negate + const neg = v1.negate(); + try std.testing.expectEqual(-10, neg.x); + try std.testing.expectEqual(-20, neg.y); + try std.testing.expectEqual(-30, neg.z); +} + +test "Vec3 Kinematics (Scalar Mul/Div)" { + const Meter = Quantity(i32, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + const Second = Quantity(i32, Dimensions.init(.{ .T = 1 }), Scales.init(.{})); + const Vec3M = Meter.Vec3; + + const pos = Vec3M{ .x = 100, .y = 200, .z = 300 }; + const time = Second{ .value = 10 }; + + // Vector divided by scalar Quantity (Velocity = Position / Time) + const vel = pos.divByScalar(time); + try std.testing.expectEqual(10, vel.x); + try std.testing.expectEqual(20, vel.y); + try std.testing.expectEqual(30, vel.z); + try std.testing.expectEqual(1, @TypeOf(vel).dims.get(.L)); + try std.testing.expectEqual(-1, @TypeOf(vel).dims.get(.T)); + + // Vector multiplied by scalar Quantity (Position = Velocity * Time) + const new_pos = vel.mulByScalar(time); + try std.testing.expectEqual(100, new_pos.x); + try std.testing.expectEqual(200, new_pos.y); + try std.testing.expectEqual(300, new_pos.z); + try std.testing.expectEqual(1, @TypeOf(new_pos).dims.get(.L)); + try std.testing.expectEqual(0, @TypeOf(new_pos).dims.get(.T)); +} + +test "Vec3 Element-wise Math and Scaling" { + const Meter = Quantity(i32, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + const Vec3M = Meter.Vec3; + + const v1 = Vec3M{ .x = 10, .y = 20, .z = 30 }; + const v2 = Vec3M{ .x = 2, .y = 5, .z = 10 }; + + // Element-wise division + const div = v1.divBy(v2); + try std.testing.expectEqual(5, div.x); + try std.testing.expectEqual(4, div.y); + try std.testing.expectEqual(3, div.z); + try std.testing.expectEqual(0, @TypeOf(div).dims.get(.L)); // M / M = Dimensionless + + // Scale by primitive + const scaled = v1.scale(2); + try std.testing.expectEqual(20, scaled.x); + try std.testing.expectEqual(40, scaled.y); + try std.testing.expectEqual(60, scaled.z); +} + +test "Vec3 Conversions" { + const KiloMeter = Quantity(i32, Dimensions.init(.{ .L = 1 }), Scales.init(.{ .L = .k })); + const Meter = Quantity(i32, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + + const v_km = KiloMeter.Vec3{ .x = 1, .y = 2, .z = 3 }; + const v_m = v_km.to(Meter); + + try std.testing.expectEqual(1000, v_m.x); + try std.testing.expectEqual(2000, v_m.y); + try std.testing.expectEqual(3000, v_m.z); + + // Type checking the result + try std.testing.expectEqual(1, @TypeOf(v_m).dims.get(.L)); + try std.testing.expectEqual(UnitScale.none, @TypeOf(v_m).scales.get(.L)); +} + +test "Vec3 Length" { + const MeterInt = Quantity(i32, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + const MeterFloat = Quantity(f32, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + + // Integer length (using your custom isqrt) + // 3-4-5 triangle on XY plane + const v_int = MeterInt.Vec3{ .x = 3, .y = 4, .z = 0 }; + try std.testing.expectEqual(25, v_int.lengthSqr()); + try std.testing.expectEqual(5, v_int.length()); + + // Float length + const v_float = MeterFloat.Vec3{ .x = 3.0, .y = 4.0, .z = 0.0 }; + try std.testing.expectApproxEqAbs(@as(f32, 25.0), v_float.lengthSqr(), 1e-4); + try std.testing.expectApproxEqAbs(@as(f32, 5.0), v_float.length(), 1e-4); +} + +test "Comprehensive Benchmark: All Ops × All Types" { + const Io = std.Io; + const ITERS: usize = 100_000; + const SAMPLES: usize = 10; // Number of samples for stats + + var gsink: f64 = 0; + const io = std.testing.io; + + // Standard Zig 0.16 timestamp retrieval + const getTime = struct { + fn f(i: Io) Io.Timestamp { + return Io.Clock.awake.now(i); + } + }.f; + + const fold = struct { + fn f(comptime TT: type, s: *f64, v: TT) void { + s.* += if (comptime @typeInfo(TT) == .float) + @as(f64, @floatCast(v)) + else + @as(f64, @floatFromInt(v)); + } + }.f; + + const getVal = struct { + fn f(comptime TT: type, i: usize, comptime mask: u7) TT { + const v: u8 = @as(u8, @truncate(i & @as(usize, mask))) + 1; + return if (comptime @typeInfo(TT) == .float) @floatFromInt(v) else @intCast(v); + } + }.f; + + const Stats = struct { + median: f64, + delta: f64, + ops_per_sec: f64, + }; + + const computeStats = struct { + fn f(samples: []f64, iters: usize) Stats { + std.mem.sort(f64, samples, {}, std.sort.asc(f64)); + const mid = samples.len / 2; + const median_ns = if (samples.len % 2 == 0) (samples[mid - 1] + samples[mid]) / 2.0 else samples[mid]; + + const low = samples[0]; + const high = samples[samples.len - 1]; + const delta_ns = (high - low) / 2.0; + + const ns_per_op = median_ns / @as(f64, @floatFromInt(iters)); + return .{ + .median = ns_per_op, + .delta = (delta_ns / @as(f64, @floatFromInt(iters))), + .ops_per_sec = 1_000_000_000.0 / ns_per_op, + }; + } + }.f; + + std.debug.print( + \\ + \\ Quantity benchmark — {d} iterations, {d} samples/cell + \\ + \\┌───────────────────┬──────┬─────────────────────┬─────────────────────┐ + \\│ Operation │ Type │ ns / op (± delta) │ Throughput (ops/s) │ + \\├───────────────────┼──────┼─────────────────────┼─────────────────────┤ + \\ + , .{ ITERS, SAMPLES }); + + const Types = .{ i16, i32, i64, i128, f32, f64 }; + const TNames = .{ "i16", "i32", "i64", "i128", "f32", "f64" }; + const Ops = .{ "add", "sub", "mulBy", "divBy", "scale", "to" }; + + var results_matrix: [Ops.len][Types.len]f64 = undefined; + + comptime var tidx: usize = 0; + inline for (Types, TNames) |T, tname| { + const M = Quantity(T, Dimensions.init(.{ .L = 1 }), Scales.init(.{})); + const KM = Quantity(T, Dimensions.init(.{ .L = 1 }), Scales.init(.{ .L = .k })); + const S = Quantity(T, Dimensions.init(.{ .T = 1 }), Scales.init(.{})); + + inline for (Ops, 0..) |op_name, oidx| { + var samples: [SAMPLES]f64 = undefined; + + for (0..SAMPLES) |s_idx| { + var sink: T = 0; + const t_start = getTime(io); + + for (0..ITERS) |i| { + const r = if (comptime std.mem.eql(u8, op_name, "add")) + (M{ .value = getVal(T, i, 63) }).add(M{ .value = getVal(T, i +% 7, 63) }) + else if (comptime std.mem.eql(u8, op_name, "sub")) + (M{ .value = getVal(T, i +% 10, 63) }).sub(M{ .value = getVal(T, i, 63) }) + else if (comptime std.mem.eql(u8, op_name, "mulBy")) + (M{ .value = getVal(T, i, 63) }).mulBy(M{ .value = getVal(T, i +% 1, 63) }) + else if (comptime std.mem.eql(u8, op_name, "divBy")) + (M{ .value = getVal(T, i +% 10, 63) }).divBy(S{ .value = getVal(T, i, 63) }) + else if (comptime std.mem.eql(u8, op_name, "scale")) + (M{ .value = getVal(T, i, 63) }).scale(getVal(T, i +% 2, 63)) + else + (KM{ .value = getVal(T, i, 15) }).to(M); + + if (comptime @typeInfo(T) == .float) sink += r.value else sink ^= r.value; + } + + const t_end = getTime(io); + samples[s_idx] = @as(f64, @floatFromInt(t_start.durationTo(t_end).toNanoseconds())); + fold(T, &gsink, sink); + } + + const stats = computeStats(&samples, ITERS); + results_matrix[oidx][tidx] = stats.median; + + std.debug.print("│ {s:<17} │ {s:<4} │ {d:>8.2} ns ±{d:<6.2} │ {d:>19.0} │\n", .{ op_name, tname, stats.median, stats.delta, stats.ops_per_sec }); + } + + if (comptime tidx < Types.len - 1) { + std.debug.print("├───────────────────┼──────┼─────────────────────┼─────────────────────┤\n", .{}); + } + tidx += 1; + } + + // Median Summary Table + std.debug.print("└───────────────────┴──────┴─────────────────────┴─────────────────────┘\n\n", .{}); + std.debug.print("Median Summary (ns/op):\n", .{}); + std.debug.print("Operation │ i16 │ i32 │ i64 │ i128 │ f32 │ f64 \n", .{}); + std.debug.print("───────────────┼───────┼───────┼───────┼───────┼───────┼───────\n", .{}); + + inline for (Ops, 0..) |op_name, oidx| { + std.debug.print("{s:<14} │", .{op_name}); + var i: usize = 0; + while (i < Types.len) : (i += 1) { + std.debug.print("{d:>6.1} │", .{results_matrix[oidx][i]}); + } + std.debug.print("\n", .{}); + } + + std.debug.print("\nAnti-optimisation sink: {d:.4}\n", .{gsink}); + try std.testing.expect(gsink != 0); +}