Last active
March 18, 2024 15:13
-
-
Save apla/90edff2e4993f3e3592f8795c2050cc5 to your computer and use it in GitHub Desktop.
WIP Convert JSCAD V1 => V2
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
)) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
may be cleaner to use: https://openjscad.xyz/docs/module-modeling_utils.html#.degToRad
add import at script top and then