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