scax-engine 0.1.7 → 0.2.0-beta.1

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