scax-engine 0.1.8 → 0.2.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6735,119 +6735,6 @@ const _m = /*@__PURE__*/ new Matrix3();
6735
6735
 
6736
6736
  _m.set( -1, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0 );
6737
6737
 
6738
- /**
6739
- * 2D affine 왜곡 추정 전용 클래스입니다.
6740
- * [x'; y'] = [[a b c], [d e f]] * [x y 1]^T 형태를 최소자승으로 적합합니다.
6741
- */
6742
- class Affine {
6743
- constructor() {
6744
- this.lastResult = null;
6745
- this.lastPairs = [];
6746
- }
6747
- estimate(pairs) {
6748
- const inputPairs = Array.isArray(pairs) ? pairs : [];
6749
- this.lastPairs = inputPairs;
6750
- const affine = this.fitAffine2D(inputPairs);
6751
- if (!affine) {
6752
- this.lastResult = null;
6753
- return null;
6754
- }
6755
- let residualSumPct = 0;
6756
- let residualCount = 0;
6757
- let residualMaxPct = 0;
6758
- const residuals = [];
6759
- for (const pair of inputPairs) {
6760
- const px = affine.a * pair.sx + affine.b * pair.sy + affine.c;
6761
- const py = affine.d * pair.sx + affine.e * pair.sy + affine.f;
6762
- const rx = pair.tx - px;
6763
- const ry = pair.ty - py;
6764
- const magnitude = Math.hypot(rx, ry);
6765
- if (magnitude < 1e-4)
6766
- continue;
6767
- const radiusRef = Math.hypot(px, py);
6768
- const pct = (magnitude / Math.max(0.2, radiusRef)) * 100;
6769
- residualSumPct += pct;
6770
- residualCount += 1;
6771
- residualMaxPct = Math.max(residualMaxPct, pct);
6772
- residuals.push({ sx: pair.sx, sy: pair.sy, px, py, rx, ry, magnitude, pct });
6773
- }
6774
- const result = {
6775
- ...affine,
6776
- count: inputPairs.length,
6777
- residualAvgPct: residualCount ? residualSumPct / residualCount : 0,
6778
- residualMaxPct,
6779
- residuals,
6780
- };
6781
- this.lastResult = result;
6782
- return result;
6783
- }
6784
- /**
6785
- * 마지막 affine 추정 결과를 반환합니다.
6786
- */
6787
- getLastResult() {
6788
- return this.lastResult;
6789
- }
6790
- /**
6791
- * 마지막 affine 추정에 사용된 입력쌍을 반환합니다.
6792
- */
6793
- getLastPairs() {
6794
- return [...this.lastPairs];
6795
- }
6796
- fitAffine2D(pairs) {
6797
- if (!Array.isArray(pairs) || pairs.length < 4)
6798
- return null;
6799
- const ata = Array.from({ length: 6 }, () => Array(6).fill(0));
6800
- const atb = Array(6).fill(0);
6801
- const accumulate = (row, rhs) => {
6802
- for (let i = 0; i < 6; i += 1) {
6803
- atb[i] += row[i] * rhs;
6804
- for (let j = 0; j < 6; j += 1)
6805
- ata[i][j] += row[i] * row[j];
6806
- }
6807
- };
6808
- for (const pair of pairs) {
6809
- accumulate([pair.sx, pair.sy, 1, 0, 0, 0], pair.tx);
6810
- accumulate([0, 0, 0, pair.sx, pair.sy, 1], pair.ty);
6811
- }
6812
- const n = 6;
6813
- const aug = ata.map((row, index) => [...row, atb[index]]);
6814
- for (let col = 0; col < n; col += 1) {
6815
- let pivot = col;
6816
- for (let row = col + 1; row < n; row += 1) {
6817
- if (Math.abs(aug[row][col]) > Math.abs(aug[pivot][col]))
6818
- pivot = row;
6819
- }
6820
- if (Math.abs(aug[pivot][col]) < 1e-10)
6821
- return null;
6822
- if (pivot !== col) {
6823
- const temp = aug[col];
6824
- aug[col] = aug[pivot];
6825
- aug[pivot] = temp;
6826
- }
6827
- const divider = aug[col][col];
6828
- for (let j = col; j <= n; j += 1)
6829
- aug[col][j] /= divider;
6830
- for (let row = 0; row < n; row += 1) {
6831
- if (row === col)
6832
- continue;
6833
- const factor = aug[row][col];
6834
- if (Math.abs(factor) < 1e-12)
6835
- continue;
6836
- for (let j = col; j <= n; j += 1)
6837
- aug[row][j] -= factor * aug[col][j];
6838
- }
6839
- }
6840
- return {
6841
- a: aug[0][n],
6842
- b: aug[1][n],
6843
- c: aug[2][n],
6844
- d: aug[3][n],
6845
- e: aug[4][n],
6846
- f: aug[5][n],
6847
- };
6848
- }
6849
- }
6850
-
6851
6738
  /**
6852
6739
  * 프라운호퍼 파장과 색상
6853
6740
  */
