// ---------- Satin construction settings ---------- import { defineModes } from '../core/mode-registry.ts'; export const SATIN_CAP_MODES = defineModes(['legacy', 'butt', 'taper', 'point', 'round']); export type SatinCapMode = (typeof SATIN_CAP_MODES)[number]; export const SATIN_JOIN_MODES = defineModes(['legacy', 'continuous', 'fan', 'miter', 'split']); export type SatinJoinMode = (typeof SATIN_JOIN_MODES)[number]; export const SATIN_WIDE_MODES = defineModes(['warn', 'split']); export type SatinWideMode = (typeof SATIN_WIDE_MODES)[number]; /** Default realized satin-chord recommendation used by warnings and preflight. */ export const DEFAULT_PREFERRED_SATIN_CHORD_MM = 8; /** Central physical bounds for satin cap construction controls. */ export const SATIN_CONSTRUCTION_RANGES = Object.freeze({ capLengthMM: Object.freeze({ min: 0.4, max: 20, default: 2 }), cornerAngleDeg: Object.freeze({ min: 5, max: 175, default: 60 }), maxWidthMM: Object.freeze({ min: 2, max: 12, default: 7.5 }), splitOverlapMM: Object.freeze({ min: 0, max: 1, default: 0.5 }), }); /** Fixed physical safeguards used by automatic sharp-corner construction. */ export const SATIN_CORNER_LIMITS = Object.freeze({ overlapMM: 0.5, maxOuterPenetrations: 8, maxInnerPenetrations: 2, miterLimit: 2.5, }); export const SATIN_CONSTRUCTION_MODE_REGISTRIES = { satincap: SATIN_CAP_MODES, satinjoin: SATIN_JOIN_MODES, satinwide: SATIN_WIDE_MODES, } as const; /** Number of interlocking subcolumns needed to keep every realized chord under the ceiling. */ export function satinSplitCount(maxWidthMM: number, ceilingMM: number, overlapMM: number): number { if (![maxWidthMM, ceilingMM, overlapMM].every(Number.isFinite)) throw new RangeError('satin split dimensions must be finite'); if (maxWidthMM < 0 || ceilingMM <= 0 || overlapMM < 0 || overlapMM >= ceilingMM) throw new RangeError('satin split dimensions are outside their safe range'); if (maxWidthMM <= ceilingMM) return 1; return Math.max(2, Math.ceil(maxWidthMM / (ceilingMM - overlapMM))); } /** * Shared seam fraction for two adjacent split columns. The seam alternates which * neighbor owns the configured overlap band, avoiding a fixed double-density strip. */ export function satinSplitSeamFraction( seamIndex: number, columnCount: number, rowIndex: number, widthMM: number, overlapMM: number, ): number { const base = seamIndex / columnCount; if (!(widthMM > 0) || overlapMM === 0) return base; const direction = (rowIndex + seamIndex) % 2 === 0 ? -1 : 1; const shifted = base + (direction * overlapMM) / (2 * widthMM); const lower = (seamIndex - 1) / columnCount; const upper = (seamIndex + 1) / columnCount; return Math.min(upper, Math.max(lower, shifted)); } export interface SatinCapPolicy { /** Start and end remain separate internally so a later surface can expose asymmetric caps. */ readonly start: SatinCapMode; readonly end: SatinCapMode; readonly lengthMM: number; } const smoothstep = (value: number) => { const t = Math.min(Math.max(value, 0), 1); return t * t * (3 - 2 * t); }; /** * Scale one side of a topping bite at a physical distance from an open-column tip. * `legacy` and `butt` deliberately retain full width. Taper retains a machine-safe * terminal bite, point converges to the spine, and round follows a circular easing. */ export function satinCapWidthFactor( mode: SatinCapMode, distanceFromTipMM: number, capLengthMM: number, fullWidthMM: number, minimumStitchMM: number, ): number { if (mode === 'legacy' || mode === 'butt') return 1; const progress = Math.min(Math.max(distanceFromTipMM / Math.max(capLengthMM, 1e-9), 0), 1); if (mode === 'taper') { const terminal = Math.min(1, Math.max(0.25, minimumStitchMM / Math.max(fullWidthMM, 1e-9))); return terminal + (1 - terminal) * smoothstep(progress); } if (mode === 'point') return smoothstep(progress); // A semicircle's half-width at inward distance d is sqrt(1 - (1-d/r)^2). return Math.sqrt(Math.max(0, 1 - (1 - progress) ** 2)); } /** Physical distance reserved at a cap so underlay cannot protrude past topping. */ export function satinCapUnderlayInset(mode: SatinCapMode, capLengthMM: number): number { return mode === 'legacy' || mode === 'butt' ? 0 : capLengthMM; }