feat: support DEF and USE syntax to share subtrees (#18)

Reviewed-on: #18
Co-authored-by: Glen Whitney <glen@studioinfinity.org>
Co-committed-by: Glen Whitney <glen@studioinfinity.org>
This commit is contained in:
Glen Whitney 2024-02-14 02:55:39 +00:00 committed by Glen Whitney
parent 1a2077a6b6
commit 1409d39665
4 changed files with 174 additions and 74 deletions

View File

@ -1,13 +1,26 @@
# vrml1to97
JavaScript converter from VRML 1.0 to VRML97 file format, based on Wings 3D
conversion logic.
JavaScript converter from VRML 1.0 to VRML97 file format.
Essentially, this is a JavaScript reimplementation of the algorithm of the
"token rearranger" found in the
This converter was originally a JavaScript reimplementation of the algorithm
of the "token rearranger" found in the
[Wings 3D x3d importer](https://github.com/dgud/wings/blob/master/plugins_src/import_export/x3d_import.erl)
(which was written in Erlang).
However, from version 0.3, it adds support for a significantly larger subset of
VRML 1.0. Overall, it recognizes and converts
* Title, SceneInfo, BackgroundColor, and View "Info" nodes [this node type
was removed in VRML97].
* All Light nodes
* Grouping nodes including Transform, Separator, Group, Switch, and WWWAnchor
* ShapeHints
* Rotations
* All shape nodes including Cube, Cone, Cylinder, Sphere, IndexedFaceSet,
IndexedLineSet, PointSet, Coordinate3, and Normal
* All material nodes including Material, TextureCoordinate2, and Texture2
* DEF and USE constructs to share subtrees
## Usage
From an es6 module under Node (for example)
@ -44,8 +57,8 @@ Currently this package exports just two functions:
actually check that its input is VRML 1, so its behavior is undefined
if given anything but valid VRML 1 syntax). Returns VRML 97 syntax for
the same scene, as nearly as it can translate. Note that not all of
VRML 1 is recognized, and some of the translations may be somewhat
approximate.
VRML 1 is recognized (see above for a list of constructs that should
be handled), and some of the translations may be somewhat approximate.
If the optional second argument `source` is supplied, `convert` adds
a comment indicating that the original vrml1 came from the specified
@ -66,10 +79,12 @@ Currently this package exports just two functions:
## Conversion notes
One sort of geometry common to VRML 1 and VRML97 is the IndexedFaceSet. These
often used to render solids. Indeed, the default in VRML97 is to assume they
do represent a solid, with the normals pointing outward. Unusual visual effects
ensue if the normals are not properly directed (basically, you see through the
"front" of the solid and see the "backs" of the faces on the opposite side).
entities are often used to render solids. Indeed, the default in VRML97 is to
assume they do represent a solid, with the normals pointing outward. Unusual
visual effects ensue if the normals are not properly directed (basically, you
see through the "front" of the solid and see the "backs" of the faces on the
opposite side).
As a result, unless the input VRML 1 explicitly includes an explicit
vertexOrdering of CLOCKWISE or COUNTERCLOCKWISE, the default solid treatment
will be turned off in the VRML97 output, meaning that all faces will be

View File

@ -1,6 +1,6 @@
{
name: 'vrml1to97',
version: '0.2.4',
version: '0.3.0',
description: 'JavaScript converter from VRML 1 to VRML97',
scripts: {
test: 'echo "Error: no test specified" && exit 1',
@ -40,7 +40,7 @@
},
type: 'module',
devDependencies: {
'@danielx/civet': '^0.6.71',
'@danielx/civet': '^0.6.72',
'@types/moo': '^0.5.9',
'http-server': '^14.1.1',
json5: '^2.2.3',

View File

@ -11,8 +11,8 @@ dependencies:
devDependencies:
'@danielx/civet':
specifier: ^0.6.71
version: 0.6.71(typescript@5.3.3)
specifier: ^0.6.72
version: 0.6.72(typescript@5.3.3)
'@types/moo':
specifier: ^0.5.9
version: 0.5.9
@ -35,8 +35,8 @@ packages:
'@jridgewell/trace-mapping': 0.3.9
dev: true
/@danielx/civet@0.6.71(typescript@5.3.3):
resolution: {integrity: sha512-piOoHtJARe6YqRiXN02Ryb+nLU9JwU8TQLknvPwmlaNm6krdKN+X9dM+C9D4LRoAnAySC2wVshR0wf7YDIUV1Q==}
/@danielx/civet@0.6.72(typescript@5.3.3):
resolution: {integrity: sha512-jumnIbXbdFs0ZiKN62fmD+p8QGi+E0jmtc02dKz9wIIoPkODsa4XXlBrS5BRR5fr3w5d3ah8Vq7gWt+DL9Wa0Q==}
engines: {node: '>=19 || ^18.6.0 || ^16.17.0'}
hasBin: true
peerDependencies:

View File

@ -2,6 +2,7 @@ moo from ../deps/moo.js
import type {Lexer, Token} from ../deps/moo.d.ts
type Tree = {[key:string]: (string | Tree)[]}
type DefTree = {_definitions?: Tree} & Tree
lexer := moo.compile
comment: /#.*?$/
@ -86,30 +87,146 @@ function addWorldParameter(name: string, value: string, tree: Tree): void
children.push world
world.WorldInfo.push(` ${name}`, ` ${value}`)
function parse(stream: Lexer, tree: Tree = {}): Tree
held .= filtered stream // for lookahead
// Current best way to re-use a pattern
matches := (str: string, pat: RegExp) => pat.test(str)
operator matches
GroupNode := /(?:Transform)?Separator|Group|Switch|WWWAnchor/
SetNode := /IndexedFaceSet|IndexedLineSet|PointSet/
LightNode := /Light$/
function parse(stream: Lexer, tree: DefTree = {}): DefTree
held .= filtered stream // for lookahead
currentDefinition .= '' // if we have just started a DEF
while next := filtered stream
unless held then break
// May need to know the current definition, but it clears each round
lastDefinition := currentDefinition
currentDefinition = ''
switch data :=
{nt: next.type, nv: next.value, ht: held.type, hv: held.value}
{ht: 'word', hv: 'Info'}
// Here the meaning is entirely dependent on what DEF we are in
if next.type !== 'obrace'
// Not sure what this construct is, so ignore it
held = next
continue
contents := toksUntilClose stream
held = filtered stream
switch lastDefinition
'BackgroundColor'
// Find the first string token and assume it is the color
stok := contents.find .type[0] === 'string'
colorString := stok?.value[0]
if colorString and typeof colorString === 'string'
color := colorString.replace /^"|"$/g, ''
addChild `Background {
groundColor [ ${color} ]
skyColor [ ${color} ]
}\n`, tree
'Title'
// Find the first string token and assume it is the title
stok := contents.find .type[0] === 'string'
titleString := stok?.value[0]
if titleString and typeof titleString === 'string'
addWorldParameter 'title', titleString, tree
'SceneInfo'
// Filter all the strings and add them as info
info := contents
|> .filter .type[0] === 'string'
|> .map .value[0]
|> .join ' '
addWorldParameter 'info', `[ ${info} ]`, tree
'Viewer'
// Filter all of the strings, and translate them into
// current viewer names
tr: Record<string, string> := {'"examiner"': '"EXAMINE"'}
viewers := contents
|> .filter .type[0] === 'string'
|> .map .value[0] as string
|> .map((x) => x in tr ? tr[x] : x.toUpperCase())
|> .join ' '
addChild `NavigationInfo { type [ ${viewers} ] }\n`, tree
{ht: 'word', hv: 'DEF'}
unless next.type is 'word'
// Don't understand this construction, ignore the DEF
held = next
continue
currentDefinition = next.value
held = filtered stream
if held?.type is 'word'
clause := `DEF ${currentDefinition}`
role .= held.value
switch role
matches GroupNode
role = 'Child'
'ShapeHints'
role = 'Dummy' // will fill in when we parse the ShapeHints
'Coordinate3'
role = 'Coordinate'
'TextureCoordinate2'
role = 'TextureCoordinate'
'Texture2'
role = 'Texture'
/Cube|Cone|Cylinder|Sphere/
role = 'Shape'
matches SetNode
role = 'Shape'
matches LightNode
role = 'Child'
(tree._definitions ??= {})[currentDefinition] = [role]
unless role is 'Info'
// Info nodes don't directly generate a node in translation
addChild clause, tree
{ht: 'word', hv: 'USE'}
unless next.type is 'word'
// Don't understand this construction, ignore the USE
held = next
continue
known := tree._definitions
if known and next.value in known
role := known[next.value][0]
clause := `USE ${next.value}`
switch role
'Child'
addChild clause, tree
'Shape'
addShape clause, [], tree
{}
mergeTree role, tree
else
tree[role] = [clause]
else
console.error 'USE of unDEFined identifier', next.value
held = filtered stream
{ht: 'word', nt: 'obrace'}
switch held.value
/(?:Transform)?Separator|Group|Switch|WWWAnchor/
matches GroupNode
parent :=
held.value.endsWith('Separator') ? 'Transform' : 'Group'
{children, ...context} := tree
subTree := parse stream, context
if newKids := subTree.children
addChild `${parent} { children [
${renderList newKids} ] }\n`, tree
newChild .= `${parent} {\n `
if 'Rotation' in subTree
newChild += renderList subTree.Rotation
newChild += "\n "
newChild += `children [\n ${renderList newKids} ] }\n`
addChild newChild, tree
'ShapeHints'
subTree := parse stream
hints: Tree := {}
if 'vertexOrdering' in subTree
tree.ccw = [
subTree.vertexOrdering[0] is 'ccw' ? 'true' : 'false']
hints.ccw = [
subTree.vertexOrdering[0] is 'ccw' ? 'TRUE' : 'FALSE']
if 'creaseAngle' in subTree
tree.creaseAngle = subTree.creaseAngle
hints.creaseAngle = subTree.creaseAngle
mergeTree hints, tree
if lastDefinition and tree._definitions
tree._definitions[lastDefinition] = [hints]
'Rotation'
tree.Rotation = toksUntilClose stream
'Coordinate3'
tree.Coordinate = toksUntilClose stream
'Normal'
@ -140,7 +257,7 @@ function parse(stream: Lexer, tree: Tree = {}): Tree
dims := radius: ''
findNumbersAtTopLevel stream, dims
addShape 'Sphere', [`radius ${dims.radius}`], tree
/IndexedFaceSet|IndexedLineSet|PointSet/
matches SetNode
isFaces := held.value is 'IndexedFaceSet'
contents := translatedToksUntilClose stream
params := []
@ -157,69 +274,31 @@ function parse(stream: Lexer, tree: Tree = {}): Tree
params.push `creaseAngle ${tree.creaseAngle[0]}`
if isFaces
if 'ccw' in tree then params.push `ccw ${tree.ccw[0]}`
else params.push 'solid false'
else params.push 'solid FALSE'
params.push ...contents
addShape held.value, params, tree
/Light$/
matches LightNode
contents := toksUntilClose stream
addChild {[held.value]: contents}, tree
else
parse stream // discard the subgroup
held = filtered stream
{ht: 'word', hv: 'vertexOrdering', nt: 'word', nv: 'COUNTERCLOCKWISE'}
tree.vertexOrdering = ['ccw']
held = filtered stream
{ht: 'word', hv: 'vertexOrdering', nt: 'word', nv: 'CLOCKWISE'}
tree.vertexOrdering = ['cw']
{ht: 'word', hv: 'vertexOrdering', nt: 'word'}
switch next.value
'COUNTERCLOCKWISE'
tree.vertexOrdering = ['ccw']
'CLOCKWISE'
tree.vertexOrdering = ['cw']
held = filtered stream
{ht: 'word', hv: 'creaseAngle', nt: 'number'}
tree.creaseAngle = [ next.value ]
held = filtered stream
{ht: 'word', nt: 'word', nv: 'Info'}
opener := filtered stream
if opener and opener.type === 'obrace'
contents := toksUntilClose stream
switch held.value
'BackgroundColor'
// Find the first string token and assume it is the color
stok := contents.find .type[0] === 'string'
colorString := stok?.value[0]
if colorString and typeof colorString === 'string'
color := colorString.replace /^"|"$/g, ''
addChild `Background {
groundColor [ ${color} ]
skyColor [ ${color} ]
}\n`, tree
'Title'
// Find the first string token and assume it is the title
stok := contents.find .type[0] === 'string'
titleString := stok?.value[0]
if titleString and typeof titleString === 'string'
addWorldParameter 'title', titleString, tree
'SceneInfo'
// Filter all the strings and add them as info
info := contents
|> .filter .type[0] === 'string'
|> .map .value[0]
|> .join ' '
addWorldParameter 'info', `[ ${info} ]`, tree
'Viewer'
// Filter all of the strings, and translate them into
// current viewer names
tr: Record<string, string> := {'"examiner"': '"EXAMINE"'}
viewers := contents
|> .filter .type[0] === 'string'
|> .map .value[0] as string
|> .map((x) => x in tr ? tr[x] : x.toUpperCase())
|> .join ' '
addChild `NavigationInfo { type [ ${viewers} ] }\n`, tree
held = filtered stream
else held = opener
else
console.error 'Ignoring unparseable token', held
held = next // ignore unknown words
if not held or held.type === 'cbrace' then break
if held and held.type !== 'cbrace'
console.log 'Oddly ended up with held', held
console.error 'Incomplete parse, symbol at end:', held
tree
function addShape(nodeType: string, params: (string | Tree)[], tree: Tree): void
@ -233,9 +312,15 @@ function addShape(nodeType: string, params: (string | Tree)[], tree: Tree): void
shape.Shape.push "texture ImageTexture {\n",
...tree.Texture, " }\n"
shape.Shape.push " }\n"
shape.Shape.push `geometry ${nodeType} {\n`, ...params, " }\n"
geometry: (string|Tree)[] := [`geometry ${nodeType}`]
if params.length then geometry.push " {\n", ...params, " }\n"
shape.Shape.push ...geometry
addChild shape, tree
function mergeTree(subtree: Tree, tree: Tree): void
for key, value in subtree
tree[key] = value
function render(t: string | Tree): string
if typeof t is 'string' then return t
if 'children' in t then return renderList t.children