scax-engine 0.1.1 → 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.
@@ -6936,6 +6936,13 @@
6936
6936
  d: 1.406,
6937
6937
  C: 1.405318,
6938
6938
  },
6939
+ // Navarro 수정체 중심 굴절률(d=1.42)용 분산 스펙
6940
+ lens_navarro: {
6941
+ F: 1.421585,
6942
+ e: 1.420542,
6943
+ d: 1.420,
6944
+ C: 1.419318,
6945
+ },
6939
6946
  lens_anterior: {
6940
6947
  F: 1.387507,
6941
6948
  e: 1.386516,
@@ -7026,6 +7033,19 @@
7026
7033
  * Sturm 분석에서 Top2 선택 시 허용하는 최소 축 각도 차(도)
7027
7034
  */
7028
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;
7029
7049
  /**
7030
7050
  * 실효 난시량이 이 값(D) 이상이면 U/V 중간점을 CLC 근사 중심으로 우선 사용
7031
7051
  */
@@ -7034,6 +7054,11 @@
7034
7054
  * Sturm z-scan 간격(mm)
7035
7055
  */
7036
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;
7037
7062
 
7038
7063
  const DEFAULT_DIR = new Vector3(0, 0, 1);
7039
7064
  function isFiniteVector3(v) {
@@ -7889,48 +7914,48 @@
7889
7914
  name: "cornea_anterior",
7890
7915
  z: 0.0,
7891
7916
  radius: 7.7,
7892
- n_before: 1.0,
7893
- n_after: 1.376,
7917
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
7918
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
7894
7919
  },
7895
7920
  {
7896
7921
  type: "spherical",
7897
7922
  name: "cornea_posterior",
7898
7923
  z: 0.5,
7899
7924
  radius: 6.8,
7900
- n_before: 1.376,
7901
- n_after: 1.336,
7925
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
7926
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
7902
7927
  },
7903
7928
  {
7904
7929
  type: "spherical",
7905
7930
  name: "lens_anterior",
7906
7931
  z: 3.6,
7907
7932
  radius: 10.0,
7908
- n_before: 1.336,
7909
- n_after: 1.386,
7933
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
7934
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_anterior,
7910
7935
  },
7911
7936
  {
7912
7937
  type: "spherical",
7913
7938
  name: "lens_nucleus_anterior",
7914
7939
  z: 4.146,
7915
7940
  radius: 7.911,
7916
- n_before: 1.386,
7917
- n_after: 1.406,
7941
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_anterior,
7942
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_anterior,
7918
7943
  },
7919
7944
  {
7920
7945
  type: "spherical",
7921
7946
  name: "lens_nucleus_posterior",
7922
7947
  z: 6.565,
7923
7948
  radius: -5.76,
7924
- n_before: 1.406,
7925
- n_after: 1.386,
7949
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_anterior,
7950
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_posterior,
7926
7951
  },
7927
7952
  {
7928
7953
  type: "spherical",
7929
7954
  name: "lens_posterior",
7930
7955
  z: 7.2,
7931
7956
  radius: -6,
7932
- n_before: 1.386,
7933
- n_after: 1.336,
7957
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_posterior,
7958
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.vitreous,
7934
7959
  },
7935
7960
  {
7936
7961
  type: "spherical-image",
@@ -7956,8 +7981,8 @@
7956
7981
  z: 0.0,
7957
7982
  radius: 7.72,
7958
7983
  conic: -0.26,
7959
- n_before: 1.0,
7960
- n_after: 1.376,
7984
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
7985
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
7961
7986
  },
7962
7987
  {
7963
7988
  type: "aspherical",
@@ -7965,8 +7990,8 @@
7965
7990
  z: 0.55,
7966
7991
  radius: 6.5,
7967
7992
  conic: 0.0,
7968
- n_before: 1.376,
7969
- n_after: 1.336,
7993
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
7994
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
7970
7995
  },
7971
7996
  {
7972
7997
  type: "aspherical",
@@ -7974,8 +7999,8 @@
7974
7999
  z: 0.55 + 3.05,
7975
8000
  radius: 10.2,
7976
8001
  conic: -3.13,
7977
- n_before: 1.336,
7978
- n_after: 1.42,
8002
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
8003
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_navarro,
7979
8004
  },
7980
8005
  {
7981
8006
  type: "aspherical",
@@ -7983,8 +8008,8 @@
7983
8008
  z: 0.55 + 3.05 + 4.0,
7984
8009
  radius: -6,
7985
8010
  conic: -1,
7986
- n_before: 1.42,
7987
- n_after: 1.336,
8011
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_navarro,
8012
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.vitreous,
7988
8013
  },
7989
8014
  {
7990
8015
  type: "spherical-image",
@@ -8005,7 +8030,7 @@
8005
8030
  this.lastResult = null;
8006
8031
  this.lineOrder = ["g", "F", "e", "d", "C", "r"];
8007
8032
  }
8008
- calculate(rays, effectiveCylinderD, axisReferenceRays) {
8033
+ calculate(rays, effectiveCylinderD, axisReferenceRays, profileWorldZBounds) {
8009
8034
  const frame = this.analysisFrameFromRays(axisReferenceRays?.length ? axisReferenceRays : rays);
8010
8035
  const depthRange = this.depthRangeFromRays(rays, frame);
8011
8036
  const sturmSlices = this.collectSturmSlices(rays, frame, depthRange, DEFAULT_STURM_STEP_MM);
@@ -8014,7 +8039,7 @@
8014
8039
  const groupFrame = this.analysisFrameFromRays(group.rays, frame);
8015
8040
  const groupDepthRange = this.depthRangeFromRays(group.rays, groupFrame);
8016
8041
  const slices = this.collectSturmSlices(group.rays, groupFrame, groupDepthRange, DEFAULT_STURM_STEP_MM);
8017
- const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD);
8042
+ const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD, profileWorldZBounds);
8018
8043
  return {
8019
8044
  line: group.line,
8020
8045
  wavelength_nm: group.wavelength_nm,
@@ -8148,6 +8173,9 @@
8148
8173
  const lambdaMinor = Math.max(0, trace / 2 - root);
8149
8174
  const thetaRad = 0.5 * Math.atan2(2 * sxy, sxx - syy);
8150
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);
8151
8179
  const majorDirection = frame.u.clone().multiplyScalar(Math.cos(thetaRad))
8152
8180
  .add(frame.v.clone().multiplyScalar(Math.sin(thetaRad)))
8153
8181
  .normalize();
@@ -8157,6 +8185,8 @@
8157
8185
  wMajor: Math.sqrt(lambdaMajor),
8158
8186
  wMinor: Math.sqrt(lambdaMinor),
8159
8187
  angleMajorDeg,
8188
+ j0,
8189
+ j45,
8160
8190
  angleMinorDeg: (angleMajorDeg + 90) % 180,
8161
8191
  majorDirection: {
8162
8192
  x: majorDirection.x,
@@ -8194,6 +8224,84 @@
8194
8224
  const d = Math.abs((((a - b) % 180) + 180) % 180);
8195
8225
  return Math.min(d, 180 - d);
8196
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
+ }
8197
8305
  buildApproxCenter(flattestTop2, smallestEllipse, preferTop2Mid) {
8198
8306
  if (flattestTop2.length <= 0)
8199
8307
  return null;
@@ -8218,6 +8326,19 @@
8218
8326
  const first = flattestTop2[0];
8219
8327
  return { x: first.profile.at.x, y: first.profile.at.y, z: first.z, mode: "top1-flat" };
8220
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
+ }
8221
8342
  groupByFraunhoferLine(rays) {
8222
8343
  const groups = new Map();
8223
8344
  for (const ray of rays) {
@@ -8238,21 +8359,17 @@
8238
8359
  }
8239
8360
  return [...groups.values()].sort((a, b) => this.lineOrder.indexOf(a.line) - this.lineOrder.indexOf(b.line));
8240
8361
  }
8241
- analyzeSturmSlices(sturmSlices, effectiveCylinderD) {
8362
+ analyzeSturmSlices(sturmSlices, effectiveCylinderD, profileWorldZBounds) {
8242
8363
  const top2MinGapMm = DEFAULT_STURM_TOP2_MIN_GAP_MM;
8243
- const top2MinAngleGapDeg = DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG;
8244
8364
  const effectiveCylinderThresholdD = DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D;
8245
8365
  const preferTop2Mid = effectiveCylinderD >= effectiveCylinderThresholdD;
8246
- const sortedByFlatness = [...sturmSlices].sort((a, b) => a.ratio - b.ratio);
8247
- let flattestTop2 = [];
8248
- if (sortedByFlatness.length > 0) {
8249
- const first = sortedByFlatness[0];
8250
- const second = sortedByFlatness.find((candidate) => (Math.abs(candidate.depth - first.depth) >= top2MinGapMm
8251
- && this.axisDiffDeg(candidate.profile.angleMajorDeg, first.profile.angleMajorDeg) >= top2MinAngleGapDeg));
8252
- flattestTop2 = second ? [first, second] : [first];
8253
- }
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
+ : [];
8254
8371
  let smallestEllipse = null;
8255
- for (const slice of sturmSlices) {
8372
+ for (const slice of slicesForAnalysis) {
8256
8373
  if (!smallestEllipse || slice.size < smallestEllipse.size)
8257
8374
  smallestEllipse = slice;
8258
8375
  }
@@ -8876,8 +8993,10 @@
8876
8993
  const eyeRotXDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.x);
8877
8994
  const eyeRotYDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.y);
8878
8995
  this.eyeTiltDeg = this.normalizeEyeTilt(eye?.tilt);
8879
- const eyeEulerXDeg = (-eyeRotYDeg) + this.eyeTiltDeg.x;
8880
- 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;
8881
9000
  this.eyeRotationQuaternion = new Quaternion().setFromEuler(new Euler((eyeEulerXDeg * Math.PI) / 180, (eyeEulerYDeg * Math.PI) / 180, 0, "XYZ"));
8882
9001
  this.eyeRotationQuaternionInverse = this.eyeRotationQuaternion.clone().invert();
8883
9002
  this.eyeRotationPivot = new Vector3(0, 0, SCAXEngineCore.EYE_ROTATION_PIVOT_FROM_CORNEA_MM);
@@ -9039,10 +9158,22 @@
9039
9158
  * - x_deg/y_deg는 프리즘 회전량에 eye.tilt를 합산한 최종 렌더 회전량입니다.
9040
9159
  */
9041
9160
  getEyeRotation() {
9042
- const rx = this.prismVectorFromBase(this.eyePrismPrescription.p, this.eyePrismPrescription.p_ax);
9043
- const eyeEffect = this.vectorToPrismInfo(rx.x, rx.y);
9044
- const xDeg = this.prismComponentToAngleDeg(eyeEffect.x) + this.eyeTiltDeg.y;
9045
- 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;
9046
9177
  return {
9047
9178
  x_deg: xDeg,
9048
9179
  y_deg: yDeg,
@@ -9102,12 +9233,40 @@
9102
9233
  this.tracedRays = traced;
9103
9234
  return traced;
9104
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
+ }
9105
9264
  /**
9106
9265
  * 2) Sturm calculation 전용 함수
9107
9266
  * traced ray 집합에서 z-scan 기반 Sturm 슬라이스/근사 중심을 계산합니다.
9108
9267
  */
9109
9268
  sturmCalculation(rays = this.tracedRays) {
9110
- this.lastSturmGapAnalysis = this.sturm.calculate(rays, this.effectiveCylinderFromOpticSurfaces(), this.lastSourceRaysForSturm);
9269
+ this.lastSturmGapAnalysis = this.sturm.calculate(rays, this.effectiveCylinderFromOpticSurfaces(), this.lastSourceRaysForSturm, this.sturmEyeProfileWorldZBounds() ?? null);
9111
9270
  return this.lastSturmGapAnalysis;
9112
9271
  }
9113
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";
@@ -93,6 +93,12 @@ export declare const FRAUNHOFER_REFRACTIVE_INDICES: {
93
93
  d: number;
94
94
  C: number;
95
95
  };
96
+ lens_navarro: {
97
+ F: number;
98
+ e: number;
99
+ d: number;
100
+ C: number;
101
+ };
96
102
  lens_anterior: {
97
103
  F: number;
98
104
  e: number;
@@ -183,6 +189,19 @@ export declare const DEFAULT_STURM_TOP2_MIN_GAP_MM = 0;
183
189
  * Sturm 분석에서 Top2 선택 시 허용하는 최소 축 각도 차(도)
184
190
  */
185
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;
186
205
  /**
187
206
  * 실효 난시량이 이 값(D) 이상이면 U/V 중간점을 CLC 근사 중심으로 우선 사용
188
207
  */
@@ -191,4 +210,9 @@ export declare const DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D = 0.125;
191
210
  * Sturm z-scan 간격(mm)
192
211
  */
193
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;
194
218
  //# sourceMappingURL=constants.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { RefractiveIndexSpec } from "../../optics/refractive-index";
1
2
  import Surface from "../../surfaces/surface";
2
3
  interface BaseEyeSurfaceParameter {
3
4
  name: string;
@@ -6,15 +7,15 @@ interface BaseEyeSurfaceParameter {
6
7
  interface SphericalEyeSurfaceParameter extends BaseEyeSurfaceParameter {
7
8
  type: "spherical";
8
9
  radius: number;
9
- n_before: number;
10
- n_after: number;
10
+ n_before: RefractiveIndexSpec;
11
+ n_after: RefractiveIndexSpec;
11
12
  }
12
13
  interface AsphericalEyeSurfaceParameter extends BaseEyeSurfaceParameter {
13
14
  type: "aspherical";
14
15
  radius: number;
15
16
  conic: number;
16
- n_before: number;
17
- n_after: number;
17
+ n_before: RefractiveIndexSpec;
18
+ n_after: RefractiveIndexSpec;
18
19
  }
19
20
  interface SphericalImageEyeSurfaceParameter extends BaseEyeSurfaceParameter {
20
21
  type: "spherical-image";
@@ -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.1",
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",