Skip to content

Instantly share code, notes, and snippets.

@apla
Last active March 18, 2024 15:13
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save apla/90edff2e4993f3e3592f8795c2050cc5 to your computer and use it in GitHub Desktop.
Save apla/90edff2e4993f3e3592f8795c2050cc5 to your computer and use it in GitHub Desktop.
WIP Convert JSCAD V1 => V2
const fs = require('fs').promises;
const { parse } = require('acorn');
const acornWalk = require('acorn-walk');
// Important limitations:
// Variables in attributes cannot be processed automatically, converter will throw in that case
// Whitespace and source comments lost sometimes, especially in first argument
// Constructs like x.difference(y), z.union(a, b, c) probably not supported - need parameter reordering
// If you see it not works and want to add support for that, modify `cluster.chunks.reduce` in `processCluster`
const conversions = {
cylinder: {
module: 'primitives',
attrsMapping: {r: 'radius', h: 'height', center: 'center'},
processAttrs (source, [attrNode]) {
const attrValues = processPrimitiveAttrs(source, 'cylinder', [attrNode]);
if (!attrValues[0].center) {
attrValues[0].center = `[0, 0, (${attrValues[0].height})/2]`
} else if (attrValues[0].center === 'true') {
delete attrValues[0].center;
} else {
// center can be runtime value
attrValues[0].center = `[0, 0, (${attrValues[0].center}) ? 0 : (${attrValues[0].height})/2]`
}
return attrValues;
}
},
cube: {
module: 'primitives',
replaceWith: 'cuboid',
arrayArgToAttr: 'size',
processAttrs (source, [attrNode]) {
const attrValues = processPrimitiveAttrs(source, 'cube', [attrNode]);
// TODO: process something like cube([2, 2, 2])
if (!attrValues[0].center) {
attrValues[0].center = `[(${attrValues[0].size[0]})/2, (${attrValues[0].size[1]})/2, (${attrValues[0].size[2]})/2]`
} else if (attrValues[0].center === 'true') {
delete attrValues[0].center;
} else {
// center can be runtime value
attrValues[0].center = `(${attrValues[0].center}) ? [0, 0, 0] : [(${attrValues[0].size[0]})/2, (${attrValues[0].size[1]})/2, (${attrValues[0].size[2]})/2]`
}
return attrValues;
}
},
torus: {
module: 'primitives',
attrsMapping: {ri: 'innerRadius', ro: 'outerRadius', fni: 'innerSegments', fno: 'outerSegments', roti: 'innerRotation', center: 'center'},
processAttrs (source, [attrNode]) {
const attrValues = processPrimitiveAttrs(source, 'torus', [attrNode]);
if (attrValues[0].center === 'true') {
delete attrValues[0].center;
} else {
// TODO: center
attrValues[0].center = 'TODO: ' + attrValues[0].center;
}
attrValues[0].innerRotation = convertToRadians(attrValues[0].innerRotation);
return attrValues;
}
},
difference: {
module: 'booleans',
replaceWith: 'subtract', // hack or do processAttrs like in rotate
},
union: {
module: 'booleans',
},
linear_extrude: {
replaceWith: 'extrudeLinear',
module: 'extrusions',
},
translate: { module: 'transforms', },
translateX: { module: 'transforms', },
translateY: { module: 'transforms', },
translateZ: { module: 'transforms', },
rotate: {
module: 'transforms',
processAttrs (source, attrNodes) {
const attrValues = processArrayAndFollowingAttrs(source, 'rotate', attrNodes);
attrValues[0] = attrValues[0].map(v => convertToRadians(v));
return attrValues;
}
},
rotateX: {
module: 'transforms',
processAttrs (source, attrNodes) {
attrNodes[0].translatedValue = convertToRadians(source.substring(attrNodes[0].start, attrNodes[0].end));
return attrNodes;
}
},
rotateY: {
module: 'transforms',
processAttrs (source, attrNodes) {
attrNodes[0].translatedValue = convertToRadians(source.substring(attrNodes[0].start, attrNodes[0].end));
return attrNodes;
}
},
rotateZ: {
module: 'transforms',
processAttrs (source, attrNodes) {
attrNodes[0].translatedValue = convertToRadians(source.substring(attrNodes[0].start, attrNodes[0].end));
return attrNodes;
}
},
setColor: {
module: 'colors',
replaceWith: 'colorize'
},
};
const usedConversions = {};
function getConversion (name) {
if (name in conversions) {
usedConversions[conversions[name].module] = usedConversions[conversions[name].module] || {};
usedConversions[conversions[name].module][conversions[name].replaceWith || name] = 1;
}
return conversions[name];
}
/**
* Convert degrees to radians
* @param {number} degrees degrees
*/
function convertToRadians (degrees) {
if (degrees % 360 === 0) return 0;
usedConversions.utils = {degToRad: 1};
return 'degToRad(' + degrees + ')';
// return '(' + degrees + ') * Math.PI / 180'
}
function processArray (source, node) {
// TODO: process toConvert
return node.elements.map ((el) => {
return source.substring(el.start, el.end);
});
}
// we need to find largest non-overlapping ranges with functions to convert,
// which is located in between property value
function findDescendantsToConvert (ancestor) {
const descendants = toConvert.filter ((node) => {
if (node.start > ancestor.start && node.end < ancestor.end) {
// console.log (`VRLP node.start > ancestor.start ${node.start > ancestor.start} && node.end < ancestor.end ${node.end < ancestor.end}`);
// console.log (ancestor.callee.name, node.callee.name);
}
return node.start > ancestor.start
&& node.end < ancestor.end
})
const directDescendats = descendants.filter ((node) => {
// console.log ('\t>>>', ancestor.callee.name, node.callee.name, ` ${node.start}:${node.end}`);
const haveAncestor = descendants.some ((subnode) => {
// console.log ('\t', `${subnode.callee.name} ${subnode.start}:${subnode.end} found later ${subnode.start > node.end} found earlier ${subnode.end < node.start} found descendant ${((node.start < subnode.start) && (node.end > subnode.end))}`);
// console.log ('\t', node.callee.name, subnode.callee.name);
return node.start > subnode.start && node.end < subnode.end;
});
return !haveAncestor;
});
// if (directDescendats.length) console.log ('DD', ancestor.callee.name, directDescendats);
return directDescendats;
}
/**
* Process first argument of JSCAD graphical primitive
* @param {string} source source
* @param {string} primitive pritive identification for logging
* @param {acorn.Node[]} attrNodes attribute nodes from AST
* @returns {Object[]}
*/
function processPrimitiveAttrs (source, primitive, [attrNode]) {
const thisConversion = getConversion(primitive);
// TODO: warn about manual conversion instead of throw
// sometimes argument provided using variable
if (attrNode.type === 'ArrayExpression' && thisConversion.arrayArgToAttr) {
// console.log(attrNode);
return [{[thisConversion.arrayArgToAttr]: processArray (source, attrNode)}];
throw new Error (`'${primitive}' expecting object as first argument, but have '${attrNode.type}'`);
} else if (attrNode.type !== 'ObjectExpression') {
throw new Error (`'${primitive}' expecting object as first argument, but have '${attrNode.type}'`);
}
const attrConversion = thisConversion.attrsMapping;
const attrValues = {};
attrNode.properties.forEach (prop => {
const attrName = attrConversion ? attrConversion[prop.key.name] : prop.key.name;
if (attrConversion && !attrConversion[prop.key.name])
throw new Error (`'${primitive}' have unexpected parameter '${prop.key.name}'`);
// console.log (prop.value);
let propValue = source.substring (prop.value.start, prop.value.end);
if (prop.value.type === 'ArrayExpression') {
propValue = processArray (source, prop.value);
} else {
// const descendantsToConvert = findDescendantsToConvert(prop);
// console.log ('OVERLAP', primitive, descendantsToConvert);
// TODO: process toConvert
}
attrValues[attrName] = propValue;
});
return [attrValues];
}
/**
*
* @param {string} source source
* @param {string} fnName function name
* @param {acorn.Node[]} attrNodes attribute nodes from AST
* @returns
*/
function processArrayAndFollowingAttrs (source, fnName, attrNodes) {
const [attrNode, ...restNodes] = attrNodes;
// first argument should be array or variable, not some jscad object
if (attrNode.type !== 'ArrayExpression') {
throw new Error(`expected array as a first argument of '${fnName}'`);
}
const firstArgument = processArray (source, attrNode);
return [firstArgument, ...restNodes];
}
function processFnArguments (source, conversion, fnNode) {
let attrValues;
let attrsString;
const fnNodeCallee = fnNode.callee.type === 'MemberExpression' ? fnNode.callee.property : fnNode.callee;
if (conversion.processAttrs) {
attrValues = conversion.processAttrs(source, fnNode.arguments);
} else if (conversion.attrsMapping) {
attrValues = processPrimitiveAttrs (source, fnNodeCallee.name, fnNode.arguments);
} else {
attrValues = fnNode.arguments;
/*.map ((arg) => {
return source.substring(arg.start, arg.end);
});*/
}
// console.log (fnNodeCallee.name, attrValues);
const descendantsToConvert = findDescendantsToConvert(fnNode);
if (attrValues && attrValues.length > 0) {
// console.log ('XXX',fnNodeCallee.name, attrValues);
// if (PASS === 2) console.log ('>>>', fnNodeCallee.name, '>>>', source.substring (fnNode.start, fnNode.end));
attrsString = [
source.substring(fnNodeCallee.end, fnNode.arguments[0].start),
...attrValues.map((av, avIdx) => {
const tail = attrValues.length - avIdx > 1
? source.substring(fnNode.arguments[avIdx].end, fnNode.arguments[avIdx + 1].start)
: source.substring(fnNode.arguments[avIdx].end, fnNode.end - 1);
if (avIdx === 0) {
// whitespace and comments are lost
if (Array.isArray(av)) {
return `[${av.join(', ')}]` + tail;
// TODO: needed for rotate([x, y, z])
} else if (av === Object(av) && av.constructor.name === 'Object' ) { // assume object
return `{${
Object.keys(av).map(
attr => `${attr}: ${Array.isArray (av[attr]) ? '[' + av[attr].join(', ') + ']' : av[attr]}`
).join(', ')
}}` + tail;
} else if (av.translatedValue) {
return av.translatedValue + tail;
}
}
const argToConvert = descendantsToConvert.filter (descNode => descNode === av);
const argNeedsInterpolation = descendantsToConvert.filter (descNode => descNode.start >= av.start && descNode.end < av.end);
let avString = source.substring(av.start, av.end);
if (argToConvert.length) {
avString = processFunction(source, av);
} else if (argNeedsInterpolation.length) {
// console.log ('&&&&&&&&&', av, descendantsToConvert, argNeedsInterpolation);
avString = source.substring(av.start, argNeedsInterpolation[0].start) +
argNeedsInterpolation.map((argInt, argIntIdx) => {
const tail = argNeedsInterpolation.length - argIntIdx > 1
? source.substring(argNeedsInterpolation[argIntIdx].end, argNeedsInterpolation[argIntIdx + 1].start)
: source.substring(argNeedsInterpolation[argIntIdx].end, av.end);
return processFunction(source, argInt) + tail;
}).join('');
}
return avString + tail;
}),
].join('');
} else {
attrsString = source.substring(fnNodeCallee.end, fnNode.end - 1);
}
// if (PASS === 2) console.log ('<<<', fnNodeCallee.name, attrsString);
return attrsString;
}
function processFunction (source, fnNode) {
const conversion = getConversion(fnNode.callee.name);
const name = conversion.replaceWith || fnNode.callee.name;
// console.log ('----', name, '----');
// console.log (fnNode);
const attrsString = processFnArguments(source, conversion, fnNode);
const replacement = name + attrsString + ')';
return replacement;
}
function splitClustersByLevel (clustersSlice = clusters) {
let topLevelClusters = [];
let descClusters = [];
// const allDescClusters =
Object.keys(clustersSlice).map(clusterStart => {
clusterStart = parseInt(clusterStart, 10);
// const clusterEnd =
const isDescendant = Object.keys(clustersSlice).filter(cStart => cStart !== clusterStart).some(cStart => {
// last chunk is the largest one
cStart = parseInt(cStart, 10);
const cCluster = clustersSlice[cStart];
const cEnd = cCluster.chunks[cCluster.chunks.length - 1].node.end;
// console.log ('CHECKING', `${cStart}:${cEnd} against ${clusterStart}`)
// if ((cStart < clusterStart) && (cEnd > clusterStart)) {
// console.log ('CLUSTER', `${cStart}:${cEnd} is ancestor for ${clusterStart}`);
//}
// clusters don't overlap
return cStart < clusterStart && cEnd > clusterStart;
});
(isDescendant ? descClusters : topLevelClusters).push (clusterStart);
});
return [topLevelClusters, descClusters];
}
function processCluster (source, clusterPos) {
const cluster = clusters[clusterPos];
const firstChunk = cluster.chunks[0];
const lastChunk = cluster.chunks[cluster.chunks.length - 1];
// console.log (firstChunk.node);
let inner = source.substring(firstChunk.node.callee.object.start, firstChunk.node.callee.object.end);
const descendantClusters = Object.keys(clusters).map(c => parseInt(c, 10)).filter(c => firstChunk.node.callee.object.start < c && c < firstChunk.node.callee.object.end);
const [immediateDesc, deepDesc] = splitClustersByLevel(descendantClusters.reduce((acc, v) => (acc[v] = clusters[v], acc), {}));
// console.log ('#######', immediateDesc, deepDesc);
if (immediateDesc.length) {
// console.log ('####', firstChunk.node.callee.object.start, clusters[immediateDesc[0]].chunks[0].node.start, source.substring(firstChunk.node.callee.object.start, clusters[immediateDesc[0]].chunks[0].node.start));
const innerReplacement = [
source.substring(firstChunk.node.callee.object.start, clusters[immediateDesc[0]].chunks[0].node.start),
...immediateDesc.map((subClusterPos, immediateIdx) => {
const firstImmChunk = clusters[subClusterPos].chunks[0];
const lastImmChunk = clusters[subClusterPos].chunks[clusters[subClusterPos].chunks.length - 1];
const tail = immediateDesc.length - immediateIdx > 1
? source.substring(lastImmChunk.node.end, clusters[immediateDesc[immediateIdx + 1]].chunks[0].node.start)
: source.substring(lastImmChunk.node.end, firstChunk.node.callee.object.end);
// console.log ('>>>', source.substring(lastImmChunk.node.start, lastImmChunk.node.end));
const clusterString = processCluster(source, subClusterPos);
// console.log ('<<<', `'${clusterString}'`, `'${tail}'`);
return clusterString + tail;
})
].join('');
// console.log ('####', innerReplacement);
inner = innerReplacement;
}
const wrapped = cluster.chunks.reduce ((toWrap, n, nIdx) => {
const conversion = getConversion(n.node.callee.property.name);
const name = conversion.replaceWith || n.node.callee.property.name;
// everything between last char of method name and closing parethesis
const args = source.substring(n.node.callee.property.end, n.node.end - 1);
// console.log ('>>>>>', name, '(())', args);
const argsString = processFnArguments (source, conversion, n.node);
// console.log ('>>>', name, n.node.arguments, '(())', argsString);
// console.log ('<<<<<', name + argsString + ', ' + toWrap)
// TODO: in case of x.difference(y) or x.union(y) we need to swap `y` in `toWrap` variable with `x` in `argsString`. enjoy!
return name + argsString + ', ' + toWrap + ')'
}, inner);
return wrapped;
}
const toConvert = [];
const clusters = {};
let PASS = 0;
/**
* Convert jscad V1 source
* @param {string} source jscad V1 source
* @returns {string}
*/
function convertV1 (source) {
// It's impossible to convert both methods like .rotate
// and functions like circle in one shot without
// fully recreating js serialization logic
// Instead of this, I'm just changing all function occurences
// in first pass and all methods in second pass
// If methods converted to functions in the first pass,
// they will be processed two times,
// which is a disaster for a functions like `rotate`
// where arguments conversion is needed
let ast = parse(source, {
ecmaVersion: 'latest',
locations: true,
});
acornWalk.ancestor(ast, {
CallExpression (node) {
if (node.callee.type === 'Identifier' && node.callee.name in conversions) {
toConvert.push(node);
}
}
})
PASS = 1;
const allDescendants = toConvert.map(n => findDescendantsToConvert(n)).flat();
const topLevelConvert = toConvert.filter(x => !allDescendants.includes(x));
let sourceFromPass1 = source;
const sourceChunksPass1 = [source.substring(0, topLevelConvert[0].start)];
topLevelConvert.forEach((fnNode, fnIdx) => {
const replacement = processFunction(sourceFromPass1, fnNode);
const tail = topLevelConvert.length - fnIdx > 1
? source.substring(topLevelConvert[fnIdx].end, topLevelConvert[fnIdx + 1].start)
: source.substr(topLevelConvert[fnIdx].end);
sourceChunksPass1.push (replacement, tail);
// sourceFromPass1 = sourceFromPass1.substring (0, fnNode.start) + replacement + sourceFromPass1.substr (fnNode.end);
});
sourceFromPass1 = sourceChunksPass1.join('');
// console.log ('PASS 1 FINISHED\n', sourceFromPass1);
toConvert.length = 0;
PASS = 2;
ast = parse(sourceFromPass1, {
ecmaVersion: 'latest',
locations: true,
// onComment
// onToken (token) {
// console.log (token);
// }
});
// ast.body.map (walkTree);
// cluster is a chained call like cube(…).rotate(…).translate(…)
// first cluster chunk will be innermost, like cube(…).rotate(…)
acornWalk.ancestor(ast, {
CallExpression (node) {
if (node.callee.type === 'MemberExpression' && node.callee.property.type === 'Identifier' && node.callee.property.name in conversions) {
// toConvert.push (node);
clusters[node.start] = clusters[node.start] || {chunks: []};
clusters[node.start].chunks.push ({node});
}
}
})
// console.log (clusters);
let sourceFromPass2 = sourceFromPass1;
let [topLevelClusters, descClusters] = splitClustersByLevel();
topLevelClusters = topLevelClusters.sort((a, b) => a - b);
// console.log('clusters', Object.keys(clusters), 'top', topLevelClusters, 'sub', descClusters);
const sourceChunksPass2 = [sourceFromPass2.substring(0, topLevelClusters[0])];
topLevelClusters.forEach ((clusterPos, clusterIdx) => {
const cluster = clusters[clusterPos];
const firstChunk = cluster.chunks[0];
const lastChunk = cluster.chunks[cluster.chunks.length - 1];
const inner = sourceFromPass2.substring(firstChunk.node.callee.object.start, firstChunk.node.callee.object.end);
const wrapped = processCluster(sourceFromPass2, clusterPos);
const tail = topLevelClusters.length - clusterIdx > 1
? sourceFromPass2.substring (lastChunk.node.end, clusters[topLevelClusters[clusterIdx + 1]].chunks[0].node.start)
: sourceFromPass2.substr (lastChunk.node.end);
sourceChunksPass2.push (wrapped, tail);
});
sourceFromPass2 = sourceChunksPass2.join('');
// console.log ('PASS 2 FINISHED\n', sourceChunksPass2);
// console.log ('PASS 2 FINISHED\n', sourceFromPass2);
const requiredModules = [];
const allRequirements = Object.keys(usedConversions).map(moduleName => {
requiredModules.push(moduleName);
const requiredFns = Object.keys(usedConversions[moduleName]).map(fnName => fnName).join(', ');
return `const { ${requiredFns} } = ${moduleName};\n`
}).join('');
const beginning = `const jscad = require('@jscad/modeling');
const { ${requiredModules.join(', ')} } = jscad;
${allRequirements}
`;
const ending = `
module.exports = {
main,
getParameterDefinitions: typeof getParameterDefinitions === "undefined" ? undefined : getParameterDefinitions
}
`;
// write to file, then throw
// setInterval (() => {
// source should be valid after all changes
ast = parse(sourceFromPass2, {
ecmaVersion: 'latest',
});
// }, 1000);
return beginning + sourceFromPass2 + ending;
}
function walkTree (node) {
let kind = 'function';
let method;
let path;
console.log (node.type, node);
switch (node.type) {
case 'FunctionDeclaration':
// body[@type=BlockStatement]/body
walkTree (node.body.body);
break;
case 'VariableDeclaration':
// body[@type=BlockStatement]/body
walkTree (node.declarations);
break;
case 'IfStatement':
// body[@type=BlockStatement]/body
walkTree (node.declarations);
break;
case 'ReturnStatement':
// body[@type=BlockStatement]/body
walkTree (node.declarations);
break;
default:
break;
}
}
fs.readFile(process.argv[2], "UTF8").then (openJSCADText => {
const openJSCADResult = convertV1(openJSCADText);
const jscadFilename = process.argv[2].replace (/\.jscad$/, '-v2.jscad');
return fs.writeFile (jscadFilename, openJSCADResult);
});
function test1 (params) {
return rotate([30, 60, 0], union(
rotate([180, 270, 0], cube({size: [10, 10, 10]})),
rotate([90, 180, 0], cube({size: [10, 10, 10]})),
z(cylinder({r: 5, h:4})) // just to do string interpolation
))
}
function test2 (params) {
rotate([30, 60, 0], union(
rotate([180, 270, 0], difference(cube({size: [10, 10, 10]}), cylinder({r: 5, h:4}))),
rotate([90, 180, 0], difference(cube({size: [10, 10, 10]}), rotate([45, 15, 0], cylinder({r: 5, h:4})))),
z(cylinder({r: 5, h:4})) // just to do string interpolation
))
}
@hrgdavor
Copy link

may be cleaner to use: https://openjscad.xyz/docs/module-modeling_utils.html#.degToRad

function convertToRadians (degrees) {
	return degrees + ' * Math.PI / 180'
}

add import at script top and then

function convertToRadians (degrees) {
	return 'degToRad('+degrees + ')'
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment