scax-engine 0.1.7 → 0.2.0-beta.1
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/README.md +16 -29
- package/dist/scax-engine.cjs +1557 -1532
- package/dist/scax-engine.js +1557 -1532
- package/dist/scax-engine.umd.js +1557 -1532
- package/dist/types/index.d.ts +2 -6
- package/dist/types/light-sources/light-source.d.ts +23 -2
- package/dist/types/optics/prism-on-ray.d.ts +19 -0
- package/dist/types/parameters/eye/eye-rigid-pose.d.ts +32 -0
- package/dist/types/parameters/eye/eyemodel-parameter.d.ts +24 -1
- package/dist/types/scax-engine.d.ts +64 -123
- package/dist/types/surfaces/st-surface.d.ts +8 -1
- package/dist/types/surfaces/surface.d.ts +7 -1
- package/package.json +1 -1
package/dist/scax-engine.cjs
CHANGED
|
@@ -6737,119 +6737,6 @@ const _m = /*@__PURE__*/ new Matrix3();
|
|
|
6737
6737
|
|
|
6738
6738
|
_m.set( -1, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0 );
|
|
6739
6739
|
|
|
6740
|
-
/**
|
|
6741
|
-
* 2D affine 왜곡 추정 전용 클래스입니다.
|
|
6742
|
-
* [x'; y'] = [[a b c], [d e f]] * [x y 1]^T 형태를 최소자승으로 적합합니다.
|
|
6743
|
-
*/
|
|
6744
|
-
class Affine {
|
|
6745
|
-
constructor() {
|
|
6746
|
-
this.lastResult = null;
|
|
6747
|
-
this.lastPairs = [];
|
|
6748
|
-
}
|
|
6749
|
-
estimate(pairs) {
|
|
6750
|
-
const inputPairs = Array.isArray(pairs) ? pairs : [];
|
|
6751
|
-
this.lastPairs = inputPairs;
|
|
6752
|
-
const affine = this.fitAffine2D(inputPairs);
|
|
6753
|
-
if (!affine) {
|
|
6754
|
-
this.lastResult = null;
|
|
6755
|
-
return null;
|
|
6756
|
-
}
|
|
6757
|
-
let residualSumPct = 0;
|
|
6758
|
-
let residualCount = 0;
|
|
6759
|
-
let residualMaxPct = 0;
|
|
6760
|
-
const residuals = [];
|
|
6761
|
-
for (const pair of inputPairs) {
|
|
6762
|
-
const px = affine.a * pair.sx + affine.b * pair.sy + affine.c;
|
|
6763
|
-
const py = affine.d * pair.sx + affine.e * pair.sy + affine.f;
|
|
6764
|
-
const rx = pair.tx - px;
|
|
6765
|
-
const ry = pair.ty - py;
|
|
6766
|
-
const magnitude = Math.hypot(rx, ry);
|
|
6767
|
-
if (magnitude < 1e-4)
|
|
6768
|
-
continue;
|
|
6769
|
-
const radiusRef = Math.hypot(px, py);
|
|
6770
|
-
const pct = (magnitude / Math.max(0.2, radiusRef)) * 100;
|
|
6771
|
-
residualSumPct += pct;
|
|
6772
|
-
residualCount += 1;
|
|
6773
|
-
residualMaxPct = Math.max(residualMaxPct, pct);
|
|
6774
|
-
residuals.push({ sx: pair.sx, sy: pair.sy, px, py, rx, ry, magnitude, pct });
|
|
6775
|
-
}
|
|
6776
|
-
const result = {
|
|
6777
|
-
...affine,
|
|
6778
|
-
count: inputPairs.length,
|
|
6779
|
-
residualAvgPct: residualCount ? residualSumPct / residualCount : 0,
|
|
6780
|
-
residualMaxPct,
|
|
6781
|
-
residuals,
|
|
6782
|
-
};
|
|
6783
|
-
this.lastResult = result;
|
|
6784
|
-
return result;
|
|
6785
|
-
}
|
|
6786
|
-
/**
|
|
6787
|
-
* 마지막 affine 추정 결과를 반환합니다.
|
|
6788
|
-
*/
|
|
6789
|
-
getLastResult() {
|
|
6790
|
-
return this.lastResult;
|
|
6791
|
-
}
|
|
6792
|
-
/**
|
|
6793
|
-
* 마지막 affine 추정에 사용된 입력쌍을 반환합니다.
|
|
6794
|
-
*/
|
|
6795
|
-
getLastPairs() {
|
|
6796
|
-
return [...this.lastPairs];
|
|
6797
|
-
}
|
|
6798
|
-
fitAffine2D(pairs) {
|
|
6799
|
-
if (!Array.isArray(pairs) || pairs.length < 4)
|
|
6800
|
-
return null;
|
|
6801
|
-
const ata = Array.from({ length: 6 }, () => Array(6).fill(0));
|
|
6802
|
-
const atb = Array(6).fill(0);
|
|
6803
|
-
const accumulate = (row, rhs) => {
|
|
6804
|
-
for (let i = 0; i < 6; i += 1) {
|
|
6805
|
-
atb[i] += row[i] * rhs;
|
|
6806
|
-
for (let j = 0; j < 6; j += 1)
|
|
6807
|
-
ata[i][j] += row[i] * row[j];
|
|
6808
|
-
}
|
|
6809
|
-
};
|
|
6810
|
-
for (const pair of pairs) {
|
|
6811
|
-
accumulate([pair.sx, pair.sy, 1, 0, 0, 0], pair.tx);
|
|
6812
|
-
accumulate([0, 0, 0, pair.sx, pair.sy, 1], pair.ty);
|
|
6813
|
-
}
|
|
6814
|
-
const n = 6;
|
|
6815
|
-
const aug = ata.map((row, index) => [...row, atb[index]]);
|
|
6816
|
-
for (let col = 0; col < n; col += 1) {
|
|
6817
|
-
let pivot = col;
|
|
6818
|
-
for (let row = col + 1; row < n; row += 1) {
|
|
6819
|
-
if (Math.abs(aug[row][col]) > Math.abs(aug[pivot][col]))
|
|
6820
|
-
pivot = row;
|
|
6821
|
-
}
|
|
6822
|
-
if (Math.abs(aug[pivot][col]) < 1e-10)
|
|
6823
|
-
return null;
|
|
6824
|
-
if (pivot !== col) {
|
|
6825
|
-
const temp = aug[col];
|
|
6826
|
-
aug[col] = aug[pivot];
|
|
6827
|
-
aug[pivot] = temp;
|
|
6828
|
-
}
|
|
6829
|
-
const divider = aug[col][col];
|
|
6830
|
-
for (let j = col; j <= n; j += 1)
|
|
6831
|
-
aug[col][j] /= divider;
|
|
6832
|
-
for (let row = 0; row < n; row += 1) {
|
|
6833
|
-
if (row === col)
|
|
6834
|
-
continue;
|
|
6835
|
-
const factor = aug[row][col];
|
|
6836
|
-
if (Math.abs(factor) < 1e-12)
|
|
6837
|
-
continue;
|
|
6838
|
-
for (let j = col; j <= n; j += 1)
|
|
6839
|
-
aug[row][j] -= factor * aug[col][j];
|
|
6840
|
-
}
|
|
6841
|
-
}
|
|
6842
|
-
return {
|
|
6843
|
-
a: aug[0][n],
|
|
6844
|
-
b: aug[1][n],
|
|
6845
|
-
c: aug[2][n],
|
|
6846
|
-
d: aug[3][n],
|
|
6847
|
-
e: aug[4][n],
|
|
6848
|
-
f: aug[5][n],
|
|
6849
|
-
};
|
|
6850
|
-
}
|
|
6851
|
-
}
|
|
6852
|
-
|
|
6853
6740
|
/**
|
|
6854
6741
|
* 프라운호퍼 파장과 색상
|
|
6855
6742
|
*/
|
|
@@ -7126,9 +7013,125 @@ class Ray {
|
|
|
7126
7013
|
}
|
|
7127
7014
|
}
|
|
7128
7015
|
|
|
7016
|
+
/**
|
|
7017
|
+
* 2D affine 왜곡 추정 전용 클래스입니다.
|
|
7018
|
+
* [x'; y'] = [[a b c], [d e f]] * [x y 1]^T 형태를 최소자승으로 적합합니다.
|
|
7019
|
+
*/
|
|
7020
|
+
class Affine {
|
|
7021
|
+
constructor() {
|
|
7022
|
+
this.lastResult = null;
|
|
7023
|
+
this.lastPairs = [];
|
|
7024
|
+
}
|
|
7025
|
+
estimate(pairs) {
|
|
7026
|
+
const inputPairs = Array.isArray(pairs) ? pairs : [];
|
|
7027
|
+
this.lastPairs = inputPairs;
|
|
7028
|
+
const affine = this.fitAffine2D(inputPairs);
|
|
7029
|
+
if (!affine) {
|
|
7030
|
+
this.lastResult = null;
|
|
7031
|
+
return null;
|
|
7032
|
+
}
|
|
7033
|
+
let residualSumPct = 0;
|
|
7034
|
+
let residualCount = 0;
|
|
7035
|
+
let residualMaxPct = 0;
|
|
7036
|
+
const residuals = [];
|
|
7037
|
+
for (const pair of inputPairs) {
|
|
7038
|
+
const px = affine.a * pair.sx + affine.b * pair.sy + affine.c;
|
|
7039
|
+
const py = affine.d * pair.sx + affine.e * pair.sy + affine.f;
|
|
7040
|
+
const rx = pair.tx - px;
|
|
7041
|
+
const ry = pair.ty - py;
|
|
7042
|
+
const magnitude = Math.hypot(rx, ry);
|
|
7043
|
+
if (magnitude < 1e-4)
|
|
7044
|
+
continue;
|
|
7045
|
+
const radiusRef = Math.hypot(px, py);
|
|
7046
|
+
const pct = (magnitude / Math.max(0.2, radiusRef)) * 100;
|
|
7047
|
+
residualSumPct += pct;
|
|
7048
|
+
residualCount += 1;
|
|
7049
|
+
residualMaxPct = Math.max(residualMaxPct, pct);
|
|
7050
|
+
residuals.push({ sx: pair.sx, sy: pair.sy, px, py, rx, ry, magnitude, pct });
|
|
7051
|
+
}
|
|
7052
|
+
const result = {
|
|
7053
|
+
...affine,
|
|
7054
|
+
count: inputPairs.length,
|
|
7055
|
+
residualAvgPct: residualCount ? residualSumPct / residualCount : 0,
|
|
7056
|
+
residualMaxPct,
|
|
7057
|
+
residuals,
|
|
7058
|
+
};
|
|
7059
|
+
this.lastResult = result;
|
|
7060
|
+
return result;
|
|
7061
|
+
}
|
|
7062
|
+
/**
|
|
7063
|
+
* 마지막 affine 추정 결과를 반환합니다.
|
|
7064
|
+
*/
|
|
7065
|
+
getLastResult() {
|
|
7066
|
+
return this.lastResult;
|
|
7067
|
+
}
|
|
7068
|
+
/**
|
|
7069
|
+
* 마지막 affine 추정에 사용된 입력쌍을 반환합니다.
|
|
7070
|
+
*/
|
|
7071
|
+
getLastPairs() {
|
|
7072
|
+
return [...this.lastPairs];
|
|
7073
|
+
}
|
|
7074
|
+
fitAffine2D(pairs) {
|
|
7075
|
+
if (!Array.isArray(pairs) || pairs.length < 4)
|
|
7076
|
+
return null;
|
|
7077
|
+
const ata = Array.from({ length: 6 }, () => Array(6).fill(0));
|
|
7078
|
+
const atb = Array(6).fill(0);
|
|
7079
|
+
const accumulate = (row, rhs) => {
|
|
7080
|
+
for (let i = 0; i < 6; i += 1) {
|
|
7081
|
+
atb[i] += row[i] * rhs;
|
|
7082
|
+
for (let j = 0; j < 6; j += 1)
|
|
7083
|
+
ata[i][j] += row[i] * row[j];
|
|
7084
|
+
}
|
|
7085
|
+
};
|
|
7086
|
+
for (const pair of pairs) {
|
|
7087
|
+
accumulate([pair.sx, pair.sy, 1, 0, 0, 0], pair.tx);
|
|
7088
|
+
accumulate([0, 0, 0, pair.sx, pair.sy, 1], pair.ty);
|
|
7089
|
+
}
|
|
7090
|
+
const n = 6;
|
|
7091
|
+
const aug = ata.map((row, index) => [...row, atb[index]]);
|
|
7092
|
+
for (let col = 0; col < n; col += 1) {
|
|
7093
|
+
let pivot = col;
|
|
7094
|
+
for (let row = col + 1; row < n; row += 1) {
|
|
7095
|
+
if (Math.abs(aug[row][col]) > Math.abs(aug[pivot][col]))
|
|
7096
|
+
pivot = row;
|
|
7097
|
+
}
|
|
7098
|
+
if (Math.abs(aug[pivot][col]) < 1e-10)
|
|
7099
|
+
return null;
|
|
7100
|
+
if (pivot !== col) {
|
|
7101
|
+
const temp = aug[col];
|
|
7102
|
+
aug[col] = aug[pivot];
|
|
7103
|
+
aug[pivot] = temp;
|
|
7104
|
+
}
|
|
7105
|
+
const divider = aug[col][col];
|
|
7106
|
+
for (let j = col; j <= n; j += 1)
|
|
7107
|
+
aug[col][j] /= divider;
|
|
7108
|
+
for (let row = 0; row < n; row += 1) {
|
|
7109
|
+
if (row === col)
|
|
7110
|
+
continue;
|
|
7111
|
+
const factor = aug[row][col];
|
|
7112
|
+
if (Math.abs(factor) < 1e-12)
|
|
7113
|
+
continue;
|
|
7114
|
+
for (let j = col; j <= n; j += 1)
|
|
7115
|
+
aug[row][j] -= factor * aug[col][j];
|
|
7116
|
+
}
|
|
7117
|
+
}
|
|
7118
|
+
return {
|
|
7119
|
+
a: aug[0][n],
|
|
7120
|
+
b: aug[1][n],
|
|
7121
|
+
c: aug[2][n],
|
|
7122
|
+
d: aug[3][n],
|
|
7123
|
+
e: aug[4][n],
|
|
7124
|
+
f: aug[5][n],
|
|
7125
|
+
};
|
|
7126
|
+
}
|
|
7127
|
+
}
|
|
7128
|
+
|
|
7129
7129
|
class LightSource {
|
|
7130
7130
|
constructor() {
|
|
7131
7131
|
this.rays = [];
|
|
7132
|
+
this.sourcePosition = new Vector3(0, 0, 0);
|
|
7133
|
+
this.sourceRotationQuaternion = new Quaternion();
|
|
7134
|
+
this.sourceRotationPivot = new Vector3(0, 0, 0);
|
|
7132
7135
|
}
|
|
7133
7136
|
directionFromVergence(origin, z, vergence) {
|
|
7134
7137
|
if (!Number.isFinite(vergence) || Math.abs(vergence) < 1e-12) {
|
|
@@ -7163,13 +7166,62 @@ class LightSource {
|
|
|
7163
7166
|
addRay(ray) {
|
|
7164
7167
|
this.rays.push(ray.clone());
|
|
7165
7168
|
}
|
|
7169
|
+
configurePose(pose, pivotZ = 0) {
|
|
7170
|
+
const position = pose?.position;
|
|
7171
|
+
const tilt = pose?.tilt;
|
|
7172
|
+
this.sourcePosition = new Vector3(this.toFiniteNumber(position?.x), this.toFiniteNumber(position?.y), this.toFiniteNumber(position?.z));
|
|
7173
|
+
const tiltXDeg = this.toFiniteNumber(tilt?.x);
|
|
7174
|
+
const tiltYDeg = this.toFiniteNumber(tilt?.y);
|
|
7175
|
+
this.sourceRotationQuaternion = new Quaternion().setFromEuler(new Euler((tiltXDeg * Math.PI) / 180, (tiltYDeg * Math.PI) / 180, 0, "XYZ"));
|
|
7176
|
+
this.sourceRotationPivot = new Vector3(0, 0, this.toFiniteNumber(pivotZ));
|
|
7177
|
+
}
|
|
7178
|
+
toFiniteNumber(value, fallback = 0) {
|
|
7179
|
+
const parsed = Number(value);
|
|
7180
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
7181
|
+
}
|
|
7182
|
+
getRayPoints(ray) {
|
|
7183
|
+
const state = ray;
|
|
7184
|
+
return Array.isArray(state.points) ? state.points : [];
|
|
7185
|
+
}
|
|
7186
|
+
rotatePointAroundPivot(point, pivot) {
|
|
7187
|
+
return point.clone().sub(pivot).applyQuaternion(this.sourceRotationQuaternion).add(pivot);
|
|
7188
|
+
}
|
|
7189
|
+
transformRayAroundPivot(ray) {
|
|
7190
|
+
const points = this.getRayPoints(ray);
|
|
7191
|
+
if (!points.length)
|
|
7192
|
+
return ray;
|
|
7193
|
+
const transformed = ray.clone();
|
|
7194
|
+
const transformedState = transformed;
|
|
7195
|
+
const nextPoints = points.map((point) => this.rotatePointAroundPivot(point, this.sourceRotationPivot));
|
|
7196
|
+
transformedState.points = nextPoints;
|
|
7197
|
+
transformedState.direction = ray.getDirection().clone().applyQuaternion(this.sourceRotationQuaternion).normalize();
|
|
7198
|
+
transformedState.origin = nextPoints[nextPoints.length - 1].clone();
|
|
7199
|
+
return transformed;
|
|
7200
|
+
}
|
|
7201
|
+
translateRay(ray) {
|
|
7202
|
+
if (this.sourcePosition.lengthSq() < 1e-12)
|
|
7203
|
+
return ray;
|
|
7204
|
+
const points = this.getRayPoints(ray);
|
|
7205
|
+
if (!points.length)
|
|
7206
|
+
return ray;
|
|
7207
|
+
const translated = ray.clone();
|
|
7208
|
+
const translatedState = translated;
|
|
7209
|
+
const nextPoints = points.map((point) => point.clone().add(this.sourcePosition));
|
|
7210
|
+
translatedState.points = nextPoints;
|
|
7211
|
+
translatedState.origin = nextPoints[nextPoints.length - 1].clone();
|
|
7212
|
+
return translated;
|
|
7213
|
+
}
|
|
7214
|
+
applyPoseToRay(ray) {
|
|
7215
|
+
const rotated = this.transformRayAroundPivot(ray.clone());
|
|
7216
|
+
return this.translateRay(rotated);
|
|
7217
|
+
}
|
|
7166
7218
|
emitRays() {
|
|
7167
|
-
return this.rays.map((ray) =>
|
|
7219
|
+
return this.rays.map((ray) => this.applyPoseToRay(ray));
|
|
7168
7220
|
}
|
|
7169
7221
|
}
|
|
7170
7222
|
class GridLightSource extends LightSource {
|
|
7171
7223
|
constructor(props) {
|
|
7172
|
-
const { width, height, division = 4, z, vergence = 0 } = props;
|
|
7224
|
+
const { width, height, division = 4, z, vergence = 0, position, tilt } = props;
|
|
7173
7225
|
if (division < 4) {
|
|
7174
7226
|
throw new Error("division must be greater than 4");
|
|
7175
7227
|
}
|
|
@@ -7190,6 +7242,7 @@ class GridLightSource extends LightSource {
|
|
|
7190
7242
|
this.division = division;
|
|
7191
7243
|
this.z = z;
|
|
7192
7244
|
this.vergence = vergence;
|
|
7245
|
+
this.configurePose({ position, tilt }, this.z);
|
|
7193
7246
|
const xStep = this.division > 1 ? this.width / (this.division - 1) : 0;
|
|
7194
7247
|
const yStep = this.division > 1 ? this.height / (this.division - 1) : 0;
|
|
7195
7248
|
const xStart = -this.width / 2;
|
|
@@ -7206,7 +7259,7 @@ class GridLightSource extends LightSource {
|
|
|
7206
7259
|
}
|
|
7207
7260
|
class GridRGLightSource extends LightSource {
|
|
7208
7261
|
constructor(props) {
|
|
7209
|
-
const { width, height, division = 4, z, vergence = 0 } = props;
|
|
7262
|
+
const { width, height, division = 4, z, vergence = 0, position, tilt } = props;
|
|
7210
7263
|
if (division < 4) {
|
|
7211
7264
|
throw new Error("division must be greater than 4");
|
|
7212
7265
|
}
|
|
@@ -7227,6 +7280,7 @@ class GridRGLightSource extends LightSource {
|
|
|
7227
7280
|
this.division = division;
|
|
7228
7281
|
this.z = z;
|
|
7229
7282
|
this.vergence = vergence;
|
|
7283
|
+
this.configurePose({ position, tilt }, this.z);
|
|
7230
7284
|
const xStep = this.division > 1 ? this.width / (this.division - 1) : 0;
|
|
7231
7285
|
const yStep = this.division > 1 ? this.height / (this.division - 1) : 0;
|
|
7232
7286
|
const xStart = -this.width / 2;
|
|
@@ -7244,7 +7298,7 @@ class GridRGLightSource extends LightSource {
|
|
|
7244
7298
|
}
|
|
7245
7299
|
class RadialLightSource extends LightSource {
|
|
7246
7300
|
constructor(props) {
|
|
7247
|
-
const { radius, division = 4, angle_division = 4, z, vergence = 0 } = props;
|
|
7301
|
+
const { radius, division = 4, angle_division = 4, z, vergence = 0, position, tilt } = props;
|
|
7248
7302
|
if (radius < 0) {
|
|
7249
7303
|
throw new Error("radius must be greater than or equal to 0");
|
|
7250
7304
|
}
|
|
@@ -7268,6 +7322,7 @@ class RadialLightSource extends LightSource {
|
|
|
7268
7322
|
this.angle_division = angle_division;
|
|
7269
7323
|
this.z = z;
|
|
7270
7324
|
this.vergence = vergence;
|
|
7325
|
+
this.configurePose({ position, tilt }, this.z);
|
|
7271
7326
|
this.createRayFromPoint(new Vector3(0, 0, this.z), this.z, this.vergence);
|
|
7272
7327
|
for (let ring = 1; ring <= this.division; ring += 1) {
|
|
7273
7328
|
const ringRadius = (this.radius * ring) / this.division;
|
|
@@ -7282,26 +7337,76 @@ class RadialLightSource extends LightSource {
|
|
|
7282
7337
|
}
|
|
7283
7338
|
}
|
|
7284
7339
|
|
|
7285
|
-
|
|
7286
|
-
|
|
7340
|
+
/** 각막 기준 안구 회전점 z(mm). `SCAXEngineCore`와 동일. */
|
|
7341
|
+
const EYE_ROTATION_PIVOT_FROM_CORNEA_MM = 13;
|
|
7342
|
+
function normalizePrismAmount$2(value) {
|
|
7343
|
+
const p = Number(value ?? 0);
|
|
7344
|
+
return Number.isFinite(p) ? Math.max(0, p) : 0;
|
|
7287
7345
|
}
|
|
7288
|
-
function
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
return lineValue;
|
|
7294
|
-
const dValue = spec.d;
|
|
7295
|
-
if (isFiniteNumber(dValue))
|
|
7296
|
-
return dValue;
|
|
7297
|
-
return 1.0;
|
|
7346
|
+
function normalizeAngle360$2(value) {
|
|
7347
|
+
const d = Number(value ?? 0);
|
|
7348
|
+
if (!Number.isFinite(d))
|
|
7349
|
+
return 0;
|
|
7350
|
+
return ((d % 360) + 360) % 360;
|
|
7298
7351
|
}
|
|
7299
|
-
function
|
|
7300
|
-
|
|
7301
|
-
|
|
7302
|
-
const
|
|
7303
|
-
const
|
|
7304
|
-
return
|
|
7352
|
+
function prismVectorFromBase(prismDiopter, baseAngleDeg) {
|
|
7353
|
+
const p = normalizePrismAmount$2(prismDiopter);
|
|
7354
|
+
const baseAngle = normalizeAngle360$2(baseAngleDeg);
|
|
7355
|
+
const deviationAngle = normalizeAngle360$2(baseAngle + 180);
|
|
7356
|
+
const rad = (-deviationAngle * Math.PI) / 180;
|
|
7357
|
+
return {
|
|
7358
|
+
x: p * Math.cos(rad),
|
|
7359
|
+
y: p * Math.sin(rad),
|
|
7360
|
+
};
|
|
7361
|
+
}
|
|
7362
|
+
function prismComponentToAngleDeg(componentPrism) {
|
|
7363
|
+
const c = Number(componentPrism);
|
|
7364
|
+
if (!Number.isFinite(c))
|
|
7365
|
+
return 0;
|
|
7366
|
+
return (Math.atan(c / 100) * 180) / Math.PI;
|
|
7367
|
+
}
|
|
7368
|
+
/** eye 처방(Base) → 실제 안구 편향에 쓰는 역벡터(내부 x/y, Δ). */
|
|
7369
|
+
function eyePrismEffectVectorFromPrescription(p, p_ax) {
|
|
7370
|
+
const eyeRx = prismVectorFromBase(normalizePrismAmount$2(p), normalizeAngle360$2(p_ax));
|
|
7371
|
+
return { x: -eyeRx.x, y: -eyeRx.y };
|
|
7372
|
+
}
|
|
7373
|
+
/**
|
|
7374
|
+
* eye.p / eye.p_ax(임상 Base) 및 eye.tilt(°)로부터
|
|
7375
|
+
* 안구 강체 회전 쿼터니언(Euler XYZ, z=0). 기존 configure 광선 피벗 회전과 동일.
|
|
7376
|
+
*/
|
|
7377
|
+
function eyePrismAndTiltToQuaternion(p, p_ax, tiltDeg) {
|
|
7378
|
+
const { x, y } = eyePrismEffectVectorFromPrescription(p, p_ax);
|
|
7379
|
+
const eyeRotXDeg = prismComponentToAngleDeg(x);
|
|
7380
|
+
const eyeRotYDeg = prismComponentToAngleDeg(y);
|
|
7381
|
+
const eyeEulerXDeg = eyeRotYDeg + tiltDeg.x;
|
|
7382
|
+
const eyeEulerYDeg = (-eyeRotXDeg) + tiltDeg.y;
|
|
7383
|
+
return new Quaternion().setFromEuler(new Euler((eyeEulerXDeg * Math.PI) / 180, (eyeEulerYDeg * Math.PI) / 180, 0, "XYZ"));
|
|
7384
|
+
}
|
|
7385
|
+
function eyeRotationPivot() {
|
|
7386
|
+
return new Vector3(0, 0, EYE_ROTATION_PIVOT_FROM_CORNEA_MM);
|
|
7387
|
+
}
|
|
7388
|
+
/** eye prism/tilt를 반영한 렌더용 각도(°). */
|
|
7389
|
+
function eyeRotationForRenderDegrees(p, p_ax, tiltDeg) {
|
|
7390
|
+
const { x, y } = eyePrismEffectVectorFromPrescription(p, p_ax);
|
|
7391
|
+
const eyeRotXDeg = prismComponentToAngleDeg(x);
|
|
7392
|
+
const eyeRotYDeg = prismComponentToAngleDeg(y);
|
|
7393
|
+
const eyeEulerXDeg = eyeRotYDeg + tiltDeg.x;
|
|
7394
|
+
const eyeEulerYDeg = (-eyeRotXDeg) + tiltDeg.y;
|
|
7395
|
+
const xDeg = eyeEulerYDeg;
|
|
7396
|
+
const yDeg = -eyeEulerXDeg;
|
|
7397
|
+
return {
|
|
7398
|
+
x_deg: xDeg,
|
|
7399
|
+
y_deg: yDeg,
|
|
7400
|
+
magnitude_deg: Math.hypot(xDeg, yDeg),
|
|
7401
|
+
};
|
|
7402
|
+
}
|
|
7403
|
+
/**
|
|
7404
|
+
* 안구 회전점 기준 강체 회전을 각 표면의 world `position` / `tilt`에 반영합니다.
|
|
7405
|
+
*/
|
|
7406
|
+
function applyRigidEyePoseToSurfaces(surfaces, pivot, rotation) {
|
|
7407
|
+
for (const surface of surfaces) {
|
|
7408
|
+
surface.applyRigidRotationAboutPivot(pivot, rotation);
|
|
7409
|
+
}
|
|
7305
7410
|
}
|
|
7306
7411
|
|
|
7307
7412
|
class Surface {
|
|
@@ -7323,6 +7428,118 @@ class Surface {
|
|
|
7323
7428
|
this.incidentRays = [];
|
|
7324
7429
|
this.refractedRays = [];
|
|
7325
7430
|
}
|
|
7431
|
+
getWorldPosition() {
|
|
7432
|
+
return this.position.clone();
|
|
7433
|
+
}
|
|
7434
|
+
setPositionAndTilt(position, tiltXDeg, tiltYDeg) {
|
|
7435
|
+
this.position.copy(position);
|
|
7436
|
+
this.tilt.set(tiltXDeg, tiltYDeg);
|
|
7437
|
+
}
|
|
7438
|
+
/**
|
|
7439
|
+
* 안질 tilt가 0인 표면을 전제로, pivot 기준 강체 회전 후 동일 Euler(XYZ) tilt를 부여합니다.
|
|
7440
|
+
*/
|
|
7441
|
+
applyRigidRotationAboutPivot(pivot, rotation) {
|
|
7442
|
+
const p1 = pivot.clone().add(new Vector3().subVectors(this.position, pivot).applyQuaternion(rotation));
|
|
7443
|
+
const euler = new Euler().setFromQuaternion(rotation, "XYZ");
|
|
7444
|
+
this.position.copy(p1);
|
|
7445
|
+
this.tilt.set((euler.x * 180) / Math.PI, (euler.y * 180) / Math.PI);
|
|
7446
|
+
}
|
|
7447
|
+
}
|
|
7448
|
+
|
|
7449
|
+
class ApertureStopSurface extends Surface {
|
|
7450
|
+
constructor(props) {
|
|
7451
|
+
super({
|
|
7452
|
+
type: "aperture_stop",
|
|
7453
|
+
name: props.name,
|
|
7454
|
+
position: props.position,
|
|
7455
|
+
tilt: props.tilt,
|
|
7456
|
+
});
|
|
7457
|
+
this.shape = props.shape;
|
|
7458
|
+
this.radius = Math.max(0, Number(props.radius ?? 0));
|
|
7459
|
+
this.width = Math.max(0, Number(props.width ?? 0));
|
|
7460
|
+
this.height = Math.max(0, Number(props.height ?? 0));
|
|
7461
|
+
}
|
|
7462
|
+
worldQuaternion() {
|
|
7463
|
+
const tiltXRad = (this.tilt.x * Math.PI) / 180;
|
|
7464
|
+
const tiltYRad = (this.tilt.y * Math.PI) / 180;
|
|
7465
|
+
return new Quaternion().setFromEuler(new Euler(tiltXRad, tiltYRad, 0, "XYZ"));
|
|
7466
|
+
}
|
|
7467
|
+
localPointFromWorld(worldPoint) {
|
|
7468
|
+
const inverse = this.worldQuaternion().invert();
|
|
7469
|
+
return worldPoint
|
|
7470
|
+
.clone()
|
|
7471
|
+
.sub(this.position)
|
|
7472
|
+
.applyQuaternion(inverse);
|
|
7473
|
+
}
|
|
7474
|
+
surfaceNormalWorld() {
|
|
7475
|
+
return new Vector3(0, 0, 1).applyQuaternion(this.worldQuaternion()).normalize();
|
|
7476
|
+
}
|
|
7477
|
+
intersectForward(origin, direction) {
|
|
7478
|
+
const normal = this.surfaceNormalWorld();
|
|
7479
|
+
const denom = normal.dot(direction);
|
|
7480
|
+
if (Math.abs(denom) < EPSILON)
|
|
7481
|
+
return null;
|
|
7482
|
+
const t = normal.dot(this.position.clone().sub(origin)) / denom;
|
|
7483
|
+
if (!Number.isFinite(t) || t <= 1e-6)
|
|
7484
|
+
return null;
|
|
7485
|
+
return origin.clone().addScaledVector(direction, t);
|
|
7486
|
+
}
|
|
7487
|
+
isInsideAperture(hitPointWorld) {
|
|
7488
|
+
const local = this.localPointFromWorld(hitPointWorld);
|
|
7489
|
+
if (this.shape === "circle") {
|
|
7490
|
+
if (this.radius <= 0)
|
|
7491
|
+
return false;
|
|
7492
|
+
return Math.hypot(local.x, local.y) <= this.radius + 1e-9;
|
|
7493
|
+
}
|
|
7494
|
+
if (this.width <= 0 || this.height <= 0)
|
|
7495
|
+
return false;
|
|
7496
|
+
return (Math.abs(local.x) <= (this.width / 2) + 1e-9
|
|
7497
|
+
&& Math.abs(local.y) <= (this.height / 2) + 1e-9);
|
|
7498
|
+
}
|
|
7499
|
+
incident(ray) {
|
|
7500
|
+
const origin = ray.endPoint();
|
|
7501
|
+
const direction = ray.getDirection().normalize();
|
|
7502
|
+
const hitPoint = this.intersectForward(origin, direction);
|
|
7503
|
+
if (!hitPoint)
|
|
7504
|
+
return null;
|
|
7505
|
+
if (!this.isInsideAperture(hitPoint))
|
|
7506
|
+
return null;
|
|
7507
|
+
this.incidentRays.push(ray.clone());
|
|
7508
|
+
return hitPoint;
|
|
7509
|
+
}
|
|
7510
|
+
refract(ray) {
|
|
7511
|
+
const hitPoint = this.incident(ray);
|
|
7512
|
+
if (!hitPoint)
|
|
7513
|
+
return null;
|
|
7514
|
+
const direction = ray.getDirection().normalize();
|
|
7515
|
+
const passedRay = ray.clone();
|
|
7516
|
+
passedRay.appendPoint(hitPoint);
|
|
7517
|
+
passedRay.continueFrom(hitPoint.clone().addScaledVector(direction, RAY_SURFACE_ESCAPE_MM), direction);
|
|
7518
|
+
this.refractedRays.push(passedRay.clone());
|
|
7519
|
+
return passedRay;
|
|
7520
|
+
}
|
|
7521
|
+
}
|
|
7522
|
+
|
|
7523
|
+
function isFiniteNumber(value) {
|
|
7524
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
7525
|
+
}
|
|
7526
|
+
function resolveRefractiveIndex(spec, line) {
|
|
7527
|
+
if (isFiniteNumber(spec))
|
|
7528
|
+
return spec;
|
|
7529
|
+
const lineValue = spec[line];
|
|
7530
|
+
if (isFiniteNumber(lineValue))
|
|
7531
|
+
return lineValue;
|
|
7532
|
+
const dValue = spec.d;
|
|
7533
|
+
if (isFiniteNumber(dValue))
|
|
7534
|
+
return dValue;
|
|
7535
|
+
return 1.0;
|
|
7536
|
+
}
|
|
7537
|
+
function normalizeRefractiveIndexSpec(spec) {
|
|
7538
|
+
if (!isFiniteNumber(spec))
|
|
7539
|
+
return spec;
|
|
7540
|
+
const entries = Object.values(FRAUNHOFER_REFRACTIVE_INDICES);
|
|
7541
|
+
const matched = entries.find((item) => isFiniteNumber(item.d) && Math.abs(item.d - spec) < 1e-6);
|
|
7542
|
+
return matched ?? spec;
|
|
7326
7543
|
}
|
|
7327
7544
|
|
|
7328
7545
|
class AsphericalSurface extends Surface {
|
|
@@ -7851,1083 +8068,1223 @@ class SphericalSurface extends Surface {
|
|
|
7851
8068
|
}
|
|
7852
8069
|
}
|
|
7853
8070
|
|
|
7854
|
-
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
}
|
|
7858
|
-
createSurface() {
|
|
7859
|
-
return this.parameter.surfaces.map((surface) => {
|
|
7860
|
-
if (surface.type === "spherical") {
|
|
7861
|
-
return new SphericalSurface({
|
|
7862
|
-
type: "spherical",
|
|
7863
|
-
name: surface.name,
|
|
7864
|
-
r: surface.radius,
|
|
7865
|
-
position: { x: 0, y: 0, z: surface.z },
|
|
7866
|
-
tilt: { x: 0, y: 0 },
|
|
7867
|
-
n_before: surface.n_before,
|
|
7868
|
-
n_after: surface.n_after,
|
|
7869
|
-
});
|
|
7870
|
-
}
|
|
7871
|
-
if (surface.type === "aspherical") {
|
|
7872
|
-
return new AsphericalSurface({
|
|
7873
|
-
type: "aspherical",
|
|
7874
|
-
name: surface.name,
|
|
7875
|
-
position: { x: 0, y: 0, z: surface.z },
|
|
7876
|
-
tilt: { x: 0, y: 0 },
|
|
7877
|
-
r: surface.radius,
|
|
7878
|
-
conic: surface.conic,
|
|
7879
|
-
n_before: surface.n_before,
|
|
7880
|
-
n_after: surface.n_after,
|
|
7881
|
-
});
|
|
7882
|
-
}
|
|
7883
|
-
if (surface.type === "spherical-image") {
|
|
7884
|
-
return new SphericalImageSurface({
|
|
7885
|
-
type: "spherical-image",
|
|
7886
|
-
name: surface.name,
|
|
7887
|
-
r: surface.radius,
|
|
7888
|
-
position: { x: 0, y: 0, z: surface.z },
|
|
7889
|
-
tilt: { x: 0, y: 0 },
|
|
7890
|
-
retina_extra_after: true,
|
|
7891
|
-
});
|
|
7892
|
-
}
|
|
7893
|
-
throw new Error(`Unsupported surface type: ${surface.type}`);
|
|
7894
|
-
});
|
|
7895
|
-
}
|
|
8071
|
+
function normalizePrismDiopters(value) {
|
|
8072
|
+
const p = Number(value ?? 0);
|
|
8073
|
+
return Number.isFinite(p) ? Math.max(0, p) : 0;
|
|
7896
8074
|
}
|
|
7897
|
-
|
|
7898
|
-
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
8075
|
+
function normalizeAngle360Degrees(value) {
|
|
8076
|
+
const d = Number(value ?? 0);
|
|
8077
|
+
if (!Number.isFinite(d))
|
|
8078
|
+
return 0;
|
|
8079
|
+
return ((d % 360) + 360) % 360;
|
|
7902
8080
|
}
|
|
7903
|
-
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
name: "lens_posterior",
|
|
7951
|
-
z: 7.2,
|
|
7952
|
-
radius: -6,
|
|
7953
|
-
n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_posterior,
|
|
7954
|
-
n_after: FRAUNHOFER_REFRACTIVE_INDICES.vitreous,
|
|
7955
|
-
},
|
|
7956
|
-
{
|
|
7957
|
-
type: "spherical-image",
|
|
7958
|
-
name: "retina",
|
|
7959
|
-
radius: -12, // mm (대략적인 망막 곡률)
|
|
7960
|
-
z: 24.0 // 중심 위치
|
|
7961
|
-
}
|
|
7962
|
-
],
|
|
8081
|
+
/**
|
|
8082
|
+
* 임상 프리즘 Base(Δ, 렌즈→각막 시점 °)를 내부 x/y 프리즘 벡터(Δ)로 변환합니다.
|
|
8083
|
+
* `SCAXEngineCore`와 동일한 관례입니다.
|
|
8084
|
+
*/
|
|
8085
|
+
function prismVectorFromClinicalBase(prismDiopter, baseAngleDeg) {
|
|
8086
|
+
const p = normalizePrismDiopters(prismDiopter);
|
|
8087
|
+
const baseAngle = normalizeAngle360Degrees(baseAngleDeg);
|
|
8088
|
+
const deviationAngle = normalizeAngle360Degrees(baseAngle + 180);
|
|
8089
|
+
const rad = (-deviationAngle * Math.PI) / 180;
|
|
8090
|
+
return {
|
|
8091
|
+
x: p * Math.cos(rad),
|
|
8092
|
+
y: p * Math.sin(rad),
|
|
8093
|
+
};
|
|
8094
|
+
}
|
|
8095
|
+
/**
|
|
8096
|
+
* 굴절 직후 광선에 프리즘 편향(소각 근사)을 적용합니다.
|
|
8097
|
+
*/
|
|
8098
|
+
function applyPrismVectorToRay(ray, prism) {
|
|
8099
|
+
const px = Number(prism?.x ?? 0);
|
|
8100
|
+
const py = Number(prism?.y ?? 0);
|
|
8101
|
+
if (!Number.isFinite(px)
|
|
8102
|
+
|| !Number.isFinite(py)
|
|
8103
|
+
|| (Math.abs(px) < 1e-12 && Math.abs(py) < 1e-12)) {
|
|
8104
|
+
return ray;
|
|
8105
|
+
}
|
|
8106
|
+
const direction = ray.getDirection();
|
|
8107
|
+
const dz = Number(direction.z);
|
|
8108
|
+
if (!Number.isFinite(dz) || Math.abs(dz) < 1e-12)
|
|
8109
|
+
return ray;
|
|
8110
|
+
const tx = (direction.x / dz) + (px / 100);
|
|
8111
|
+
const ty = (direction.y / dz) + (py / 100);
|
|
8112
|
+
const signZ = dz >= 0 ? 1 : -1;
|
|
8113
|
+
const newDirection = new Vector3(tx * signZ, ty * signZ, signZ).normalize();
|
|
8114
|
+
if (!Number.isFinite(newDirection.x) || !Number.isFinite(newDirection.y) || !Number.isFinite(newDirection.z)) {
|
|
8115
|
+
return ray;
|
|
8116
|
+
}
|
|
8117
|
+
const updated = ray.clone();
|
|
8118
|
+
const origin = updated.endPoint();
|
|
8119
|
+
updated.continueFrom(origin.clone().addScaledVector(newDirection, RAY_SURFACE_ESCAPE_MM), newDirection);
|
|
8120
|
+
return updated;
|
|
8121
|
+
}
|
|
8122
|
+
|
|
8123
|
+
const DegToTABO = (degree) => {
|
|
8124
|
+
const d = Number(degree);
|
|
8125
|
+
if (!Number.isFinite(d))
|
|
8126
|
+
return 0;
|
|
8127
|
+
return (((180 - d) % 180) + 180) % 180;
|
|
7963
8128
|
};
|
|
7964
8129
|
|
|
7965
|
-
class
|
|
7966
|
-
constructor() {
|
|
7967
|
-
super(
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
|
|
7975
|
-
|
|
7976
|
-
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
conic: -0.26,
|
|
7980
|
-
n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
|
|
7981
|
-
n_after: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
|
|
7982
|
-
},
|
|
7983
|
-
{
|
|
7984
|
-
type: "aspherical",
|
|
7985
|
-
name: "cornea_posterior",
|
|
7986
|
-
z: 0.55,
|
|
7987
|
-
radius: 6.5,
|
|
7988
|
-
conic: 0.0,
|
|
7989
|
-
n_before: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
|
|
7990
|
-
n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
|
|
7991
|
-
},
|
|
7992
|
-
{
|
|
7993
|
-
type: "aspherical",
|
|
7994
|
-
name: "lens_anterior",
|
|
7995
|
-
z: 0.55 + 3.05,
|
|
7996
|
-
radius: 10.2,
|
|
7997
|
-
conic: -3.13,
|
|
7998
|
-
n_before: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
|
|
7999
|
-
n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_navarro,
|
|
8000
|
-
},
|
|
8001
|
-
{
|
|
8002
|
-
type: "aspherical",
|
|
8003
|
-
name: "lens_posterior",
|
|
8004
|
-
z: 0.55 + 3.05 + 4.0,
|
|
8005
|
-
radius: -6,
|
|
8006
|
-
conic: -1,
|
|
8007
|
-
n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_navarro,
|
|
8008
|
-
n_after: FRAUNHOFER_REFRACTIVE_INDICES.vitreous,
|
|
8009
|
-
},
|
|
8010
|
-
{
|
|
8011
|
-
type: "spherical-image",
|
|
8012
|
-
name: "retina",
|
|
8013
|
-
radius: -12, // mm (대략적인 망막 곡률)
|
|
8014
|
-
z: 24.04 // 중심 위치
|
|
8015
|
-
}
|
|
8016
|
-
],
|
|
8017
|
-
};
|
|
8018
|
-
|
|
8019
|
-
/**
|
|
8020
|
-
* Sturm 계산 전용 클래스입니다.
|
|
8021
|
-
* - traced ray 집합에서 z-scan slice를 생성하고
|
|
8022
|
-
* - 평탄도/최소타원/근사중심을 계산해 분석 결과를 반환합니다.
|
|
8023
|
-
*/
|
|
8024
|
-
class Sturm {
|
|
8025
|
-
constructor() {
|
|
8026
|
-
this.lastResult = null;
|
|
8027
|
-
this.lineOrder = ["g", "F", "e", "d", "C", "r"];
|
|
8130
|
+
class ToricSurface extends Surface {
|
|
8131
|
+
constructor(props) {
|
|
8132
|
+
super({ type: "toric", name: props.name, position: props.position, tilt: props.tilt });
|
|
8133
|
+
this.r_axis = 0;
|
|
8134
|
+
this.r_perp = 0;
|
|
8135
|
+
this.n_before = 1.0;
|
|
8136
|
+
this.n_after = 1.0;
|
|
8137
|
+
this.axisDeg = 0;
|
|
8138
|
+
const { r_axis, r_perp, axis_deg = 0, n_before = 1.0, n_after = 1.0 } = props;
|
|
8139
|
+
this.r_axis = r_axis;
|
|
8140
|
+
this.r_perp = r_perp;
|
|
8141
|
+
this.axisDeg = axis_deg;
|
|
8142
|
+
this.n_before = normalizeRefractiveIndexSpec(n_before);
|
|
8143
|
+
this.n_after = normalizeRefractiveIndexSpec(n_after);
|
|
8028
8144
|
}
|
|
8029
|
-
|
|
8030
|
-
const
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
const sturmInfo = groupedByLine.map((group) => {
|
|
8035
|
-
const groupFrame = this.analysisFrameFromRays(group.rays, frame);
|
|
8036
|
-
const groupDepthRange = this.depthRangeFromRays(group.rays, groupFrame);
|
|
8037
|
-
const slices = this.collectSturmSlices(group.rays, groupFrame, groupDepthRange, DEFAULT_STURM_STEP_MM);
|
|
8038
|
-
const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD, profileWorldZBounds);
|
|
8039
|
-
return {
|
|
8040
|
-
line: group.line,
|
|
8041
|
-
wavelength_nm: group.wavelength_nm,
|
|
8042
|
-
color: group.color,
|
|
8043
|
-
ray_count: group.rays.length,
|
|
8044
|
-
analysis_axis: {
|
|
8045
|
-
x: groupFrame.axis.x,
|
|
8046
|
-
y: groupFrame.axis.y,
|
|
8047
|
-
z: groupFrame.axis.z,
|
|
8048
|
-
},
|
|
8049
|
-
...analysis,
|
|
8050
|
-
};
|
|
8051
|
-
});
|
|
8052
|
-
const result = {
|
|
8053
|
-
slices_info: {
|
|
8054
|
-
count: sturmSlices.length,
|
|
8055
|
-
slices: sturmSlices,
|
|
8056
|
-
},
|
|
8057
|
-
sturm_info: sturmInfo,
|
|
8145
|
+
refractiveIndicesForRay(ray) {
|
|
8146
|
+
const line = ray.getFraunhoferLine();
|
|
8147
|
+
return {
|
|
8148
|
+
nBefore: resolveRefractiveIndex(this.n_before, line),
|
|
8149
|
+
nAfter: resolveRefractiveIndex(this.n_after, line),
|
|
8058
8150
|
};
|
|
8059
|
-
this.lastResult = result;
|
|
8060
|
-
return result;
|
|
8061
8151
|
}
|
|
8062
8152
|
/**
|
|
8063
|
-
*
|
|
8153
|
+
* Toric 면의 축(meridian) 회전을 위해 사용하는 삼각함수 값입니다.
|
|
8154
|
+
* 축(axis) 회전은 axisDeg(도 단위)를 사용합니다.
|
|
8064
8155
|
*/
|
|
8065
|
-
|
|
8066
|
-
|
|
8156
|
+
axisTrig() {
|
|
8157
|
+
const rad = (this.axisDeg * Math.PI) / 180;
|
|
8158
|
+
return { c: Math.cos(rad), s: Math.sin(rad) };
|
|
8067
8159
|
}
|
|
8068
|
-
|
|
8069
|
-
const
|
|
8070
|
-
|
|
8160
|
+
worldQuaternion() {
|
|
8161
|
+
const tiltXRad = (this.tilt.x * Math.PI) / 180;
|
|
8162
|
+
const tiltYRad = (this.tilt.y * Math.PI) / 180;
|
|
8163
|
+
return new Quaternion().setFromEuler(new Euler(tiltXRad, tiltYRad, 0, "XYZ"));
|
|
8071
8164
|
}
|
|
8072
|
-
|
|
8073
|
-
const
|
|
8074
|
-
|
|
8075
|
-
axis.add(ray.getDirection());
|
|
8076
|
-
if (axis.lengthSq() < 1e-12) {
|
|
8077
|
-
if (fallback)
|
|
8078
|
-
return fallback;
|
|
8079
|
-
axis.set(0, 0, 1);
|
|
8080
|
-
}
|
|
8081
|
-
else {
|
|
8082
|
-
axis.normalize();
|
|
8083
|
-
}
|
|
8084
|
-
const origin = new Vector3(0, 0, 0);
|
|
8085
|
-
let helper = new Vector3(0, 1, 0);
|
|
8086
|
-
if (Math.abs(helper.dot(axis)) > 0.95)
|
|
8087
|
-
helper = new Vector3(1, 0, 0);
|
|
8088
|
-
const u = helper.clone().cross(axis).normalize();
|
|
8089
|
-
const v = axis.clone().cross(u).normalize();
|
|
8090
|
-
return { origin, axis, u, v };
|
|
8165
|
+
worldPointToLocal(worldPoint) {
|
|
8166
|
+
const inverse = this.worldQuaternion().invert();
|
|
8167
|
+
return worldPoint.clone().sub(this.position).applyQuaternion(inverse);
|
|
8091
8168
|
}
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
for (let i = 0; i < points.length - 1; i += 1) {
|
|
8095
|
-
const a = points[i];
|
|
8096
|
-
const b = points[i + 1];
|
|
8097
|
-
const da = a.clone().sub(frame.origin).dot(frame.axis);
|
|
8098
|
-
const db = b.clone().sub(frame.origin).dot(frame.axis);
|
|
8099
|
-
if ((da <= depth && depth <= db) || (db <= depth && depth <= da)) {
|
|
8100
|
-
const denom = db - da;
|
|
8101
|
-
if (Math.abs(denom) < 1e-10)
|
|
8102
|
-
return null;
|
|
8103
|
-
return a.clone().lerp(b, (depth - da) / denom);
|
|
8104
|
-
}
|
|
8105
|
-
}
|
|
8106
|
-
return null;
|
|
8169
|
+
localPointToWorld(localPoint) {
|
|
8170
|
+
return localPoint.clone().applyQuaternion(this.worldQuaternion()).add(this.position);
|
|
8107
8171
|
}
|
|
8108
|
-
|
|
8109
|
-
|
|
8110
|
-
|
|
8111
|
-
for (const ray of rays ?? []) {
|
|
8112
|
-
for (const point of this.getRayPoints(ray)) {
|
|
8113
|
-
const d = point.clone().sub(frame.origin).dot(frame.axis);
|
|
8114
|
-
depthMin = Math.min(depthMin, d);
|
|
8115
|
-
depthMax = Math.max(depthMax, d);
|
|
8116
|
-
}
|
|
8117
|
-
}
|
|
8118
|
-
if (!Number.isFinite(depthMin) || !Number.isFinite(depthMax) || depthMax <= depthMin)
|
|
8119
|
-
return null;
|
|
8120
|
-
return { depthMin, depthMax };
|
|
8172
|
+
worldDirToLocal(worldDirection) {
|
|
8173
|
+
const inverse = this.worldQuaternion().invert();
|
|
8174
|
+
return worldDirection.clone().applyQuaternion(inverse).normalize();
|
|
8121
8175
|
}
|
|
8122
|
-
|
|
8123
|
-
|
|
8124
|
-
for (const ray of rays) {
|
|
8125
|
-
const point = this.sampleRayPointAtDepth(ray, frame, depth);
|
|
8126
|
-
if (point)
|
|
8127
|
-
points.push(point);
|
|
8128
|
-
}
|
|
8129
|
-
if (points.length < 4)
|
|
8130
|
-
return null;
|
|
8131
|
-
let cxWorld = 0;
|
|
8132
|
-
let cyWorld = 0;
|
|
8133
|
-
let czWorld = 0;
|
|
8134
|
-
let cx = 0;
|
|
8135
|
-
let cy = 0;
|
|
8136
|
-
for (const p of points) {
|
|
8137
|
-
cxWorld += p.x;
|
|
8138
|
-
cyWorld += p.y;
|
|
8139
|
-
czWorld += p.z;
|
|
8140
|
-
const delta = p.clone().sub(frame.origin);
|
|
8141
|
-
cx += delta.dot(frame.u);
|
|
8142
|
-
cy += delta.dot(frame.v);
|
|
8143
|
-
}
|
|
8144
|
-
cxWorld /= points.length;
|
|
8145
|
-
cyWorld /= points.length;
|
|
8146
|
-
czWorld /= points.length;
|
|
8147
|
-
cx /= points.length;
|
|
8148
|
-
cy /= points.length;
|
|
8149
|
-
let sxx = 0;
|
|
8150
|
-
let syy = 0;
|
|
8151
|
-
let sxy = 0;
|
|
8152
|
-
for (const p of points) {
|
|
8153
|
-
const delta = p.clone().sub(frame.origin);
|
|
8154
|
-
const x = delta.dot(frame.u);
|
|
8155
|
-
const y = delta.dot(frame.v);
|
|
8156
|
-
const dx = x - cx;
|
|
8157
|
-
const dy = y - cy;
|
|
8158
|
-
sxx += dx * dx;
|
|
8159
|
-
syy += dy * dy;
|
|
8160
|
-
sxy += dx * dy;
|
|
8161
|
-
}
|
|
8162
|
-
sxx /= points.length;
|
|
8163
|
-
syy /= points.length;
|
|
8164
|
-
sxy /= points.length;
|
|
8165
|
-
const trace = sxx + syy;
|
|
8166
|
-
const halfDiff = (sxx - syy) / 2;
|
|
8167
|
-
const root = Math.sqrt(Math.max(0, halfDiff * halfDiff + sxy * sxy));
|
|
8168
|
-
const lambdaMajor = Math.max(0, trace / 2 + root);
|
|
8169
|
-
const lambdaMinor = Math.max(0, trace / 2 - root);
|
|
8170
|
-
const thetaRad = 0.5 * Math.atan2(2 * sxy, sxx - syy);
|
|
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);
|
|
8175
|
-
const majorDirection = frame.u.clone().multiplyScalar(Math.cos(thetaRad))
|
|
8176
|
-
.add(frame.v.clone().multiplyScalar(Math.sin(thetaRad)))
|
|
8177
|
-
.normalize();
|
|
8178
|
-
const minorDirection = frame.axis.clone().cross(majorDirection).normalize();
|
|
8179
|
-
return {
|
|
8180
|
-
at: { x: cxWorld, y: cyWorld, z: czWorld },
|
|
8181
|
-
wMajor: Math.sqrt(lambdaMajor),
|
|
8182
|
-
wMinor: Math.sqrt(lambdaMinor),
|
|
8183
|
-
angleMajorDeg,
|
|
8184
|
-
j0,
|
|
8185
|
-
j45,
|
|
8186
|
-
angleMinorDeg: (angleMajorDeg + 90) % 180,
|
|
8187
|
-
majorDirection: {
|
|
8188
|
-
x: majorDirection.x,
|
|
8189
|
-
y: majorDirection.y,
|
|
8190
|
-
z: majorDirection.z,
|
|
8191
|
-
},
|
|
8192
|
-
minorDirection: {
|
|
8193
|
-
x: minorDirection.x,
|
|
8194
|
-
y: minorDirection.y,
|
|
8195
|
-
z: minorDirection.z,
|
|
8196
|
-
},
|
|
8197
|
-
};
|
|
8176
|
+
localDirToWorld(localDirection) {
|
|
8177
|
+
return localDirection.clone().applyQuaternion(this.worldQuaternion()).normalize();
|
|
8198
8178
|
}
|
|
8199
|
-
|
|
8200
|
-
|
|
8201
|
-
|
|
8202
|
-
|
|
8203
|
-
|
|
8204
|
-
|
|
8205
|
-
|
|
8206
|
-
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
8210
|
-
// Preserve analysis-axis depth for off-axis robust ranking/interval logic.
|
|
8211
|
-
depth,
|
|
8212
|
-
ratio: profile.wMinor / Math.max(profile.wMajor, 1e-9),
|
|
8213
|
-
size: Math.hypot(profile.wMajor, profile.wMinor),
|
|
8214
|
-
profile,
|
|
8215
|
-
});
|
|
8216
|
-
}
|
|
8217
|
-
return out;
|
|
8179
|
+
/**
|
|
8180
|
+
* 월드 좌표계 (x, y)를 토릭 로컬 좌표계 (u, v)로 변환합니다.
|
|
8181
|
+
* - u: 축 방향 meridian
|
|
8182
|
+
* - v: 축에 수직인 meridian
|
|
8183
|
+
*/
|
|
8184
|
+
toLocalUV(x, y) {
|
|
8185
|
+
const { c, s } = this.axisTrig();
|
|
8186
|
+
return {
|
|
8187
|
+
u: c * x + s * y,
|
|
8188
|
+
v: -s * x + c * y,
|
|
8189
|
+
};
|
|
8218
8190
|
}
|
|
8219
|
-
|
|
8220
|
-
|
|
8221
|
-
|
|
8191
|
+
/**
|
|
8192
|
+
* 로컬 좌표계에서 계산한 sag 미분(dz/du, dz/dv)을
|
|
8193
|
+
* 월드 좌표계의 기울기(dz/dx, dz/dy)로 다시 매핑합니다.
|
|
8194
|
+
*/
|
|
8195
|
+
localDerivativesToWorld(dZdu, dZdv) {
|
|
8196
|
+
const { c, s } = this.axisTrig();
|
|
8197
|
+
return {
|
|
8198
|
+
dzdx: dZdu * c - dZdv * s,
|
|
8199
|
+
dzdy: dZdu * s + dZdv * c,
|
|
8200
|
+
};
|
|
8222
8201
|
}
|
|
8223
|
-
|
|
8224
|
-
|
|
8202
|
+
/**
|
|
8203
|
+
* 반경으로부터 곡률(1/R)을 계산합니다.
|
|
8204
|
+
* - 반경이 너무 크거나 무한대면 평면으로 간주하여 0 반환
|
|
8205
|
+
* - 반경이 0에 너무 가까우면 비정상 값으로 NaN 반환
|
|
8206
|
+
*/
|
|
8207
|
+
curvature(radius) {
|
|
8208
|
+
if (!Number.isFinite(radius) || Math.abs(radius) > 1e12)
|
|
8209
|
+
return 0;
|
|
8210
|
+
if (Math.abs(radius) < EPSILON)
|
|
8211
|
+
return Number.NaN;
|
|
8212
|
+
return 1 / radius;
|
|
8225
8213
|
}
|
|
8226
8214
|
/**
|
|
8227
|
-
*
|
|
8228
|
-
*
|
|
8215
|
+
* 주어진 XY에서 토릭면의 기하정보를 계산합니다.
|
|
8216
|
+
* - sag: 꼭지점 대비 z 높이
|
|
8217
|
+
* - dzdx/dzdy: 면 기울기
|
|
8218
|
+
* - normal: 2번째 매질 방향을 만들 때 사용할 기본 법선
|
|
8229
8219
|
*/
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
const
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
const
|
|
8238
|
-
const
|
|
8239
|
-
|
|
8240
|
-
|
|
8241
|
-
|
|
8242
|
-
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
|
|
8254
|
-
|
|
8255
|
-
|
|
8256
|
-
|
|
8257
|
-
|
|
8258
|
-
|
|
8259
|
-
|
|
8220
|
+
geometryAtXY(x, y) {
|
|
8221
|
+
const { u, v } = this.toLocalUV(x, y);
|
|
8222
|
+
const cu = this.curvature(this.r_axis);
|
|
8223
|
+
const cv = this.curvature(this.r_perp);
|
|
8224
|
+
if (!Number.isFinite(cu) || !Number.isFinite(cv))
|
|
8225
|
+
return null;
|
|
8226
|
+
// biconic(conic=0) sag 식
|
|
8227
|
+
const a = cu * u * u + cv * v * v;
|
|
8228
|
+
const b = 1 - cu * cu * u * u - cv * cv * v * v;
|
|
8229
|
+
if (b < -1e-6)
|
|
8230
|
+
return null; // 루트 내부가 음수면 정의역 밖
|
|
8231
|
+
const sqrtB = Math.sqrt(Math.max(0, b));
|
|
8232
|
+
const den = 1 + sqrtB;
|
|
8233
|
+
if (Math.abs(den) < EPSILON || Math.abs(sqrtB) < EPSILON)
|
|
8234
|
+
return null;
|
|
8235
|
+
const sag = a / den;
|
|
8236
|
+
// sag 미분 계산
|
|
8237
|
+
const dAdu = 2 * cu * u;
|
|
8238
|
+
const dAdv = 2 * cv * v;
|
|
8239
|
+
const dSqrtBdu = (-(cu * cu) * u) / sqrtB;
|
|
8240
|
+
const dSqrtBdv = (-(cv * cv) * v) / sqrtB;
|
|
8241
|
+
const denSq = den * den;
|
|
8242
|
+
const dZdu = (dAdu * den - a * dSqrtBdu) / denSq;
|
|
8243
|
+
const dZdv = (dAdv * den - a * dSqrtBdv) / denSq;
|
|
8244
|
+
const { dzdx, dzdy } = this.localDerivativesToWorld(dZdu, dZdv);
|
|
8245
|
+
const normal = new Vector3(-dzdx, -dzdy, 1).normalize();
|
|
8246
|
+
return { sag, dzdx, dzdy, normal };
|
|
8247
|
+
}
|
|
8248
|
+
incident(ray) {
|
|
8249
|
+
const origin = this.worldPointToLocal(ray.endPoint());
|
|
8250
|
+
const direction = this.worldDirToLocal(ray.getDirection().normalize());
|
|
8251
|
+
// 시작점이 이미 표면 위라면 재계산 없이 바로 반환합니다.
|
|
8252
|
+
const geometryAtOrigin = this.geometryAtXY(origin.x, origin.y);
|
|
8253
|
+
if (geometryAtOrigin) {
|
|
8254
|
+
const f0 = origin.z - geometryAtOrigin.sag;
|
|
8255
|
+
if (Math.abs(f0) <= TORIC_ON_SURFACE_TOL_MM) {
|
|
8256
|
+
this.incidentRays.push(ray.clone());
|
|
8257
|
+
return origin.clone();
|
|
8260
8258
|
}
|
|
8261
|
-
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
8265
|
-
|
|
8266
|
-
|
|
8267
|
-
|
|
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
|
-
}
|
|
8259
|
+
// 동일 z 근처의 연속 표면에서 앞면이 origin을 약간 밀어냈을 때를 허용합니다.
|
|
8260
|
+
if (f0 > 0
|
|
8261
|
+
&& f0 <= TORIC_COINCIDENT_SURFACE_TOL_MM
|
|
8262
|
+
&& direction.z > 0
|
|
8263
|
+
&& 0 <= origin.z + TORIC_COINCIDENT_SURFACE_TOL_MM) {
|
|
8264
|
+
this.incidentRays.push(ray.clone());
|
|
8265
|
+
return origin.clone();
|
|
8276
8266
|
}
|
|
8277
|
-
if (best && bestDot < -0.08)
|
|
8278
|
-
second = best;
|
|
8279
8267
|
}
|
|
8280
|
-
//
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
8294
|
-
|
|
8268
|
+
// z-plane 기준 초기 seed 이후 뉴턴법으로 교점 t를 수렴시킵니다.
|
|
8269
|
+
let t = Math.max(TORIC_MIN_T_MM, -origin.z);
|
|
8270
|
+
for (let i = 0; i < TORIC_MAX_ITERS; i++) {
|
|
8271
|
+
const p = origin.clone().addScaledVector(direction, t);
|
|
8272
|
+
const geometry = this.geometryAtXY(p.x, p.y);
|
|
8273
|
+
if (!geometry)
|
|
8274
|
+
return null;
|
|
8275
|
+
const f = p.z - geometry.sag;
|
|
8276
|
+
const df = direction.z - geometry.dzdx * direction.x - geometry.dzdy * direction.y;
|
|
8277
|
+
if (!Number.isFinite(df) || Math.abs(df) < EPSILON)
|
|
8278
|
+
return null;
|
|
8279
|
+
const dt = f / df;
|
|
8280
|
+
t -= dt;
|
|
8281
|
+
if (!Number.isFinite(t) || t < TORIC_MIN_T_MM)
|
|
8282
|
+
return null;
|
|
8283
|
+
if (Math.abs(dt) < 1e-8) {
|
|
8284
|
+
const hitPoint = this.localPointToWorld(origin.clone().addScaledVector(direction, t));
|
|
8285
|
+
this.incidentRays.push(ray.clone());
|
|
8286
|
+
return hitPoint;
|
|
8295
8287
|
}
|
|
8296
|
-
if (best)
|
|
8297
|
-
second = best;
|
|
8298
8288
|
}
|
|
8299
|
-
return
|
|
8289
|
+
return null;
|
|
8300
8290
|
}
|
|
8301
|
-
|
|
8302
|
-
|
|
8291
|
+
refract(ray) {
|
|
8292
|
+
const hitPoint = this.incident(ray);
|
|
8293
|
+
if (!hitPoint)
|
|
8303
8294
|
return null;
|
|
8304
|
-
|
|
8305
|
-
|
|
8306
|
-
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
|
|
8310
|
-
|
|
8311
|
-
|
|
8312
|
-
|
|
8313
|
-
}
|
|
8314
|
-
if (smallestEllipse) {
|
|
8315
|
-
return {
|
|
8316
|
-
x: smallestEllipse.profile.at.x,
|
|
8317
|
-
y: smallestEllipse.profile.at.y,
|
|
8318
|
-
z: smallestEllipse.z,
|
|
8319
|
-
mode: "min-size",
|
|
8320
|
-
};
|
|
8295
|
+
const geometry = this.geometryAtXY(hitPoint.x, hitPoint.y);
|
|
8296
|
+
if (!geometry)
|
|
8297
|
+
return null;
|
|
8298
|
+
// 스넬 굴절 벡터 계산
|
|
8299
|
+
const incidentDir = this.worldDirToLocal(ray.getDirection().normalize());
|
|
8300
|
+
const normalIntoSecond = geometry.normal.clone();
|
|
8301
|
+
// 법선을 입사방향과 같은 반공간으로 맞춰 2번째 매질 방향으로 정렬
|
|
8302
|
+
if (normalIntoSecond.dot(incidentDir) < 0) {
|
|
8303
|
+
normalIntoSecond.multiplyScalar(-1);
|
|
8321
8304
|
}
|
|
8322
|
-
const
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
const
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
8337
|
-
|
|
8338
|
-
|
|
8339
|
-
const
|
|
8340
|
-
|
|
8341
|
-
|
|
8342
|
-
|
|
8343
|
-
|
|
8344
|
-
if (!groups.has(line)) {
|
|
8345
|
-
groups.set(line, {
|
|
8346
|
-
line,
|
|
8347
|
-
wavelength_nm: wavelength,
|
|
8348
|
-
color: Number.isFinite(color) ? color : null,
|
|
8349
|
-
rays: [],
|
|
8350
|
-
});
|
|
8351
|
-
}
|
|
8352
|
-
const group = groups.get(line);
|
|
8353
|
-
if (group)
|
|
8354
|
-
group.rays.push(ray);
|
|
8355
|
-
}
|
|
8356
|
-
return [...groups.values()].sort((a, b) => this.lineOrder.indexOf(a.line) - this.lineOrder.indexOf(b.line));
|
|
8357
|
-
}
|
|
8358
|
-
analyzeSturmSlices(sturmSlices, effectiveCylinderD, profileWorldZBounds) {
|
|
8359
|
-
const top2MinGapMm = DEFAULT_STURM_TOP2_MIN_GAP_MM;
|
|
8360
|
-
const effectiveCylinderThresholdD = DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D;
|
|
8361
|
-
const preferTop2Mid = effectiveCylinderD >= effectiveCylinderThresholdD;
|
|
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
|
-
: [];
|
|
8367
|
-
let smallestEllipse = null;
|
|
8368
|
-
for (const slice of slicesForAnalysis) {
|
|
8369
|
-
if (!smallestEllipse || slice.size < smallestEllipse.size)
|
|
8370
|
-
smallestEllipse = slice;
|
|
8371
|
-
}
|
|
8372
|
-
const approxCenter = this.buildApproxCenter(flattestTop2, smallestEllipse, preferTop2Mid);
|
|
8373
|
-
const anterior = flattestTop2[0] ?? null;
|
|
8374
|
-
const posterior = preferTop2Mid ? (flattestTop2[1] ?? null) : null;
|
|
8375
|
-
return {
|
|
8376
|
-
has_astigmatism: preferTop2Mid,
|
|
8377
|
-
method: preferTop2Mid ? "sturm-interval-midpoint" : "minimum-ellipse",
|
|
8378
|
-
anterior,
|
|
8379
|
-
posterior,
|
|
8380
|
-
approx_center: approxCenter,
|
|
8381
|
-
};
|
|
8382
|
-
}
|
|
8383
|
-
}
|
|
8384
|
-
|
|
8385
|
-
class ApertureStopSurface extends Surface {
|
|
8386
|
-
constructor(props) {
|
|
8387
|
-
super({
|
|
8388
|
-
type: "aperture_stop",
|
|
8389
|
-
name: props.name,
|
|
8390
|
-
position: props.position,
|
|
8391
|
-
tilt: props.tilt,
|
|
8392
|
-
});
|
|
8393
|
-
this.shape = props.shape;
|
|
8394
|
-
this.radius = Math.max(0, Number(props.radius ?? 0));
|
|
8395
|
-
this.width = Math.max(0, Number(props.width ?? 0));
|
|
8396
|
-
this.height = Math.max(0, Number(props.height ?? 0));
|
|
8397
|
-
}
|
|
8398
|
-
worldQuaternion() {
|
|
8399
|
-
const tiltXRad = (this.tilt.x * Math.PI) / 180;
|
|
8400
|
-
const tiltYRad = (this.tilt.y * Math.PI) / 180;
|
|
8401
|
-
return new Quaternion().setFromEuler(new Euler(tiltXRad, tiltYRad, 0, "XYZ"));
|
|
8402
|
-
}
|
|
8403
|
-
localPointFromWorld(worldPoint) {
|
|
8404
|
-
const inverse = this.worldQuaternion().invert();
|
|
8405
|
-
return worldPoint
|
|
8406
|
-
.clone()
|
|
8407
|
-
.sub(this.position)
|
|
8408
|
-
.applyQuaternion(inverse);
|
|
8409
|
-
}
|
|
8410
|
-
surfaceNormalWorld() {
|
|
8411
|
-
return new Vector3(0, 0, 1).applyQuaternion(this.worldQuaternion()).normalize();
|
|
8412
|
-
}
|
|
8413
|
-
intersectForward(origin, direction) {
|
|
8414
|
-
const normal = this.surfaceNormalWorld();
|
|
8415
|
-
const denom = normal.dot(direction);
|
|
8416
|
-
if (Math.abs(denom) < EPSILON)
|
|
8417
|
-
return null;
|
|
8418
|
-
const t = normal.dot(this.position.clone().sub(origin)) / denom;
|
|
8419
|
-
if (!Number.isFinite(t) || t <= 1e-6)
|
|
8420
|
-
return null;
|
|
8421
|
-
return origin.clone().addScaledVector(direction, t);
|
|
8422
|
-
}
|
|
8423
|
-
isInsideAperture(hitPointWorld) {
|
|
8424
|
-
const local = this.localPointFromWorld(hitPointWorld);
|
|
8425
|
-
if (this.shape === "circle") {
|
|
8426
|
-
if (this.radius <= 0)
|
|
8427
|
-
return false;
|
|
8428
|
-
return Math.hypot(local.x, local.y) <= this.radius + 1e-9;
|
|
8429
|
-
}
|
|
8430
|
-
if (this.width <= 0 || this.height <= 0)
|
|
8431
|
-
return false;
|
|
8432
|
-
return (Math.abs(local.x) <= (this.width / 2) + 1e-9
|
|
8433
|
-
&& Math.abs(local.y) <= (this.height / 2) + 1e-9);
|
|
8434
|
-
}
|
|
8435
|
-
incident(ray) {
|
|
8436
|
-
const origin = ray.endPoint();
|
|
8437
|
-
const direction = ray.getDirection().normalize();
|
|
8438
|
-
const hitPoint = this.intersectForward(origin, direction);
|
|
8439
|
-
if (!hitPoint)
|
|
8440
|
-
return null;
|
|
8441
|
-
if (!this.isInsideAperture(hitPoint))
|
|
8442
|
-
return null;
|
|
8443
|
-
this.incidentRays.push(ray.clone());
|
|
8444
|
-
return hitPoint;
|
|
8445
|
-
}
|
|
8446
|
-
refract(ray) {
|
|
8447
|
-
const hitPoint = this.incident(ray);
|
|
8448
|
-
if (!hitPoint)
|
|
8449
|
-
return null;
|
|
8450
|
-
const direction = ray.getDirection().normalize();
|
|
8451
|
-
const passedRay = ray.clone();
|
|
8452
|
-
passedRay.appendPoint(hitPoint);
|
|
8453
|
-
passedRay.continueFrom(hitPoint.clone().addScaledVector(direction, RAY_SURFACE_ESCAPE_MM), direction);
|
|
8454
|
-
this.refractedRays.push(passedRay.clone());
|
|
8455
|
-
return passedRay;
|
|
8305
|
+
const cos1 = Math.max(-1, Math.min(1, normalIntoSecond.dot(incidentDir)));
|
|
8306
|
+
const sin1Sq = Math.max(0, 1 - cos1 * cos1);
|
|
8307
|
+
const { nBefore, nAfter } = this.refractiveIndicesForRay(ray);
|
|
8308
|
+
const sin2 = (nBefore / nAfter) * Math.sqrt(sin1Sq);
|
|
8309
|
+
// 전반사(TIR)
|
|
8310
|
+
if (sin2 > 1 + 1e-10)
|
|
8311
|
+
return null;
|
|
8312
|
+
const cos2 = Math.sqrt(Math.max(0, 1 - sin2 * sin2));
|
|
8313
|
+
const tangent = incidentDir.clone().sub(normalIntoSecond.clone().multiplyScalar(cos1));
|
|
8314
|
+
const outDirectionLocal = tangent.lengthSq() < 1e-12
|
|
8315
|
+
? incidentDir.clone()
|
|
8316
|
+
: normalIntoSecond
|
|
8317
|
+
.clone()
|
|
8318
|
+
.multiplyScalar(cos2)
|
|
8319
|
+
.add(tangent.normalize().multiplyScalar(sin2))
|
|
8320
|
+
.normalize();
|
|
8321
|
+
const outDirection = this.localDirToWorld(outDirectionLocal);
|
|
8322
|
+
const refractedRay = ray.clone();
|
|
8323
|
+
refractedRay.appendPoint(hitPoint);
|
|
8324
|
+
refractedRay.continueFrom(hitPoint.clone().addScaledVector(outDirection, RAY_SURFACE_ESCAPE_MM), outDirection);
|
|
8325
|
+
this.refractedRays.push(refractedRay.clone());
|
|
8326
|
+
return refractedRay;
|
|
8456
8327
|
}
|
|
8457
8328
|
}
|
|
8458
8329
|
|
|
8459
|
-
|
|
8460
|
-
const d = Number(degree);
|
|
8461
|
-
if (!Number.isFinite(d))
|
|
8462
|
-
return 0;
|
|
8463
|
-
return (((180 - d) % 180) + 180) % 180;
|
|
8464
|
-
};
|
|
8465
|
-
|
|
8466
|
-
class ToricSurface extends Surface {
|
|
8330
|
+
class STSurface extends Surface {
|
|
8467
8331
|
constructor(props) {
|
|
8468
|
-
super({ type: "
|
|
8469
|
-
this.
|
|
8470
|
-
this.
|
|
8332
|
+
super({ type: "compound", name: props.name, position: props.position, tilt: props.tilt });
|
|
8333
|
+
this.s = 0;
|
|
8334
|
+
this.c = 0;
|
|
8335
|
+
this.ax = 0;
|
|
8471
8336
|
this.n_before = 1.0;
|
|
8337
|
+
this.n = 1.0;
|
|
8472
8338
|
this.n_after = 1.0;
|
|
8473
|
-
this.
|
|
8474
|
-
|
|
8475
|
-
this.
|
|
8476
|
-
|
|
8477
|
-
this.
|
|
8339
|
+
this.thickness = 0;
|
|
8340
|
+
this.sphericalRadiusMm = Number.POSITIVE_INFINITY;
|
|
8341
|
+
this.toricRadiusPerpMm = Number.POSITIVE_INFINITY;
|
|
8342
|
+
const { s, c, ax, n_before = 1.0, n = 1.0, n_after = n_before, referencePoint, thickness = ST_DEFAULT_THICKNESS_MM, } = props;
|
|
8343
|
+
this.s = s;
|
|
8344
|
+
this.c = c;
|
|
8345
|
+
// paraxial-surface와 동일: 처방 축은 TABO(°), 굴절/기하에는 수학 좌표 각(0~180)로 변환해 저장한다.
|
|
8346
|
+
this.ax = DegToTABO(ax);
|
|
8478
8347
|
this.n_before = normalizeRefractiveIndexSpec(n_before);
|
|
8348
|
+
this.n = normalizeRefractiveIndexSpec(n);
|
|
8479
8349
|
this.n_after = normalizeRefractiveIndexSpec(n_after);
|
|
8350
|
+
const hasToric = Math.abs(this.c) >= ST_POWER_EPS_D;
|
|
8351
|
+
// Radius must be computed with the same media pair that each sub-surface actually uses.
|
|
8352
|
+
// - toric: n_before -> n
|
|
8353
|
+
// - spherical: n -> n_after when toric exists, else n_before -> n
|
|
8354
|
+
this.toricRadiusPerpMm = this.radiusFromPower(this.c, this.refractiveIndexAtD(this.n_before), this.refractiveIndexAtD(this.n));
|
|
8355
|
+
this.sphericalRadiusMm = this.radiusFromPower(this.s, hasToric ? this.refractiveIndexAtD(this.n) : this.refractiveIndexAtD(this.n_before), hasToric ? this.refractiveIndexAtD(this.n_after) : this.refractiveIndexAtD(this.n));
|
|
8356
|
+
const requestedThickness = Math.max(0, thickness);
|
|
8357
|
+
this.thickness = requestedThickness === 0
|
|
8358
|
+
? this.optimizeThickness(0)
|
|
8359
|
+
: requestedThickness;
|
|
8360
|
+
this.position.z = this.optimizeToricVertexZFromReference(this.position.z, referencePoint?.z, this.thickness);
|
|
8361
|
+
// 복합면: 작은 z에 토릭, 큰 z에 구면(+z 광축 기준 토릭 → 구면 순).
|
|
8362
|
+
this.sphericalSurface = this.buildSphericalSurface();
|
|
8363
|
+
this.toricSurface = this.buildToricSurface();
|
|
8364
|
+
this.prismVector = prismVectorFromClinicalBase(props.p, props.p_ax);
|
|
8480
8365
|
}
|
|
8481
|
-
|
|
8482
|
-
const
|
|
8483
|
-
|
|
8484
|
-
|
|
8485
|
-
nAfter: resolveRefractiveIndex(this.n_after, line),
|
|
8486
|
-
};
|
|
8366
|
+
pushRefractedWithPrism(ray) {
|
|
8367
|
+
const out = applyPrismVectorToRay(ray, this.prismVector);
|
|
8368
|
+
this.refractedRays.push(out.clone());
|
|
8369
|
+
return out;
|
|
8487
8370
|
}
|
|
8488
8371
|
/**
|
|
8489
|
-
*
|
|
8490
|
-
*
|
|
8372
|
+
* 디옵터(D)로부터 반경(mm)을 계산합니다.
|
|
8373
|
+
*
|
|
8374
|
+
* power(D) = (n2 - n1) / R(m)
|
|
8375
|
+
* -> R(mm) = 1000 * (n2 - n1) / power(D)
|
|
8376
|
+
*
|
|
8377
|
+
* 굴절력이 사실상 0이면 평면으로 간주하기 위해 +Infinity를 반환합니다.
|
|
8491
8378
|
*/
|
|
8492
|
-
|
|
8493
|
-
|
|
8494
|
-
|
|
8495
|
-
|
|
8496
|
-
|
|
8497
|
-
|
|
8498
|
-
|
|
8499
|
-
|
|
8500
|
-
|
|
8501
|
-
worldPointToLocal(worldPoint) {
|
|
8502
|
-
const inverse = this.worldQuaternion().invert();
|
|
8503
|
-
return worldPoint.clone().sub(this.position).applyQuaternion(inverse);
|
|
8504
|
-
}
|
|
8505
|
-
localPointToWorld(localPoint) {
|
|
8506
|
-
return localPoint.clone().applyQuaternion(this.worldQuaternion()).add(this.position);
|
|
8507
|
-
}
|
|
8508
|
-
worldDirToLocal(worldDirection) {
|
|
8509
|
-
const inverse = this.worldQuaternion().invert();
|
|
8510
|
-
return worldDirection.clone().applyQuaternion(inverse).normalize();
|
|
8511
|
-
}
|
|
8512
|
-
localDirToWorld(localDirection) {
|
|
8513
|
-
return localDirection.clone().applyQuaternion(this.worldQuaternion()).normalize();
|
|
8379
|
+
radiusFromPower(powerD, nBefore, nAfter) {
|
|
8380
|
+
if (!Number.isFinite(powerD)
|
|
8381
|
+
|| !Number.isFinite(nBefore)
|
|
8382
|
+
|| !Number.isFinite(nAfter)) {
|
|
8383
|
+
return Number.NaN;
|
|
8384
|
+
}
|
|
8385
|
+
if (Math.abs(powerD) < ST_POWER_EPS_D)
|
|
8386
|
+
return Number.POSITIVE_INFINITY;
|
|
8387
|
+
return (1000 * (nAfter - nBefore)) / powerD;
|
|
8514
8388
|
}
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
* - u: 축 방향 meridian
|
|
8518
|
-
* - v: 축에 수직인 meridian
|
|
8519
|
-
*/
|
|
8520
|
-
toLocalUV(x, y) {
|
|
8521
|
-
const { c, s } = this.axisTrig();
|
|
8522
|
-
return {
|
|
8523
|
-
u: c * x + s * y,
|
|
8524
|
-
v: -s * x + c * y,
|
|
8525
|
-
};
|
|
8389
|
+
refractiveIndexAtD(spec) {
|
|
8390
|
+
return resolveRefractiveIndex(spec, "d");
|
|
8526
8391
|
}
|
|
8527
8392
|
/**
|
|
8528
|
-
*
|
|
8529
|
-
* 월드 좌표계의 기울기(dz/dx, dz/dy)로 다시 매핑합니다.
|
|
8393
|
+
* ST 구면 측: z가 더 큰 꼭지( +z 진행 시 출사 쪽 ).
|
|
8530
8394
|
*/
|
|
8531
|
-
|
|
8532
|
-
const
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8395
|
+
buildSphericalSurface() {
|
|
8396
|
+
const sphericalZ = this.position.z + this.thickness;
|
|
8397
|
+
const hasToric = Math.abs(this.c) >= ST_POWER_EPS_D;
|
|
8398
|
+
const nBefore = hasToric ? this.n : this.n_before;
|
|
8399
|
+
const nAfter = hasToric ? this.n_after : this.n;
|
|
8400
|
+
const props = {
|
|
8401
|
+
type: "spherical",
|
|
8402
|
+
name: `${this.name}_spherical`,
|
|
8403
|
+
position: { x: this.position.x, y: this.position.y, z: sphericalZ },
|
|
8404
|
+
tilt: { x: this.tilt.x, y: this.tilt.y },
|
|
8405
|
+
r: this.sphericalRadiusMm,
|
|
8406
|
+
n_before: nBefore,
|
|
8407
|
+
n_after: nAfter,
|
|
8536
8408
|
};
|
|
8409
|
+
return new SphericalSurface(props);
|
|
8537
8410
|
}
|
|
8538
8411
|
/**
|
|
8539
|
-
*
|
|
8540
|
-
* - 반경이 너무 크거나 무한대면 평면으로 간주하여 0 반환
|
|
8541
|
-
* - 반경이 0에 너무 가까우면 비정상 값으로 NaN 반환
|
|
8412
|
+
* ST 토릭 측: cylinder가 있을 때만. z가 더 작은 꼭지( +z 진행 시 입사 쪽 ).
|
|
8542
8413
|
*/
|
|
8543
|
-
|
|
8544
|
-
if (
|
|
8545
|
-
return
|
|
8546
|
-
|
|
8414
|
+
buildToricSurface() {
|
|
8415
|
+
if (Math.abs(this.c) < ST_POWER_EPS_D)
|
|
8416
|
+
return null;
|
|
8417
|
+
const props = {
|
|
8418
|
+
type: "toric",
|
|
8419
|
+
name: `${this.name}_toric`,
|
|
8420
|
+
position: {
|
|
8421
|
+
x: this.position.x,
|
|
8422
|
+
y: this.position.y,
|
|
8423
|
+
z: this.position.z,
|
|
8424
|
+
},
|
|
8425
|
+
tilt: { x: this.tilt.x, y: this.tilt.y },
|
|
8426
|
+
r_axis: Number.POSITIVE_INFINITY,
|
|
8427
|
+
r_perp: this.toricRadiusPerpMm,
|
|
8428
|
+
axis_deg: this.ax,
|
|
8429
|
+
n_before: this.n_before,
|
|
8430
|
+
n_after: this.n,
|
|
8431
|
+
};
|
|
8432
|
+
return new ToricSurface(props);
|
|
8433
|
+
}
|
|
8434
|
+
applyChromaticIndicesToSubSurfaces(ray) {
|
|
8435
|
+
const line = ray.getFraunhoferLine();
|
|
8436
|
+
const nBefore = resolveRefractiveIndex(this.n_before, line);
|
|
8437
|
+
const n = resolveRefractiveIndex(this.n, line);
|
|
8438
|
+
const nAfter = resolveRefractiveIndex(this.n_after, line);
|
|
8439
|
+
const sphericalState = this.sphericalSurface;
|
|
8440
|
+
if (this.toricSurface) {
|
|
8441
|
+
sphericalState.n_before = n;
|
|
8442
|
+
sphericalState.n_after = nAfter;
|
|
8443
|
+
const toricState = this.toricSurface;
|
|
8444
|
+
toricState.n_before = nBefore;
|
|
8445
|
+
toricState.n_after = n;
|
|
8446
|
+
return;
|
|
8447
|
+
}
|
|
8448
|
+
sphericalState.n_before = nBefore;
|
|
8449
|
+
sphericalState.n_after = n;
|
|
8450
|
+
}
|
|
8451
|
+
/**
|
|
8452
|
+
* 구면/토릭 곡면의 z 교차(토릭이 구면을 관통) 방지를 위해
|
|
8453
|
+
* 샘플링 영역에서 필요한 최소 중심두께를 계산합니다.
|
|
8454
|
+
*/
|
|
8455
|
+
optimizeThickness(requestedThickness) {
|
|
8456
|
+
if (Math.abs(this.c) < ST_POWER_EPS_D)
|
|
8457
|
+
return requestedThickness;
|
|
8458
|
+
const sampleRadius = this.samplingRadiusMm();
|
|
8459
|
+
if (!Number.isFinite(sampleRadius) || sampleRadius <= 0)
|
|
8460
|
+
return requestedThickness;
|
|
8461
|
+
const samplesPerAxis = 49;
|
|
8462
|
+
let requiredThickness = requestedThickness;
|
|
8463
|
+
const safetyMargin = Math.max(0.05, 2 * RAY_SURFACE_ESCAPE_MM);
|
|
8464
|
+
for (let iy = 0; iy < samplesPerAxis; iy++) {
|
|
8465
|
+
const y = -sampleRadius + (2 * sampleRadius * iy) / (samplesPerAxis - 1);
|
|
8466
|
+
for (let ix = 0; ix < samplesPerAxis; ix++) {
|
|
8467
|
+
const x = -sampleRadius + (2 * sampleRadius * ix) / (samplesPerAxis - 1);
|
|
8468
|
+
if ((x * x + y * y) > sampleRadius * sampleRadius)
|
|
8469
|
+
continue;
|
|
8470
|
+
const sphericalSag = this.sphericalSagAtXY(x, y);
|
|
8471
|
+
const toricSag = this.toricSagAtXY(x, y);
|
|
8472
|
+
if (!Number.isFinite(sphericalSag) || !Number.isFinite(toricSag))
|
|
8473
|
+
continue;
|
|
8474
|
+
const localRequired = (sphericalSag - toricSag) + safetyMargin;
|
|
8475
|
+
if (localRequired > requiredThickness)
|
|
8476
|
+
requiredThickness = localRequired;
|
|
8477
|
+
}
|
|
8478
|
+
}
|
|
8479
|
+
return Math.max(0, requiredThickness);
|
|
8480
|
+
}
|
|
8481
|
+
/**
|
|
8482
|
+
* 기준점(referencePoint.z)으로부터 토릭 꼭지(z)까지의 최소 간격을 확보합니다.
|
|
8483
|
+
* - 기준점과 반대 방향으로 현재 토릭 꼭지가 놓인 쪽(sign)을 유지합니다.
|
|
8484
|
+
* - 최소 간격은 "토릭–구면 거리(thickness) + 안전여유"입니다.
|
|
8485
|
+
*/
|
|
8486
|
+
optimizeToricVertexZFromReference(toricVertexZ, referenceZ, thicknessMm = this.thickness) {
|
|
8487
|
+
if (!Number.isFinite(referenceZ))
|
|
8488
|
+
return toricVertexZ;
|
|
8489
|
+
const safetyMargin = Math.max(0.05, 2 * RAY_SURFACE_ESCAPE_MM);
|
|
8490
|
+
const minGap = Math.max(EYE_ST_SURFACE_OFFSET_MM, Math.max(0, thicknessMm) + safetyMargin);
|
|
8491
|
+
const delta = toricVertexZ - referenceZ;
|
|
8492
|
+
const side = Math.abs(delta) < 1e-12 ? -1 : Math.sign(delta);
|
|
8493
|
+
const currentGap = Math.abs(delta);
|
|
8494
|
+
if (currentGap >= minGap)
|
|
8495
|
+
return toricVertexZ;
|
|
8496
|
+
return referenceZ + side * minGap;
|
|
8497
|
+
}
|
|
8498
|
+
samplingRadiusMm() {
|
|
8499
|
+
const defaultRadius = 12;
|
|
8500
|
+
const finiteRadii = [this.sphericalRadiusMm, this.toricRadiusPerpMm]
|
|
8501
|
+
.filter((r) => Number.isFinite(r) && Math.abs(r) > 1e-6)
|
|
8502
|
+
.map((r) => Math.abs(r));
|
|
8503
|
+
if (!finiteRadii.length)
|
|
8504
|
+
return defaultRadius;
|
|
8505
|
+
return Math.max(1.0, Math.min(defaultRadius, 0.98 * Math.min(...finiteRadii)));
|
|
8506
|
+
}
|
|
8507
|
+
/**
|
|
8508
|
+
* 구면 측 꼭지점 기준 sag(mm)
|
|
8509
|
+
*/
|
|
8510
|
+
sphericalSagAtXY(x, y) {
|
|
8511
|
+
const rhoSq = x * x + y * y;
|
|
8512
|
+
const r = this.sphericalRadiusMm;
|
|
8513
|
+
if (!Number.isFinite(r) || Math.abs(r) > 1e12)
|
|
8514
|
+
return 0;
|
|
8515
|
+
const rr = r * r;
|
|
8516
|
+
if (rhoSq > rr)
|
|
8547
8517
|
return Number.NaN;
|
|
8548
|
-
|
|
8518
|
+
const root = Math.sqrt(Math.max(0, rr - rhoSq));
|
|
8519
|
+
return r > 0 ? r - root : r + root;
|
|
8549
8520
|
}
|
|
8550
8521
|
/**
|
|
8551
|
-
*
|
|
8552
|
-
* - sag: 꼭지점 대비 z 높이
|
|
8553
|
-
* - dzdx/dzdy: 면 기울기
|
|
8554
|
-
* - normal: 2번째 매질 방향을 만들 때 사용할 기본 법선
|
|
8522
|
+
* 토릭 측 꼭지점 기준 sag(mm)
|
|
8555
8523
|
*/
|
|
8556
|
-
|
|
8557
|
-
const
|
|
8558
|
-
const
|
|
8559
|
-
const
|
|
8560
|
-
|
|
8561
|
-
|
|
8562
|
-
|
|
8524
|
+
toricSagAtXY(x, y) {
|
|
8525
|
+
const axisRad = (this.ax * Math.PI) / 180;
|
|
8526
|
+
const cAxis = Math.cos(axisRad);
|
|
8527
|
+
const sAxis = Math.sin(axisRad);
|
|
8528
|
+
const u = cAxis * x + sAxis * y;
|
|
8529
|
+
const v = -sAxis * x + cAxis * y;
|
|
8530
|
+
const cu = 0; // r_axis = Infinity
|
|
8531
|
+
const cv = (!Number.isFinite(this.toricRadiusPerpMm) || Math.abs(this.toricRadiusPerpMm) > 1e12)
|
|
8532
|
+
? 0
|
|
8533
|
+
: 1 / this.toricRadiusPerpMm;
|
|
8563
8534
|
const a = cu * u * u + cv * v * v;
|
|
8564
8535
|
const b = 1 - cu * cu * u * u - cv * cv * v * v;
|
|
8565
|
-
if (b <
|
|
8566
|
-
return
|
|
8567
|
-
const
|
|
8568
|
-
|
|
8569
|
-
|
|
8570
|
-
|
|
8571
|
-
const sag = a / den;
|
|
8572
|
-
// sag 미분 계산
|
|
8573
|
-
const dAdu = 2 * cu * u;
|
|
8574
|
-
const dAdv = 2 * cv * v;
|
|
8575
|
-
const dSqrtBdu = (-(cu * cu) * u) / sqrtB;
|
|
8576
|
-
const dSqrtBdv = (-(cv * cv) * v) / sqrtB;
|
|
8577
|
-
const denSq = den * den;
|
|
8578
|
-
const dZdu = (dAdu * den - a * dSqrtBdu) / denSq;
|
|
8579
|
-
const dZdv = (dAdv * den - a * dSqrtBdv) / denSq;
|
|
8580
|
-
const { dzdx, dzdy } = this.localDerivativesToWorld(dZdu, dZdv);
|
|
8581
|
-
const normal = new Vector3(-dzdx, -dzdy, 1).normalize();
|
|
8582
|
-
return { sag, dzdx, dzdy, normal };
|
|
8536
|
+
if (b < 0)
|
|
8537
|
+
return Number.NaN;
|
|
8538
|
+
const den = 1 + Math.sqrt(Math.max(0, b));
|
|
8539
|
+
if (Math.abs(den) < 1e-12)
|
|
8540
|
+
return Number.NaN;
|
|
8541
|
+
return a / den;
|
|
8583
8542
|
}
|
|
8584
|
-
|
|
8585
|
-
|
|
8586
|
-
|
|
8587
|
-
|
|
8588
|
-
|
|
8589
|
-
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
&& f0 <= TORIC_COINCIDENT_SURFACE_TOL_MM
|
|
8598
|
-
&& direction.z > 0
|
|
8599
|
-
&& 0 <= origin.z + TORIC_COINCIDENT_SURFACE_TOL_MM) {
|
|
8600
|
-
this.incidentRays.push(ray.clone());
|
|
8601
|
-
return origin.clone();
|
|
8543
|
+
isOpticallyNeutral() {
|
|
8544
|
+
return (Math.abs(Number(this.s) || 0) < STSurface.POWER_EPS_D
|
|
8545
|
+
&& Math.abs(Number(this.c) || 0) < STSurface.POWER_EPS_D);
|
|
8546
|
+
}
|
|
8547
|
+
refract(ray) {
|
|
8548
|
+
// 무도수 ST면은 기하(면 위치/경사)는 유지하되 굴절력은 0으로 취급하여 직진 통과시킵니다.
|
|
8549
|
+
if (this.isOpticallyNeutral()) {
|
|
8550
|
+
const direction = ray.getDirection().normalize();
|
|
8551
|
+
const passthrough = ray.clone();
|
|
8552
|
+
const hitPoint = this.sphericalSurface.incident(ray);
|
|
8553
|
+
if (hitPoint) {
|
|
8554
|
+
passthrough.appendPoint(hitPoint);
|
|
8555
|
+
passthrough.continueFrom(hitPoint.clone().addScaledVector(direction, RAY_SURFACE_ESCAPE_MM), direction);
|
|
8602
8556
|
}
|
|
8557
|
+
return this.pushRefractedWithPrism(passthrough);
|
|
8603
8558
|
}
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
const
|
|
8608
|
-
|
|
8609
|
-
if (!geometry)
|
|
8610
|
-
return null;
|
|
8611
|
-
const f = p.z - geometry.sag;
|
|
8612
|
-
const df = direction.z - geometry.dzdx * direction.x - geometry.dzdy * direction.y;
|
|
8613
|
-
if (!Number.isFinite(df) || Math.abs(df) < EPSILON)
|
|
8614
|
-
return null;
|
|
8615
|
-
const dt = f / df;
|
|
8616
|
-
t -= dt;
|
|
8617
|
-
if (!Number.isFinite(t) || t < TORIC_MIN_T_MM)
|
|
8559
|
+
this.applyChromaticIndicesToSubSurfaces(ray);
|
|
8560
|
+
// 원통 성분이 없으면 단일(구면)면으로 처리합니다.
|
|
8561
|
+
if (!this.toricSurface) {
|
|
8562
|
+
const single = this.sphericalSurface.refract(ray);
|
|
8563
|
+
if (!single)
|
|
8618
8564
|
return null;
|
|
8619
|
-
|
|
8620
|
-
const hitPoint = this.localPointToWorld(origin.clone().addScaledVector(direction, t));
|
|
8621
|
-
this.incidentRays.push(ray.clone());
|
|
8622
|
-
return hitPoint;
|
|
8623
|
-
}
|
|
8565
|
+
return this.pushRefractedWithPrism(single);
|
|
8624
8566
|
}
|
|
8625
|
-
|
|
8626
|
-
|
|
8627
|
-
|
|
8628
|
-
const hitPoint = this.incident(ray);
|
|
8629
|
-
if (!hitPoint)
|
|
8567
|
+
// +z 진행: 토릭(작은 z) → 구면(큰 z)
|
|
8568
|
+
const afterToric = this.toricSurface.refract(ray);
|
|
8569
|
+
if (!afterToric)
|
|
8630
8570
|
return null;
|
|
8631
|
-
const
|
|
8632
|
-
if (!
|
|
8571
|
+
const afterSpherical = this.sphericalSurface.refract(afterToric);
|
|
8572
|
+
if (!afterSpherical)
|
|
8633
8573
|
return null;
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
//
|
|
8638
|
-
|
|
8639
|
-
|
|
8640
|
-
|
|
8641
|
-
const cos1 = Math.max(-1, Math.min(1, normalIntoSecond.dot(incidentDir)));
|
|
8642
|
-
const sin1Sq = Math.max(0, 1 - cos1 * cos1);
|
|
8643
|
-
const { nBefore, nAfter } = this.refractiveIndicesForRay(ray);
|
|
8644
|
-
const sin2 = (nBefore / nAfter) * Math.sqrt(sin1Sq);
|
|
8645
|
-
// 전반사(TIR)
|
|
8646
|
-
if (sin2 > 1 + 1e-10)
|
|
8574
|
+
return this.pushRefractedWithPrism(afterSpherical);
|
|
8575
|
+
}
|
|
8576
|
+
incident(ray) {
|
|
8577
|
+
// 복합면의 첫 hit: +z 기준 토릭이 앞서므로 toricSurface 우선.
|
|
8578
|
+
const primary = this.toricSurface ?? this.sphericalSurface;
|
|
8579
|
+
const hitPoint = primary.incident(ray);
|
|
8580
|
+
if (!hitPoint)
|
|
8647
8581
|
return null;
|
|
8648
|
-
|
|
8649
|
-
|
|
8650
|
-
const outDirectionLocal = tangent.lengthSq() < 1e-12
|
|
8651
|
-
? incidentDir.clone()
|
|
8652
|
-
: normalIntoSecond
|
|
8653
|
-
.clone()
|
|
8654
|
-
.multiplyScalar(cos2)
|
|
8655
|
-
.add(tangent.normalize().multiplyScalar(sin2))
|
|
8656
|
-
.normalize();
|
|
8657
|
-
const outDirection = this.localDirToWorld(outDirectionLocal);
|
|
8658
|
-
const refractedRay = ray.clone();
|
|
8659
|
-
refractedRay.appendPoint(hitPoint);
|
|
8660
|
-
refractedRay.continueFrom(hitPoint.clone().addScaledVector(outDirection, RAY_SURFACE_ESCAPE_MM), outDirection);
|
|
8661
|
-
this.refractedRays.push(refractedRay.clone());
|
|
8662
|
-
return refractedRay;
|
|
8582
|
+
this.incidentRays.push(ray.clone());
|
|
8583
|
+
return hitPoint;
|
|
8663
8584
|
}
|
|
8664
|
-
|
|
8585
|
+
getOptimizedThicknessMm() {
|
|
8586
|
+
return this.thickness;
|
|
8587
|
+
}
|
|
8588
|
+
applyRigidRotationAboutPivot(pivot, rotation) {
|
|
8589
|
+
const move = (p0) => pivot.clone().add(new Vector3().subVectors(p0, pivot).applyQuaternion(rotation));
|
|
8590
|
+
const euler = new Euler().setFromQuaternion(rotation, "XYZ");
|
|
8591
|
+
const tx = (euler.x * 180) / Math.PI;
|
|
8592
|
+
const ty = (euler.y * 180) / Math.PI;
|
|
8593
|
+
const newTor = move(this.position.clone());
|
|
8594
|
+
const newSph = move(this.sphericalSurface.getWorldPosition());
|
|
8595
|
+
this.setPositionAndTilt(newTor, tx, ty);
|
|
8596
|
+
this.sphericalSurface.setPositionAndTilt(newSph, tx, ty);
|
|
8597
|
+
if (this.toricSurface) {
|
|
8598
|
+
this.toricSurface.setPositionAndTilt(newTor.clone(), tx, ty);
|
|
8599
|
+
}
|
|
8600
|
+
}
|
|
8601
|
+
}
|
|
8602
|
+
STSurface.POWER_EPS_D = ST_POWER_EPS_D;
|
|
8665
8603
|
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
8680
|
-
|
|
8681
|
-
|
|
8682
|
-
this.
|
|
8683
|
-
this.n_before = normalizeRefractiveIndexSpec(n_before);
|
|
8684
|
-
this.n = normalizeRefractiveIndexSpec(n);
|
|
8685
|
-
this.n_after = normalizeRefractiveIndexSpec(n_after);
|
|
8686
|
-
const hasToric = Math.abs(this.c) >= ST_POWER_EPS_D;
|
|
8687
|
-
// Radius must be computed with the same media pair that each sub-surface actually uses.
|
|
8688
|
-
// - toric: n_before -> n
|
|
8689
|
-
// - spherical: n -> n_after when toric exists, else n_before -> n
|
|
8690
|
-
this.toricRadiusPerpMm = this.radiusFromPower(this.c, this.refractiveIndexAtD(this.n_before), this.refractiveIndexAtD(this.n));
|
|
8691
|
-
this.sphericalRadiusMm = this.radiusFromPower(this.s, hasToric ? this.refractiveIndexAtD(this.n) : this.refractiveIndexAtD(this.n_before), hasToric ? this.refractiveIndexAtD(this.n_after) : this.refractiveIndexAtD(this.n));
|
|
8692
|
-
const requestedThickness = Math.max(0, thickness);
|
|
8693
|
-
this.thickness = requestedThickness === 0
|
|
8694
|
-
? this.optimizeThickness(0)
|
|
8695
|
-
: requestedThickness;
|
|
8696
|
-
this.position.z = this.optimizeToricVertexZFromReference(this.position.z, referencePoint?.z, this.thickness);
|
|
8697
|
-
// 복합면: 작은 z에 토릭, 큰 z에 구면(+z 광축 기준 토릭 → 구면 순).
|
|
8698
|
-
this.sphericalSurface = this.buildSphericalSurface();
|
|
8699
|
-
this.toricSurface = this.buildToricSurface();
|
|
8604
|
+
function toFiniteNumber$1(value, fallback = 0) {
|
|
8605
|
+
const parsed = Number(value);
|
|
8606
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
8607
|
+
}
|
|
8608
|
+
function normalizePrismAmount$1(value) {
|
|
8609
|
+
const p = Number(value ?? 0);
|
|
8610
|
+
return Number.isFinite(p) ? Math.max(0, p) : 0;
|
|
8611
|
+
}
|
|
8612
|
+
function normalizeAngle360$1(value) {
|
|
8613
|
+
const d = Number(value ?? 0);
|
|
8614
|
+
if (!Number.isFinite(d))
|
|
8615
|
+
return 0;
|
|
8616
|
+
return ((d % 360) + 360) % 360;
|
|
8617
|
+
}
|
|
8618
|
+
class EyeModelParameter {
|
|
8619
|
+
constructor(parameter) {
|
|
8620
|
+
this.parameter = parameter;
|
|
8700
8621
|
}
|
|
8701
8622
|
/**
|
|
8702
|
-
*
|
|
8703
|
-
*
|
|
8704
|
-
* power(D) = (n2 - n1) / R(m)
|
|
8705
|
-
* -> R(mm) = 1000 * (n2 - n1) / power(D)
|
|
8706
|
-
*
|
|
8707
|
-
* 굴절력이 사실상 0이면 평면으로 간주하기 위해 +Infinity를 반환합니다.
|
|
8623
|
+
* 동공(선택) → eye_st(ST 복합면) → 안구 모델 해부면 순으로 생성한 뒤,
|
|
8624
|
+
* `p`/`p_ax`/`tilt`를 안구 회전점 기준 강체 회전으로 반영합니다.
|
|
8708
8625
|
*/
|
|
8709
|
-
|
|
8710
|
-
|
|
8711
|
-
|
|
8712
|
-
|
|
8713
|
-
|
|
8626
|
+
createSurface(eye) {
|
|
8627
|
+
const normalizedEyeSphere = toFiniteNumber$1(eye.s) + (eye.eyeModel === "gullstrand" ? -1 : 0);
|
|
8628
|
+
const eyePower = {
|
|
8629
|
+
s: -normalizedEyeSphere,
|
|
8630
|
+
c: -toFiniteNumber$1(eye.c),
|
|
8631
|
+
ax: toFiniteNumber$1(eye.ax),
|
|
8632
|
+
};
|
|
8633
|
+
const pNorm = normalizePrismAmount$1(eye.p);
|
|
8634
|
+
const pAxNorm = normalizeAngle360$1(eye.p_ax);
|
|
8635
|
+
const tiltDeg = {
|
|
8636
|
+
x: toFiniteNumber$1(eye.tilt?.x),
|
|
8637
|
+
y: toFiniteNumber$1(eye.tilt?.y),
|
|
8638
|
+
};
|
|
8639
|
+
const rotation = eyePrismAndTiltToQuaternion(pNorm, pAxNorm, tiltDeg);
|
|
8640
|
+
const eyeSt = new STSurface({
|
|
8641
|
+
type: "compound",
|
|
8642
|
+
name: "eye_st",
|
|
8643
|
+
position: { x: 0, y: 0, z: -EYE_ST_SURFACE_OFFSET_MM },
|
|
8644
|
+
referencePoint: { x: 0, y: 0, z: 0 },
|
|
8645
|
+
tilt: { x: 0, y: 0 },
|
|
8646
|
+
s: eyePower.s,
|
|
8647
|
+
c: eyePower.c,
|
|
8648
|
+
ax: eyePower.ax,
|
|
8649
|
+
n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
|
|
8650
|
+
n: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
|
|
8651
|
+
n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
|
|
8652
|
+
});
|
|
8653
|
+
const anatomical = this.parameter.surfaces.map((surface) => {
|
|
8654
|
+
if (surface.type === "spherical") {
|
|
8655
|
+
return new SphericalSurface({
|
|
8656
|
+
type: "spherical",
|
|
8657
|
+
name: surface.name,
|
|
8658
|
+
r: surface.radius,
|
|
8659
|
+
position: { x: 0, y: 0, z: surface.z },
|
|
8660
|
+
tilt: { x: 0, y: 0 },
|
|
8661
|
+
n_before: surface.n_before,
|
|
8662
|
+
n_after: surface.n_after,
|
|
8663
|
+
});
|
|
8664
|
+
}
|
|
8665
|
+
if (surface.type === "aspherical") {
|
|
8666
|
+
return new AsphericalSurface({
|
|
8667
|
+
type: "aspherical",
|
|
8668
|
+
name: surface.name,
|
|
8669
|
+
position: { x: 0, y: 0, z: surface.z },
|
|
8670
|
+
tilt: { x: 0, y: 0 },
|
|
8671
|
+
r: surface.radius,
|
|
8672
|
+
conic: surface.conic,
|
|
8673
|
+
n_before: surface.n_before,
|
|
8674
|
+
n_after: surface.n_after,
|
|
8675
|
+
});
|
|
8676
|
+
}
|
|
8677
|
+
if (surface.type === "spherical-image") {
|
|
8678
|
+
return new SphericalImageSurface({
|
|
8679
|
+
type: "spherical-image",
|
|
8680
|
+
name: surface.name,
|
|
8681
|
+
r: surface.radius,
|
|
8682
|
+
position: { x: 0, y: 0, z: surface.z },
|
|
8683
|
+
tilt: { x: 0, y: 0 },
|
|
8684
|
+
retina_extra_after: true,
|
|
8685
|
+
});
|
|
8686
|
+
}
|
|
8687
|
+
throw new Error(`Unsupported surface type: ${surface.type}`);
|
|
8688
|
+
});
|
|
8689
|
+
const surfaces = [eyeSt, ...anatomical];
|
|
8690
|
+
const pupilMm = toFiniteNumber$1(eye.pupilDiameterMm);
|
|
8691
|
+
if (Number.isFinite(pupilMm) && pupilMm > 0) {
|
|
8692
|
+
const pupilStop = new ApertureStopSurface({
|
|
8693
|
+
type: "aperture_stop",
|
|
8694
|
+
name: "pupil_stop",
|
|
8695
|
+
shape: "circle",
|
|
8696
|
+
radius: pupilMm / 2,
|
|
8697
|
+
position: {
|
|
8698
|
+
x: 0,
|
|
8699
|
+
y: 0,
|
|
8700
|
+
z: -EYE_ST_SURFACE_OFFSET_MM - (2 * RAY_SURFACE_ESCAPE_MM),
|
|
8701
|
+
},
|
|
8702
|
+
tilt: { x: 0, y: 0 },
|
|
8703
|
+
});
|
|
8704
|
+
surfaces.unshift(pupilStop);
|
|
8714
8705
|
}
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
return (1000 * (nAfter - nBefore)) / powerD;
|
|
8706
|
+
applyRigidEyePoseToSurfaces(surfaces, eyeRotationPivot(), rotation);
|
|
8707
|
+
return surfaces;
|
|
8718
8708
|
}
|
|
8719
|
-
|
|
8720
|
-
|
|
8709
|
+
}
|
|
8710
|
+
|
|
8711
|
+
class GullstrandParameter extends EyeModelParameter {
|
|
8712
|
+
constructor() {
|
|
8713
|
+
super(GullstrandParameter.parameter);
|
|
8721
8714
|
}
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
const nAfter = hasToric ? this.n_after : this.n;
|
|
8730
|
-
const props = {
|
|
8715
|
+
}
|
|
8716
|
+
GullstrandParameter.parameter = {
|
|
8717
|
+
unit: "mm",
|
|
8718
|
+
axis: "optical_axis_z",
|
|
8719
|
+
origin: "cornea_anterior_vertex",
|
|
8720
|
+
surfaces: [
|
|
8721
|
+
{
|
|
8731
8722
|
type: "spherical",
|
|
8732
|
-
name:
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8723
|
+
name: "cornea_anterior",
|
|
8724
|
+
z: 0.0,
|
|
8725
|
+
radius: 7.7,
|
|
8726
|
+
n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
|
|
8727
|
+
n_after: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
|
|
8728
|
+
},
|
|
8729
|
+
{
|
|
8730
|
+
type: "spherical",
|
|
8731
|
+
name: "cornea_posterior",
|
|
8732
|
+
z: 0.5,
|
|
8733
|
+
radius: 6.8,
|
|
8734
|
+
n_before: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
|
|
8735
|
+
n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
|
|
8736
|
+
},
|
|
8737
|
+
{
|
|
8738
|
+
type: "spherical",
|
|
8739
|
+
name: "lens_anterior",
|
|
8740
|
+
z: 3.6,
|
|
8741
|
+
radius: 10.0,
|
|
8742
|
+
n_before: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
|
|
8743
|
+
n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_anterior,
|
|
8744
|
+
},
|
|
8745
|
+
{
|
|
8746
|
+
type: "spherical",
|
|
8747
|
+
name: "lens_nucleus_anterior",
|
|
8748
|
+
z: 4.146,
|
|
8749
|
+
radius: 7.911,
|
|
8750
|
+
n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_anterior,
|
|
8751
|
+
n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_anterior,
|
|
8752
|
+
},
|
|
8753
|
+
{
|
|
8754
|
+
type: "spherical",
|
|
8755
|
+
name: "lens_nucleus_posterior",
|
|
8756
|
+
z: 6.565,
|
|
8757
|
+
radius: -5.76,
|
|
8758
|
+
n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_anterior,
|
|
8759
|
+
n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_posterior,
|
|
8760
|
+
},
|
|
8761
|
+
{
|
|
8762
|
+
type: "spherical",
|
|
8763
|
+
name: "lens_posterior",
|
|
8764
|
+
z: 7.2,
|
|
8765
|
+
radius: -6,
|
|
8766
|
+
n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_nucleus_posterior,
|
|
8767
|
+
n_after: FRAUNHOFER_REFRACTIVE_INDICES.vitreous,
|
|
8768
|
+
},
|
|
8769
|
+
{
|
|
8770
|
+
type: "spherical-image",
|
|
8771
|
+
name: "retina",
|
|
8772
|
+
radius: -12, // mm (대략적인 망막 곡률)
|
|
8773
|
+
z: 24.0 // 중심 위치
|
|
8774
|
+
}
|
|
8775
|
+
],
|
|
8776
|
+
};
|
|
8777
|
+
|
|
8778
|
+
class NavarroParameter extends EyeModelParameter {
|
|
8779
|
+
constructor() {
|
|
8780
|
+
super(NavarroParameter.parameter);
|
|
8781
|
+
}
|
|
8782
|
+
}
|
|
8783
|
+
NavarroParameter.parameter = {
|
|
8784
|
+
unit: "mm",
|
|
8785
|
+
axis: "optical_axis_z",
|
|
8786
|
+
surfaces: [
|
|
8787
|
+
{
|
|
8788
|
+
type: "aspherical",
|
|
8789
|
+
name: "cornea_anterior",
|
|
8790
|
+
z: 0.0,
|
|
8791
|
+
radius: 7.72,
|
|
8792
|
+
conic: -0.26,
|
|
8793
|
+
n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
|
|
8794
|
+
n_after: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
|
|
8795
|
+
},
|
|
8796
|
+
{
|
|
8797
|
+
type: "aspherical",
|
|
8798
|
+
name: "cornea_posterior",
|
|
8799
|
+
z: 0.55,
|
|
8800
|
+
radius: 6.5,
|
|
8801
|
+
conic: 0.0,
|
|
8802
|
+
n_before: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
|
|
8803
|
+
n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
|
|
8804
|
+
},
|
|
8805
|
+
{
|
|
8806
|
+
type: "aspherical",
|
|
8807
|
+
name: "lens_anterior",
|
|
8808
|
+
z: 0.55 + 3.05,
|
|
8809
|
+
radius: 10.2,
|
|
8810
|
+
conic: -3.13,
|
|
8811
|
+
n_before: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
|
|
8812
|
+
n_after: FRAUNHOFER_REFRACTIVE_INDICES.lens_navarro,
|
|
8813
|
+
},
|
|
8814
|
+
{
|
|
8815
|
+
type: "aspherical",
|
|
8816
|
+
name: "lens_posterior",
|
|
8817
|
+
z: 0.55 + 3.05 + 4.0,
|
|
8818
|
+
radius: -6,
|
|
8819
|
+
conic: -1,
|
|
8820
|
+
n_before: FRAUNHOFER_REFRACTIVE_INDICES.lens_navarro,
|
|
8821
|
+
n_after: FRAUNHOFER_REFRACTIVE_INDICES.vitreous,
|
|
8822
|
+
},
|
|
8823
|
+
{
|
|
8824
|
+
type: "spherical-image",
|
|
8825
|
+
name: "retina",
|
|
8826
|
+
radius: -12, // mm (대략적인 망막 곡률)
|
|
8827
|
+
z: 24.04 // 중심 위치
|
|
8828
|
+
}
|
|
8829
|
+
],
|
|
8830
|
+
};
|
|
8831
|
+
|
|
8832
|
+
/**
|
|
8833
|
+
* Sturm 계산 전용 클래스입니다.
|
|
8834
|
+
* - traced ray 집합에서 z-scan slice를 생성하고
|
|
8835
|
+
* - 평탄도/최소타원/근사중심을 계산해 분석 결과를 반환합니다.
|
|
8836
|
+
*/
|
|
8837
|
+
class Sturm {
|
|
8838
|
+
constructor() {
|
|
8839
|
+
this.lastResult = null;
|
|
8840
|
+
this.lineOrder = ["g", "F", "e", "d", "C", "r"];
|
|
8841
|
+
}
|
|
8842
|
+
calculate(rays, effectiveCylinderD, axisReferenceRays, profileWorldZBounds) {
|
|
8843
|
+
const frame = this.analysisFrameFromRays(axisReferenceRays?.length ? axisReferenceRays : rays);
|
|
8844
|
+
const depthRange = this.depthRangeFromRays(rays, frame);
|
|
8845
|
+
const sturmSlices = this.collectSturmSlices(rays, frame, depthRange, DEFAULT_STURM_STEP_MM);
|
|
8846
|
+
const groupedByLine = this.groupByFraunhoferLine(rays);
|
|
8847
|
+
const sturmInfo = groupedByLine.map((group) => {
|
|
8848
|
+
const groupFrame = this.analysisFrameFromRays(group.rays, frame);
|
|
8849
|
+
const groupDepthRange = this.depthRangeFromRays(group.rays, groupFrame);
|
|
8850
|
+
const slices = this.collectSturmSlices(group.rays, groupFrame, groupDepthRange, DEFAULT_STURM_STEP_MM);
|
|
8851
|
+
const analysis = this.analyzeSturmSlices(slices, effectiveCylinderD, profileWorldZBounds);
|
|
8852
|
+
return {
|
|
8853
|
+
line: group.line,
|
|
8854
|
+
wavelength_nm: group.wavelength_nm,
|
|
8855
|
+
color: group.color,
|
|
8856
|
+
ray_count: group.rays.length,
|
|
8857
|
+
analysis_axis: {
|
|
8858
|
+
x: groupFrame.axis.x,
|
|
8859
|
+
y: groupFrame.axis.y,
|
|
8860
|
+
z: groupFrame.axis.z,
|
|
8861
|
+
},
|
|
8862
|
+
...analysis,
|
|
8863
|
+
};
|
|
8864
|
+
});
|
|
8865
|
+
const result = {
|
|
8866
|
+
slices_info: {
|
|
8867
|
+
count: sturmSlices.length,
|
|
8868
|
+
slices: sturmSlices,
|
|
8869
|
+
},
|
|
8870
|
+
sturm_info: sturmInfo,
|
|
8738
8871
|
};
|
|
8739
|
-
|
|
8872
|
+
this.lastResult = result;
|
|
8873
|
+
return result;
|
|
8740
8874
|
}
|
|
8741
8875
|
/**
|
|
8742
|
-
*
|
|
8876
|
+
* 마지막 Sturm 계산 결과를 반환합니다.
|
|
8743
8877
|
*/
|
|
8744
|
-
|
|
8745
|
-
|
|
8878
|
+
getLastResult() {
|
|
8879
|
+
return this.lastResult;
|
|
8880
|
+
}
|
|
8881
|
+
getRayPoints(ray) {
|
|
8882
|
+
const points = ray.points;
|
|
8883
|
+
return Array.isArray(points) ? points : [];
|
|
8884
|
+
}
|
|
8885
|
+
analysisFrameFromRays(rays, fallback) {
|
|
8886
|
+
const axis = new Vector3();
|
|
8887
|
+
for (const ray of rays ?? [])
|
|
8888
|
+
axis.add(ray.getDirection());
|
|
8889
|
+
if (axis.lengthSq() < 1e-12) {
|
|
8890
|
+
if (fallback)
|
|
8891
|
+
return fallback;
|
|
8892
|
+
axis.set(0, 0, 1);
|
|
8893
|
+
}
|
|
8894
|
+
else {
|
|
8895
|
+
axis.normalize();
|
|
8896
|
+
}
|
|
8897
|
+
const origin = new Vector3(0, 0, 0);
|
|
8898
|
+
let helper = new Vector3(0, 1, 0);
|
|
8899
|
+
if (Math.abs(helper.dot(axis)) > 0.95)
|
|
8900
|
+
helper = new Vector3(1, 0, 0);
|
|
8901
|
+
const u = helper.clone().cross(axis).normalize();
|
|
8902
|
+
const v = axis.clone().cross(u).normalize();
|
|
8903
|
+
return { origin, axis, u, v };
|
|
8904
|
+
}
|
|
8905
|
+
sampleRayPointAtDepth(ray, frame, depth) {
|
|
8906
|
+
const points = this.getRayPoints(ray);
|
|
8907
|
+
for (let i = 0; i < points.length - 1; i += 1) {
|
|
8908
|
+
const a = points[i];
|
|
8909
|
+
const b = points[i + 1];
|
|
8910
|
+
const da = a.clone().sub(frame.origin).dot(frame.axis);
|
|
8911
|
+
const db = b.clone().sub(frame.origin).dot(frame.axis);
|
|
8912
|
+
if ((da <= depth && depth <= db) || (db <= depth && depth <= da)) {
|
|
8913
|
+
const denom = db - da;
|
|
8914
|
+
if (Math.abs(denom) < 1e-10)
|
|
8915
|
+
return null;
|
|
8916
|
+
return a.clone().lerp(b, (depth - da) / denom);
|
|
8917
|
+
}
|
|
8918
|
+
}
|
|
8919
|
+
return null;
|
|
8920
|
+
}
|
|
8921
|
+
depthRangeFromRays(rays, frame) {
|
|
8922
|
+
let depthMin = Number.POSITIVE_INFINITY;
|
|
8923
|
+
let depthMax = Number.NEGATIVE_INFINITY;
|
|
8924
|
+
for (const ray of rays ?? []) {
|
|
8925
|
+
for (const point of this.getRayPoints(ray)) {
|
|
8926
|
+
const d = point.clone().sub(frame.origin).dot(frame.axis);
|
|
8927
|
+
depthMin = Math.min(depthMin, d);
|
|
8928
|
+
depthMax = Math.max(depthMax, d);
|
|
8929
|
+
}
|
|
8930
|
+
}
|
|
8931
|
+
if (!Number.isFinite(depthMin) || !Number.isFinite(depthMax) || depthMax <= depthMin)
|
|
8746
8932
|
return null;
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
8933
|
+
return { depthMin, depthMax };
|
|
8934
|
+
}
|
|
8935
|
+
secondMomentProfileAtDepth(rays, frame, depth) {
|
|
8936
|
+
const points = [];
|
|
8937
|
+
for (const ray of rays) {
|
|
8938
|
+
const point = this.sampleRayPointAtDepth(ray, frame, depth);
|
|
8939
|
+
if (point)
|
|
8940
|
+
points.push(point);
|
|
8941
|
+
}
|
|
8942
|
+
if (points.length < 4)
|
|
8943
|
+
return null;
|
|
8944
|
+
let cxWorld = 0;
|
|
8945
|
+
let cyWorld = 0;
|
|
8946
|
+
let czWorld = 0;
|
|
8947
|
+
let cx = 0;
|
|
8948
|
+
let cy = 0;
|
|
8949
|
+
for (const p of points) {
|
|
8950
|
+
cxWorld += p.x;
|
|
8951
|
+
cyWorld += p.y;
|
|
8952
|
+
czWorld += p.z;
|
|
8953
|
+
const delta = p.clone().sub(frame.origin);
|
|
8954
|
+
cx += delta.dot(frame.u);
|
|
8955
|
+
cy += delta.dot(frame.v);
|
|
8956
|
+
}
|
|
8957
|
+
cxWorld /= points.length;
|
|
8958
|
+
cyWorld /= points.length;
|
|
8959
|
+
czWorld /= points.length;
|
|
8960
|
+
cx /= points.length;
|
|
8961
|
+
cy /= points.length;
|
|
8962
|
+
let sxx = 0;
|
|
8963
|
+
let syy = 0;
|
|
8964
|
+
let sxy = 0;
|
|
8965
|
+
for (const p of points) {
|
|
8966
|
+
const delta = p.clone().sub(frame.origin);
|
|
8967
|
+
const x = delta.dot(frame.u);
|
|
8968
|
+
const y = delta.dot(frame.v);
|
|
8969
|
+
const dx = x - cx;
|
|
8970
|
+
const dy = y - cy;
|
|
8971
|
+
sxx += dx * dx;
|
|
8972
|
+
syy += dy * dy;
|
|
8973
|
+
sxy += dx * dy;
|
|
8974
|
+
}
|
|
8975
|
+
sxx /= points.length;
|
|
8976
|
+
syy /= points.length;
|
|
8977
|
+
sxy /= points.length;
|
|
8978
|
+
const trace = sxx + syy;
|
|
8979
|
+
const halfDiff = (sxx - syy) / 2;
|
|
8980
|
+
const root = Math.sqrt(Math.max(0, halfDiff * halfDiff + sxy * sxy));
|
|
8981
|
+
const lambdaMajor = Math.max(0, trace / 2 + root);
|
|
8982
|
+
const lambdaMinor = Math.max(0, trace / 2 - root);
|
|
8983
|
+
const thetaRad = 0.5 * Math.atan2(2 * sxy, sxx - syy);
|
|
8984
|
+
const angleMajorDeg = ((thetaRad * 180) / Math.PI + 360) % 180;
|
|
8985
|
+
const twoThetaRad = 2 * thetaRad;
|
|
8986
|
+
const j0 = Math.cos(twoThetaRad);
|
|
8987
|
+
const j45 = Math.sin(twoThetaRad);
|
|
8988
|
+
const majorDirection = frame.u.clone().multiplyScalar(Math.cos(thetaRad))
|
|
8989
|
+
.add(frame.v.clone().multiplyScalar(Math.sin(thetaRad)))
|
|
8990
|
+
.normalize();
|
|
8991
|
+
const minorDirection = frame.axis.clone().cross(majorDirection).normalize();
|
|
8992
|
+
return {
|
|
8993
|
+
at: { x: cxWorld, y: cyWorld, z: czWorld },
|
|
8994
|
+
wMajor: Math.sqrt(lambdaMajor),
|
|
8995
|
+
wMinor: Math.sqrt(lambdaMinor),
|
|
8996
|
+
angleMajorDeg,
|
|
8997
|
+
j0,
|
|
8998
|
+
j45,
|
|
8999
|
+
angleMinorDeg: (angleMajorDeg + 90) % 180,
|
|
9000
|
+
majorDirection: {
|
|
9001
|
+
x: majorDirection.x,
|
|
9002
|
+
y: majorDirection.y,
|
|
9003
|
+
z: majorDirection.z,
|
|
9004
|
+
},
|
|
9005
|
+
minorDirection: {
|
|
9006
|
+
x: minorDirection.x,
|
|
9007
|
+
y: minorDirection.y,
|
|
9008
|
+
z: minorDirection.z,
|
|
8754
9009
|
},
|
|
8755
|
-
tilt: { x: this.tilt.x, y: this.tilt.y },
|
|
8756
|
-
r_axis: Number.POSITIVE_INFINITY,
|
|
8757
|
-
r_perp: this.toricRadiusPerpMm,
|
|
8758
|
-
axis_deg: this.ax,
|
|
8759
|
-
n_before: this.n_before,
|
|
8760
|
-
n_after: this.n,
|
|
8761
9010
|
};
|
|
8762
|
-
return new ToricSurface(props);
|
|
8763
9011
|
}
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
const
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
|
|
8772
|
-
|
|
8773
|
-
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
9012
|
+
collectSturmSlices(rays, frame, depthRange, stepMm) {
|
|
9013
|
+
if (!depthRange)
|
|
9014
|
+
return [];
|
|
9015
|
+
const out = [];
|
|
9016
|
+
for (let depth = depthRange.depthMin; depth <= depthRange.depthMax; depth += stepMm) {
|
|
9017
|
+
const profile = this.secondMomentProfileAtDepth(rays, frame, depth);
|
|
9018
|
+
if (!profile)
|
|
9019
|
+
continue;
|
|
9020
|
+
out.push({
|
|
9021
|
+
// Keep z in world coordinates for backward-compatible consumers.
|
|
9022
|
+
z: profile.at.z,
|
|
9023
|
+
// Preserve analysis-axis depth for off-axis robust ranking/interval logic.
|
|
9024
|
+
depth,
|
|
9025
|
+
ratio: profile.wMinor / Math.max(profile.wMajor, 1e-9),
|
|
9026
|
+
size: Math.hypot(profile.wMajor, profile.wMinor),
|
|
9027
|
+
profile,
|
|
9028
|
+
});
|
|
8777
9029
|
}
|
|
8778
|
-
|
|
8779
|
-
|
|
9030
|
+
return out;
|
|
9031
|
+
}
|
|
9032
|
+
axisDiffDeg(a, b) {
|
|
9033
|
+
const d = Math.abs((((a - b) % 180) + 180) % 180);
|
|
9034
|
+
return Math.min(d, 180 - d);
|
|
9035
|
+
}
|
|
9036
|
+
phasorDot(p, q) {
|
|
9037
|
+
return p.j0 * q.j0 + p.j45 * q.j45;
|
|
8780
9038
|
}
|
|
8781
9039
|
/**
|
|
8782
|
-
*
|
|
8783
|
-
*
|
|
9040
|
+
* 평탄도 순으로 정렬된 슬라이스에서 선초점 쌍(직교 주경선)에 해당하는 Top2를 고른다.
|
|
9041
|
+
* 주경선 각 대신 이각 phasor (cos 2θ, sin 2θ)로 직교를 판정하고, 실패 시 각도·전역 검색·깊이 폴백을 사용한다.
|
|
8784
9042
|
*/
|
|
8785
|
-
|
|
8786
|
-
if (
|
|
8787
|
-
return
|
|
8788
|
-
const
|
|
8789
|
-
|
|
8790
|
-
|
|
8791
|
-
const
|
|
8792
|
-
|
|
8793
|
-
const
|
|
8794
|
-
|
|
8795
|
-
|
|
8796
|
-
|
|
8797
|
-
|
|
8798
|
-
|
|
9043
|
+
pickFlattestSturmPair(sortedByFlatness, top2MinGapMm, allSlices) {
|
|
9044
|
+
if (sortedByFlatness.length === 0)
|
|
9045
|
+
return [];
|
|
9046
|
+
const first = sortedByFlatness[0];
|
|
9047
|
+
const top2MinAngleGapDeg = DEFAULT_STURM_TOP2_MIN_ANGLE_GAP_DEG;
|
|
9048
|
+
const phasorStrict = DEFAULT_STURM_TOP2_PHASOR_OPPOSITION_MAX_DOT;
|
|
9049
|
+
const phasorLoose = DEFAULT_STURM_TOP2_PHASOR_FALLBACK_MAX_DOT;
|
|
9050
|
+
const lastResortMinDeg = DEFAULT_STURM_TOP2_AXIS_LAST_RESORT_MIN_GAP_DEG;
|
|
9051
|
+
const depthOk = (c) => Math.abs(c.depth - first.depth) >= top2MinGapMm;
|
|
9052
|
+
const byPhasor = (pool, maxDot) => pool.find((c) => (c !== first
|
|
9053
|
+
&& depthOk(c)
|
|
9054
|
+
&& this.phasorDot(first.profile, c.profile) <= maxDot));
|
|
9055
|
+
let second = byPhasor(sortedByFlatness, phasorStrict)
|
|
9056
|
+
?? byPhasor(sortedByFlatness, phasorLoose);
|
|
9057
|
+
if (!second) {
|
|
9058
|
+
second = sortedByFlatness.find((c) => (c !== first
|
|
9059
|
+
&& depthOk(c)
|
|
9060
|
+
&& this.axisDiffDeg(c.profile.angleMajorDeg, first.profile.angleMajorDeg) >= top2MinAngleGapDeg));
|
|
9061
|
+
}
|
|
9062
|
+
if (!second) {
|
|
9063
|
+
let best = null;
|
|
9064
|
+
let bestAxDiff = -1;
|
|
9065
|
+
for (const c of sortedByFlatness) {
|
|
9066
|
+
if (c === first || !depthOk(c))
|
|
8799
9067
|
continue;
|
|
8800
|
-
const
|
|
8801
|
-
|
|
8802
|
-
|
|
9068
|
+
const axd = this.axisDiffDeg(c.profile.angleMajorDeg, first.profile.angleMajorDeg);
|
|
9069
|
+
if (axd > bestAxDiff) {
|
|
9070
|
+
bestAxDiff = axd;
|
|
9071
|
+
best = c;
|
|
9072
|
+
}
|
|
9073
|
+
}
|
|
9074
|
+
if (best && bestAxDiff >= lastResortMinDeg)
|
|
9075
|
+
second = best;
|
|
9076
|
+
}
|
|
9077
|
+
// 평탄도 정렬 풀만으로는 같은 phasor만 연속으로 나오는 경우가 있어, 전 스캔에서 가장 반대인 phasor를 고른다.
|
|
9078
|
+
if (!second) {
|
|
9079
|
+
let best = null;
|
|
9080
|
+
let bestDot = 1;
|
|
9081
|
+
for (const c of allSlices) {
|
|
9082
|
+
if (c === first || !depthOk(c))
|
|
8803
9083
|
continue;
|
|
8804
|
-
const
|
|
8805
|
-
if (
|
|
8806
|
-
|
|
9084
|
+
const d = this.phasorDot(first.profile, c.profile);
|
|
9085
|
+
if (d < bestDot) {
|
|
9086
|
+
bestDot = d;
|
|
9087
|
+
best = c;
|
|
9088
|
+
}
|
|
8807
9089
|
}
|
|
9090
|
+
if (best && bestDot < -0.08)
|
|
9091
|
+
second = best;
|
|
8808
9092
|
}
|
|
8809
|
-
|
|
8810
|
-
|
|
8811
|
-
|
|
8812
|
-
|
|
8813
|
-
|
|
8814
|
-
|
|
8815
|
-
|
|
8816
|
-
|
|
8817
|
-
|
|
8818
|
-
|
|
8819
|
-
|
|
8820
|
-
|
|
8821
|
-
|
|
8822
|
-
|
|
8823
|
-
|
|
8824
|
-
|
|
8825
|
-
|
|
8826
|
-
|
|
8827
|
-
|
|
8828
|
-
|
|
8829
|
-
const defaultRadius = 12;
|
|
8830
|
-
const finiteRadii = [this.sphericalRadiusMm, this.toricRadiusPerpMm]
|
|
8831
|
-
.filter((r) => Number.isFinite(r) && Math.abs(r) > 1e-6)
|
|
8832
|
-
.map((r) => Math.abs(r));
|
|
8833
|
-
if (!finiteRadii.length)
|
|
8834
|
-
return defaultRadius;
|
|
8835
|
-
return Math.max(1.0, Math.min(defaultRadius, 0.98 * Math.min(...finiteRadii)));
|
|
8836
|
-
}
|
|
8837
|
-
/**
|
|
8838
|
-
* 구면 측 꼭지점 기준 sag(mm)
|
|
8839
|
-
*/
|
|
8840
|
-
sphericalSagAtXY(x, y) {
|
|
8841
|
-
const rhoSq = x * x + y * y;
|
|
8842
|
-
const r = this.sphericalRadiusMm;
|
|
8843
|
-
if (!Number.isFinite(r) || Math.abs(r) > 1e12)
|
|
8844
|
-
return 0;
|
|
8845
|
-
const rr = r * r;
|
|
8846
|
-
if (rhoSq > rr)
|
|
8847
|
-
return Number.NaN;
|
|
8848
|
-
const root = Math.sqrt(Math.max(0, rr - rhoSq));
|
|
8849
|
-
return r > 0 ? r - root : r + root;
|
|
8850
|
-
}
|
|
8851
|
-
/**
|
|
8852
|
-
* 토릭 측 꼭지점 기준 sag(mm)
|
|
8853
|
-
*/
|
|
8854
|
-
toricSagAtXY(x, y) {
|
|
8855
|
-
const axisRad = (this.ax * Math.PI) / 180;
|
|
8856
|
-
const cAxis = Math.cos(axisRad);
|
|
8857
|
-
const sAxis = Math.sin(axisRad);
|
|
8858
|
-
const u = cAxis * x + sAxis * y;
|
|
8859
|
-
const v = -sAxis * x + cAxis * y;
|
|
8860
|
-
const cu = 0; // r_axis = Infinity
|
|
8861
|
-
const cv = (!Number.isFinite(this.toricRadiusPerpMm) || Math.abs(this.toricRadiusPerpMm) > 1e12)
|
|
8862
|
-
? 0
|
|
8863
|
-
: 1 / this.toricRadiusPerpMm;
|
|
8864
|
-
const a = cu * u * u + cv * v * v;
|
|
8865
|
-
const b = 1 - cu * cu * u * u - cv * cv * v * v;
|
|
8866
|
-
if (b < 0)
|
|
8867
|
-
return Number.NaN;
|
|
8868
|
-
const den = 1 + Math.sqrt(Math.max(0, b));
|
|
8869
|
-
if (Math.abs(den) < 1e-12)
|
|
8870
|
-
return Number.NaN;
|
|
8871
|
-
return a / den;
|
|
9093
|
+
// 마지막: 가장 납작한 슬라이스와 깊이 차가 가장 큰 “비교적 납작한” 슬라이스 (선초점이 z로 분리될 때)
|
|
9094
|
+
if (!second) {
|
|
9095
|
+
const flatThreshold = first.ratio * 1.5 + 1e-6;
|
|
9096
|
+
let best = null;
|
|
9097
|
+
let bestDepthSpan = -1;
|
|
9098
|
+
for (const c of allSlices) {
|
|
9099
|
+
if (c === first || !depthOk(c))
|
|
9100
|
+
continue;
|
|
9101
|
+
if (c.ratio > flatThreshold)
|
|
9102
|
+
continue;
|
|
9103
|
+
const span = Math.abs(c.depth - first.depth);
|
|
9104
|
+
if (span > bestDepthSpan) {
|
|
9105
|
+
bestDepthSpan = span;
|
|
9106
|
+
best = c;
|
|
9107
|
+
}
|
|
9108
|
+
}
|
|
9109
|
+
if (best)
|
|
9110
|
+
second = best;
|
|
9111
|
+
}
|
|
9112
|
+
return second ? [first, second] : [first];
|
|
8872
9113
|
}
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
9114
|
+
buildApproxCenter(flattestTop2, smallestEllipse, preferTop2Mid) {
|
|
9115
|
+
if (flattestTop2.length <= 0)
|
|
9116
|
+
return null;
|
|
9117
|
+
if (preferTop2Mid && flattestTop2.length >= 2) {
|
|
9118
|
+
const first = flattestTop2[0];
|
|
9119
|
+
const second = flattestTop2[1];
|
|
9120
|
+
return {
|
|
9121
|
+
x: (first.profile.at.x + second.profile.at.x) / 2,
|
|
9122
|
+
y: (first.profile.at.y + second.profile.at.y) / 2,
|
|
9123
|
+
z: (first.z + second.z) / 2,
|
|
9124
|
+
mode: "top2-mid",
|
|
9125
|
+
};
|
|
9126
|
+
}
|
|
9127
|
+
if (smallestEllipse) {
|
|
9128
|
+
return {
|
|
9129
|
+
x: smallestEllipse.profile.at.x,
|
|
9130
|
+
y: smallestEllipse.profile.at.y,
|
|
9131
|
+
z: smallestEllipse.z,
|
|
9132
|
+
mode: "min-size",
|
|
9133
|
+
};
|
|
9134
|
+
}
|
|
9135
|
+
const first = flattestTop2[0];
|
|
9136
|
+
return { x: first.profile.at.x, y: first.profile.at.y, z: first.z, mode: "top1-flat" };
|
|
8876
9137
|
}
|
|
8877
|
-
|
|
8878
|
-
|
|
8879
|
-
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
|
|
8885
|
-
|
|
9138
|
+
slicesForSturmAnalysis(sturmSlices, bounds) {
|
|
9139
|
+
if (!bounds)
|
|
9140
|
+
return sturmSlices;
|
|
9141
|
+
const zMin = bounds.zMin;
|
|
9142
|
+
const zMax = bounds.zMax;
|
|
9143
|
+
if (!Number.isFinite(zMin) || !Number.isFinite(zMax) || zMax < zMin)
|
|
9144
|
+
return sturmSlices;
|
|
9145
|
+
const bounded = sturmSlices.filter((s) => {
|
|
9146
|
+
const z = s.profile?.at?.z;
|
|
9147
|
+
return Number.isFinite(z) && z >= zMin && z <= zMax;
|
|
9148
|
+
});
|
|
9149
|
+
return bounded.length >= 2 ? bounded : sturmSlices;
|
|
9150
|
+
}
|
|
9151
|
+
groupByFraunhoferLine(rays) {
|
|
9152
|
+
const groups = new Map();
|
|
9153
|
+
for (const ray of rays) {
|
|
9154
|
+
const line = ray.getFraunhoferLine();
|
|
9155
|
+
const wavelength = ray.getWavelengthNm();
|
|
9156
|
+
const color = Number(ray.displayColor);
|
|
9157
|
+
if (!groups.has(line)) {
|
|
9158
|
+
groups.set(line, {
|
|
9159
|
+
line,
|
|
9160
|
+
wavelength_nm: wavelength,
|
|
9161
|
+
color: Number.isFinite(color) ? color : null,
|
|
9162
|
+
rays: [],
|
|
9163
|
+
});
|
|
8886
9164
|
}
|
|
8887
|
-
|
|
8888
|
-
|
|
8889
|
-
|
|
8890
|
-
this.applyChromaticIndicesToSubSurfaces(ray);
|
|
8891
|
-
// 원통 성분이 없으면 단일(구면)면으로 처리합니다.
|
|
8892
|
-
if (!this.toricSurface) {
|
|
8893
|
-
const single = this.sphericalSurface.refract(ray);
|
|
8894
|
-
if (!single)
|
|
8895
|
-
return null;
|
|
8896
|
-
this.refractedRays.push(single.clone());
|
|
8897
|
-
return single;
|
|
9165
|
+
const group = groups.get(line);
|
|
9166
|
+
if (group)
|
|
9167
|
+
group.rays.push(ray);
|
|
8898
9168
|
}
|
|
8899
|
-
|
|
8900
|
-
const afterToric = this.toricSurface.refract(ray);
|
|
8901
|
-
if (!afterToric)
|
|
8902
|
-
return null;
|
|
8903
|
-
const afterSpherical = this.sphericalSurface.refract(afterToric);
|
|
8904
|
-
if (!afterSpherical)
|
|
8905
|
-
return null;
|
|
8906
|
-
this.refractedRays.push(afterSpherical.clone());
|
|
8907
|
-
return afterSpherical;
|
|
8908
|
-
}
|
|
8909
|
-
incident(ray) {
|
|
8910
|
-
// 복합면의 첫 hit: +z 기준 토릭이 앞서므로 toricSurface 우선.
|
|
8911
|
-
const primary = this.toricSurface ?? this.sphericalSurface;
|
|
8912
|
-
const hitPoint = primary.incident(ray);
|
|
8913
|
-
if (!hitPoint)
|
|
8914
|
-
return null;
|
|
8915
|
-
this.incidentRays.push(ray.clone());
|
|
8916
|
-
return hitPoint;
|
|
9169
|
+
return [...groups.values()].sort((a, b) => this.lineOrder.indexOf(a.line) - this.lineOrder.indexOf(b.line));
|
|
8917
9170
|
}
|
|
8918
|
-
|
|
8919
|
-
|
|
9171
|
+
analyzeSturmSlices(sturmSlices, effectiveCylinderD, profileWorldZBounds) {
|
|
9172
|
+
const top2MinGapMm = DEFAULT_STURM_TOP2_MIN_GAP_MM;
|
|
9173
|
+
const effectiveCylinderThresholdD = DEFAULT_EFFECTIVE_CYLINDER_THRESHOLD_D;
|
|
9174
|
+
const preferTop2Mid = effectiveCylinderD >= effectiveCylinderThresholdD;
|
|
9175
|
+
const slicesForAnalysis = this.slicesForSturmAnalysis(sturmSlices, profileWorldZBounds ?? null);
|
|
9176
|
+
const sortedByFlatness = [...slicesForAnalysis].sort((a, b) => a.ratio - b.ratio);
|
|
9177
|
+
const flattestTop2 = sortedByFlatness.length > 0
|
|
9178
|
+
? this.pickFlattestSturmPair(sortedByFlatness, top2MinGapMm, slicesForAnalysis)
|
|
9179
|
+
: [];
|
|
9180
|
+
let smallestEllipse = null;
|
|
9181
|
+
for (const slice of slicesForAnalysis) {
|
|
9182
|
+
if (!smallestEllipse || slice.size < smallestEllipse.size)
|
|
9183
|
+
smallestEllipse = slice;
|
|
9184
|
+
}
|
|
9185
|
+
const approxCenter = this.buildApproxCenter(flattestTop2, smallestEllipse, preferTop2Mid);
|
|
9186
|
+
const anterior = flattestTop2[0] ?? null;
|
|
9187
|
+
const posterior = preferTop2Mid ? (flattestTop2[1] ?? null) : null;
|
|
9188
|
+
return {
|
|
9189
|
+
has_astigmatism: preferTop2Mid,
|
|
9190
|
+
method: preferTop2Mid ? "sturm-interval-midpoint" : "minimum-ellipse",
|
|
9191
|
+
anterior,
|
|
9192
|
+
posterior,
|
|
9193
|
+
approx_center: approxCenter,
|
|
9194
|
+
};
|
|
8920
9195
|
}
|
|
8921
9196
|
}
|
|
8922
|
-
STSurface.POWER_EPS_D = ST_POWER_EPS_D;
|
|
8923
9197
|
|
|
8924
|
-
const
|
|
8925
|
-
|
|
8926
|
-
|
|
8927
|
-
|
|
8928
|
-
|
|
9198
|
+
const DEFAULT_LIGHT_SOURCE_CONFIG = {
|
|
9199
|
+
type: "grid",
|
|
9200
|
+
width: 10,
|
|
9201
|
+
height: 10,
|
|
9202
|
+
division: 4,
|
|
9203
|
+
z: -10,
|
|
9204
|
+
vergence: 0,
|
|
8929
9205
|
};
|
|
8930
|
-
|
|
9206
|
+
// ================ Helper functions ================
|
|
9207
|
+
function toFiniteNumber(value, fallback = 0) {
|
|
9208
|
+
const parsed = Number(value);
|
|
9209
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
9210
|
+
}
|
|
9211
|
+
function normalizePrismAmount(value) {
|
|
9212
|
+
const p = Number(value ?? 0);
|
|
9213
|
+
return Number.isFinite(p) ? Math.max(0, p) : 0;
|
|
9214
|
+
}
|
|
9215
|
+
function normalizeAngle360(value) {
|
|
9216
|
+
const d = Number(value ?? 0);
|
|
9217
|
+
if (!Number.isFinite(d))
|
|
9218
|
+
return 0;
|
|
9219
|
+
return ((d % 360) + 360) % 360;
|
|
9220
|
+
}
|
|
9221
|
+
function normalizeEyeTilt(value) {
|
|
9222
|
+
return {
|
|
9223
|
+
x: toFiniteNumber(value?.x),
|
|
9224
|
+
y: toFiniteNumber(value?.y),
|
|
9225
|
+
};
|
|
9226
|
+
}
|
|
9227
|
+
function normalizeLightSourcePose(value) {
|
|
9228
|
+
return {
|
|
9229
|
+
position: {
|
|
9230
|
+
x: toFiniteNumber(value?.position?.x),
|
|
9231
|
+
y: toFiniteNumber(value?.position?.y),
|
|
9232
|
+
z: toFiniteNumber(value?.position?.z),
|
|
9233
|
+
},
|
|
9234
|
+
tilt: {
|
|
9235
|
+
x: toFiniteNumber(value?.tilt?.x),
|
|
9236
|
+
y: toFiniteNumber(value?.tilt?.y),
|
|
9237
|
+
},
|
|
9238
|
+
};
|
|
9239
|
+
}
|
|
9240
|
+
function isNormalizedEngineConfig(config) {
|
|
9241
|
+
const candidate = config;
|
|
9242
|
+
return (candidate.eyeModel !== undefined
|
|
9243
|
+
&& candidate.eye !== undefined
|
|
9244
|
+
&& candidate.light_source !== undefined
|
|
9245
|
+
&& candidate.pupil_type !== undefined
|
|
9246
|
+
&& Array.isArray(candidate.lens));
|
|
9247
|
+
}
|
|
9248
|
+
function normalizeEngineProps(props = {}) {
|
|
9249
|
+
const eyeModel = props.eyeModel ?? "gullstrand";
|
|
9250
|
+
const eyeInput = props.eye ?? { s: 0, c: 0, ax: 0, p: 0, p_ax: 0 };
|
|
9251
|
+
const lensInput = Array.isArray(props.lens) ? props.lens : [];
|
|
9252
|
+
const lightSourceInput = props.light_source ?? DEFAULT_LIGHT_SOURCE_CONFIG;
|
|
9253
|
+
const normalizedLightSourcePose = normalizeLightSourcePose(lightSourceInput);
|
|
9254
|
+
return {
|
|
9255
|
+
eyeModel,
|
|
9256
|
+
eye: {
|
|
9257
|
+
s: toFiniteNumber(eyeInput?.s),
|
|
9258
|
+
c: toFiniteNumber(eyeInput?.c),
|
|
9259
|
+
ax: toFiniteNumber(eyeInput?.ax),
|
|
9260
|
+
p: normalizePrismAmount(eyeInput?.p),
|
|
9261
|
+
p_ax: normalizeAngle360(eyeInput?.p_ax),
|
|
9262
|
+
tilt: normalizeEyeTilt(eyeInput?.tilt),
|
|
9263
|
+
},
|
|
9264
|
+
lens: lensInput.map((spec) => ({
|
|
9265
|
+
s: toFiniteNumber(spec?.s),
|
|
9266
|
+
c: toFiniteNumber(spec?.c),
|
|
9267
|
+
ax: toFiniteNumber(spec?.ax),
|
|
9268
|
+
p: normalizePrismAmount(spec?.p),
|
|
9269
|
+
p_ax: normalizeAngle360(spec?.p_ax),
|
|
9270
|
+
position: {
|
|
9271
|
+
x: toFiniteNumber(spec?.position?.x),
|
|
9272
|
+
y: toFiniteNumber(spec?.position?.y),
|
|
9273
|
+
z: toFiniteNumber(spec?.position?.z, SPECTACLE_VERTEX_DISTANCE_MM),
|
|
9274
|
+
},
|
|
9275
|
+
tilt: {
|
|
9276
|
+
x: toFiniteNumber(spec?.tilt?.x),
|
|
9277
|
+
y: toFiniteNumber(spec?.tilt?.y),
|
|
9278
|
+
},
|
|
9279
|
+
})),
|
|
9280
|
+
light_source: {
|
|
9281
|
+
...lightSourceInput,
|
|
9282
|
+
position: { ...normalizedLightSourcePose.position },
|
|
9283
|
+
tilt: { ...normalizedLightSourcePose.tilt },
|
|
9284
|
+
},
|
|
9285
|
+
pupil_type: props.pupil_type ?? "neutral",
|
|
9286
|
+
};
|
|
9287
|
+
}
|
|
8931
9288
|
/**
|
|
8932
9289
|
* legacy simulator.js를 TypeScript로 옮긴 핵심 시뮬레이터입니다.
|
|
8933
9290
|
* - 광원 광선을 생성하고
|
|
@@ -8935,7 +9292,10 @@ const TABOToDeg = (TABOAngle) => {
|
|
|
8935
9292
|
* - Sturm 분석까지 제공합니다.
|
|
8936
9293
|
*/
|
|
8937
9294
|
class SCAXEngineCore {
|
|
8938
|
-
|
|
9295
|
+
get eyeModelParameter() {
|
|
9296
|
+
return this._eyeModelParameter;
|
|
9297
|
+
}
|
|
9298
|
+
constructor(config = {}) {
|
|
8939
9299
|
this.tracedRays = [];
|
|
8940
9300
|
this.lastSourceRaysForSturm = [];
|
|
8941
9301
|
this.lastSturmGapAnalysis = null;
|
|
@@ -8944,14 +9304,14 @@ class SCAXEngineCore {
|
|
|
8944
9304
|
this.sortedEyeSurfaces = [];
|
|
8945
9305
|
this.sturm = new Sturm();
|
|
8946
9306
|
this.affine = new Affine();
|
|
8947
|
-
this.configure(
|
|
9307
|
+
this.configure(isNormalizedEngineConfig(config) ? config : normalizeEngineProps(config));
|
|
8948
9308
|
}
|
|
8949
9309
|
/**
|
|
8950
9310
|
* 생성자와 동일한 기본값 규칙으로 광학 설정을 다시 적용합니다.
|
|
8951
9311
|
* 생략한 최상위 필드는 매번 기본값으로 돌아갑니다(이전 값과 병합하지 않음).
|
|
8952
9312
|
*/
|
|
8953
|
-
update(
|
|
8954
|
-
this.configure(
|
|
9313
|
+
update(config = {}) {
|
|
9314
|
+
this.configure(isNormalizedEngineConfig(config) ? config : normalizeEngineProps(config));
|
|
8955
9315
|
}
|
|
8956
9316
|
dispose() {
|
|
8957
9317
|
[...this.lens, ...this.surfaces].forEach((surface) => {
|
|
@@ -8966,141 +9326,40 @@ class SCAXEngineCore {
|
|
|
8966
9326
|
this.sortedEyeSurfaces = [];
|
|
8967
9327
|
this.sortedLensSurfaces = [];
|
|
8968
9328
|
}
|
|
8969
|
-
configure(
|
|
9329
|
+
configure(config) {
|
|
8970
9330
|
this.lastSturmGapAnalysis = null;
|
|
8971
9331
|
this.lastAffineAnalysis = null;
|
|
8972
|
-
const { eyeModel
|
|
8973
|
-
|
|
8974
|
-
|
|
8975
|
-
|
|
8976
|
-
|
|
8977
|
-
|
|
8978
|
-
|
|
8979
|
-
c: -this.toFiniteNumber(eye?.c),
|
|
8980
|
-
ax: this.toFiniteNumber(eye?.ax),
|
|
8981
|
-
};
|
|
8982
|
-
this.eyePrismPrescription = {
|
|
8983
|
-
p: this.normalizePrismAmount(eye?.p),
|
|
8984
|
-
p_ax: this.normalizeAngle360(eye?.p_ax),
|
|
8985
|
-
};
|
|
8986
|
-
const eyeRx = this.prismVectorFromBase(this.eyePrismPrescription.p, this.eyePrismPrescription.p_ax);
|
|
8987
|
-
// eye 입력은 "교정 필요량(처방값)"이므로 실제 안구 편위는 역벡터입니다.
|
|
8988
|
-
this.eyePrismEffectVector = { x: -eyeRx.x, y: -eyeRx.y };
|
|
8989
|
-
const eyeRotXDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.x);
|
|
8990
|
-
const eyeRotYDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.y);
|
|
8991
|
-
this.eyeTiltDeg = this.normalizeEyeTilt(eye?.tilt);
|
|
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;
|
|
8996
|
-
this.eyeRotationQuaternion = new Quaternion().setFromEuler(new Euler((eyeEulerXDeg * Math.PI) / 180, (eyeEulerYDeg * Math.PI) / 180, 0, "XYZ"));
|
|
8997
|
-
this.eyeRotationQuaternionInverse = this.eyeRotationQuaternion.clone().invert();
|
|
8998
|
-
this.eyeRotationPivot = new Vector3(0, 0, SCAXEngineCore.EYE_ROTATION_PIVOT_FROM_CORNEA_MM);
|
|
8999
|
-
const normalizedLightSourcePose = this.normalizeLightSourcePose(light_source);
|
|
9000
|
-
this.lightSourcePosition = new Vector3(normalizedLightSourcePose.position.x, normalizedLightSourcePose.position.y, normalizedLightSourcePose.position.z);
|
|
9001
|
-
this.lightSourceTiltDeg = { ...normalizedLightSourcePose.tilt };
|
|
9002
|
-
this.lightSourceRotationQuaternion = new Quaternion().setFromEuler(new Euler((this.lightSourceTiltDeg.x * Math.PI) / 180, (this.lightSourceTiltDeg.y * Math.PI) / 180, 0, "XYZ"));
|
|
9003
|
-
// Rotate around the light source local center plane (z), not world origin,
|
|
9004
|
-
// so tilt changes orientation without unintentionally translating the source center.
|
|
9005
|
-
this.lightSourceRotationPivot = new Vector3(0, 0, this.toFiniteNumber(light_source?.z));
|
|
9006
|
-
this.lensConfigs = (Array.isArray(lens) ? lens : []).map((spec) => ({
|
|
9007
|
-
s: this.toFiniteNumber(spec?.s),
|
|
9008
|
-
c: this.toFiniteNumber(spec?.c),
|
|
9009
|
-
ax: this.toFiniteNumber(spec?.ax),
|
|
9010
|
-
p: this.normalizePrismAmount(spec?.p),
|
|
9011
|
-
p_ax: this.normalizeAngle360(spec?.p_ax),
|
|
9332
|
+
const { eyeModel, eye, lens, light_source, pupil_type } = config;
|
|
9333
|
+
const lensConfigs = lens.map((spec) => ({
|
|
9334
|
+
s: spec.s,
|
|
9335
|
+
c: spec.c,
|
|
9336
|
+
ax: spec.ax,
|
|
9337
|
+
p: spec.p,
|
|
9338
|
+
p_ax: spec.p_ax,
|
|
9012
9339
|
position: {
|
|
9013
|
-
x:
|
|
9014
|
-
y:
|
|
9015
|
-
z:
|
|
9340
|
+
x: spec.position.x,
|
|
9341
|
+
y: spec.position.y,
|
|
9342
|
+
z: spec.position.z,
|
|
9016
9343
|
},
|
|
9017
9344
|
tilt: {
|
|
9018
|
-
x:
|
|
9019
|
-
y:
|
|
9020
|
-
},
|
|
9021
|
-
includeInAstigmatismSummary: spec?.includeInAstigmatismSummary !== false,
|
|
9022
|
-
}));
|
|
9023
|
-
this.currentProps = {
|
|
9024
|
-
eyeModel,
|
|
9025
|
-
eye: {
|
|
9026
|
-
s: this.toFiniteNumber(eye?.s),
|
|
9027
|
-
c: this.toFiniteNumber(eye?.c),
|
|
9028
|
-
ax: this.toFiniteNumber(eye?.ax),
|
|
9029
|
-
p: this.normalizePrismAmount(eye?.p),
|
|
9030
|
-
p_ax: this.normalizeAngle360(eye?.p_ax),
|
|
9031
|
-
tilt: this.normalizeEyeTilt(eye?.tilt),
|
|
9032
|
-
},
|
|
9033
|
-
lens: this.lensConfigs.map((spec) => ({
|
|
9034
|
-
s: this.toFiniteNumber(spec.s),
|
|
9035
|
-
c: this.toFiniteNumber(spec.c),
|
|
9036
|
-
ax: this.toFiniteNumber(spec.ax),
|
|
9037
|
-
p: this.normalizePrismAmount(spec.p),
|
|
9038
|
-
p_ax: this.normalizeAngle360(spec.p_ax),
|
|
9039
|
-
position: {
|
|
9040
|
-
x: this.toFiniteNumber(spec.position?.x),
|
|
9041
|
-
y: this.toFiniteNumber(spec.position?.y),
|
|
9042
|
-
z: this.toFiniteNumber(spec.position?.z, SPECTACLE_VERTEX_DISTANCE_MM),
|
|
9043
|
-
},
|
|
9044
|
-
tilt: {
|
|
9045
|
-
x: this.toFiniteNumber(spec.tilt?.x),
|
|
9046
|
-
y: this.toFiniteNumber(spec.tilt?.y),
|
|
9047
|
-
},
|
|
9048
|
-
includeInAstigmatismSummary: spec.includeInAstigmatismSummary,
|
|
9049
|
-
})),
|
|
9050
|
-
light_source: {
|
|
9051
|
-
...light_source,
|
|
9052
|
-
position: { ...normalizedLightSourcePose.position },
|
|
9053
|
-
tilt: { ...normalizedLightSourcePose.tilt },
|
|
9345
|
+
x: spec.tilt.x,
|
|
9346
|
+
y: spec.tilt.y,
|
|
9054
9347
|
},
|
|
9055
|
-
pupil_type,
|
|
9056
|
-
};
|
|
9057
|
-
this.lensPowers = this.lensConfigs.map((spec) => ({
|
|
9058
|
-
s: this.toFiniteNumber(spec?.s),
|
|
9059
|
-
c: this.toFiniteNumber(spec?.c),
|
|
9060
|
-
ax: this.toFiniteNumber(spec?.ax),
|
|
9061
9348
|
}));
|
|
9062
|
-
// eye/lens prism axis는 모두 임상 Base 방향(렌즈->각막 시점)으로 입력합니다.
|
|
9063
|
-
// 내부 광선 편향 벡터는 Base의 반대방향(= +180° 변환)으로 계산합니다.
|
|
9064
|
-
this.lensPrismVector = this.lensConfigs.reduce((acc, spec) => {
|
|
9065
|
-
const v = this.prismVectorFromBase(spec.p ?? 0, spec.p_ax ?? 0);
|
|
9066
|
-
return { x: acc.x + v.x, y: acc.y + v.y };
|
|
9067
|
-
}, { x: 0, y: 0 });
|
|
9068
9349
|
// "none"은 동공 제한을 완전히 비활성화합니다.
|
|
9069
|
-
|
|
9070
|
-
this.
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
|
|
9081
|
-
n: FRAUNHOFER_REFRACTIVE_INDICES.cornea,
|
|
9082
|
-
n_after: FRAUNHOFER_REFRACTIVE_INDICES.aqueous,
|
|
9350
|
+
const pupilMm = pupil_type === "none" ? 0 : Number(PUPIL_SIZE[pupil_type]);
|
|
9351
|
+
this._eyeModelParameter = eyeModel === "gullstrand" ? new GullstrandParameter() : new NavarroParameter();
|
|
9352
|
+
this.surfaces = this._eyeModelParameter.createSurface({
|
|
9353
|
+
eyeModel,
|
|
9354
|
+
s: eye.s,
|
|
9355
|
+
c: eye.c,
|
|
9356
|
+
ax: eye.ax,
|
|
9357
|
+
p: eye.p,
|
|
9358
|
+
p_ax: eye.p_ax,
|
|
9359
|
+
tilt: eye.tilt,
|
|
9360
|
+
pupilDiameterMm: pupilMm,
|
|
9083
9361
|
});
|
|
9084
|
-
this.
|
|
9085
|
-
this.hasPupilStop = false;
|
|
9086
|
-
if (Number.isFinite(this.pupilDiameterMm) && this.pupilDiameterMm > 0) {
|
|
9087
|
-
const pupilStop = new ApertureStopSurface({
|
|
9088
|
-
type: "aperture_stop",
|
|
9089
|
-
name: "pupil_stop",
|
|
9090
|
-
shape: "circle",
|
|
9091
|
-
radius: this.pupilDiameterMm / 2,
|
|
9092
|
-
// eye_st(눈 굴절력 surface)의 첫 굴절면(back vertex) 바로 앞에서 차단/통과를 판정합니다.
|
|
9093
|
-
position: {
|
|
9094
|
-
x: 0,
|
|
9095
|
-
y: 0,
|
|
9096
|
-
z: -EYE_ST_SURFACE_OFFSET_MM - (2 * RAY_SURFACE_ESCAPE_MM),
|
|
9097
|
-
},
|
|
9098
|
-
tilt: { x: 0, y: 0 },
|
|
9099
|
-
});
|
|
9100
|
-
this.surfaces = [pupilStop, ...this.surfaces];
|
|
9101
|
-
this.hasPupilStop = true;
|
|
9102
|
-
}
|
|
9103
|
-
this.lens = this.lensConfigs.map((spec, index) => new STSurface({
|
|
9362
|
+
this.lens = lensConfigs.map((spec, index) => new STSurface({
|
|
9104
9363
|
type: "compound",
|
|
9105
9364
|
name: `lens_st_${index + 1}`,
|
|
9106
9365
|
position: {
|
|
@@ -9113,12 +9372,14 @@ class SCAXEngineCore {
|
|
|
9113
9372
|
// 1) back surface is fixed at vertex distance(position.z)
|
|
9114
9373
|
// 2) back-front gap is optimized (thickness=0 => auto)
|
|
9115
9374
|
thickness: 0,
|
|
9116
|
-
s:
|
|
9117
|
-
c:
|
|
9118
|
-
ax:
|
|
9375
|
+
s: spec.s,
|
|
9376
|
+
c: spec.c,
|
|
9377
|
+
ax: spec.ax,
|
|
9119
9378
|
n_before: FRAUNHOFER_REFRACTIVE_INDICES.air,
|
|
9120
9379
|
n: FRAUNHOFER_REFRACTIVE_INDICES.crown_glass,
|
|
9121
9380
|
n_after: FRAUNHOFER_REFRACTIVE_INDICES.air,
|
|
9381
|
+
p: spec.p,
|
|
9382
|
+
p_ax: spec.p_ax,
|
|
9122
9383
|
}));
|
|
9123
9384
|
this.light_source = light_source.type === "radial"
|
|
9124
9385
|
? new RadialLightSource(light_source)
|
|
@@ -9134,52 +9395,7 @@ class SCAXEngineCore {
|
|
|
9134
9395
|
simulate() {
|
|
9135
9396
|
const tracedRays = this.rayTracing();
|
|
9136
9397
|
this.sturmCalculation(tracedRays);
|
|
9137
|
-
|
|
9138
|
-
return {
|
|
9139
|
-
traced_rays: tracedRays,
|
|
9140
|
-
info: {
|
|
9141
|
-
astigmatism: {
|
|
9142
|
-
eye: this.principalMeridiansFromPowers([this.eyePower]),
|
|
9143
|
-
lens: this.principalMeridiansFromPowers(this.astigmatismSummaryLensPowers()),
|
|
9144
|
-
combined: this.principalMeridiansFromPowers([
|
|
9145
|
-
this.eyePower,
|
|
9146
|
-
...this.astigmatismSummaryLensPowers(),
|
|
9147
|
-
]),
|
|
9148
|
-
},
|
|
9149
|
-
prism: {
|
|
9150
|
-
eye: this.toPrismSummaryItem(lightDeviation.eye_prism_effect),
|
|
9151
|
-
lens: this.toPrismSummaryItem(lightDeviation.lens_prism_total),
|
|
9152
|
-
combined: this.toPrismSummaryItem(lightDeviation.net_prism),
|
|
9153
|
-
},
|
|
9154
|
-
},
|
|
9155
|
-
};
|
|
9156
|
-
}
|
|
9157
|
-
/**
|
|
9158
|
-
* eye.p / eye.p_ax(처방값)를 기준으로, 렌더링에서 바로 쓸 눈 회전량을 반환합니다.
|
|
9159
|
-
* - x_deg/y_deg는 프리즘 회전량에 eye.tilt를 합산한 최종 렌더 회전량입니다.
|
|
9160
|
-
*/
|
|
9161
|
-
getEyeRotation() {
|
|
9162
|
-
// Keep render rotation strictly aligned with the internal eye rotation used in ray tracing.
|
|
9163
|
-
// Internal eye-space Euler:
|
|
9164
|
-
// eyeEulerXDeg = eyeRotYDeg + tilt.x
|
|
9165
|
-
// eyeEulerYDeg = (-eyeRotXDeg) + tilt.y
|
|
9166
|
-
// UI mapping (applyEyeRenderRotation):
|
|
9167
|
-
// object.rotation.x = -y_deg
|
|
9168
|
-
// object.rotation.y = x_deg
|
|
9169
|
-
// so we expose:
|
|
9170
|
-
// x_deg = eyeEulerYDeg
|
|
9171
|
-
// y_deg = -eyeEulerXDeg
|
|
9172
|
-
const eyeRotXDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.x);
|
|
9173
|
-
const eyeRotYDeg = this.prismComponentToAngleDeg(this.eyePrismEffectVector.y);
|
|
9174
|
-
const eyeEulerXDeg = eyeRotYDeg + this.eyeTiltDeg.x;
|
|
9175
|
-
const eyeEulerYDeg = (-eyeRotXDeg) + this.eyeTiltDeg.y;
|
|
9176
|
-
const xDeg = eyeEulerYDeg;
|
|
9177
|
-
const yDeg = -eyeEulerXDeg;
|
|
9178
|
-
return {
|
|
9179
|
-
x_deg: xDeg,
|
|
9180
|
-
y_deg: yDeg,
|
|
9181
|
-
magnitude_deg: Math.hypot(xDeg, yDeg),
|
|
9182
|
-
};
|
|
9398
|
+
return { traced_rays: tracedRays };
|
|
9183
9399
|
}
|
|
9184
9400
|
/**
|
|
9185
9401
|
* 1) Ray tracing 전용 함수
|
|
@@ -9190,10 +9406,8 @@ class SCAXEngineCore {
|
|
|
9190
9406
|
const eyeSurfaces = this.sortedEyeSurfaces;
|
|
9191
9407
|
lensSurfaces.forEach((surface) => surface.clearTraceHistory());
|
|
9192
9408
|
eyeSurfaces.forEach((surface) => surface.clearTraceHistory());
|
|
9193
|
-
const emittedSourceRays = this.light_source.emitRays()
|
|
9194
|
-
const sourceRays =
|
|
9195
|
-
? emittedSourceRays
|
|
9196
|
-
: emittedSourceRays.filter((ray) => this.isRayInsidePupil(ray));
|
|
9409
|
+
const emittedSourceRays = this.light_source.emitRays();
|
|
9410
|
+
const sourceRays = emittedSourceRays;
|
|
9197
9411
|
this.lastSourceRaysForSturm = sourceRays.map((ray) => ray.clone());
|
|
9198
9412
|
const traced = [];
|
|
9199
9413
|
for (const sourceRay of sourceRays) {
|
|
@@ -9212,8 +9426,6 @@ class SCAXEngineCore {
|
|
|
9212
9426
|
traced.push(activeRay);
|
|
9213
9427
|
continue;
|
|
9214
9428
|
}
|
|
9215
|
-
activeRay = this.applyPrismVectorToRay(activeRay, this.lensPrismVector);
|
|
9216
|
-
activeRay = this.transformRayAroundPivot(activeRay, this.eyeRotationQuaternionInverse, this.eyeRotationPivot);
|
|
9217
9429
|
for (const surface of eyeSurfaces) {
|
|
9218
9430
|
const nextRay = surface.refract(activeRay);
|
|
9219
9431
|
if (!(nextRay instanceof Ray)) {
|
|
@@ -9222,7 +9434,6 @@ class SCAXEngineCore {
|
|
|
9222
9434
|
}
|
|
9223
9435
|
activeRay = nextRay;
|
|
9224
9436
|
}
|
|
9225
|
-
activeRay = this.transformRayAroundPivot(activeRay, this.eyeRotationQuaternion, this.eyeRotationPivot);
|
|
9226
9437
|
if (valid) {
|
|
9227
9438
|
traced.push(activeRay);
|
|
9228
9439
|
}
|
|
@@ -9294,17 +9505,6 @@ class SCAXEngineCore {
|
|
|
9294
9505
|
const points = ray.points;
|
|
9295
9506
|
return Array.isArray(points) ? points : [];
|
|
9296
9507
|
}
|
|
9297
|
-
isRayInsidePupil(ray) {
|
|
9298
|
-
const diameter = this.pupilDiameterMm;
|
|
9299
|
-
if (!Number.isFinite(diameter) || diameter <= 0)
|
|
9300
|
-
return true;
|
|
9301
|
-
const radius = diameter / 2;
|
|
9302
|
-
const points = this.getRayPoints(ray);
|
|
9303
|
-
const origin = points[0];
|
|
9304
|
-
if (!origin)
|
|
9305
|
-
return false;
|
|
9306
|
-
return Math.hypot(origin.x, origin.y) <= radius + 1e-6;
|
|
9307
|
-
}
|
|
9308
9508
|
powerVectorFromCylinder(cylinderD, axisDeg) {
|
|
9309
9509
|
const c = Number(cylinderD);
|
|
9310
9510
|
const ax = ((Number(axisDeg) % 180) + 180) % 180;
|
|
@@ -9314,221 +9514,10 @@ class SCAXEngineCore {
|
|
|
9314
9514
|
const scale = -c / 2;
|
|
9315
9515
|
return { j0: scale * Math.cos(rad), j45: scale * Math.sin(rad) };
|
|
9316
9516
|
}
|
|
9317
|
-
aggregatePowerVector(powers) {
|
|
9318
|
-
let m = 0;
|
|
9319
|
-
let j0 = 0;
|
|
9320
|
-
let j45 = 0;
|
|
9321
|
-
for (const power of powers) {
|
|
9322
|
-
const sphere = Number(power?.s ?? 0);
|
|
9323
|
-
const cylinder = Number(power?.c ?? 0);
|
|
9324
|
-
const axisTABO = Number(power?.ax ?? 0);
|
|
9325
|
-
if (!Number.isFinite(sphere) || !Number.isFinite(cylinder) || !Number.isFinite(axisTABO))
|
|
9326
|
-
continue;
|
|
9327
|
-
const axisDeg = DegToTABO(axisTABO);
|
|
9328
|
-
const rad = (2 * axisDeg * Math.PI) / 180;
|
|
9329
|
-
const halfMinusCylinder = -cylinder / 2;
|
|
9330
|
-
m += sphere + (cylinder / 2);
|
|
9331
|
-
j0 += halfMinusCylinder * Math.cos(rad);
|
|
9332
|
-
j45 += halfMinusCylinder * Math.sin(rad);
|
|
9333
|
-
}
|
|
9334
|
-
return { m, j0, j45 };
|
|
9335
|
-
}
|
|
9336
|
-
/** 난시 주경선 TABO 각도: 180° 동치이므로 표시·비교는 항상 [0, 180)으로 맞춘다. */
|
|
9337
|
-
normalizeTaboMeridian180(value) {
|
|
9338
|
-
const d = Number(value ?? 0);
|
|
9339
|
-
if (!Number.isFinite(d))
|
|
9340
|
-
return 0;
|
|
9341
|
-
return ((d % 180) + 180) % 180;
|
|
9342
|
-
}
|
|
9343
|
-
principalMeridiansFromVector(m, j0, j45) {
|
|
9344
|
-
if (!Number.isFinite(m) || !Number.isFinite(j0) || !Number.isFinite(j45))
|
|
9345
|
-
return [];
|
|
9346
|
-
const axisDeg = (((0.5 * Math.atan2(j45, j0) * 180) / Math.PI) % 180 + 180) % 180;
|
|
9347
|
-
const taboAxis = this.normalizeTaboMeridian180(TABOToDeg(axisDeg));
|
|
9348
|
-
const orthogonalTabo = (taboAxis + 90) % 180;
|
|
9349
|
-
const r = Math.hypot(j0, j45);
|
|
9350
|
-
const meridians = [
|
|
9351
|
-
{ tabo: taboAxis, d: m - r },
|
|
9352
|
-
{ tabo: orthogonalTabo, d: m + r },
|
|
9353
|
-
];
|
|
9354
|
-
return meridians.sort((a, b) => a.d - b.d);
|
|
9355
|
-
}
|
|
9356
|
-
astigmatismSummaryLensPowers() {
|
|
9357
|
-
return this.lensConfigs
|
|
9358
|
-
.filter((spec) => spec.includeInAstigmatismSummary)
|
|
9359
|
-
.map((spec) => ({
|
|
9360
|
-
s: this.toFiniteNumber(spec.s),
|
|
9361
|
-
c: this.toFiniteNumber(spec.c),
|
|
9362
|
-
ax: this.toFiniteNumber(spec.ax),
|
|
9363
|
-
}));
|
|
9364
|
-
}
|
|
9365
|
-
principalMeridiansFromPowers(powers) {
|
|
9366
|
-
if (powers.length === 0)
|
|
9367
|
-
return [];
|
|
9368
|
-
const { m, j0, j45 } = this.aggregatePowerVector(powers);
|
|
9369
|
-
return this.principalMeridiansFromVector(m, j0, j45);
|
|
9370
|
-
}
|
|
9371
|
-
normalizePrismAmount(value) {
|
|
9372
|
-
const p = Number(value ?? 0);
|
|
9373
|
-
return Number.isFinite(p) ? Math.max(0, p) : 0;
|
|
9374
|
-
}
|
|
9375
|
-
normalizeAngle360(value) {
|
|
9376
|
-
const d = Number(value ?? 0);
|
|
9377
|
-
if (!Number.isFinite(d))
|
|
9378
|
-
return 0;
|
|
9379
|
-
return ((d % 360) + 360) % 360;
|
|
9380
|
-
}
|
|
9381
|
-
normalizeEyeTilt(value) {
|
|
9382
|
-
return {
|
|
9383
|
-
x: this.toFiniteNumber(value?.x),
|
|
9384
|
-
y: this.toFiniteNumber(value?.y),
|
|
9385
|
-
};
|
|
9386
|
-
}
|
|
9387
|
-
normalizeLightSourcePose(value) {
|
|
9388
|
-
return {
|
|
9389
|
-
position: {
|
|
9390
|
-
x: this.toFiniteNumber(value?.position?.x),
|
|
9391
|
-
y: this.toFiniteNumber(value?.position?.y),
|
|
9392
|
-
z: this.toFiniteNumber(value?.position?.z),
|
|
9393
|
-
},
|
|
9394
|
-
tilt: {
|
|
9395
|
-
x: this.toFiniteNumber(value?.tilt?.x),
|
|
9396
|
-
y: this.toFiniteNumber(value?.tilt?.y),
|
|
9397
|
-
},
|
|
9398
|
-
};
|
|
9399
|
-
}
|
|
9400
|
-
toFiniteNumber(value, fallback = 0) {
|
|
9401
|
-
const parsed = Number(value);
|
|
9402
|
-
return Number.isFinite(parsed) ? parsed : fallback;
|
|
9403
|
-
}
|
|
9404
9517
|
refreshSortedSurfaces() {
|
|
9405
9518
|
this.sortedLensSurfaces = [...this.lens].sort((a, b) => this.surfaceOrderZ(a) - this.surfaceOrderZ(b));
|
|
9406
9519
|
this.sortedEyeSurfaces = [...this.surfaces].sort((a, b) => this.surfaceOrderZ(a) - this.surfaceOrderZ(b));
|
|
9407
9520
|
}
|
|
9408
|
-
prismVectorFromBase(prismDiopter, baseAngleDeg) {
|
|
9409
|
-
const p = this.normalizePrismAmount(prismDiopter);
|
|
9410
|
-
const baseAngle = this.normalizeAngle360(baseAngleDeg);
|
|
9411
|
-
// Clinical Base convention:
|
|
9412
|
-
// - input axis is prism base direction, viewed from lens side toward cornea (OD: right=0°)
|
|
9413
|
-
// - light deviation is opposite to base, so internally convert by +180°
|
|
9414
|
-
const deviationAngle = this.normalizeAngle360(baseAngle + 180);
|
|
9415
|
-
// Internal x/y uses math-style +x right, +y up.
|
|
9416
|
-
// Because user axis is defined from lens->cornea view, flip angular direction by negating theta.
|
|
9417
|
-
const rad = (-deviationAngle * Math.PI) / 180;
|
|
9418
|
-
return {
|
|
9419
|
-
x: p * Math.cos(rad),
|
|
9420
|
-
y: p * Math.sin(rad),
|
|
9421
|
-
};
|
|
9422
|
-
}
|
|
9423
|
-
vectorToPrismInfo(x, y) {
|
|
9424
|
-
const xx = Number.isFinite(x) ? x : 0;
|
|
9425
|
-
const yy = Number.isFinite(y) ? y : 0;
|
|
9426
|
-
const magnitude = Math.hypot(xx, yy);
|
|
9427
|
-
const angleDeg = this.normalizeAngle360((Math.atan2(yy, xx) * 180) / Math.PI);
|
|
9428
|
-
return {
|
|
9429
|
-
x: xx,
|
|
9430
|
-
y: yy,
|
|
9431
|
-
magnitude,
|
|
9432
|
-
angle_deg: magnitude < 1e-12 ? 0 : angleDeg,
|
|
9433
|
-
};
|
|
9434
|
-
}
|
|
9435
|
-
toPrismSummaryItem(value) {
|
|
9436
|
-
const magnitude = Number(value?.magnitude);
|
|
9437
|
-
return {
|
|
9438
|
-
p_x: Number(value?.x ?? 0),
|
|
9439
|
-
p_y: Number(value?.y ?? 0),
|
|
9440
|
-
prism_angle: this.normalizeAngle360(value?.angle_deg),
|
|
9441
|
-
magnitude: Number.isFinite(magnitude) && magnitude >= 1e-9 ? magnitude : null,
|
|
9442
|
-
};
|
|
9443
|
-
}
|
|
9444
|
-
prismComponentToAngleDeg(componentPrism) {
|
|
9445
|
-
const c = Number(componentPrism);
|
|
9446
|
-
if (!Number.isFinite(c))
|
|
9447
|
-
return 0;
|
|
9448
|
-
return (Math.atan(c / 100) * 180) / Math.PI;
|
|
9449
|
-
}
|
|
9450
|
-
prismMagnitudeToAngleDeg(prismDiopter) {
|
|
9451
|
-
const p = Number(prismDiopter);
|
|
9452
|
-
if (!Number.isFinite(p) || p < 1e-12)
|
|
9453
|
-
return 0;
|
|
9454
|
-
return (Math.atan(p / 100) * 180) / Math.PI;
|
|
9455
|
-
}
|
|
9456
|
-
applyPrismVectorToRay(ray, prism) {
|
|
9457
|
-
const px = Number(prism?.x ?? 0);
|
|
9458
|
-
const py = Number(prism?.y ?? 0);
|
|
9459
|
-
if (!Number.isFinite(px)
|
|
9460
|
-
|| !Number.isFinite(py)
|
|
9461
|
-
|| (Math.abs(px) < 1e-12 && Math.abs(py) < 1e-12)) {
|
|
9462
|
-
return ray;
|
|
9463
|
-
}
|
|
9464
|
-
const direction = ray.getDirection();
|
|
9465
|
-
const dz = Number(direction.z);
|
|
9466
|
-
if (!Number.isFinite(dz) || Math.abs(dz) < 1e-12)
|
|
9467
|
-
return ray;
|
|
9468
|
-
const tx = (direction.x / dz) + (px / 100);
|
|
9469
|
-
const ty = (direction.y / dz) + (py / 100);
|
|
9470
|
-
const signZ = dz >= 0 ? 1 : -1;
|
|
9471
|
-
const newDirection = new Vector3(tx * signZ, ty * signZ, signZ).normalize();
|
|
9472
|
-
if (!Number.isFinite(newDirection.x) || !Number.isFinite(newDirection.y) || !Number.isFinite(newDirection.z)) {
|
|
9473
|
-
return ray;
|
|
9474
|
-
}
|
|
9475
|
-
const updated = ray.clone();
|
|
9476
|
-
const origin = updated.endPoint();
|
|
9477
|
-
updated.continueFrom(origin.clone().addScaledVector(newDirection, RAY_SURFACE_ESCAPE_MM), newDirection);
|
|
9478
|
-
return updated;
|
|
9479
|
-
}
|
|
9480
|
-
rotatePointAroundPivot(point, rotation, pivot) {
|
|
9481
|
-
return point.clone().sub(pivot).applyQuaternion(rotation).add(pivot);
|
|
9482
|
-
}
|
|
9483
|
-
translateRay(ray, offset) {
|
|
9484
|
-
if (offset.lengthSq() < 1e-12)
|
|
9485
|
-
return ray;
|
|
9486
|
-
const points = this.getRayPoints(ray);
|
|
9487
|
-
if (!points.length)
|
|
9488
|
-
return ray;
|
|
9489
|
-
const translated = ray.clone();
|
|
9490
|
-
const translatedState = translated;
|
|
9491
|
-
const nextPoints = points.map((point) => point.clone().add(offset));
|
|
9492
|
-
translatedState.points = nextPoints;
|
|
9493
|
-
translatedState.origin = nextPoints[nextPoints.length - 1].clone();
|
|
9494
|
-
return translated;
|
|
9495
|
-
}
|
|
9496
|
-
transformRayAroundPivot(ray, rotation, pivot) {
|
|
9497
|
-
const points = this.getRayPoints(ray);
|
|
9498
|
-
if (!points.length)
|
|
9499
|
-
return ray;
|
|
9500
|
-
const transformed = ray.clone();
|
|
9501
|
-
const transformedState = transformed;
|
|
9502
|
-
const nextPoints = points.map((point) => this.rotatePointAroundPivot(point, rotation, pivot));
|
|
9503
|
-
transformedState.points = nextPoints;
|
|
9504
|
-
transformedState.direction = ray.getDirection().clone().applyQuaternion(rotation).normalize();
|
|
9505
|
-
transformedState.origin = nextPoints[nextPoints.length - 1].clone();
|
|
9506
|
-
return transformed;
|
|
9507
|
-
}
|
|
9508
|
-
applyLightSourceTransformToRay(ray) {
|
|
9509
|
-
const rotated = this.transformRayAroundPivot(ray, this.lightSourceRotationQuaternion, this.lightSourceRotationPivot);
|
|
9510
|
-
return this.translateRay(rotated, this.lightSourcePosition);
|
|
9511
|
-
}
|
|
9512
|
-
calculateLightDeviation() {
|
|
9513
|
-
// eye/lens 입력은 모두 임상 Base 방향이므로, prismVectorFromBase 내부에서
|
|
9514
|
-
// Base -> 광선편향(+180°) 변환이 적용됩니다.
|
|
9515
|
-
// eye는 "처방값(교정 필요량)"으로 해석하므로 실제 눈 편위는 역벡터입니다.
|
|
9516
|
-
const eyeEffect = this.vectorToPrismInfo(this.eyePrismEffectVector.x, this.eyePrismEffectVector.y);
|
|
9517
|
-
// lens 입력은 교정량이며, 렌즈는 Base 반대방향으로 광선을 실제 굴절시킵니다.
|
|
9518
|
-
// prismVectorFromBase에서 Base->광선편향이 이미 반영된 벡터를 그대로 합산합니다.
|
|
9519
|
-
const lensX = this.lensPrismVector.x;
|
|
9520
|
-
const lensY = this.lensPrismVector.y;
|
|
9521
|
-
const lensTotal = this.vectorToPrismInfo(lensX, lensY);
|
|
9522
|
-
const net = this.vectorToPrismInfo(eyeEffect.x + lensTotal.x, eyeEffect.y + lensTotal.y);
|
|
9523
|
-
return {
|
|
9524
|
-
eye_prism_effect: eyeEffect,
|
|
9525
|
-
lens_prism_total: lensTotal,
|
|
9526
|
-
net_prism: net,
|
|
9527
|
-
x_angle_deg: this.prismComponentToAngleDeg(net.x),
|
|
9528
|
-
y_angle_deg: this.prismComponentToAngleDeg(net.y),
|
|
9529
|
-
net_angle_deg: this.prismMagnitudeToAngleDeg(net.magnitude),
|
|
9530
|
-
};
|
|
9531
|
-
}
|
|
9532
9521
|
effectiveCylinderFromOpticSurfaces() {
|
|
9533
9522
|
let j0 = 0;
|
|
9534
9523
|
let j45 = 0;
|
|
@@ -9574,14 +9563,14 @@ class SCAXEngineCore {
|
|
|
9574
9563
|
.filter((pair) => Boolean(pair));
|
|
9575
9564
|
}
|
|
9576
9565
|
}
|
|
9577
|
-
SCAXEngineCore.EYE_ROTATION_PIVOT_FROM_CORNEA_MM = 13;
|
|
9578
9566
|
/**
|
|
9579
9567
|
* 외부 공개 API 전용 Facade입니다.
|
|
9580
9568
|
* 내부 광학 상태/연산은 SCAXEngineCore에 위임합니다.
|
|
9581
9569
|
*/
|
|
9582
9570
|
class SCAXEngine {
|
|
9583
9571
|
constructor(props = {}) {
|
|
9584
|
-
this.
|
|
9572
|
+
this.normalizedConfig = normalizeEngineProps(props);
|
|
9573
|
+
this.core = new SCAXEngineCore(this.normalizedConfig);
|
|
9585
9574
|
}
|
|
9586
9575
|
// Test/debug bridge for legacy direct field access patterns.
|
|
9587
9576
|
get lens() {
|
|
@@ -9594,7 +9583,8 @@ class SCAXEngine {
|
|
|
9594
9583
|
return this.core.surfaces;
|
|
9595
9584
|
}
|
|
9596
9585
|
update(props = {}) {
|
|
9597
|
-
this.
|
|
9586
|
+
this.normalizedConfig = normalizeEngineProps(props);
|
|
9587
|
+
this.core.update(this.normalizedConfig);
|
|
9598
9588
|
}
|
|
9599
9589
|
dispose() {
|
|
9600
9590
|
this.core.dispose();
|
|
@@ -9602,17 +9592,52 @@ class SCAXEngine {
|
|
|
9602
9592
|
simulate() {
|
|
9603
9593
|
return this.core.simulate();
|
|
9604
9594
|
}
|
|
9605
|
-
getEyeRotation() {
|
|
9606
|
-
return this.core.getEyeRotation();
|
|
9607
|
-
}
|
|
9608
9595
|
rayTracing() {
|
|
9609
9596
|
return this.core.rayTracing();
|
|
9610
9597
|
}
|
|
9611
9598
|
sturmCalculation(rays) {
|
|
9612
9599
|
return this.core.sturmCalculation(rays);
|
|
9613
9600
|
}
|
|
9614
|
-
|
|
9615
|
-
|
|
9601
|
+
calculateMeridians(scaxPowers) {
|
|
9602
|
+
const normalize180 = (angle) => (((angle % 180) + 180) % 180);
|
|
9603
|
+
const taboToDeg = (taboAngle) => normalize180(180 - taboAngle);
|
|
9604
|
+
const degToTabo = (degreeAngle) => normalize180(180 - degreeAngle);
|
|
9605
|
+
let m = 0;
|
|
9606
|
+
let j0 = 0;
|
|
9607
|
+
let j45 = 0;
|
|
9608
|
+
for (const power of Array.isArray(scaxPowers) ? scaxPowers : []) {
|
|
9609
|
+
const sphere = Number(power?.s ?? 0);
|
|
9610
|
+
const cylinder = Number(power?.c ?? 0);
|
|
9611
|
+
const axisTABO = Number(power?.ax ?? 0);
|
|
9612
|
+
if (!Number.isFinite(sphere) || !Number.isFinite(cylinder) || !Number.isFinite(axisTABO))
|
|
9613
|
+
continue;
|
|
9614
|
+
const axisDeg = taboToDeg(axisTABO);
|
|
9615
|
+
const rad = (2 * axisDeg * Math.PI) / 180;
|
|
9616
|
+
const halfMinusCylinder = -cylinder / 2;
|
|
9617
|
+
m += sphere + (cylinder / 2);
|
|
9618
|
+
j0 += halfMinusCylinder * Math.cos(rad);
|
|
9619
|
+
j45 += halfMinusCylinder * Math.sin(rad);
|
|
9620
|
+
}
|
|
9621
|
+
if (!Number.isFinite(m) || !Number.isFinite(j0) || !Number.isFinite(j45))
|
|
9622
|
+
return [];
|
|
9623
|
+
const axisDeg = normalize180((0.5 * Math.atan2(j45, j0) * 180) / Math.PI);
|
|
9624
|
+
const taboAxis = degToTabo(axisDeg);
|
|
9625
|
+
const orthogonalTabo = (taboAxis + 90) % 180;
|
|
9626
|
+
const r = Math.hypot(j0, j45);
|
|
9627
|
+
return [
|
|
9628
|
+
{ tabo: taboAxis, d: m - r },
|
|
9629
|
+
{ tabo: orthogonalTabo, d: m + r },
|
|
9630
|
+
].sort((a, b) => a.d - b.d);
|
|
9631
|
+
}
|
|
9632
|
+
calculateEyeRotationByPrism(prism) {
|
|
9633
|
+
// 프리즘 처방으로 인한 안구 회전량만 계산 (tilt 제외)
|
|
9634
|
+
const p = normalizePrismAmount(prism?.p);
|
|
9635
|
+
const p_ax = normalizeAngle360(prism?.p_ax);
|
|
9636
|
+
const rotation = eyeRotationForRenderDegrees(p, p_ax, { x: 0, y: 0 });
|
|
9637
|
+
return {
|
|
9638
|
+
x: Number.isFinite(rotation.x_deg) ? rotation.x_deg : 0,
|
|
9639
|
+
y: Number.isFinite(rotation.y_deg) ? rotation.y_deg : 0,
|
|
9640
|
+
};
|
|
9616
9641
|
}
|
|
9617
9642
|
}
|
|
9618
9643
|
|