Repository navigation
Expand file tree
/
Copy pathLysConverter.ts
More file actions
680 lines (616 loc) · 28.4 KB
/
Copy pathLysConverter.ts
File metadata and controls
680 lines (616 loc) · 28.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
import * as THREE from 'three';
import {
DragonfruitImportFormat,
Joint,
} from '@/supports/types';
import { SupportSettings } from '@/supports/Settings';
import type {
ModelMeshModifiers,
ModelHollowingModifier,
ModelHolePunchPlacement,
} from '@/features/mesh-modifiers/types';
import { MeshBVH } from 'three-mesh-bvh';
import { quaternionFromGlobalEulerDegrees } from '@/utils/rotation';
import { convertLysData } from './converter/convertLysData';
import { LysData, LysHollowing } from './converter/types';
/**
* Resolve the inward drill direction for a tilted hole whose cylinder axis lies
* along `axis` (the tipRotation frame's Z). Lychee stores the axis line but no
* reliable inward sign, so we read it from the mesh: cast a ray each way from
* the tip.
*
* Supporters place most holes on the OUTER surface, but some drain/vent holes are
* anchored to the INTERIOR (cavity) wall and drill outward through the shell. The
* cavity mesh distinguishes the two:
* - Anchored to the cavity wall (tip nearer the cavity mesh than the outer
* surface): drill OUT through the near shell → toward the nearer outer hit.
* - Anchored to the outer surface: a side that escapes to open air is OUTWARD,
* so drill the other way; if both sides hit a wall the tip is embedded, so
* drill toward the farther wall (more material).
*
* Returns null if the geometry gives no usable hit.
*/
function resolveDrillInward(
bvh: MeshBVH,
cavityBvh: MeshBVH | null,
tip: THREE.Vector3,
axis: THREE.Vector3,
): THREE.Vector3 | null {
const firstHitDistance = (d: THREE.Vector3): number => {
const ray = new THREE.Ray(tip.clone().addScaledVector(d, 1e-3), d.clone());
let nearest = Infinity;
for (const hit of bvh.raycast(ray, THREE.DoubleSide)) {
if (hit.distance < nearest) nearest = hit.distance;
}
return nearest;
};
const forward = firstHitDistance(axis);
const backward = firstHitDistance(axis.clone().negate());
// Interior-anchored drain hole: tip sits nearer the cavity mesh than the outer
// surface. Drill toward the nearer outer-surface hit — the short way out through
// the shell. (closestPointToPoint returns squared distance; comparison only.)
if (cavityBvh) {
const dOuter = bvh.closestPointToPoint(tip)?.distance ?? Infinity;
const dCavity = cavityBvh.closestPointToPoint(tip)?.distance ?? Infinity;
if (dCavity < dOuter) {
return axis.clone().multiplyScalar(forward < backward ? 1 : -1);
}
}
const forwardEscapes = !Number.isFinite(forward);
const backwardEscapes = !Number.isFinite(backward);
if (forwardEscapes && backwardEscapes) return null;
let sign: number;
if (forwardEscapes) sign = -1;
else if (backwardEscapes) sign = 1;
else sign = forward > backward ? 1 : -1;
return axis.clone().multiplyScalar(sign);
}
/** Normalize an LYS objectId field (may be string or number) to a string, or null. */
function normLysObjectId(val: unknown): string | null {
if (typeof val === 'string' && val.trim()) return val.trim();
if (typeof val === 'number' && Number.isFinite(val)) return String(val);
return null;
}
/** Base64-encode a typed array for storage in meshModifiers. */
function bytesToBase64(bytes: Uint8Array): string {
if (typeof btoa === 'function') {
const CHUNK_SIZE = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
const chunk = bytes.subarray(i, i + CHUNK_SIZE);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(bytes).toString('base64');
}
throw new Error('No base64 encoding available');
}
/**
* High-level adapter between raw parsed LYS data and DragonFruit import payloads.
*
* This class intentionally keeps conversion orchestration separate from parser logic
* and scene integration hooks.
*/
export class LysConverter {
/** Produces compact entity counts for debug logs and diagnostics. */
private static summarizeConvertedData(data: DragonfruitImportFormat) {
return {
roots: data.roots?.length ?? 0,
trunks: data.trunks?.length ?? 0,
branches: data.branches?.length ?? 0,
leaves: data.leaves?.length ?? 0,
twigs: data.twigs?.length ?? 0,
sticks: data.sticks?.length ?? 0,
braces: data.braces?.length ?? 0,
knots: data.knots?.length ?? 0,
kickstands: data.kickstands?.length ?? 0,
};
}
/** Collects every model id referenced anywhere inside converted support payloads. */
private static collectModelIds(data: DragonfruitImportFormat): string[] {
const ids = new Set<string>();
for (const root of data.roots || []) if (root?.modelId) ids.add(root.modelId);
for (const trunk of data.trunks || []) if (trunk?.modelId) ids.add(trunk.modelId);
for (const branch of data.branches || []) if (branch?.modelId) ids.add(branch.modelId);
for (const leaf of data.leaves || []) if (leaf?.modelId) ids.add(leaf.modelId);
for (const twig of data.twigs || []) if (twig?.modelId) ids.add(twig.modelId);
for (const stick of data.sticks || []) if (stick?.modelId) ids.add(stick.modelId);
for (const brace of data.braces || []) if (brace?.modelId) ids.add(brace.modelId);
for (const kickstandBuild of data.kickstands || []) {
if (kickstandBuild?.root?.modelId) ids.add(kickstandBuild.root.modelId);
if (kickstandBuild?.kickstand?.modelId) ids.add(kickstandBuild.kickstand.modelId);
}
return [...ids];
}
/**
* Rewrites all converted entities to a single target model id.
* Used after conversion when importing into a freshly created scene model.
*/
static reassignModelId(data: DragonfruitImportFormat, modelId: string): void {
if (!modelId) return;
const beforeModelIds = this.collectModelIds(data);
for (const root of data.roots) root.modelId = modelId;
for (const trunk of data.trunks) trunk.modelId = modelId;
for (const branch of data.branches) branch.modelId = modelId;
for (const leaf of data.leaves) leaf.modelId = modelId;
for (const twig of data.twigs || []) twig.modelId = modelId;
for (const stick of data.sticks || []) stick.modelId = modelId;
for (const brace of data.braces) brace.modelId = modelId;
for (const kickstandBuild of data.kickstands || []) {
kickstandBuild.root.modelId = modelId;
kickstandBuild.kickstand.modelId = modelId;
}
console.log('[LysConverter][debug] reassignModelId', {
targetModelId: modelId,
beforeModelIds,
afterModelIds: this.collectModelIds(data),
supportSummary: this.summarizeConvertedData(data),
});
}
/**
* Applies world-space XY offset to every converted support entity.
*/
static applyWorldXYPlacement(data: DragonfruitImportFormat, offsetX: number, offsetY: number): void {
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return;
if (Math.abs(offsetX) < 1e-8 && Math.abs(offsetY) < 1e-8) return;
const shiftedJointIds = new Set<string>();
const shiftPos = (pos?: { x: number; y: number }) => {
if (!pos) return;
pos.x += offsetX;
pos.y += offsetY;
};
const shiftJoint = (joint?: Joint) => {
if (!joint?.pos) return;
if (shiftedJointIds.has(joint.id)) return;
joint.pos.x += offsetX;
joint.pos.y += offsetY;
shiftedJointIds.add(joint.id);
};
for (const root of data.roots) {
shiftPos(root.transform?.pos);
}
for (const trunk of data.trunks) {
for (const seg of trunk.segments) {
shiftJoint(seg.bottomJoint);
shiftJoint(seg.topJoint);
if (seg.type === 'bezier') {
shiftPos(seg.controlPoint1 as { x: number; y: number } | undefined);
shiftPos(seg.controlPoint2 as { x: number; y: number } | undefined);
}
}
shiftPos(trunk.contactCone?.pos);
}
for (const branch of data.branches) {
for (const seg of branch.segments) {
shiftJoint(seg.bottomJoint);
shiftJoint(seg.topJoint);
if (seg.type === 'bezier') {
shiftPos(seg.controlPoint1 as { x: number; y: number } | undefined);
shiftPos(seg.controlPoint2 as { x: number; y: number } | undefined);
}
}
shiftPos(branch.contactCone?.pos);
}
for (const leaf of data.leaves) {
shiftPos(leaf.contactCone?.pos);
}
for (const twig of data.twigs || []) {
for (const seg of twig.segments) {
shiftJoint(seg.bottomJoint);
shiftJoint(seg.topJoint);
if (seg.type === 'bezier') {
shiftPos(seg.controlPoint1 as { x: number; y: number } | undefined);
shiftPos(seg.controlPoint2 as { x: number; y: number } | undefined);
}
}
shiftPos(twig.contactDiskA?.pos);
shiftPos(twig.contactDiskB?.pos);
}
for (const stick of data.sticks || []) {
for (const seg of stick.segments) {
shiftJoint(seg.bottomJoint);
shiftJoint(seg.topJoint);
if (seg.type === 'bezier') {
shiftPos(seg.controlPoint1 as { x: number; y: number } | undefined);
shiftPos(seg.controlPoint2 as { x: number; y: number } | undefined);
}
}
shiftPos(stick.contactConeA?.pos);
shiftPos(stick.contactConeB?.pos);
}
for (const knot of data.knots) {
shiftPos(knot.pos);
}
for (const kickstandBuild of data.kickstands || []) {
shiftPos(kickstandBuild.root.transform?.pos);
shiftPos(kickstandBuild.hostKnot.pos);
for (const seg of kickstandBuild.kickstand.segments) {
shiftJoint(seg.bottomJoint);
shiftJoint(seg.topJoint);
if (seg.type === 'bezier') {
shiftPos(seg.controlPoint1 as { x: number; y: number } | undefined);
shiftPos(seg.controlPoint2 as { x: number; y: number } | undefined);
}
}
}
}
/**
* Converts parsed LYS scene payload into DragonFruit's import format.
*
* @param data Parsed LYS scene data.
* @param settings Active support settings profile used for defaults.
* @param mesh Optional transformed mesh used for contact/raycast alignment.
*/
static convert(data: LysData, settings: SupportSettings, mesh?: THREE.Mesh): DragonfruitImportFormat {
const objectIds = Object.keys(data?.objects?.present?.byId ?? {});
const supportIds = Object.keys(data?.supports?.present?.byId ?? {});
console.log('[LysConverter][debug] convert:start', {
objectCount: objectIds.length,
objectIds,
supportCount: supportIds.length,
meshVertexCount: mesh?.geometry?.getAttribute('position')?.count ?? null,
});
const converted = convertLysData(data, settings, mesh);
console.log('[LysConverter][debug] convert:done', {
outputSummary: this.summarizeConvertedData(converted),
outputModelIds: this.collectModelIds(converted),
});
return converted;
}
/**
* Applies a Z-axis rotation to all support coordinates in a DragonfruitImportFormat,
* rotating around a world-space XY pivot. Z coordinates are preserved (rotation is
* purely in the XY plane). Direction vectors (normals, tangents, axes) are also rotated.
*
* Call this AFTER convert() and any Z-offset adjustments but BEFORE applyWorldXYPlacement().
* This is the post-import equivalent of what the gizmo does via transformSupportsForModel
* when you rotate a model that already has supports.
*/
static applyZRotation(
data: DragonfruitImportFormat,
pivotX: number,
pivotY: number,
rotZRad: number,
): void {
// Guard against no-op / non-finite rotation requests.
if (!Number.isFinite(rotZRad) || Math.abs(rotZRad) < 1e-8) return;
const px = Number.isFinite(pivotX) ? pivotX : 0;
const py = Number.isFinite(pivotY) ? pivotY : 0;
const cosZ = Math.cos(rotZRad);
const sinZ = Math.sin(rotZRad);
// Position rotation around world-space pivot in XY plane.
const rotPos = (pos: { x: number; y: number; z?: number }) => {
const dx = pos.x - px;
const dy = pos.y - py;
pos.x = dx * cosZ - dy * sinZ + px;
pos.y = dx * sinZ + dy * cosZ + py;
};
// Direction vectors are rotated about origin (no pivot translation).
const rotDir = (dir: { x: number; y: number; z: number }) => {
const nx = dir.x * cosZ - dir.y * sinZ;
const ny = dir.x * sinZ + dir.y * cosZ;
dir.x = nx;
dir.y = ny;
};
// Joints can be shared across segments; rotate each joint only once.
const rotatedJointIds = new Set<string>();
const rotJoint = (joint?: { id?: string; pos: { x: number; y: number; z: number } }) => {
if (!joint?.pos) return;
const key = typeof joint.id === 'string' ? joint.id : null;
if (key && rotatedJointIds.has(key)) return;
rotPos(joint.pos);
if (key) rotatedJointIds.add(key);
};
const rotSegments = (segments: import('@/supports/types').Segment[]) => {
for (const seg of segments) {
rotJoint(seg.topJoint);
rotJoint(seg.bottomJoint);
if (seg.type === 'bezier') {
rotPos(seg.controlPoint1);
rotPos(seg.controlPoint2);
rotDir(seg.startTangent);
rotDir(seg.endTangent);
}
}
};
for (const root of data.roots) {
if (root.transform?.pos) rotPos(root.transform.pos);
}
for (const trunk of data.trunks) {
rotSegments(trunk.segments);
if (trunk.contactCone) {
rotPos(trunk.contactCone.pos);
rotDir(trunk.contactCone.normal);
if (trunk.contactCone.surfaceNormal) rotDir(trunk.contactCone.surfaceNormal);
}
}
for (const branch of data.branches) {
rotSegments(branch.segments);
if (branch.contactCone) {
rotPos(branch.contactCone.pos);
rotDir(branch.contactCone.normal);
if (branch.contactCone.surfaceNormal) rotDir(branch.contactCone.surfaceNormal);
}
}
for (const leaf of data.leaves) {
rotPos(leaf.contactCone.pos);
rotDir(leaf.contactCone.normal);
if (leaf.contactCone.surfaceNormal) rotDir(leaf.contactCone.surfaceNormal);
}
for (const twig of data.twigs || []) {
rotSegments(twig.segments);
rotPos(twig.contactDiskA.pos);
rotDir(twig.contactDiskA.surfaceNormal);
rotDir(twig.contactDiskA.coneAxis);
rotPos(twig.contactDiskB.pos);
rotDir(twig.contactDiskB.surfaceNormal);
rotDir(twig.contactDiskB.coneAxis);
}
for (const stick of data.sticks || []) {
rotSegments(stick.segments);
rotPos(stick.contactConeA.pos);
rotDir(stick.contactConeA.normal);
if (stick.contactConeA.surfaceNormal) rotDir(stick.contactConeA.surfaceNormal);
rotPos(stick.contactConeB.pos);
rotDir(stick.contactConeB.normal);
if (stick.contactConeB.surfaceNormal) rotDir(stick.contactConeB.surfaceNormal);
}
for (const knot of data.knots) {
rotPos(knot.pos);
}
for (const kickstandBuild of data.kickstands || []) {
if (kickstandBuild.root.transform?.pos) rotPos(kickstandBuild.root.transform.pos);
if (kickstandBuild.hostKnot?.pos) rotPos(kickstandBuild.hostKnot.pos);
rotSegments(kickstandBuild.kickstand.segments);
}
}
/**
* Converts LYS hollowing settings and hole punches into DragonFruit ModelMeshModifiers.
*
* Extracts:
* - Object infill/hollowing preset → `ModelHollowingModifier`
* - Pre-computed cavity mesh (`_hollowing` geometry blob) → `cavityPositionsBase64`
* - Drain hole placements → `ModelHolePunchPlacement[]`
*
* @param sceneData Raw decoded LYS scene payload (the full scene object).
* @param geometry Optional geometry used to compute bounding box for hole-position normalisation.
* @param geometriesByName All parsed geometry blobs keyed by stem name. Used to
* locate `_hollowing` cavity meshes.
* @param options Per-object scoping for multi-part scenes:
* - `objectId`: keep only holes whose `objectId` matches. Omit
* to process the whole scene as one model (legacy behavior).
* - `cavityGeometry`: this object's own `_hollowing` cavity
* mesh, used only to resolve a tilted hole's drill sign. The
* caller resolves it (it already knows the object→geometry
* mapping), just like the outer `geometry` argument.
* @returns ModelMeshModifiers or undefined if no hollowing/hole data is found.
*/
static convertHollowing(
sceneData: LysData | null | undefined,
geometry?: THREE.BufferGeometry,
geometriesByName?: Map<string, THREE.BufferGeometry>,
isLegacyGeometry?: boolean,
options?: { objectId?: string; cavityGeometry?: THREE.BufferGeometry },
): ModelMeshModifiers | undefined {
if (!sceneData) {
console.log('[LysConverter][convertHollowing] No sceneData — skipping');
return undefined;
}
const result: ModelMeshModifiers = {};
// Multi-part scenes call this once per object; scope the hole punches to that
// object so holes don't leak across models. Omitted for the whole-scene
// (single-model) path, which keeps the legacy behavior. (Hollowing/cavity
// extraction for Interior View is unchanged — see section 1.)
const targetObjectId = normLysObjectId(options?.objectId);
// -----------------------------------------------------------------------
// 1) Hollowing settings
//
// Lychee stores hollowing per-object (`objects.present.byId[ID].hollowing`).
// Some variants also carry a global fallback at `settings.objectInfill.preset`.
// We check per-object first, then fall back to the global path.
// -----------------------------------------------------------------------
// Scan all objects for per-object hollowing data.
let hollowingSource: LysHollowing | null = null;
const objects = sceneData?.objects?.present?.byId;
if (objects) {
const objectIds = Object.keys(objects);
console.log(`[LysConverter][convertHollowing] Scanning ${objectIds.length} objects for per-object hollowing: [${objectIds.join(', ')}]`);
for (const [objId, obj] of Object.entries(objects)) {
if (obj?.hollowing) {
console.log(`[LysConverter][convertHollowing] Object ${objId} has hollowing: enabled=${obj.hollowing.enabled}, outer=${obj.hollowing.outer}`);
if (obj.hollowing.enabled === true
|| (isLegacyGeometry && typeof obj.hollowing.outer === 'number' && obj.hollowing.outer > 0)) {
hollowingSource = obj.hollowing;
console.log(`[LysConverter][convertHollowing] Using per-object hollowing from ${objId}`);
break;
}
} else {
console.log(`[LysConverter][convertHollowing] Object ${objId} has no .hollowing property`);
}
}
} else {
console.log('[LysConverter][convertHollowing] No objects.present.byId found in sceneData');
}
// Fall back to the global `settings.objectInfill.preset` path.
if (!hollowingSource) {
const preset = sceneData?.settings?.objectInfill?.preset;
console.log('[LysConverter][convertHollowing] No per-object hollowing; checking settings.objectInfill.preset:', JSON.stringify(preset));
if (preset?.enabled === true) {
hollowingSource = preset;
console.log('[LysConverter][convertHollowing] Using global settings.objectInfill.preset');
}
}
if (hollowingSource) {
console.log('[LysConverter][convertHollowing] Hollowing found — outer:', hollowingSource.outer, 'infillInterval:', hollowingSource.infillInterval, 'infillEnabled:', hollowingSource.infillEnabled);
// Lychee doesn't expose voxel size — default to 0.5mm.
const hollowingModifier: ModelHollowingModifier = {
enabled: true,
mode: 'cavity',
voxelSizeMm: 0.5,
shellThicknessMm: hollowingSource.outer ?? 1.8,
infillMode: hollowingSource.infillEnabled ? 'lattice' : undefined,
infillCellMm: hollowingSource.infillInterval ?? 5,
infillBeamRadiusMm: 0.5,
openFace: 'z_max',
// Mark as baked — Lychee already pre-computed the cavity mesh (stored
// as `_hollowing` geometry). This keeps the hollowing panel from
// offering to re-apply voxel hollowing when the cavity is already
// available for Interior View.
bakedIntoGeometry: true,
};
// Lychee pre-computes the cavity interior mesh and stores it with a
// `_hollowing` suffix (e.g. `<hash>_hollowing`). Extract it so DragonFruit's
// Interior View can display the pre-baked cavity without re-hollowing.
if (geometriesByName) {
for (const [stem, cavityGeom] of geometriesByName) {
if (stem.toLowerCase().endsWith('_hollowing')) {
const posAttr = cavityGeom.getAttribute('position');
if (posAttr) {
const positions = posAttr.array as Float32Array;
const cavityBytes = new Uint8Array(positions.buffer, positions.byteOffset, positions.byteLength);
hollowingModifier.cavityPositionsBase64 = bytesToBase64(cavityBytes);
hollowingModifier.cavityPositionCount = positions.length / 3;
console.log(`[LysConverter][convertHollowing] Extracted _hollowing cavity mesh from "${stem}": ${positions.length / 3} vertices`);
}
break;
}
}
}
result.hollowing = hollowingModifier;
console.log('[LysConverter][convertHollowing] Hollowing modifier set:', JSON.stringify(result.hollowing));
} else {
console.log('[LysConverter][convertHollowing] No hollowing settings found in scene');
}
// -----------------------------------------------------------------------
// 2) Hole punches (drain holes)
// -----------------------------------------------------------------------
const allHoles = sceneData?.holes?.present?.byId;
// In a multi-part scene every hole carries an `objectId`; keep only the ones
// belonging to the object we're converting. Without this, each imported model
// would receive every hole in the file. Unscoped, keep all holes.
const holeEntries = allHoles
? Object.entries(allHoles).filter(([, hole]) => {
if (!targetObjectId) return true;
return normLysObjectId(hole?.objectId) === targetObjectId;
})
: [];
console.log(`[LysConverter][convertHollowing] holes.present.byId has ${allHoles ? Object.keys(allHoles).length : 0} entries; ${holeEntries.length} match${targetObjectId ? ` object ${targetObjectId}` : ' (unscoped)'}`);
if (holeEntries.length > 0) {
// Compute bounding box once for coordinate normalisation.
geometry?.computeBoundingBox();
const bbox = geometry?.boundingBox ?? null;
const size = bbox ? bbox.getSize(new THREE.Vector3()) : null;
const placements: ModelHolePunchPlacement[] = [];
// A tilted hole's drill sign is resolved against the mesh geometry; build
// an acceleration structure once, only when a tilted hole is present.
const hasTiltedHole = holeEntries.some(([, h]) => h && h.tipRotation);
const holeBvh = hasTiltedHole && geometry?.getAttribute('position')
? new MeshBVH(geometry)
: null;
// The pre-baked cavity mesh lets us tell interior-anchored drain holes from
// outer-surface ones when resolving the drill sign. Scoped: multi-part
// callers pass this object's own cavity; unscoped, use the first `_hollowing`
// mesh in the map (single-model file — there's only one).
let cavityGeom: THREE.BufferGeometry | null = null;
if (targetObjectId) {
cavityGeom = options?.cavityGeometry ?? null;
} else if (geometriesByName) {
for (const [stem, g] of geometriesByName) {
if (stem.toLowerCase().endsWith('_hollowing') && g.getAttribute('position')) {
cavityGeom = g;
break;
}
}
}
const cavityBvh = hasTiltedHole && cavityGeom?.getAttribute('position')
? new MeshBVH(cavityGeom)
: null;
for (const [holeId, hole] of holeEntries) {
if (!hole) {
console.log(`[LysConverter][convertHollowing] Skipping hole ${holeId}: null/undefined entry`);
continue;
}
// LYS holes are always cylinders — check `settings.type` first, then top-level `type`.
const lysType = (hole.settings?.type as string | undefined) ?? (hole.type as string | undefined);
if (lysType && lysType !== 'cylinder') {
console.log(`[LysConverter][convertHollowing] Skipping hole ${holeId}: non-cylinder type="${lysType}"`);
continue;
}
// LYS holes store their surface contact point at `tip`. The drill
// direction depends on whether the user tilted the hole:
// - un-rotated (`tipRotation == null`): `tipNormal` is the OUTWARD
// surface normal, so we negate it to drill inward.
// - tilted (`tipRotation` set): the cylinder axis is the rotation
// frame's Z (`tipNormal` is that frame's −Y and points sideways).
// The frame doesn't encode which way along the axis is inward, so we
// resolve the sign against the mesh (and cavity mesh for interior-
// anchored drain holes — see resolveDrillInward). Verified across
// 5 files / 20 holes.
// Some variants use a 4x4 `stlMatrix` instead — fall back to that.
const pos = new THREE.Vector3(0, 0, 0);
const dir = new THREE.Vector3(0, 0, -1);
if (hole.tip && typeof hole.tip.x === 'number') {
pos.set(hole.tip.x, hole.tip.y, hole.tip.z);
if (hole.tipRotation) {
const q = quaternionFromGlobalEulerDegrees(hole.tipRotation);
const axis = new THREE.Vector3(0, 0, 1).applyQuaternion(q).normalize();
const inward = holeBvh ? resolveDrillInward(holeBvh, cavityBvh, pos, axis) : null;
dir.copy(inward ?? axis);
} else if (hole.tipNormal && typeof hole.tipNormal.x === 'number') {
const n = new THREE.Vector3(hole.tipNormal.x, hole.tipNormal.y, hole.tipNormal.z);
if (n.lengthSq() > 1e-8) dir.copy(n.normalize().negate());
}
} else {
const stlMatrix: number[] | undefined = hole.stlMatrix;
if (stlMatrix && stlMatrix.length >= 16) {
// Translation = column 3 (indices 12,13,14)
pos.set(stlMatrix[12], stlMatrix[13], stlMatrix[14]);
// Z-axis = column 2 (indices 8,9,10) — the cylinder axis
const zAxis = new THREE.Vector3(stlMatrix[8], stlMatrix[9], stlMatrix[10]);
if (zAxis.lengthSq() > 1e-8) dir.copy(zAxis.normalize());
}
}
// Normalise position to 0–1 range relative to bounding box.
let centerNorm: [number, number, number] = [0.5, 0.5, 0.5];
if (size && bbox) {
centerNorm = [
size.x > 1e-9
? Math.max(0, Math.min(1, (pos.x - bbox.min.x) / size.x))
: 0.5,
size.y > 1e-9
? Math.max(0, Math.min(1, (pos.y - bbox.min.y) / size.y))
: 0.5,
size.z > 1e-9
? Math.max(0, Math.min(1, (pos.z - bbox.min.z) / size.z))
: 0.5,
];
}
// Diameter/depth come from `settings`, then fall back to top-level fields.
const settings = hole.settings;
const diameter = (settings?.diameter ?? hole.diameter ?? 2) as number;
const depth = (settings?.depth ?? hole.depth ?? 3) as number;
const radiusMm = diameter / 2;
const depthMm = depth;
console.log(`[LysConverter][convertHollowing] Hole ${holeId}: pos=(${pos.x.toFixed(2)}, ${pos.y.toFixed(2)}, ${pos.z.toFixed(2)}), dir=(${dir.x.toFixed(3)}, ${dir.y.toFixed(3)}, ${dir.z.toFixed(3)}), radius=${radiusMm}, depth=${depthMm}`);
placements.push({
id: hole.id || `lys-hole-${holeId}`,
centerNorm,
radiusMm,
depthMm,
direction: [dir.x, dir.y, dir.z],
});
}
if (placements.length > 0) {
result.holePunches = placements;
console.log(`[LysConverter][convertHollowing] Extracted ${placements.length} hole punches`);
} else {
console.log('[LysConverter][convertHollowing] No cylinder holes found after filtering');
}
}
const hasResult = Object.keys(result).length > 0;
console.log(`[LysConverter][convertHollowing] Returning ${hasResult ? 'meshModifiers' : 'undefined'} (hollowing=${!!result.hollowing}, holes=${result.holePunches?.length ?? 0})`);
return hasResult ? result : undefined;
}
}