442 lines
19 KiB
JavaScript
442 lines
19 KiB
JavaScript
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;
|
|
}
|