Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
Replace the old touchmark/PNG identity with the supplied lockup and monogram, segmented role-aware navigation, Live/Draft listing filters with generation-based cache invalidation, and admin Projects covering immutable file revisions, listing live pointers, and exact-decimal filament/packaging/shipping costing. Additive schema only; no Etsy publication. Isolated clone tests: 301 tests, 1162 assertions.
154 lines
4.8 KiB
PHP
154 lines
4.8 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
use InvalidArgumentException;
|
|
use OverflowException;
|
|
|
|
/**
|
|
* Exact decimal fixed-point arithmetic using bounded native integers.
|
|
*
|
|
* Neither the bcmath nor gmp extensions are available in this application's
|
|
* runtime, so costing math is done by scaling decimal strings into integers
|
|
* (never floats) and checking every operation for overflow against the
|
|
* platform's native integer range.
|
|
*/
|
|
class FixedPoint
|
|
{
|
|
private const MAX_INT = PHP_INT_MAX;
|
|
|
|
private const MIN_INT = PHP_INT_MIN;
|
|
|
|
/**
|
|
* Parse a decimal string (or int/numeric string) into an integer scaled by 10^$scale.
|
|
*/
|
|
public static function toScaled(string $decimal, int $scale, ?int $maxAbs = null, bool $allowNegative = true): int
|
|
{
|
|
$decimal = trim($decimal);
|
|
if ($decimal === '' || ! preg_match('/^-?\d+(\.\d+)?$/', $decimal)) {
|
|
throw new InvalidArgumentException("Invalid decimal value: {$decimal}");
|
|
}
|
|
|
|
$negative = $decimal[0] === '-';
|
|
if ($negative) {
|
|
if (! $allowNegative) {
|
|
throw new InvalidArgumentException("Negative values are not allowed: {$decimal}");
|
|
}
|
|
$decimal = substr($decimal, 1);
|
|
}
|
|
|
|
[$whole, $fraction] = array_pad(explode('.', $decimal, 2), 2, '');
|
|
if (strlen($fraction) > $scale) {
|
|
throw new InvalidArgumentException("Value has more than {$scale} decimal places: {$decimal}");
|
|
}
|
|
$fraction = str_pad($fraction, $scale, '0');
|
|
$whole = $whole === '' ? '0' : $whole;
|
|
|
|
$combined = ltrim($whole.$fraction, '0');
|
|
$combined = $combined === '' ? '0' : $combined;
|
|
|
|
if (strlen($combined) > 18) {
|
|
throw new InvalidArgumentException("Value out of bounds: {$decimal}");
|
|
}
|
|
|
|
$value = (int) $combined;
|
|
if ($value < 0 || $combined !== (string) $value) {
|
|
throw new InvalidArgumentException("Value out of bounds: {$decimal}");
|
|
}
|
|
|
|
if ($maxAbs !== null && $value > $maxAbs * (10 ** $scale)) {
|
|
throw new InvalidArgumentException("Value exceeds the allowed maximum: {$decimal}");
|
|
}
|
|
|
|
return $negative ? -$value : $value;
|
|
}
|
|
|
|
public static function add(int $a, int $b): int
|
|
{
|
|
if (($b > 0 && $a > self::MAX_INT - $b) || ($b < 0 && $a < self::MIN_INT - $b)) {
|
|
throw new OverflowException('Fixed-point addition overflowed.');
|
|
}
|
|
|
|
return $a + $b;
|
|
}
|
|
|
|
public static function subtract(int $a, int $b): int
|
|
{
|
|
return self::add($a, -$b);
|
|
}
|
|
|
|
public static function multiply(int $a, int $b): int
|
|
{
|
|
if ($a === 0 || $b === 0) {
|
|
return 0;
|
|
}
|
|
$result = $a * $b;
|
|
if (! is_int($result) || intdiv($result, $b) !== $a) {
|
|
throw new OverflowException('Fixed-point multiplication overflowed.');
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Divide a scaled value by a plain positive integer (e.g. batch units), rounding half away from zero.
|
|
*/
|
|
public static function divideByInt(int $value, int $divisor): int
|
|
{
|
|
if ($divisor === 0) {
|
|
throw new InvalidArgumentException('Cannot divide by zero.');
|
|
}
|
|
|
|
return self::roundDivide($value, $divisor);
|
|
}
|
|
|
|
/**
|
|
* Rescale a value from one decimal scale to another, rounding half away from zero.
|
|
*/
|
|
public static function rescale(int $value, int $fromScale, int $toScale): int
|
|
{
|
|
if ($toScale === $fromScale) {
|
|
return $value;
|
|
}
|
|
if ($toScale > $fromScale) {
|
|
$factor = 10 ** ($toScale - $fromScale);
|
|
$result = $value * $factor;
|
|
if ($factor !== 0 && intdiv($result, $factor) !== $value) {
|
|
throw new OverflowException('Fixed-point rescale overflowed.');
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
return self::roundDivide($value, 10 ** ($fromScale - $toScale));
|
|
}
|
|
|
|
public static function format(int $scaled, int $scale): string
|
|
{
|
|
$negative = $scaled < 0;
|
|
$abs = (string) abs($scaled);
|
|
$abs = str_pad($abs, $scale + 1, '0', STR_PAD_LEFT);
|
|
$whole = substr($abs, 0, strlen($abs) - $scale);
|
|
$fraction = $scale > 0 ? substr($abs, strlen($abs) - $scale) : '';
|
|
|
|
$formatted = $scale > 0 ? "{$whole}.{$fraction}" : $whole;
|
|
|
|
return ($negative && $scaled !== 0 ? '-' : '').$formatted;
|
|
}
|
|
|
|
private static function roundDivide(int $value, int $divisor): int
|
|
{
|
|
$sign = ($value < 0) !== ($divisor < 0) ? -1 : 1;
|
|
$numerator = abs($value);
|
|
$denominator = abs($divisor);
|
|
$quotient = intdiv($numerator, $denominator);
|
|
$remainder = $numerator % $denominator;
|
|
|
|
if ($remainder * 2 >= $denominator) {
|
|
$quotient++;
|
|
}
|
|
|
|
return $sign * $quotient;
|
|
}
|
|
}
|