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.
- package/dist/scax-engine.cjs +172 -20
- package/dist/scax-engine.js +172 -20
- package/dist/scax-engine.umd.js +172 -20
- package/dist/types/index.d.ts +1 -0
- package/dist/types/parameters/constants.d.ts +18 -0
- package/dist/types/scax-engine.d.ts +17 -0
- package/dist/types/sturm/sturm.d.ts +16 -1
- package/package.json +1 -1
package/dist/scax-engine.cjs
CHANGED
|
@@ -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
|
|
8250
|
-
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
|
|
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
|
|
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
|
-
|
|
8883
|
-
|
|
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
|
-
|
|
9046
|
-
|
|
9047
|
-
|
|
9048
|
-
|
|
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
|
/**
|
package/dist/scax-engine.js
CHANGED
|
@@ -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
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
|
|
8251
|
-
|
|
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
|
|
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
|
-
|
|
8881
|
-
|
|
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
|
-
|
|
9044
|
-
|
|
9045
|
-
|
|
9046
|
-
|
|
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
|
/**
|
package/dist/scax-engine.umd.js
CHANGED
|
@@ -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
|
|
8254
|
-
|
|
8255
|
-
|
|
8256
|
-
|
|
8257
|
-
|
|
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
|
|
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
|
-
|
|
8887
|
-
|
|
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
|
-
|
|
9050
|
-
|
|
9051
|
-
|
|
9052
|
-
|
|
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
|
/**
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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