@@ -7127,6 +7014,9 @@ class Ray {
7127
7014
  class LightSource {
7128
7015
  constructor() {
7129
7016
  this.rays = [];
7017
+ this.sourcePosition = new Vector3(0, 0, 0);
7018
+ this.sourceRotationQuaternion = new Quaternion();
7019
+ this.sourceRotationPivot = new Vector3(0, 0, 0);
7130
7020
  }
7131
7021
  directionFromVergence(origin, z, vergence) {
7132
7022
  if (!Number.isFinite(vergence) || Math.abs(vergence) < 1e-12) {
@@ -7161,13 +7051,62 @@ class LightSource {
7161
7051
  addRay(ray) {
7162
7052
  this.rays.push(ray.clone());
7163
7053
  }
7054
+ configurePose(pose, pivotZ = 0) {
7055
+ const position = pose?.position;
7056
+ const tilt = pose?.tilt;
7057
+ this.sourcePosition = new Vector3(this.toFiniteNumber(position?.x), this.toFiniteNumber(position?.y), this.toFiniteNumber(position?.z));
7058
+ const tiltXDeg = this.toFiniteNumber(tilt?.x);
7059
+ const tiltYDeg = this.toFiniteNumber(tilt?.y);
7060
+ this.sourceRotationQuaternion = new Quaternion().setFromEuler(new Euler((tiltXDeg * Math.PI) / 180, (tiltYDeg * Math.PI) / 180, 0, "XYZ"));
7061
+ this.sourceRotationPivot = new Vector3(0, 0, this.toFiniteNumber(pivotZ));
7062
+ }
7063
+ toFiniteNumber(value, fallback = 0) {
7064
+ const parsed = Number(value);
7065
+ return Number.isFinite(parsed) ? parsed : fallback;
7066
+ }
7067
+ getRayPoints(ray) {
7068
+ const state = ray;
7069
+ return Array.isArray(state.points) ? state.points : [];
7070
+ }
7071
+ rotatePointAroundPivot(point, pivot) {
7072
+ return point.clone().sub(pivot).applyQuaternion(this.sourceRotationQuaternion).add(pivot);
7073
+ }
7074
+ transformRayAroundPivot(ray) {
7075
+ const points = this.getRayPoints(ray);
7076
+ if (!points.length)
7077
+ return ray;
7078
+ const transformed = ray.clone();
7079
+ const transformedState = transformed;
7080
+ const nextPoints = points.map((point) => this.rotatePointAroundPivot(point, this.sourceRotationPivot));
7081
+ transformedState.points = nextPoints;
7082
+ transformedState.direction = ray.getDirection().clone().applyQuaternion(this.sourceRotationQuaternion).normalize();
7083
+ transformedState.origin = nextPoints[nextPoints.length - 1].clone();
7084
+ return transformed;
7085
+ }
7086
+ translateRay(ray) {
7087
+ if (this.sourcePosition.lengthSq() < 1e-12)
7088
+ return ray;
7089
+ const points = this.getRayPoints(ray);
7090
+ if (!points.length)
7091
+ return ray;
7092
+ const translated = ray.clone();
7093
+ const translatedState = translated;
7094
+ const nextPoints = points.map((point) => point.clone().add(this.sourcePosition));
7095
+ translatedState.points = nextPoints;
7096
+ translatedState.origin = nextPoints[nextPoints.length - 1].clone();
7097
+ return translated;
7098
+ }
7099
+ applyPoseToRay(ray) {
7100
+ const rotated = this.transformRayAroundPivot(ray.clone());
7101
+ return this.translateRay(rotated);
7102
+ }
7164
7103
  emitRays() {
7165
- return this.rays.map((ray) => ray.clone());
7104
+ return this.rays.map((ray) => this.applyPoseToRay(ray));
7166
7105
  }
7167
7106
  }
7168
7107
  class GridLightSource extends LightSource {
7169
7108
  constructor(props) {
7170
- const { width, height, division = 4, z, vergence = 0 } = props;
7109
+ const { width, height, division = 4, z, vergence = 0, position, tilt } = props;
7171
7110
  if (division < 4) {
7172
7111
  throw new Error("division must be greater than 4");
7173
7112
  }
@@ -7188,6 +7127,7 @@ class GridLightSource extends LightSource {
7188
7127
  this.division = division;
7189
7128
  this.z = z;
7190
7129
  this.vergence = vergence;
7130
+ this.configurePose({ position, tilt }, this.z);
7191
7131
  const xStep = this.division > 1 ? this.width / (this.division - 1) : 0;
7192
7132
  const yStep = this.division > 1 ? this.height / (this.division - 1) : 0;
7193
7133
  const xStart = -this.width / 2;
@@ -7204,7 +7144,7 @@ class GridLightSource extends LightSource {
7204
7144
  }
7205
7145
  class GridRGLightSource extends LightSource {
7206
7146
  constructor(props) {
7207
- const { width, height, division = 4, z, vergence = 0 } = props;
7147
+ const { width, height, division = 4, z, vergence = 0, position, tilt } = props;
7208
7148
  if (division < 4) {
7209
7149
  throw new Error("division must be greater than 4");
7210
7150
  }
@@ -7225,6 +7165,7 @@ class GridRGLightSource extends LightSource {
7225
7165
  this.division = division;
7226
7166
  this.z = z;
7227
7167
  this.vergence = vergence;
7168
+ this.configurePose({ position, tilt }, this.z);
7228
7169
  const xStep = this.division > 1 ? this.width / (this.division - 1) : 0;
7229
7170
  const yStep = this.division > 1 ? this.height / (this.division - 1) : 0;
7230
7171
  const xStart = -this.width / 2;
@@ -7242,7 +7183,7 @@ class GridRGLightSource extends LightSource {
7242
7183
  }
7243
7184
  class RadialLightSource extends LightSource {
7244
7185
  constructor(props) {
7245
- const { radius, division = 4, angle_division = 4, z, vergence = 0 } = props;
7186
+ const { radius, division = 4, angle_division = 4, z, vergence = 0, position, tilt } = props;
7246
7187
  if (radius < 0) {
7247
7188
  throw new Error("radius must be greater than or equal to 0");
7248
7189
  }
@@ -7266,6 +7207,7 @@ class RadialLightSource extends LightSource {
7266
7207
  this.angle_division = angle_division;
7267
7208
  this.z = z;
7268
7209
  this.vergence = vergence;
7210
+ this.configurePose({ position, tilt }, this.z);
7269
7211
  this.createRayFromPoint(new Vector3(0, 0, this.z), this.z, this.vergence);
7270
7212
  for (let ring = 1; ring <= this.division; ring += 1) {
7271
7213
  const ringRadius = (this.radius * ring) / this.division;
@@ -7280,28 +7222,6 @@ class RadialLightSource extends LightSource {
7280
7222
  }
7281
7223
  }
7282
7224
 
7283
- function isFiniteNumber(value) {
7284
- return typeof value === "number" && Number.isFinite(value);
7285
- }
7286
- function resolveRefractiveIndex(spec, line) {
7287
- if (isFiniteNumber(spec))
7288
- return spec;
7289
- const lineValue = spec[line];
7290
- if (isFiniteNumber(lineValue))
7291
- return lineValue;
7292
- const dValue = spec.d;
7293
- if (isFiniteNumber(dValue))
7294
- return dValue;
7295
- return 1.0;
7296
- }
7297
- function normalizeRefractiveIndexSpec(spec) {
7298
- if (!isFiniteNumber(spec))
7299
- return spec;
7300
- const entries = Object.values(FRAUNHOFER_REFRACTIVE_INDICES);
7301
- const matched = entries.find((item) => isFiniteNumber(item.d) && Math.abs(item.d - spec) < 1e-6);
7302
- return matched ?? spec;
7303
- }
7304
-
7305
7225
  class Surface {
7306
7226
  constructor(props) {
7307
7227
  this.type = "";
@@ -7321,47 +7241,344 @@ class Surface {
7321
7241
  this.incidentRays = [];
7322
7242
  this.refractedRays = [];
7323
7243
  }
7244
+ getWorldPosition() {
7245
+ return this.position.clone();
7246
+ }
7247
+ setPositionAndTilt(position, tiltXDeg, tiltYDeg) {
7248
+ this.position.copy(position);
7249
+ this.tilt.set(tiltXDeg, tiltYDeg);
7250
+ }
7251
+ /**
7252
+ * 안질 tilt가 0인 표면을 전제로, pivot 기준 강체 회전 후 동일 Euler(XYZ) tilt를 부여합니다.
7253
+ */
7254
+ applyRigidRotationAboutPivot(pivot, rotation) {
7255
+ const p1 = pivot.clone().add(new Vector3().subVectors(this.position, pivot).applyQuaternion(rotation));
7256
+ const euler = new Euler().setFromQuaternion(rotation, "XYZ");
7257
+ this.position.copy(p1);
7258
+ this.tilt.set((euler.x * 180) / Math.PI, (euler.y * 180) / Math.PI);
7259
+ }
7324
7260
  }
7325
7261
 
7326
- class AsphericalSurface extends Surface {
7327
- constructor(props) {
7328
- super({ type: "aspherical", name: props.name, position: props.position, tilt: props.tilt });
7329
- this.r = 0;
7330
- this.conic = 0;
7331
- this.n_before = 1.0;
7332
- this.n_after = 1.0;
7333
- const { r, conic = -1, n_before = 1.0, n_after = 1.0 } = props;
7334
- this.r = r;
7335
- this.conic = conic;
7336
- this.n_before = normalizeRefractiveIndexSpec(n_before);
7337
- this.n_after = normalizeRefractiveIndexSpec(n_after);
7262
+ /**
7263
+ * 2D affine 왜곡 추정 전용 클래스입니다.
7264
+ * [x'; y'] = [[a b c], [d e f]] * [x y 1]^T 형태를 최소자승으로 적합합니다.
7265
+ */
7266
+ class Affine {
7267
+ constructor() {
7268
+ this.lastResult = null;
7269
+ this.lastPairs = [];
7338
7270
  }
7339
- refractiveIndicesForRay(ray) {
7340
- const line = ray.getFraunhoferLine();
7341
- return {
7342
- nBefore: resolveRefractiveIndex(this.n_before, line),
7343
- nAfter: resolveRefractiveIndex(this.n_after, line),
7271
+ estimate(pairs) {
7272
+ const inputPairs = Array.isArray(pairs) ? pairs : [];
7273
+ this.lastPairs = inputPairs;
7274
+ const affine = this.fitAffine2D(inputPairs);
7275
+ if (!affine) {
7276
+ this.lastResult = null;
7277
+ return null;
7278
+ }
7279
+ let residualSumPct = 0;
7280
+ let residualCount = 0;
7281
+ let residualMaxPct = 0;
7282
+ const residuals = [];
7283
+ for (const pair of inputPairs) {
7284
+ const px = affine.a * pair.sx + affine.b * pair.sy + affine.c;
7285
+ const py = affine.d * pair.sx + affine.e * pair.sy + affine.f;
7286
+ const rx = pair.tx - px;
7287
+ const ry = pair.ty - py;
7288
+ const magnitude = Math.hypot(rx, ry);
7289
+ if (magnitude < 1e-4)
7290
+ continue;
7291
+ const radiusRef = Math.hypot(px, py);
7292
+ const pct = (magnitude / Math.max(0.2, radiusRef)) * 100;
7293
+ residualSumPct += pct;
7294
+ residualCount += 1;
7295
+ residualMaxPct = Math.max(residualMaxPct, pct);
7296
+ residuals.push({ sx: pair.sx, sy: pair.sy, px, py, rx, ry, magnitude, pct });
7297
+ }
7298
+ const result = {
7299
+ ...affine,
7300
+ count: inputPairs.length,
7301
+ residualAvgPct: residualCount ? residualSumPct / residualCount : 0,
7302
+ residualMaxPct,
7303
+ residuals,
7344
7304
  };
7305
+ this.lastResult = result;
7306
+ return result;
7345
7307
  }
7346
7308
  /**
7347
- * 비구면 사그(sag)와 기울기(미분)를 계산합니다.
7348
- *
7349
- * 회전대칭 비구면 식:
7350
- * z = c*rho^2 / (1 + sqrt(1 - (1+k)*c^2*rho^2))
7351
- * - c = 1 / R (곡률)
7352
- * - k = conic constant
7353
- * - rho^2 = x^2 + y^2
7354
- *
7355
- * 반환값:
7356
- * - sag: 꼭지점 기준 z 변위(mm)
7357
- * - dzdx, dzdy: 표면 기울기 (법선 계산과 뉴턴법 미분에 사용)
7309
+ * 마지막 affine 추정 결과를 반환합니다.
7358
7310
  */
7359
- geometryAtXY(x, y) {
7360
- if (!Number.isFinite(this.r) || Math.abs(this.r) < EPSILON)
7311
+ getLastResult() {
7312
+ return this.lastResult;
7313
+ }
7314
+ /**
7315
+ * 마지막 affine 추정에 사용된 입력쌍을 반환합니다.
7316
+ */
7317
+ getLastPairs() {
7318
+ return [...this.lastPairs];
7319
+ }
7320
+ fitAffine2D(pairs) {
7321
+ if (!Array.isArray(pairs) || pairs.length < 4)
7361
7322
  return null;
7362
- const curvature = 1 / this.r;
7363
- const rho2 = x * x + y * y;
7364
- const onePlusConic = 1 + this.conic;
7323
+ const ata = Array.from({ length: 6 }, () => Array(6).fill(0));
7324
+ const atb = Array(6).fill(0);
7325
+ const accumulate = (row, rhs) => {
7326
+ for (let i = 0; i < 6; i += 1) {
7327
+ atb[i] += row[i] * rhs;
7328
+ for (let j = 0; j < 6; j += 1)
7329
+ ata[i][j] += row[i] * row[j];
7330
+ }
7331
+ };
7332
+ for (const pair of pairs) {
7333
+ accumulate([pair.sx, pair.sy, 1, 0, 0, 0], pair.tx);
7334
+ accumulate([0, 0, 0, pair.sx, pair.sy, 1], pair.ty);
7335
+ }
7336
+ const n = 6;
7337
+ const aug = ata.map((row, index) => [...row, atb[index]]);
7338
+ for (let col = 0; col < n; col += 1) {
7339
+ let pivot = col;
7340
+ for (let row = col + 1; row < n; row += 1) {
7341
+ if (Math.abs(aug[row][col]) > Math.abs(aug[pivot][col]))
7342
+ pivot = row;
7343
+ }
7344
+ if (Math.abs(aug[pivot][col]) < 1e-10)
7345
+ return null;
7346
+ if (pivot !== col) {
7347
+ const temp = aug[col];
7348
+ aug[col] = aug[pivot];
7349
+ aug[pivot] = temp;
7350
+ }
7351
+ const divider = aug[col][col];
7352
+ for (let j = col; j <= n; j += 1)
7353
+ aug[col][j] /= divider;
7354
+ for (let row = 0; row < n; row += 1) {
7355
+ if (row === col)
7356
+ continue;
7357
+ const factor = aug[row][col];
7358
+ if (Math.abs(factor) < 1e-12)
7359
+ continue;
7360
+ for (let j = col; j <= n; j += 1)
7361
+ aug[row][j] -= factor * aug[col][j];
7362
+ }
7363
+ }
7364
+ return {
7365
+ a: aug[0][n],
7366
+ b: aug[1][n],
7367
+ c: aug[2][n],
7368
+ d: aug[3][n],
7369
+ e: aug[4][n],
7370
+ f: aug[5][n],
7371
+ };
7372
+ }
7373
+ }
7374
+
7375
+ /** 각막 기준 안구 회전점 z(mm). `SCAXEngineCore`와 동일. */
7376
+ const EYE_ROTATION_PIVOT_FROM_CORNEA_MM = 13;
7377
+ function normalizePrismAmount$2(value) {
7378
+ const p = Number(value ?? 0);
7379
+ return Number.isFinite(p) ? Math.max(0, p) : 0;
7380
+ }
7381
+ function normalizeAngle360$2(value) {
7382
+ const d = Number(value ?? 0);
7383
+ if (!Number.isFinite(d))
7384
+ return 0;
7385
+ return ((d % 360) + 360) % 360;
7386
+ }
7387
+ function prismVectorFromBase(prismDiopter, baseAngleDeg) {
7388
+ const p = normalizePrismAmount$2(prismDiopter);
7389
+ const baseAngle = normalizeAngle360$2(baseAngleDeg);
7390
+ const deviationAngle = normalizeAngle360$2(baseAngle + 180);
7391
+ const rad = (-deviationAngle * Math.PI) / 180;
7392
+ return {
7393
+ x: p * Math.cos(rad),
7394
+ y: p * Math.sin(rad),
7395
+ };
7396
+ }
7397
+ function prismComponentToAngleDeg(componentPrism) {
7398
+ const c = Number(componentPrism);
7399
+ if (!Number.isFinite(c))
7400
+ return 0;
7401
+ return (Math.atan(c / 100) * 180) / Math.PI;
7402
+ }
7403
+ /** eye 처방(Base) → 실제 안구 편향에 쓰는 역벡터(내부 x/y, Δ). */
7404
+ function eyePrismEffectVectorFromPrescription(p, p_ax) {
7405
+ const eyeRx = prismVectorFromBase(normalizePrismAmount$2(p), normalizeAngle360$2(p_ax));
7406
+ return { x: -eyeRx.x, y: -eyeRx.y };
7407
+ }
7408
+ /**
7409
+ * eye.p / eye.p_ax(임상 Base) 및 eye.tilt(°)로부터
7410
+ * 안구 강체 회전 쿼터니언(Euler XYZ, z=0). 기존 configure 광선 피벗 회전과 동일.
7411
+ */
7412
+ function eyePrismAndTiltToQuaternion(p, p_ax, tiltDeg) {
7413
+ const { x, y } = eyePrismEffectVectorFromPrescription(p, p_ax);
7414
+ const eyeRotXDeg = prismComponentToAngleDeg(x);
7415
+ const eyeRotYDeg = prismComponentToAngleDeg(y);
7416
+ const eyeEulerXDeg = eyeRotYDeg + tiltDeg.x;
7417
+ const eyeEulerYDeg = (-eyeRotXDeg) + tiltDeg.y;
7418
+ return new Quaternion().setFromEuler(new Euler((eyeEulerXDeg * Math.PI) / 180, (eyeEulerYDeg * Math.PI) / 180, 0, "XYZ"));
7419
+ }
7420
+ function eyeRotationPivot() {
7421
+ return new Vector3(0, 0, EYE_ROTATION_PIVOT_FROM_CORNEA_MM);
7422
+ }
7423
+ /** eye prism/tilt를 반영한 렌더용 각도(°). */
7424
+ function eyeRotationForRenderDegrees(p, p_ax, tiltDeg) {
7425
+ const { x, y } = eyePrismEffectVectorFromPrescription(p, p_ax);
7426
+ const eyeRotXDeg = prismComponentToAngleDeg(x);
7427
+ const eyeRotYDeg = prismComponentToAngleDeg(y);
7428
+ const eyeEulerXDeg = eyeRotYDeg + tiltDeg.x;
7429
+ const eyeEulerYDeg = (-eyeRotXDeg) + tiltDeg.y;
7430
+ const xDeg = eyeEulerYDeg;
7431
+ const yDeg = -eyeEulerXDeg;
7432
+ return {
7433
+ x_deg: xDeg,
7434
+ y_deg: yDeg,
7435
+ magnitude_deg: Math.hypot(xDeg, yDeg),
7436
+ };
7437
+ }
7438
+ /**
7439
+ * 안구 회전점 기준 강체 회전을 각 표면의 world `position` / `tilt`에 반영합니다.
7440
+ */
7441
+ function applyRigidEyePoseToSurfaces(surfaces, pivot, rotation) {
7442
+ for (const surface of surfaces) {
7443
+ surface.applyRigidRotationAboutPivot(pivot, rotation);
7444
+ }
7445
+ }
7446
+
7447
+ class ApertureStopSurface extends Surface {
7448
+ constructor(props) {
7449
+ super({
7450
+ type: "aperture_stop",
7451
+ name: props.name,
7452
+ position: props.position,
7453
+ tilt: props.tilt,
7454
+ });
7455
+ this.shape = props.shape;
7456
+ this.radius = Math.max(0, Number(props.radius ?? 0));
7457
+ this.width = Math.max(0, Number(props.width ?? 0));
7458
+ this.height = Math.max(0, Number(props.height ?? 0));
7459
+ }
7460
+ worldQuaternion() {
7461
+ const tiltXRad = (this.tilt.x * Math.PI) / 180;
7462
+ const tiltYRad = (this.tilt.y * Math.PI) / 180;
7463
+ return new Quaternion().setFromEuler(new Euler(tiltXRad, tiltYRad, 0, "XYZ"));
7464
+ }
7465
+ localPointFromWorld(worldPoint) {
7466
+ const inverse = this.worldQuaternion().invert();
7467
+ return worldPoint
7468
+ .clone()
7469
+ .sub(this.position)
7470
+ .applyQuaternion(inverse);
7471
+ }
7472
+ surfaceNormalWorld() {
7473
+ return new Vector3(0, 0, 1).applyQuaternion(this.worldQuaternion()).normalize();
7474
+ }
7475
+ intersectForward(origin, direction) {
7476
+ const normal = this.surfaceNormalWorld();
7477
+ const denom = normal.dot(direction);
7478
+ if (Math.abs(denom) < EPSILON)
7479
+ return null;
7480
+ const t = normal.dot(this.position.clone().sub(origin)) / denom;
7481
+ if (!Number.isFinite(t) || t <= 1e-6)
7482
+ return null;
7483
+ return origin.clone().addScaledVector(direction, t);
7484
+ }
7485
+ isInsideAperture(hitPointWorld) {
7486
+ const local = this.localPointFromWorld(hitPointWorld);
7487
+ if (this.shape === "circle") {
7488
+ if (this.radius <= 0)
7489
+ return false;
7490
+ return Math.hypot(local.x, local.y) <= this.radius + 1e-9;
7491
+ }
7492
+ if (this.width <= 0 || this.height <= 0)
7493
+ return false;
7494
+ return (Math.abs(local.x) <= (this.width / 2) + 1e-9
7495
+ && Math.abs(local.y) <= (this.height / 2) + 1e-9);
7496
+ }
7497
+ incident(ray) {
7498
+ const origin = ray.endPoint();
7499
+ const direction = ray.getDirection().normalize();
7500
+ const hitPoint = this.intersectForward(origin, direction);
7501
+ if (!hitPoint)
7502
+ return null;
7503
+ if (!this.isInsideAperture(hitPoint))
7504
+ return null;
7505
+ this.incidentRays.push(ray.clone());
7506
+ return hitPoint;
7507
+ }
7508
+ refract(ray) {
7509
+ const hitPoint = this.incident(ray);
7510
+ if (!hitPoint)
7511
+ return null;
7512
+ const direction = ray.getDirection().normalize();
7513
+ const passedRay = ray.clone();
7514
+ passedRay.appendPoint(hitPoint);
7515
+ passedRay.continueFrom(hitPoint.clone().addScaledVector(direction, RAY_SURFACE_ESCAPE_MM), direction);
7516
+ this.refractedRays.push(passedRay.clone());
7517
+ return passedRay;
7518
+ }
7519
+ }
7520
+
7521
+ function isFiniteNumber(value) {
7522
+ return typeof value === "number" && Number.isFinite(value);
7523
+ }
7524
+ function resolveRefractiveIndex(spec, line) {
7525
+ if (isFiniteNumber(spec))
7526
+ return spec;
7527
+ const lineValue = spec[line];
7528
+ if (isFiniteNumber(lineValue))
7529
+ return lineValue;
7530
+ const dValue = spec.d;
7531
+ if (isFiniteNumber(dValue))
7532
+ return dValue;
7533
+ return 1.0;
7534
+ }
7535
+ function normalizeRefractiveIndexSpec(spec) {
7536
+ if (!isFiniteNumber(spec))
7537
+ return spec;
7538
+ const entries = Object.values(FRAUNHOFER_REFRACTIVE_INDICES);
7539
+ const matched = entries.find((item) => isFiniteNumber(item.d) && Math.abs(item.d - spec) < 1e-6);
7540
+ return matched ?? spec;
7541
+ }
7542
+
7543
+ class AsphericalSurface extends Surface {
7544
+ constructor(props) {
7545
+ super({ type: "aspherical", name: props.name, position: props.position, tilt: props.tilt });
7546
+ this.r = 0;
7547
+ this.conic = 0;
7548
+ this.n_before = 1.0;
7549
+ this.n_after = 1.0;
7550
+ const { r, conic = -1, n_before = 1.0, n_after = 1.0 } = props;
7551
+ this.r = r;
7552
+ this.conic = conic;
7553
+ this.n_before = normalizeRefractiveIndexSpec(n_before);
7554
+ this.n_after = normalizeRefractiveIndexSpec(n_after);
7555
+ }
7556
+ refractiveIndicesForRay(ray) {
7557
+ const line = ray.getFraunhoferLine();
7558
+ return {
7559
+ nBefore: resolveRefractiveIndex(this.n_before, line),
7560
+ nAfter: resolveRefractiveIndex(this.n_after, line),
7561
+ };
7562
+ }
7563
+ /**
7564
+ * 비구면 사그(sag)와 그 기울기(미분)를 계산합니다.
7565
+ *
7566
+ * 회전대칭 비구면 식:
7567
+ * z = c*rho^2 / (1 + sqrt(1 - (1+k)*c^2*rho^2))
7568
+ * - c = 1 / R (곡률)
7569
+ * - k = conic constant
7570
+ * - rho^2 = x^2 + y^2
7571
+ *
7572
+ * 반환값:
7573
+ * - sag: 꼭지점 기준 z 변위(mm)
7574
+ * - dzdx, dzdy: 표면 기울기 (법선 계산과 뉴턴법 미분에 사용)
7575
+ */
7576
+ geometryAtXY(x, y) {
7577
+ if (!Number.isFinite(this.r) || Math.abs(this.r) < EPSILON)
7578
+ return null;
7579
+ const curvature = 1 / this.r;
7580
+ const rho2 = x * x + y * y;
7581
+ const onePlusConic = 1 + this.conic;
7365
7582
  const b = 1 - onePlusConic * curvature * curvature * rho2;
7366
7583
  // 루트 내부가 큰 음수면 이 좌표는 표면 정의역 밖입니다.
7367
7584
  if (b < -1e-3)
@@ -7849,860 +8066,313 @@ class SphericalSurface extends Surface {
7849
8066
  }
7850
8067
  }
7851
8068
 
7852
- class EyeModelParameter {
7853
- constructor(parameter) {
7854
- this.parameter = parameter;
7855
- }
7856
- createSurface() {
7857
- return this.parameter.surfaces.map((surface) => {
7858
- if (surface.type === "spherical") {
7859
- return new SphericalSurface({
7860
- type: "spherical",
7861
- name: surface.name,
7862
- r: surface.radius,
7863
- position: { x: 0, y: 0, z: surface.z },
7864
- tilt: { x: 0, y: 0 },
7865
- n_before: surface.n_before,
7866
- n_after: surface.n_after,
7867
- });
7868
- }
7869
- if (surface.type === "aspherical") {
7870
- return new AsphericalSurface({
7871
- type: "aspherical",
7872
- name: surface.name,
7873
- position: { x: 0, y: 0, z: surface.z },
7874
- tilt: { x: 0, y: 0 },
7875
- r: surface.radius,
7876
- conic: surface.conic,
7877
- n_before: surface.n_before,
7878
- n_after: surface.n_after,
7879
- });
7880
- }
7881
- if (surface.type === "spherical-image") {
7882
- return new SphericalImageSurface({
7883
- type: "spherical-image",
7884
- name: surface.name,
7885
- r: surface.radius,
7886
- position: { x: 0, y: 0, z: surface.z },
7887
- tilt: { x: 0, y: 0 },
7888
- retina_extra_after: true,
7889
- });
7890
- }
7891
- throw new Error(`Unsupported surface type: ${surface.type}`);
7892
- });
7893
- }
8069
+ function normalizePrismDiopters(value) {
8070
+ const p = Number(value ?? 0);
8071
+ return Number.isFinite(p) ? Math.max(0, p) : 0;
7894
8072
  }
7895
-
7896
- class GullstrandParameter extends EyeModelParameter {
7897
- constructor() {
7898
- super(GullstrandParameter.parameter);
7899
- }
8073
+ function normalizeAngle360Degrees(value) {
8074
+ const d = Number(value ?? 0);
8075
+ if (!Number.isFinite(d))
8076
+ return 0;
8077
+ return ((d % 360) + 360) % 360;
7900
8078
  }
7901
- GullstrandParameter.parameter = {
7902
- unit: "mm",
7903
- axis: "optical_axis_z",
7904
- origin: "cornea_anterior_vertex",
7905
- surfaces: [
7906
- {
7907
- type: "spherical",
7908
- name: "cornea_anterior",
7909
- z: 0.0,
7910
- radius: 7.7,
7911
- n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
7912
- n_after: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
7913
- },
7914
- {
7915
- type: "spherical",
7916
- name: "cornea_posterior",
7917
- z: 0.5,
7918
- radius: 6.8,
7919
- n_before: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
7920
- n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
7921
- },
7922
- {
7923
- type: "spherical",
7924
- name: "lens_anterior",
7925
- z: 3.6,
7926
- radius: 10.0,
7927
- n_before: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
7928
- n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_anterior,
7929
- },
7930
- {
7931
- type: "spherical",
7932
- name: "lens_nucleus_anterior",
7933
- z: 4.146,
7934
- radius: 7.911,
7935
- n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_anterior,
7936
- n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_anterior,
7937
- },
7938
- {
7939
- type: "spherical",
7940
- name: "lens_nucleus_posterior",
7941
- z: 6.565,
7942
- radius: -5.76,
7943
- n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_anterior,
7944
- n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_posterior,
7945
- },
7946
- {
7947
- type: "spherical",
7948
- name: "lens_posterior",
7949
- z: 7.2,
7950
- radius: -6,
7951
- n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_posterior,
7952
- n_after: FRAUNHOFER_REFRACTIVE_INDICES.vitreous,
7953
- },
7954
- {
7955
- type: "spherical-image",
7956
- name: "retina",
7957
- radius: -12, // mm (대략적인 망막 곡률)
7958
- z: 24.0 // 중심 위치
7959
- }
7960
- ],
7961
- };
7962
-
7963
- class NavarroParameter extends EyeModelParameter {
7964
- constructor() {
7965
- super(NavarroParameter.parameter);
7966
- }
8079
+ /**
8080
+ * 임상 프리즘 Base(Δ, 렌즈→각막 시점 °)를 내부 x/y 프리즘 벡터(Δ)로 변환합니다.
8081
+ * `SCAXEngineCore`와 동일한 관례입니다.
8082
+ */
8083
+ function prismVectorFromClinicalBase(prismDiopter, baseAngleDeg) {
8084
+ const p = normalizePrismDiopters(prismDiopter);
8085
+ const baseAngle = normalizeAngle360Degrees(baseAngleDeg);
8086
+ const deviationAngle = normalizeAngle360Degrees(baseAngle + 180);
8087
+ const rad = (-deviationAngle * Math.PI) / 180;
8088
+ return {
8089
+ x: p * Math.cos(rad),
8090
+ y: p * Math.sin(rad),
8091
+ };
7967
8092
  }
7968
- NavarroParameter.parameter = {
7969
- unit: "mm",
7970
- axis: "optical_axis_z",
7971
- surfaces: [
7972
- {
7973
- type: "aspherical",
7974
- name: "cornea_anterior",
7975
- z: 0.0,
7976
- radius: 7.72,
7977
- conic: -0.26,
7978
- n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
7979
- n_after: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
7980
- },
7981
- {
7982
- type: "aspherical",
7983
- name: "cornea_posterior",
7984
- z: 0.55,
7985
- radius: 6.5,
7986
- conic: 0.0,
7987
- n_before: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
7988
- n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
7989
- },
7990
- {
7991
- type: "aspherical",
7992
- name: "lens_anterior",
7993
- z: 0.55 + 3.05,
7994
- radius: 10.2,
7995
- conic: -3.13,
7996
- n_before: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
7997
- n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_navarro,
7998
- },
7999
- {
8000
- type: "aspherical",
8001
- name: "lens_posterior",
8002
- z: 0.55 + 3.05 + 4.0,
8003
- radius: -6,
8004
- conic: -1,
8005
- n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_navarro,
8006
- n_after: FRAUNHOFER_REFRACTIVE_INDICES.vitreous,
8007
- },
8008
- {
8009
- type: "spherical-image",
8010
- name: "retina",
8011
- radius: -12, // mm (대략적인 망막 곡률)
8012
- z: 24.04 // 중심 위치
8013
- }
8014
- ],
8015
- };
8016
-
8017
8093
  /**
8018
- * Sturm 계산 전용 클래스입니다.
8019
- * - traced ray 집합에서 z-scan slice를 생성하고
8020
- * - 평탄도/최소타원/근사중심을 계산해 분석 결과를 반환합니다.
8094
+ * 굴절 직후 광선에 프리즘 편향(소각 근사)을 적용합니다.
8021
8095
  */
8022
- class Sturm {
8023
- constructor() {
8024
- this.lastResult = null;
8025
- this.lineOrder = ["g", "F", "e", "d", "C", "r"];
8096
+ function applyPrismVectorToRay(ray, prism) {
8097
+ const px = Number(prism?.x ?? 0);
8098
+ const py = Number(prism?.y ?? 0);
8099
+ if (!Number.isFinite(px)
8100
+ || !Number.isFinite(py)
8101
+ || (Math.abs(px) < 1e-12 && Math.abs(py) < 1e-12)) {
8102
+ return ray;
8103
+ }
8104
+ const direction = ray.getDirection();
8105
+ const dz = Number(direction.z);
8106
+ if (!Number.isFinite(dz) || Math.abs(dz) < 1e-12)
8107
+ return ray;
8108
+ const tx = (direction.x / dz) + (px / 100);
8109
+ const ty = (direction.y / dz) + (py / 100);
8110
+ const signZ = dz >= 0 ? 1 : -1;
8111
+ const newDirection = new Vector3(tx * signZ, ty * signZ, signZ).normalize();
8112
+ if (!Number.isFinite(newDirection.x) || !Number.isFinite(newDirection.y) || !Number.isFinite(newDirection.z)) {
8113
+ return ray;
8114
+ }
8115
+ const updated = ray.clone();
8116
+ const origin = updated.endPoint();
8117
+ updated.continueFrom(origin.clone().addScaledVector(newDirection, RAY_SURFACE_ESCAPE_MM), newDirection);
8118
+ return updated;
8119
+ }
8120
+
8121
+ const DegToTABO = (degree) => {
8122
+ const d = Number(degree);
8123
+ if (!Number.isFinite(d))
8124
+ return 0;
8125
+ return (((180 - d) % 180) + 180) % 180;
8126
+ };
8127
+
8128
+ class ToricSurface extends Surface {
8129
+ constructor(props) {
8130
+ super({ type: "toric", name: props.name, position: props.position, tilt: props.tilt });
8131
+ this.r_axis = 0;
8132
+ this.r_perp = 0;
8133
+ this.n_before = 1.0;
8134
+ this.n_after = 1.0;
8135
+ this.axisDeg = 0;
8136
+ const { r_axis, r_perp, axis_deg = 0, n_before = 1.0, n_after = 1.0 } = props;
8137
+ this.r_axis = r_axis;
8138
+ this.r_perp = r_perp;
8139
+ this.axisDeg = axis_deg;
8140
+ this.n_before = normalizeRefractiveIndexSpec(n_before);
8141
+ this.n_after = normalizeRefractiveIndexSpec(n_after);
8026
8142
  }
8027
- calculate(rays, effectiveCylinderD, axisReferenceRays, profileWorldZBounds) {
8028
- const frame = this.analysisFrameFromRays(axisReferenceRays?.length ? axisReferenceRays : rays);
8029
- const depthRange = this.depthRangeFromRays(rays, frame);
8030
- const sturmSlices = this.collectSturmSlices(rays, frame, depthRange, DEFAULT_STURM_STEP_MM);
8031
- const groupedByLine = this.groupByFraunhoferLine(rays);
8032
- const sturmInfo = groupedByLine.map((group) => {
8033
- const groupFrame = this.analysisFrameFromRays(group.rays, frame);
8034
- const groupDepthRange = this.depthRangeFromRays(group.rays, groupFrame);
8035
- const slices = this.collectSturmSlices(group.rays, groupFrame, groupDepthRange, DEFAULT_STURM_STEP_MM);
8036
- const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD, profileWorldZBounds);
8037
- return {
8038
- line: group.line,
8039
- wavelength_nm: group.wavelength_nm,
8040
- color: group.color,
8041
- ray_count: group.rays.length,
8042
- analysis_axis: {
8043
- x: groupFrame.axis.x,
8044
- y: groupFrame.axis.y,
8045
- z: groupFrame.axis.z,
8046
- },
8047
- ...analysis,
8048
- };
8049
- });
8050
- const result = {
8051
- slices_info: {
8052
- count: sturmSlices.length,
8053
- slices: sturmSlices,
8054
- },
8055
- sturm_info: sturmInfo,
8143
+ refractiveIndicesForRay(ray) {
8144
+ const line = ray.getFraunhoferLine();
8145
+ return {
8146
+ nBefore: resolveRefractiveIndex(this.n_before, line),
8147
+ nAfter: resolveRefractiveIndex(this.n_after, line),
8056
8148
  };
8057
- this.lastResult = result;
8058
- return result;
8059
8149
  }
8060
8150
  /**
8061
- * 마지막 Sturm 계산 결과를 반환합니다.
8151
+ * Toric 면의 축(meridian) 회전을 위해 사용하는 삼각함수 값입니다.
8152
+ * 축(axis) 회전은 axisDeg(도 단위)를 사용합니다.
8062
8153
  */
8063
- getLastResult() {
8064
- return this.lastResult;
8154
+ axisTrig() {
8155
+ const rad = (this.axisDeg * Math.PI) / 180;
8156
+ return { c: Math.cos(rad), s: Math.sin(rad) };
8065
8157
  }
8066
- getRayPoints(ray) {
8067
- const points = ray.points;
8068
- return Array.isArray(points) ? points : [];
8158
+ worldQuaternion() {
8159
+ const tiltXRad = (this.tilt.x * Math.PI) / 180;
8160
+ const tiltYRad = (this.tilt.y * Math.PI) / 180;
8161
+ return new Quaternion().setFromEuler(new Euler(tiltXRad, tiltYRad, 0, "XYZ"));
8069
8162
  }
8070
- analysisFrameFromRays(rays, fallback) {
8071
- const axis = new Vector3();
8072
- for (const ray of rays ?? [])
8073
- axis.add(ray.getDirection());
8074
- if (axis.lengthSq() < 1e-12) {
8075
- if (fallback)
8076
- return fallback;
8077
- axis.set(0, 0, 1);
8078
- }
8079
- else {
8080
- axis.normalize();
8081
- }
8082
- const origin = new Vector3(0, 0, 0);
8083
- let helper = new Vector3(0, 1, 0);
8084
- if (Math.abs(helper.dot(axis)) > 0.95)
8085
- helper = new Vector3(1, 0, 0);
8086
- const u = helper.clone().cross(axis).normalize();
8087
- const v = axis.clone().cross(u).normalize();
8088
- return { origin, axis, u, v };
8163
+ worldPointToLocal(worldPoint) {
8164
+ const inverse = this.worldQuaternion().invert();
8165
+ return worldPoint.clone().sub(this.position).applyQuaternion(inverse);
8089
8166
  }
8090
- sampleRayPointAtDepth(ray, frame, depth) {
8091
- const points = this.getRayPoints(ray);
8092
- for (let i = 0; i < points.length - 1; i += 1) {
8093
- const a = points[i];
8094
- const b = points[i + 1];
8095
- const da = a.clone().sub(frame.origin).dot(frame.axis);
8096
- const db = b.clone().sub(frame.origin).dot(frame.axis);
8097
- if ((da <= depth && depth <= db) || (db <= depth && depth <= da)) {
8098
- const denom = db - da;
8099
- if (Math.abs(denom) < 1e-10)
8100
- return null;
8101
- return a.clone().lerp(b, (depth - da) / denom);
8102
- }
8103
- }
8104
- return null;
8167
+ localPointToWorld(localPoint) {
8168
+ return localPoint.clone().applyQuaternion(this.worldQuaternion()).add(this.position);
8105
8169
  }
8106
- depthRangeFromRays(rays, frame) {
8107
- let depthMin = Number.POSITIVE_INFINITY;
8108
- let depthMax = Number.NEGATIVE_INFINITY;
8109
- for (const ray of rays ?? []) {
8110
- for (const point of this.getRayPoints(ray)) {
8111
- const d = point.clone().sub(frame.origin).dot(frame.axis);
8112
- depthMin = Math.min(depthMin, d);
8113
- depthMax = Math.max(depthMax, d);
8114
- }
8115
- }
8116
- if (!Number.isFinite(depthMin) || !Number.isFinite(depthMax) || depthMax <= depthMin)
8117
- return null;
8118
- return { depthMin, depthMax };
8170
+ worldDirToLocal(worldDirection) {
8171
+ const inverse = this.worldQuaternion().invert();
8172
+ return worldDirection.clone().applyQuaternion(inverse).normalize();
8119
8173
  }
8120
- secondMomentProfileAtDepth(rays, frame, depth) {
8121
- const points = [];
8122
- for (const ray of rays) {
8123
- const point = this.sampleRayPointAtDepth(ray, frame, depth);
8124
- if (point)
8125
- points.push(point);
8126
- }
8127
- if (points.length < 4)
8128
- return null;
8129
- let cxWorld = 0;
8130
- let cyWorld = 0;
8131
- let czWorld = 0;
8132
- let cx = 0;
8133
- let cy = 0;
8134
- for (const p of points) {
8135
- cxWorld += p.x;
8136
- cyWorld += p.y;
8137
- czWorld += p.z;
8138
- const delta = p.clone().sub(frame.origin);
8139
- cx += delta.dot(frame.u);
8140
- cy += delta.dot(frame.v);
8141
- }
8142
- cxWorld /= points.length;
8143
- cyWorld /= points.length;
8144
- czWorld /= points.length;
8145
- cx /= points.length;
8146
- cy /= points.length;
8147
- let sxx = 0;
8148
- let syy = 0;
8149
- let sxy = 0;
8150
- for (const p of points) {
8151
- const delta = p.clone().sub(frame.origin);
8152
- const x = delta.dot(frame.u);
8153
- const y = delta.dot(frame.v);
8154
- const dx = x - cx;
8155
- const dy = y - cy;
8156
- sxx += dx * dx;
8157
- syy += dy * dy;
8158
- sxy += dx * dy;
8159
- }
8160
- sxx /= points.length;
8161
- syy /= points.length;
8162
- sxy /= points.length;
8163
- const trace = sxx + syy;
8164
- const halfDiff = (sxx - syy) / 2;
8165
- const root = Math.sqrt(Math.max(0, halfDiff * halfDiff + sxy * sxy));
8166
- const lambdaMajor = Math.max(0, trace / 2 + root);
8167
- const lambdaMinor = Math.max(0, trace / 2 - root);
8168
- const thetaRad = 0.5 * Math.atan2(2 * sxy, sxx - syy);
8169
- const angleMajorDeg = ((thetaRad * 180) / Math.PI + 360) % 180;
8170
- const twoThetaRad = 2 * thetaRad;
8171
- const j0 = Math.cos(twoThetaRad);
8172
- const j45 = Math.sin(twoThetaRad);
8173
- const majorDirection = frame.u.clone().multiplyScalar(Math.cos(thetaRad))
8174
- .add(frame.v.clone().multiplyScalar(Math.sin(thetaRad)))
8175
- .normalize();
8176
- const minorDirection = frame.axis.clone().cross(majorDirection).normalize();
8174
+ localDirToWorld(localDirection) {
8175
+ return localDirection.clone().applyQuaternion(this.worldQuaternion()).normalize();
8176
+ }
8177
+ /**
8178
+ * 월드 좌표계 (x, y)를 토릭 로컬 좌표계 (u, v)로 변환합니다.
8179
+ * - u: 축 방향 meridian
8180
+ * - v: 축에 수직인 meridian
8181
+ */
8182
+ toLocalUV(x, y) {
8183
+ const { c, s } = this.axisTrig();
8177
8184
  return {
8178
- at: { x: cxWorld, y: cyWorld, z: czWorld },
8179
- wMajor: Math.sqrt(lambdaMajor),
8180
- wMinor: Math.sqrt(lambdaMinor),
8181
- angleMajorDeg,
8182
- j0,
8183
- j45,
8184
- angleMinorDeg: (angleMajorDeg + 90) % 180,
8185
- majorDirection: {
8186
- x: majorDirection.x,
8187
- y: majorDirection.y,
8188
- z: majorDirection.z,
8189
- },
8190
- minorDirection: {
8191
- x: minorDirection.x,
8192
- y: minorDirection.y,
8193
- z: minorDirection.z,
8194
- },
8185
+ u: c * x + s * y,
8186
+ v: -s * x + c * y,
8195
8187
  };
8196
8188
  }
8197
- collectSturmSlices(rays, frame, depthRange, stepMm) {
8198
- if (!depthRange)
8199
- return [];
8200
- const out = [];
8201
- for (let depth = depthRange.depthMin; depth <= depthRange.depthMax; depth += stepMm) {
8202
- const profile = this.secondMomentProfileAtDepth(rays, frame, depth);
8203
- if (!profile)
8204
- continue;
8205
- out.push({
8206
- // Keep z in world coordinates for backward-compatible consumers.
8207
- z: profile.at.z,
8208
- // Preserve analysis-axis depth for off-axis robust ranking/interval logic.
8209
- depth,
8210
- ratio: profile.wMinor / Math.max(profile.wMajor, 1e-9),
8211
- size: Math.hypot(profile.wMajor, profile.wMinor),
8212
- profile,
8213
- });
8214
- }
8215
- return out;
8216
- }
8217
- axisDiffDeg(a, b) {
8218
- const d = Math.abs((((a - b) % 180) + 180) % 180);
8219
- return Math.min(d, 180 - d);
8189
+ /**
8190
+ * 로컬 좌표계에서 계산한 sag 미분(dz/du, dz/dv)
8191
+ * 월드 좌표계의 기울기(dz/dx, dz/dy)로 다시 매핑합니다.
8192
+ */
8193
+ localDerivativesToWorld(dZdu, dZdv) {
8194
+ const { c, s } = this.axisTrig();
8195
+ return {
8196
+ dzdx: dZdu * c - dZdv * s,
8197
+ dzdy: dZdu * s + dZdv * c,
8198
+ };
8220
8199
  }
8221
- phasorDot(p, q) {
8222
- return p.j0 * q.j0 + p.j45 * q.j45;
8200
+ /**
8201
+ * 반경으로부터 곡률(1/R)을 계산합니다.
8202
+ * - 반경이 너무 크거나 무한대면 평면으로 간주하여 0 반환
8203
+ * - 반경이 0에 너무 가까우면 비정상 값으로 NaN 반환
8204
+ */
8205
+ curvature(radius) {
8206
+ if (!Number.isFinite(radius) || Math.abs(radius) > 1e12)
8207
+ return 0;
8208
+ if (Math.abs(radius) < EPSILON)
8209
+ return Number.NaN;
8210
+ return 1 / radius;
8223
8211
  }
8224
8212
  /**
8225
- * 평탄도 순으로 정렬된 슬라이스에서 선초점 쌍(직교 주경선)에 해당하는 Top2를 고른다.
8226
- * 주경선 대신 이각 phasor (cos 2θ, sin 2θ)로 직교를 판정하고, 실패 시 각도·전역 검색·깊이 폴백을 사용한다.
8213
+ * 주어진 XY에서 토릭면의 기하정보를 계산합니다.
8214
+ * - sag: 꼭지점 대비 z 높이
8215
+ * - dzdx/dzdy: 면 기울기
8216
+ * - normal: 2번째 매질 방향을 만들 때 사용할 기본 법선
8227
8217
  */
8228
- pickFlattestSturmPair(sortedByFlatness, top2MinGapMm, allSlices) {
8229
- if (sortedByFlatness.length === 0)
8230
- return [];
8231
- const first = sortedByFlatness[0];
8232
- const top2MinAngleGapDeg = DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG;
8233
- const phasorStrict = DEFAULT_STURM_TOP2_PHASOR_OPPOSITION_MAX_DOT;
8234
- const phasorLoose = DEFAULT_STURM_TOP2_PHASOR_FALLBACK_MAX_DOT;
8235
- const lastResortMinDeg = DEFAULT_STURM_TOP2_AXIS_LAST_RESORT_MIN_GAP_DEG;
8236
- const depthOk = (c) => Math.abs(c.depth - first.depth) >= top2MinGapMm;
8237
- const byPhasor = (pool, maxDot) => pool.find((c) => (c !== first
8238
- && depthOk(c)
8239
- && this.phasorDot(first.profile, c.profile) <= maxDot));
8240
- let second = byPhasor(sortedByFlatness, phasorStrict)
8241
- ?? byPhasor(sortedByFlatness, phasorLoose);
8242
- if (!second) {
8243
- second = sortedByFlatness.find((c) => (c !== first
8244
- && depthOk(c)
8245
- && this.axisDiffDeg(c.profile.angleMajorDeg, first.profile.angleMajorDeg) >= top2MinAngleGapDeg));
8246
- }
8247
- if (!second) {
8248
- let best = null;
8249
- let bestAxDiff = -1;
8250
- for (const c of sortedByFlatness) {
8251
- if (c === first || !depthOk(c))
8252
- continue;
8253
- const axd = this.axisDiffDeg(c.profile.angleMajorDeg, first.profile.angleMajorDeg);
8254
- if (axd > bestAxDiff) {
8255
- bestAxDiff = axd;
8256
- best = c;
8257
- }
8218
+ geometryAtXY(x, y) {
8219
+ const { u, v } = this.toLocalUV(x, y);
8220
+ const cu = this.curvature(this.r_axis);
8221
+ const cv = this.curvature(this.r_perp);
8222
+ if (!Number.isFinite(cu) || !Number.isFinite(cv))
8223
+ return null;
8224
+ // biconic(conic=0) sag 식
8225
+ const a = cu * u * u + cv * v * v;
8226
+ const b = 1 - cu * cu * u * u - cv * cv * v * v;
8227
+ if (b < -1e-6)
8228
+ return null; // 루트 내부가 음수면 정의역 밖
8229
+ const sqrtB = Math.sqrt(Math.max(0, b));
8230
+ const den = 1 + sqrtB;
8231
+ if (Math.abs(den) < EPSILON || Math.abs(sqrtB) < EPSILON)
8232
+ return null;
8233
+ const sag = a / den;
8234
+ // sag 미분 계산
8235
+ const dAdu = 2 * cu * u;
8236
+ const dAdv = 2 * cv * v;
8237
+ const dSqrtBdu = (-(cu * cu) * u) / sqrtB;
8238
+ const dSqrtBdv = (-(cv * cv) * v) / sqrtB;
8239
+ const denSq = den * den;
8240
+ const dZdu = (dAdu * den - a * dSqrtBdu) / denSq;
8241
+ const dZdv = (dAdv * den - a * dSqrtBdv) / denSq;
8242
+ const { dzdx, dzdy } = this.localDerivativesToWorld(dZdu, dZdv);
8243
+ const normal = new Vector3(-dzdx, -dzdy, 1).normalize();
8244
+ return { sag, dzdx, dzdy, normal };
8245
+ }
8246
+ incident(ray) {
8247
+ const origin = this.worldPointToLocal(ray.endPoint());
8248
+ const direction = this.worldDirToLocal(ray.getDirection().normalize());
8249
+ // 시작점이 이미 표면 위라면 재계산 없이 바로 반환합니다.
8250
+ const geometryAtOrigin = this.geometryAtXY(origin.x, origin.y);
8251
+ if (geometryAtOrigin) {
8252
+ const f0 = origin.z - geometryAtOrigin.sag;
8253
+ if (Math.abs(f0) <= TORIC_ON_SURFACE_TOL_MM) {
8254
+ this.incidentRays.push(ray.clone());
8255
+ return origin.clone();
8256
+ }
8257
+ // 동일 z 근처의 연속 표면에서 앞면이 origin을 약간 밀어냈을 때를 허용합니다.
8258
+ if (f0 > 0
8259
+ && f0 <= TORIC_COINCIDENT_SURFACE_TOL_MM
8260
+ && direction.z > 0
8261
+ && 0 <= origin.z + TORIC_COINCIDENT_SURFACE_TOL_MM) {
8262
+ this.incidentRays.push(ray.clone());
8263
+ return origin.clone();
8258
8264
  }
8259
- if (best && bestAxDiff >= lastResortMinDeg)
8260
- second = best;
8261
8265
  }
8262
- // 평탄도 정렬 풀만으로는 같은 phasor만 연속으로 나오는 경우가 있어, 전 스캔에서 가장 반대인 phasor고른다.
8263
- if (!second) {
8264
- let best = null;
8265
- let bestDot = 1;
8266
- for (const c of allSlices) {
8267
- if (c === first || !depthOk(c))
8268
- continue;
8269
- const d = this.phasorDot(first.profile, c.profile);
8270
- if (d < bestDot) {
8271
- bestDot = d;
8272
- best = c;
8273
- }
8266
+ // z-plane 기준 초기 seed 이후 뉴턴법으로 교점 t수렴시킵니다.
8267
+ let t = Math.max(TORIC_MIN_T_MM, -origin.z);
8268
+ for (let i = 0; i < TORIC_MAX_ITERS; i++) {
8269
+ const p = origin.clone().addScaledVector(direction, t);
8270
+ const geometry = this.geometryAtXY(p.x, p.y);
8271
+ if (!geometry)
8272
+ return null;
8273
+ const f = p.z - geometry.sag;
8274
+ const df = direction.z - geometry.dzdx * direction.x - geometry.dzdy * direction.y;
8275
+ if (!Number.isFinite(df) || Math.abs(df) < EPSILON)
8276
+ return null;
8277
+ const dt = f / df;
8278
+ t -= dt;
8279
+ if (!Number.isFinite(t) || t < TORIC_MIN_T_MM)
8280
+ return null;
8281
+ if (Math.abs(dt) < 1e-8) {
8282
+ const hitPoint = this.localPointToWorld(origin.clone().addScaledVector(direction, t));
8283
+ this.incidentRays.push(ray.clone());
8284
+ return hitPoint;
8274
8285
  }
8275
- if (best && bestDot < -0.08)
8276
- second = best;
8277
8286
  }
8278
- // 마지막: 가장 납작한 슬라이스와 깊이 차가 가장 큰 “비교적 납작한” 슬라이스 (선초점이 z로 분리될 때)
8279
- if (!second) {
8280
- const flatThreshold = first.ratio * 1.5 + 1e-6;
8281
- let best = null;
8282
- let bestDepthSpan = -1;
8283
- for (const c of allSlices) {
8284
- if (c === first || !depthOk(c))
8285
- continue;
8286
- if (c.ratio > flatThreshold)
8287
- continue;
8288
- const span = Math.abs(c.depth - first.depth);
8289
- if (span > bestDepthSpan) {
8290
- bestDepthSpan = span;
8291
- best = c;
8292
- }
8293
- }
8294
- if (best)
8295
- second = best;
8296
- }
8297
- return second ? [first, second] : [first];
8298
- }
8299
- buildApproxCenter(flattestTop2, smallestEllipse, preferTop2Mid) {
8300
- if (flattestTop2.length <= 0)
8301
- return null;
8302
- if (preferTop2Mid && flattestTop2.length >= 2) {
8303
- const first = flattestTop2[0];
8304
- const second = flattestTop2[1];
8305
- return {
8306
- x: (first.profile.at.x + second.profile.at.x) / 2,
8307
- y: (first.profile.at.y + second.profile.at.y) / 2,
8308
- z: (first.z + second.z) / 2,
8309
- mode: "top2-mid",
8310
- };
8311
- }
8312
- if (smallestEllipse) {
8313
- return {
8314
- x: smallestEllipse.profile.at.x,
8315
- y: smallestEllipse.profile.at.y,
8316
- z: smallestEllipse.z,
8317
- mode: "min-size",
8318
- };
8319
- }
8320
- const first = flattestTop2[0];
8321
- return { x: first.profile.at.x, y: first.profile.at.y, z: first.z, mode: "top1-flat" };
8322
- }
8323
- slicesForSturmAnalysis(sturmSlices, bounds) {
8324
- if (!bounds)
8325
- return sturmSlices;
8326
- const zMin = bounds.zMin;
8327
- const zMax = bounds.zMax;
8328
- if (!Number.isFinite(zMin) || !Number.isFinite(zMax) || zMax < zMin)
8329
- return sturmSlices;
8330
- const bounded = sturmSlices.filter((s) => {
8331
- const z = s.profile?.at?.z;
8332
- return Number.isFinite(z) && z >= zMin && z <= zMax;
8333
- });
8334
- return bounded.length >= 2 ? bounded : sturmSlices;
8335
- }
8336
- groupByFraunhoferLine(rays) {
8337
- const groups = new Map();
8338
- for (const ray of rays) {
8339
- const line = ray.getFraunhoferLine();
8340
- const wavelength = ray.getWavelengthNm();
8341
- const color = Number(ray.displayColor);
8342
- if (!groups.has(line)) {
8343
- groups.set(line, {
8344
- line,
8345
- wavelength_nm: wavelength,
8346
- color: Number.isFinite(color) ? color : null,
8347
- rays: [],
8348
- });
8349
- }
8350
- const group = groups.get(line);
8351
- if (group)
8352
- group.rays.push(ray);
8353
- }
8354
- return [...groups.values()].sort((a, b) => this.lineOrder.indexOf(a.line) - this.lineOrder.indexOf(b.line));
8355
- }
8356
- analyzeSturmSlices(sturmSlices, effectiveCylinderD, profileWorldZBounds) {
8357
- const top2MinGapMm = DEFAULT_STURM_TOP2_MIN_GAP_MM;
8358
- const effectiveCylinderThresholdD = DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D;
8359
- const preferTop2Mid = effectiveCylinderD >= effectiveCylinderThresholdD;
8360
- const slicesForAnalysis = this.slicesForSturmAnalysis(sturmSlices, profileWorldZBounds ?? null);
8361
- const sortedByFlatness = [...slicesForAnalysis].sort((a, b) => a.ratio - b.ratio);
8362
- const flattestTop2 = sortedByFlatness.length > 0
8363
- ? this.pickFlattestSturmPair(sortedByFlatness, top2MinGapMm, slicesForAnalysis)
8364
- : [];
8365
- let smallestEllipse = null;
8366
- for (const slice of slicesForAnalysis) {
8367
- if (!smallestEllipse || slice.size < smallestEllipse.size)
8368
- smallestEllipse = slice;
8369
- }
8370
- const approxCenter = this.buildApproxCenter(flattestTop2, smallestEllipse, preferTop2Mid);
8371
- const anterior = flattestTop2[0] ?? null;
8372
- const posterior = preferTop2Mid ? (flattestTop2[1] ?? null) : null;
8373
- return {
8374
- has_astigmatism: preferTop2Mid,
8375
- method: preferTop2Mid ? "sturm-interval-midpoint" : "minimum-ellipse",
8376
- anterior,
8377
- posterior,
8378
- approx_center: approxCenter,
8379
- };
8380
- }
8381
- }
8382
-
8383
- class ApertureStopSurface extends Surface {
8384
- constructor(props) {
8385
- super({
8386
- type: "aperture_stop",
8387
- name: props.name,
8388
- position: props.position,
8389
- tilt: props.tilt,
8390
- });
8391
- this.shape = props.shape;
8392
- this.radius = Math.max(0, Number(props.radius ?? 0));
8393
- this.width = Math.max(0, Number(props.width ?? 0));
8394
- this.height = Math.max(0, Number(props.height ?? 0));
8395
- }
8396
- worldQuaternion() {
8397
- const tiltXRad = (this.tilt.x * Math.PI) / 180;
8398
- const tiltYRad = (this.tilt.y * Math.PI) / 180;
8399
- return new Quaternion().setFromEuler(new Euler(tiltXRad, tiltYRad, 0, "XYZ"));
8400
- }
8401
- localPointFromWorld(worldPoint) {
8402
- const inverse = this.worldQuaternion().invert();
8403
- return worldPoint
8404
- .clone()
8405
- .sub(this.position)
8406
- .applyQuaternion(inverse);
8407
- }
8408
- surfaceNormalWorld() {
8409
- return new Vector3(0, 0, 1).applyQuaternion(this.worldQuaternion()).normalize();
8410
- }
8411
- intersectForward(origin, direction) {
8412
- const normal = this.surfaceNormalWorld();
8413
- const denom = normal.dot(direction);
8414
- if (Math.abs(denom) < EPSILON)
8415
- return null;
8416
- const t = normal.dot(this.position.clone().sub(origin)) / denom;
8417
- if (!Number.isFinite(t) || t <= 1e-6)
8418
- return null;
8419
- return origin.clone().addScaledVector(direction, t);
8420
- }
8421
- isInsideAperture(hitPointWorld) {
8422
- const local = this.localPointFromWorld(hitPointWorld);
8423
- if (this.shape === "circle") {
8424
- if (this.radius <= 0)
8425
- return false;
8426
- return Math.hypot(local.x, local.y) <= this.radius + 1e-9;
8427
- }
8428
- if (this.width <= 0 || this.height <= 0)
8429
- return false;
8430
- return (Math.abs(local.x) <= (this.width / 2) + 1e-9
8431
- && Math.abs(local.y) <= (this.height / 2) + 1e-9);
8432
- }
8433
- incident(ray) {
8434
- const origin = ray.endPoint();
8435
- const direction = ray.getDirection().normalize();
8436
- const hitPoint = this.intersectForward(origin, direction);
8437
- if (!hitPoint)
8438
- return null;
8439
- if (!this.isInsideAperture(hitPoint))
8440
- return null;
8441
- this.incidentRays.push(ray.clone());
8442
- return hitPoint;
8287
+ return null;
8443
8288
  }
8444
8289
  refract(ray) {
8445
8290
  const hitPoint = this.incident(ray);
8446
8291
  if (!hitPoint)
8447
8292
  return null;
8448
- const direction = ray.getDirection().normalize();
8449
- const passedRay = ray.clone();
8450
- passedRay.appendPoint(hitPoint);
8451
- passedRay.continueFrom(hitPoint.clone().addScaledVector(direction, RAY_SURFACE_ESCAPE_MM), direction);
8452
- this.refractedRays.push(passedRay.clone());
8453
- return passedRay;
8293
+ const geometry = this.geometryAtXY(hitPoint.x, hitPoint.y);
8294
+ if (!geometry)
8295
+ return null;
8296
+ // 스넬 굴절 벡터 계산
8297
+ const incidentDir = this.worldDirToLocal(ray.getDirection().normalize());
8298
+ const normalIntoSecond = geometry.normal.clone();
8299
+ // 법선을 입사방향과 같은 반공간으로 맞춰 2번째 매질 방향으로 정렬
8300
+ if (normalIntoSecond.dot(incidentDir) < 0) {
8301
+ normalIntoSecond.multiplyScalar(-1);
8302
+ }
8303
+ const cos1 = Math.max(-1, Math.min(1, normalIntoSecond.dot(incidentDir)));
8304
+ const sin1Sq = Math.max(0, 1 - cos1 * cos1);
8305
+ const { nBefore, nAfter } = this.refractiveIndicesForRay(ray);
8306
+ const sin2 = (nBefore / nAfter) * Math.sqrt(sin1Sq);
8307
+ // 전반사(TIR)
8308
+ if (sin2 > 1 + 1e-10)
8309
+ return null;
8310
+ const cos2 = Math.sqrt(Math.max(0, 1 - sin2 * sin2));
8311
+ const tangent = incidentDir.clone().sub(normalIntoSecond.clone().multiplyScalar(cos1));
8312
+ const outDirectionLocal = tangent.lengthSq() < 1e-12
8313
+ ? incidentDir.clone()
8314
+ : normalIntoSecond
8315
+ .clone()
8316
+ .multiplyScalar(cos2)
8317
+ .add(tangent.normalize().multiplyScalar(sin2))
8318
+ .normalize();
8319
+ const outDirection = this.localDirToWorld(outDirectionLocal);
8320
+ const refractedRay = ray.clone();
8321
+ refractedRay.appendPoint(hitPoint);
8322
+ refractedRay.continueFrom(hitPoint.clone().addScaledVector(outDirection, RAY_SURFACE_ESCAPE_MM), outDirection);
8323
+ this.refractedRays.push(refractedRay.clone());
8324
+ return refractedRay;
8454
8325
  }
8455
8326
  }
8456
8327
 
8457
- const DegToTABO = (degree) => {
8458
- const d = Number(degree);
8459
- if (!Number.isFinite(d))
8460
- return 0;
8461
- return (((180 - d) % 180) + 180) % 180;
8462
- };
8463
-
8464
- class ToricSurface extends Surface {
8328
+ class STSurface extends Surface {
8465
8329
  constructor(props) {
8466
- super({ type: "toric", name: props.name, position: props.position, tilt: props.tilt });
8467
- this.r_axis = 0;
8468
- this.r_perp = 0;
8330
+ super({ type: "compound", name: props.name, position: props.position, tilt: props.tilt });
8331
+ this.s = 0;
8332
+ this.c = 0;
8333
+ this.ax = 0;
8469
8334
  this.n_before = 1.0;
8335
+ this.n = 1.0;
8470
8336
  this.n_after = 1.0;
8471
- this.axisDeg = 0;
8472
- const { r_axis, r_perp, axis_deg = 0, n_before = 1.0, n_after = 1.0 } = props;
8473
- this.r_axis = r_axis;
8474
- this.r_perp = r_perp;
8475
- this.axisDeg = axis_deg;
8337
+ this.thickness = 0;
8338
+ this.sphericalRadiusMm = Number.POSITIVE_INFINITY;
8339
+ this.toricRadiusPerpMm = Number.POSITIVE_INFINITY;
8340
+ const { s, c, ax, n_before = 1.0, n = 1.0, n_after = n_before, referencePoint, thickness = ST_DEFAULT_THICKNESS_MM, } = props;
8341
+ this.s = s;
8342
+ this.c = c;
8343
+ // paraxial-surface와 동일: 처방 축은 TABO(°), 굴절/기하에는 수학 좌표 각(0~180)로 변환해 저장한다.
8344
+ this.ax = DegToTABO(ax);
8476
8345
  this.n_before = normalizeRefractiveIndexSpec(n_before);
8346
+ this.n = normalizeRefractiveIndexSpec(n);
8477
8347
  this.n_after = normalizeRefractiveIndexSpec(n_after);
8348
+ const hasToric = Math.abs(this.c) >= ST_POWER_EPS_D;
8349
+ // Radius must be computed with the same media pair that each sub-surface actually uses.
8350
+ // - toric: n_before -> n
8351
+ // - spherical: n -> n_after when toric exists, else n_before -> n
8352
+ this.toricRadiusPerpMm = this.radiusFromPower(this.c, this.refractiveIndexAtD(this.n_before), this.refractiveIndexAtD(this.n));
8353
+ this.sphericalRadiusMm = this.radiusFromPower(this.s, hasToric ? this.refractiveIndexAtD(this.n) : this.refractiveIndexAtD(this.n_before), hasToric ? this.refractiveIndexAtD(this.n_after) : this.refractiveIndexAtD(this.n));
8354
+ const requestedThickness = Math.max(0, thickness);
8355
+ this.thickness = requestedThickness === 0
8356
+ ? this.optimizeThickness(0)
8357
+ : requestedThickness;
8358
+ this.position.z = this.optimizeToricVertexZFromReference(this.position.z, referencePoint?.z, this.thickness);
8359
+ // 복합면: 작은 z에 토릭, 큰 z에 구면(+z 광축 기준 토릭 → 구면 순).
8360
+ this.sphericalSurface = this.buildSphericalSurface();
8361
+ this.toricSurface = this.buildToricSurface();
8362
+ this.prismVector = prismVectorFromClinicalBase(props.p, props.p_ax);
8478
8363
  }
8479
- refractiveIndicesForRay(ray) {
8480
- const line = ray.getFraunhoferLine();
8481
- return {
8482
- nBefore: resolveRefractiveIndex(this.n_before, line),
8483
- nAfter: resolveRefractiveIndex(this.n_after, line),
8484
- };
8364
+ pushRefractedWithPrism(ray) {
8365
+ const out = applyPrismVectorToRay(ray, this.prismVector);
8366
+ this.refractedRays.push(out.clone());
8367
+ return out;
8485
8368
  }
8486
8369
  /**
8487
- * Toric 면의 축(meridian) 회전을 위해 사용하는 삼각함수 값입니다.
8488
- * 축(axis) 회전은 axisDeg(도 단위)를 사용합니다.
8489
- */
8490
- axisTrig() {
8491
- const rad = (this.axisDeg * Math.PI) / 180;
8492
- return { c: Math.cos(rad), s: Math.sin(rad) };
8493
- }
8494
- worldQuaternion() {
8495
- const tiltXRad = (this.tilt.x * Math.PI) / 180;
8496
- const tiltYRad = (this.tilt.y * Math.PI) / 180;
8497
- return new Quaternion().setFromEuler(new Euler(tiltXRad, tiltYRad, 0, "XYZ"));
8498
- }
8499
- worldPointToLocal(worldPoint) {
8500
- const inverse = this.worldQuaternion().invert();
8501
- return worldPoint.clone().sub(this.position).applyQuaternion(inverse);
8502
- }
8503
- localPointToWorld(localPoint) {
8504
- return localPoint.clone().applyQuaternion(this.worldQuaternion()).add(this.position);
8505
- }
8506
- worldDirToLocal(worldDirection) {
8507
- const inverse = this.worldQuaternion().invert();
8508
- return worldDirection.clone().applyQuaternion(inverse).normalize();
8509
- }
8510
- localDirToWorld(localDirection) {
8511
- return localDirection.clone().applyQuaternion(this.worldQuaternion()).normalize();
8512
- }
8513
- /**
8514
- * 월드 좌표계 (x, y)를 토릭 로컬 좌표계 (u, v)로 변환합니다.
8515
- * - u: 축 방향 meridian
8516
- * - v: 축에 수직인 meridian
8517
- */
8518
- toLocalUV(x, y) {
8519
- const { c, s } = this.axisTrig();
8520
- return {
8521
- u: c * x + s * y,
8522
- v: -s * x + c * y,
8523
- };
8524
- }
8525
- /**
8526
- * 로컬 좌표계에서 계산한 sag 미분(dz/du, dz/dv)을
8527
- * 월드 좌표계의 기울기(dz/dx, dz/dy)로 다시 매핑합니다.
8528
- */
8529
- localDerivativesToWorld(dZdu, dZdv) {
8530
- const { c, s } = this.axisTrig();
8531
- return {
8532
- dzdx: dZdu * c - dZdv * s,
8533
- dzdy: dZdu * s + dZdv * c,
8534
- };
8535
- }
8536
- /**
8537
- * 반경으로부터 곡률(1/R)을 계산합니다.
8538
- * - 반경이 너무 크거나 무한대면 평면으로 간주하여 0 반환
8539
- * - 반경이 0에 너무 가까우면 비정상 값으로 NaN 반환
8540
- */
8541
- curvature(radius) {
8542
- if (!Number.isFinite(radius) || Math.abs(radius) > 1e12)
8543
- return 0;
8544
- if (Math.abs(radius) < EPSILON)
8545
- return Number.NaN;
8546
- return 1 / radius;
8547
- }
8548
- /**
8549
- * 주어진 XY에서 토릭면의 기하정보를 계산합니다.
8550
- * - sag: 꼭지점 대비 z 높이
8551
- * - dzdx/dzdy: 면 기울기
8552
- * - normal: 2번째 매질 방향을 만들 때 사용할 기본 법선
8553
- */
8554
- geometryAtXY(x, y) {
8555
- const { u, v } = this.toLocalUV(x, y);
8556
- const cu = this.curvature(this.r_axis);
8557
- const cv = this.curvature(this.r_perp);
8558
- if (!Number.isFinite(cu) || !Number.isFinite(cv))
8559
- return null;
8560
- // biconic(conic=0) sag 식
8561
- const a = cu * u * u + cv * v * v;
8562
- const b = 1 - cu * cu * u * u - cv * cv * v * v;
8563
- if (b < -1e-6)
8564
- return null; // 루트 내부가 음수면 정의역 밖
8565
- const sqrtB = Math.sqrt(Math.max(0, b));
8566
- const den = 1 + sqrtB;
8567
- if (Math.abs(den) < EPSILON || Math.abs(sqrtB) < EPSILON)
8568
- return null;
8569
- const sag = a / den;
8570
- // sag 미분 계산
8571
- const dAdu = 2 * cu * u;
8572
- const dAdv = 2 * cv * v;
8573
- const dSqrtBdu = (-(cu * cu) * u) / sqrtB;
8574
- const dSqrtBdv = (-(cv * cv) * v) / sqrtB;
8575
- const denSq = den * den;
8576
- const dZdu = (dAdu * den - a * dSqrtBdu) / denSq;
8577
- const dZdv = (dAdv * den - a * dSqrtBdv) / denSq;
8578
- const { dzdx, dzdy } = this.localDerivativesToWorld(dZdu, dZdv);
8579
- const normal = new Vector3(-dzdx, -dzdy, 1).normalize();
8580
- return { sag, dzdx, dzdy, normal };
8581
- }
8582
- incident(ray) {
8583
- const origin = this.worldPointToLocal(ray.endPoint());
8584
- const direction = this.worldDirToLocal(ray.getDirection().normalize());
8585
- // 시작점이 이미 표면 위라면 재계산 없이 바로 반환합니다.
8586
- const geometryAtOrigin = this.geometryAtXY(origin.x, origin.y);
8587
- if (geometryAtOrigin) {
8588
- const f0 = origin.z - geometryAtOrigin.sag;
8589
- if (Math.abs(f0) <= TORIC_ON_SURFACE_TOL_MM) {
8590
- this.incidentRays.push(ray.clone());
8591
- return origin.clone();
8592
- }
8593
- // 동일 z 근처의 연속 표면에서 앞면이 origin을 약간 밀어냈을 때를 허용합니다.
8594
- if (f0 > 0
8595
- && f0 <= TORIC_COINCIDENT_SURFACE_TOL_MM
8596
- && direction.z > 0
8597
- && 0 <= origin.z + TORIC_COINCIDENT_SURFACE_TOL_MM) {
8598
- this.incidentRays.push(ray.clone());
8599
- return origin.clone();
8600
- }
8601
- }
8602
- // z-plane 기준 초기 seed 이후 뉴턴법으로 교점 t를 수렴시킵니다.
8603
- let t = Math.max(TORIC_MIN_T_MM, -origin.z);
8604
- for (let i = 0; i < TORIC_MAX_ITERS; i++) {
8605
- const p = origin.clone().addScaledVector(direction, t);
8606
- const geometry = this.geometryAtXY(p.x, p.y);
8607
- if (!geometry)
8608
- return null;
8609
- const f = p.z - geometry.sag;
8610
- const df = direction.z - geometry.dzdx * direction.x - geometry.dzdy * direction.y;
8611
- if (!Number.isFinite(df) || Math.abs(df) < EPSILON)
8612
- return null;
8613
- const dt = f / df;
8614
- t -= dt;
8615
- if (!Number.isFinite(t) || t < TORIC_MIN_T_MM)
8616
- return null;
8617
- if (Math.abs(dt) < 1e-8) {
8618
- const hitPoint = this.localPointToWorld(origin.clone().addScaledVector(direction, t));
8619
- this.incidentRays.push(ray.clone());
8620
- return hitPoint;
8621
- }
8622
- }
8623
- return null;
8624
- }
8625
- refract(ray) {
8626
- const hitPoint = this.incident(ray);
8627
- if (!hitPoint)
8628
- return null;
8629
- const geometry = this.geometryAtXY(hitPoint.x, hitPoint.y);
8630
- if (!geometry)
8631
- return null;
8632
- // 스넬 굴절 벡터 계산
8633
- const incidentDir = this.worldDirToLocal(ray.getDirection().normalize());
8634
- const normalIntoSecond = geometry.normal.clone();
8635
- // 법선을 입사방향과 같은 반공간으로 맞춰 2번째 매질 방향으로 정렬
8636
- if (normalIntoSecond.dot(incidentDir) < 0) {
8637
- normalIntoSecond.multiplyScalar(-1);
8638
- }
8639
- const cos1 = Math.max(-1, Math.min(1, normalIntoSecond.dot(incidentDir)));
8640
- const sin1Sq = Math.max(0, 1 - cos1 * cos1);
8641
- const { nBefore, nAfter } = this.refractiveIndicesForRay(ray);
8642
- const sin2 = (nBefore / nAfter) * Math.sqrt(sin1Sq);
8643
- // 전반사(TIR)
8644
- if (sin2 > 1 + 1e-10)
8645
- return null;
8646
- const cos2 = Math.sqrt(Math.max(0, 1 - sin2 * sin2));
8647
- const tangent = incidentDir.clone().sub(normalIntoSecond.clone().multiplyScalar(cos1));
8648
- const outDirectionLocal = tangent.lengthSq() < 1e-12
8649
- ? incidentDir.clone()
8650
- : normalIntoSecond
8651
- .clone()
8652
- .multiplyScalar(cos2)
8653
- .add(tangent.normalize().multiplyScalar(sin2))
8654
- .normalize();
8655
- const outDirection = this.localDirToWorld(outDirectionLocal);
8656
- const refractedRay = ray.clone();
8657
- refractedRay.appendPoint(hitPoint);
8658
- refractedRay.continueFrom(hitPoint.clone().addScaledVector(outDirection, RAY_SURFACE_ESCAPE_MM), outDirection);
8659
- this.refractedRays.push(refractedRay.clone());
8660
- return refractedRay;
8661
- }
8662
- }
8663
-
8664
- class STSurface extends Surface {
8665
- constructor(props) {
8666
- super({ type: "compound", name: props.name, position: props.position, tilt: props.tilt });
8667
- this.s = 0;
8668
- this.c = 0;
8669
- this.ax = 0;
8670
- this.n_before = 1.0;
8671
- this.n = 1.0;
8672
- this.n_after = 1.0;
8673
- this.thickness = 0;
8674
- this.sphericalRadiusMm = Number.POSITIVE_INFINITY;
8675
- this.toricRadiusPerpMm = Number.POSITIVE_INFINITY;
8676
- const { s, c, ax, n_before = 1.0, n = 1.0, n_after = n_before, referencePoint, thickness = ST_DEFAULT_THICKNESS_MM, } = props;
8677
- this.s = s;
8678
- this.c = c;
8679
- // paraxial-surface와 동일: 처방 축은 TABO(°), 굴절/기하에는 수학 좌표 각(0~180)로 변환해 저장한다.
8680
- this.ax = DegToTABO(ax);
8681
- this.n_before = normalizeRefractiveIndexSpec(n_before);
8682
- this.n = normalizeRefractiveIndexSpec(n);
8683
- this.n_after = normalizeRefractiveIndexSpec(n_after);
8684
- const hasToric = Math.abs(this.c) >= ST_POWER_EPS_D;
8685
- // Radius must be computed with the same media pair that each sub-surface actually uses.
8686
- // - toric: n_before -> n
8687
- // - spherical: n -> n_after when toric exists, else n_before -> n
8688
- this.toricRadiusPerpMm = this.radiusFromPower(this.c, this.refractiveIndexAtD(this.n_before), this.refractiveIndexAtD(this.n));
8689
- this.sphericalRadiusMm = this.radiusFromPower(this.s, hasToric ? this.refractiveIndexAtD(this.n) : this.refractiveIndexAtD(this.n_before), hasToric ? this.refractiveIndexAtD(this.n_after) : this.refractiveIndexAtD(this.n));
8690
- const requestedThickness = Math.max(0, thickness);
8691
- this.thickness = requestedThickness === 0
8692
- ? this.optimizeThickness(0)
8693
- : requestedThickness;
8694
- this.position.z = this.optimizeToricVertexZFromReference(this.position.z, referencePoint?.z, this.thickness);
8695
- // 복합면: 작은 z에 토릭, 큰 z에 구면(+z 광축 기준 토릭 → 구면 순).
8696
- this.sphericalSurface = this.buildSphericalSurface();
8697
- this.toricSurface = this.buildToricSurface();
8698
- }
8699
- /**
8700
- * 디옵터(D)로부터 반경(mm)을 계산합니다.
8701
- *
8702
- * power(D) = (n2 - n1) / R(m)
8703
- * -> R(mm) = 1000 * (n2 - n1) / power(D)
8704
- *
8705
- * 굴절력이 사실상 0이면 평면으로 간주하기 위해 +Infinity를 반환합니다.
8370
+ * 디옵터(D)로부터 반경(mm) 계산합니다.
8371
+ *
8372
+ * power(D) = (n2 - n1) / R(m)
8373
+ * -> R(mm) = 1000 * (n2 - n1) / power(D)
8374
+ *
8375
+ * 굴절력이 사실상 0이면 평면으로 간주하기 위해 +Infinity를 반환합니다.
8706
8376
  */
8707
8377
  radiusFromPower(powerD, nBefore, nAfter) {
8708
8378
  if (!Number.isFinite(powerD)
@@ -8750,182 +8420,869 @@ class STSurface extends Surface {
8750
8420
  y: this.position.y,
8751
8421
  z: this.position.z,
8752
8422
  },
8753
- tilt: { x: this.tilt.x, y: this.tilt.y },
8754
- r_axis: Number.POSITIVE_INFINITY,
8755
- r_perp: this.toricRadiusPerpMm,
8756
- axis_deg: this.ax,
8757
- n_before: this.n_before,
8758
- n_after: this.n,
8423
+ tilt: { x: this.tilt.x, y: this.tilt.y },
8424
+ r_axis: Number.POSITIVE_INFINITY,
8425
+ r_perp: this.toricRadiusPerpMm,
8426
+ axis_deg: this.ax,
8427
+ n_before: this.n_before,
8428
+ n_after: this.n,
8429
+ };
8430
+ return new ToricSurface(props);
8431
+ }
8432
+ applyChromaticIndicesToSubSurfaces(ray) {
8433
+ const line = ray.getFraunhoferLine();
8434
+ const nBefore = resolveRefractiveIndex(this.n_before, line);
8435
+ const n = resolveRefractiveIndex(this.n, line);
8436
+ const nAfter = resolveRefractiveIndex(this.n_after, line);
8437
+ const sphericalState = this.sphericalSurface;
8438
+ if (this.toricSurface) {
8439
+ sphericalState.n_before = n;
8440
+ sphericalState.n_after = nAfter;
8441
+ const toricState = this.toricSurface;
8442
+ toricState.n_before = nBefore;
8443
+ toricState.n_after = n;
8444
+ return;
8445
+ }
8446
+ sphericalState.n_before = nBefore;
8447
+ sphericalState.n_after = n;
8448
+ }
8449
+ /**
8450
+ * 구면/토릭 곡면의 z 교차(토릭이 구면을 관통) 방지를 위해
8451
+ * 샘플링 영역에서 필요한 최소 중심두께를 계산합니다.
8452
+ */
8453
+ optimizeThickness(requestedThickness) {
8454
+ if (Math.abs(this.c) < ST_POWER_EPS_D)
8455
+ return requestedThickness;
8456
+ const sampleRadius = this.samplingRadiusMm();
8457
+ if (!Number.isFinite(sampleRadius) || sampleRadius <= 0)
8458
+ return requestedThickness;
8459
+ const samplesPerAxis = 49;
8460
+ let requiredThickness = requestedThickness;
8461
+ const safetyMargin = Math.max(0.05, 2 * RAY_SURFACE_ESCAPE_MM);
8462
+ for (let iy = 0; iy < samplesPerAxis; iy++) {
8463
+ const y = -sampleRadius + (2 * sampleRadius * iy) / (samplesPerAxis - 1);
8464
+ for (let ix = 0; ix < samplesPerAxis; ix++) {
8465
+ const x = -sampleRadius + (2 * sampleRadius * ix) / (samplesPerAxis - 1);
8466
+ if ((x * x + y * y) > sampleRadius * sampleRadius)
8467
+ continue;
8468
+ const sphericalSag = this.sphericalSagAtXY(x, y);
8469
+ const toricSag = this.toricSagAtXY(x, y);
8470
+ if (!Number.isFinite(sphericalSag) || !Number.isFinite(toricSag))
8471
+ continue;
8472
+ const localRequired = (sphericalSag - toricSag) + safetyMargin;
8473
+ if (localRequired > requiredThickness)
8474
+ requiredThickness = localRequired;
8475
+ }
8476
+ }
8477
+ return Math.max(0, requiredThickness);
8478
+ }
8479
+ /**
8480
+ * 기준점(referencePoint.z)으로부터 토릭 꼭지(z)까지의 최소 간격을 확보합니다.
8481
+ * - 기준점과 반대 방향으로 현재 토릭 꼭지가 놓인 쪽(sign)을 유지합니다.
8482
+ * - 최소 간격은 "토릭–구면 거리(thickness) + 안전여유"입니다.
8483
+ */
8484
+ optimizeToricVertexZFromReference(toricVertexZ, referenceZ, thicknessMm = this.thickness) {
8485
+ if (!Number.isFinite(referenceZ))
8486
+ return toricVertexZ;
8487
+ const safetyMargin = Math.max(0.05, 2 * RAY_SURFACE_ESCAPE_MM);
8488
+ const minGap = Math.max(EYE_ST_SURFACE_OFFSET_MM, Math.max(0, thicknessMm) + safetyMargin);
8489
+ const delta = toricVertexZ - referenceZ;
8490
+ const side = Math.abs(delta) < 1e-12 ? -1 : Math.sign(delta);
8491
+ const currentGap = Math.abs(delta);
8492
+ if (currentGap >= minGap)
8493
+ return toricVertexZ;
8494
+ return referenceZ + side * minGap;
8495
+ }
8496
+ samplingRadiusMm() {
8497
+ const defaultRadius = 12;
8498
+ const finiteRadii = [this.sphericalRadiusMm, this.toricRadiusPerpMm]
8499
+ .filter((r) => Number.isFinite(r) && Math.abs(r) > 1e-6)
8500
+ .map((r) => Math.abs(r));
8501
+ if (!finiteRadii.length)
8502
+ return defaultRadius;
8503
+ return Math.max(1.0, Math.min(defaultRadius, 0.98 * Math.min(...finiteRadii)));
8504
+ }
8505
+ /**
8506
+ * 구면 측 꼭지점 기준 sag(mm)
8507
+ */
8508
+ sphericalSagAtXY(x, y) {
8509
+ const rhoSq = x * x + y * y;
8510
+ const r = this.sphericalRadiusMm;
8511
+ if (!Number.isFinite(r) || Math.abs(r) > 1e12)
8512
+ return 0;
8513
+ const rr = r * r;
8514
+ if (rhoSq > rr)
8515
+ return Number.NaN;
8516
+ const root = Math.sqrt(Math.max(0, rr - rhoSq));
8517
+ return r > 0 ? r - root : r + root;
8518
+ }
8519
+ /**
8520
+ * 토릭 측 꼭지점 기준 sag(mm)
8521
+ */
8522
+ toricSagAtXY(x, y) {
8523
+ const axisRad = (this.ax * Math.PI) / 180;
8524
+ const cAxis = Math.cos(axisRad);
8525
+ const sAxis = Math.sin(axisRad);
8526
+ const u = cAxis * x + sAxis * y;
8527
+ const v = -sAxis * x + cAxis * y;
8528
+ const cu = 0; // r_axis = Infinity
8529
+ const cv = (!Number.isFinite(this.toricRadiusPerpMm) || Math.abs(this.toricRadiusPerpMm) > 1e12)
8530
+ ? 0
8531
+ : 1 / this.toricRadiusPerpMm;
8532
+ const a = cu * u * u + cv * v * v;
8533
+ const b = 1 - cu * cu * u * u - cv * cv * v * v;
8534
+ if (b < 0)
8535
+ return Number.NaN;
8536
+ const den = 1 + Math.sqrt(Math.max(0, b));
8537
+ if (Math.abs(den) < 1e-12)
8538
+ return Number.NaN;
8539
+ return a / den;
8540
+ }
8541
+ isOpticallyNeutral() {
8542
+ return (Math.abs(Number(this.s) || 0) < STSurface.POWER_EPS_D
8543
+ && Math.abs(Number(this.c) || 0) < STSurface.POWER_EPS_D);
8544
+ }
8545
+ refract(ray) {
8546
+ // 무도수 ST면은 기하(면 위치/경사)는 유지하되 굴절력은 0으로 취급하여 직진 통과시킵니다.
8547
+ if (this.isOpticallyNeutral()) {
8548
+ const direction = ray.getDirection().normalize();
8549
+ const passthrough = ray.clone();
8550
+ const hitPoint = this.sphericalSurface.incident(ray);
8551
+ if (hitPoint) {
8552
+ passthrough.appendPoint(hitPoint);
8553
+ passthrough.continueFrom(hitPoint.clone().addScaledVector(direction, RAY_SURFACE_ESCAPE_MM), direction);
8554
+ }
8555
+ return this.pushRefractedWithPrism(passthrough);
8556
+ }
8557
+ this.applyChromaticIndicesToSubSurfaces(ray);
8558
+ // 원통 성분이 없으면 단일(구면)면으로 처리합니다.
8559
+ if (!this.toricSurface) {
8560
+ const single = this.sphericalSurface.refract(ray);
8561
+ if (!single)
8562
+ return null;
8563
+ return this.pushRefractedWithPrism(single);
8564
+ }
8565
+ // +z 진행: 토릭(작은 z) → 구면(큰 z)
8566
+ const afterToric = this.toricSurface.refract(ray);
8567
+ if (!afterToric)
8568
+ return null;
8569
+ const afterSpherical = this.sphericalSurface.refract(afterToric);
8570
+ if (!afterSpherical)
8571
+ return null;
8572
+ return this.pushRefractedWithPrism(afterSpherical);
8573
+ }
8574
+ incident(ray) {
8575
+ // 복합면의 첫 hit: +z 기준 토릭이 앞서므로 toricSurface 우선.
8576
+ const primary = this.toricSurface ?? this.sphericalSurface;
8577
+ const hitPoint = primary.incident(ray);
8578
+ if (!hitPoint)
8579
+ return null;
8580
+ this.incidentRays.push(ray.clone());
8581
+ return hitPoint;
8582
+ }
8583
+ getOptimizedThicknessMm() {
8584
+ return this.thickness;
8585
+ }
8586
+ applyRigidRotationAboutPivot(pivot, rotation) {
8587
+ const move = (p0) => pivot.clone().add(new Vector3().subVectors(p0, pivot).applyQuaternion(rotation));
8588
+ const euler = new Euler().setFromQuaternion(rotation, "XYZ");
8589
+ const tx = (euler.x * 180) / Math.PI;
8590
+ const ty = (euler.y * 180) / Math.PI;
8591
+ const newTor = move(this.position.clone());
8592
+ const newSph = move(this.sphericalSurface.getWorldPosition());
8593
+ this.setPositionAndTilt(newTor, tx, ty);
8594
+ this.sphericalSurface.setPositionAndTilt(newSph, tx, ty);
8595
+ if (this.toricSurface) {
8596
+ this.toricSurface.setPositionAndTilt(newTor.clone(), tx, ty);
8597
+ }
8598
+ }
8599
+ }
8600
+ STSurface.POWER_EPS_D = ST_POWER_EPS_D;
8601
+
8602
+ function toFiniteNumber$1(value, fallback = 0) {
8603
+ const parsed = Number(value);
8604
+ return Number.isFinite(parsed) ? parsed : fallback;
8605
+ }
8606
+ function normalizePrismAmount$1(value) {
8607
+ const p = Number(value ?? 0);
8608
+ return Number.isFinite(p) ? Math.max(0, p) : 0;
8609
+ }
8610
+ function normalizeAngle360$1(value) {
8611
+ const d = Number(value ?? 0);
8612
+ if (!Number.isFinite(d))
8613
+ return 0;
8614
+ return ((d % 360) + 360) % 360;
8615
+ }
8616
+ class EyeModelParameter {
8617
+ constructor(parameter) {
8618
+ this.parameter = parameter;
8619
+ }
8620
+ /**
8621
+ * 동공(선택) → eye_st(ST 복합면) → 안구 모델 해부면 순으로 생성한 뒤,
8622
+ * `p`/`p_ax`/`tilt`를 안구 회전점 기준 강체 회전으로 반영합니다.
8623
+ */
8624
+ createSurface(eye) {
8625
+ const normalizedEyeSphere = toFiniteNumber$1(eye.s) + (eye.eyeModel === "gullstrand" ? -1 : 0);
8626
+ const eyePower = {
8627
+ s: -normalizedEyeSphere,
8628
+ c: -toFiniteNumber$1(eye.c),
8629
+ ax: toFiniteNumber$1(eye.ax),
8630
+ };
8631
+ const pNorm = normalizePrismAmount$1(eye.p);
8632
+ const pAxNorm = normalizeAngle360$1(eye.p_ax);
8633
+ const tiltDeg = {
8634
+ x: toFiniteNumber$1(eye.tilt?.x),
8635
+ y: toFiniteNumber$1(eye.tilt?.y),
8636
+ };
8637
+ const rotation = eyePrismAndTiltToQuaternion(pNorm, pAxNorm, tiltDeg);
8638
+ const eyeSt = new STSurface({
8639
+ type: "compound",
8640
+ name: "eye_st",
8641
+ position: { x: 0, y: 0, z: -EYE_ST_SURFACE_OFFSET_MM },
8642
+ referencePoint: { x: 0, y: 0, z: 0 },
8643
+ tilt: { x: 0, y: 0 },
8644
+ s: eyePower.s,
8645
+ c: eyePower.c,
8646
+ ax: eyePower.ax,
8647
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
8648
+ n: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
8649
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
8650
+ });
8651
+ const anatomical = this.parameter.surfaces.map((surface) => {
8652
+ if (surface.type === "spherical") {
8653
+ return new SphericalSurface({
8654
+ type: "spherical",
8655
+ name: surface.name,
8656
+ r: surface.radius,
8657
+ position: { x: 0, y: 0, z: surface.z },
8658
+ tilt: { x: 0, y: 0 },
8659
+ n_before: surface.n_before,
8660
+ n_after: surface.n_after,
8661
+ });
8662
+ }
8663
+ if (surface.type === "aspherical") {
8664
+ return new AsphericalSurface({
8665
+ type: "aspherical",
8666
+ name: surface.name,
8667
+ position: { x: 0, y: 0, z: surface.z },
8668
+ tilt: { x: 0, y: 0 },
8669
+ r: surface.radius,
8670
+ conic: surface.conic,
8671
+ n_before: surface.n_before,
8672
+ n_after: surface.n_after,
8673
+ });
8674
+ }
8675
+ if (surface.type === "spherical-image") {
8676
+ return new SphericalImageSurface({
8677
+ type: "spherical-image",
8678
+ name: surface.name,
8679
+ r: surface.radius,
8680
+ position: { x: 0, y: 0, z: surface.z },
8681
+ tilt: { x: 0, y: 0 },
8682
+ retina_extra_after: true,
8683
+ });
8684
+ }
8685
+ throw new Error(`Unsupported surface type: ${surface.type}`);
8686
+ });
8687
+ const surfaces = [eyeSt, ...anatomical];
8688
+ const pupilMm = toFiniteNumber$1(eye.pupilDiameterMm);
8689
+ if (Number.isFinite(pupilMm) && pupilMm > 0) {
8690
+ const pupilStop = new ApertureStopSurface({
8691
+ type: "aperture_stop",
8692
+ name: "pupil_stop",
8693
+ shape: "circle",
8694
+ radius: pupilMm / 2,
8695
+ position: {
8696
+ x: 0,
8697
+ y: 0,
8698
+ z: -EYE_ST_SURFACE_OFFSET_MM - (2 * RAY_SURFACE_ESCAPE_MM),
8699
+ },
8700
+ tilt: { x: 0, y: 0 },
8701
+ });
8702
+ surfaces.unshift(pupilStop);
8703
+ }
8704
+ applyRigidEyePoseToSurfaces(surfaces, eyeRotationPivot(), rotation);
8705
+ return surfaces;
8706
+ }
8707
+ }
8708
+
8709
+ class GullstrandParameter extends EyeModelParameter {
8710
+ constructor() {
8711
+ super(GullstrandParameter.parameter);
8712
+ }
8713
+ }
8714
+ GullstrandParameter.parameter = {
8715
+ unit: "mm",
8716
+ axis: "optical_axis_z",
8717
+ origin: "cornea_anterior_vertex",
8718
+ surfaces: [
8719
+ {
8720
+ type: "spherical",
8721
+ name: "cornea_anterior",
8722
+ z: 0.0,
8723
+ radius: 7.7,
8724
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
8725
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
8726
+ },
8727
+ {
8728
+ type: "spherical",
8729
+ name: "cornea_posterior",
8730
+ z: 0.5,
8731
+ radius: 6.8,
8732
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
8733
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
8734
+ },
8735
+ {
8736
+ type: "spherical",
8737
+ name: "lens_anterior",
8738
+ z: 3.6,
8739
+ radius: 10.0,
8740
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
8741
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_anterior,
8742
+ },
8743
+ {
8744
+ type: "spherical",
8745
+ name: "lens_nucleus_anterior",
8746
+ z: 4.146,
8747
+ radius: 7.911,
8748
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_anterior,
8749
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_anterior,
8750
+ },
8751
+ {
8752
+ type: "spherical",
8753
+ name: "lens_nucleus_posterior",
8754
+ z: 6.565,
8755
+ radius: -5.76,
8756
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_anterior,
8757
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_posterior,
8758
+ },
8759
+ {
8760
+ type: "spherical",
8761
+ name: "lens_posterior",
8762
+ z: 7.2,
8763
+ radius: -6,
8764
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_posterior,
8765
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.vitreous,
8766
+ },
8767
+ {
8768
+ type: "spherical-image",
8769
+ name: "retina",
8770
+ radius: -12, // mm (대략적인 망막 곡률)
8771
+ z: 24.0 // 중심 위치
8772
+ }
8773
+ ],
8774
+ };
8775
+
8776
+ class NavarroParameter extends EyeModelParameter {
8777
+ constructor() {
8778
+ super(NavarroParameter.parameter);
8779
+ }
8780
+ }
8781
+ NavarroParameter.parameter = {
8782
+ unit: "mm",
8783
+ axis: "optical_axis_z",
8784
+ surfaces: [
8785
+ {
8786
+ type: "aspherical",
8787
+ name: "cornea_anterior",
8788
+ z: 0.0,
8789
+ radius: 7.72,
8790
+ conic: -0.26,
8791
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
8792
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
8793
+ },
8794
+ {
8795
+ type: "aspherical",
8796
+ name: "cornea_posterior",
8797
+ z: 0.55,
8798
+ radius: 6.5,
8799
+ conic: 0.0,
8800
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
8801
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
8802
+ },
8803
+ {
8804
+ type: "aspherical",
8805
+ name: "lens_anterior",
8806
+ z: 0.55 + 3.05,
8807
+ radius: 10.2,
8808
+ conic: -3.13,
8809
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
8810
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_navarro,
8811
+ },
8812
+ {
8813
+ type: "aspherical",
8814
+ name: "lens_posterior",
8815
+ z: 0.55 + 3.05 + 4.0,
8816
+ radius: -6,
8817
+ conic: -1,
8818
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_navarro,
8819
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.vitreous,
8820
+ },
8821
+ {
8822
+ type: "spherical-image",
8823
+ name: "retina",
8824
+ radius: -12, // mm (대략적인 망막 곡률)
8825
+ z: 24.04 // 중심 위치
8826
+ }
8827
+ ],
8828
+ };
8829
+
8830
+ /**
8831
+ * Sturm 계산 전용 클래스입니다.
8832
+ * - traced ray 집합에서 z-scan slice를 생성하고
8833
+ * - 평탄도/최소타원/근사중심을 계산해 분석 결과를 반환합니다.
8834
+ */
8835
+ class Sturm {
8836
+ constructor() {
8837
+ this.lastResult = null;
8838
+ this.lineOrder = ["g", "F", "e", "d", "C", "r"];
8839
+ }
8840
+ calculate(rays, effectiveCylinderD, axisReferenceRays, profileWorldZBounds) {
8841
+ const frame = this.analysisFrameFromRays(axisReferenceRays?.length ? axisReferenceRays : rays);
8842
+ const depthRange = this.depthRangeFromRays(rays, frame);
8843
+ const sturmSlices = this.collectSturmSlices(rays, frame, depthRange, DEFAULT_STURM_STEP_MM);
8844
+ const groupedByLine = this.groupByFraunhoferLine(rays);
8845
+ const sturmInfo = groupedByLine.map((group) => {
8846
+ const groupFrame = this.analysisFrameFromRays(group.rays, frame);
8847
+ const groupDepthRange = this.depthRangeFromRays(group.rays, groupFrame);
8848
+ const slices = this.collectSturmSlices(group.rays, groupFrame, groupDepthRange, DEFAULT_STURM_STEP_MM);
8849
+ const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD, profileWorldZBounds);
8850
+ return {
8851
+ line: group.line,
8852
+ wavelength_nm: group.wavelength_nm,
8853
+ color: group.color,
8854
+ ray_count: group.rays.length,
8855
+ analysis_axis: {
8856
+ x: groupFrame.axis.x,
8857
+ y: groupFrame.axis.y,
8858
+ z: groupFrame.axis.z,
8859
+ },
8860
+ ...analysis,
8861
+ };
8862
+ });
8863
+ const result = {
8864
+ slices_info: {
8865
+ count: sturmSlices.length,
8866
+ slices: sturmSlices,
8867
+ },
8868
+ sturm_info: sturmInfo,
8869
+ };
8870
+ this.lastResult = result;
8871
+ return result;
8872
+ }
8873
+ /**
8874
+ * 마지막 Sturm 계산 결과를 반환합니다.
8875
+ */
8876
+ getLastResult() {
8877
+ return this.lastResult;
8878
+ }
8879
+ getRayPoints(ray) {
8880
+ const points = ray.points;
8881
+ return Array.isArray(points) ? points : [];
8882
+ }
8883
+ analysisFrameFromRays(rays, fallback) {
8884
+ const axis = new Vector3();
8885
+ for (const ray of rays ?? [])
8886
+ axis.add(ray.getDirection());
8887
+ if (axis.lengthSq() < 1e-12) {
8888
+ if (fallback)
8889
+ return fallback;
8890
+ axis.set(0, 0, 1);
8891
+ }
8892
+ else {
8893
+ axis.normalize();
8894
+ }
8895
+ const origin = new Vector3(0, 0, 0);
8896
+ let helper = new Vector3(0, 1, 0);
8897
+ if (Math.abs(helper.dot(axis)) > 0.95)
8898
+ helper = new Vector3(1, 0, 0);
8899
+ const u = helper.clone().cross(axis).normalize();
8900
+ const v = axis.clone().cross(u).normalize();
8901
+ return { origin, axis, u, v };
8902
+ }
8903
+ sampleRayPointAtDepth(ray, frame, depth) {
8904
+ const points = this.getRayPoints(ray);
8905
+ for (let i = 0; i < points.length - 1; i += 1) {
8906
+ const a = points[i];
8907
+ const b = points[i + 1];
8908
+ const da = a.clone().sub(frame.origin).dot(frame.axis);
8909
+ const db = b.clone().sub(frame.origin).dot(frame.axis);
8910
+ if ((da <= depth && depth <= db) || (db <= depth && depth <= da)) {
8911
+ const denom = db - da;
8912
+ if (Math.abs(denom) < 1e-10)
8913
+ return null;
8914
+ return a.clone().lerp(b, (depth - da) / denom);
8915
+ }
8916
+ }
8917
+ return null;
8918
+ }
8919
+ depthRangeFromRays(rays, frame) {
8920
+ let depthMin = Number.POSITIVE_INFINITY;
8921
+ let depthMax = Number.NEGATIVE_INFINITY;
8922
+ for (const ray of rays ?? []) {
8923
+ for (const point of this.getRayPoints(ray)) {
8924
+ const d = point.clone().sub(frame.origin).dot(frame.axis);
8925
+ depthMin = Math.min(depthMin, d);
8926
+ depthMax = Math.max(depthMax, d);
8927
+ }
8928
+ }
8929
+ if (!Number.isFinite(depthMin) || !Number.isFinite(depthMax) || depthMax <= depthMin)
8930
+ return null;
8931
+ return { depthMin, depthMax };
8932
+ }
8933
+ secondMomentProfileAtDepth(rays, frame, depth) {
8934
+ const points = [];
8935
+ for (const ray of rays) {
8936
+ const point = this.sampleRayPointAtDepth(ray, frame, depth);
8937
+ if (point)
8938
+ points.push(point);
8939
+ }
8940
+ if (points.length < 4)
8941
+ return null;
8942
+ let cxWorld = 0;
8943
+ let cyWorld = 0;
8944
+ let czWorld = 0;
8945
+ let cx = 0;
8946
+ let cy = 0;
8947
+ for (const p of points) {
8948
+ cxWorld += p.x;
8949
+ cyWorld += p.y;
8950
+ czWorld += p.z;
8951
+ const delta = p.clone().sub(frame.origin);
8952
+ cx += delta.dot(frame.u);
8953
+ cy += delta.dot(frame.v);
8954
+ }
8955
+ cxWorld /= points.length;
8956
+ cyWorld /= points.length;
8957
+ czWorld /= points.length;
8958
+ cx /= points.length;
8959
+ cy /= points.length;
8960
+ let sxx = 0;
8961
+ let syy = 0;
8962
+ let sxy = 0;
8963
+ for (const p of points) {
8964
+ const delta = p.clone().sub(frame.origin);
8965
+ const x = delta.dot(frame.u);
8966
+ const y = delta.dot(frame.v);
8967
+ const dx = x - cx;
8968
+ const dy = y - cy;
8969
+ sxx += dx * dx;
8970
+ syy += dy * dy;
8971
+ sxy += dx * dy;
8972
+ }
8973
+ sxx /= points.length;
8974
+ syy /= points.length;
8975
+ sxy /= points.length;
8976
+ const trace = sxx + syy;
8977
+ const halfDiff = (sxx - syy) / 2;
8978
+ const root = Math.sqrt(Math.max(0, halfDiff * halfDiff + sxy * sxy));
8979
+ const lambdaMajor = Math.max(0, trace / 2 + root);
8980
+ const lambdaMinor = Math.max(0, trace / 2 - root);
8981
+ const thetaRad = 0.5 * Math.atan2(2 * sxy, sxx - syy);
8982
+ const angleMajorDeg = ((thetaRad * 180) / Math.PI + 360) % 180;
8983
+ const twoThetaRad = 2 * thetaRad;
8984
+ const j0 = Math.cos(twoThetaRad);
8985
+ const j45 = Math.sin(twoThetaRad);
8986
+ const majorDirection = frame.u.clone().multiplyScalar(Math.cos(thetaRad))
8987
+ .add(frame.v.clone().multiplyScalar(Math.sin(thetaRad)))
8988
+ .normalize();
8989
+ const minorDirection = frame.axis.clone().cross(majorDirection).normalize();
8990
+ return {
8991
+ at: { x: cxWorld, y: cyWorld, z: czWorld },
8992
+ wMajor: Math.sqrt(lambdaMajor),
8993
+ wMinor: Math.sqrt(lambdaMinor),
8994
+ angleMajorDeg,
8995
+ j0,
8996
+ j45,
8997
+ angleMinorDeg: (angleMajorDeg + 90) % 180,
8998
+ majorDirection: {
8999
+ x: majorDirection.x,
9000
+ y: majorDirection.y,
9001
+ z: majorDirection.z,
9002
+ },
9003
+ minorDirection: {
9004
+ x: minorDirection.x,
9005
+ y: minorDirection.y,
9006
+ z: minorDirection.z,
9007
+ },
8759
9008
  };
8760
- return new ToricSurface(props);
8761
9009
  }
8762
- applyChromaticIndicesToSubSurfaces(ray) {
8763
- const line = ray.getFraunhoferLine();
8764
- const nBefore = resolveRefractiveIndex(this.n_before, line);
8765
- const n = resolveRefractiveIndex(this.n, line);
8766
- const nAfter = resolveRefractiveIndex(this.n_after, line);
8767
- const sphericalState = this.sphericalSurface;
8768
- if (this.toricSurface) {
8769
- sphericalState.n_before = n;
8770
- sphericalState.n_after = nAfter;
8771
- const toricState = this.toricSurface;
8772
- toricState.n_before = nBefore;
8773
- toricState.n_after = n;
8774
- return;
9010
+ collectSturmSlices(rays, frame, depthRange, stepMm) {
9011
+ if (!depthRange)
9012
+ return [];
9013
+ const out = [];
9014
+ for (let depth = depthRange.depthMin; depth <= depthRange.depthMax; depth += stepMm) {
9015
+ const profile = this.secondMomentProfileAtDepth(rays, frame, depth);
9016
+ if (!profile)
9017
+ continue;
9018
+ out.push({
9019
+ // Keep z in world coordinates for backward-compatible consumers.
9020
+ z: profile.at.z,
9021
+ // Preserve analysis-axis depth for off-axis robust ranking/interval logic.
9022
+ depth,
9023
+ ratio: profile.wMinor / Math.max(profile.wMajor, 1e-9),
9024
+ size: Math.hypot(profile.wMajor, profile.wMinor),
9025
+ profile,
9026
+ });
8775
9027
  }
8776
- sphericalState.n_before = nBefore;
8777
- sphericalState.n_after = n;
9028
+ return out;
9029
+ }
9030
+ axisDiffDeg(a, b) {
9031
+ const d = Math.abs((((a - b) % 180) + 180) % 180);
9032
+ return Math.min(d, 180 - d);
9033
+ }
9034
+ phasorDot(p, q) {
9035
+ return p.j0 * q.j0 + p.j45 * q.j45;
8778
9036
  }
8779
9037
  /**
8780
- * 구면/토릭 곡면의 z 교차(토릭이 구면을 관통) 방지를 위해
8781
- * 샘플링 영역에서 필요한 최소 중심두께를 계산합니다.
9038
+ * 평탄도 순으로 정렬된 슬라이스에서 선초점 쌍(직교 주경선) 해당하는 Top2를 고른다.
9039
+ * 주경선 대신 이각 phasor (cos 2θ, sin 2θ)로 직교를 판정하고, 실패 시 각도·전역 검색·깊이 폴백을 사용한다.
8782
9040
  */
8783
- optimizeThickness(requestedThickness) {
8784
- if (Math.abs(this.c) < ST_POWER_EPS_D)
8785
- return requestedThickness;
8786
- const sampleRadius = this.samplingRadiusMm();
8787
- if (!Number.isFinite(sampleRadius) || sampleRadius <= 0)
8788
- return requestedThickness;
8789
- const samplesPerAxis = 49;
8790
- let requiredThickness = requestedThickness;
8791
- const safetyMargin = Math.max(0.05, 2 * RAY_SURFACE_ESCAPE_MM);
8792
- for (let iy = 0; iy < samplesPerAxis; iy++) {
8793
- const y = -sampleRadius + (2 * sampleRadius * iy) / (samplesPerAxis - 1);
8794
- for (let ix = 0; ix < samplesPerAxis; ix++) {
8795
- const x = -sampleRadius + (2 * sampleRadius * ix) / (samplesPerAxis - 1);
8796
- if ((x * x + y * y) > sampleRadius * sampleRadius)
9041
+ pickFlattestSturmPair(sortedByFlatness, top2MinGapMm, allSlices) {
9042
+ if (sortedByFlatness.length === 0)
9043
+ return [];
9044
+ const first = sortedByFlatness[0];
9045
+ const top2MinAngleGapDeg = DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG;
9046
+ const phasorStrict = DEFAULT_STURM_TOP2_PHASOR_OPPOSITION_MAX_DOT;
9047
+ const phasorLoose = DEFAULT_STURM_TOP2_PHASOR_FALLBACK_MAX_DOT;
9048
+ const lastResortMinDeg = DEFAULT_STURM_TOP2_AXIS_LAST_RESORT_MIN_GAP_DEG;
9049
+ const depthOk = (c) => Math.abs(c.depth - first.depth) >= top2MinGapMm;
9050
+ const byPhasor = (pool, maxDot) => pool.find((c) => (c !== first
9051
+ && depthOk(c)
9052
+ && this.phasorDot(first.profile, c.profile) <= maxDot));
9053
+ let second = byPhasor(sortedByFlatness, phasorStrict)
9054
+ ?? byPhasor(sortedByFlatness, phasorLoose);
9055
+ if (!second) {
9056
+ second = sortedByFlatness.find((c) => (c !== first
9057
+ && depthOk(c)
9058
+ && this.axisDiffDeg(c.profile.angleMajorDeg, first.profile.angleMajorDeg) >= top2MinAngleGapDeg));
9059
+ }
9060
+ if (!second) {
9061
+ let best = null;
9062
+ let bestAxDiff = -1;
9063
+ for (const c of sortedByFlatness) {
9064
+ if (c === first || !depthOk(c))
8797
9065
  continue;
8798
- const sphericalSag = this.sphericalSagAtXY(x, y);
8799
- const toricSag = this.toricSagAtXY(x, y);
8800
- if (!Number.isFinite(sphericalSag) || !Number.isFinite(toricSag))
9066
+ const axd = this.axisDiffDeg(c.profile.angleMajorDeg, first.profile.angleMajorDeg);
9067
+ if (axd > bestAxDiff) {
9068
+ bestAxDiff = axd;
9069
+ best = c;
9070
+ }
9071
+ }
9072
+ if (best && bestAxDiff >= lastResortMinDeg)
9073
+ second = best;
9074
+ }
9075
+ // 평탄도 정렬 풀만으로는 같은 phasor만 연속으로 나오는 경우가 있어, 전 스캔에서 가장 반대인 phasor를 고른다.
9076
+ if (!second) {
9077
+ let best = null;
9078
+ let bestDot = 1;
9079
+ for (const c of allSlices) {
9080
+ if (c === first || !depthOk(c))
8801
9081
  continue;
8802
- const localRequired = (sphericalSag - toricSag) + safetyMargin;
8803
- if (localRequired > requiredThickness)
8804
- requiredThickness = localRequired;
9082
+ const d = this.phasorDot(first.profile, c.profile);
9083
+ if (d < bestDot) {
9084
+ bestDot = d;
9085
+ best = c;
9086
+ }
8805
9087
  }
9088
+ if (best && bestDot < -0.08)
9089
+ second = best;
8806
9090
  }
8807
- return Math.max(0, requiredThickness);
8808
- }
8809
- /**
8810
- * 기준점(referencePoint.z)으로부터 토릭 꼭지(z)까지의 최소 간격을 확보합니다.
8811
- * - 기준점과 반대 방향으로 현재 토릭 꼭지가 놓인 쪽(sign)을 유지합니다.
8812
- * - 최소 간격은 "토릭–구면 거리(thickness) + 안전여유"입니다.
8813
- */
8814
- optimizeToricVertexZFromReference(toricVertexZ, referenceZ, thicknessMm = this.thickness) {
8815
- if (!Number.isFinite(referenceZ))
8816
- return toricVertexZ;
8817
- const safetyMargin = Math.max(0.05, 2 * RAY_SURFACE_ESCAPE_MM);
8818
- const minGap = Math.max(EYE_ST_SURFACE_OFFSET_MM, Math.max(0, thicknessMm) + safetyMargin);
8819
- const delta = toricVertexZ - referenceZ;
8820
- const side = Math.abs(delta) < 1e-12 ? -1 : Math.sign(delta);
8821
- const currentGap = Math.abs(delta);
8822
- if (currentGap >= minGap)
8823
- return toricVertexZ;
8824
- return referenceZ + side * minGap;
8825
- }
8826
- samplingRadiusMm() {
8827
- const defaultRadius = 12;
8828
- const finiteRadii = [this.sphericalRadiusMm, this.toricRadiusPerpMm]
8829
- .filter((r) => Number.isFinite(r) && Math.abs(r) > 1e-6)
8830
- .map((r) => Math.abs(r));
8831
- if (!finiteRadii.length)
8832
- return defaultRadius;
8833
- return Math.max(1.0, Math.min(defaultRadius, 0.98 * Math.min(...finiteRadii)));
8834
- }
8835
- /**
8836
- * 구면 측 꼭지점 기준 sag(mm)
8837
- */
8838
- sphericalSagAtXY(x, y) {
8839
- const rhoSq = x * x + y * y;
8840
- const r = this.sphericalRadiusMm;
8841
- if (!Number.isFinite(r) || Math.abs(r) > 1e12)
8842
- return 0;
8843
- const rr = r * r;
8844
- if (rhoSq > rr)
8845
- return Number.NaN;
8846
- const root = Math.sqrt(Math.max(0, rr - rhoSq));
8847
- return r > 0 ? r - root : r + root;
8848
- }
8849
- /**
8850
- * 토릭 측 꼭지점 기준 sag(mm)
8851
- */
8852
- toricSagAtXY(x, y) {
8853
- const axisRad = (this.ax * Math.PI) / 180;
8854
- const cAxis = Math.cos(axisRad);
8855
- const sAxis = Math.sin(axisRad);
8856
- const u = cAxis * x + sAxis * y;
8857
- const v = -sAxis * x + cAxis * y;
8858
- const cu = 0; // r_axis = Infinity
8859
- const cv = (!Number.isFinite(this.toricRadiusPerpMm) || Math.abs(this.toricRadiusPerpMm) > 1e12)
8860
- ? 0
8861
- : 1 / this.toricRadiusPerpMm;
8862
- const a = cu * u * u + cv * v * v;
8863
- const b = 1 - cu * cu * u * u - cv * cv * v * v;
8864
- if (b < 0)
8865
- return Number.NaN;
8866
- const den = 1 + Math.sqrt(Math.max(0, b));
8867
- if (Math.abs(den) < 1e-12)
8868
- return Number.NaN;
8869
- return a / den;
9091
+ // 마지막: 가장 납작한 슬라이스와 깊이 차가 가장 큰 “비교적 납작한” 슬라이스 (선초점이 z로 분리될 때)
9092
+ if (!second) {
9093
+ const flatThreshold = first.ratio * 1.5 + 1e-6;
9094
+ let best = null;
9095
+ let bestDepthSpan = -1;
9096
+ for (const c of allSlices) {
9097
+ if (c === first || !depthOk(c))
9098
+ continue;
9099
+ if (c.ratio > flatThreshold)
9100
+ continue;
9101
+ const span = Math.abs(c.depth - first.depth);
9102
+ if (span > bestDepthSpan) {
9103
+ bestDepthSpan = span;
9104
+ best = c;
9105
+ }
9106
+ }
9107
+ if (best)
9108
+ second = best;
9109
+ }
9110
+ return second ? [first, second] : [first];
8870
9111
  }
8871
- isOpticallyNeutral() {
8872
- return (Math.abs(Number(this.s) || 0) < STSurface.POWER_EPS_D
8873
- && Math.abs(Number(this.c) || 0) < STSurface.POWER_EPS_D);
9112
+ buildApproxCenter(flattestTop2, smallestEllipse, preferTop2Mid) {
9113
+ if (flattestTop2.length <= 0)
9114
+ return null;
9115
+ if (preferTop2Mid && flattestTop2.length >= 2) {
9116
+ const first = flattestTop2[0];
9117
+ const second = flattestTop2[1];
9118
+ return {
9119
+ x: (first.profile.at.x + second.profile.at.x) / 2,
9120
+ y: (first.profile.at.y + second.profile.at.y) / 2,
9121
+ z: (first.z + second.z) / 2,
9122
+ mode: "top2-mid",
9123
+ };
9124
+ }
9125
+ if (smallestEllipse) {
9126
+ return {
9127
+ x: smallestEllipse.profile.at.x,
9128
+ y: smallestEllipse.profile.at.y,
9129
+ z: smallestEllipse.z,
9130
+ mode: "min-size",
9131
+ };
9132
+ }
9133
+ const first = flattestTop2[0];
9134
+ return { x: first.profile.at.x, y: first.profile.at.y, z: first.z, mode: "top1-flat" };
8874
9135
  }
8875
- refract(ray) {
8876
- // 무도수 ST면은 기하(면 위치/경사)는 유지하되 굴절력은 0으로 취급하여 직진 통과시킵니다.
8877
- if (this.isOpticallyNeutral()) {
8878
- const direction = ray.getDirection().normalize();
8879
- const passthrough = ray.clone();
8880
- const hitPoint = this.sphericalSurface.incident(ray);
8881
- if (hitPoint) {
8882
- passthrough.appendPoint(hitPoint);
8883
- passthrough.continueFrom(hitPoint.clone().addScaledVector(direction, RAY_SURFACE_ESCAPE_MM), direction);
9136
+ slicesForSturmAnalysis(sturmSlices, bounds) {
9137
+ if (!bounds)
9138
+ return sturmSlices;
9139
+ const zMin = bounds.zMin;
9140
+ const zMax = bounds.zMax;
9141
+ if (!Number.isFinite(zMin) || !Number.isFinite(zMax) || zMax < zMin)
9142
+ return sturmSlices;
9143
+ const bounded = sturmSlices.filter((s) => {
9144
+ const z = s.profile?.at?.z;
9145
+ return Number.isFinite(z) && z >= zMin && z <= zMax;
9146
+ });
9147
+ return bounded.length >= 2 ? bounded : sturmSlices;
9148
+ }
9149
+ groupByFraunhoferLine(rays) {
9150
+ const groups = new Map();
9151
+ for (const ray of rays) {
9152
+ const line = ray.getFraunhoferLine();
9153
+ const wavelength = ray.getWavelengthNm();
9154
+ const color = Number(ray.displayColor);
9155
+ if (!groups.has(line)) {
9156
+ groups.set(line, {
9157
+ line,
9158
+ wavelength_nm: wavelength,
9159
+ color: Number.isFinite(color) ? color : null,
9160
+ rays: [],
9161
+ });
8884
9162
  }
8885
- this.refractedRays.push(passthrough.clone());
8886
- return passthrough;
8887
- }
8888
- this.applyChromaticIndicesToSubSurfaces(ray);
8889
- // 원통 성분이 없으면 단일(구면)면으로 처리합니다.
8890
- if (!this.toricSurface) {
8891
- const single = this.sphericalSurface.refract(ray);
8892
- if (!single)
8893
- return null;
8894
- this.refractedRays.push(single.clone());
8895
- return single;
9163
+ const group = groups.get(line);
9164
+ if (group)
9165
+ group.rays.push(ray);
8896
9166
  }
8897
- // +z 진행: 토릭(작은 z) 구면(큰 z)
8898
- const afterToric = this.toricSurface.refract(ray);
8899
- if (!afterToric)
8900
- return null;
8901
- const afterSpherical = this.sphericalSurface.refract(afterToric);
8902
- if (!afterSpherical)
8903
- return null;
8904
- this.refractedRays.push(afterSpherical.clone());
8905
- return afterSpherical;
8906
- }
8907
- incident(ray) {
8908
- // 복합면의 첫 hit: +z 기준 토릭이 앞서므로 toricSurface 우선.
8909
- const primary = this.toricSurface ?? this.sphericalSurface;
8910
- const hitPoint = primary.incident(ray);
8911
- if (!hitPoint)
8912
- return null;
8913
- this.incidentRays.push(ray.clone());
8914
- return hitPoint;
9167
+ return [...groups.values()].sort((a, b) => this.lineOrder.indexOf(a.line) - this.lineOrder.indexOf(b.line));
8915
9168
  }
8916
- getOptimizedThicknessMm() {
8917
- return this.thickness;
9169
+ analyzeSturmSlices(sturmSlices, effectiveCylinderD, profileWorldZBounds) {
9170
+ const top2MinGapMm = DEFAULT_STURM_TOP2_MIN_GAP_MM;
9171
+ const effectiveCylinderThresholdD = DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D;
9172
+ const preferTop2Mid = effectiveCylinderD >= effectiveCylinderThresholdD;
9173
+ const slicesForAnalysis = this.slicesForSturmAnalysis(sturmSlices, profileWorldZBounds ?? null);
9174
+ const sortedByFlatness = [...slicesForAnalysis].sort((a, b) => a.ratio - b.ratio);
9175
+ const flattestTop2 = sortedByFlatness.length > 0
9176
+ ? this.pickFlattestSturmPair(sortedByFlatness, top2MinGapMm, slicesForAnalysis)
9177
+ : [];
9178
+ let smallestEllipse = null;
9179
+ for (const slice of slicesForAnalysis) {
9180
+ if (!smallestEllipse || slice.size < smallestEllipse.size)
9181
+ smallestEllipse = slice;
9182
+ }
9183
+ const approxCenter = this.buildApproxCenter(flattestTop2, smallestEllipse, preferTop2Mid);
9184
+ const anterior = flattestTop2[0] ?? null;
9185
+ const posterior = preferTop2Mid ? (flattestTop2[1] ?? null) : null;
9186
+ return {
9187
+ has_astigmatism: preferTop2Mid,
9188
+ method: preferTop2Mid ? "sturm-interval-midpoint" : "minimum-ellipse",
9189
+ anterior,
9190
+ posterior,
9191
+ approx_center: approxCenter,
9192
+ };
8918
9193
  }
8919
9194
  }
8920
- STSurface.POWER_EPS_D = ST_POWER_EPS_D;
8921
9195
 
8922
- const TABOToDeg = (TABOAngle) => {
8923
- const t = Number(TABOAngle);
8924
- if (!Number.isFinite(t))
8925
- return 0;
8926
- return (((180 - t) % 180) + 180) % 180;
9196
+ const DEFAULT_LIGHT_SOURCE_CONFIG = {
9197
+ type: "grid",
9198
+ width: 10,
9199
+ height: 10,
9200
+ division: 4,
9201
+ z: -10,
9202
+ vergence: 0,
8927
9203
  };
8928
-
9204
+ // ================ Helper functions ================
9205
+ function toFiniteNumber(value, fallback = 0) {
9206
+ const parsed = Number(value);
9207
+ return Number.isFinite(parsed) ? parsed : fallback;
9208
+ }
9209
+ function normalizePrismAmount(value) {
9210
+ const p = Number(value ?? 0);
9211
+ return Number.isFinite(p) ? Math.max(0, p) : 0;
9212
+ }
9213
+ function normalizeAngle360(value) {
9214
+ const d = Number(value ?? 0);
9215
+ if (!Number.isFinite(d))
9216
+ return 0;
9217
+ return ((d % 360) + 360) % 360;
9218
+ }
9219
+ function normalizeEyeTilt(value) {
9220
+ return {
9221
+ x: toFiniteNumber(value?.x),
9222
+ y: toFiniteNumber(value?.y),
9223
+ };
9224
+ }
9225
+ function normalizeLightSourcePose(value) {
9226
+ return {
9227
+ position: {
9228
+ x: toFiniteNumber(value?.position?.x),
9229
+ y: toFiniteNumber(value?.position?.y),
9230
+ z: toFiniteNumber(value?.position?.z),
9231
+ },
9232
+ tilt: {
9233
+ x: toFiniteNumber(value?.tilt?.x),
9234
+ y: toFiniteNumber(value?.tilt?.y),
9235
+ },
9236
+ };
9237
+ }
9238
+ function isNormalizedEngineConfig(config) {
9239
+ const candidate = config;
9240
+ return (candidate.eyeModel !== undefined
9241
+ && candidate.eye !== undefined
9242
+ && candidate.light_source !== undefined
9243
+ && candidate.pupil_type !== undefined
9244
+ && Array.isArray(candidate.lens));
9245
+ }
9246
+ function normalizeEngineProps(props = {}) {
9247
+ const eyeModel = props.eyeModel ?? "gullstrand";
9248
+ const eyeInput = props.eye ?? { s: 0, c: 0, ax: 0, p: 0, p_ax: 0 };
9249
+ const lensInput = Array.isArray(props.lens) ? props.lens : [];
9250
+ const lightSourceInput = props.light_source ?? DEFAULT_LIGHT_SOURCE_CONFIG;
9251
+ const normalizedLightSourcePose = normalizeLightSourcePose(lightSourceInput);
9252
+ return {
9253
+ eyeModel,
9254
+ eye: {
9255
+ s: toFiniteNumber(eyeInput?.s),
9256
+ c: toFiniteNumber(eyeInput?.c),
9257
+ ax: toFiniteNumber(eyeInput?.ax),
9258
+ p: normalizePrismAmount(eyeInput?.p),
9259
+ p_ax: normalizeAngle360(eyeInput?.p_ax),
9260
+ tilt: normalizeEyeTilt(eyeInput?.tilt),
9261
+ },
9262
+ lens: lensInput.map((spec) => ({
9263
+ s: toFiniteNumber(spec?.s),
9264
+ c: toFiniteNumber(spec?.c),
9265
+ ax: toFiniteNumber(spec?.ax),
9266
+ p: normalizePrismAmount(spec?.p),
9267
+ p_ax: normalizeAngle360(spec?.p_ax),
9268
+ position: {
9269
+ x: toFiniteNumber(spec?.position?.x),
9270
+ y: toFiniteNumber(spec?.position?.y),
9271
+ z: toFiniteNumber(spec?.position?.z, SPECTACLE_VERTEX_DISTANCE_MM),
9272
+ },
9273
+ tilt: {
9274
+ x: toFiniteNumber(spec?.tilt?.x),
9275
+ y: toFiniteNumber(spec?.tilt?.y),
9276
+ },
9277
+ })),
9278
+ light_source: {
9279
+ ...lightSourceInput,
9280
+ position: { ...normalizedLightSourcePose.position },
9281
+ tilt: { ...normalizedLightSourcePose.tilt },
9282
+ },
9283
+ pupil_type: props.pupil_type ?? "neutral",
9284
+ };
9285
+ }
8929
9286
  /**
8930
9287
  * legacy simulator.js를 TypeScript로 옮긴 핵심 시뮬레이터입니다.
8931
9288
  * - 광원 광선을 생성하고
@@ -8933,7 +9290,10 @@ const TABOToDeg = (TABOAngle) => {
8933
9290
  * - Sturm 분석까지 제공합니다.
8934
9291
  */
8935
9292
  class SCAXEngineCore {
8936
- constructor(props = {}) {
9293
+ get eyeModelParameter() {
9294
+ return this._eyeModelParameter;
9295
+ }
9296
+ constructor(config = {}) {
8937
9297
  this.tracedRays = [];
8938
9298
  this.lastSourceRaysForSturm = [];
8939
9299
  this.lastSturmGapAnalysis = null;
@@ -8942,14 +9302,14 @@ class SCAXEngineCore {
8942
9302
  this.sortedEyeSurfaces = [];
8943
9303
  this.sturm = new Sturm();
8944
9304
  this.affine = new Affine();
8945
- this.configure(props);
9305
+ this.configure(isNormalizedEngineConfig(config) ? config : normalizeEngineProps(config));
8946
9306
  }
8947
9307
  /**
8948
9308
  * 생성자와 동일한 기본값 규칙으로 광학 설정을 다시 적용합니다.
8949
9309
  * 생략한 최상위 필드는 매번 기본값으로 돌아갑니다(이전 값과 병합하지 않음).
8950
9310
  */
8951
- update(props = {}) {
8952
- this.configure(props);
9311
+ update(config = {}) {
9312
+ this.configure(isNormalizedEngineConfig(config) ? config : normalizeEngineProps(config));
8953
9313
  }
8954
9314
  dispose() {
8955
9315
  [...this.lens, ...this.surfaces].forEach((surface) => {
@@ -8964,141 +9324,40 @@ class SCAXEngineCore {
8964
9324
  this.sortedEyeSurfaces = [];
8965
9325
  this.sortedLensSurfaces = [];
8966
9326
  }
8967
- configure(props = {}) {
9327
+ configure(config) {
8968
9328
  this.lastSturmGapAnalysis = null;
8969
9329
  this.lastAffineAnalysis = null;
8970
- const { eyeModel = "gullstrand", eye = { s: 0, c: 0, ax: 0, p: 0, p_ax: 0 }, lens = [], light_source = { type: "grid", width: 10, height: 10, division: 4, z: -10, vergence: 0 }, pupil_type = "neutral", } = props;
8971
- this.eyeModel = eyeModel;
8972
- const normalizedEyeSphere = this.toFiniteNumber(eye?.s) + (eyeModel === "gullstrand" ? -1 : 0);
8973
- // eye 입력은 auto refractor(굴절오차) 기준이므로,
8974
- // 광학면에 적용할 때는 보정렌즈 파워 관점으로 부호를 반전합니다.
8975
- this.eyePower = {
8976
- s: -normalizedEyeSphere,
8977
- c: -this.toFiniteNumber(eye?.c),
8978
- ax: this.toFiniteNumber(eye?.ax),
8979
- };
8980
- this.eyePrismPrescription = {
8981
- p: this.normalizePrismAmount(eye?.p),
8982
- p_ax: this.normalizeAngle360(eye?.p_ax),
8983
- };
8984
- const eyeRx = this.prismVectorFromBase(this.eyePrismPrescription.p, this.eyePrismPrescription.p_ax);
8985
- // eye 입력은 "교정 필요량(처방값)"이므로 실제 안구 편위는 역벡터입니다.
8986
- this.eyePrismEffectVector = { x: -eyeRx.x, y: -eyeRx.y };
8987
- const eyeRotXDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.x);
8988
- const eyeRotYDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.y);
8989
- this.eyeTiltDeg = this.normalizeEyeTilt(eye?.tilt);
8990
- // Apply eye deviation in the opposite direction so a lens with the same
8991
- // prism/base prescription can optically compensate the eye deviation.
8992
- const eyeEulerXDeg = eyeRotYDeg + this.eyeTiltDeg.x;
8993
- const eyeEulerYDeg = (-eyeRotXDeg) + this.eyeTiltDeg.y;
8994
- this.eyeRotationQuaternion = new Quaternion().setFromEuler(new Euler((eyeEulerXDeg * Math.PI) / 180, (eyeEulerYDeg * Math.PI) / 180, 0, "XYZ"));
8995
- this.eyeRotationQuaternionInverse = this.eyeRotationQuaternion.clone().invert();
8996
- this.eyeRotationPivot = new Vector3(0, 0, SCAXEngineCore.EYE_ROTATION_PIVOT_FROM_CORNEA_MM);
8997
- const normalizedLightSourcePose = this.normalizeLightSourcePose(light_source);
8998
- this.lightSourcePosition = new Vector3(normalizedLightSourcePose.position.x, normalizedLightSourcePose.position.y, normalizedLightSourcePose.position.z);
8999
- this.lightSourceTiltDeg = { ...normalizedLightSourcePose.tilt };
9000
- this.lightSourceRotationQuaternion = new Quaternion().setFromEuler(new Euler((this.lightSourceTiltDeg.x * Math.PI) / 180, (this.lightSourceTiltDeg.y * Math.PI) / 180, 0, "XYZ"));
9001
- // Rotate around the light source local center plane (z), not world origin,
9002
- // so tilt changes orientation without unintentionally translating the source center.
9003
- this.lightSourceRotationPivot = new Vector3(0, 0, this.toFiniteNumber(light_source?.z));
9004
- this.lensConfigs = (Array.isArray(lens) ? lens : []).map((spec) => ({
9005
- s: this.toFiniteNumber(spec?.s),
9006
- c: this.toFiniteNumber(spec?.c),
9007
- ax: this.toFiniteNumber(spec?.ax),
9008
- p: this.normalizePrismAmount(spec?.p),
9009
- p_ax: this.normalizeAngle360(spec?.p_ax),
9330
+ const { eyeModel, eye, lens, light_source, pupil_type } = config;
9331
+ const lensConfigs = lens.map((spec) => ({
9332
+ s: spec.s,
9333
+ c: spec.c,
9334
+ ax: spec.ax,
9335
+ p: spec.p,
9336
+ p_ax: spec.p_ax,
9010
9337
  position: {
9011
- x: this.toFiniteNumber(spec?.position?.x),
9012
- y: this.toFiniteNumber(spec?.position?.y),
9013
- z: this.toFiniteNumber(spec?.position?.z, SPECTACLE_VERTEX_DISTANCE_MM),
9338
+ x: spec.position.x,
9339
+ y: spec.position.y,
9340
+ z: spec.position.z,
9014
9341
  },
9015
9342
  tilt: {
9016
- x: this.toFiniteNumber(spec?.tilt?.x),
9017
- y: this.toFiniteNumber(spec?.tilt?.y),
9018
- },
9019
- includeInAstigmatismSummary: spec?.includeInAstigmatismSummary !== false,
9020
- }));
9021
- this.currentProps = {
9022
- eyeModel,
9023
- eye: {
9024
- s: this.toFiniteNumber(eye?.s),
9025
- c: this.toFiniteNumber(eye?.c),
9026
- ax: this.toFiniteNumber(eye?.ax),
9027
- p: this.normalizePrismAmount(eye?.p),
9028
- p_ax: this.normalizeAngle360(eye?.p_ax),
9029
- tilt: this.normalizeEyeTilt(eye?.tilt),
9030
- },
9031
- lens: this.lensConfigs.map((spec) => ({
9032
- s: this.toFiniteNumber(spec.s),
9033
- c: this.toFiniteNumber(spec.c),
9034
- ax: this.toFiniteNumber(spec.ax),
9035
- p: this.normalizePrismAmount(spec.p),
9036
- p_ax: this.normalizeAngle360(spec.p_ax),
9037
- position: {
9038
- x: this.toFiniteNumber(spec.position?.x),
9039
- y: this.toFiniteNumber(spec.position?.y),
9040
- z: this.toFiniteNumber(spec.position?.z, SPECTACLE_VERTEX_DISTANCE_MM),
9041
- },
9042
- tilt: {
9043
- x: this.toFiniteNumber(spec.tilt?.x),
9044
- y: this.toFiniteNumber(spec.tilt?.y),
9045
- },
9046
- includeInAstigmatismSummary: spec.includeInAstigmatismSummary,
9047
- })),
9048
- light_source: {
9049
- ...light_source,
9050
- position: { ...normalizedLightSourcePose.position },
9051
- tilt: { ...normalizedLightSourcePose.tilt },
9343
+ x: spec.tilt.x,
9344
+ y: spec.tilt.y,
9052
9345
  },
9053
- pupil_type,
9054
- };
9055
- this.lensPowers = this.lensConfigs.map((spec) => ({
9056
- s: this.toFiniteNumber(spec?.s),
9057
- c: this.toFiniteNumber(spec?.c),
9058
- ax: this.toFiniteNumber(spec?.ax),
9059
9346
  }));
9060
- // eye/lens prism axis는 모두 임상 Base 방향(렌즈->각막 시점)으로 입력합니다.
9061
- // 내부 광선 편향 벡터는 Base의 반대방향(= +180° 변환)으로 계산합니다.
9062
- this.lensPrismVector = this.lensConfigs.reduce((acc, spec) => {
9063
- const v = this.prismVectorFromBase(spec.p ?? 0, spec.p_ax ?? 0);
9064
- return { x: acc.x + v.x, y: acc.y + v.y };
9065
- }, { x: 0, y: 0 });
9066
9347
  // "none"은 동공 제한을 완전히 비활성화합니다.
9067
- this.pupilDiameterMm = pupil_type === "none" ? 0 : Number(PUPIL_SIZE[pupil_type]);
9068
- this.eyeModelParameter = eyeModel === "gullstrand" ? new GullstrandParameter() : new NavarroParameter();
9069
- const eyeSt = new STSurface({
9070
- type: "compound",
9071
- name: "eye_st",
9072
- position: { x: 0, y: 0, z: -EYE_ST_SURFACE_OFFSET_MM },
9073
- referencePoint: { x: 0, y: 0, z: 0 },
9074
- tilt: { x: 0, y: 0 },
9075
- s: this.eyePower.s,
9076
- c: this.eyePower.c,
9077
- ax: this.eyePower.ax,
9078
- n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
9079
- n: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
9080
- n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
9348
+ const pupilMm = pupil_type === "none" ? 0 : Number(PUPIL_SIZE[pupil_type]);
9349
+ this._eyeModelParameter = eyeModel === "gullstrand" ? new GullstrandParameter() : new NavarroParameter();
9350
+ this.surfaces = this._eyeModelParameter.createSurface({
9351
+ eyeModel,
9352
+ s: eye.s,
9353
+ c: eye.c,
9354
+ ax: eye.ax,
9355
+ p: eye.p,
9356
+ p_ax: eye.p_ax,
9357
+ tilt: eye.tilt,
9358
+ pupilDiameterMm: pupilMm,
9081
9359
  });
9082
- this.surfaces = [eyeSt, ...this.eyeModelParameter.createSurface()];
9083
- this.hasPupilStop = false;
9084
- if (Number.isFinite(this.pupilDiameterMm) && this.pupilDiameterMm > 0) {
9085
- const pupilStop = new ApertureStopSurface({
9086
- type: "aperture_stop",
9087
- name: "pupil_stop",
9088
- shape: "circle",
9089
- radius: this.pupilDiameterMm / 2,
9090
- // eye_st(눈 굴절력 surface)의 첫 굴절면(back vertex) 바로 앞에서 차단/통과를 판정합니다.
9091
- position: {
9092
- x: 0,
9093
- y: 0,
9094
- z: -EYE_ST_SURFACE_OFFSET_MM - (2 * RAY_SURFACE_ESCAPE_MM),
9095
- },
9096
- tilt: { x: 0, y: 0 },
9097
- });
9098
- this.surfaces = [pupilStop, ...this.surfaces];
9099
- this.hasPupilStop = true;
9100
- }
9101
- this.lens = this.lensConfigs.map((spec, index) => new STSurface({
9360
+ this.lens = lensConfigs.map((spec, index) => new STSurface({
9102
9361
  type: "compound",
9103
9362
  name: `lens_st_${index + 1}`,
9104
9363
  position: {
@@ -9111,12 +9370,14 @@ class SCAXEngineCore {
9111
9370
  // 1) back surface is fixed at vertex distance(position.z)
9112
9371
  // 2) back-front gap is optimized (thickness=0 => auto)
9113
9372
  thickness: 0,
9114
- s: this.toFiniteNumber(spec?.s),
9115
- c: this.toFiniteNumber(spec?.c),
9116
- ax: this.toFiniteNumber(spec?.ax),
9373
+ s: spec.s,
9374
+ c: spec.c,
9375
+ ax: spec.ax,
9117
9376
  n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
9118
9377
  n: FRAUNHOFER_REFRACTIVE_INDICES.crown_glass,
9119
9378
  n_after: FRAUNHOFER_REFRACTIVE_INDICES.air,
9379
+ p: spec.p,
9380
+ p_ax: spec.p_ax,
9120
9381
  }));
9121
9382
  this.light_source = light_source.type === "radial"
9122
9383
  ? new RadialLightSource(light_source)
@@ -9132,52 +9393,7 @@ class SCAXEngineCore {
9132
9393
  simulate() {
9133
9394
  const tracedRays = this.rayTracing();
9134
9395
  this.sturmCalculation(tracedRays);
9135
- const lightDeviation = this.calculateLightDeviation();
9136
- return {
9137
- traced_rays: tracedRays,
9138
- info: {
9139
- astigmatism: {
9140
- eye: this.principalMeridiansFromPowers([this.eyePower]),
9141
- lens: this.principalMeridiansFromPowers(this.astigmatismSummaryLensPowers()),
9142
- combined: this.principalMeridiansFromPowers([
9143
- this.eyePower,
9144
- ...this.lensPowers,
9145
- ]),
9146
- },
9147
- prism: {
9148
- eye: this.toPrismSummaryItem(lightDeviation.eye_prism_effect),
9149
- lens: this.toPrismSummaryItem(lightDeviation.lens_prism_total),
9150
- combined: this.toPrismSummaryItem(lightDeviation.net_prism),
9151
- },
9152
- },
9153
- };
9154
- }
9155
- /**
9156
- * eye.p / eye.p_ax(처방값)를 기준으로, 렌더링에서 바로 쓸 눈 회전량을 반환합니다.
9157
- * - x_deg/y_deg는 프리즘 회전량에 eye.tilt를 합산한 최종 렌더 회전량입니다.
9158
- */
9159
- getEyeRotation() {
9160
- // Keep render rotation strictly aligned with the internal eye rotation used in ray tracing.
9161
- // Internal eye-space Euler:
9162
- // eyeEulerXDeg = eyeRotYDeg + tilt.x
9163
- // eyeEulerYDeg = (-eyeRotXDeg) + tilt.y
9164
- // UI mapping (applyEyeRenderRotation):
9165
- // object.rotation.x = -y_deg
9166
- // object.rotation.y = x_deg
9167
- // so we expose:
9168
- // x_deg = eyeEulerYDeg
9169
- // y_deg = -eyeEulerXDeg
9170
- const eyeRotXDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.x);
9171
- const eyeRotYDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.y);
9172
- const eyeEulerXDeg = eyeRotYDeg + this.eyeTiltDeg.x;
9173
- const eyeEulerYDeg = (-eyeRotXDeg) + this.eyeTiltDeg.y;
9174
- const xDeg = eyeEulerYDeg;
9175
- const yDeg = -eyeEulerXDeg;
9176
- return {
9177
- x_deg: xDeg,
9178
- y_deg: yDeg,
9179
- magnitude_deg: Math.hypot(xDeg, yDeg),
9180
- };
9396
+ return { traced_rays: tracedRays };
9181
9397
  }
9182
9398
  /**
9183
9399
  * 1) Ray tracing 전용 함수
@@ -9188,10 +9404,8 @@ class SCAXEngineCore {
9188
9404
  const eyeSurfaces = this.sortedEyeSurfaces;
9189
9405
  lensSurfaces.forEach((surface) => surface.clearTraceHistory());
9190
9406
  eyeSurfaces.forEach((surface) => surface.clearTraceHistory());
9191
- const emittedSourceRays = this.light_source.emitRays().map((ray) => this.applyLightSourceTransformToRay(ray));
9192
- const sourceRays = this.hasPupilStop
9193
- ? emittedSourceRays
9194
- : emittedSourceRays.filter((ray) => this.isRayInsidePupil(ray));
9407
+ const emittedSourceRays = this.light_source.emitRays();
9408
+ const sourceRays = emittedSourceRays;
9195
9409
  this.lastSourceRaysForSturm = sourceRays.map((ray) => ray.clone());
9196
9410
  const traced = [];
9197
9411
  for (const sourceRay of sourceRays) {
@@ -9210,8 +9424,6 @@ class SCAXEngineCore {
9210
9424
  traced.push(activeRay);
9211
9425
  continue;
9212
9426
  }
9213
- activeRay = this.applyPrismVectorToRay(activeRay, this.lensPrismVector);
9214
- activeRay = this.transformRayAroundPivot(activeRay, this.eyeRotationQuaternionInverse, this.eyeRotationPivot);
9215
9427
  for (const surface of eyeSurfaces) {
9216
9428
  const nextRay = surface.refract(activeRay);
9217
9429
  if (!(nextRay instanceof Ray)) {
@@ -9220,7 +9432,6 @@ class SCAXEngineCore {
9220
9432
  }
9221
9433
  activeRay = nextRay;
9222
9434
  }
9223
- activeRay = this.transformRayAroundPivot(activeRay, this.eyeRotationQuaternion, this.eyeRotationPivot);
9224
9435
  if (valid) {
9225
9436
  traced.push(activeRay);
9226
9437
  }
@@ -9292,17 +9503,6 @@ class SCAXEngineCore {
9292
9503
  const points = ray.points;
9293
9504
  return Array.isArray(points) ? points : [];
9294
9505
  }
9295
- isRayInsidePupil(ray) {
9296
- const diameter = this.pupilDiameterMm;
9297
- if (!Number.isFinite(diameter) || diameter <= 0)
9298
- return true;
9299
- const radius = diameter / 2;
9300
- const points = this.getRayPoints(ray);
9301
- const origin = points[0];
9302
- if (!origin)
9303
- return false;
9304
- return Math.hypot(origin.x, origin.y) <= radius + 1e-6;
9305
- }
9306
9506
  powerVectorFromCylinder(cylinderD, axisDeg) {
9307
9507
  const c = Number(cylinderD);
9308
9508
  const ax = ((Number(axisDeg) % 180) + 180) % 180;
@@ -9312,221 +9512,10 @@ class SCAXEngineCore {
9312
9512
  const scale = -c / 2;
9313
9513
  return { j0: scale * Math.cos(rad), j45: scale * Math.sin(rad) };
9314
9514
  }
9315
- aggregatePowerVector(powers) {
9316
- let m = 0;
9317
- let j0 = 0;
9318
- let j45 = 0;
9319
- for (const power of powers) {
9320
- const sphere = Number(power?.s ?? 0);
9321
- const cylinder = Number(power?.c ?? 0);
9322
- const axisTABO = Number(power?.ax ?? 0);
9323
- if (!Number.isFinite(sphere) || !Number.isFinite(cylinder) || !Number.isFinite(axisTABO))
9324
- continue;
9325
- const axisDeg = DegToTABO(axisTABO);
9326
- const rad = (2 * axisDeg * Math.PI) / 180;
9327
- const halfMinusCylinder = -cylinder / 2;
9328
- m += sphere + (cylinder / 2);
9329
- j0 += halfMinusCylinder * Math.cos(rad);
9330
- j45 += halfMinusCylinder * Math.sin(rad);
9331
- }
9332
- return { m, j0, j45 };
9333
- }
9334
- /** 난시 주경선 TABO 각도: 180° 동치이므로 표시·비교는 항상 [0, 180)으로 맞춘다. */
9335
- normalizeTaboMeridian180(value) {
9336
- const d = Number(value ?? 0);
9337
- if (!Number.isFinite(d))
9338
- return 0;
9339
- return ((d % 180) + 180) % 180;
9340
- }
9341
- principalMeridiansFromVector(m, j0, j45) {
9342
- if (!Number.isFinite(m) || !Number.isFinite(j0) || !Number.isFinite(j45))
9343
- return [];
9344
- const axisDeg = (((0.5 * Math.atan2(j45, j0) * 180) / Math.PI) % 180 + 180) % 180;
9345
- const taboAxis = this.normalizeTaboMeridian180(TABOToDeg(axisDeg));
9346
- const orthogonalTabo = (taboAxis + 90) % 180;
9347
- const r = Math.hypot(j0, j45);
9348
- const meridians = [
9349
- { tabo: taboAxis, d: m - r },
9350
- { tabo: orthogonalTabo, d: m + r },
9351
- ];
9352
- return meridians.sort((a, b) => a.d - b.d);
9353
- }
9354
- astigmatismSummaryLensPowers() {
9355
- return this.lensConfigs
9356
- .filter((spec) => spec.includeInAstigmatismSummary)
9357
- .map((spec) => ({
9358
- s: this.toFiniteNumber(spec.s),
9359
- c: this.toFiniteNumber(spec.c),
9360
- ax: this.toFiniteNumber(spec.ax),
9361
- }));
9362
- }
9363
- principalMeridiansFromPowers(powers) {
9364
- if (powers.length === 0)
9365
- return [];
9366
- const { m, j0, j45 } = this.aggregatePowerVector(powers);
9367
- return this.principalMeridiansFromVector(m, j0, j45);
9368
- }
9369
- normalizePrismAmount(value) {
9370
- const p = Number(value ?? 0);
9371
- return Number.isFinite(p) ? Math.max(0, p) : 0;
9372
- }
9373
- normalizeAngle360(value) {
9374
- const d = Number(value ?? 0);
9375
- if (!Number.isFinite(d))
9376
- return 0;
9377
- return ((d % 360) + 360) % 360;
9378
- }
9379
- normalizeEyeTilt(value) {
9380
- return {
9381
- x: this.toFiniteNumber(value?.x),
9382
- y: this.toFiniteNumber(value?.y),
9383
- };
9384
- }
9385
- normalizeLightSourcePose(value) {
9386
- return {
9387
- position: {
9388
- x: this.toFiniteNumber(value?.position?.x),
9389
- y: this.toFiniteNumber(value?.position?.y),
9390
- z: this.toFiniteNumber(value?.position?.z),
9391
- },
9392
- tilt: {
9393
- x: this.toFiniteNumber(value?.tilt?.x),
9394
- y: this.toFiniteNumber(value?.tilt?.y),
9395
- },
9396
- };
9397
- }
9398
- toFiniteNumber(value, fallback = 0) {
9399
- const parsed = Number(value);
9400
- return Number.isFinite(parsed) ? parsed : fallback;
9401
- }
9402
9515
  refreshSortedSurfaces() {
9403
9516
  this.sortedLensSurfaces = [...this.lens].sort((a, b) => this.surfaceOrderZ(a) - this.surfaceOrderZ(b));
9404
9517
  this.sortedEyeSurfaces = [...this.surfaces].sort((a, b) => this.surfaceOrderZ(a) - this.surfaceOrderZ(b));
9405
9518
  }
9406
- prismVectorFromBase(prismDiopter, baseAngleDeg) {
9407
- const p = this.normalizePrismAmount(prismDiopter);
9408
- const baseAngle = this.normalizeAngle360(baseAngleDeg);
9409
- // Clinical Base convention:
9410
- // - input axis is prism base direction, viewed from lens side toward cornea (OD: right=0°)
9411
- // - light deviation is opposite to base, so internally convert by +180°
9412
- const deviationAngle = this.normalizeAngle360(baseAngle + 180);
9413
- // Internal x/y uses math-style +x right, +y up.
9414
- // Because user axis is defined from lens->cornea view, flip angular direction by negating theta.
9415
- const rad = (-deviationAngle * Math.PI) / 180;
9416
- return {
9417
- x: p * Math.cos(rad),
9418
- y: p * Math.sin(rad),
9419
- };
9420
- }
9421
- vectorToPrismInfo(x, y) {
9422
- const xx = Number.isFinite(x) ? x : 0;
9423
- const yy = Number.isFinite(y) ? y : 0;
9424
- const magnitude = Math.hypot(xx, yy);
9425
- const angleDeg = this.normalizeAngle360((Math.atan2(yy, xx) * 180) / Math.PI);
9426
- return {
9427
- x: xx,
9428
- y: yy,
9429
- magnitude,
9430
- angle_deg: magnitude < 1e-12 ? 0 : angleDeg,
9431
- };
9432
- }
9433
- toPrismSummaryItem(value) {
9434
- const magnitude = Number(value?.magnitude);
9435
- return {
9436
- p_x: Number(value?.x ?? 0),
9437
- p_y: Number(value?.y ?? 0),
9438
- prism_angle: this.normalizeAngle360(value?.angle_deg),
9439
- magnitude: Number.isFinite(magnitude) && magnitude >= 1e-9 ? magnitude : null,
9440
- };
9441
- }
9442
- prismComponentToAngleDeg(componentPrism) {
9443
- const c = Number(componentPrism);
9444
- if (!Number.isFinite(c))
9445
- return 0;
9446
- return (Math.atan(c / 100) * 180) / Math.PI;
9447
- }
9448
- prismMagnitudeToAngleDeg(prismDiopter) {
9449
- const p = Number(prismDiopter);
9450
- if (!Number.isFinite(p) || p < 1e-12)
9451
- return 0;
9452
- return (Math.atan(p / 100) * 180) / Math.PI;
9453
- }
9454
- applyPrismVectorToRay(ray, prism) {
9455
- const px = Number(prism?.x ?? 0);
9456
- const py = Number(prism?.y ?? 0);
9457
- if (!Number.isFinite(px)
9458
- || !Number.isFinite(py)
9459
- || (Math.abs(px) < 1e-12 && Math.abs(py) < 1e-12)) {
9460
- return ray;
9461
- }
9462
- const direction = ray.getDirection();
9463
- const dz = Number(direction.z);
9464
- if (!Number.isFinite(dz) || Math.abs(dz) < 1e-12)
9465
- return ray;
9466
- const tx = (direction.x / dz) + (px / 100);
9467
- const ty = (direction.y / dz) + (py / 100);
9468
- const signZ = dz >= 0 ? 1 : -1;
9469
- const newDirection = new Vector3(tx * signZ, ty * signZ, signZ).normalize();
9470
- if (!Number.isFinite(newDirection.x) || !Number.isFinite(newDirection.y) || !Number.isFinite(newDirection.z)) {
9471
- return ray;
9472
- }
9473
- const updated = ray.clone();
9474
- const origin = updated.endPoint();
9475
- updated.continueFrom(origin.clone().addScaledVector(newDirection, RAY_SURFACE_ESCAPE_MM), newDirection);
9476
- return updated;
9477
- }
9478
- rotatePointAroundPivot(point, rotation, pivot) {
9479
- return point.clone().sub(pivot).applyQuaternion(rotation).add(pivot);
9480
- }
9481
- translateRay(ray, offset) {
9482
- if (offset.lengthSq() < 1e-12)
9483
- return ray;
9484
- const points = this.getRayPoints(ray);
9485
- if (!points.length)
9486
- return ray;
9487
- const translated = ray.clone();
9488
- const translatedState = translated;
9489
- const nextPoints = points.map((point) => point.clone().add(offset));
9490
- translatedState.points = nextPoints;
9491
- translatedState.origin = nextPoints[nextPoints.length - 1].clone();
9492
- return translated;
9493
- }
9494
- transformRayAroundPivot(ray, rotation, pivot) {
9495
- const points = this.getRayPoints(ray);
9496
- if (!points.length)
9497
- return ray;
9498
- const transformed = ray.clone();
9499
- const transformedState = transformed;
9500
- const nextPoints = points.map((point) => this.rotatePointAroundPivot(point, rotation, pivot));
9501
- transformedState.points = nextPoints;
9502
- transformedState.direction = ray.getDirection().clone().applyQuaternion(rotation).normalize();
9503
- transformedState.origin = nextPoints[nextPoints.length - 1].clone();
9504
- return transformed;
9505
- }
9506
- applyLightSourceTransformToRay(ray) {
9507
- const rotated = this.transformRayAroundPivot(ray, this.lightSourceRotationQuaternion, this.lightSourceRotationPivot);
9508
- return this.translateRay(rotated, this.lightSourcePosition);
9509
- }
9510
- calculateLightDeviation() {
9511
- // eye/lens 입력은 모두 임상 Base 방향이므로, prismVectorFromBase 내부에서
9512
- // Base -> 광선편향(+180°) 변환이 적용됩니다.
9513
- // eye는 "처방값(교정 필요량)"으로 해석하므로 실제 눈 편위는 역벡터입니다.
9514
- const eyeEffect = this.vectorToPrismInfo(this.eyePrismEffectVector.x, this.eyePrismEffectVector.y);
9515
- // lens 입력은 교정량이며, 렌즈는 Base 반대방향으로 광선을 실제 굴절시킵니다.
9516
- // prismVectorFromBase에서 Base->광선편향이 이미 반영된 벡터를 그대로 합산합니다.
9517
- const lensX = this.lensPrismVector.x;
9518
- const lensY = this.lensPrismVector.y;
9519
- const lensTotal = this.vectorToPrismInfo(lensX, lensY);
9520
- const net = this.vectorToPrismInfo(eyeEffect.x + lensTotal.x, eyeEffect.y + lensTotal.y);
9521
- return {
9522
- eye_prism_effect: eyeEffect,
9523
- lens_prism_total: lensTotal,
9524
- net_prism: net,
9525
- x_angle_deg: this.prismComponentToAngleDeg(net.x),
9526
- y_angle_deg: this.prismComponentToAngleDeg(net.y),
9527
- net_angle_deg: this.prismMagnitudeToAngleDeg(net.magnitude),
9528
- };
9529
- }
9530
9519
  effectiveCylinderFromOpticSurfaces() {
9531
9520
  let j0 = 0;
9532
9521
  let j45 = 0;
@@ -9572,14 +9561,14 @@ class SCAXEngineCore {
9572
9561
  .filter((pair) => Boolean(pair));
9573
9562
  }
9574
9563
  }
9575
- SCAXEngineCore.EYE_ROTATION_PIVOT_FROM_CORNEA_MM = 13;
9576
9564
  /**
9577
9565
  * 외부 공개 API 전용 Facade입니다.
9578
9566
  * 내부 광학 상태/연산은 SCAXEngineCore에 위임합니다.
9579
9567
  */
9580
9568
  class SCAXEngine {
9581
9569
  constructor(props = {}) {
9582
- this.core = new SCAXEngineCore(props);
9570
+ this.normalizedConfig = normalizeEngineProps(props);
9571
+ this.core = new SCAXEngineCore(this.normalizedConfig);
9583
9572
  }
9584
9573
  // Test/debug bridge for legacy direct field access patterns.
9585
9574
  get lens() {
@@ -9592,7 +9581,8 @@ class SCAXEngine {
9592
9581
  return this.core.surfaces;
9593
9582
  }
9594
9583
  update(props = {}) {
9595
- this.core.update(props);
9584
+ this.normalizedConfig = normalizeEngineProps(props);
9585
+ this.core.update(this.normalizedConfig);
9596
9586
  }
9597
9587
  dispose() {
9598
9588
  this.core.dispose();
@@ -9600,19 +9590,54 @@ class SCAXEngine {
9600
9590
  simulate() {
9601
9591
  return this.core.simulate();
9602
9592
  }
9603
- getEyeRotation() {
9604
- return this.core.getEyeRotation();
9605
- }
9606
9593
  rayTracing() {
9607
9594
  return this.core.rayTracing();
9608
9595
  }
9609
9596
  sturmCalculation(rays) {
9610
9597
  return this.core.sturmCalculation(rays);
9611
9598
  }
9612
- getAffineAnalysis() {
9613
- return this.core.getAffineAnalysis();
9599
+ calculateMeridians(scaxPowers) {
9600
+ const normalize180 = (angle) => (((angle % 180) + 180) % 180);
9601
+ const taboToDeg = (taboAngle) => normalize180(180 - taboAngle);
9602
+ const degToTabo = (degreeAngle) => normalize180(180 - degreeAngle);
9603
+ let m = 0;
9604
+ let j0 = 0;
9605
+ let j45 = 0;
9606
+ for (const power of Array.isArray(scaxPowers) ? scaxPowers : []) {
9607
+ const sphere = Number(power?.s ?? 0);
9608
+ const cylinder = Number(power?.c ?? 0);
9609
+ const axisTABO = Number(power?.ax ?? 0);
9610
+ if (!Number.isFinite(sphere) || !Number.isFinite(cylinder) || !Number.isFinite(axisTABO))
9611
+ continue;
9612
+ const axisDeg = taboToDeg(axisTABO);
9613
+ const rad = (2 * axisDeg * Math.PI) / 180;
9614
+ const halfMinusCylinder = -cylinder / 2;
9615
+ m += sphere + (cylinder / 2);
9616
+ j0 += halfMinusCylinder * Math.cos(rad);
9617
+ j45 += halfMinusCylinder * Math.sin(rad);
9618
+ }
9619
+ if (!Number.isFinite(m) || !Number.isFinite(j0) || !Number.isFinite(j45))
9620
+ return [];
9621
+ const axisDeg = normalize180((0.5 * Math.atan2(j45, j0) * 180) / Math.PI);
9622
+ const taboAxis = degToTabo(axisDeg);
9623
+ const orthogonalTabo = (taboAxis + 90) % 180;
9624
+ const r = Math.hypot(j0, j45);
9625
+ return [
9626
+ { tabo: taboAxis, d: m - r },
9627
+ { tabo: orthogonalTabo, d: m + r },
9628
+ ].sort((a, b) => a.d - b.d);
9629
+ }
9630
+ calculateEyeRotationByPrism(prism) {
9631
+ // 프리즘 처방으로 인한 안구 회전량만 계산 (tilt 제외)
9632
+ const p = normalizePrismAmount(prism?.p);
9633
+ const p_ax = normalizeAngle360(prism?.p_ax);
9634
+ const rotation = eyeRotationForRenderDegrees(p, p_ax, { x: 0, y: 0 });
9635
+ return {
9636
+ x: Number.isFinite(rotation.x_deg) ? rotation.x_deg : 0,
9637
+ y: Number.isFinite(rotation.y_deg) ? rotation.y_deg : 0,
9638
+ };
9614
9639
  }
9615
9640
  }
9616
9641
 
9617
- export { Ray, SCAXEngine, SCAXEngineCore };
9642
+ export { LightSource, Ray, SCAXEngine, SCAXEngineCore, Surface };
9618
9643
  //# sourceMappingURL=scax-engine.js.map