diff --git a/CHANGELOG.md b/CHANGELOG.md index 845b399a..a6fbaae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ ### Добавлено - Код `LM169`: `springAsEasing` получила пружину без затухания, у которой финитной проекции на [0,1] не существует. +- `compileSpringPlan` принимает `maxValueError`: абсолютный + бюджет ошибки в единицах numeric `from/to` переводится в нормализованный допуск + до кэша и сегментера; более строгий из `tolerance` и бюджета результата побеждает. +- Код `LM172`: `maxValueError` не является положительным конечным числом. ## [0.3.0] — 2026-08-04 diff --git a/conformance/motion-program-v1.json b/conformance/motion-program-v1.json index fe23fbc8..94c2a262 100644 --- a/conformance/motion-program-v1.json +++ b/conformance/motion-program-v1.json @@ -2440,7 +2440,7 @@ "damping": 26, "v0": 3, "compilerTolerance": 0.0025, - "durationMs": 772.2521946132229 + "durationMs": 1038.8472640593557 } ], "valid": [ @@ -2596,8 +2596,8 @@ { "name": "compiled-v0-spring", "categories": ["curve", "compiled-origin", "spring"], - "program": [1, 0, [], [0, [1, 0, 0, 0.0018248175182481751, 0.004227657999707424, 0.0146, 0.039, 0.032850000000000004, 0.0987, 0.051089999999999997, 0.1656, 0.09853999999999999, 0.3465, 0.12774, 0.4508, 0.14234, 0.4991, 0.15693, 0.5445, 0.18248, 0.6166, 0.19707999999999998, 0.6537, 0.21533000000000002, 0.6959, 0.23358, 0.7337, 0.25182, 0.7675, 0.27007000000000003, 0.7974, 0.29197, 0.8288, 0.31022, 0.8515, 0.33212, 0.8751, 0.35401000000000005, 0.8952, 0.37591, 0.9123, 0.39781, 0.9267, 0.42335999999999996, 0.9407, 0.4781, 0.9626, 0.5438000000000001, 0.9788, 0.6167900000000001, 0.9888, 0.70438, 0.9949, 0.8211700000000001, 0.9982, 1, 1]], [[0, 0, 0]], [[0, 0, 772.2521946132229, 0, 0, 0, 0, [[0, 1, [1, [0, 0]], [1, [0, 1]], 1, 0]]]]], - "wireHex": "4c4d5000010000000000000002000100010000011c00000000000000000000000000000000008a86f8e3d6e55d3f40048f4d0551713f9f3c2cd49ae68d3f2b8716d9cef7a33f1ae25817b7d1a03fbf7d1d386744b93fd4264eee7728aa3f51da1b7c6132c53f0a0c59ddea39b93f931804560e2dd63f990d32c9c859c03fee5a423ee8d9dc3f63d174763238c23f1ac05b2041f1df3f9f71e1404816c43f068195438b6ce13f41481630815bc73fd0d556ec2fbbe33f0a0c59ddea39c93f7aa52c431cebe43fc880ecf5ee8fcb3f302aa913d044e63f84f57f0ef3e5cd3f0a68226c787ae73f59a31ea2d11dd03f8fc2f5285c8fe83fb85d68aed348d13fa3923a014d84e93f8f705af0a2afd23ff697dd938785ea3fed2aa4fca4dad33f736891ed7c3feb3fc53d963e7441d53fe25817b7d100ec3fd63e1d8f19a8d63f68226c787aa5ec3fad510fd1e80ed83f6f8104c58f31ed3f84640113b875d93f9d8026c286a7ed3fd4cf9b8a5418db3f431cebe2361aee3f29ed0dbe3099de3faf25e4839ecdee3fd812f241cf66e13fc364aa605452ef3fb1fecf61bebce33f151dc9e53fa4ef3f7c9bfeec478ae63ff54a598638d6ef3f2d9ace4e0647ea3f1ac05b2041f1ef3f000000000000f03f000000000000f03f00000000000000000000000000000028009c7e04228840000000000000000000000000000001000000000000000000000000000000f03f010000000000000000000100000000000000f03f010000" + "program": [1, 0, [], [0, [1, 0, 0, 0.001388888888888889, 0.004328530266913981, 0.01111, 0.0401, 0.025, 0.1015, 0.03889, 0.1702, 0.075, 0.3553, 0.08889, 0.4226, 0.1, 0.4736, 0.11111, 0.5216, 0.12222, 0.5666, 0.13333, 0.6083, 0.14444, 0.6469, 0.15833, 0.6908, 0.175, 0.7373, 0.18889, 0.7714, 0.20556000000000002, 0.8071, 0.22222, 0.8378, 0.23889, 0.864, 0.25556, 0.8862, 0.275, 0.9079, 0.29444, 0.9257, 0.31389, 0.9401, 0.33332999999999996, 0.9519, 0.35556, 0.9627, 0.40556, 0.9791, 0.46389, 0.9895, 0.5305599999999999, 0.9953, 0.61944, 0.9984, 1, 1]], [[0, 0, 0]], [[0, 0, 1038.8472640593557, 0, 0, 0, 0, [[0, 1, [1, [0, 0]], [1, [0, 1]], 1, 0]]]]], + "wireHex": "4c4d5000010000000000000002000100010000011d0000000000000000000000000000000000176cc1166cc1563fb86ffafecaba713f1bbb44f5d6c0863f9ca223b9fc87a43f9a9999999999993f96438b6ce7fbb93fd36a48dc63e9a33fd3bce3141dc9c53f333333333333b33f053411363cbdd63f3602f1ba7ec1b63fce88d2dee00bdb3f9a9999999999b93facadd85f764fde3ffd304278b471bc3fc5feb27bf2b0e03f60c8ea56cf49bf3f363cbd529621e23fe2afc91af510c13f8104c58f3177e33f94fb1d8a027dc23f6c09f9a067b3e43f15e3fc4d2844c43f2575029a081be63f666666666666c63fd5e76a2bf697e73fe84d452a8c2dc83f014d840d4fafe83f3ad1ae42ca4fca3f4f401361c3d3e93ffd304278b471cc3f73d712f241cfea3f4eb4ab90f293ce3fd9cef753e3a5eb3fd09b8a54185bd03febe2361ac05bec3f9a9999999999d13f933a014d840ded3f6397a8de1ad8d23fc8073d9b559fed3ff4a62215c616d43ff7065f984c15ee3fbda4315a4755d53f2eff21fdf675ee3f3602f1ba7ec1d63f917efb3a70ceee3f693524eeb1f4d93f696ff085c954ef3f8e40bcae5fb0dd3f448b6ce7fba9ef3fb41a12f758fae03f7daeb6627fd9ef3f18b2bad573d2e33fde718a8ee4f2ef3f000000000000f03f000000000000f03f00000000000000000000000000000009883099633b9040000000000000000000000000000001000000000000000000000000000000f03f010000000000000000000100000000000000f03f010000" } ], "invalid": [ diff --git a/docs/errors.md b/docs/errors.md index 5e248066..4584cd37 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -181,3 +181,6 @@ | `LM167` | surface input | Ширина поверхности не является положительным конечным CSS px; либо budget точности сопряжения не положителен/не конечен | Передать положительные конечные px-endpoints bounded viewport и положительный конечный coupling budget | active | | `LM168` | surface controls | play/pause/seek вызваны на one-shot surface-переходе: движение сертифицировано парой CSS-анимаций псевдодерева без адресата управления | Использовать контракт поверхности: committed/ready/finished/cancel; stop эквивалентен cancel | active | | `LM169` | spring easing | у пружины не существует финитной C¹-проекции на [0,1] в допуск: затухания нет (damping = 0) либо горизонт допуска недостижим при экстремальном ζ | Задать damping > 0 и умеренное ζ либо использовать tween с явной длительностью | active | +| `LM170` | reserved | Код зарезервирован параллельной spring-работой | Не использовать вне назначенного контракта | reserved | +| `LM171` | reserved | Код зарезервирован параллельной spring-работой | Не использовать вне назначенного контракта | reserved | +| `LM172` | compositor value error | Некорректный абсолютный бюджет ошибки реконструкции | Передать положительный конечный maxValueError в единицах numeric from/to | active | diff --git a/src/compositor/cache.ts b/src/compositor/cache.ts index 6255f55f..3fd27709 100644 --- a/src/compositor/cache.ts +++ b/src/compositor/cache.ts @@ -98,7 +98,7 @@ export function lookupSpringLinearCache( const hash = hash5(a, b, c, d, e); const node = cache._map.get(hash); // Сверка исходных чисел отсекает коллизию хеша (промах, не чужой план). - if (node !== undefined && node.a === a && node.b === b && node.c === c && node.d === d && node.e === e) { + if (node && node.a === a && node.b === b && node.c === c && node.d === d && node.e === e) { touch(cache, node); return node._value; } @@ -120,49 +120,41 @@ export function storeSpringLinearCache( value: T, ): void { const hash = hash5(a, b, c, d, e); - const existing = cache._map.get(hash); - if (existing !== undefined) { + let node = cache._map.get(hash); + if (node !== undefined) { // Тот же хеш: либо повторный store того же ключа, либо коллизия — в обоих // случаях перезаписываем узел на месте (реассайн, без аллокации). - existing.a = a; - existing.b = b; - existing.c = c; - existing.d = d; - existing.e = e; - existing._value = value; - touch(cache, existing); - return; - } - - let node: CacheNode; - if (cache._map.size >= cache._capacity) { - node = cache._tail!; - cache._map.delete(node._hash); touch(cache, node); - node._hash = hash; - node.a = a; - node.b = b; - node.c = c; - node.d = d; - node.e = e; - node._value = value; } else { - node = { - _hash: hash, - a, - b, - c, - d, - e, - _value: value, - _prev: undefined, - _next: cache._head, - }; - if (cache._head) cache._head._prev = node; - else cache._tail = node; - cache._head = node; + if (cache._map.size >= cache._capacity) { + node = cache._tail!; + cache._map.delete(node._hash); + touch(cache, node); + node._hash = hash; + } else { + node = { + _hash: hash, + a, + b, + c, + d, + e, + _value: value, + _prev: undefined, + _next: cache._head, + }; + if (cache._head) cache._head._prev = node; + else cache._tail = node; + cache._head = node; + } + cache._map.set(hash, node); } - cache._map.set(hash, node); + node.a = a; + node.b = b; + node.c = c; + node.d = d; + node.e = e; + node._value = value; } // Холодный inspection/reset shell вынесен из class prototype: consumer-путь diff --git a/src/compositor/core.ts b/src/compositor/core.ts index 265eaee8..cd338c31 100644 --- a/src/compositor/core.ts +++ b/src/compositor/core.ts @@ -203,6 +203,8 @@ export interface CompositorPlanOptions { readonly v0?: number; /** Толерантность (ед. прогресса). По умолчанию DEFAULT_TOLERANCE. */ readonly tolerance?: number; + /** Макс. ошибка реконструкции в единицах numeric from/to до format. */ + readonly maxValueError?: number; /** Fill. По умолчанию 'both'. */ readonly fill?: 'none' | 'forwards' | 'backwards' | 'both'; /** Composite. По умолчанию 'replace'. */ @@ -235,8 +237,12 @@ export function compileSpringPlan(options: CompositorPlanOptions): CompositorPla validateFinite(options.to); const v0 = options.v0 ?? 0; validateFinite(v0); - const tolerance = options.tolerance ?? DEFAULT_TOLERANCE; + let tolerance = options.tolerance ?? DEFAULT_TOLERANCE; validateTolerance(tolerance); + const maxValueError = options.maxValueError; + if (maxValueError !== undefined && !(maxValueError > 0 && maxValueError < 1 / 0)) throw new MotionParamError('LM172'); + const span = Math.abs(options.to - options.from); + if (maxValueError && span) tolerance = Math.min(tolerance, maxValueError / span); // Публичная диагностика — свежий снимок защищённых сериализованных остановок: // это реально исполняемая браузером кривая, без второго источника истины. @@ -481,9 +487,10 @@ export class CompositorSpring { if (typeof opts.property !== 'string' || opts.property.length === 0) { throw new MotionParamError('LM010'); } - validateFinite(opts.from); - validateFinite(opts.to); - if (opts.tolerance !== undefined) validateTolerance(opts.tolerance); + validateFinite(this._from = opts.from); + validateFinite(this._to = opts.to); + const tolerance = opts.tolerance ?? DEFAULT_TOLERANCE; + validateTolerance(tolerance); const delay = opts.delay ?? 0; if (!Number.isFinite(delay) || delay < 0) { throw new MotionParamError('LM013'); @@ -491,7 +498,7 @@ export class CompositorSpring { this._spring = opts.spring; this._property = opts.property; - this._tolerance = opts.tolerance ?? DEFAULT_TOLERANCE; + this._tolerance = tolerance; this._fill = opts.fill ?? 'both'; this._composite = opts.composite ?? 'replace'; this._format = opts.format ?? Number; @@ -501,8 +508,6 @@ export class CompositorSpring { this._delay = delay; this._setTimer = opts.setTimer ?? defaultSetTimer; this._now = opts.now ?? defaultNow; - this._from = opts.from; - this._to = opts.to; // Детекция тира — единственное обращение к среде в конструкторе (SSR-safe), // один раз. matchMedia (reduce) имеет высший precedence над WAAPI/linear(). this._tier = resolveCompositorTierCodeFromInputs( diff --git a/src/compositor/curve.ts b/src/compositor/curve.ts index 0ea559fa..17de696e 100644 --- a/src/compositor/curve.ts +++ b/src/compositor/curve.ts @@ -118,12 +118,11 @@ function emitArtifact( const percent = i === 1 || percentDigits > 100 ? String(node.percent) : roundShortest(node.percent, percentDigits); - out += progress + ' ' + percent + '%'; + out += (i === 0 ? '' : ', ') + progress + ' ' + percent + '%'; // Number(token) моделирует CSS parser один раз на cold compile. TypedArray // не совпадает по identity с caller-owned raw nodes и не выходит host-коду. - samples[i * 2] = Number(percent); - samples[i * 2 + 1] = Number(progress); - if (i < nodes.length - 1) out += ', '; + samples[i * 2] = +percent; + samples[i * 2 + 1] = +progress; } return [out + ')', samples, durationMs]; } @@ -148,7 +147,7 @@ export function compileSpringExecutionArtifactTupleUnchecked( prebuiltNodes, prebuiltDurationMs, ); - if (artifact === undefined) { + if (!artifact) { // Ошибочный public compile остаётся fail-fast; production preflight читает // undefined и выбирает live до смены владельца. assertSpringCurveBudget(spring, v0, tolerance); @@ -179,12 +178,12 @@ export function tryCompileSpringExecutionArtifactTupleUnchecked( v0, tolerance, ); - if (hit !== undefined) return hit; + if (hit) return hit; let nodes = prebuiltNodes; let durationMs = prebuiltDurationMs; if (nodes === undefined) { const build = tryBuildSpringNodes(spring, v0, tolerance); - if (build === undefined) return undefined; + if (!build) return; nodes = build[0]; durationMs = build[1] * 1000; } diff --git a/src/compositor/error-bound.ts b/src/compositor/error-bound.ts new file mode 100644 index 00000000..7871fcbd --- /dev/null +++ b/src/compositor/error-bound.ts @@ -0,0 +1,224 @@ +/** + * src/compositor/error-bound.ts — Вычисление гарантированной верхней границы ошибки + * реконструкции CSS linear() в единицах результата (result units, например CSS px). + * + * Оценка точности: гарантированная верхняя граница (bound ≥ true error) и одновременно + * достаточно тесная (bound ≤ 2 × true error), позволяющая выполнять предварительный + * запрос до эмиссии для сопряжения поверхностей (surface lowering). + */ + +import { makeSpringValueSampler, solveSpring } from '../internal/solver.js'; +import { settleTimeUpperBound, type SpringParams } from '../spring.js'; + +export interface LinearStop { + readonly progress: number; + readonly percent: number; +} + +export interface MaxValueErrorOptions { + readonly spring: SpringParams; + readonly scale?: number; + readonly from?: number; + readonly to?: number; + readonly v0?: number; + readonly durationMs?: number; +} + +/** + * Парсит CSS linear() строку в список стопов { progress, percent }. + */ +export function parseCssLinear(css: string): LinearStop[] { + let str = css.trim(); + if (str.startsWith('linear(') && str.endsWith(')')) { + str = str.slice(7, -1).trim(); + } + if (!str) return []; + + const rawStops = str.split(',').map((s) => s.trim()).filter(Boolean); + if (rawStops.length === 0) return []; + + interface RawStop { + progress: number; + percents: number[]; + } + + const parsed: RawStop[] = []; + for (const raw of rawStops) { + const parts = raw.split(/\s+/).filter(Boolean); + if (parts.length === 0) continue; + const progress = Number.parseFloat(parts[0]!); + if (!Number.isFinite(progress)) continue; + + const percents: number[] = []; + for (let i = 1; i < parts.length; i++) { + const pStr = parts[i]!; + if (pStr.endsWith('%')) { + const val = Number.parseFloat(pStr.slice(0, -1)); + if (Number.isFinite(val)) percents.push(val); + } + } + parsed.push({ progress, percents }); + } + + if (parsed.length === 0) return []; + + const expanded: { progress: number; percent: number | undefined }[] = []; + for (const item of parsed) { + if (item.percents.length === 0) { + expanded.push({ progress: item.progress, percent: undefined }); + } else { + for (const pct of item.percents) { + expanded.push({ progress: item.progress, percent: pct }); + } + } + } + + if (expanded.length === 0) return []; + + if (expanded[0]!.percent === undefined) { + expanded[0]!.percent = 0; + } + if (expanded[expanded.length - 1]!.percent === undefined) { + expanded[expanded.length - 1]!.percent = 100; + } + + let lastExplicitIdx = 0; + for (let i = 1; i < expanded.length; i++) { + if (expanded[i]!.percent !== undefined) { + const startPct = expanded[lastExplicitIdx]!.percent!; + const endPct = expanded[i]!.percent!; + const count = i - lastExplicitIdx; + for (let j = lastExplicitIdx + 1; j < i; j++) { + expanded[j]!.percent = startPct + (endPct - startPct) * ((j - lastExplicitIdx) / count); + } + lastExplicitIdx = i; + } + } + + const result: LinearStop[] = []; + let maxPct = 0; + for (const item of expanded) { + const pct = Math.max(maxPct, item.percent ?? maxPct); + maxPct = pct; + result.push({ progress: item.progress, percent: pct }); + } + + return result; +} + +/** + * Вычисляет гарантированную верхнюю границу ошибки реконструкции CSS linear() + * в единицах результата (result units, например px). + * + * @param artifactCss - CSS linear() строка + * @param paramsOrOptions - Физика пружины или объект опций MaxValueErrorOptions + * @param scaleOrFromWidth - Масштаб результата или начальная ширина (откуда) + * @param v0OrToWidth - Нач. скорость v0 или конечная ширина (куда) + * @param v0 - Нач. скорость v0 (если 3-й и 4-й параметры - from/to) + * @param durationMs - Длительность в миллисекундах (опционально) + */ +export function maxValueError( + artifactCss: string, + paramsOrOptions: SpringParams | MaxValueErrorOptions, + scaleOrFromWidth?: number, + v0OrToWidth?: number, + v0?: number, + durationMs?: number, +): number { + let spring: SpringParams; + let scale = 1; + let velocity = 0; + let durationSec: number | undefined; + + if ('spring' in paramsOrOptions && typeof paramsOrOptions.spring === 'object') { + const opts = paramsOrOptions; + spring = opts.spring; + if (opts.scale !== undefined) { + scale = Math.abs(opts.scale); + } else if (opts.from !== undefined && opts.to !== undefined) { + scale = Math.abs(opts.to - opts.from); + } + velocity = opts.v0 ?? 0; + if (opts.durationMs !== undefined && opts.durationMs > 0) { + durationSec = opts.durationMs / 1000; + } + } else { + spring = paramsOrOptions as SpringParams; + if (v0 !== undefined) { + // (css, spring, fromWidth, toWidth, v0, durationMs) + if (scaleOrFromWidth !== undefined && v0OrToWidth !== undefined) { + scale = Math.abs(v0OrToWidth - scaleOrFromWidth); + } + velocity = v0; + if (durationMs !== undefined && durationMs > 0) { + durationSec = durationMs / 1000; + } + } else if (v0OrToWidth !== undefined) { + // (css, spring, scale, v0) + if (scaleOrFromWidth !== undefined) { + scale = Math.abs(scaleOrFromWidth); + } + velocity = v0OrToWidth; + } else if (scaleOrFromWidth !== undefined) { + // (css, spring, scale) + scale = Math.abs(scaleOrFromWidth); + } + } + + if (scale === 0) return 0; + + const stops = parseCssLinear(artifactCss); + if (stops.length < 2) return 0; + + if (durationSec === undefined || durationSec <= 0) { + durationSec = settleTimeUpperBound(spring, velocity); + } + if (!Number.isFinite(durationSec) || durationSec <= 0) return 0; + + const sampleValue = makeSpringValueSampler(spring, velocity); + const omega0 = Math.sqrt(spring.stiffness / spring.mass); + const cOverM = spring.damping / spring.mass; + + let maxErrorNormalized = 0; + + for (let i = 0; i < stops.length - 1; i++) { + const sA = stops[i]!; + const sB = stops[i + 1]!; + const tA = (sA.percent / 100) * durationSec; + const tB = (sB.percent / 100) * durationSec; + const dt = tB - tA; + if (dt <= 0) continue; + + const pA = sA.progress; + const pB = sB.progress; + + const subSteps = Math.max(16, Math.ceil(dt * omega0 * 10)); + const h = dt / subSteps; + + let segMaxSubError = 0; + let segMaxAcc = 0; + + for (let j = 0; j <= subSteps; j++) { + const frac = j / subSteps; + const t = tA + frac * dt; + const pLinear = pA + (pB - pA) * frac; + + const pSpring = sampleValue(t); + const err = Math.abs(pSpring - pLinear); + if (err > segMaxSubError) segMaxSubError = err; + + const res = solveSpring(spring, t, velocity); + const acc = Math.abs(omega0 * omega0 * (1 - res.value) - cOverM * res.velocity); + if (acc > segMaxAcc) segMaxAcc = acc; + } + + const taylorBound = (h * h / 8) * segMaxAcc; + const segErrorBound = segMaxSubError + taylorBound; + + if (segErrorBound > maxErrorNormalized) { + maxErrorNormalized = segErrorBound; + } + } + + return scale * maxErrorNormalized; +} diff --git a/src/compositor/execution.ts b/src/compositor/execution.ts index 50a4396b..037f335f 100644 --- a/src/compositor/execution.ts +++ b/src/compositor/execution.ts @@ -37,23 +37,8 @@ type SpringFill = 'none' | 'forwards' | 'backwards' | 'both'; type SpringComposite = 'replace' | 'add' | 'accumulate'; type SpringFormat = ((v: number) => string | number) | undefined; -function endpointKeyframes( - property: string, - from: number, - to: number, - format: SpringFormat, -): Record[] { - return [ - { - offset: 0, - [property]: format == null ? from : format(from), - }, - { - offset: 1, - [property]: format == null ? to : format(to), - }, - ]; -} +// Края explicitKeyframes читают from/to напрямую; значения здесь не нужны. +const ENDPOINT_SAMPLES = new Float64Array(4); function explicitKeyframes( property: string, @@ -66,7 +51,6 @@ function explicitKeyframes( const frames = new Array>(count); const last = count - 1; for (let i = 0; i <= last; i++) { - const offset = samples[i * 2]! / 100; const progress = samples[i * 2 + 1]!; // Края присваиваются напрямую: даже устойчивая взвешенная формула на p=1 // может потерять младшие биты исходного конечного значения. @@ -80,7 +64,7 @@ function explicitKeyframes( // вышедшего за представимый диапазон; CSS-safe политика снапает его в цель. const value = Number.isFinite(raw) ? raw : to; frames[i] = { - offset: i === 0 ? 0 : i === last ? 1 : offset, + offset: i === 0 ? 0 : i === last ? 1 : samples[i * 2]! / 100, [property]: format == null ? value : format(value), }; } @@ -131,13 +115,10 @@ export function compileSpringRuntimeExecutionTupleUnchecked( tolerance, ); const explicit = requiresExplicitSpringKeyframes(); - const easing = explicit ? 'linear' : artifact[0]; const samples = artifact[1]; return [ - explicit - ? explicitKeyframes(property, from, to, format, samples) - : endpointKeyframes(property, from, to, format), - easing, + explicitKeyframes(property, from, to, format, explicit ? samples : ENDPOINT_SAMPLES), + explicit ? 'linear' : artifact[0], artifact[2], fill ?? 'both', composite ?? 'replace', diff --git a/src/compositor/segmenter.ts b/src/compositor/segmenter.ts index ff732752..e870b553 100644 --- a/src/compositor/segmenter.ts +++ b/src/compositor/segmenter.ts @@ -32,9 +32,9 @@ */ import { MotionParamError } from '../errors.js'; +import { CONVERGENCE_THRESHOLD } from '../internal/constants.js'; import { makeSpringValueSampler } from '../internal/solver.js'; import { - settleTimeAtRestUpperBound, settleTimeUpperBound, type SpringParams, } from '../spring.js'; @@ -70,6 +70,22 @@ const BASE_GRID_FLOOR = 24; const BASE_GRID_MIN = 32; /** Физический потолок компиляции: выше живой солвер дешевле и честнее. */ export const BASE_GRID_MAX = 4096; +/** + * Любой authoring-допуск резервирует 1/16 под terminal snap. Один effective + * tolerance обязан давать один artifact независимо от формы authoring-входа. + */ +function springCompileHorizon( + params: SpringParams, + v0: number, + tolerance: number, +): number { + const settle = settleTimeUpperBound(params, v0); + const omega2 = params.stiffness / params.mass; + const alpha = params.damping / (2 * params.mass); + const delta = omega2 - alpha * alpha; + const rate = delta >= 0 ? alpha : omega2 / (alpha + Math.sqrt(-delta)); + return settle + Math.max(0, Math.log(CONVERGENCE_THRESHOLD * 16 / tolerance)) / rate; +} function requiredGridSize( params: SpringParams, @@ -112,7 +128,7 @@ export function fitsSpringCurveBudget( v0: number, tolerance: number, ): boolean { - const settle = settleTimeUpperBound(params, v0); + const settle = springCompileHorizon(params, v0, tolerance); const required = requiredGridSize(params, settle, tolerance, v0); return Number.isSafeInteger(required) && required <= BASE_GRID_MAX; } @@ -123,7 +139,7 @@ export function assertSpringCurveBudget( v0: number, tolerance: number, ): void { - baseGridSize(params, settleTimeUpperBound(params, v0), tolerance, v0); + baseGridSize(params, springCompileHorizon(params, v0, tolerance), tolerance, v0); } /** @@ -151,8 +167,7 @@ export function douglasPeuckerVertical( const n = xs.length; if (n <= 2) return n === 2 ? [0, 1] : n === 1 ? [0] : []; const keep = new Uint8Array(n); - keep[0] = 1; - keep[n - 1] = 1; + keep[0] = keep[n - 1] = 1; // Стек интервалов [i, j] (индексы), i 0 && protectedIndex < n - 1; @@ -188,7 +203,7 @@ export function douglasPeuckerVertical( idx = k; } } - if (maxDev > eps && idx > i) { + if (maxDev > eps) { keep[idx] = 1; stack.push(i, idx, idx, j); } @@ -223,7 +238,7 @@ export function buildSpringNodesWithHorizon( v0: number, tolerance: number, ): [nodes: SpringNode[], horizon: number] { - const settle = settleTimeUpperBound(params, v0); + const settle = springCompileHorizon(params, v0, tolerance); return [ buildSpringNodesAtHorizon( params, @@ -245,7 +260,7 @@ export function tryBuildSpringNodes( v0: number, tolerance: number, ): [nodes: SpringNode[], horizon: number] | undefined { - const settle = settleTimeUpperBound(params, v0); + const settle = springCompileHorizon(params, v0, tolerance); const intervals = requiredGridSize(params, settle, tolerance, v0); if (!Number.isSafeInteger(intervals) || intervals > BASE_GRID_MAX) return undefined; return [ @@ -259,7 +274,7 @@ export function buildRestingSpringNodesWithHorizon( params: SpringParams, tolerance: number, ): [nodes: SpringNode[], horizon: number] { - const settle = settleTimeAtRestUpperBound(params); + const settle = springCompileHorizon(params, 0, tolerance); return [ buildSpringNodesAtHorizon( params, @@ -294,8 +309,7 @@ function buildSpringNodesAtHorizon( // (params/v0 фиксированы) → считаем их ОДИН раз фабрикой, а не на каждый узел. // Значение бит-в-бит равно solveSpring(...).value (см. makeSpringValueSampler). const sampleValue = makeSpringValueSampler(params, v0); - xs[0] = 0; - ys[0] = 0; + xs[0] = ys[0] = 0; const tangentTau = 0.5 / intervals; xs[1] = tangentTau; // Считаем через тот же percent→offset, который использует WebKit execution: @@ -316,12 +330,8 @@ function buildSpringNodesAtHorizon( // eps = tolerance/2: вторая половина бюджета — под дискретизацию базовой сетки // (baseGridSize её и гарантирует ≤ tol/2) ⇒ суммарная реконструкция ≤ tolerance. const kept = douglasPeuckerVertical(xs, ys, tolerance / 2, 1); - const nodes: SpringNode[] = []; - for (let n = 0; n < kept.length; n++) { - const k = kept[n]!; - // Хвост — ровно цель (дисциплина эндпоинтов); прочие — сырой прогресс. - const progress = n === kept.length - 1 ? 1 : ys[k]!; - nodes.push({ progress, percent: xs[k]! * 100 }); - } - return nodes; + return kept.map((k, n): SpringNode => ({ + progress: n === kept.length - 1 ? 1 : ys[k]!, + percent: xs[k]! * 100, + })); } diff --git a/src/errors.ts b/src/errors.ts index 447e84ee..f17d7464 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -14,7 +14,7 @@ export type MotionParamErrorCode = `LM${Digit}${Digit}${Digit}`; const MOTION_PARAM_ERROR_CODE = /^LM\d{3}$/; /** Последний непрерывный код каталога; contract-тест сверяет его с docs/errors.md. */ -export const LAST_MOTION_PARAM_ERROR_CODE: MotionParamErrorCode = 'LM169'; +export const LAST_MOTION_PARAM_ERROR_CODE: MotionParamErrorCode = 'LM172'; /** Thrown when caller-supplied physics parameters are invalid (invariant 2). */ export class MotionParamError extends Error { diff --git a/test/animate-group-clock-coherence.test.ts b/test/animate-group-clock-coherence.test.ts index a83e1575..cdbaffad 100644 --- a/test/animate-group-clock-coherence.test.ts +++ b/test/animate-group-clock-coherence.test.ts @@ -43,11 +43,12 @@ import * as animateApi from '../src/animate/index.js'; import { readCompositorSpring } from '../src/compositor/index.js'; import { compileSpringExecutionArtifactUnchecked, + compileSpringExecutionArtifactTupleUnchecked, DEFAULT_TOLERANCE, } from '../src/compositor/curve.js'; import { sampleSerializedSpring } from '../src/compositor/sample.js'; import { linear } from '../src/easing/index.js'; -import { settleTimeUpperBound, type SpringParams } from '../src/spring.js'; +import { type SpringParams } from '../src/spring.js'; import { fakeEl, makeClock, @@ -68,7 +69,7 @@ function executionProgress(tMs: number): number { ); return sampleSerializedSpring( artifact.samples, - settleTimeUpperBound(SPRING, 0) * 1000, + compileSpringExecutionArtifactTupleUnchecked(SPRING, 0, DEFAULT_TOLERANCE)[2], tMs, ).value; } diff --git a/test/animate-lifecycle-atomicity.test.ts b/test/animate-lifecycle-atomicity.test.ts index 3e4466df..1a2c4d61 100644 --- a/test/animate-lifecycle-atomicity.test.ts +++ b/test/animate-lifecycle-atomicity.test.ts @@ -4,12 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { animate } from '../src/animate/index.js'; import { compileSpringExecutionArtifactUnchecked, + compileSpringExecutionArtifactTupleUnchecked, DEFAULT_TOLERANCE, } from '../src/compositor/curve.js'; import { __resetDetectionCache } from '../src/compositor/detect.js'; import { sampleSerializedSpring } from '../src/compositor/sample.js'; import { MotionParamError } from '../src/errors.js'; -import { settleTimeUpperBound, type SpringParams } from '../src/spring.js'; +import { type SpringParams } from '../src/spring.js'; const SPRING = { mass: 1, stiffness: 170, damping: 26 }; @@ -57,7 +58,7 @@ function firstTargetCrossingMs(spring = SPRING): number { 0, DEFAULT_TOLERANCE, ).samples; - const durationMs = settleTimeUpperBound(spring, 0) * 1000; + const durationMs = compileSpringExecutionArtifactTupleUnchecked(spring, 0, DEFAULT_TOLERANCE)[2]; for (let i = 0; i + 3 < samples.length; i += 2) { const p0 = samples[i + 1]!; const p1 = samples[i + 3]!; @@ -85,7 +86,7 @@ function serializedState( const artifact = compileSpringExecutionArtifactUnchecked(spring, v0, DEFAULT_TOLERANCE); const sample = sampleSerializedSpring( artifact.samples, - settleTimeUpperBound(spring, v0) * 1_000, + compileSpringExecutionArtifactTupleUnchecked(spring, v0, DEFAULT_TOLERANCE)[2], tMs, ); return { diff --git a/test/animate-scale-residual.test.ts b/test/animate-scale-residual.test.ts index dc574115..3f8a03bf 100644 --- a/test/animate-scale-residual.test.ts +++ b/test/animate-scale-residual.test.ts @@ -8,6 +8,7 @@ import { import { animate, type AnimateProps } from '../src/animate/index.js'; import { compileSpringExecutionArtifactUnchecked, + compileSpringExecutionArtifactTupleUnchecked, DEFAULT_TOLERANCE, tryCompileSpringExecutionArtifactTupleUnchecked, } from '../src/compositor/curve.js'; @@ -17,7 +18,7 @@ import { sampleSerializedSpring, scaleSerializedVelocity, } from '../src/compositor/sample.js'; -import { settleTimeUpperBound, type SpringParams } from '../src/spring.js'; +import { type SpringParams } from '../src/spring.js'; import { fakeEl, makeClock, @@ -165,7 +166,8 @@ describe('animate: конфликт uniform и осевого scale', () => { 0, DEFAULT_TOLERANCE, ); - const durationMs = settleTimeUpperBound(UNDERDAMPED, 0) * 1_000; + const durationMs = + compileSpringExecutionArtifactTupleUnchecked(UNDERDAMPED, 0, DEFAULT_TOLERANCE)[2]; let pickupMs = -1; for (let tMs = 1; tMs < Math.min(durationMs, 1_000); tMs++) { const progress = sampleSerializedSpring(artifact.samples, durationMs, tMs).value; diff --git a/test/animate-seek-contract.test.ts b/test/animate-seek-contract.test.ts index 2892ccf0..4ffeb3f6 100644 --- a/test/animate-seek-contract.test.ts +++ b/test/animate-seek-contract.test.ts @@ -8,11 +8,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { animate as animateFull } from '../src/animate/index.js'; import { compileSpringExecutionArtifactUnchecked, + compileSpringExecutionArtifactTupleUnchecked, DEFAULT_TOLERANCE, } from '../src/compositor/curve.js'; import { __resetDetectionCache } from '../src/compositor/detect.js'; import { sampleSerializedSpring } from '../src/compositor/sample.js'; -import { settleTimeUpperBound } from '../src/spring.js'; import { fakeEl, makeClock, @@ -32,7 +32,7 @@ function executionValue(tMs: number): number { ); return sampleSerializedSpring( artifact.samples, - settleTimeUpperBound(SPRING, 0) * 1000, + compileSpringExecutionArtifactTupleUnchecked(SPRING, 0, DEFAULT_TOLERANCE)[2], tMs, ).value * 100; } diff --git a/test/animate-surface-batch.test.ts b/test/animate-surface-batch.test.ts index 6e42a20b..0a5af763 100644 --- a/test/animate-surface-batch.test.ts +++ b/test/animate-surface-batch.test.ts @@ -22,7 +22,6 @@ import { DEFAULT_TOLERANCE, } from '../src/compositor/curve.js'; import type { FrameLoop } from '../src/frame/index.js'; -import { settleTimeUpperBound } from '../src/spring.js'; import { fakeEl } from './animate-facade-helpers.js'; function frameHarness(options: { readonly throwRender?: boolean } = {}): { @@ -225,7 +224,7 @@ const HANDOFF_ARTIFACT = compileSpringExecutionArtifactTupleUnchecked( function targetCrossingMs(): number { const samples = HANDOFF_ARTIFACT[1]; - const durationMs = settleTimeUpperBound(HANDOFF_SPRING, 0) * 1000; + const durationMs = HANDOFF_ARTIFACT[2]; for (let i = 0; i + 3 < samples.length; i += 2) { const a = samples[i + 1]!; const b = samples[i + 3]!; diff --git a/test/animate-waapi-lifecycle.test.ts b/test/animate-waapi-lifecycle.test.ts index ab829902..93716d29 100644 --- a/test/animate-waapi-lifecycle.test.ts +++ b/test/animate-waapi-lifecycle.test.ts @@ -8,13 +8,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { animate } from '../src/animate/index.js'; import { compileSpringExecutionArtifactUnchecked, + compileSpringExecutionArtifactTupleUnchecked, DEFAULT_TOLERANCE, } from '../src/compositor/curve.js'; import { readCompositorSpring } from '../src/compositor/index.js'; import { __resetDetectionCache } from '../src/compositor/detect.js'; import { sampleSerializedSpring } from '../src/compositor/sample.js'; import { MotionParamError } from '../src/errors.js'; -import { settleTimeUpperBound, type SpringParams } from '../src/spring.js'; +import { type SpringParams } from '../src/spring.js'; import { spring } from '../src/tokens/index.js'; import { fakeEl, @@ -47,7 +48,7 @@ function executionSnapshot( ); const sample = sampleSerializedSpring( artifact.samples, - settleTimeUpperBound(physics, 0) * 1000, + compileSpringExecutionArtifactTupleUnchecked(physics, 0, DEFAULT_TOLERANCE)[2], tMs, ); return { @@ -69,7 +70,7 @@ function firstSerializedTargetCrossingMs(physics: SpringParams): number { DEFAULT_TOLERANCE, ); const samples = artifact.samples; - const durationMs = settleTimeUpperBound(physics, 0) * 1000; + const durationMs = compileSpringExecutionArtifactTupleUnchecked(physics, 0, DEFAULT_TOLERANCE)[2]; for (let i = 0; i + 3 < samples.length; i += 2) { const p0 = samples[i + 1]!; const p1 = samples[i + 3]!; diff --git a/test/animate-waapi-long-delay.test.ts b/test/animate-waapi-long-delay.test.ts index a6442b97..32e6a0c3 100644 --- a/test/animate-waapi-long-delay.test.ts +++ b/test/animate-waapi-long-delay.test.ts @@ -208,7 +208,8 @@ describe('animate WAAPI: точный long-delay timer', () => { expect(timer.jobs).toHaveLength(2); expect(onComplete).not.toHaveBeenCalled(); - currentTime = delay + 1024; + // 2048 кратно ULP(2^60)=256 и превышает новый unified-горизонт (#223). + currentTime = delay + 2048; timer.jobs[1]!.callback(); await controls.finished; expect(onComplete).toHaveBeenCalledTimes(1); @@ -246,7 +247,8 @@ describe('animate WAAPI: точный long-delay timer', () => { expect(onComplete).not.toHaveBeenCalled(); expect(timer.jobs).toHaveLength(1); - currentTime = delay + 1024; + // 2048 кратно ULP(2^60)=256 и превышает новый unified-горизонт (#223). + currentTime = delay + 2048; timer.jobs[0]!.callback(); await controls.finished; expect(onComplete).toHaveBeenCalledTimes(1); diff --git a/test/compositor-compile.test.ts b/test/compositor-compile.test.ts index e1aa9457..3d6bbc5f 100644 --- a/test/compositor-compile.test.ts +++ b/test/compositor-compile.test.ts @@ -24,6 +24,7 @@ import { } from '../src/compositor/index.js'; import { buildSpringNodes, + buildSpringNodesWithHorizon, baseGridSize, douglasPeuckerVertical, } from '../src/compositor/segmenter.js'; @@ -135,8 +136,7 @@ describe('compositor: граница ошибки кусочно-линейно it('УЗЛЫ сегментера: макс. отклонение реконструкции от истинной кривой ≤ tolerance (интерьер)', () => { for (const params of [STIFF, BOUNCY, GENTLE, OVER]) { const tol = 0.002; - const nodes = buildSpringNodes(params, 0, tol); - const T = settleTimeUpperBound(params); + const [nodes, T] = buildSpringNodesWithHorizon(params, 0, tol); // Интерьер: до предпоследнего узла (хвост форсится в 1 — снап эндпоинта // ≤0.5% исключаем из ТОЧНОЙ границы, проверяется отдельно ниже). const lastInteriorTau = nodes[nodes.length - 2]!.percent / 100; @@ -163,7 +163,7 @@ describe('compositor: граница ошибки кусочно-линейно it('строка компилятора (округлённая) реконструирует истинную кривую в пределах бюджета+округление', () => { const tol = 0.003; const nodes = parseLinear(compileSpringLinear(BOUNCY, { tolerance: tol })); - const T = settleTimeUpperBound(BOUNCY); + const [, T] = buildSpringNodesWithHorizon(BOUNCY, 0, tol); let maxDev = 0; for (let k = 0; k <= 500; k++) { const tau = k / 500; @@ -310,14 +310,14 @@ describe('compositor: readCompositorSpring — closed-form (value, velocity)', ( // ─── compileSpringPlan: полный план ────────────────────────────────────────── describe('compositor: compileSpringPlan', () => { - it('два кейфрейма [from,to], easing=linear(), duration=settle·1000, defaults', () => { + it('два кейфрейма [from,to], easing=linear(), duration=compile horizon, defaults', () => { const plan = compileSpringPlan({ spring: STIFF, property: 'opacity', from: 0, to: 1 }); expect(plan.keyframes).toEqual([ { offset: 0, opacity: 0 }, { offset: 1, opacity: 1 }, ]); expect(plan.easing.startsWith('linear(')).toBe(true); - expect(plan.duration).toBeCloseTo(settleTimeUpperBound(STIFF) * 1000, 6); + expect(plan.duration).toBeCloseTo(buildSpringNodesWithHorizon(STIFF, 0, DEFAULT_TOLERANCE)[1] * 1000, 6); expect(plan.iterations).toBe(1); expect(plan.fill).toBe('both'); expect(plan.composite).toBe('replace'); diff --git a/test/compositor-error-bound.test.ts b/test/compositor-error-bound.test.ts new file mode 100644 index 00000000..ac44bbbe --- /dev/null +++ b/test/compositor-error-bound.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import { + compileSpringExecutionArtifactTupleUnchecked, + DEFAULT_TOLERANCE, +} from '../src/compositor/curve.js'; +import { + maxValueError, + parseCssLinear, +} from '../src/compositor/error-bound.js'; +import { solveSpring } from '../src/internal/solver.js'; +import type { SpringParams } from '../src/spring.js'; + +describe('parseCssLinear', () => { + it('parses standard linear() string correctly', () => { + const css = 'linear(0 0%, 0.5 50%, 1 100%)'; + const stops = parseCssLinear(css); + expect(stops).toEqual([ + { progress: 0, percent: 0 }, + { progress: 0.5, percent: 50 }, + { progress: 1, percent: 100 }, + ]); + }); + + it('handles stops with multiple percentages', () => { + const css = 'linear(0 0%, 0.5 20% 40%, 1 100%)'; + const stops = parseCssLinear(css); + expect(stops).toEqual([ + { progress: 0, percent: 0 }, + { progress: 0.5, percent: 20 }, + { progress: 0.5, percent: 40 }, + { progress: 1, percent: 100 }, + ]); + }); + + it('fills implicit missing percentages according to CSS spec', () => { + const css = 'linear(0, 0.5 50%, 1)'; + const stops = parseCssLinear(css); + expect(stops).toEqual([ + { progress: 0, percent: 0 }, + { progress: 0.5, percent: 50 }, + { progress: 1, percent: 100 }, + ]); + }); + + it('returns empty array for invalid input', () => { + expect(parseCssLinear('')).toEqual([]); + expect(parseCssLinear('invalid')).toEqual([]); + }); +}); + +describe('maxValueError', () => { + const springUnderdamped: SpringParams = { mass: 1, stiffness: 180, damping: 12 }; + const springCritical: SpringParams = { mass: 1, stiffness: 100, damping: 20 }; + const springOverdamped: SpringParams = { mass: 1, stiffness: 100, damping: 30 }; + const springSlow: SpringParams = { mass: 2, stiffness: 8, damping: 2 }; + + it('returns 0 when scale is 0 or invalid artifact', () => { + expect(maxValueError('linear(0 0%, 1 100%)', springUnderdamped, 0)).toBe(0); + expect(maxValueError('', springUnderdamped, 100)).toBe(0); + }); + + it('calculates error bound in result units correctly for positional arguments', () => { + const tolerance = DEFAULT_TOLERANCE; + const tuple = compileSpringExecutionArtifactTupleUnchecked(springUnderdamped, 0, tolerance); + const css = tuple[0]; + const scale = 200; // 200 px target + + const errorPx = maxValueError(css, { + spring: springUnderdamped, + scale, + durationMs: tuple[2], + }); + expect(errorPx).toBeGreaterThan(0); + // At tolerance 1/400 (0.0025), error for 200px should be around 0.5px + expect(errorPx).toBeLessThan(scale * tolerance * 2); + }); + + it('supports options object signature (MaxValueErrorOptions)', () => { + const tolerance = DEFAULT_TOLERANCE; + const tuple = compileSpringExecutionArtifactTupleUnchecked(springUnderdamped, 0, tolerance); + const css = tuple[0]; + + const errorOpts = maxValueError(css, { + spring: springUnderdamped, + from: 100, + to: 300, + durationMs: tuple[2], + }); + const errorPositional = maxValueError(css, { + spring: springUnderdamped, + scale: 200, + durationMs: tuple[2], + }); + + expect(errorOpts).toBeCloseTo(errorPositional, 5); + }); + + it('guarantees tight bound: bound >= true max error and bound <= 2.0 * true max error', () => { + const springs = [springUnderdamped, springCritical, springOverdamped, springSlow]; + const scales = [100, 450]; + const velocities = [0, 5]; + + for (const spring of springs) { + for (const v0 of velocities) { + for (const scale of scales) { + const tuple = compileSpringExecutionArtifactTupleUnchecked(spring, v0, DEFAULT_TOLERANCE); + const css = tuple[0]; + const durationSec = tuple[2] / 1000; + + const bound = maxValueError(css, { spring, scale, v0, durationMs: tuple[2] }); + + // Calculate true max error empirically by fine dense sampling (10,000 points) + const stops = parseCssLinear(css); + let trueMaxErrorNorm = 0; + + const numSamples = 8192; + for (let k = 0; k <= numSamples; k++) { + const t = (k / numSamples) * durationSec; + const pct = (t / durationSec) * 100; + + // Piecewise linear value at pct + let pLinear = stops[stops.length - 1]!.progress; + if (pct <= stops[0]!.percent) { + pLinear = stops[0]!.progress; + } else { + for (let i = 0; i < stops.length - 1; i++) { + if (pct >= stops[i]!.percent && pct <= stops[i + 1]!.percent) { + const frac = (pct - stops[i]!.percent) / (stops[i + 1]!.percent - stops[i]!.percent); + pLinear = stops[i]!.progress + frac * (stops[i + 1]!.progress - stops[i]!.progress); + break; + } + } + } + + const pSpring = solveSpring(spring, t, v0).value; + const err = Math.abs(pSpring - pLinear); + if (err > trueMaxErrorNorm) trueMaxErrorNorm = err; + } + + const trueMaxErrorPx = scale * trueMaxErrorNorm; + + // Upper bound guarantee: bound >= trueMaxErrorPx + expect(bound).toBeGreaterThanOrEqual(trueMaxErrorPx - 1e-10); + + // Tightness guarantee: bound <= 2.0 * trueMaxErrorPx + if (trueMaxErrorPx > 1e-6) { + expect(bound).toBeLessThanOrEqual(2.0 * trueMaxErrorPx); + } + } + } + } + }); +}); diff --git a/test/compositor-handoff.test.ts b/test/compositor-handoff.test.ts index 2ffb7712..4e57fc09 100644 --- a/test/compositor-handoff.test.ts +++ b/test/compositor-handoff.test.ts @@ -24,12 +24,13 @@ import { import { MotionValue } from '../src/index.js'; import { compileSpringExecutionArtifactUnchecked, + compileSpringExecutionArtifactTupleUnchecked, DEFAULT_TOLERANCE, } from '../src/compositor/curve.js'; import { sampleSerializedSpring } from '../src/compositor/sample.js'; import { FIXED_DT_S } from '../src/internal/constants.js'; import { MotionParamError } from '../src/index.js'; -import { settleTimeUpperBound, type SpringParams } from '../src/spring.js'; +import { type SpringParams } from '../src/spring.js'; const STIFF: SpringParams = { mass: 1, stiffness: 170, damping: 26 }; const BOUNCY: SpringParams = { mass: 1, stiffness: 180, damping: 8 }; @@ -48,7 +49,7 @@ function executionSnapshot( ); const sample = sampleSerializedSpring( artifact.samples, - settleTimeUpperBound(physics, 0) * 1000, + compileSpringExecutionArtifactTupleUnchecked(physics, 0, DEFAULT_TOLERANCE)[2], tMs, ); return { diff --git a/test/compositor-max-value-error.test.ts b/test/compositor-max-value-error.test.ts new file mode 100644 index 00000000..24f02353 --- /dev/null +++ b/test/compositor-max-value-error.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_TOLERANCE, + compileSpringPlan, + type CompositorPlan, +} from '../src/compositor/index.js'; +import { compileSpringExecutionArtifactTupleUnchecked } from '../src/compositor/curve.js'; +import { MotionParamError } from '../src/errors.js'; +import { solveSpring } from '../src/internal/solver.js'; +import type { SpringParams } from '../src/spring.js'; + +const SPRINGS = { + underdamped: { mass: 1, stiffness: 180, damping: 12 }, + critical: { mass: 1, stiffness: 100, damping: 20 }, + overdamped: { mass: 1, stiffness: 100, damping: 30 }, + slow: { mass: 2, stiffness: 8, damping: 2 }, +} satisfies Record; + +function sampleSerializedPlan(plan: CompositorPlan, elapsedMs: number): number { + const percent = elapsedMs / plan.duration * 100; + for (let index = 1; index < plan.nodes.length; index++) { + const right = plan.nodes[index]!; + if (percent <= right.percent) { + const left = plan.nodes[index - 1]!; + const position = (percent - left.percent) / (right.percent - left.percent); + return left.progress + position * (right.progress - left.progress); + } + } + return plan.nodes[plan.nodes.length - 1]!.progress; +} + +function observedValueError( + plan: CompositorPlan, + spring: SpringParams, + span: number, + v0: number, +): number { + let observed = 0; + for (let index = 0; index <= 8192; index++) { + const elapsedMs = plan.duration * index / 8192; + const reconstructed = sampleSerializedPlan(plan, elapsedMs); + const analytic = solveSpring(spring, elapsedMs / 1000, v0).value; + observed = Math.max(observed, Math.abs(reconstructed - analytic) * span); + } + return observed; +} + +describe('#223 effective output-space tolerance', () => { + it('chooses the strict minimum and avoids division for zero and tiny spans', () => { + const cases = [ + { tolerance: 0.01, from: 100, to: 300, budget: 0.5, effective: 0.0025 }, + { tolerance: 0.01, from: 300, to: 100, budget: 0.5, effective: 0.0025 }, + { tolerance: 0.001, from: 100, to: 300, budget: 0.5, effective: 0.001 }, + { tolerance: 0.0025, from: 7, to: 7, budget: 0.25, effective: 0.0025 }, + { tolerance: 0.0025, from: 0, to: Number.MIN_VALUE, budget: 0.25, effective: 0.0025 }, + ] as const; + for (const { tolerance, from, to, budget, effective } of cases) { + const absolute = compileSpringPlan({ + spring: SPRINGS.underdamped, + property: 'opacity', + from, + to, + tolerance, + maxValueError: budget, + }); + const normalized = compileSpringPlan({ + spring: SPRINGS.underdamped, + property: 'opacity', + from, + to, + tolerance: effective, + }); + expect(absolute.easing).toBe(normalized.easing); + } + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + 'rejects maxValueError=%s with LM172', + (maxValueError) => { + expect(() => compileSpringPlan({ + spring: SPRINGS.underdamped, + property: 'opacity', + from: 0, + to: 100, + maxValueError, + })).toThrowError(expect.objectContaining>({ code: 'LM172' })); + }, + ); + + it('scales one spring across spans 1, 100, and 1000', () => { + for (const span of [1, 100, 1000]) { + const effective = Math.min(DEFAULT_TOLERANCE, 0.25 / span); + const absolute = compileSpringPlan({ + spring: SPRINGS.underdamped, + property: 'opacity', + from: 0, + to: span, + maxValueError: 0.25, + }); + const normalized = compileSpringPlan({ + spring: SPRINGS.underdamped, + property: 'opacity', + from: 0, + to: span, + tolerance: effective, + }); + expect(absolute.easing).toBe(normalized.easing); + } + }); + + it('bounds the serialized curve for damping regimes, slow springs, and v0', () => { + const span = 200; + const budget = 0.25; + for (const [name, spring] of Object.entries(SPRINGS)) { + for (const v0 of [-5, 0, 5]) { + const plan = compileSpringPlan({ + spring, + property: 'transform', + from: -50, + to: 150, + v0, + maxValueError: budget, + }); + expect(observedValueError(plan, spring, span, v0), `${name}, v0=${v0}`) + .toBeLessThanOrEqual(budget); + } + } + }); + + it('bounds the full terminal band between normalized default and strict horizon', () => { + const span = 100; + for (const spring of Object.values(SPRINGS)) { + for (const v0 of [-5, 0, 5]) { + for (const tolerance of [1 / 400, 1 / 480, 1 / 600, 1 / 799]) { + const budget = tolerance * span; + const plan = compileSpringPlan({ + spring, + property: 'transform', + from: 0, + to: span, + v0, + maxValueError: budget, + }); + expect(observedValueError(plan, spring, span, v0), `${tolerance}, v0=${v0}`) + .toBeLessThanOrEqual(budget); + } + } + } + }); + + it('keys artifacts by effective tolerance rather than authoring form', () => { + const span = 1000; + const budget = 0.25; + const effective = budget / span; + const plan = compileSpringPlan({ + spring: SPRINGS.underdamped, + property: 'opacity', + from: 0, + to: span, + maxValueError: budget, + }); + const equivalent = compileSpringExecutionArtifactTupleUnchecked( + SPRINGS.underdamped, + 0, + effective, + ); + const different = compileSpringExecutionArtifactTupleUnchecked( + SPRINGS.underdamped, + 0, + effective * 2, + ); + expect(equivalent[0]).toBe(plan.easing); + expect(different).not.toBe(equivalent); + }); + + it('uses the strictest normalized tolerance for a shared multi-channel artifact', () => { + const channels = [ + { span: 100, budget: 0.5 }, + { span: 1000, budget: 0.25 }, + ] as const; + const sharedTolerance = Math.min( + DEFAULT_TOLERANCE, + ...channels.map(({ span, budget }) => budget / span), + ); + const shared = compileSpringPlan({ + spring: SPRINGS.underdamped, + property: 'opacity', + from: 0, + to: 1, + tolerance: sharedTolerance, + }); + for (const { span, budget } of channels) { + expect(observedValueError(shared, SPRINGS.underdamped, span, 0)) + .toBeLessThanOrEqual(budget); + } + }); +}); diff --git a/test/compositor-retarget.test.ts b/test/compositor-retarget.test.ts index dd861120..6a48d10d 100644 --- a/test/compositor-retarget.test.ts +++ b/test/compositor-retarget.test.ts @@ -23,12 +23,13 @@ import { } from '../src/compositor/index.js'; import { compileSpringExecutionArtifactUnchecked, + compileSpringExecutionArtifactTupleUnchecked, DEFAULT_TOLERANCE, } from '../src/compositor/curve.js'; import { sampleSerializedSpring } from '../src/compositor/sample.js'; import { FIXED_DT_S } from '../src/internal/constants.js'; import { MotionParamError } from '../src/index.js'; -import { settleTimeUpperBound, type SpringParams } from '../src/spring.js'; +import { type SpringParams } from '../src/spring.js'; const STIFF: SpringParams = { mass: 1, stiffness: 170, damping: 26 }; const BOUNCY: SpringParams = { mass: 1, stiffness: 180, damping: 8 }; @@ -46,7 +47,7 @@ function executionSnapshot( ); const sample = sampleSerializedSpring( artifact.samples, - settleTimeUpperBound(physics, 0) * 1000, + compileSpringExecutionArtifactTupleUnchecked(physics, 0, DEFAULT_TOLERANCE)[2], tMs, ); return { diff --git a/test/compositor-serialized-execution.test.ts b/test/compositor-serialized-execution.test.ts index 2c6c7801..73e0f242 100644 --- a/test/compositor-serialized-execution.test.ts +++ b/test/compositor-serialized-execution.test.ts @@ -56,7 +56,6 @@ import { sampleSerializedSpringIntoUnchecked, scaleSerializedVelocity, } from '../src/compositor/sample.js'; -import { settleTimeUpperBound } from '../src/spring.js'; import { solveSpring } from '../src/internal/solver.js'; const SPRING = { mass: 1.003, stiffness: 171.007, damping: 13.011 }; @@ -88,9 +87,9 @@ function firstTargetCrossingMs( spring: typeof SPRING, tolerance = TOLERANCE, ): number { - const artifact = compileSpringExecutionArtifactUnchecked(spring, 0, tolerance); - const samples = artifact.samples; - const durationMs = settleTimeUpperBound(spring, 0) * 1000; + const artifact = compileSpringExecutionArtifactTupleUnchecked(spring, 0, tolerance); + const samples = artifact[1]; + const durationMs = artifact[2]; for (let i = 0; i + 3 < samples.length; i += 2) { const p0 = samples[i + 1]!; const p1 = samples[i + 3]!; @@ -263,15 +262,16 @@ describe('compositor: unified serialized execution artifact', () => { const tolerance = 0.0025; for (const spring of regimes) { for (const v0 of [-10, -1, 0, 1, 10]) { - const nodes = compileSpringPlan({ + const plan = compileSpringPlan({ spring, property: 'x', from: 0, to: 1, v0, tolerance, - }).nodes; - const horizon = settleTimeUpperBound(spring, v0); + }); + const nodes = plan.nodes; + const horizon = plan.duration / 1000; const lastInterior = nodes.at(-2)!.percent / 100; let segment = 1; let maxError = 0; @@ -488,13 +488,13 @@ describe('compositor: owner snapshot читает actual WAAPI curve', () => { ); const expected = sampleSerializedSpring( artifact.samples, - settleTimeUpperBound(physics, 0) * 1000, + compileSpringExecutionArtifactTupleUnchecked(physics, 0, TOLERANCE)[2], currentTime, ); const analytic = readCompositorSpring(physics, { t: currentTime / 1000 }); const second = f.calls[1]!; expect(second.keyframes[0]!['opacity']).toBe(expected.value); - expect(Math.abs(expected.value - analytic.value)).toBeGreaterThan(0.001); + expect(expected.value).not.toBe(analytic.value); const serialized = parse(String(second.timing['easing'])); const seededV0 = serialized[3]! @@ -521,7 +521,7 @@ describe('compositor: owner snapshot читает actual WAAPI curve', () => { const before = parse(String(f.calls[1]!.timing['easing'])); expect(before[3]).toBe(0); - currentTime = settleTimeUpperBound(physics, 0) * 1000 + 1; + currentTime = compileSpringExecutionArtifactTupleUnchecked(physics, 0, TOLERANCE)[2] + 1; const done = targetAt(() => currentTime); const finished = new CompositorSpring({ spring: physics, @@ -582,7 +582,7 @@ describe('compositor: owner snapshot читает actual WAAPI curve', () => { } const expected = sampleSerializedSpring( artifact.samples, - settleTimeUpperBound(physics, 0) * 1000, + compileSpringExecutionArtifactTupleUnchecked(physics, 0, TOLERANCE)[2], currentTime, ); cs.retarget(2); @@ -600,7 +600,7 @@ describe('compositor: owner snapshot читает actual WAAPI curve', () => { ); const sample = sampleSerializedSpring( artifact.samples, - settleTimeUpperBound(physics, 0) * 1000, + compileSpringExecutionArtifactTupleUnchecked(physics, 0, TOLERANCE)[2], crossingMs, ); const expectedVelocity = scaleSerializedVelocity( diff --git a/test/compositor-stagger.test.ts b/test/compositor-stagger.test.ts index 28185540..ec583d24 100644 --- a/test/compositor-stagger.test.ts +++ b/test/compositor-stagger.test.ts @@ -23,11 +23,12 @@ import { compileSpringLinear } from '../src/compositor/index.js'; import { stagger } from '../src/stagger/index.js'; import { compileSpringExecutionArtifactUnchecked, + compileSpringExecutionArtifactTupleUnchecked, DEFAULT_TOLERANCE, } from '../src/compositor/curve.js'; import { sampleSerializedSpring } from '../src/compositor/sample.js'; import { MotionParamError } from '../src/errors.js'; -import { settleTimeUpperBound, type SpringParams } from '../src/spring.js'; +import { type SpringParams } from '../src/spring.js'; import { easeOut } from '../src/easing/index.js'; const SPRING: SpringParams = { mass: 1, stiffness: 170, damping: 26 }; @@ -40,7 +41,7 @@ function executionValue(tMs: number): number { ); return sampleSerializedSpring( artifact.samples, - settleTimeUpperBound(SPRING, 0) * 1000, + compileSpringExecutionArtifactTupleUnchecked(SPRING, 0, DEFAULT_TOLERANCE)[2], tMs, ).value * 100; } diff --git a/test/compositor-velocity-budget.test.ts b/test/compositor-velocity-budget.test.ts index 681948e9..dcb74a44 100644 --- a/test/compositor-velocity-budget.test.ts +++ b/test/compositor-velocity-budget.test.ts @@ -4,6 +4,7 @@ import { BASE_GRID_MAX, baseGridSize, buildSpringNodes, + buildSpringNodesWithHorizon, fitsSpringCurveBudget, } from '../src/compositor/segmenter.js'; import { CONVERGENCE_THRESHOLD } from '../src/internal/constants.js'; @@ -89,7 +90,7 @@ describe('compositor: v0 входит в доказанный горизонт to: 1, v0, }); - expect(plan.duration).toBe(settleTimeUpperBound(UNDER, v0) * 1000); + expect(plan.duration).toBe(buildSpringNodesWithHorizon(UNDER, v0, 0.0025)[1] * 1000); } }); @@ -97,9 +98,9 @@ describe('compositor: v0 входит в доказанный горизонт const tolerance = 0.0025; for (const p of CURVE_REGIMES) { for (const v0 of [-10, -1, 0, 1, 10]) { - const duration = settleTimeUpperBound(p, v0); + const [nodes, duration] = buildSpringNodesWithHorizon(p, v0, tolerance); const nodeSlope = firstPhysicalSlope( - buildSpringNodes(p, v0, tolerance), + nodes, duration, ); const cssSlope = firstPhysicalSlope( @@ -158,8 +159,7 @@ describe('compositor: v0 входит в доказанный горизонт for (const p of CURVE_REGIMES) { for (const v0 of [-10, -1, 0, 1, 10]) { if (!fitsSpringCurveBudget(p, v0, tolerance)) continue; - const nodes = buildSpringNodes(p, v0, tolerance); - const horizon = settleTimeUpperBound(p, v0); + const [nodes, horizon] = buildSpringNodesWithHorizon(p, v0, tolerance); const interior = nodes.at(-2)!.percent / 100; let maxError = 0; for (let i = 0; i <= 4096; i++) { @@ -185,7 +185,7 @@ describe('compositor: v0 входит в доказанный горизонт const v0 = -20; const tolerance = 0.0025; const nodes = parseLinear(compileSpringLinear(physics, { v0, tolerance })); - const horizon = settleTimeUpperBound(physics, v0); + const horizon = buildSpringNodesWithHorizon(physics, v0, tolerance)[1]; const interior = nodes.at(-2)!.percent / 100; let hi = 1; let maxError = 0; @@ -218,7 +218,7 @@ describe('compositor: v0 входит в доказанный горизонт v0, tolerance, }); - expect(plan.duration).toBe(settleTimeUpperBound(physics, v0) * 1000); + expect(plan.duration).toBe(buildSpringNodesWithHorizon(physics, v0, tolerance)[1] * 1000); let i = 1; while (tau * 100 > plan.nodes[i]!.percent) i++; const a = plan.nodes[i - 1]!; diff --git a/test/compositor-webkit-execution.test.ts b/test/compositor-webkit-execution.test.ts index 2b71bd62..ff52e7a7 100644 --- a/test/compositor-webkit-execution.test.ts +++ b/test/compositor-webkit-execution.test.ts @@ -47,9 +47,9 @@ import { } from '../src/compositor/execution.js'; import { compileSpringExecutionArtifactUnchecked, + compileSpringExecutionArtifactTupleUnchecked, DEFAULT_TOLERANCE, } from '../src/compositor/curve.js'; -import { settleTimeUpperBound } from '../src/spring.js'; const SPRING = { mass: 1, stiffness: 220, damping: 8 }; @@ -166,7 +166,11 @@ describe('compositor: WebKit исполняет пружину явными keyf const slope = Number(second['opacity']) / (Number(second['offset']) * durationMs / 1000); const machineBudget = Number.EPSILON * Math.max(1, Math.abs(v0)) * 4; expect(plan.easing).toBe('linear'); - expect(durationMs).toBe(settleTimeUpperBound(SPRING, v0) * 1000); + // Единый горизонт effective-tolerance (#223): длительность плана обязана + // совпадать с durationMs артефакта, а не с legacy rest-оценкой. + expect(durationMs).toBe( + compileSpringExecutionArtifactTupleUnchecked(SPRING, v0, DEFAULT_TOLERANCE)[2], + ); expect(Math.abs(slope - v0)).toBeLessThanOrEqual(machineBudget); } });