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.
@@ -6930,6 +6930,13 @@ const FRAUNHOFER_REFRACTIVE_INDICES = {
6930
6930
  d: 1.406,
6931
6931
  C: 1.405318,
6932
6932
  },
6933
+ // Navarro 수정체 중심 굴절률(d=1.42)용 분산 스펙
6934
+ lens_navarro: {
6935
+ F: 1.421585,
6936
+ e: 1.420542,
6937
+ d: 1.420,
6938
+ C: 1.419318,
6939
+ },
6933
6940
  lens_anterior: {
6934
6941
  F: 1.387507,
6935
6942
  e: 1.386516,
@@ -7020,6 +7027,19 @@ const DEFAULT_STURM_TOP2_MIN_GAP_MM = 0.0;
7020
7027
  * Sturm 분석에서 Top2 선택 시 허용하는 최소 축 각도 차(도)
7021
7028
  */
7022
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;
7023
7043
  /**
7024
7044
  * 실효 난시량이 이 값(D) 이상이면 U/V 중간점을 CLC 근사 중심으로 우선 사용
7025
7045
  */
@@ -7028,6 +7048,11 @@ const DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D = 0.125;
7028
7048
  * Sturm z-scan 간격(mm)
7029
7049
  */
7030
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;
7031
7056
 
7032
7057
  const DEFAULT_DIR = new Vector3(0, 0, 1);
7033
7058
  function isFiniteVector3(v) {
@@ -7883,48 +7908,48 @@ GullstrandParameter.parameter = {
7883
7908
  name: "cornea_anterior",
7884
7909
  z: 0.0,
7885
7910
  radius: 7.7,
7886
- n_before: 1.0,
7887
- n_after: 1.376,
7911
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
7912
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
7888
7913
  },
7889
7914
  {
7890
7915
  type: "spherical",
7891
7916
  name: "cornea_posterior",
7892
7917
  z: 0.5,
7893
7918
  radius: 6.8,
7894
- n_before: 1.376,
7895
- n_after: 1.336,
7919
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
7920
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
7896
7921
  },
7897
7922
  {
7898
7923
  type: "spherical",
7899
7924
  name: "lens_anterior",
7900
7925
  z: 3.6,
7901
7926
  radius: 10.0,
7902
- n_before: 1.336,
7903
- n_after: 1.386,
7927
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
7928
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_anterior,
7904
7929
  },
7905
7930
  {
7906
7931
  type: "spherical",
7907
7932
  name: "lens_nucleus_anterior",
7908
7933
  z: 4.146,
7909
7934
  radius: 7.911,
7910
- n_before: 1.386,
7911
- n_after: 1.406,
7935
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_anterior,
7936
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_anterior,
7912
7937
  },
7913
7938
  {
7914
7939
  type: "spherical",
7915
7940
  name: "lens_nucleus_posterior",
7916
7941
  z: 6.565,
7917
7942
  radius: -5.76,
7918
- n_before: 1.406,
7919
- n_after: 1.386,
7943
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_anterior,
7944
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_posterior,
7920
7945
  },
7921
7946
  {
7922
7947
  type: "spherical",
7923
7948
  name: "lens_posterior",
7924
7949
  z: 7.2,
7925
7950
  radius: -6,
7926
- n_before: 1.386,
7927
- n_after: 1.336,
7951
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_posterior,
7952
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.vitreous,
7928
7953
  },
7929
7954
  {
7930
7955
  type: "spherical-image",
@@ -7950,8 +7975,8 @@ NavarroParameter.parameter = {
7950
7975
  z: 0.0,
7951
7976
  radius: 7.72,
7952
7977
  conic: -0.26,
7953
- n_before: 1.0,
7954
- n_after: 1.376,
7978
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
7979
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
7955
7980
  },
7956
7981
  {
7957
7982
  type: "aspherical",
@@ -7959,8 +7984,8 @@ NavarroParameter.parameter = {
7959
7984
  z: 0.55,
7960
7985
  radius: 6.5,
7961
7986
  conic: 0.0,
7962
- n_before: 1.376,
7963
- n_after: 1.336,
7987
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
7988
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
7964
7989
  },
7965
7990
  {
7966
7991
  type: "aspherical",
@@ -7968,8 +7993,8 @@ NavarroParameter.parameter = {
7968
7993
  z: 0.55 + 3.05,
7969
7994
  radius: 10.2,
7970
7995
  conic: -3.13,
7971
- n_before: 1.336,
7972
- n_after: 1.42,
7996
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
7997
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_navarro,
7973
7998
  },
7974
7999
  {
7975
8000
  type: "aspherical",
@@ -7977,8 +8002,8 @@ NavarroParameter.parameter = {
7977
8002
  z: 0.55 + 3.05 + 4.0,
7978
8003
  radius: -6,
7979
8004
  conic: -1,
7980
- n_before: 1.42,
7981
- n_after: 1.336,
8005
+ n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_navarro,
8006
+ n_after: FRAUNHOFER_REFRACTIVE_INDICES.vitreous,
7982
8007
  },
7983
8008
  {
7984
8009
  type: "spherical-image",
@@ -7999,7 +8024,7 @@ class Sturm {
7999
8024
  this.lastResult = null;
8000
8025
  this.lineOrder = ["g", "F", "e", "d", "C", "r"];
8001
8026
  }
8002
- calculate(rays, effectiveCylinderD, axisReferenceRays) {
8027
+ calculate(rays, effectiveCylinderD, axisReferenceRays, profileWorldZBounds) {
8003
8028
  const frame = this.analysisFrameFromRays(axisReferenceRays?.length ? axisReferenceRays : rays);
8004
8029
  const depthRange = this.depthRangeFromRays(rays, frame);
8005
8030
  const sturmSlices = this.collectSturmSlices(rays, frame, depthRange, DEFAULT_STURM_STEP_MM);
@@ -8008,7 +8033,7 @@ class Sturm {
8008
8033
  const groupFrame = this.analysisFrameFromRays(group.rays, frame);
8009
8034
  const groupDepthRange = this.depthRangeFromRays(group.rays, groupFrame);
8010
8035
  const slices = this.collectSturmSlices(group.rays, groupFrame, groupDepthRange, DEFAULT_STURM_STEP_MM);
8011
- const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD);
8036
+ const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD, profileWorldZBounds);
8012
8037
  return {
8013
8038
  line: group.line,
8014
8039
  wavelength_nm: group.wavelength_nm,
@@ -8142,6 +8167,9 @@ class Sturm {
8142
8167
  const lambdaMinor = Math.max(0, trace / 2 - root);
8143
8168
  const thetaRad = 0.5 * Math.atan2(2 * sxy, sxx - syy);
8144
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);
8145
8173
  const majorDirection = frame.u.clone().multiplyScalar(Math.cos(thetaRad))
8146
8174
  .add(frame.v.clone().multiplyScalar(Math.sin(thetaRad)))
8147
8175
  .normalize();
@@ -8151,6 +8179,8 @@ class Sturm {
8151
8179
  wMajor: Math.sqrt(lambdaMajor),
8152
8180
  wMinor: Math.sqrt(lambdaMinor),
8153
8181
  angleMajorDeg,
8182
+ j0,
8183
+ j45,
8154
8184
  angleMinorDeg: (angleMajorDeg + 90) % 180,
8155
8185
  majorDirection: {
8156
8186
  x: majorDirection.x,
@@ -8188,6 +8218,84 @@ class Sturm {
8188
8218
  const d = Math.abs((((a - b) % 180) + 180) % 180);
8189
8219
  return Math.min(d, 180 - d);
8190
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
+ }
8191
8299
  buildApproxCenter(flattestTop2, smallestEllipse, preferTop2Mid) {
8192
8300
  if (flattestTop2.length <= 0)
8193
8301
  return null;
@@ -8212,6 +8320,19 @@ class Sturm {
8212
8320
  const first = flattestTop2[0];
8213
8321
  return { x: first.profile.at.x, y: first.profile.at.y, z: first.z, mode: "top1-flat" };
8214
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
+ }
8215
8336
  groupByFraunhoferLine(rays) {
8216
8337
  const groups = new Map();
8217
8338
  for (const ray of rays) {
@@ -8232,21 +8353,17 @@ class Sturm {
8232
8353
  }
8233
8354
  return [...groups.values()].sort((a, b) => this.lineOrder.indexOf(a.line) - this.lineOrder.indexOf(b.line));
8234
8355
  }
8235
- analyzeSturmSlices(sturmSlices, effectiveCylinderD) {
8356
+ analyzeSturmSlices(sturmSlices, effectiveCylinderD, profileWorldZBounds) {
8236
8357
  const top2MinGapMm = DEFAULT_STURM_TOP2_MIN_GAP_MM;
8237
- const top2MinAngleGapDeg = DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG;
8238
8358
  const effectiveCylinderThresholdD = DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D;
8239
8359
  const preferTop2Mid = effectiveCylinderD >= effectiveCylinderThresholdD;
8240
- const sortedByFlatness = [...sturmSlices].sort((a, b) => a.ratio - b.ratio);
8241
- let flattestTop2 = [];
8242
- if (sortedByFlatness.length > 0) {
8243
- const first = sortedByFlatness[0];
8244
- const second = sortedByFlatness.find((candidate) => (Math.abs(candidate.depth - first.depth) >= top2MinGapMm
8245
- && this.axisDiffDeg(candidate.profile.angleMajorDeg, first.profile.angleMajorDeg) >= top2MinAngleGapDeg));
8246
- flattestTop2 = second ? [first, second] : [first];
8247
- }
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
+ : [];
8248
8365
  let smallestEllipse = null;
8249
- for (const slice of sturmSlices) {
8366
+ for (const slice of slicesForAnalysis) {
8250
8367
  if (!smallestEllipse || slice.size < smallestEllipse.size)
8251
8368
  smallestEllipse = slice;
8252
8369
  }
@@ -8870,8 +8987,10 @@ class SCAXEngineCore {
8870
8987
  const eyeRotXDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.x);
8871
8988
  const eyeRotYDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.y);
8872
8989
  this.eyeTiltDeg = this.normalizeEyeTilt(eye?.tilt);
8873
- const eyeEulerXDeg = (-eyeRotYDeg) + this.eyeTiltDeg.x;
8874
- 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;
8875
8994
  this.eyeRotationQuaternion = new Quaternion().setFromEuler(new Euler((eyeEulerXDeg * Math.PI) / 180, (eyeEulerYDeg * Math.PI) / 180, 0, "XYZ"));
8876
8995
  this.eyeRotationQuaternionInverse = this.eyeRotationQuaternion.clone().invert();
8877
8996
  this.eyeRotationPivot = new Vector3(0, 0, SCAXEngineCore.EYE_ROTATION_PIVOT_FROM_CORNEA_MM);
@@ -9033,10 +9152,22 @@ class SCAXEngineCore {
9033
9152
  * - x_deg/y_deg는 프리즘 회전량에 eye.tilt를 합산한 최종 렌더 회전량입니다.
9034
9153
  */
9035
9154
  getEyeRotation() {
9036
- const rx = this.prismVectorFromBase(this.eyePrismPrescription.p, this.eyePrismPrescription.p_ax);
9037
- const eyeEffect = this.vectorToPrismInfo(rx.x, rx.y);
9038
- const xDeg = this.prismComponentToAngleDeg(eyeEffect.x) + this.eyeTiltDeg.y;
9039
- 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;
9040
9171
  return {
9041
9172
  x_deg: xDeg,
9042
9173
  y_deg: yDeg,
@@ -9096,12 +9227,40 @@ class SCAXEngineCore {
9096
9227
  this.tracedRays = traced;
9097
9228
  return traced;
9098
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
+ }
9099
9258
  /**
9100
9259
  * 2) Sturm calculation 전용 함수
9101
9260
  * traced ray 집합에서 z-scan 기반 Sturm 슬라이스/근사 중심을 계산합니다.
9102
9261
  */
9103
9262
  sturmCalculation(rays = this.tracedRays) {
9104
- this.lastSturmGapAnalysis = this.sturm.calculate(rays, this.effectiveCylinderFromOpticSurfaces(), this.lastSourceRaysForSturm);
9263
+ this.lastSturmGapAnalysis = this.sturm.calculate(rays, this.effectiveCylinderFromOpticSurfaces(), this.lastSourceRaysForSturm, this.sturmEyeProfileWorldZBounds() ?? null);
9105
9264
  return this.lastSturmGapAnalysis;
9106
9265
  }
9107
9266
  /**