scax-engine 0.1.2 → 0.1.4

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # scax-engine
2
2
 
3
- 눈 모델(Gullstrand / Navarro)에 대한 광선 추적, Sturm 간격 분석, 아핀 왜곡 추정, 유발 난시·프리즘 편위 계산을 제공하는 TypeScript 안경광학 시뮬레이션(단안 기준, OD) 라이브러리입니다. ESM, CJS, UMD 빌드를 지원합니다.
3
+ 눈 모델(Gullstrand / Navarro)에 대한 광선 추적, Sturm 간격 분석, 유발 난시·프리즘 편위 계산을 제공하는 TypeScript 안경광학 시뮬레이션(단안 기준, OD) 라이브러리입니다. ESM, CJS, UMD 빌드를 지원합니다.
4
4
 
5
5
 
6
6
  English documentation: [README-en.md](README-en.md)
@@ -69,7 +69,7 @@ const next = engine.simulate();
69
69
 
70
70
  ### `new SCAXEngine(props?)`
71
71
 
72
- 시뮬레이션 엔진 인스턴스를 생성합니다. 내부 `Sturm`, `Affine` 헬퍼는 인스턴스당 한 번만 생성됩니다.
72
+ 시뮬레이션 엔진 인스턴스를 생성합니다. 내부 `Sturm` 헬퍼는 인스턴스당 한 번만 생성됩니다.
73
73
 
74
74
  #### `props`
75
75
 
@@ -103,13 +103,13 @@ const next = engine.simulate();
103
103
 
104
104
  생성자와 동일한 설정 경로를 다시 실행합니다. `props`를 기준으로 eye / lens / 표면 / 광원을 재구성하며, 생략된 키에는 위 기본값이 적용됩니다.
105
105
 
106
- - `Sturm`, `Affine` 인스턴스는 재생성되지 않지만, `update` 직후에는 Sturm·affine 분석 캐시가 비워지며 `simulate` 등으로 다시 채워집니다.
106
+ - `Sturm` 인스턴스는 재생성되지 않지만, `update` 직후에는 Sturm 분석 캐시가 비워지며 `simulate` 등으로 다시 채워집니다.
107
107
 
108
108
  ### `engine.dispose()`
109
109
 
110
110
  엔진 인스턴스가 잡고 있던 추적/분석 캐시와 표면 trace history를 정리합니다.
111
111
 
112
- - 해제 대상: traced rays, Sturm/affine 캐시, surface incident/refracted history
112
+ - 해제 대상: traced rays, Sturm 캐시, surface incident/refracted history
113
113
  - UI에서 엔진을 자주 새로 만들 때(예: 프레임별/슬라이더 연속 갱신) 이전 인스턴스에 `dispose()`를 호출하면 메모리 사용량을 안정적으로 유지할 수 있습니다.
114
114
 
115
115
  ### 인스턴스 메서드
@@ -152,7 +152,6 @@ const next = engine.simulate();
152
152
 
153
153
  - `sturmCalculation(rays?)` — 추적 광선(인자 생략 시 마지막 `rayTracing`/`simulate` 결과)으로 Sturm 슬라이스·스펙트럼선별 분석 객체를 계산해 반환합니다. `simulate()` 호출 시 내부에서 Sturm도 갱신됩니다.
154
154
 
155
- - `getAffineAnalysis()` — 현재 설정으로 망막 대응점 쌍을 만들어 아핀 적합을 수행(또는 캐시 재사용)합니다. 유효한 쌍이 부족하면 `null`이 될 수 있습니다.
156
155
 
157
156
  ## UMD
158
157
 
@@ -7029,6 +7029,19 @@ const DEFAULT_STURM_TOP2_MIN_GAP_MM = 0.0;
7029
7029
  * Sturm 분석에서 Top2 선택 시 허용하는 최소 축 각도 차(도)
7030
7030
  */
7031
7031
  const DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG = 45;
7032
+ /**
7033
+ * Sturm Top2 이각(J0,J45) phasor 내적 상한: cos(Δ₂θ) 이 이 값 이하일 때
7034
+ * 두 주경선을 직교(선초점 쌍)로 취급한다. -1에 가까울수록 엄격.
7035
+ */
7036
+ const DEFAULT_STURM_TOP2_PHASOR_OPPOSITION_MAX_DOT = -0.86;
7037
+ /**
7038
+ * phasor 직교 판정 실패 시 완화 단계에서 쓰는 내적 상한
7039
+ */
7040
+ const DEFAULT_STURM_TOP2_PHASOR_FALLBACK_MAX_DOT = -0.55;
7041
+ /**
7042
+ * 축 각·phasor 폴백 후에도 후보가 없을 때 허용하는 최소 각도 차(도)
7043
+ */
7044
+ const DEFAULT_STURM_TOP2_AXIS_LAST_RESORT_MIN_GAP_DEG = 22;
7032
7045
  /**
7033
7046
  * 실효 난시량이 이 값(D) 이상이면 U/V 중간점을 CLC 근사 중심으로 우선 사용
7034
7047
  */
@@ -7037,6 +7050,11 @@ const DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D = 0.125;
7037
7050
  * Sturm z-scan 간격(mm)
7038
7051
  */
7039
7052
  const DEFAULT_STURM_STEP_MM = 0.01;
7053
+ /**
7054
+ * Sturm 분석 슬라이스의 profile.at.z를 각막(앞)~망막+이 값(mm) 구간으로 한정할 때
7055
+ * 망막 표면 z보다 진행 방향 +z로 허용하는 여유.
7056
+ */
7057
+ const DEFAULT_STURM_PROFILE_WORLD_Z_PAST_RETINA_MM = 4;
7040
7058
 
7041
7059
  const DEFAULT_DIR = new Vector3(0, 0, 1);
