scax-engine 0.1.2 → 0.1.3

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.
@@ -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
  }
@@ -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,12 +9229,40 @@ 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
9268
  /**
@@ -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
  }
@@ -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,12 +9227,40 @@ 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
9266
  /**
@@ -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
  }
@@ -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,12 +9233,40 @@
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
9272
  /**
@@ -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
@@ -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;
@@ -350,6 +361,8 @@ export default class SCAXEngine {
350
361
  wMajor: number;
351
362
  wMinor: number;
352
363
  angleMajorDeg: number;
364
+ j0: number;
365
+ j45: number;
353
366
  angleMinorDeg: number;
354
367
  majorDirection: {
355
368
  x: number;
@@ -381,6 +394,8 @@ export default class SCAXEngine {
381
394
  wMajor: number;
382
395
  wMinor: number;
383
396
  angleMajorDeg: number;
397
+ j0: number;
398
+ j45: number;
384
399
  angleMinorDeg: number;
385
400
  majorDirection: {
386
401
  x: number;
@@ -408,6 +423,8 @@ export default class SCAXEngine {
408
423
  wMajor: number;
409
424
  wMinor: number;
410
425
  angleMajorDeg: number;
426
+ j0: number;
427
+ j45: number;
411
428
  angleMinorDeg: number;
412
429
  majorDirection: {
413
430
  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.3",
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",