3d-odontogram 0.0.0-stage → 0.1.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 +122 -2
- package/dist/index.cjs +601 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +145 -0
- package/dist/index.d.ts +145 -0
- package/dist/index.js +567 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
|
|
3
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
4
|
+
var __propIsEnum = Object.prototype.propertyIsEnumerable;
|
|
5
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
6
|
+
var __spreadValues = (a, b) => {
|
|
7
|
+
for (var prop in b || (b = {}))
|
|
8
|
+
if (__hasOwnProp.call(b, prop))
|
|
9
|
+
__defNormalProp(a, prop, b[prop]);
|
|
10
|
+
if (__getOwnPropSymbols)
|
|
11
|
+
for (var prop of __getOwnPropSymbols(b)) {
|
|
12
|
+
if (__propIsEnum.call(b, prop))
|
|
13
|
+
__defNormalProp(a, prop, b[prop]);
|
|
14
|
+
}
|
|
15
|
+
return a;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// src/Odontogram3D.tsx
|
|
19
|
+
import {
|
|
20
|
+
forwardRef,
|
|
21
|
+
useCallback,
|
|
22
|
+
useEffect,
|
|
23
|
+
useImperativeHandle,
|
|
24
|
+
useRef
|
|
25
|
+
} from "react";
|
|
26
|
+
|
|
27
|
+
// src/engine.ts
|
|
28
|
+
import * as THREE2 from "three";
|
|
29
|
+
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
30
|
+
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
31
|
+
|
|
32
|
+
// src/theme.ts
|
|
33
|
+
import * as THREE from "three";
|
|
34
|
+
var DEFAULT_THEME = {
|
|
35
|
+
toothColor: 16119260,
|
|
36
|
+
// Ivory
|
|
37
|
+
highlightColor: 3900150,
|
|
38
|
+
// Tailwind blue-500
|
|
39
|
+
markerColor: 16711680,
|
|
40
|
+
// Red
|
|
41
|
+
canvasBackground: 15528177,
|
|
42
|
+
canvasBackgroundOpacity: 0,
|
|
43
|
+
ambientLightColor: 16777215,
|
|
44
|
+
ambientLightIntensity: 0.8,
|
|
45
|
+
keyLightColor: 16777215,
|
|
46
|
+
keyLightIntensity: 0.6,
|
|
47
|
+
fillLightColor: 16777215,
|
|
48
|
+
fillLightIntensity: 0.4
|
|
49
|
+
};
|
|
50
|
+
function mergeTheme(theme) {
|
|
51
|
+
return __spreadValues(__spreadValues({}, DEFAULT_THEME), theme);
|
|
52
|
+
}
|
|
53
|
+
function toHexColor(input) {
|
|
54
|
+
if (typeof input === "number") return input;
|
|
55
|
+
return new THREE.Color(input).getHex();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/toothNameMaps.ts
|
|
59
|
+
var TOOTH_MESH_NAME_MAP_CHILD = {
|
|
60
|
+
Gums_Maxilla001_0: "Gums Maxilla",
|
|
61
|
+
Gums_Mandibula001_0: "Gums Mandibulla",
|
|
62
|
+
A_0: "(Left Top) Molar",
|
|
63
|
+
B_0: "(Left Top) Premolar",
|
|
64
|
+
C_0: "(Left Top) Canine",
|
|
65
|
+
D_0: "(Left Top) Lateral",
|
|
66
|
+
E_0: "(Left Top) Central",
|
|
67
|
+
F_0: "(Right Top) Central",
|
|
68
|
+
G_0: "(Right Top) Lateral",
|
|
69
|
+
H_0: "(Right Top) Canine",
|
|
70
|
+
I_0: "(Right Top) Premolar",
|
|
71
|
+
J_0: "(Right Top) Molar",
|
|
72
|
+
K_0: "(Left Bottom) Molar",
|
|
73
|
+
L_0: "(Left Bottom) Premolar",
|
|
74
|
+
M_0: "(Left Bottom) Canine",
|
|
75
|
+
N_0: "(Left Bottom) Lateral",
|
|
76
|
+
O_0: "(Left Bottom) Central",
|
|
77
|
+
P_0: "(Right Bottom) Central",
|
|
78
|
+
Q_0: "(Right Bottom) Lateral",
|
|
79
|
+
R_0: "(Right Bottom) Canine",
|
|
80
|
+
S_0: "(Right Bottom) Premolar",
|
|
81
|
+
T_0: "(Right Bottom) Molar"
|
|
82
|
+
};
|
|
83
|
+
var TOOTH_MESH_NAME_MAP_ADULT = {
|
|
84
|
+
// --- Rahang Atas Kanan (Quadrant 1) ---
|
|
85
|
+
"Box023_01_-_Default_0": "11 (Right Top) Central Incisor",
|
|
86
|
+
"Box024_01_-_Default_0": "12 (Right Top) Lateral Incisor",
|
|
87
|
+
"Box025_01_-_Default_0": "13 (Right Top) Canine",
|
|
88
|
+
"Box031_01_-_Default_0": "14 (Right Top) First Premolar",
|
|
89
|
+
"Box033_01_-_Default_0": "15 (Right Top) Second Premolar",
|
|
90
|
+
"Box032_01_-_Default_0": "16 (Right Top) First Molar",
|
|
91
|
+
"Box035_01_-_Default_0": "17 (Right Top) Second Molar",
|
|
92
|
+
"Box034_01_-_Default_0": "18 (Right Top) Third Molar",
|
|
93
|
+
// --- Rahang Atas Kiri (Quadrant 2) ---
|
|
94
|
+
"Box020_01_-_Default_0": "21 (Left Top) Central Incisor",
|
|
95
|
+
"Box021_01_-_Default_0": "22 (Left Top) Lateral Incisor",
|
|
96
|
+
"Box022_01_-_Default_0": "23 (Left Top) Canine",
|
|
97
|
+
"Box026_01_-_Default_0": "24 (Left Top) First Premolar",
|
|
98
|
+
"Box028_01_-_Default_0": "25 (Left Top) Second Premolar",
|
|
99
|
+
"Box027_01_-_Default_0": "26 (Left Top) First Molar",
|
|
100
|
+
"Box030_01_-_Default_0": "27 (Left Top) Second Molar",
|
|
101
|
+
"Box029_01_-_Default_0": "28 (Left Top) Third Molar",
|
|
102
|
+
// --- Rahang Bawah Kiri (Quadrant 3) ---
|
|
103
|
+
"Box011_01_-_Default_0": "38 (Left Bottom) Third Molar",
|
|
104
|
+
"Box013_01_-_Default_0": "37 (Left Bottom) Second Molar",
|
|
105
|
+
"Box008_01_-_Default_0": "36 (Left Bottom) First Molar",
|
|
106
|
+
"Box009_01_-_Default_0": "35 (Left Bottom) Second Premolar",
|
|
107
|
+
"Box007_01_-_Default_0": "34 (Left Bottom) First Premolar",
|
|
108
|
+
"Box003_01_-_Default_0": "33 (Left Bottom) Canine",
|
|
109
|
+
"Box002_01_-_Default_0": "32 (Left Bottom) Lateral Incisor",
|
|
110
|
+
"Box001_01_-_Default_0": "31 (Left Bottom) Central Incisor",
|
|
111
|
+
// --- Rahang Bawah Kanan (Quadrant 4) ---
|
|
112
|
+
"Box004_01_-_Default_0": "41 (Right Bottom) Central Incisor",
|
|
113
|
+
"Box005_01_-_Default_0": "42 (Right Bottom) Lateral Incisor",
|
|
114
|
+
"Box006_01_-_Default_0": "43 (Right Bottom) Canine",
|
|
115
|
+
"Box015_01_-_Default_0": "44 (Right Bottom) First Premolar",
|
|
116
|
+
"Box017_01_-_Default_0": "45 (Right Bottom) Second Premolar",
|
|
117
|
+
"Box016_01_-_Default_0": "46 (Right Bottom) First Molar",
|
|
118
|
+
"Box019_01_-_Default_0": "47 (Right Bottom) Second Molar",
|
|
119
|
+
"GeoSphere001_01_-_Default_0": "48 (Right Bottom) Third Molar",
|
|
120
|
+
// --- Lain-lain ---
|
|
121
|
+
"Tube002_07_-_Default_0": "(Top Tube) Gums Maxilla",
|
|
122
|
+
"Tube001_07_-_Default_0": "(Bottom Tube) Gums Mandibula",
|
|
123
|
+
"lingua_02_-_Default_0": "(Tongue) Lingua"
|
|
124
|
+
};
|
|
125
|
+
function mapForToothType(toothType) {
|
|
126
|
+
return toothType === "child" || !toothType ? TOOTH_MESH_NAME_MAP_CHILD : TOOTH_MESH_NAME_MAP_ADULT;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/engine.ts
|
|
130
|
+
var DEFAULT_MODEL_FILE_NAMES = { child: "ip1.bin.gz", adult: "ip3.bin.gz" };
|
|
131
|
+
var OdontogramEngine = class {
|
|
132
|
+
constructor(canvas, options = {}) {
|
|
133
|
+
this.raycaster = new THREE2.Raycaster();
|
|
134
|
+
this.pointer = new THREE2.Vector2();
|
|
135
|
+
this.toothMeshes = [];
|
|
136
|
+
this.animationFrameId = null;
|
|
137
|
+
this.mappedPoints = [];
|
|
138
|
+
this.disposed = false;
|
|
139
|
+
this.lights = null;
|
|
140
|
+
var _a, _b;
|
|
141
|
+
this.canvas = canvas;
|
|
142
|
+
this.options = options;
|
|
143
|
+
this.theme = mergeTheme(options.theme);
|
|
144
|
+
this.scene = new THREE2.Scene();
|
|
145
|
+
this.camera = new THREE2.PerspectiveCamera(
|
|
146
|
+
50,
|
|
147
|
+
canvas.clientWidth / canvas.clientHeight || 1,
|
|
148
|
+
0.1,
|
|
149
|
+
200
|
|
150
|
+
);
|
|
151
|
+
this.camera.position.set(0, 2.5, 100);
|
|
152
|
+
this.renderer = new THREE2.WebGLRenderer({ antialias: true, canvas, alpha: true });
|
|
153
|
+
this.renderer.setSize(canvas.clientWidth, canvas.clientHeight);
|
|
154
|
+
this.renderer.setPixelRatio(window.devicePixelRatio);
|
|
155
|
+
this.applyCanvasBackground();
|
|
156
|
+
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
157
|
+
this.controls.enableDamping = true;
|
|
158
|
+
this.controls.dampingFactor = 0.05;
|
|
159
|
+
this.controls.screenSpacePanning = false;
|
|
160
|
+
this.controls.minDistance = (_a = options.minDistance) != null ? _a : 100;
|
|
161
|
+
this.controls.maxDistance = (_b = options.maxDistance) != null ? _b : 130;
|
|
162
|
+
this.controls.target.set(4, 8, 0);
|
|
163
|
+
this.toothMaterialTemplate = new THREE2.MeshPhongMaterial({
|
|
164
|
+
color: toHexColor(this.theme.toothColor),
|
|
165
|
+
specular: 5592405,
|
|
166
|
+
shininess: 30,
|
|
167
|
+
flatShading: false,
|
|
168
|
+
side: THREE2.DoubleSide
|
|
169
|
+
});
|
|
170
|
+
this.setupLights();
|
|
171
|
+
const animate = () => {
|
|
172
|
+
if (this.disposed) return;
|
|
173
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
174
|
+
this.controls.update();
|
|
175
|
+
this.renderer.render(this.scene, this.camera);
|
|
176
|
+
};
|
|
177
|
+
animate();
|
|
178
|
+
}
|
|
179
|
+
setupLights() {
|
|
180
|
+
const ambient = new THREE2.AmbientLight(
|
|
181
|
+
toHexColor(this.theme.ambientLightColor),
|
|
182
|
+
this.theme.ambientLightIntensity
|
|
183
|
+
);
|
|
184
|
+
this.scene.add(ambient);
|
|
185
|
+
const key = new THREE2.DirectionalLight(
|
|
186
|
+
toHexColor(this.theme.keyLightColor),
|
|
187
|
+
this.theme.keyLightIntensity
|
|
188
|
+
);
|
|
189
|
+
key.position.set(10, 10, 10);
|
|
190
|
+
this.scene.add(key);
|
|
191
|
+
const fill = new THREE2.DirectionalLight(
|
|
192
|
+
toHexColor(this.theme.fillLightColor),
|
|
193
|
+
this.theme.fillLightIntensity
|
|
194
|
+
);
|
|
195
|
+
fill.position.set(-10, -10, -5);
|
|
196
|
+
this.scene.add(fill);
|
|
197
|
+
this.lights = { ambient, key, fill };
|
|
198
|
+
}
|
|
199
|
+
applyCanvasBackground() {
|
|
200
|
+
this.renderer.setClearColor(
|
|
201
|
+
toHexColor(this.theme.canvasBackground),
|
|
202
|
+
this.theme.canvasBackgroundOpacity
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
resolveModelUrl(toothType) {
|
|
206
|
+
var _a;
|
|
207
|
+
const base = (_a = this.options.modelBaseUrl) != null ? _a : "/assets/bin";
|
|
208
|
+
const fileNames = __spreadValues(__spreadValues({}, DEFAULT_MODEL_FILE_NAMES), this.options.modelFileNames);
|
|
209
|
+
const fileName = toothType === "adult" ? fileNames.adult : fileNames.child;
|
|
210
|
+
return `${base.replace(/\/$/, "")}/${fileName}`;
|
|
211
|
+
}
|
|
212
|
+
/** Re-apply a (partial) theme to lights, background and every tooth currently in its default state. */
|
|
213
|
+
applyTheme(theme) {
|
|
214
|
+
this.theme = mergeTheme(__spreadValues(__spreadValues({}, this.theme), theme));
|
|
215
|
+
this.applyCanvasBackground();
|
|
216
|
+
if (this.lights) {
|
|
217
|
+
this.lights.ambient.color.setHex(toHexColor(this.theme.ambientLightColor));
|
|
218
|
+
this.lights.ambient.intensity = this.theme.ambientLightIntensity;
|
|
219
|
+
this.lights.key.color.setHex(toHexColor(this.theme.keyLightColor));
|
|
220
|
+
this.lights.key.intensity = this.theme.keyLightIntensity;
|
|
221
|
+
this.lights.fill.color.setHex(toHexColor(this.theme.fillLightColor));
|
|
222
|
+
this.lights.fill.intensity = this.theme.fillLightIntensity;
|
|
223
|
+
}
|
|
224
|
+
this.toothMaterialTemplate.color.setHex(toHexColor(this.theme.toothColor));
|
|
225
|
+
const mappedToothIds = new Set(this.mappedPoints.map((p) => p.toothId));
|
|
226
|
+
this.forEachToothGroup((group) => {
|
|
227
|
+
const isMapped = Boolean(group.userData.isMapped);
|
|
228
|
+
const color = isMapped ? this.theme.highlightColor : this.theme.toothColor;
|
|
229
|
+
this.setGroupColor(group, toHexColor(color));
|
|
230
|
+
});
|
|
231
|
+
this.scene.children.filter((child) => child.name.startsWith("Marker-")).forEach((marker) => {
|
|
232
|
+
if (marker instanceof THREE2.Mesh && marker.material instanceof THREE2.MeshBasicMaterial) {
|
|
233
|
+
marker.material.color.setHex(toHexColor(this.theme.markerColor));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
void mappedToothIds;
|
|
237
|
+
}
|
|
238
|
+
forEachToothGroup(callback) {
|
|
239
|
+
const seen = /* @__PURE__ */ new Set();
|
|
240
|
+
this.toothMeshes.forEach((mesh) => {
|
|
241
|
+
const group = mesh.userData.parentGroup;
|
|
242
|
+
if (group && !seen.has(group)) {
|
|
243
|
+
seen.add(group);
|
|
244
|
+
callback(group);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
setGroupColor(group, hex) {
|
|
249
|
+
group.traverse((child) => {
|
|
250
|
+
if (child instanceof THREE2.Mesh && child.userData.parentGroup === group && child.material instanceof THREE2.MeshPhongMaterial) {
|
|
251
|
+
child.material.color.setHex(hex);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
async loadModel(toothType) {
|
|
256
|
+
var _a, _b, _c, _d, _e, _f;
|
|
257
|
+
const oldArchGroup = this.scene.getObjectByName("ArchGroup");
|
|
258
|
+
if (oldArchGroup) {
|
|
259
|
+
this.scene.remove(oldArchGroup);
|
|
260
|
+
oldArchGroup.traverse((child) => {
|
|
261
|
+
if (child instanceof THREE2.Mesh) {
|
|
262
|
+
child.geometry.dispose();
|
|
263
|
+
const material = child.material;
|
|
264
|
+
if (Array.isArray(material)) material.forEach((m) => m.dispose());
|
|
265
|
+
else material.dispose();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
this.toothMeshes = [];
|
|
270
|
+
this.mappedPoints = [];
|
|
271
|
+
(_b = (_a = this.options).onPointsChange) == null ? void 0 : _b.call(_a, []);
|
|
272
|
+
const loader = new GLTFLoader();
|
|
273
|
+
const modelUrl = this.resolveModelUrl(toothType);
|
|
274
|
+
(_d = (_c = this.options).onLoadingChange) == null ? void 0 : _d.call(_c, true);
|
|
275
|
+
try {
|
|
276
|
+
const gltf = await loader.loadAsync(modelUrl);
|
|
277
|
+
if (this.disposed) return;
|
|
278
|
+
const loadedModel = gltf.scene;
|
|
279
|
+
loadedModel.scale.set(1, 1, 1);
|
|
280
|
+
loadedModel.position.set(0, -2, 0);
|
|
281
|
+
loadedModel.rotation.set(0, 0, 0);
|
|
282
|
+
loadedModel.name = "ArchGroup";
|
|
283
|
+
const nameMap = mapForToothType(toothType);
|
|
284
|
+
loadedModel.traverse((child) => {
|
|
285
|
+
if (!(child instanceof THREE2.Mesh)) return;
|
|
286
|
+
child.geometry.computeVertexNormals();
|
|
287
|
+
if (child.geometry.attributes.color) {
|
|
288
|
+
child.geometry.deleteAttribute("color");
|
|
289
|
+
}
|
|
290
|
+
let mappedName = nameMap[child.name];
|
|
291
|
+
let targetObject = child;
|
|
292
|
+
if (!mappedName && child.parent && child.parent.type !== "Scene") {
|
|
293
|
+
if (nameMap[child.parent.name]) {
|
|
294
|
+
mappedName = nameMap[child.parent.name];
|
|
295
|
+
targetObject = child.parent;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
child.material = this.toothMaterialTemplate.clone();
|
|
299
|
+
if (!targetObject.userData.isTooth) {
|
|
300
|
+
targetObject.userData = {
|
|
301
|
+
toothId: mappedName != null ? mappedName : child.name,
|
|
302
|
+
isTooth: true,
|
|
303
|
+
isMapped: false,
|
|
304
|
+
markerIds: []
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
child.userData.parentGroup = targetObject;
|
|
308
|
+
this.toothMeshes.push(child);
|
|
309
|
+
});
|
|
310
|
+
this.scene.add(loadedModel);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
console.error(`[odontogram-3d] Failed to load model at ${modelUrl}:`, error);
|
|
313
|
+
} finally {
|
|
314
|
+
(_f = (_e = this.options).onLoadingChange) == null ? void 0 : _f.call(_e, false);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
removeMarkers(markerIds) {
|
|
318
|
+
markerIds.forEach((id) => {
|
|
319
|
+
const marker = this.scene.getObjectByName(`Marker-${id}`);
|
|
320
|
+
if (marker instanceof THREE2.Mesh) {
|
|
321
|
+
this.scene.remove(marker);
|
|
322
|
+
marker.geometry.dispose();
|
|
323
|
+
marker.material.dispose();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
addMarker(id, position) {
|
|
328
|
+
const geometry = new THREE2.SphereGeometry(0.05, 8, 8);
|
|
329
|
+
const material = new THREE2.MeshBasicMaterial({ color: toHexColor(this.theme.markerColor) });
|
|
330
|
+
const marker = new THREE2.Mesh(geometry, material);
|
|
331
|
+
marker.position.copy(position);
|
|
332
|
+
marker.name = `Marker-${id}`;
|
|
333
|
+
this.scene.add(marker);
|
|
334
|
+
}
|
|
335
|
+
emitPointsChange() {
|
|
336
|
+
var _a, _b;
|
|
337
|
+
(_b = (_a = this.options).onPointsChange) == null ? void 0 : _b.call(_a, [...this.mappedPoints]);
|
|
338
|
+
}
|
|
339
|
+
/** Handle a click/tap on the canvas (expects coordinates relative to the viewport, like a MouseEvent). */
|
|
340
|
+
handlePointerDown(clientX, clientY) {
|
|
341
|
+
var _a;
|
|
342
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
343
|
+
this.pointer.x = (clientX - rect.left) / rect.width * 2 - 1;
|
|
344
|
+
this.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
|
345
|
+
this.raycaster.setFromCamera(this.pointer, this.camera);
|
|
346
|
+
const intersects = this.raycaster.intersectObjects(this.toothMeshes, false);
|
|
347
|
+
if (intersects.length === 0) return;
|
|
348
|
+
const clickedMesh = intersects[0].object;
|
|
349
|
+
const toothGroup = clickedMesh.userData.parentGroup;
|
|
350
|
+
if (!toothGroup || !toothGroup.userData.isTooth) return;
|
|
351
|
+
const toothId = toothGroup.userData.toothId;
|
|
352
|
+
const point = intersects[0].point;
|
|
353
|
+
const isCurrentlyMapped = Boolean(toothGroup.userData.isMapped);
|
|
354
|
+
if (isCurrentlyMapped) {
|
|
355
|
+
this.removeMarkers((_a = toothGroup.userData.markerIds) != null ? _a : []);
|
|
356
|
+
this.setGroupColor(toothGroup, toHexColor(this.theme.toothColor));
|
|
357
|
+
toothGroup.userData.isMapped = false;
|
|
358
|
+
toothGroup.userData.markerIds = [];
|
|
359
|
+
this.mappedPoints = this.mappedPoints.filter((p) => p.toothId !== toothId);
|
|
360
|
+
this.emitPointsChange();
|
|
361
|
+
} else {
|
|
362
|
+
const newMarkerId = Date.now();
|
|
363
|
+
this.setGroupColor(toothGroup, toHexColor(this.theme.highlightColor));
|
|
364
|
+
toothGroup.userData.isMapped = true;
|
|
365
|
+
toothGroup.userData.markerIds = [newMarkerId];
|
|
366
|
+
this.addMarker(newMarkerId, point);
|
|
367
|
+
const newPoint = {
|
|
368
|
+
id: newMarkerId,
|
|
369
|
+
toothId,
|
|
370
|
+
x: point.x.toFixed(2),
|
|
371
|
+
y: point.y.toFixed(2),
|
|
372
|
+
z: point.z.toFixed(2),
|
|
373
|
+
type: "Karies",
|
|
374
|
+
description: ""
|
|
375
|
+
};
|
|
376
|
+
this.mappedPoints = [...this.mappedPoints, newPoint];
|
|
377
|
+
this.emitPointsChange();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
getMappedPoints() {
|
|
381
|
+
return [...this.mappedPoints];
|
|
382
|
+
}
|
|
383
|
+
/** Patch metadata (e.g. description) on an existing point without touching the 3D scene. */
|
|
384
|
+
updatePoint(id, patch) {
|
|
385
|
+
this.mappedPoints = this.mappedPoints.map((p) => p.id === id ? __spreadValues(__spreadValues({}, p), patch) : p);
|
|
386
|
+
this.emitPointsChange();
|
|
387
|
+
}
|
|
388
|
+
resetMapping() {
|
|
389
|
+
const existingMarkerIds = this.scene.children.filter((c) => c.name.startsWith("Marker-")).map((c) => Number(c.name.replace("Marker-", "")));
|
|
390
|
+
this.removeMarkers(existingMarkerIds);
|
|
391
|
+
this.forEachToothGroup((group) => {
|
|
392
|
+
this.setGroupColor(group, toHexColor(this.theme.toothColor));
|
|
393
|
+
group.userData.isMapped = false;
|
|
394
|
+
group.userData.markerIds = [];
|
|
395
|
+
});
|
|
396
|
+
this.mappedPoints = [];
|
|
397
|
+
this.emitPointsChange();
|
|
398
|
+
}
|
|
399
|
+
/** Replace all diagnostic points and redraw markers/highlights to match (e.g. after importing JSON). */
|
|
400
|
+
setMappedPoints(points) {
|
|
401
|
+
this.resetMapping();
|
|
402
|
+
points.forEach((point) => {
|
|
403
|
+
var _a, _b;
|
|
404
|
+
const group = (_a = this.toothMeshes.find(
|
|
405
|
+
(mesh) => {
|
|
406
|
+
var _a2;
|
|
407
|
+
return ((_a2 = mesh.userData.parentGroup) == null ? void 0 : _a2.userData.toothId) === point.toothId;
|
|
408
|
+
}
|
|
409
|
+
)) == null ? void 0 : _a.userData.parentGroup;
|
|
410
|
+
if (!group) return;
|
|
411
|
+
this.setGroupColor(group, toHexColor(this.theme.highlightColor));
|
|
412
|
+
group.userData.isMapped = true;
|
|
413
|
+
group.userData.markerIds = [...(_b = group.userData.markerIds) != null ? _b : [], point.id];
|
|
414
|
+
this.addMarker(point.id, new THREE2.Vector3(parseFloat(point.x), parseFloat(point.y), parseFloat(point.z)));
|
|
415
|
+
});
|
|
416
|
+
this.mappedPoints = [...points];
|
|
417
|
+
this.emitPointsChange();
|
|
418
|
+
}
|
|
419
|
+
resize() {
|
|
420
|
+
const container = this.canvas.parentElement;
|
|
421
|
+
if (!container) return;
|
|
422
|
+
const { clientWidth, clientHeight } = container;
|
|
423
|
+
if (clientWidth === 0 || clientHeight === 0) return;
|
|
424
|
+
this.camera.aspect = clientWidth / clientHeight;
|
|
425
|
+
this.camera.updateProjectionMatrix();
|
|
426
|
+
this.renderer.setSize(clientWidth, clientHeight, false);
|
|
427
|
+
this.renderer.setPixelRatio(window.devicePixelRatio);
|
|
428
|
+
}
|
|
429
|
+
dispose() {
|
|
430
|
+
this.disposed = true;
|
|
431
|
+
if (this.animationFrameId !== null) cancelAnimationFrame(this.animationFrameId);
|
|
432
|
+
const oldArchGroup = this.scene.getObjectByName("ArchGroup");
|
|
433
|
+
if (oldArchGroup) this.scene.remove(oldArchGroup);
|
|
434
|
+
this.scene.traverse((child) => {
|
|
435
|
+
if (child instanceof THREE2.Mesh) {
|
|
436
|
+
child.geometry.dispose();
|
|
437
|
+
const material = child.material;
|
|
438
|
+
if (Array.isArray(material)) material.forEach((m) => m.dispose());
|
|
439
|
+
else material == null ? void 0 : material.dispose();
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
this.toothMaterialTemplate.dispose();
|
|
443
|
+
this.controls.dispose();
|
|
444
|
+
this.renderer.dispose();
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// src/Odontogram3D.tsx
|
|
449
|
+
import { jsx } from "react/jsx-runtime";
|
|
450
|
+
var Odontogram3D = forwardRef(
|
|
451
|
+
function Odontogram3D2({
|
|
452
|
+
toothType = "child",
|
|
453
|
+
theme,
|
|
454
|
+
modelBaseUrl,
|
|
455
|
+
modelFileNames,
|
|
456
|
+
initialPoints,
|
|
457
|
+
onPointsChange,
|
|
458
|
+
onLoadingChange,
|
|
459
|
+
minDistance,
|
|
460
|
+
maxDistance,
|
|
461
|
+
className,
|
|
462
|
+
style
|
|
463
|
+
}, ref) {
|
|
464
|
+
const canvasRef = useRef(null);
|
|
465
|
+
const engineRef = useRef(null);
|
|
466
|
+
const isLoadingRef = useRef(true);
|
|
467
|
+
const onPointsChangeRef = useRef(onPointsChange);
|
|
468
|
+
const onLoadingChangeRef = useRef(onLoadingChange);
|
|
469
|
+
onPointsChangeRef.current = onPointsChange;
|
|
470
|
+
onLoadingChangeRef.current = onLoadingChange;
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
const canvas = canvasRef.current;
|
|
473
|
+
if (!canvas) return;
|
|
474
|
+
const engine = new OdontogramEngine(canvas, {
|
|
475
|
+
theme,
|
|
476
|
+
modelBaseUrl,
|
|
477
|
+
modelFileNames,
|
|
478
|
+
minDistance,
|
|
479
|
+
maxDistance,
|
|
480
|
+
onLoadingChange: (loading) => {
|
|
481
|
+
var _a;
|
|
482
|
+
isLoadingRef.current = loading;
|
|
483
|
+
(_a = onLoadingChangeRef.current) == null ? void 0 : _a.call(onLoadingChangeRef, loading);
|
|
484
|
+
},
|
|
485
|
+
onPointsChange: (points) => {
|
|
486
|
+
var _a;
|
|
487
|
+
return (_a = onPointsChangeRef.current) == null ? void 0 : _a.call(onPointsChangeRef, points);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
engineRef.current = engine;
|
|
491
|
+
engine.loadModel(toothType).then(() => {
|
|
492
|
+
if (initialPoints && initialPoints.length > 0) {
|
|
493
|
+
engine.setMappedPoints(initialPoints);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
const handleResize = () => engine.resize();
|
|
497
|
+
window.addEventListener("resize", handleResize);
|
|
498
|
+
return () => {
|
|
499
|
+
window.removeEventListener("resize", handleResize);
|
|
500
|
+
engine.dispose();
|
|
501
|
+
engineRef.current = null;
|
|
502
|
+
};
|
|
503
|
+
}, []);
|
|
504
|
+
const previousToothType = useRef(toothType);
|
|
505
|
+
useEffect(() => {
|
|
506
|
+
var _a;
|
|
507
|
+
if (previousToothType.current === toothType) return;
|
|
508
|
+
previousToothType.current = toothType;
|
|
509
|
+
(_a = engineRef.current) == null ? void 0 : _a.loadModel(toothType);
|
|
510
|
+
}, [toothType]);
|
|
511
|
+
useEffect(() => {
|
|
512
|
+
var _a;
|
|
513
|
+
if (!theme) return;
|
|
514
|
+
(_a = engineRef.current) == null ? void 0 : _a.applyTheme(theme);
|
|
515
|
+
}, [theme]);
|
|
516
|
+
const handlePointerDown = useCallback((event) => {
|
|
517
|
+
var _a;
|
|
518
|
+
if (isLoadingRef.current) return;
|
|
519
|
+
(_a = engineRef.current) == null ? void 0 : _a.handlePointerDown(event.clientX, event.clientY);
|
|
520
|
+
}, []);
|
|
521
|
+
useImperativeHandle(
|
|
522
|
+
ref,
|
|
523
|
+
() => ({
|
|
524
|
+
resetMapping: () => {
|
|
525
|
+
var _a;
|
|
526
|
+
return (_a = engineRef.current) == null ? void 0 : _a.resetMapping();
|
|
527
|
+
},
|
|
528
|
+
getMappedPoints: () => {
|
|
529
|
+
var _a, _b;
|
|
530
|
+
return (_b = (_a = engineRef.current) == null ? void 0 : _a.getMappedPoints()) != null ? _b : [];
|
|
531
|
+
},
|
|
532
|
+
setMappedPoints: (points) => {
|
|
533
|
+
var _a;
|
|
534
|
+
return (_a = engineRef.current) == null ? void 0 : _a.setMappedPoints(points);
|
|
535
|
+
},
|
|
536
|
+
updatePoint: (id, patch) => {
|
|
537
|
+
var _a;
|
|
538
|
+
return (_a = engineRef.current) == null ? void 0 : _a.updatePoint(id, patch);
|
|
539
|
+
},
|
|
540
|
+
applyTheme: (nextTheme) => {
|
|
541
|
+
var _a;
|
|
542
|
+
return (_a = engineRef.current) == null ? void 0 : _a.applyTheme(nextTheme);
|
|
543
|
+
}
|
|
544
|
+
}),
|
|
545
|
+
[]
|
|
546
|
+
);
|
|
547
|
+
return /* @__PURE__ */ jsx(
|
|
548
|
+
"canvas",
|
|
549
|
+
{
|
|
550
|
+
ref: canvasRef,
|
|
551
|
+
className,
|
|
552
|
+
style: __spreadValues({ width: "100%", height: "100%" }, style),
|
|
553
|
+
onMouseDown: handlePointerDown
|
|
554
|
+
}
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
);
|
|
558
|
+
export {
|
|
559
|
+
DEFAULT_THEME,
|
|
560
|
+
Odontogram3D,
|
|
561
|
+
OdontogramEngine,
|
|
562
|
+
TOOTH_MESH_NAME_MAP_ADULT,
|
|
563
|
+
TOOTH_MESH_NAME_MAP_CHILD,
|
|
564
|
+
mergeTheme,
|
|
565
|
+
toHexColor
|
|
566
|
+
};
|
|
567
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/Odontogram3D.tsx","../src/engine.ts","../src/theme.ts","../src/toothNameMaps.ts"],"sourcesContent":["\"use client\";\n\nimport React, {\n forwardRef,\n useCallback,\n useEffect,\n useImperativeHandle,\n useRef,\n} from \"react\";\nimport { OdontogramEngine } from \"./engine\";\nimport type {\n MappedPoint,\n Odontogram3DHandle,\n OdontogramTheme,\n ToothType,\n} from \"./types\";\n\nexport interface Odontogram3DProps {\n /** \"child\" (20 teeth) or \"adult\" (32 teeth). Default: \"child\". */\n toothType?: ToothType;\n /** Colors for the tooth, highlight, marker, canvas background and lighting. */\n theme?: OdontogramTheme;\n /** Base path where the `.bin.gz` model files are served from. Default: \"/assets/bin\". */\n modelBaseUrl?: string;\n /** Override the model file name per tooth type. */\n modelFileNames?: { child?: string; adult?: string };\n /** Diagnostic points to render as soon as the model finishes loading (e.g. from imported JSON). */\n initialPoints?: MappedPoint[];\n /** Fires whenever a point is added/removed/replaced. */\n onPointsChange?: (points: MappedPoint[]) => void;\n /** Fires when the model starts/finishes loading. */\n onLoadingChange?: (loading: boolean) => void;\n minDistance?: number;\n maxDistance?: number;\n className?: string;\n style?: React.CSSProperties;\n}\n\n/**\n * Self-contained 3D odontogram canvas: owns its own Three.js scene/renderer/model instance,\n * so multiple instances can be mounted on the same page without colliding.\n */\nexport const Odontogram3D = forwardRef<Odontogram3DHandle, Odontogram3DProps>(\n function Odontogram3D(\n {\n toothType = \"child\",\n theme,\n modelBaseUrl,\n modelFileNames,\n initialPoints,\n onPointsChange,\n onLoadingChange,\n minDistance,\n maxDistance,\n className,\n style,\n },\n ref\n ) {\n const canvasRef = useRef<HTMLCanvasElement | null>(null);\n const engineRef = useRef<OdontogramEngine | null>(null);\n const isLoadingRef = useRef(true);\n const onPointsChangeRef = useRef(onPointsChange);\n const onLoadingChangeRef = useRef(onLoadingChange);\n onPointsChangeRef.current = onPointsChange;\n onLoadingChangeRef.current = onLoadingChange;\n\n // Mount once: create the engine instance for this component's lifetime.\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n const engine = new OdontogramEngine(canvas, {\n theme,\n modelBaseUrl,\n modelFileNames,\n minDistance,\n maxDistance,\n onLoadingChange: (loading) => {\n isLoadingRef.current = loading;\n onLoadingChangeRef.current?.(loading);\n },\n onPointsChange: (points) => onPointsChangeRef.current?.(points),\n });\n engineRef.current = engine;\n\n engine.loadModel(toothType).then(() => {\n if (initialPoints && initialPoints.length > 0) {\n engine.setMappedPoints(initialPoints);\n }\n });\n\n const handleResize = () => engine.resize();\n window.addEventListener(\"resize\", handleResize);\n\n return () => {\n window.removeEventListener(\"resize\", handleResize);\n engine.dispose();\n engineRef.current = null;\n };\n // Intentionally mount/unmount only: toothType/theme changes below are handled by\n // dedicated effects instead of recreating the whole scene.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Reload the model whenever toothType changes (but not on the very first mount).\n const previousToothType = useRef(toothType);\n useEffect(() => {\n if (previousToothType.current === toothType) return;\n previousToothType.current = toothType;\n engineRef.current?.loadModel(toothType);\n }, [toothType]);\n\n // Re-apply theme changes live, without reloading the model.\n useEffect(() => {\n if (!theme) return;\n engineRef.current?.applyTheme(theme);\n }, [theme]);\n\n const handlePointerDown = useCallback((event: React.MouseEvent<HTMLCanvasElement>) => {\n if (isLoadingRef.current) return;\n engineRef.current?.handlePointerDown(event.clientX, event.clientY);\n }, []);\n\n useImperativeHandle(\n ref,\n () => ({\n resetMapping: () => engineRef.current?.resetMapping(),\n getMappedPoints: () => engineRef.current?.getMappedPoints() ?? [],\n setMappedPoints: (points: MappedPoint[]) => engineRef.current?.setMappedPoints(points),\n updatePoint: (id: number, patch: Partial<MappedPoint>) => engineRef.current?.updatePoint(id, patch),\n applyTheme: (nextTheme: OdontogramTheme) => engineRef.current?.applyTheme(nextTheme),\n }),\n []\n );\n\n return (\n <canvas\n ref={canvasRef}\n className={className}\n style={{ width: \"100%\", height: \"100%\", ...style }}\n onMouseDown={handlePointerDown}\n />\n );\n }\n);\n","import * as THREE from \"three\";\nimport { OrbitControls } from \"three/examples/jsm/controls/OrbitControls.js\";\nimport { GLTFLoader } from \"three/examples/jsm/loaders/GLTFLoader.js\";\nimport { DEFAULT_THEME, mergeTheme, toHexColor } from \"./theme\";\nimport { mapForToothType } from \"./toothNameMaps\";\nimport type { MappedPoint, OdontogramTheme, ToothType } from \"./types\";\n\nexport interface OdontogramEngineOptions {\n theme?: OdontogramTheme;\n /** Base path where `<toothType>` model files are served from. Default: \"/assets/bin\". */\n modelBaseUrl?: string;\n /** Override the model file name per tooth type. Defaults to \"ip1.bin.gz\" (child) / \"ip3.bin.gz\" (adult). */\n modelFileNames?: { child?: string; adult?: string };\n onLoadingChange?: (loading: boolean) => void;\n onPointsChange?: (points: MappedPoint[]) => void;\n /** Camera orbit distance limits. */\n minDistance?: number;\n maxDistance?: number;\n}\n\nconst DEFAULT_MODEL_FILE_NAMES = { child: \"ip1.bin.gz\", adult: \"ip3.bin.gz\" };\n\n/**\n * Encapsulates one Three.js scene/renderer/model instance. Every instance owns its own\n * state (no module-level globals), so multiple <Odontogram3D /> can be mounted at once.\n */\nexport class OdontogramEngine {\n private canvas: HTMLCanvasElement;\n private scene: THREE.Scene;\n private camera: THREE.PerspectiveCamera;\n private renderer: THREE.WebGLRenderer;\n private controls: OrbitControls;\n private raycaster = new THREE.Raycaster();\n private pointer = new THREE.Vector2();\n private toothMeshes: THREE.Object3D[] = [];\n private animationFrameId: number | null = null;\n private theme: Required<OdontogramTheme>;\n private toothMaterialTemplate: THREE.MeshPhongMaterial;\n private mappedPoints: MappedPoint[] = [];\n private options: OdontogramEngineOptions;\n private disposed = false;\n\n constructor(canvas: HTMLCanvasElement, options: OdontogramEngineOptions = {}) {\n this.canvas = canvas;\n this.options = options;\n this.theme = mergeTheme(options.theme);\n\n this.scene = new THREE.Scene();\n\n this.camera = new THREE.PerspectiveCamera(\n 50,\n canvas.clientWidth / canvas.clientHeight || 1,\n 0.1,\n 200\n );\n this.camera.position.set(0, 2.5, 100);\n\n this.renderer = new THREE.WebGLRenderer({ antialias: true, canvas, alpha: true });\n this.renderer.setSize(canvas.clientWidth, canvas.clientHeight);\n this.renderer.setPixelRatio(window.devicePixelRatio);\n this.applyCanvasBackground();\n\n this.controls = new OrbitControls(this.camera, this.renderer.domElement);\n this.controls.enableDamping = true;\n this.controls.dampingFactor = 0.05;\n this.controls.screenSpacePanning = false;\n this.controls.minDistance = options.minDistance ?? 100;\n this.controls.maxDistance = options.maxDistance ?? 130;\n this.controls.target.set(4, 8, 0);\n\n this.toothMaterialTemplate = new THREE.MeshPhongMaterial({\n color: toHexColor(this.theme.toothColor),\n specular: 0x555555,\n shininess: 30,\n flatShading: false,\n side: THREE.DoubleSide,\n });\n\n this.setupLights();\n\n const animate = () => {\n if (this.disposed) return;\n this.animationFrameId = requestAnimationFrame(animate);\n this.controls.update();\n this.renderer.render(this.scene, this.camera);\n };\n animate();\n }\n\n private lights: {\n ambient: THREE.AmbientLight;\n key: THREE.DirectionalLight;\n fill: THREE.DirectionalLight;\n } | null = null;\n\n private setupLights() {\n const ambient = new THREE.AmbientLight(\n toHexColor(this.theme.ambientLightColor),\n this.theme.ambientLightIntensity\n );\n this.scene.add(ambient);\n\n const key = new THREE.DirectionalLight(\n toHexColor(this.theme.keyLightColor),\n this.theme.keyLightIntensity\n );\n key.position.set(10, 10, 10);\n this.scene.add(key);\n\n const fill = new THREE.DirectionalLight(\n toHexColor(this.theme.fillLightColor),\n this.theme.fillLightIntensity\n );\n fill.position.set(-10, -10, -5);\n this.scene.add(fill);\n\n this.lights = { ambient, key, fill };\n }\n\n private applyCanvasBackground() {\n this.renderer.setClearColor(\n toHexColor(this.theme.canvasBackground),\n this.theme.canvasBackgroundOpacity\n );\n }\n\n private resolveModelUrl(toothType: ToothType) {\n const base = this.options.modelBaseUrl ?? \"/assets/bin\";\n const fileNames = { ...DEFAULT_MODEL_FILE_NAMES, ...this.options.modelFileNames };\n const fileName = toothType === \"adult\" ? fileNames.adult : fileNames.child;\n return `${base.replace(/\\/$/, \"\")}/${fileName}`;\n }\n\n /** Re-apply a (partial) theme to lights, background and every tooth currently in its default state. */\n applyTheme(theme: OdontogramTheme) {\n this.theme = mergeTheme({ ...this.theme, ...theme });\n this.applyCanvasBackground();\n\n if (this.lights) {\n this.lights.ambient.color.setHex(toHexColor(this.theme.ambientLightColor));\n this.lights.ambient.intensity = this.theme.ambientLightIntensity;\n this.lights.key.color.setHex(toHexColor(this.theme.keyLightColor));\n this.lights.key.intensity = this.theme.keyLightIntensity;\n this.lights.fill.color.setHex(toHexColor(this.theme.fillLightColor));\n this.lights.fill.intensity = this.theme.fillLightIntensity;\n }\n\n this.toothMaterialTemplate.color.setHex(toHexColor(this.theme.toothColor));\n\n const mappedToothIds = new Set(this.mappedPoints.map((p) => p.toothId));\n this.forEachToothGroup((group) => {\n const isMapped = Boolean(group.userData.isMapped);\n const color = isMapped ? this.theme.highlightColor : this.theme.toothColor;\n this.setGroupColor(group, toHexColor(color));\n });\n\n // Markers use their own small sphere material, update those too.\n this.scene.children\n .filter((child) => child.name.startsWith(\"Marker-\"))\n .forEach((marker) => {\n if (marker instanceof THREE.Mesh && marker.material instanceof THREE.MeshBasicMaterial) {\n marker.material.color.setHex(toHexColor(this.theme.markerColor));\n }\n });\n\n void mappedToothIds; // kept for clarity/future use, no-op reference\n }\n\n private forEachToothGroup(callback: (group: THREE.Object3D) => void) {\n const seen = new Set<THREE.Object3D>();\n this.toothMeshes.forEach((mesh) => {\n const group = mesh.userData.parentGroup as THREE.Object3D | undefined;\n if (group && !seen.has(group)) {\n seen.add(group);\n callback(group);\n }\n });\n }\n\n private setGroupColor(group: THREE.Object3D, hex: number) {\n group.traverse((child) => {\n if (\n child instanceof THREE.Mesh &&\n child.userData.parentGroup === group &&\n child.material instanceof THREE.MeshPhongMaterial\n ) {\n child.material.color.setHex(hex);\n }\n });\n }\n\n async loadModel(toothType: ToothType) {\n const oldArchGroup = this.scene.getObjectByName(\"ArchGroup\");\n if (oldArchGroup) {\n this.scene.remove(oldArchGroup);\n oldArchGroup.traverse((child) => {\n if (child instanceof THREE.Mesh) {\n child.geometry.dispose();\n const material = child.material;\n if (Array.isArray(material)) material.forEach((m) => m.dispose());\n else material.dispose();\n }\n });\n }\n\n this.toothMeshes = [];\n this.mappedPoints = [];\n this.options.onPointsChange?.([]);\n\n const loader = new GLTFLoader();\n const modelUrl = this.resolveModelUrl(toothType);\n this.options.onLoadingChange?.(true);\n\n try {\n const gltf = await loader.loadAsync(modelUrl);\n if (this.disposed) return;\n\n const loadedModel = gltf.scene;\n loadedModel.scale.set(1.0, 1.0, 1.0);\n loadedModel.position.set(0, -2, 0);\n loadedModel.rotation.set(0, 0, 0);\n loadedModel.name = \"ArchGroup\";\n\n const nameMap = mapForToothType(toothType);\n\n loadedModel.traverse((child) => {\n if (!(child instanceof THREE.Mesh)) return;\n\n child.geometry.computeVertexNormals();\n if (child.geometry.attributes.color) {\n child.geometry.deleteAttribute(\"color\");\n }\n\n let mappedName = nameMap[child.name];\n let targetObject: THREE.Object3D = child;\n\n if (!mappedName && child.parent && child.parent.type !== \"Scene\") {\n if (nameMap[child.parent.name]) {\n mappedName = nameMap[child.parent.name];\n targetObject = child.parent;\n }\n }\n\n child.material = this.toothMaterialTemplate.clone();\n\n if (!targetObject.userData.isTooth) {\n targetObject.userData = {\n toothId: mappedName ?? child.name,\n isTooth: true,\n isMapped: false,\n markerIds: [] as number[],\n };\n }\n\n child.userData.parentGroup = targetObject;\n this.toothMeshes.push(child);\n });\n\n this.scene.add(loadedModel);\n } catch (error) {\n console.error(`[odontogram-3d] Failed to load model at ${modelUrl}:`, error);\n } finally {\n this.options.onLoadingChange?.(false);\n }\n }\n\n private removeMarkers(markerIds: number[]) {\n markerIds.forEach((id) => {\n const marker = this.scene.getObjectByName(`Marker-${id}`);\n if (marker instanceof THREE.Mesh) {\n this.scene.remove(marker);\n marker.geometry.dispose();\n marker.material.dispose();\n }\n });\n }\n\n private addMarker(id: number, position: THREE.Vector3) {\n const geometry = new THREE.SphereGeometry(0.05, 8, 8);\n const material = new THREE.MeshBasicMaterial({ color: toHexColor(this.theme.markerColor) });\n const marker = new THREE.Mesh(geometry, material);\n marker.position.copy(position);\n marker.name = `Marker-${id}`;\n this.scene.add(marker);\n }\n\n private emitPointsChange() {\n this.options.onPointsChange?.([...this.mappedPoints]);\n }\n\n /** Handle a click/tap on the canvas (expects coordinates relative to the viewport, like a MouseEvent). */\n handlePointerDown(clientX: number, clientY: number) {\n const rect = this.canvas.getBoundingClientRect();\n this.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;\n this.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;\n\n this.raycaster.setFromCamera(this.pointer, this.camera);\n const intersects = this.raycaster.intersectObjects(this.toothMeshes, false);\n if (intersects.length === 0) return;\n\n const clickedMesh = intersects[0].object;\n const toothGroup = clickedMesh.userData.parentGroup as THREE.Object3D | undefined;\n if (!toothGroup || !toothGroup.userData.isTooth) return;\n\n const toothId: string = toothGroup.userData.toothId;\n const point = intersects[0].point;\n const isCurrentlyMapped = Boolean(toothGroup.userData.isMapped);\n\n if (isCurrentlyMapped) {\n this.removeMarkers(toothGroup.userData.markerIds ?? []);\n this.setGroupColor(toothGroup, toHexColor(this.theme.toothColor));\n toothGroup.userData.isMapped = false;\n toothGroup.userData.markerIds = [];\n this.mappedPoints = this.mappedPoints.filter((p) => p.toothId !== toothId);\n this.emitPointsChange();\n } else {\n const newMarkerId = Date.now();\n this.setGroupColor(toothGroup, toHexColor(this.theme.highlightColor));\n toothGroup.userData.isMapped = true;\n toothGroup.userData.markerIds = [newMarkerId];\n this.addMarker(newMarkerId, point);\n\n const newPoint: MappedPoint = {\n id: newMarkerId,\n toothId,\n x: point.x.toFixed(2),\n y: point.y.toFixed(2),\n z: point.z.toFixed(2),\n type: \"Karies\",\n description: \"\",\n };\n this.mappedPoints = [...this.mappedPoints, newPoint];\n this.emitPointsChange();\n }\n }\n\n getMappedPoints(): MappedPoint[] {\n return [...this.mappedPoints];\n }\n\n /** Patch metadata (e.g. description) on an existing point without touching the 3D scene. */\n updatePoint(id: number, patch: Partial<MappedPoint>) {\n this.mappedPoints = this.mappedPoints.map((p) => (p.id === id ? { ...p, ...patch } : p));\n this.emitPointsChange();\n }\n\n resetMapping() {\n const existingMarkerIds = this.scene.children\n .filter((c) => c.name.startsWith(\"Marker-\"))\n .map((c) => Number(c.name.replace(\"Marker-\", \"\")));\n this.removeMarkers(existingMarkerIds);\n\n this.forEachToothGroup((group) => {\n this.setGroupColor(group, toHexColor(this.theme.toothColor));\n group.userData.isMapped = false;\n group.userData.markerIds = [];\n });\n\n this.mappedPoints = [];\n this.emitPointsChange();\n }\n\n /** Replace all diagnostic points and redraw markers/highlights to match (e.g. after importing JSON). */\n setMappedPoints(points: MappedPoint[]) {\n this.resetMapping();\n\n points.forEach((point) => {\n const group = this.toothMeshes.find(\n (mesh) => (mesh.userData.parentGroup as THREE.Object3D | undefined)?.userData.toothId === point.toothId\n )?.userData.parentGroup as THREE.Object3D | undefined;\n\n if (!group) return;\n\n this.setGroupColor(group, toHexColor(this.theme.highlightColor));\n group.userData.isMapped = true;\n group.userData.markerIds = [...(group.userData.markerIds ?? []), point.id];\n\n this.addMarker(point.id, new THREE.Vector3(parseFloat(point.x), parseFloat(point.y), parseFloat(point.z)));\n });\n\n this.mappedPoints = [...points];\n this.emitPointsChange();\n }\n\n resize() {\n const container = this.canvas.parentElement;\n if (!container) return;\n const { clientWidth, clientHeight } = container;\n if (clientWidth === 0 || clientHeight === 0) return;\n\n this.camera.aspect = clientWidth / clientHeight;\n this.camera.updateProjectionMatrix();\n this.renderer.setSize(clientWidth, clientHeight, false);\n this.renderer.setPixelRatio(window.devicePixelRatio);\n }\n\n dispose() {\n this.disposed = true;\n if (this.animationFrameId !== null) cancelAnimationFrame(this.animationFrameId);\n\n const oldArchGroup = this.scene.getObjectByName(\"ArchGroup\");\n if (oldArchGroup) this.scene.remove(oldArchGroup);\n\n this.scene.traverse((child) => {\n if (child instanceof THREE.Mesh) {\n child.geometry.dispose();\n const material = child.material;\n if (Array.isArray(material)) material.forEach((m) => m.dispose());\n else material?.dispose();\n }\n });\n\n this.toothMaterialTemplate.dispose();\n this.controls.dispose();\n this.renderer.dispose();\n }\n}\n\nexport { DEFAULT_THEME };\n","import * as THREE from \"three\";\nimport type { ColorInput, OdontogramTheme } from \"./types\";\n\nexport const DEFAULT_THEME: Required<OdontogramTheme> = {\n toothColor: 0xf5f5dc, // Ivory\n highlightColor: 0x3b82f6, // Tailwind blue-500\n markerColor: 0xff0000, // Red\n canvasBackground: 0xecf0f1,\n canvasBackgroundOpacity: 0,\n ambientLightColor: 0xffffff,\n ambientLightIntensity: 0.8,\n keyLightColor: 0xffffff,\n keyLightIntensity: 0.6,\n fillLightColor: 0xffffff,\n fillLightIntensity: 0.4,\n};\n\nexport function mergeTheme(theme?: OdontogramTheme): Required<OdontogramTheme> {\n return { ...DEFAULT_THEME, ...theme };\n}\n\n/** Three.js color helpers accept numbers (0xff0000) or strings (\"#ff0000\"); normalize both to a hex number. */\nexport function toHexColor(input: ColorInput): number {\n if (typeof input === \"number\") return input;\n return new THREE.Color(input).getHex();\n}\n","export const TOOTH_MESH_NAME_MAP_CHILD: { [key: string]: string } = {\n Gums_Maxilla001_0: \"Gums Maxilla\",\n Gums_Mandibula001_0: \"Gums Mandibulla\",\n A_0: \"(Left Top) Molar\",\n B_0: \"(Left Top) Premolar\",\n C_0: \"(Left Top) Canine\",\n D_0: \"(Left Top) Lateral\",\n E_0: \"(Left Top) Central\",\n F_0: \"(Right Top) Central\",\n G_0: \"(Right Top) Lateral\",\n H_0: \"(Right Top) Canine\",\n I_0: \"(Right Top) Premolar\",\n J_0: \"(Right Top) Molar\",\n K_0: \"(Left Bottom) Molar\",\n L_0: \"(Left Bottom) Premolar\",\n M_0: \"(Left Bottom) Canine\",\n N_0: \"(Left Bottom) Lateral\",\n O_0: \"(Left Bottom) Central\",\n P_0: \"(Right Bottom) Central\",\n Q_0: \"(Right Bottom) Lateral\",\n R_0: \"(Right Bottom) Canine\",\n S_0: \"(Right Bottom) Premolar\",\n T_0: \"(Right Bottom) Molar\",\n};\n\nexport const TOOTH_MESH_NAME_MAP_ADULT: { [key: string]: string } = {\n // --- Rahang Atas Kanan (Quadrant 1) ---\n \"Box023_01_-_Default_0\": \"11 (Right Top) Central Incisor\",\n \"Box024_01_-_Default_0\": \"12 (Right Top) Lateral Incisor\",\n \"Box025_01_-_Default_0\": \"13 (Right Top) Canine\",\n \"Box031_01_-_Default_0\": \"14 (Right Top) First Premolar\",\n \"Box033_01_-_Default_0\": \"15 (Right Top) Second Premolar\",\n \"Box032_01_-_Default_0\": \"16 (Right Top) First Molar\",\n \"Box035_01_-_Default_0\": \"17 (Right Top) Second Molar\",\n \"Box034_01_-_Default_0\": \"18 (Right Top) Third Molar\",\n\n // --- Rahang Atas Kiri (Quadrant 2) ---\n \"Box020_01_-_Default_0\": \"21 (Left Top) Central Incisor\",\n \"Box021_01_-_Default_0\": \"22 (Left Top) Lateral Incisor\",\n \"Box022_01_-_Default_0\": \"23 (Left Top) Canine\",\n \"Box026_01_-_Default_0\": \"24 (Left Top) First Premolar\",\n \"Box028_01_-_Default_0\": \"25 (Left Top) Second Premolar\",\n \"Box027_01_-_Default_0\": \"26 (Left Top) First Molar\",\n \"Box030_01_-_Default_0\": \"27 (Left Top) Second Molar\",\n \"Box029_01_-_Default_0\": \"28 (Left Top) Third Molar\",\n\n // --- Rahang Bawah Kiri (Quadrant 3) ---\n \"Box011_01_-_Default_0\": \"38 (Left Bottom) Third Molar\",\n \"Box013_01_-_Default_0\": \"37 (Left Bottom) Second Molar\",\n \"Box008_01_-_Default_0\": \"36 (Left Bottom) First Molar\",\n \"Box009_01_-_Default_0\": \"35 (Left Bottom) Second Premolar\",\n \"Box007_01_-_Default_0\": \"34 (Left Bottom) First Premolar\",\n \"Box003_01_-_Default_0\": \"33 (Left Bottom) Canine\",\n \"Box002_01_-_Default_0\": \"32 (Left Bottom) Lateral Incisor\",\n \"Box001_01_-_Default_0\": \"31 (Left Bottom) Central Incisor\",\n\n // --- Rahang Bawah Kanan (Quadrant 4) ---\n \"Box004_01_-_Default_0\": \"41 (Right Bottom) Central Incisor\",\n \"Box005_01_-_Default_0\": \"42 (Right Bottom) Lateral Incisor\",\n \"Box006_01_-_Default_0\": \"43 (Right Bottom) Canine\",\n \"Box015_01_-_Default_0\": \"44 (Right Bottom) First Premolar\",\n \"Box017_01_-_Default_0\": \"45 (Right Bottom) Second Premolar\",\n \"Box016_01_-_Default_0\": \"46 (Right Bottom) First Molar\",\n \"Box019_01_-_Default_0\": \"47 (Right Bottom) Second Molar\",\n \"GeoSphere001_01_-_Default_0\": \"48 (Right Bottom) Third Molar\",\n\n // --- Lain-lain ---\n \"Tube002_07_-_Default_0\": \"(Top Tube) Gums Maxilla\",\n \"Tube001_07_-_Default_0\": \"(Bottom Tube) Gums Mandibula\",\n \"lingua_02_-_Default_0\": \"(Tongue) Lingua\",\n};\n\nexport function mapForToothType(toothType: string): { [key: string]: string } {\n return toothType === \"child\" || !toothType\n ? TOOTH_MESH_NAME_MAP_CHILD\n : TOOTH_MESH_NAME_MAP_ADULT;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAEA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;;;ACRP,YAAYA,YAAW;AACvB,SAAS,qBAAqB;AAC9B,SAAS,kBAAkB;;;ACF3B,YAAY,WAAW;AAGhB,IAAM,gBAA2C;AAAA,EACtD,YAAY;AAAA;AAAA,EACZ,gBAAgB;AAAA;AAAA,EAChB,aAAa;AAAA;AAAA,EACb,kBAAkB;AAAA,EAClB,yBAAyB;AAAA,EACzB,mBAAmB;AAAA,EACnB,uBAAuB;AAAA,EACvB,eAAe;AAAA,EACf,mBAAmB;AAAA,EACnB,gBAAgB;AAAA,EAChB,oBAAoB;AACtB;AAEO,SAAS,WAAW,OAAoD;AAC7E,SAAO,kCAAK,gBAAkB;AAChC;AAGO,SAAS,WAAW,OAA2B;AACpD,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,SAAO,IAAU,YAAM,KAAK,EAAE,OAAO;AACvC;;;ACzBO,IAAM,4BAAuD;AAAA,EAClE,mBAAmB;AAAA,EACnB,qBAAqB;AAAA,EACrB,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AACP;AAEO,IAAM,4BAAuD;AAAA;AAAA,EAElE,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA;AAAA,EAGzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA;AAAA,EAGzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA;AAAA,EAGzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,yBAAyB;AAAA,EACzB,+BAA+B;AAAA;AAAA,EAG/B,0BAA0B;AAAA,EAC1B,0BAA0B;AAAA,EAC1B,yBAAyB;AAC3B;AAEO,SAAS,gBAAgB,WAA8C;AAC5E,SAAO,cAAc,WAAW,CAAC,YAC7B,4BACA;AACN;;;AFxDA,IAAM,2BAA2B,EAAE,OAAO,cAAc,OAAO,aAAa;AAMrE,IAAM,mBAAN,MAAuB;AAAA,EAgB5B,YAAY,QAA2B,UAAmC,CAAC,GAAG;AAV9E,SAAQ,YAAY,IAAU,iBAAU;AACxC,SAAQ,UAAU,IAAU,eAAQ;AACpC,SAAQ,cAAgC,CAAC;AACzC,SAAQ,mBAAkC;AAG1C,SAAQ,eAA8B,CAAC;AAEvC,SAAQ,WAAW;AAiDnB,SAAQ,SAIG;AA7Fb;AA2CI,SAAK,SAAS;AACd,SAAK,UAAU;AACf,SAAK,QAAQ,WAAW,QAAQ,KAAK;AAErC,SAAK,QAAQ,IAAU,aAAM;AAE7B,SAAK,SAAS,IAAU;AAAA,MACtB;AAAA,MACA,OAAO,cAAc,OAAO,gBAAgB;AAAA,MAC5C;AAAA,MACA;AAAA,IACF;AACA,SAAK,OAAO,SAAS,IAAI,GAAG,KAAK,GAAG;AAEpC,SAAK,WAAW,IAAU,qBAAc,EAAE,WAAW,MAAM,QAAQ,OAAO,KAAK,CAAC;AAChF,SAAK,SAAS,QAAQ,OAAO,aAAa,OAAO,YAAY;AAC7D,SAAK,SAAS,cAAc,OAAO,gBAAgB;AACnD,SAAK,sBAAsB;AAE3B,SAAK,WAAW,IAAI,cAAc,KAAK,QAAQ,KAAK,SAAS,UAAU;AACvE,SAAK,SAAS,gBAAgB;AAC9B,SAAK,SAAS,gBAAgB;AAC9B,SAAK,SAAS,qBAAqB;AACnC,SAAK,SAAS,eAAc,aAAQ,gBAAR,YAAuB;AACnD,SAAK,SAAS,eAAc,aAAQ,gBAAR,YAAuB;AACnD,SAAK,SAAS,OAAO,IAAI,GAAG,GAAG,CAAC;AAEhC,SAAK,wBAAwB,IAAU,yBAAkB;AAAA,MACvD,OAAO,WAAW,KAAK,MAAM,UAAU;AAAA,MACvC,UAAU;AAAA,MACV,WAAW;AAAA,MACX,aAAa;AAAA,MACb,MAAY;AAAA,IACd,CAAC;AAED,SAAK,YAAY;AAEjB,UAAM,UAAU,MAAM;AACpB,UAAI,KAAK,SAAU;AACnB,WAAK,mBAAmB,sBAAsB,OAAO;AACrD,WAAK,SAAS,OAAO;AACrB,WAAK,SAAS,OAAO,KAAK,OAAO,KAAK,MAAM;AAAA,IAC9C;AACA,YAAQ;AAAA,EACV;AAAA,EAQQ,cAAc;AACpB,UAAM,UAAU,IAAU;AAAA,MACxB,WAAW,KAAK,MAAM,iBAAiB;AAAA,MACvC,KAAK,MAAM;AAAA,IACb;AACA,SAAK,MAAM,IAAI,OAAO;AAEtB,UAAM,MAAM,IAAU;AAAA,MACpB,WAAW,KAAK,MAAM,aAAa;AAAA,MACnC,KAAK,MAAM;AAAA,IACb;AACA,QAAI,SAAS,IAAI,IAAI,IAAI,EAAE;AAC3B,SAAK,MAAM,IAAI,GAAG;AAElB,UAAM,OAAO,IAAU;AAAA,MACrB,WAAW,KAAK,MAAM,cAAc;AAAA,MACpC,KAAK,MAAM;AAAA,IACb;AACA,SAAK,SAAS,IAAI,KAAK,KAAK,EAAE;AAC9B,SAAK,MAAM,IAAI,IAAI;AAEnB,SAAK,SAAS,EAAE,SAAS,KAAK,KAAK;AAAA,EACrC;AAAA,EAEQ,wBAAwB;AAC9B,SAAK,SAAS;AAAA,MACZ,WAAW,KAAK,MAAM,gBAAgB;AAAA,MACtC,KAAK,MAAM;AAAA,IACb;AAAA,EACF;AAAA,EAEQ,gBAAgB,WAAsB;AA9HhD;AA+HI,UAAM,QAAO,UAAK,QAAQ,iBAAb,YAA6B;AAC1C,UAAM,YAAY,kCAAK,2BAA6B,KAAK,QAAQ;AACjE,UAAM,WAAW,cAAc,UAAU,UAAU,QAAQ,UAAU;AACrE,WAAO,GAAG,KAAK,QAAQ,OAAO,EAAE,CAAC,IAAI,QAAQ;AAAA,EAC/C;AAAA;AAAA,EAGA,WAAW,OAAwB;AACjC,SAAK,QAAQ,WAAW,kCAAK,KAAK,QAAU,MAAO;AACnD,SAAK,sBAAsB;AAE3B,QAAI,KAAK,QAAQ;AACf,WAAK,OAAO,QAAQ,MAAM,OAAO,WAAW,KAAK,MAAM,iBAAiB,CAAC;AACzE,WAAK,OAAO,QAAQ,YAAY,KAAK,MAAM;AAC3C,WAAK,OAAO,IAAI,MAAM,OAAO,WAAW,KAAK,MAAM,aAAa,CAAC;AACjE,WAAK,OAAO,IAAI,YAAY,KAAK,MAAM;AACvC,WAAK,OAAO,KAAK,MAAM,OAAO,WAAW,KAAK,MAAM,cAAc,CAAC;AACnE,WAAK,OAAO,KAAK,YAAY,KAAK,MAAM;AAAA,IAC1C;AAEA,SAAK,sBAAsB,MAAM,OAAO,WAAW,KAAK,MAAM,UAAU,CAAC;AAEzE,UAAM,iBAAiB,IAAI,IAAI,KAAK,aAAa,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC;AACtE,SAAK,kBAAkB,CAAC,UAAU;AAChC,YAAM,WAAW,QAAQ,MAAM,SAAS,QAAQ;AAChD,YAAM,QAAQ,WAAW,KAAK,MAAM,iBAAiB,KAAK,MAAM;AAChE,WAAK,cAAc,OAAO,WAAW,KAAK,CAAC;AAAA,IAC7C,CAAC;AAGD,SAAK,MAAM,SACR,OAAO,CAAC,UAAU,MAAM,KAAK,WAAW,SAAS,CAAC,EAClD,QAAQ,CAAC,WAAW;AACnB,UAAI,kBAAwB,eAAQ,OAAO,oBAA0B,0BAAmB;AACtF,eAAO,SAAS,MAAM,OAAO,WAAW,KAAK,MAAM,WAAW,CAAC;AAAA,MACjE;AAAA,IACF,CAAC;AAEH,SAAK;AAAA,EACP;AAAA,EAEQ,kBAAkB,UAA2C;AACnE,UAAM,OAAO,oBAAI,IAAoB;AACrC,SAAK,YAAY,QAAQ,CAAC,SAAS;AACjC,YAAM,QAAQ,KAAK,SAAS;AAC5B,UAAI,SAAS,CAAC,KAAK,IAAI,KAAK,GAAG;AAC7B,aAAK,IAAI,KAAK;AACd,iBAAS,KAAK;AAAA,MAChB;AAAA,IACF,CAAC;AAAA,EACH;AAAA,EAEQ,cAAc,OAAuB,KAAa;AACxD,UAAM,SAAS,CAAC,UAAU;AACxB,UACE,iBAAuB,eACvB,MAAM,SAAS,gBAAgB,SAC/B,MAAM,oBAA0B,0BAChC;AACA,cAAM,SAAS,MAAM,OAAO,GAAG;AAAA,MACjC;AAAA,IACF,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,UAAU,WAAsB;AA/LxC;AAgMI,UAAM,eAAe,KAAK,MAAM,gBAAgB,WAAW;AAC3D,QAAI,cAAc;AAChB,WAAK,MAAM,OAAO,YAAY;AAC9B,mBAAa,SAAS,CAAC,UAAU;AAC/B,YAAI,iBAAuB,aAAM;AAC/B,gBAAM,SAAS,QAAQ;AACvB,gBAAM,WAAW,MAAM;AACvB,cAAI,MAAM,QAAQ,QAAQ,EAAG,UAAS,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;AAAA,cAC3D,UAAS,QAAQ;AAAA,QACxB;AAAA,MACF,CAAC;AAAA,IACH;AAEA,SAAK,cAAc,CAAC;AACpB,SAAK,eAAe,CAAC;AACrB,qBAAK,SAAQ,mBAAb,4BAA8B,CAAC;AAE/B,UAAM,SAAS,IAAI,WAAW;AAC9B,UAAM,WAAW,KAAK,gBAAgB,SAAS;AAC/C,qBAAK,SAAQ,oBAAb,4BAA+B;AAE/B,QAAI;AACF,YAAM,OAAO,MAAM,OAAO,UAAU,QAAQ;AAC5C,UAAI,KAAK,SAAU;AAEnB,YAAM,cAAc,KAAK;AACzB,kBAAY,MAAM,IAAI,GAAK,GAAK,CAAG;AACnC,kBAAY,SAAS,IAAI,GAAG,IAAI,CAAC;AACjC,kBAAY,SAAS,IAAI,GAAG,GAAG,CAAC;AAChC,kBAAY,OAAO;AAEnB,YAAM,UAAU,gBAAgB,SAAS;AAEzC,kBAAY,SAAS,CAAC,UAAU;AAC9B,YAAI,EAAE,iBAAuB,aAAO;AAEpC,cAAM,SAAS,qBAAqB;AACpC,YAAI,MAAM,SAAS,WAAW,OAAO;AACnC,gBAAM,SAAS,gBAAgB,OAAO;AAAA,QACxC;AAEA,YAAI,aAAa,QAAQ,MAAM,IAAI;AACnC,YAAI,eAA+B;AAEnC,YAAI,CAAC,cAAc,MAAM,UAAU,MAAM,OAAO,SAAS,SAAS;AAChE,cAAI,QAAQ,MAAM,OAAO,IAAI,GAAG;AAC9B,yBAAa,QAAQ,MAAM,OAAO,IAAI;AACtC,2BAAe,MAAM;AAAA,UACvB;AAAA,QACF;AAEA,cAAM,WAAW,KAAK,sBAAsB,MAAM;AAElD,YAAI,CAAC,aAAa,SAAS,SAAS;AAClC,uBAAa,WAAW;AAAA,YACtB,SAAS,kCAAc,MAAM;AAAA,YAC7B,SAAS;AAAA,YACT,UAAU;AAAA,YACV,WAAW,CAAC;AAAA,UACd;AAAA,QACF;AAEA,cAAM,SAAS,cAAc;AAC7B,aAAK,YAAY,KAAK,KAAK;AAAA,MAC7B,CAAC;AAED,WAAK,MAAM,IAAI,WAAW;AAAA,IAC5B,SAAS,OAAO;AACd,cAAQ,MAAM,2CAA2C,QAAQ,KAAK,KAAK;AAAA,IAC7E,UAAE;AACA,uBAAK,SAAQ,oBAAb,4BAA+B;AAAA,IACjC;AAAA,EACF;AAAA,EAEQ,cAAc,WAAqB;AACzC,cAAU,QAAQ,CAAC,OAAO;AACxB,YAAM,SAAS,KAAK,MAAM,gBAAgB,UAAU,EAAE,EAAE;AACxD,UAAI,kBAAwB,aAAM;AAChC,aAAK,MAAM,OAAO,MAAM;AACxB,eAAO,SAAS,QAAQ;AACxB,eAAO,SAAS,QAAQ;AAAA,MAC1B;AAAA,IACF,CAAC;AAAA,EACH;AAAA,EAEQ,UAAU,IAAY,UAAyB;AACrD,UAAM,WAAW,IAAU,sBAAe,MAAM,GAAG,CAAC;AACpD,UAAM,WAAW,IAAU,yBAAkB,EAAE,OAAO,WAAW,KAAK,MAAM,WAAW,EAAE,CAAC;AAC1F,UAAM,SAAS,IAAU,YAAK,UAAU,QAAQ;AAChD,WAAO,SAAS,KAAK,QAAQ;AAC7B,WAAO,OAAO,UAAU,EAAE;AAC1B,SAAK,MAAM,IAAI,MAAM;AAAA,EACvB;AAAA,EAEQ,mBAAmB;AA9R7B;AA+RI,qBAAK,SAAQ,mBAAb,4BAA8B,CAAC,GAAG,KAAK,YAAY;AAAA,EACrD;AAAA;AAAA,EAGA,kBAAkB,SAAiB,SAAiB;AAnStD;AAoSI,UAAM,OAAO,KAAK,OAAO,sBAAsB;AAC/C,SAAK,QAAQ,KAAM,UAAU,KAAK,QAAQ,KAAK,QAAS,IAAI;AAC5D,SAAK,QAAQ,IAAI,GAAG,UAAU,KAAK,OAAO,KAAK,UAAU,IAAI;AAE7D,SAAK,UAAU,cAAc,KAAK,SAAS,KAAK,MAAM;AACtD,UAAM,aAAa,KAAK,UAAU,iBAAiB,KAAK,aAAa,KAAK;AAC1E,QAAI,WAAW,WAAW,EAAG;AAE7B,UAAM,cAAc,WAAW,CAAC,EAAE;AAClC,UAAM,aAAa,YAAY,SAAS;AACxC,QAAI,CAAC,cAAc,CAAC,WAAW,SAAS,QAAS;AAEjD,UAAM,UAAkB,WAAW,SAAS;AAC5C,UAAM,QAAQ,WAAW,CAAC,EAAE;AAC5B,UAAM,oBAAoB,QAAQ,WAAW,SAAS,QAAQ;AAE9D,QAAI,mBAAmB;AACrB,WAAK,eAAc,gBAAW,SAAS,cAApB,YAAiC,CAAC,CAAC;AACtD,WAAK,cAAc,YAAY,WAAW,KAAK,MAAM,UAAU,CAAC;AAChE,iBAAW,SAAS,WAAW;AAC/B,iBAAW,SAAS,YAAY,CAAC;AACjC,WAAK,eAAe,KAAK,aAAa,OAAO,CAAC,MAAM,EAAE,YAAY,OAAO;AACzE,WAAK,iBAAiB;AAAA,IACxB,OAAO;AACL,YAAM,cAAc,KAAK,IAAI;AAC7B,WAAK,cAAc,YAAY,WAAW,KAAK,MAAM,cAAc,CAAC;AACpE,iBAAW,SAAS,WAAW;AAC/B,iBAAW,SAAS,YAAY,CAAC,WAAW;AAC5C,WAAK,UAAU,aAAa,KAAK;AAEjC,YAAM,WAAwB;AAAA,QAC5B,IAAI;AAAA,QACJ;AAAA,QACA,GAAG,MAAM,EAAE,QAAQ,CAAC;AAAA,QACpB,GAAG,MAAM,EAAE,QAAQ,CAAC;AAAA,QACpB,GAAG,MAAM,EAAE,QAAQ,CAAC;AAAA,QACpB,MAAM;AAAA,QACN,aAAa;AAAA,MACf;AACA,WAAK,eAAe,CAAC,GAAG,KAAK,cAAc,QAAQ;AACnD,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA,EAEA,kBAAiC;AAC/B,WAAO,CAAC,GAAG,KAAK,YAAY;AAAA,EAC9B;AAAA;AAAA,EAGA,YAAY,IAAY,OAA6B;AACnD,SAAK,eAAe,KAAK,aAAa,IAAI,CAAC,MAAO,EAAE,OAAO,KAAK,kCAAK,IAAM,SAAU,CAAE;AACvF,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,eAAe;AACb,UAAM,oBAAoB,KAAK,MAAM,SAClC,OAAO,CAAC,MAAM,EAAE,KAAK,WAAW,SAAS,CAAC,EAC1C,IAAI,CAAC,MAAM,OAAO,EAAE,KAAK,QAAQ,WAAW,EAAE,CAAC,CAAC;AACnD,SAAK,cAAc,iBAAiB;AAEpC,SAAK,kBAAkB,CAAC,UAAU;AAChC,WAAK,cAAc,OAAO,WAAW,KAAK,MAAM,UAAU,CAAC;AAC3D,YAAM,SAAS,WAAW;AAC1B,YAAM,SAAS,YAAY,CAAC;AAAA,IAC9B,CAAC;AAED,SAAK,eAAe,CAAC;AACrB,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA,EAGA,gBAAgB,QAAuB;AACrC,SAAK,aAAa;AAElB,WAAO,QAAQ,CAAC,UAAU;AA9W9B;AA+WM,YAAM,SAAQ,UAAK,YAAY;AAAA,QAC7B,CAAC,SAAM;AAhXf,cAAAC;AAgXmB,mBAAAA,MAAA,KAAK,SAAS,gBAAd,gBAAAA,IAA0D,SAAS,aAAY,MAAM;AAAA;AAAA,MAClG,MAFc,mBAEX,SAAS;AAEZ,UAAI,CAAC,MAAO;AAEZ,WAAK,cAAc,OAAO,WAAW,KAAK,MAAM,cAAc,CAAC;AAC/D,YAAM,SAAS,WAAW;AAC1B,YAAM,SAAS,YAAY,CAAC,IAAI,WAAM,SAAS,cAAf,YAA4B,CAAC,GAAI,MAAM,EAAE;AAEzE,WAAK,UAAU,MAAM,IAAI,IAAU,eAAQ,WAAW,MAAM,CAAC,GAAG,WAAW,MAAM,CAAC,GAAG,WAAW,MAAM,CAAC,CAAC,CAAC;AAAA,IAC3G,CAAC;AAED,SAAK,eAAe,CAAC,GAAG,MAAM;AAC9B,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,SAAS;AACP,UAAM,YAAY,KAAK,OAAO;AAC9B,QAAI,CAAC,UAAW;AAChB,UAAM,EAAE,aAAa,aAAa,IAAI;AACtC,QAAI,gBAAgB,KAAK,iBAAiB,EAAG;AAE7C,SAAK,OAAO,SAAS,cAAc;AACnC,SAAK,OAAO,uBAAuB;AACnC,SAAK,SAAS,QAAQ,aAAa,cAAc,KAAK;AACtD,SAAK,SAAS,cAAc,OAAO,gBAAgB;AAAA,EACrD;AAAA,EAEA,UAAU;AACR,SAAK,WAAW;AAChB,QAAI,KAAK,qBAAqB,KAAM,sBAAqB,KAAK,gBAAgB;AAE9E,UAAM,eAAe,KAAK,MAAM,gBAAgB,WAAW;AAC3D,QAAI,aAAc,MAAK,MAAM,OAAO,YAAY;AAEhD,SAAK,MAAM,SAAS,CAAC,UAAU;AAC7B,UAAI,iBAAuB,aAAM;AAC/B,cAAM,SAAS,QAAQ;AACvB,cAAM,WAAW,MAAM;AACvB,YAAI,MAAM,QAAQ,QAAQ,EAAG,UAAS,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;AAAA,YAC3D,sCAAU;AAAA,MACjB;AAAA,IACF,CAAC;AAED,SAAK,sBAAsB,QAAQ;AACnC,SAAK,SAAS,QAAQ;AACtB,SAAK,SAAS,QAAQ;AAAA,EACxB;AACF;;;ADvRM;AA/FC,IAAM,eAAe;AAAA,EAC1B,SAASC,cACP;AAAA,IACE,YAAY;AAAA,IACZ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,GACA,KACA;AACA,UAAM,YAAY,OAAiC,IAAI;AACvD,UAAM,YAAY,OAAgC,IAAI;AACtD,UAAM,eAAe,OAAO,IAAI;AAChC,UAAM,oBAAoB,OAAO,cAAc;AAC/C,UAAM,qBAAqB,OAAO,eAAe;AACjD,sBAAkB,UAAU;AAC5B,uBAAmB,UAAU;AAG7B,cAAU,MAAM;AACd,YAAM,SAAS,UAAU;AACzB,UAAI,CAAC,OAAQ;AAEb,YAAM,SAAS,IAAI,iBAAiB,QAAQ;AAAA,QAC1C;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA,iBAAiB,CAAC,YAAY;AA9EtC;AA+EU,uBAAa,UAAU;AACvB,mCAAmB,YAAnB,4CAA6B;AAAA,QAC/B;AAAA,QACA,gBAAgB,CAAC,WAAQ;AAlFjC;AAkFoC,yCAAkB,YAAlB,2CAA4B;AAAA;AAAA,MAC1D,CAAC;AACD,gBAAU,UAAU;AAEpB,aAAO,UAAU,SAAS,EAAE,KAAK,MAAM;AACrC,YAAI,iBAAiB,cAAc,SAAS,GAAG;AAC7C,iBAAO,gBAAgB,aAAa;AAAA,QACtC;AAAA,MACF,CAAC;AAED,YAAM,eAAe,MAAM,OAAO,OAAO;AACzC,aAAO,iBAAiB,UAAU,YAAY;AAE9C,aAAO,MAAM;AACX,eAAO,oBAAoB,UAAU,YAAY;AACjD,eAAO,QAAQ;AACf,kBAAU,UAAU;AAAA,MACtB;AAAA,IAIF,GAAG,CAAC,CAAC;AAGL,UAAM,oBAAoB,OAAO,SAAS;AAC1C,cAAU,MAAM;AA3GpB;AA4GM,UAAI,kBAAkB,YAAY,UAAW;AAC7C,wBAAkB,UAAU;AAC5B,sBAAU,YAAV,mBAAmB,UAAU;AAAA,IAC/B,GAAG,CAAC,SAAS,CAAC;AAGd,cAAU,MAAM;AAlHpB;AAmHM,UAAI,CAAC,MAAO;AACZ,sBAAU,YAAV,mBAAmB,WAAW;AAAA,IAChC,GAAG,CAAC,KAAK,CAAC;AAEV,UAAM,oBAAoB,YAAY,CAAC,UAA+C;AAvH1F;AAwHM,UAAI,aAAa,QAAS;AAC1B,sBAAU,YAAV,mBAAmB,kBAAkB,MAAM,SAAS,MAAM;AAAA,IAC5D,GAAG,CAAC,CAAC;AAEL;AAAA,MACE;AAAA,MACA,OAAO;AAAA,QACL,cAAc,MAAG;AA/HzB;AA+H4B,iCAAU,YAAV,mBAAmB;AAAA;AAAA,QACvC,iBAAiB,MAAG;AAhI5B;AAgI+B,uCAAU,YAAV,mBAAmB,sBAAnB,YAAwC,CAAC;AAAA;AAAA,QAChE,iBAAiB,CAAC,WAAuB;AAjIjD;AAiIoD,iCAAU,YAAV,mBAAmB,gBAAgB;AAAA;AAAA,QAC/E,aAAa,CAAC,IAAY,UAA6B;AAlI/D;AAkIkE,iCAAU,YAAV,mBAAmB,YAAY,IAAI;AAAA;AAAA,QAC7F,YAAY,CAAC,cAA4B;AAnIjD;AAmIoD,iCAAU,YAAV,mBAAmB,WAAW;AAAA;AAAA,MAC5E;AAAA,MACA,CAAC;AAAA,IACH;AAEA,WACE;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL;AAAA,QACA,OAAO,iBAAE,OAAO,QAAQ,QAAQ,UAAW;AAAA,QAC3C,aAAa;AAAA;AAAA,IACf;AAAA,EAEJ;AACF;","names":["THREE","_a","Odontogram3D"]}
|