import { ocRef } from './oc.js'; export function create3DModel(positions, config) { const oc = ocRef.instance; if (!oc || positions.length === 0) return null; const { cellSize, spacing, height, terminalDiameter, terminalDepth, coverThickness, ledgeWidth, roundedCorners, bmsHoles, useTabs, useFullCircles, filletBms, tabOverlapSide, layoutType = 'honeycomb', } = config; const r = cellSize / 2; const minX = Math.min(...positions.map(p => p[0])) - r - spacing; const minY = Math.min(...positions.map(p => p[1])) - r - spacing; const maxX = Math.max(...positions.map(p => p[0])) + r + spacing; const maxY = Math.max(...positions.map(p => p[1])) + r + spacing; const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; const adjusted = positions.map(([x, y]) => [x - centerX, y - centerY]); const width = maxX - minX; const length = maxY - minY; const perfStart = performance.now(); let perfLast = perfStart; const logTime = (label) => { const now = performance.now(); perfLast = now; }; const boxMaker = new oc.BRepPrimAPI_MakeBox(width, length, height); let base = boxMaker.Shape(); const translation = new oc.gp_Trsf(); translation.SetTranslation(new oc.gp_Vec(-width / 2, -length / 2, 0)); const transform = new oc.BRepBuilderAPI_Transform(base, translation, false); base = transform.Shape(); if (roundedCorners) { try { const cornerRadius = 5.0; const forEachEdge = (shape, callback) => { const edgeHashes = {}; let edgeIndex = 0; const anExplorer = new oc.TopExp_Explorer(shape, oc.TopAbs_EDGE); for (anExplorer.Init(shape, oc.TopAbs_EDGE); anExplorer.More(); anExplorer.Next()) { const edge = oc.TopoDS.prototype.Edge(anExplorer.Current()); const edgeHash = edge.HashCode(100000000); if (!edgeHashes.hasOwnProperty(edgeHash)) { edgeHashes[edgeHash] = edgeIndex; callback(edgeIndex++, edge); } } return edgeHashes; }; const verticalEdgeIndices = []; forEachEdge(base, (index, edge) => { try { const bbox = new oc.Bnd_Box(); oc.BRepBndLib.prototype.Add(edge, bbox, false); const bboxMin = bbox.CornerMin(); const bboxMax = bbox.CornerMax(); const dx = Math.abs(bboxMax.X() - bboxMin.X()); const dy = Math.abs(bboxMax.Y() - bboxMin.Y()); const dz = Math.abs(bboxMax.Z() - bboxMin.Z()); if (dx < 1.0 && dy < 1.0 && dz > height * 0.8) { verticalEdgeIndices.push(index); } } catch (e) { /* ignore */ } }); if (verticalEdgeIndices.length > 0) { const mkFillet = new oc.BRepFilletAPI_MakeFillet(base); let edgeCount = 0; forEachEdge(base, (index, edge) => { if (verticalEdgeIndices.includes(index)) { try { mkFillet.Add(cornerRadius, edge); edgeCount++; } catch (e) { console.error(` Failed to add edge ${index}:`, e.message); } } }); if (edgeCount > 0) { base = new oc.TopoDS_Solid(mkFillet.Shape()); console.log(' Applied rounded corners'); logTime('Rounded corners'); } } else { console.log(' No vertical edges found to fillet'); } } catch (e) { console.error(' Fillet operation failed:', e.message); console.log(' Continuing without rounded corners'); } } // Build cell hole cutters. // When a ledge is requested (ledgeWidth > 0 || coverThickness > 0) we use a // two-step bore so the ledge ring stays part of the base body: // • outer bore (radius r) from z=coverThickness to z=height // • inner bore (radius r - ledgeWidth) from z=0 to z=height const hasLedge = (ledgeWidth > 0 || coverThickness > 0); if (!hasLedge) { const cutters = adjusted.map(([x, y]) => { const ax = new oc.gp_Ax2(new oc.gp_Pnt(x, y, 0), oc.gp.prototype.DZ()); return new oc.BRepPrimAPI_MakeCylinder(ax, r, height).Shape(); }); let allCutters = cutters[0]; for (let i = 1; i < cutters.length; i++) { allCutters = new oc.BRepAlgoAPI_Fuse(allCutters, cutters[i]).Shape(); } base = new oc.BRepAlgoAPI_Cut(base, allCutters).Shape(); } else { const outerZ = coverThickness; const outerH = height - coverThickness; const innerR = Math.max(0.1, r - ledgeWidth); const outerCutters = adjusted.map(([x, y]) => { const ax = new oc.gp_Ax2(new oc.gp_Pnt(x, y, outerZ), oc.gp.prototype.DZ()); return new oc.BRepPrimAPI_MakeCylinder(ax, r, outerH).Shape(); }); let allOuter = outerCutters[0]; for (let i = 1; i < outerCutters.length; i++) { allOuter = new oc.BRepAlgoAPI_Fuse(allOuter, outerCutters[i]).Shape(); } base = new oc.BRepAlgoAPI_Cut(base, allOuter).Shape(); const innerCutters = adjusted.map(([x, y]) => { const ax = new oc.gp_Ax2(new oc.gp_Pnt(x, y, 0), oc.gp.prototype.DZ()); return new oc.BRepPrimAPI_MakeCylinder(ax, innerR, height).Shape(); }); let allInner = innerCutters[0]; for (let i = 1; i < innerCutters.length; i++) { allInner = new oc.BRepAlgoAPI_Fuse(allInner, innerCutters[i]).Shape(); } base = new oc.BRepAlgoAPI_Cut(base, allInner).Shape(); } console.log(' Cell holes cut' + (hasLedge ? ' (with integrated ledge)' : '')); logTime('Cell holes'); if (bmsHoles) { const holeDiameter = config.bmsHoleDiameter || 4.0; let holeYTop, holeYBottom; if (useFullCircles) { holeYTop = null; holeYBottom = null; } else { holeYTop = length / 2; holeYBottom = -length / 2; } const rows = {}; adjusted.forEach(([x, y]) => { const yKey = Math.round(y * 1000); if (!rows[yKey]) rows[yKey] = []; rows[yKey].push([x, y]); }); const rowKeys = Object.keys(rows).map(k => parseInt(k)).sort((a, b) => b - a); const topYKey = rowKeys[0]; const bottomYKey = rowKeys[rowKeys.length - 1]; if (useFullCircles) { const topCellY = rows[topYKey][0][1]; const bottomCellY = rows[bottomYKey][0][1]; const wallTop = length / 2; const wallBottom = -length / 2; const solveEquilateral3D = (wallY, cellY, x1, x2) => { const xMid = (x1 + x2) / 2; const flip = cellY < wallY ? -1 : 1; let lo = flip > 0 ? -Math.PI / 2 : 0; let hi = flip > 0 ? 0 : Math.PI / 2; for (let i = 0; i < 80; i++) { const alpha = (lo + hi) / 2; const Bx = x1 + r * Math.cos(alpha); const By = cellY + r * Math.sin(alpha); const d = xMid - Bx; const h = (By - wallY) * flip; const diff = h - d * Math.sqrt(3); if (Math.abs(diff) < 1e-8) break; if (diff < 0) { flip > 0 ? (lo = alpha) : (hi = alpha); } else { flip > 0 ? (hi = alpha) : (lo = alpha); } } const alpha = (lo + hi) / 2; const By = cellY + r * Math.sin(alpha); return (wallY + 2 * By) / 3; }; holeYTop = solveEquilateral3D(wallTop, topCellY, rows[topYKey][0][0], rows[topYKey][1][0]); holeYBottom = solveEquilateral3D(wallBottom, bottomCellY, rows[bottomYKey][0][0], rows[bottomYKey][1][0]); } const topRow = rows[topYKey].sort((a, b) => a[0] - b[0]); const bottomRow = rows[bottomYKey].sort((a, b) => a[0] - b[0]); const minAllX = Math.min(...positions.map(([x]) => x)); // Vertical column pitch: minimum X delta between any two cells const _allXSorted = [...new Set(positions.map(([x]) => Math.round(x * 1000)))] .sort((a, b) => a - b).map(v => v / 1000); const vertColPitch = _allXSorted.length >= 2 ? _allXSorted[1] - _allXSorted[0] : 0; const topPitch = topRow.length >= 2 ? topRow[topRow.length - 1][0] - topRow[topRow.length - 2][0] : 0; const topHoles = []; for (let i = 0; i < topRow.length - 1; i++) { topHoles.push([(topRow[i][0] + topRow[i + 1][0]) / 2, holeYTop]); } if (layoutType === 'vertical') { const topIsEven = vertColPitch === 0 || (topRow[0][0] - minAllX) < vertColPitch / 2; if (!topIsEven) { topHoles.unshift([topRow[0][0] - vertColPitch / 2, holeYTop]); topHoles.push([topRow[topRow.length - 1][0] + vertColPitch / 2, holeYTop]); } } else if (layoutType !== 'grid') { const topExtraRight = topRow.length >= 2 && (topRow[0][0] - minAllX) < topPitch / 4; if (topExtraRight) { topHoles.push([topRow[topRow.length - 1][0] + topPitch / 2, holeYTop]); } else { topHoles.unshift([topRow[0][0] - topPitch / 2, holeYTop]); } } const bottomPitch = bottomRow.length >= 2 ? bottomRow[bottomRow.length - 1][0] - bottomRow[bottomRow.length - 2][0] : 0; const bottomHoles = []; for (let i = 0; i < bottomRow.length - 1; i++) { bottomHoles.push([(bottomRow[i][0] + bottomRow[i + 1][0]) / 2, holeYBottom]); } if (layoutType === 'vertical') { const bottomIsEven = vertColPitch === 0 || (bottomRow[0][0] - minAllX) < vertColPitch / 2; if (!bottomIsEven) { bottomHoles.unshift([bottomRow[0][0] - vertColPitch / 2, holeYBottom]); bottomHoles.push([bottomRow[bottomRow.length - 1][0] + vertColPitch / 2, holeYBottom]); } } else if (layoutType !== 'grid') { const bottomExtraRight = bottomRow.length >= 2 && (bottomRow[0][0] - minAllX) < bottomPitch / 4; if (bottomExtraRight) { bottomHoles.push([bottomRow[bottomRow.length - 1][0] + bottomPitch / 2, holeYBottom]); } else { bottomHoles.unshift([bottomRow[0][0] - bottomPitch / 2, holeYBottom]); } } const allBmsHoles = [...topHoles, ...bottomHoles]; if (useTabs) { const slotWidth = config.tabWidth || holeDiameter; const slotInset = config.tabDepth || 1.0; const slotHeight = Number.isFinite(config.tabLength) && config.tabLength > 0 ? Math.min(config.tabLength, height) : height; const topEdgeY = length / 2; const bottomEdgeY = -length / 2; const allSlots = []; topHoles.forEach(([xPos]) => { const slotBox = new oc.BRepPrimAPI_MakeBox(slotWidth, slotInset, slotHeight); const slot = slotBox.Shape(); const trans = new oc.gp_Trsf(); trans.SetTranslation(new oc.gp_Vec(xPos - slotWidth / 2, topEdgeY - slotInset, height - slotHeight)); const slotTransform = new oc.BRepBuilderAPI_Transform(slot, trans, false); allSlots.push(slotTransform.Shape()); }); bottomHoles.forEach(([xPos]) => { const slotBox = new oc.BRepPrimAPI_MakeBox(slotWidth, slotInset, slotHeight); const slot = slotBox.Shape(); const trans = new oc.gp_Trsf(); trans.SetTranslation(new oc.gp_Vec(xPos - slotWidth / 2, bottomEdgeY, height - slotHeight)); const slotTransform = new oc.BRepBuilderAPI_Transform(slot, trans, false); allSlots.push(slotTransform.Shape()); }); if (allSlots.length > 0) { if (allSlots.length === 1) { base = new oc.BRepAlgoAPI_Cut(base, allSlots[0]).Shape(); } else { let compound = allSlots[0]; for (let i = 1; i < allSlots.length; i++) { compound = new oc.BRepAlgoAPI_Fuse(compound, allSlots[i]).Shape(); } base = new oc.BRepAlgoAPI_Cut(base, compound).Shape(); } } logTime('Edge tabs'); } else { if (allBmsHoles.length <= 10) { allBmsHoles.forEach(([x, y]) => { const cylinderAxis = new oc.gp_Ax2(new oc.gp_Pnt(x, y, 0), oc.gp.prototype.DZ()); const cylinder = new oc.BRepPrimAPI_MakeCylinder(cylinderAxis, holeDiameter / 2, height).Shape(); base = new oc.BRepAlgoAPI_Cut(base, cylinder).Shape(); }); } else { const bmsCylinders = allBmsHoles.map(([x, y]) => { const cylinderAxis = new oc.gp_Ax2(new oc.gp_Pnt(x, y, 0), oc.gp.prototype.DZ()); return new oc.BRepPrimAPI_MakeCylinder(cylinderAxis, holeDiameter / 2, height).Shape(); }); let compound = bmsCylinders[0]; for (let i = 1; i < bmsCylinders.length; i++) { compound = new oc.BRepAlgoAPI_Fuse(compound, bmsCylinders[i]).Shape(); } base = new oc.BRepAlgoAPI_Cut(base, compound).Shape(); } logTime('BMS holes'); if (filletBms && allBmsHoles.length > 0) { try { console.log(' Filleting BMS hole edges...'); const bmsFilletRadius = 0.5; const forEachEdge = (shape, callback) => { const edgeHashes = {}; let edgeIndex = 0; const anExplorer = new oc.TopExp_Explorer(shape, oc.TopAbs_EDGE); for (anExplorer.Init(shape, oc.TopAbs_EDGE); anExplorer.More(); anExplorer.Next()) { const edge = oc.TopoDS.prototype.Edge(anExplorer.Current()); const edgeHash = edge.HashCode(100000000); if (!edgeHashes.hasOwnProperty(edgeHash)) { edgeHashes[edgeHash] = edgeIndex; callback(edgeIndex++, edge); } } return edgeHashes; }; const bmsEdgeIndices = []; forEachEdge(base, (index, edge) => { try { const bbox = new oc.Bnd_Box(); oc.BRepBndLib.prototype.Add(edge, bbox, false); const bboxMin = bbox.CornerMin(); const bboxMax = bbox.CornerMax(); const centerX = (bboxMin.X() + bboxMax.X()) / 2; const centerY = (bboxMin.Y() + bboxMax.Y()) / 2; const centerZ = (bboxMin.Z() + bboxMax.Z()) / 2; for (const [holeX, holeY] of allBmsHoles) { const distXY = Math.hypot(centerX - holeX, centerY - holeY); const isNearHole = distXY < holeDiameter; const isAtTopOrBottom = Math.abs(centerZ - 0) < 1.0 || Math.abs(centerZ - height) < 1.0; if (isNearHole && isAtTopOrBottom) { bmsEdgeIndices.push(index); break; } } } catch (e) { /* ignore */ } }); if (bmsEdgeIndices.length > 0) { const mkFillet = new oc.BRepFilletAPI_MakeFillet(base); let edgeCount = 0; forEachEdge(base, (index, edge) => { if (bmsEdgeIndices.includes(index)) { try { mkFillet.Add(bmsFilletRadius, edge); edgeCount++; } catch (e) { console.error(` Failed to add BMS edge ${index}`); } } }); if (edgeCount > 0) { base = new oc.TopoDS_Solid(mkFillet.Shape()); console.log(` Applied ${bmsFilletRadius}mm fillet to ${edgeCount} BMS hole edges`); } } } catch (e) { console.error(' BMS fillet failed:', e.message); console.log(' Continuing without BMS filleting'); } } } } if (adjusted.length <= 10) { adjusted.forEach(([x, y]) => { const cylinderAxis = new oc.gp_Ax2(new oc.gp_Pnt(x, y, height - terminalDepth), oc.gp.prototype.DZ()); const cylinder = new oc.BRepPrimAPI_MakeCylinder(cylinderAxis, terminalDiameter / 2, terminalDepth).Shape(); base = new oc.BRepAlgoAPI_Cut(base, cylinder).Shape(); }); } else { const terminals = adjusted.map(([x, y]) => { const cylinderAxis = new oc.gp_Ax2(new oc.gp_Pnt(x, y, height - terminalDepth), oc.gp.prototype.DZ()); return new oc.BRepPrimAPI_MakeCylinder(cylinderAxis, terminalDiameter / 2, terminalDepth).Shape(); }); const batchSize = 30; const batches = []; for (let i = 0; i < terminals.length; i += batchSize) { const end = Math.min(i + batchSize, terminals.length); let batch = terminals[i]; for (let j = i + 1; j < end; j++) { batch = new oc.BRepAlgoAPI_Fuse(batch, terminals[j]).Shape(); } batches.push(batch); } let allTerminals = batches[0]; for (let i = 1; i < batches.length; i++) { allTerminals = new oc.BRepAlgoAPI_Fuse(allTerminals, batches[i]).Shape(); } base = new oc.BRepAlgoAPI_Cut(base, allTerminals).Shape(); } console.log(' Terminal recesses cut'); logTime('Terminal recesses'); if (hasLedge) { console.log(' Ledge integrated into cell holes'); } logTime('Ledge rings'); logTime('TOTAL GENERATION'); return base; }