7042
7060
  function isFiniteVector3(v) {
@@ -8008,7 +8026,7 @@ class Sturm {
8008
8026
  this.lastResult = null;
8009
8027
  this.lineOrder = ["g", "F", "e", "d", "C", "r"];
8010
8028
  }
8011
- calculate(rays, effectiveCylinderD, axisReferenceRays) {
8029
+ calculate(rays, effectiveCylinderD, axisReferenceRays, profileWorldZBounds) {
8012
8030
  const frame = this.analysisFrameFromRays(axisReferenceRays?.length ? axisReferenceRays : rays);
8013
8031
  const depthRange = this.depthRangeFromRays(rays, frame);
8014
8032
  const sturmSlices = this.collectSturmSlices(rays, frame, depthRange, DEFAULT_STURM_STEP_MM);
@@ -8017,7 +8035,7 @@ class Sturm {
8017
8035
  const groupFrame = this.analysisFrameFromRays(group.rays, frame);
8018
8036
  const groupDepthRange = this.depthRangeFromRays(group.rays, groupFrame);
8019
8037
  const slices = this.collectSturmSlices(group.rays, groupFrame, groupDepthRange, DEFAULT_STURM_STEP_MM);
8020
- const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD);
8038
+ const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD, profileWorldZBounds);
8021
8039
  return {
8022
8040
  line: group.line,
8023
8041
  wavelength_nm: group.wavelength_nm,
@@ -8151,6 +8169,9 @@ class Sturm {
8151
8169
  const lambdaMinor = Math.max(0, trace / 2 - root);
8152
8170
  const thetaRad = 0.5 * Math.atan2(2 * sxy, sxx - syy);
8153
8171
  const angleMajorDeg = ((thetaRad * 180) / Math.PI + 360) % 180;
8172
+ const twoThetaRad = 2 * thetaRad;
8173
+ const j0 = Math.cos(twoThetaRad);
8174
+ const j45 = Math.sin(twoThetaRad);
8154
8175
  const majorDirection = frame.u.clone().multiplyScalar(Math.cos(thetaRad))
8155
8176
  .add(frame.v.clone().multiplyScalar(Math.sin(thetaRad)))
8156
8177
  .normalize();
@@ -8160,6 +8181,8 @@ class Sturm {
8160
8181
  wMajor: Math.sqrt(lambdaMajor),
8161
8182
  wMinor: Math.sqrt(lambdaMinor),
8162
8183
  angleMajorDeg,
8184
+ j0,
8185
+ j45,
8163
8186
  angleMinorDeg: (angleMajorDeg + 90) % 180,
8164
8187
  majorDirection: {
8165
8188
  x: majorDirection.x,
@@ -8197,6 +8220,84 @@ class Sturm {
8197
8220
  const d = Math.abs((((a - b) % 180) + 180) % 180);
8198
8221
  return Math.min(d, 180 - d);
8199
8222
  }
8223
+ phasorDot(p, q) {
8224
+ return p.j0 * q.j0 + p.j45 * q.j45;
8225
+ }
8226
+ /**
8227
+ * 평탄도 순으로 정렬된 슬라이스에서 선초점 쌍(직교 주경선)에 해당하는 Top2를 고른다.
8228
+ * 주경선 각 대신 이각 phasor (cos 2θ, sin 2θ)로 직교를 판정하고, 실패 시 각도·전역 검색·깊이 폴백을 사용한다.
8229
+ */
8230
+ pickFlattestSturmPair(sortedByFlatness, top2MinGapMm, allSlices) {
8231
+ if (sortedByFlatness.length === 0)
8232
+ return [];
8233
+ const first = sortedByFlatness[0];
8234
+ const top2MinAngleGapDeg = DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG;
8235
+ const phasorStrict = DEFAULT_STURM_TOP2_PHASOR_OPPOSITION_MAX_DOT;
8236
+ const phasorLoose = DEFAULT_STURM_TOP2_PHASOR_FALLBACK_MAX_DOT;
8237
+ const lastResortMinDeg = DEFAULT_STURM_TOP2_AXIS_LAST_RESORT_MIN_GAP_DEG;
8238
+ const depthOk = (c) => Math.abs(c.depth - first.depth) >= top2MinGapMm;
8239
+ const byPhasor = (pool, maxDot) => pool.find((c) => (c !== first
8240
+ && depthOk(c)
8241
+ && this.phasorDot(first.profile, c.profile) <= maxDot));
8242
+ let second = byPhasor(sortedByFlatness, phasorStrict)
8243
+ ?? byPhasor(sortedByFlatness, phasorLoose);
8244
+ if (!second) {
8245
+ second = sortedByFlatness.find((c) => (c !== first
8246
+ && depthOk(c)
8247
+ && this.axisDiffDeg(c.profile.angleMajorDeg, first.profile.angleMajorDeg) >= top2MinAngleGapDeg));
8248
+ }
8249
+ if (!second) {
8250
+ let best = null;
8251
+ let bestAxDiff = -1;
8252
+ for (const c of sortedByFlatness) {
8253
+ if (c === first || !depthOk(c))
8254
+ continue;
8255
+ const axd = this.axisDiffDeg(c.profile.angleMajorDeg, first.profile.angleMajorDeg);
8256
+ if (axd > bestAxDiff) {
8257
+ bestAxDiff = axd;
8258
+ best = c;
8259
+ }
8260
+ }
8261
+ if (best && bestAxDiff >= lastResortMinDeg)
8262
+ second = best;
8263
+ }
8264
+ // 평탄도 정렬 풀만으로는 같은 phasor만 연속으로 나오는 경우가 있어, 전 스캔에서 가장 반대인 phasor를 고른다.
8265
+ if (!second) {
8266
+ let best = null;
8267
+ let bestDot = 1;
8268
+ for (const c of allSlices) {
8269
+ if (c === first || !depthOk(c))
8270
+ continue;
8271
+ const d = this.phasorDot(first.profile, c.profile);
8272
+ if (d < bestDot) {
8273
+ bestDot = d;
8274
+ best = c;
8275
+ }
8276
+ }
8277
+ if (best && bestDot < -0.08)
8278
+ second = best;
8279
+ }
8280
+ // 마지막: 가장 납작한 슬라이스와 깊이 차가 가장 큰 “비교적 납작한” 슬라이스 (선초점이 z로 분리될 때)
8281
+ if (!second) {
8282
+ const flatThreshold = first.ratio * 1.5 + 1e-6;
8283
+ let best = null;
8284
+ let bestDepthSpan = -1;
8285
+ for (const c of allSlices) {
8286
+ if (c === first || !depthOk(c))
8287
+ continue;
8288
+ if (c.ratio > flatThreshold)
8289
+ continue;
8290
+ const span = Math.abs(c.depth - first.depth);
8291
+ if (span > bestDepthSpan) {
8292
+ bestDepthSpan = span;
8293
+ best = c;
8294
+ }
8295
+ }
8296
+ if (best)
8297
+ second = best;
8298
+ }
8299
+ return second ? [first, second] : [first];
8300
+ }
8200
8301
  buildApproxCenter(flattestTop2, smallestEllipse, preferTop2Mid) {
8201
8302
  if (flattestTop2.length <= 0)
8202
8303
  return null;
@@ -8221,6 +8322,19 @@ class Sturm {
8221
8322
  const first = flattestTop2[0];
8222
8323
  return { x: first.profile.at.x, y: first.profile.at.y, z: first.z, mode: "top1-flat" };
8223
8324
  }
8325
+ slicesForSturmAnalysis(sturmSlices, bounds) {
8326
+ if (!bounds)
8327
+ return sturmSlices;
8328
+ const zMin = bounds.zMin;
8329
+ const zMax = bounds.zMax;
8330
+ if (!Number.isFinite(zMin) || !Number.isFinite(zMax) || zMax < zMin)
8331
+ return sturmSlices;
8332
+ const bounded = sturmSlices.filter((s) => {
8333
+ const z = s.profile?.at?.z;
8334
+ return Number.isFinite(z) && z >= zMin && z <= zMax;
8335
+ });
8336
+ return bounded.length >= 2 ? bounded : sturmSlices;
8337
+ }
8224
8338
  groupByFraunhoferLine(rays) {
8225
8339
  const groups = new Map();
8226
8340
  for (const ray of rays) {
@@ -8241,21 +8355,17 @@ class Sturm {
8241
8355
  }
8242
8356
  return [...groups.values()].sort((a, b) => this.lineOrder.indexOf(a.line) - this.lineOrder.indexOf(b.line));
8243
8357
  }
8244
- analyzeSturmSlices(sturmSlices, effectiveCylinderD) {
8358
+ analyzeSturmSlices(sturmSlices, effectiveCylinderD, profileWorldZBounds) {
8245
8359
  const top2MinGapMm = DEFAULT_STURM_TOP2_MIN_GAP_MM;
8246
- const top2MinAngleGapDeg = DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG;
8247
8360
  const effectiveCylinderThresholdD = DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D;
8248
8361
  const preferTop2Mid = effectiveCylinderD >= effectiveCylinderThresholdD;
8249
- const sortedByFlatness = [...sturmSlices].sort((a, b) => a.ratio - b.ratio);
8250
- let flattestTop2 = [];
8251
- if (sortedByFlatness.length > 0) {
8252
- const first = sortedByFlatness[0];
8253
- const second = sortedByFlatness.find((candidate) => (Math.abs(candidate.depth - first.depth) >= top2MinGapMm
8254
- && this.axisDiffDeg(candidate.profile.angleMajorDeg, first.profile.angleMajorDeg) >= top2MinAngleGapDeg));
8255
- flattestTop2 = second ? [first, second] : [first];
8256
- }
8362
+ const slicesForAnalysis = this.slicesForSturmAnalysis(sturmSlices, profileWorldZBounds ?? null);
8363
+ const sortedByFlatness = [...slicesForAnalysis].sort((a, b) => a.ratio - b.ratio);
8364
+ const flattestTop2 = sortedByFlatness.length > 0
8365
+ ? this.pickFlattestSturmPair(sortedByFlatness, top2MinGapMm, slicesForAnalysis)
8366
+ : [];
8257
8367
  let smallestEllipse = null;
8258
- for (const slice of sturmSlices) {
8368
+ for (const slice of slicesForAnalysis) {
8259
8369
  if (!smallestEllipse || slice.size < smallestEllipse.size)
8260
8370
  smallestEllipse = slice;
8261
8371
  }
@@ -8822,7 +8932,7 @@ const TABOToDeg = (TABOAngle) => {
8822
8932
  * legacy simulator.js를 TypeScript로 옮긴 핵심 시뮬레이터입니다.
8823
8933
  * - 광원 광선을 생성하고
8824
8934
  * - 표면들을 순서대로 통과시키며 굴절을 계산한 뒤
8825
- * - 망막 대응쌍, Sturm 분석, 왜곡(affine) 분석까지 제공합니다.
8935
+ * - Sturm 분석까지 제공합니다.
8826
8936
  */
8827
8937
  class SCAXEngineCore {
8828
8938
  constructor(props = {}) {
@@ -8879,8 +8989,10 @@ class SCAXEngineCore {
8879
8989
  const eyeRotXDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.x);
8880
8990
  const eyeRotYDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.y);
8881
8991
  this.eyeTiltDeg = this.normalizeEyeTilt(eye?.tilt);
8882
- const eyeEulerXDeg = (-eyeRotYDeg) + this.eyeTiltDeg.x;
8883
- const eyeEulerYDeg = eyeRotXDeg + this.eyeTiltDeg.y;
8992
+ // Apply eye deviation in the opposite direction so a lens with the same
8993
+ // prism/base prescription can optically compensate the eye deviation.
8994
+ const eyeEulerXDeg = eyeRotYDeg + this.eyeTiltDeg.x;
8995
+ const eyeEulerYDeg = (-eyeRotXDeg) + this.eyeTiltDeg.y;
8884
8996
  this.eyeRotationQuaternion = new Quaternion().setFromEuler(new Euler((eyeEulerXDeg * Math.PI) / 180, (eyeEulerYDeg * Math.PI) / 180, 0, "XYZ"));
8885
8997
  this.eyeRotationQuaternionInverse = this.eyeRotationQuaternion.clone().invert();
8886
8998
  this.eyeRotationPivot = new Vector3(0, 0, SCAXEngineCore.EYE_ROTATION_PIVOT_FROM_CORNEA_MM);
@@ -9042,10 +9154,22 @@ class SCAXEngineCore {
9042
9154
  * - x_deg/y_deg는 프리즘 회전량에 eye.tilt를 합산한 최종 렌더 회전량입니다.
9043
9155
  */
9044
9156
  getEyeRotation() {
9045
- const rx = this.prismVectorFromBase(this.eyePrismPrescription.p, this.eyePrismPrescription.p_ax);
9046
- const eyeEffect = this.vectorToPrismInfo(rx.x, rx.y);
9047
- const xDeg = this.prismComponentToAngleDeg(eyeEffect.x) + this.eyeTiltDeg.y;
9048
- const yDeg = this.prismComponentToAngleDeg(eyeEffect.y) + this.eyeTiltDeg.x;
9157
+ // Keep render rotation strictly aligned with the internal eye rotation used in ray tracing.
9158
+ // Internal eye-space Euler:
9159
+ // eyeEulerXDeg = eyeRotYDeg + tilt.x
9160
+ // eyeEulerYDeg = (-eyeRotXDeg) + tilt.y
9161
+ // UI mapping (applyEyeRenderRotation):
9162
+ // object.rotation.x = -y_deg
9163
+ // object.rotation.y = x_deg
9164
+ // so we expose:
9165
+ // x_deg = eyeEulerYDeg
9166
+ // y_deg = -eyeEulerXDeg
9167
+ const eyeRotXDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.x);
9168
+ const eyeRotYDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.y);
9169
+ const eyeEulerXDeg = eyeRotYDeg + this.eyeTiltDeg.x;
9170
+ const eyeEulerYDeg = (-eyeRotXDeg) + this.eyeTiltDeg.y;
9171
+ const xDeg = eyeEulerYDeg;
9172
+ const yDeg = -eyeEulerXDeg;
9049
9173
  return {
9050
9174
  x_deg: xDeg,
9051
9175
  y_deg: yDeg,
@@ -9105,35 +9229,54 @@ class SCAXEngineCore {
9105
9229
  this.tracedRays = traced;
9106
9230
  return traced;
9107
9231
  }
9232
+ /**
9233
+ * Sturm 선초/근사중심에 사용할 슬라이스 centroid(world z) 구간:
9234
+ * 각막 전면(cornea_anterior) 이상 ~ 망막(retina) + 여유 이하.
9235
+ */
9236
+ sturmEyeProfileWorldZBounds() {
9237
+ let corneaAnteriorZ = Number.POSITIVE_INFINITY;
9238
+ let retinaZ = Number.NEGATIVE_INFINITY;
9239
+ for (const surface of this.surfaces) {
9240
+ const name = String(surface.name || "").toLowerCase();
9241
+ const z = Number(this.readSurfacePosition(surface)?.z);
9242
+ if (!Number.isFinite(z))
9243
+ continue;
9244
+ if (name.includes("cornea") && name.includes("anterior")) {
9245
+ corneaAnteriorZ = Math.min(corneaAnteriorZ, z);
9246
+ }
9247
+ if (name === "retina") {
9248
+ retinaZ = Math.max(retinaZ, z);
9249
+ }
9250
+ }
9251
+ if (!Number.isFinite(corneaAnteriorZ) || !Number.isFinite(retinaZ))
9252
+ return undefined;
9253
+ if (retinaZ + 1e-9 < corneaAnteriorZ)
9254
+ return undefined;
9255
+ return {
9256
+ zMin: corneaAnteriorZ,
9257
+ zMax: retinaZ + DEFAULT_STURM_PROFILE_WORLD_Z_PAST_RETINA_MM,
9258
+ };
9259
+ }
9108
9260
  /**
9109
9261
  * 2) Sturm calculation 전용 함수
9110
9262
  * traced ray 집합에서 z-scan 기반 Sturm 슬라이스/근사 중심을 계산합니다.
9111
9263
  */
9112
9264
  sturmCalculation(rays = this.tracedRays) {
9113
- this.lastSturmGapAnalysis = this.sturm.calculate(rays, this.effectiveCylinderFromOpticSurfaces(), this.lastSourceRaysForSturm);
9265
+ this.lastSturmGapAnalysis = this.sturm.calculate(rays, this.effectiveCylinderFromOpticSurfaces(), this.lastSourceRaysForSturm, this.sturmEyeProfileWorldZBounds() ?? null);
9114
9266
  return this.lastSturmGapAnalysis;
9115
9267
  }
9116
- /**
9117
- * 3) Affine 왜곡 추정 전용 함수
9118
- * 광선 대응쌍(sx,sy)->(tx,ty)에 대해 최소자승 2D affine을 적합합니다.
9119
- */
9120
- estimateAffineDistortion(pairs) {
9121
- const inputPairs = Array.isArray(pairs) ? pairs : [];
9122
- this.lastAffineAnalysis = this.affine.estimate(inputPairs);
9123
- return this.lastAffineAnalysis;
9124
- }
9125
- /**
9126
- * 현재 eye+lens 설정 기준 affine 왜곡 결과를 반환합니다.
9127
- * traced ray/affine 결과는 기존 계산값을 우선 재사용합니다.
9128
- */
9129
9268
  getAffineAnalysis() {
9269
+ /*
9130
9270
  if (!this.tracedRays.length) {
9131
- this.simulate();
9271
+ this.simulate();
9132
9272
  }
9133
9273
  if (!this.lastAffineAnalysis) {
9134
- this.lastAffineAnalysis = this.estimateAffineDistortion(this.createAffinePairs(this.tracedRays));
9274
+ this.lastAffineAnalysis = this.estimateAffineDistortion(this.createAffinePairs(this.tracedRays));
9135
9275
  }
9136
9276
  return this.lastAffineAnalysis;
9277
+ */
9278
+ // Affine analysis is intentionally disabled for now.
9279
+ return null;
9137
9280
  }
9138
9281
  surfaceOrderZ(surface) {
9139
9282
  const z = Number(this.readSurfacePosition(surface)?.z);
@@ -9385,6 +9528,13 @@ class SCAXEngineCore {
9385
9528
  }
9386
9529
  return 2 * Math.hypot(j0, j45);
9387
9530
  }
9531
+ // Kept for later review/release.
9532
+ estimateAffineDistortion(pairs) {
9533
+ const inputPairs = Array.isArray(pairs) ? pairs : [];
9534
+ this.lastAffineAnalysis = this.affine.estimate(inputPairs);
9535
+ return this.lastAffineAnalysis;
9536
+ }
9537
+ // Kept for later review/release.
9388
9538
  createAffinePairs(rays) {
9389
9539
  return (Array.isArray(rays) ? rays : [])
9390
9540
  .map((ray) => {
@@ -7027,6 +7027,19 @@ const DEFAULT_STURM_TOP2_MIN_GAP_MM = 0.0;
7027
7027
  * Sturm 분석에서 Top2 선택 시 허용하는 최소 축 각도 차(도)
7028
7028
  */
7029
7029
  const DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG = 45;
7030
+ /**
7031
+ * Sturm Top2 이각(J0,J45) phasor 내적 상한: cos(Δ₂θ) 이 이 값 이하일 때
7032
+ * 두 주경선을 직교(선초점 쌍)로 취급한다. -1에 가까울수록 엄격.
7033
+ */
7034
+ const DEFAULT_STURM_TOP2_PHASOR_OPPOSITION_MAX_DOT = -0.86;
7035
+ /**
7036
+ * phasor 직교 판정 실패 시 완화 단계에서 쓰는 내적 상한
7037
+ */
7038
+ const DEFAULT_STURM_TOP2_PHASOR_FALLBACK_MAX_DOT = -0.55;
7039
+ /**
7040
+ * 축 각·phasor 폴백 후에도 후보가 없을 때 허용하는 최소 각도 차(도)
7041
+ */
7042
+ const DEFAULT_STURM_TOP2_AXIS_LAST_RESORT_MIN_GAP_DEG = 22;
7030
7043
  /**
7031
7044
  * 실효 난시량이 이 값(D) 이상이면 U/V 중간점을 CLC 근사 중심으로 우선 사용
7032
7045
  */
@@ -7035,6 +7048,11 @@ const DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D = 0.125;
7035
7048
  * Sturm z-scan 간격(mm)
7036
7049
  */
7037
7050
  const DEFAULT_STURM_STEP_MM = 0.01;
7051
+ /**
7052
+ * Sturm 분석 슬라이스의 profile.at.z를 각막(앞)~망막+이 값(mm) 구간으로 한정할 때
7053
+ * 망막 표면 z보다 진행 방향 +z로 허용하는 여유.
7054
+ */
7055
+ const DEFAULT_STURM_PROFILE_WORLD_Z_PAST_RETINA_MM = 4;
7038
7056
 
7039
7057
  const DEFAULT_DIR = new Vector3(0, 0, 1);
7040
7058
  function isFiniteVector3(v) {
@@ -8006,7 +8024,7 @@ class Sturm {
8006
8024
  this.lastResult = null;
8007
8025
  this.lineOrder = ["g", "F", "e", "d", "C", "r"];
8008
8026
  }
8009
- calculate(rays, effectiveCylinderD, axisReferenceRays) {
8027
+ calculate(rays, effectiveCylinderD, axisReferenceRays, profileWorldZBounds) {
8010
8028
  const frame = this.analysisFrameFromRays(axisReferenceRays?.length ? axisReferenceRays : rays);
8011
8029
  const depthRange = this.depthRangeFromRays(rays, frame);
8012
8030
  const sturmSlices = this.collectSturmSlices(rays, frame, depthRange, DEFAULT_STURM_STEP_MM);
@@ -8015,7 +8033,7 @@ class Sturm {
8015
8033
  const groupFrame = this.analysisFrameFromRays(group.rays, frame);
8016
8034
  const groupDepthRange = this.depthRangeFromRays(group.rays, groupFrame);
8017
8035
  const slices = this.collectSturmSlices(group.rays, groupFrame, groupDepthRange, DEFAULT_STURM_STEP_MM);
8018
- const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD);
8036
+ const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD, profileWorldZBounds);
8019
8037
  return {
8020
8038
  line: group.line,
8021
8039
  wavelength_nm: group.wavelength_nm,
@@ -8149,6 +8167,9 @@ class Sturm {
8149
8167
  const lambdaMinor = Math.max(0, trace / 2 - root);
8150
8168
  const thetaRad = 0.5 * Math.atan2(2 * sxy, sxx - syy);
8151
8169
  const angleMajorDeg = ((thetaRad * 180) / Math.PI + 360) % 180;
8170
+ const twoThetaRad = 2 * thetaRad;
8171
+ const j0 = Math.cos(twoThetaRad);
8172
+ const j45 = Math.sin(twoThetaRad);
8152
8173
  const majorDirection = frame.u.clone().multiplyScalar(Math.cos(thetaRad))
8153
8174
  .add(frame.v.clone().multiplyScalar(Math.sin(thetaRad)))
8154
8175
  .normalize();
@@ -8158,6 +8179,8 @@ class Sturm {
8158
8179
  wMajor: Math.sqrt(lambdaMajor),
8159
8180
  wMinor: Math.sqrt(lambdaMinor),
8160
8181
  angleMajorDeg,
8182
+ j0,
8183
+ j45,
8161
8184
  angleMinorDeg: (angleMajorDeg + 90) % 180,
8162
8185
  majorDirection: {
8163
8186
  x: majorDirection.x,
@@ -8195,6 +8218,84 @@ class Sturm {
8195
8218
  const d = Math.abs((((a - b) % 180) + 180) % 180);
8196
8219
  return Math.min(d, 180 - d);
8197
8220
  }
8221
+ phasorDot(p, q) {
8222
+ return p.j0 * q.j0 + p.j45 * q.j45;
8223
+ }
8224
+ /**
8225
+ * 평탄도 순으로 정렬된 슬라이스에서 선초점 쌍(직교 주경선)에 해당하는 Top2를 고른다.
8226
+ * 주경선 각 대신 이각 phasor (cos 2θ, sin 2θ)로 직교를 판정하고, 실패 시 각도·전역 검색·깊이 폴백을 사용한다.
8227
+ */
8228
+ pickFlattestSturmPair(sortedByFlatness, top2MinGapMm, allSlices) {
8229
+ if (sortedByFlatness.length === 0)
8230
+ return [];
8231
+ const first = sortedByFlatness[0];
8232
+ const top2MinAngleGapDeg = DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG;
8233
+ const phasorStrict = DEFAULT_STURM_TOP2_PHASOR_OPPOSITION_MAX_DOT;
8234
+ const phasorLoose = DEFAULT_STURM_TOP2_PHASOR_FALLBACK_MAX_DOT;
8235
+ const lastResortMinDeg = DEFAULT_STURM_TOP2_AXIS_LAST_RESORT_MIN_GAP_DEG;
8236
+ const depthOk = (c) => Math.abs(c.depth - first.depth) >= top2MinGapMm;
8237
+ const byPhasor = (pool, maxDot) => pool.find((c) => (c !== first
8238
+ && depthOk(c)
8239
+ && this.phasorDot(first.profile, c.profile) <= maxDot));
8240
+ let second = byPhasor(sortedByFlatness, phasorStrict)
8241
+ ?? byPhasor(sortedByFlatness, phasorLoose);
8242
+ if (!second) {
8243
+ second = sortedByFlatness.find((c) => (c !== first
8244
+ && depthOk(c)
8245
+ && this.axisDiffDeg(c.profile.angleMajorDeg, first.profile.angleMajorDeg) >= top2MinAngleGapDeg));
8246
+ }
8247
+ if (!second) {
8248
+ let best = null;
8249
+ let bestAxDiff = -1;
8250
+ for (const c of sortedByFlatness) {
8251
+ if (c === first || !depthOk(c))
8252
+ continue;
8253
+ const axd = this.axisDiffDeg(c.profile.angleMajorDeg, first.profile.angleMajorDeg);
8254
+ if (axd > bestAxDiff) {
8255
+ bestAxDiff = axd;
8256
+ best = c;
8257
+ }
8258
+ }
8259
+ if (best && bestAxDiff >= lastResortMinDeg)
8260
+ second = best;
8261
+ }
8262
+ // 평탄도 정렬 풀만으로는 같은 phasor만 연속으로 나오는 경우가 있어, 전 스캔에서 가장 반대인 phasor를 고른다.
8263
+ if (!second) {
8264
+ let best = null;
8265
+ let bestDot = 1;
8266
+ for (const c of allSlices) {
8267
+ if (c === first || !depthOk(c))
8268
+ continue;
8269
+ const d = this.phasorDot(first.profile, c.profile);
8270
+ if (d < bestDot) {
8271
+ bestDot = d;
8272
+ best = c;
8273
+ }
8274
+ }
8275
+ if (best && bestDot < -0.08)
8276
+ second = best;
8277
+ }
8278
+ // 마지막: 가장 납작한 슬라이스와 깊이 차가 가장 큰 “비교적 납작한” 슬라이스 (선초점이 z로 분리될 때)
8279
+ if (!second) {
8280
+ const flatThreshold = first.ratio * 1.5 + 1e-6;
8281
+ let best = null;
8282
+ let bestDepthSpan = -1;
8283
+ for (const c of allSlices) {
8284
+ if (c === first || !depthOk(c))
8285
+ continue;
8286
+ if (c.ratio > flatThreshold)
8287
+ continue;
8288
+ const span = Math.abs(c.depth - first.depth);
8289
+ if (span > bestDepthSpan) {
8290
+ bestDepthSpan = span;
8291
+ best = c;
8292
+ }
8293
+ }
8294
+ if (best)
8295
+ second = best;
8296
+ }
8297
+ return second ? [first, second] : [first];
8298
+ }
8198
8299
  buildApproxCenter(flattestTop2, smallestEllipse, preferTop2Mid) {
8199
8300
  if (flattestTop2.length <= 0)
8200
8301
  return null;
@@ -8219,6 +8320,19 @@ class Sturm {
8219
8320
  const first = flattestTop2[0];
8220
8321
  return { x: first.profile.at.x, y: first.profile.at.y, z: first.z, mode: "top1-flat" };
8221
8322
  }
8323
+ slicesForSturmAnalysis(sturmSlices, bounds) {
8324
+ if (!bounds)
8325
+ return sturmSlices;
8326
+ const zMin = bounds.zMin;
8327
+ const zMax = bounds.zMax;
8328
+ if (!Number.isFinite(zMin) || !Number.isFinite(zMax) || zMax < zMin)
8329
+ return sturmSlices;
8330
+ const bounded = sturmSlices.filter((s) => {
8331
+ const z = s.profile?.at?.z;
8332
+ return Number.isFinite(z) && z >= zMin && z <= zMax;
8333
+ });
8334
+ return bounded.length >= 2 ? bounded : sturmSlices;
8335
+ }
8222
8336
  groupByFraunhoferLine(rays) {
8223
8337
  const groups = new Map();
8224
8338
  for (const ray of rays) {
@@ -8239,21 +8353,17 @@ class Sturm {
8239
8353
  }
8240
8354
  return [...groups.values()].sort((a, b) => this.lineOrder.indexOf(a.line) - this.lineOrder.indexOf(b.line));
8241
8355
  }
8242
- analyzeSturmSlices(sturmSlices, effectiveCylinderD) {
8356
+ analyzeSturmSlices(sturmSlices, effectiveCylinderD, profileWorldZBounds) {
8243
8357
  const top2MinGapMm = DEFAULT_STURM_TOP2_MIN_GAP_MM;
8244
- const top2MinAngleGapDeg = DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG;
8245
8358
  const effectiveCylinderThresholdD = DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D;
8246
8359
  const preferTop2Mid = effectiveCylinderD >= effectiveCylinderThresholdD;
8247
- const sortedByFlatness = [...sturmSlices].sort((a, b) => a.ratio - b.ratio);
8248
- let flattestTop2 = [];
8249
- if (sortedByFlatness.length > 0) {
8250
- const first = sortedByFlatness[0];
8251
- const second = sortedByFlatness.find((candidate) => (Math.abs(candidate.depth - first.depth) >= top2MinGapMm
8252
- && this.axisDiffDeg(candidate.profile.angleMajorDeg, first.profile.angleMajorDeg) >= top2MinAngleGapDeg));
8253
- flattestTop2 = second ? [first, second] : [first];
8254
- }
8360
+ const slicesForAnalysis = this.slicesForSturmAnalysis(sturmSlices, profileWorldZBounds ?? null);
8361
+ const sortedByFlatness = [...slicesForAnalysis].sort((a, b) => a.ratio - b.ratio);
8362
+ const flattestTop2 = sortedByFlatness.length > 0
8363
+ ? this.pickFlattestSturmPair(sortedByFlatness, top2MinGapMm, slicesForAnalysis)
8364
+ : [];
8255
8365
  let smallestEllipse = null;
8256
- for (const slice of sturmSlices) {
8366
+ for (const slice of slicesForAnalysis) {
8257
8367
  if (!smallestEllipse || slice.size < smallestEllipse.size)
8258
8368
  smallestEllipse = slice;
8259
8369
  }
@@ -8820,7 +8930,7 @@ const TABOToDeg = (TABOAngle) => {
8820
8930
  * legacy simulator.js를 TypeScript로 옮긴 핵심 시뮬레이터입니다.
8821
8931
  * - 광원 광선을 생성하고
8822
8932
  * - 표면들을 순서대로 통과시키며 굴절을 계산한 뒤
8823
- * - 망막 대응쌍, Sturm 분석, 왜곡(affine) 분석까지 제공합니다.
8933
+ * - Sturm 분석까지 제공합니다.
8824
8934
  */
8825
8935
  class SCAXEngineCore {
8826
8936
  constructor(props = {}) {
@@ -8877,8 +8987,10 @@ class SCAXEngineCore {
8877
8987
  const eyeRotXDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.x);
8878
8988
  const eyeRotYDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.y);
8879
8989
  this.eyeTiltDeg = this.normalizeEyeTilt(eye?.tilt);
8880
- const eyeEulerXDeg = (-eyeRotYDeg) + this.eyeTiltDeg.x;
8881
- const eyeEulerYDeg = eyeRotXDeg + this.eyeTiltDeg.y;
8990
+ // Apply eye deviation in the opposite direction so a lens with the same
8991
+ // prism/base prescription can optically compensate the eye deviation.
8992
+ const eyeEulerXDeg = eyeRotYDeg + this.eyeTiltDeg.x;
8993
+ const eyeEulerYDeg = (-eyeRotXDeg) + this.eyeTiltDeg.y;
8882
8994
  this.eyeRotationQuaternion = new Quaternion().setFromEuler(new Euler((eyeEulerXDeg * Math.PI) / 180, (eyeEulerYDeg * Math.PI) / 180, 0, "XYZ"));
8883
8995
  this.eyeRotationQuaternionInverse = this.eyeRotationQuaternion.clone().invert();
8884
8996
  this.eyeRotationPivot = new Vector3(0, 0, SCAXEngineCore.EYE_ROTATION_PIVOT_FROM_CORNEA_MM);
@@ -9040,10 +9152,22 @@ class SCAXEngineCore {
9040
9152
  * - x_deg/y_deg는 프리즘 회전량에 eye.tilt를 합산한 최종 렌더 회전량입니다.
9041
9153
  */
9042
9154
  getEyeRotation() {
9043
- const rx = this.prismVectorFromBase(this.eyePrismPrescription.p, this.eyePrismPrescription.p_ax);
9044
- const eyeEffect = this.vectorToPrismInfo(rx.x, rx.y);
9045
- const xDeg = this.prismComponentToAngleDeg(eyeEffect.x) + this.eyeTiltDeg.y;
9046
- const yDeg = this.prismComponentToAngleDeg(eyeEffect.y) + this.eyeTiltDeg.x;
9155
+ // Keep render rotation strictly aligned with the internal eye rotation used in ray tracing.
9156
+ // Internal eye-space Euler:
9157
+ // eyeEulerXDeg = eyeRotYDeg + tilt.x
9158
+ // eyeEulerYDeg = (-eyeRotXDeg) + tilt.y
9159
+ // UI mapping (applyEyeRenderRotation):
9160
+ // object.rotation.x = -y_deg
9161
+ // object.rotation.y = x_deg
9162
+ // so we expose:
9163
+ // x_deg = eyeEulerYDeg
9164
+ // y_deg = -eyeEulerXDeg
9165
+ const eyeRotXDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.x);
9166
+ const eyeRotYDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.y);
9167
+ const eyeEulerXDeg = eyeRotYDeg + this.eyeTiltDeg.x;
9168
+ const eyeEulerYDeg = (-eyeRotXDeg) + this.eyeTiltDeg.y;
9169
+ const xDeg = eyeEulerYDeg;
9170
+ const yDeg = -eyeEulerXDeg;
9047
9171
  return {
9048
9172
  x_deg: xDeg,
9049
9173
  y_deg: yDeg,
@@ -9103,35 +9227,54 @@ class SCAXEngineCore {
9103
9227
  this.tracedRays = traced;
9104
9228
  return traced;
9105
9229
  }
9230
+ /**
9231
+ * Sturm 선초/근사중심에 사용할 슬라이스 centroid(world z) 구간:
9232
+ * 각막 전면(cornea_anterior) 이상 ~ 망막(retina) + 여유 이하.
9233
+ */
9234
+ sturmEyeProfileWorldZBounds() {
9235
+ let corneaAnteriorZ = Number.POSITIVE_INFINITY;
9236
+ let retinaZ = Number.NEGATIVE_INFINITY;
9237
+ for (const surface of this.surfaces) {
9238
+ const name = String(surface.name || "").toLowerCase();
9239
+ const z = Number(this.readSurfacePosition(surface)?.z);
9240
+ if (!Number.isFinite(z))
9241
+ continue;
9242
+ if (name.includes("cornea") && name.includes("anterior")) {
9243
+ corneaAnteriorZ = Math.min(corneaAnteriorZ, z);
9244
+ }
9245
+ if (name === "retina") {
9246
+ retinaZ = Math.max(retinaZ, z);
9247
+ }
9248
+ }
9249
+ if (!Number.isFinite(corneaAnteriorZ) || !Number.isFinite(retinaZ))
9250
+ return undefined;
9251
+ if (retinaZ + 1e-9 < corneaAnteriorZ)
9252
+ return undefined;
9253
+ return {
9254
+ zMin: corneaAnteriorZ,
9255
+ zMax: retinaZ + DEFAULT_STURM_PROFILE_WORLD_Z_PAST_RETINA_MM,
9256
+ };
9257
+ }
9106
9258
  /**
9107
9259
  * 2) Sturm calculation 전용 함수
9108
9260
  * traced ray 집합에서 z-scan 기반 Sturm 슬라이스/근사 중심을 계산합니다.
9109
9261
  */
9110
9262
  sturmCalculation(rays = this.tracedRays) {
9111
- this.lastSturmGapAnalysis = this.sturm.calculate(rays, this.effectiveCylinderFromOpticSurfaces(), this.lastSourceRaysForSturm);
9263
+ this.lastSturmGapAnalysis = this.sturm.calculate(rays, this.effectiveCylinderFromOpticSurfaces(), this.lastSourceRaysForSturm, this.sturmEyeProfileWorldZBounds() ?? null);
9112
9264
  return this.lastSturmGapAnalysis;
9113
9265
  }
9114
- /**
9115
- * 3) Affine 왜곡 추정 전용 함수
9116
- * 광선 대응쌍(sx,sy)->(tx,ty)에 대해 최소자승 2D affine을 적합합니다.
9117
- */
9118
- estimateAffineDistortion(pairs) {
9119
- const inputPairs = Array.isArray(pairs) ? pairs : [];
9120
- this.lastAffineAnalysis = this.affine.estimate(inputPairs);
9121
- return this.lastAffineAnalysis;
9122
- }
9123
- /**
9124
- * 현재 eye+lens 설정 기준 affine 왜곡 결과를 반환합니다.
9125
- * traced ray/affine 결과는 기존 계산값을 우선 재사용합니다.
9126
- */
9127
9266
  getAffineAnalysis() {
9267
+ /*
9128
9268
  if (!this.tracedRays.length) {
9129
- this.simulate();
9269
+ this.simulate();
9130
9270
  }
9131
9271
  if (!this.lastAffineAnalysis) {
9132
- this.lastAffineAnalysis = this.estimateAffineDistortion(this.createAffinePairs(this.tracedRays));
9272
+ this.lastAffineAnalysis = this.estimateAffineDistortion(this.createAffinePairs(this.tracedRays));
9133
9273
  }
9134
9274
  return this.lastAffineAnalysis;
9275
+ */
9276
+ // Affine analysis is intentionally disabled for now.
9277
+ return null;
9135
9278
  }
9136
9279
  surfaceOrderZ(surface) {
9137
9280
  const z = Number(this.readSurfacePosition(surface)?.z);
@@ -9383,6 +9526,13 @@ class SCAXEngineCore {
9383
9526
  }
9384
9527
  return 2 * Math.hypot(j0, j45);
9385
9528
  }
9529
+ // Kept for later review/release.
9530
+ estimateAffineDistortion(pairs) {
9531
+ const inputPairs = Array.isArray(pairs) ? pairs : [];
9532
+ this.lastAffineAnalysis = this.affine.estimate(inputPairs);
9533
+ return this.lastAffineAnalysis;
9534
+ }
9535
+ // Kept for later review/release.
9386
9536
  createAffinePairs(rays) {
9387
9537
  return (Array.isArray(rays) ? rays : [])
9388
9538
  .map((ray) => {
@@ -7033,6 +7033,19 @@
7033
7033
  * Sturm 분석에서 Top2 선택 시 허용하는 최소 축 각도 차(도)
7034
7034
  */
7035
7035
  const DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG = 45;
7036
+ /**
7037
+ * Sturm Top2 이각(J0,J45) phasor 내적 상한: cos(Δ₂θ) 이 이 값 이하일 때
7038
+ * 두 주경선을 직교(선초점 쌍)로 취급한다. -1에 가까울수록 엄격.
7039
+ */
7040
+ const DEFAULT_STURM_TOP2_PHASOR_OPPOSITION_MAX_DOT = -0.86;
7041
+ /**
7042
+ * phasor 직교 판정 실패 시 완화 단계에서 쓰는 내적 상한
7043
+ */
7044
+ const DEFAULT_STURM_TOP2_PHASOR_FALLBACK_MAX_DOT = -0.55;
7045
+ /**
7046
+ * 축 각·phasor 폴백 후에도 후보가 없을 때 허용하는 최소 각도 차(도)
7047
+ */
7048
+ const DEFAULT_STURM_TOP2_AXIS_LAST_RESORT_MIN_GAP_DEG = 22;
7036
7049
  /**
7037
7050
  * 실효 난시량이 이 값(D) 이상이면 U/V 중간점을 CLC 근사 중심으로 우선 사용
7038
7051
  */
@@ -7041,6 +7054,11 @@
7041
7054
  * Sturm z-scan 간격(mm)
7042
7055
  */
7043
7056
  const DEFAULT_STURM_STEP_MM = 0.01;
7057
+ /**
7058
+ * Sturm 분석 슬라이스의 profile.at.z를 각막(앞)~망막+이 값(mm) 구간으로 한정할 때
7059
+ * 망막 표면 z보다 진행 방향 +z로 허용하는 여유.
7060
+ */
7061
+ const DEFAULT_STURM_PROFILE_WORLD_Z_PAST_RETINA_MM = 4;
7044
7062
 
7045
7063
  const DEFAULT_DIR = new Vector3(0, 0, 1);
7046
7064
  function isFiniteVector3(v) {
@@ -8012,7 +8030,7 @@
8012
8030
  this.lastResult = null;
8013
8031
  this.lineOrder = ["g", "F", "e", "d", "C", "r"];
8014
8032
  }
8015
- calculate(rays, effectiveCylinderD, axisReferenceRays) {
8033
+ calculate(rays, effectiveCylinderD, axisReferenceRays, profileWorldZBounds) {
8016
8034
  const frame = this.analysisFrameFromRays(axisReferenceRays?.length ? axisReferenceRays : rays);
8017
8035
  const depthRange = this.depthRangeFromRays(rays, frame);
8018
8036
  const sturmSlices = this.collectSturmSlices(rays, frame, depthRange, DEFAULT_STURM_STEP_MM);
@@ -8021,7 +8039,7 @@
8021
8039
  const groupFrame = this.analysisFrameFromRays(group.rays, frame);
8022
8040
  const groupDepthRange = this.depthRangeFromRays(group.rays, groupFrame);
8023
8041
  const slices = this.collectSturmSlices(group.rays, groupFrame, groupDepthRange, DEFAULT_STURM_STEP_MM);
8024
- const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD);
8042
+ const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD, profileWorldZBounds);
8025
8043
  return {
8026
8044
  line: group.line,
8027
8045
  wavelength_nm: group.wavelength_nm,
@@ -8155,6 +8173,9 @@
8155
8173
  const lambdaMinor = Math.max(0, trace / 2 - root);
8156
8174
  const thetaRad = 0.5 * Math.atan2(2 * sxy, sxx - syy);
8157
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);
8158
8179
  const majorDirection = frame.u.clone().multiplyScalar(Math.cos(thetaRad))
8159
8180
  .add(frame.v.clone().multiplyScalar(Math.sin(thetaRad)))
8160
8181
  .normalize();
@@ -8164,6 +8185,8 @@
8164
8185
  wMajor: Math.sqrt(lambdaMajor),
8165
8186
  wMinor: Math.sqrt(lambdaMinor),
8166
8187
  angleMajorDeg,
8188
+ j0,
8189
+ j45,
8167
8190
  angleMinorDeg: (angleMajorDeg + 90) % 180,
8168
8191
  majorDirection: {
8169
8192
  x: majorDirection.x,
@@ -8201,6 +8224,84 @@
8201
8224
  const d = Math.abs((((a - b) % 180) + 180) % 180);
8202
8225
  return Math.min(d, 180 - d);
8203
8226
  }
8227
+ phasorDot(p, q) {
8228
+ return p.j0 * q.j0 + p.j45 * q.j45;
8229
+ }
8230
+ /**
8231
+ * 평탄도 순으로 정렬된 슬라이스에서 선초점 쌍(직교 주경선)에 해당하는 Top2를 고른다.
8232
+ * 주경선 각 대신 이각 phasor (cos 2θ, sin 2θ)로 직교를 판정하고, 실패 시 각도·전역 검색·깊이 폴백을 사용한다.
8233
+ */
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
+ }
8264
+ }
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
+ }
8280
+ }
8281
+ if (best && bestDot < -0.08)
8282
+ second = best;
8283
+ }
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
+ }
8299
+ }
8300
+ if (best)
8301
+ second = best;
8302
+ }
8303
+ return second ? [first, second] : [first];
8304
+ }
8204
8305
  buildApproxCenter(flattestTop2, smallestEllipse, preferTop2Mid) {
8205
8306
  if (flattestTop2.length <= 0)
8206
8307
  return null;
@@ -8225,6 +8326,19 @@
8225
8326
  const first = flattestTop2[0];
8226
8327
  return { x: first.profile.at.x, y: first.profile.at.y, z: first.z, mode: "top1-flat" };
8227
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
+ }
8228
8342
  groupByFraunhoferLine(rays) {
8229
8343
  const groups = new Map();
8230
8344
  for (const ray of rays) {
@@ -8245,21 +8359,17 @@
8245
8359
  }
8246
8360
  return [...groups.values()].sort((a, b) => this.lineOrder.indexOf(a.line) - this.lineOrder.indexOf(b.line));
8247
8361
  }
8248
- analyzeSturmSlices(sturmSlices, effectiveCylinderD) {
8362
+ analyzeSturmSlices(sturmSlices, effectiveCylinderD, profileWorldZBounds) {
8249
8363
  const top2MinGapMm = DEFAULT_STURM_TOP2_MIN_GAP_MM;
8250
- const top2MinAngleGapDeg = DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG;
8251
8364
  const effectiveCylinderThresholdD = DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D;
8252
8365
  const preferTop2Mid = effectiveCylinderD >= effectiveCylinderThresholdD;
8253
- const sortedByFlatness = [...sturmSlices].sort((a, b) => a.ratio - b.ratio);
8254
- let flattestTop2 = [];
8255
- if (sortedByFlatness.length > 0) {
8256
- const first = sortedByFlatness[0];
8257
- const second = sortedByFlatness.find((candidate) => (Math.abs(candidate.depth - first.depth) >= top2MinGapMm
8258
- && this.axisDiffDeg(candidate.profile.angleMajorDeg, first.profile.angleMajorDeg) >= top2MinAngleGapDeg));
8259
- flattestTop2 = second ? [first, second] : [first];
8260
- }
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
+ : [];
8261
8371
  let smallestEllipse = null;
8262
- for (const slice of sturmSlices) {
8372
+ for (const slice of slicesForAnalysis) {
8263
8373
  if (!smallestEllipse || slice.size < smallestEllipse.size)
8264
8374
  smallestEllipse = slice;
8265
8375
  }
@@ -8826,7 +8936,7 @@
8826
8936
  * legacy simulator.js를 TypeScript로 옮긴 핵심 시뮬레이터입니다.
8827
8937
  * - 광원 광선을 생성하고
8828
8938
  * - 표면들을 순서대로 통과시키며 굴절을 계산한 뒤
8829
- * - 망막 대응쌍, Sturm 분석, 왜곡(affine) 분석까지 제공합니다.
8939
+ * - Sturm 분석까지 제공합니다.
8830
8940
  */
8831
8941
  class SCAXEngineCore {
8832
8942
  constructor(props = {}) {
@@ -8883,8 +8993,10 @@
8883
8993
  const eyeRotXDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.x);
8884
8994
  const eyeRotYDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.y);
8885
8995
  this.eyeTiltDeg = this.normalizeEyeTilt(eye?.tilt);
8886
- const eyeEulerXDeg = (-eyeRotYDeg) + this.eyeTiltDeg.x;
8887
- const eyeEulerYDeg = eyeRotXDeg + this.eyeTiltDeg.y;
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;
8888
9000
  this.eyeRotationQuaternion = new Quaternion().setFromEuler(new Euler((eyeEulerXDeg * Math.PI) / 180, (eyeEulerYDeg * Math.PI) / 180, 0, "XYZ"));
8889
9001
  this.eyeRotationQuaternionInverse = this.eyeRotationQuaternion.clone().invert();
8890
9002
  this.eyeRotationPivot = new Vector3(0, 0, SCAXEngineCore.EYE_ROTATION_PIVOT_FROM_CORNEA_MM);
@@ -9046,10 +9158,22 @@
9046
9158
  * - x_deg/y_deg는 프리즘 회전량에 eye.tilt를 합산한 최종 렌더 회전량입니다.
9047
9159
  */
9048
9160
  getEyeRotation() {
9049
- const rx = this.prismVectorFromBase(this.eyePrismPrescription.p, this.eyePrismPrescription.p_ax);
9050
- const eyeEffect = this.vectorToPrismInfo(rx.x, rx.y);
9051
- const xDeg = this.prismComponentToAngleDeg(eyeEffect.x) + this.eyeTiltDeg.y;
9052
- const yDeg = this.prismComponentToAngleDeg(eyeEffect.y) + this.eyeTiltDeg.x;
9161
+ // Keep render rotation strictly aligned with the internal eye rotation used in ray tracing.
9162
+ // Internal eye-space Euler:
9163
+ // eyeEulerXDeg = eyeRotYDeg + tilt.x
9164
+ // eyeEulerYDeg = (-eyeRotXDeg) + tilt.y
9165
+ // UI mapping (applyEyeRenderRotation):
9166
+ // object.rotation.x = -y_deg
9167
+ // object.rotation.y = x_deg
9168
+ // so we expose:
9169
+ // x_deg = eyeEulerYDeg
9170
+ // y_deg = -eyeEulerXDeg
9171
+ const eyeRotXDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.x);
9172
+ const eyeRotYDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.y);
9173
+ const eyeEulerXDeg = eyeRotYDeg + this.eyeTiltDeg.x;
9174
+ const eyeEulerYDeg = (-eyeRotXDeg) + this.eyeTiltDeg.y;
9175
+ const xDeg = eyeEulerYDeg;
9176
+ const yDeg = -eyeEulerXDeg;
9053
9177
  return {
9054
9178
  x_deg: xDeg,
9055
9179
  y_deg: yDeg,
@@ -9109,35 +9233,54 @@
9109
9233
  this.tracedRays = traced;
9110
9234
  return traced;
9111
9235
  }
9236
+ /**
9237
+ * Sturm 선초/근사중심에 사용할 슬라이스 centroid(world z) 구간:
9238
+ * 각막 전면(cornea_anterior) 이상 ~ 망막(retina) + 여유 이하.
9239
+ */
9240
+ sturmEyeProfileWorldZBounds() {
9241
+ let corneaAnteriorZ = Number.POSITIVE_INFINITY;
9242
+ let retinaZ = Number.NEGATIVE_INFINITY;
9243
+ for (const surface of this.surfaces) {
9244
+ const name = String(surface.name || "").toLowerCase();
9245
+ const z = Number(this.readSurfacePosition(surface)?.z);
9246
+ if (!Number.isFinite(z))
9247
+ continue;
9248
+ if (name.includes("cornea") && name.includes("anterior")) {
9249
+ corneaAnteriorZ = Math.min(corneaAnteriorZ, z);
9250
+ }
9251
+ if (name === "retina") {
9252
+ retinaZ = Math.max(retinaZ, z);
9253
+ }
9254
+ }
9255
+ if (!Number.isFinite(corneaAnteriorZ) || !Number.isFinite(retinaZ))
9256
+ return undefined;
9257
+ if (retinaZ + 1e-9 < corneaAnteriorZ)
9258
+ return undefined;
9259
+ return {
9260
+ zMin: corneaAnteriorZ,
9261
+ zMax: retinaZ + DEFAULT_STURM_PROFILE_WORLD_Z_PAST_RETINA_MM,
9262
+ };
9263
+ }
9112
9264
  /**
9113
9265
  * 2) Sturm calculation 전용 함수
9114
9266
  * traced ray 집합에서 z-scan 기반 Sturm 슬라이스/근사 중심을 계산합니다.
9115
9267
  */
9116
9268
  sturmCalculation(rays = this.tracedRays) {
9117
- this.lastSturmGapAnalysis = this.sturm.calculate(rays, this.effectiveCylinderFromOpticSurfaces(), this.lastSourceRaysForSturm);
9269
+ this.lastSturmGapAnalysis = this.sturm.calculate(rays, this.effectiveCylinderFromOpticSurfaces(), this.lastSourceRaysForSturm, this.sturmEyeProfileWorldZBounds() ?? null);
9118
9270
  return this.lastSturmGapAnalysis;
9119
9271
  }
9120
- /**
9121
- * 3) Affine 왜곡 추정 전용 함수
9122
- * 광선 대응쌍(sx,sy)->(tx,ty)에 대해 최소자승 2D affine을 적합합니다.
9123
- */
9124
- estimateAffineDistortion(pairs) {
9125
- const inputPairs = Array.isArray(pairs) ? pairs : [];
9126
- this.lastAffineAnalysis = this.affine.estimate(inputPairs);
9127
- return this.lastAffineAnalysis;
9128
- }
9129
- /**
9130
- * 현재 eye+lens 설정 기준 affine 왜곡 결과를 반환합니다.
9131
- * traced ray/affine 결과는 기존 계산값을 우선 재사용합니다.
9132
- */
9133
9272
  getAffineAnalysis() {
9273
+ /*
9134
9274
  if (!this.tracedRays.length) {
9135
- this.simulate();
9275
+ this.simulate();
9136
9276
  }
9137
9277
  if (!this.lastAffineAnalysis) {
9138
- this.lastAffineAnalysis = this.estimateAffineDistortion(this.createAffinePairs(this.tracedRays));
9278
+ this.lastAffineAnalysis = this.estimateAffineDistortion(this.createAffinePairs(this.tracedRays));
9139
9279
  }
9140
9280
  return this.lastAffineAnalysis;
9281
+ */
9282
+ // Affine analysis is intentionally disabled for now.
9283
+ return null;
9141
9284
  }
9142
9285
  surfaceOrderZ(surface) {
9143
9286
  const z = Number(this.readSurfacePosition(surface)?.z);
@@ -9389,6 +9532,13 @@
9389
9532
  }
9390
9533
  return 2 * Math.hypot(j0, j45);
9391
9534
  }
9535
+ // Kept for later review/release.
9536
+ estimateAffineDistortion(pairs) {
9537
+ const inputPairs = Array.isArray(pairs) ? pairs : [];
9538
+ this.lastAffineAnalysis = this.affine.estimate(inputPairs);
9539
+ return this.lastAffineAnalysis;
9540
+ }
9541
+ // Kept for later review/release.
9392
9542
  createAffinePairs(rays) {
9393
9543
  return (Array.isArray(rays) ? rays : [])
9394
9544
  .map((ray) => {
@@ -1,5 +1,6 @@
1
1
  export { default as SCAXEngine } from "./scax-engine";
2
2
  export { SCAXEngineCore } from "./scax-engine";
3
+ export type { SturmProfileWorldZBounds } from "./sturm/sturm";
3
4
  export { default as Ray } from "./ray/ray";
4
5
  export type { RayProps } from "./ray/ray";
5
6
  export type { AffinePair } from "./affine/affine";
@@ -189,6 +189,19 @@ export declare const DEFAULT_STURM_TOP2_MIN_GAP_MM = 0;
189
189
  * Sturm 분석에서 Top2 선택 시 허용하는 최소 축 각도 차(도)
190
190
  */
191
191
  export declare const DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG = 45;
192
+ /**
193
+ * Sturm Top2 이각(J0,J45) phasor 내적 상한: cos(Δ₂θ) 이 이 값 이하일 때
194
+ * 두 주경선을 직교(선초점 쌍)로 취급한다. -1에 가까울수록 엄격.
195
+ */
196
+ export declare const DEFAULT_STURM_TOP2_PHASOR_OPPOSITION_MAX_DOT = -0.86;
197
+ /**
198
+ * phasor 직교 판정 실패 시 완화 단계에서 쓰는 내적 상한
199
+ */
200
+ export declare const DEFAULT_STURM_TOP2_PHASOR_FALLBACK_MAX_DOT = -0.55;
201
+ /**
202
+ * 축 각·phasor 폴백 후에도 후보가 없을 때 허용하는 최소 각도 차(도)
203
+ */
204
+ export declare const DEFAULT_STURM_TOP2_AXIS_LAST_RESORT_MIN_GAP_DEG = 22;
192
205
  /**
193
206
  * 실효 난시량이 이 값(D) 이상이면 U/V 중간점을 CLC 근사 중심으로 우선 사용
194
207
  */
@@ -197,4 +210,9 @@ export declare const DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D = 0.125;
197
210
  * Sturm z-scan 간격(mm)
198
211
  */
199
212
  export declare const DEFAULT_STURM_STEP_MM = 0.01;
213
+ /**
214
+ * Sturm 분석 슬라이스의 profile.at.z를 각막(앞)~망막+이 값(mm) 구간으로 한정할 때
215
+ * 망막 표면 z보다 진행 방향 +z로 허용하는 여유.
216
+ */
217
+ export declare const DEFAULT_STURM_PROFILE_WORLD_Z_PAST_RETINA_MM = 4;
200
218
  //# sourceMappingURL=constants.d.ts.map
@@ -112,7 +112,7 @@ export type AffineAnalysisResult = ReturnType<Affine["estimate"]>;
112
112
  * legacy simulator.js를 TypeScript로 옮긴 핵심 시뮬레이터입니다.
113
113
  * - 광원 광선을 생성하고
114
114
  * - 표면들을 순서대로 통과시키며 굴절을 계산한 뒤
115
- * - 망막 대응쌍, Sturm 분석, 왜곡(affine) 분석까지 제공합니다.
115
+ * - Sturm 분석까지 제공합니다.
116
116
  */
117
117
  export declare class SCAXEngineCore {
118
118
  private static readonly EYE_ROTATION_PIVOT_FROM_CORNEA_MM;
@@ -169,6 +169,11 @@ export declare class SCAXEngineCore {
169
169
  * 광원에서 시작한 광선을 표면 순서대로 굴절시켜 최종 광선 집합을 반환합니다.
170
170
  */
171
171
  rayTracing(): Ray[];
172
+ /**
173
+ * Sturm 선초/근사중심에 사용할 슬라이스 centroid(world z) 구간:
174
+ * 각막 전면(cornea_anterior) 이상 ~ 망막(retina) + 여유 이하.
175
+ */
176
+ private sturmEyeProfileWorldZBounds;
172
177
  /**
173
178
  * 2) Sturm calculation 전용 함수
174
179
  * traced ray 집합에서 z-scan 기반 Sturm 슬라이스/근사 중심을 계산합니다.
@@ -190,6 +195,8 @@ export declare class SCAXEngineCore {
190
195
  wMajor: number;
191
196
  wMinor: number;
192
197
  angleMajorDeg: number;
198
+ j0: number;
199
+ j45: number;
193
200
  angleMinorDeg: number;
194
201
  majorDirection: {
195
202
  x: number;
@@ -221,6 +228,8 @@ export declare class SCAXEngineCore {
221
228
  wMajor: number;
222
229
  wMinor: number;
223
230
  angleMajorDeg: number;
231
+ j0: number;
232
+ j45: number;
224
233
  angleMinorDeg: number;
225
234
  majorDirection: {
226
235
  x: number;
@@ -248,6 +257,8 @@ export declare class SCAXEngineCore {
248
257
  wMajor: number;
249
258
  wMinor: number;
250
259
  angleMajorDeg: number;
260
+ j0: number;
261
+ j45: number;
251
262
  angleMinorDeg: number;
252
263
  majorDirection: {
253
264
  x: number;
@@ -278,15 +289,6 @@ export declare class SCAXEngineCore {
278
289
  };
279
290
  }[];
280
291
  };
281
- /**
282
- * 3) Affine 왜곡 추정 전용 함수
283
- * 광선 대응쌍(sx,sy)->(tx,ty)에 대해 최소자승 2D affine을 적합합니다.
284
- */
285
- private estimateAffineDistortion;
286
- /**
287
- * 현재 eye+lens 설정 기준 affine 왜곡 결과를 반환합니다.
288
- * traced ray/affine 결과는 기존 계산값을 우선 재사용합니다.
289
- */
290
292
  getAffineAnalysis(): AffineAnalysisResult;
291
293
  private surfaceOrderZ;
292
294
  private readSurfacePosition;
@@ -316,6 +318,7 @@ export declare class SCAXEngineCore {
316
318
  private applyLightSourceTransformToRay;
317
319
  private calculateLightDeviation;
318
320
  private effectiveCylinderFromOpticSurfaces;
321
+ private estimateAffineDistortion;
319
322
  private createAffinePairs;
320
323
  }
321
324
  /**
@@ -350,6 +353,8 @@ export default class SCAXEngine {
350
353
  wMajor: number;
351
354
  wMinor: number;
352
355
  angleMajorDeg: number;
356
+ j0: number;
357
+ j45: number;
353
358
  angleMinorDeg: number;
354
359
  majorDirection: {
355
360
  x: number;
@@ -381,6 +386,8 @@ export default class SCAXEngine {
381
386
  wMajor: number;
382
387
  wMinor: number;
383
388
  angleMajorDeg: number;
389
+ j0: number;
390
+ j45: number;
384
391
  angleMinorDeg: number;
385
392
  majorDirection: {
386
393
  x: number;
@@ -408,6 +415,8 @@ export default class SCAXEngine {
408
415
  wMajor: number;
409
416
  wMinor: number;
410
417
  angleMajorDeg: number;
418
+ j0: number;
419
+ j45: number;
411
420
  angleMinorDeg: number;
412
421
  majorDirection: {
413
422
  x: number;
@@ -1,4 +1,9 @@
1
1
  import Ray from "../ray/ray";
2
+ /** Sturm 선초 분석에 사용할 슬라이스 centroid(world) z 구간 [zMin, zMax] (mm) */
3
+ export type SturmProfileWorldZBounds = {
4
+ zMin: number;
5
+ zMax: number;
6
+ };
2
7
  type FraunhoferLine = "g" | "F" | "e" | "d" | "C" | "r";
3
8
  type SturmSlice = {
4
9
  z: number;
@@ -14,6 +19,9 @@ type SturmSlice = {
14
19
  wMajor: number;
15
20
  wMinor: number;
16
21
  angleMajorDeg: number;
22
+ /** 이각 공간 난시 phasor: (cos 2θ, sin 2θ), θ = 주경선 각(rad) */
23
+ j0: number;
24
+ j45: number;
17
25
  angleMinorDeg: number;
18
26
  majorDirection: {
19
27
  x: number;
@@ -34,7 +42,7 @@ type SturmSlice = {
34
42
  */
35
43
  export default class Sturm {
36
44
  private lastResult;
37
- calculate(rays: Ray[], effectiveCylinderD: number, axisReferenceRays?: Ray[]): {
45
+ calculate(rays: Ray[], effectiveCylinderD: number, axisReferenceRays?: Ray[], profileWorldZBounds?: SturmProfileWorldZBounds | null): {
38
46
  slices_info: {
39
47
  count: number;
40
48
  slices: SturmSlice[];
@@ -73,7 +81,14 @@ export default class Sturm {
73
81
  private secondMomentProfileAtDepth;
74
82
  private collectSturmSlices;
75
83
  private axisDiffDeg;
84
+ private phasorDot;
85
+ /**
86
+ * 평탄도 순으로 정렬된 슬라이스에서 선초점 쌍(직교 주경선)에 해당하는 Top2를 고른다.
87
+ * 주경선 각 대신 이각 phasor (cos 2θ, sin 2θ)로 직교를 판정하고, 실패 시 각도·전역 검색·깊이 폴백을 사용한다.
88
+ */
89
+ private pickFlattestSturmPair;
76
90
  private buildApproxCenter;
91
+ private slicesForSturmAnalysis;
77
92
  private groupByFraunhoferLine;
78
93
  private analyzeSturmSlices;
79
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scax-engine",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Optical calculation engine for an eye model, with ESM, CJS, and UMD builds.",
5
5
  "main": "./dist/scax-engine.cjs",
6
6
  "module": "./dist/scax-engine.js",