Compare commits

...

4 Commits
0.2.4 ... main

Author SHA1 Message Date
Glen Whitney 6892001b8d feat: support Viewpoints, translating PerspectiveCamera (#21)
Reviewed-on: #21
Co-authored-by: Glen Whitney <glen@studioinfinity.org>
Co-committed-by: Glen Whitney <glen@studioinfinity.org>
2024-02-18 19:57:29 +00:00
Glen Whitney 5bd62ffe31 fix: Transformation nodes must create new scope (#20)
Improve the handling of transformation nodes so that their translations always open a new scope.
As a side effect, expands handling of transformation nodes to include Scale and Transform as well.

Reviewed-on: #20
Co-authored-by: Glen Whitney <glen@studioinfinity.org>
Co-committed-by: Glen Whitney <glen@studioinfinity.org>
2024-02-14 08:10:00 +00:00
Glen Whitney a6a6e60894 feat: Implement Translations (#19)
Also fixes bug: Translations and Rotations should not be inherited
  into inner grouping nodes.

Reviewed-on: #19
Co-authored-by: Glen Whitney <glen@studioinfinity.org>
Co-committed-by: Glen Whitney <glen@studioinfinity.org>
2024-02-14 05:36:28 +00:00
Glen Whitney 1409d39665 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>
2024-02-14 02:55:39 +00:00
4 changed files with 231 additions and 76 deletions

View File

@ -1,13 +1,28 @@
# 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
* PerspectiveCamera nodes (converted into Viewpoint nodes)
* Grouping nodes including Separator, Group, Switch, and WWWAnchor
* Interprets a Switch named "Cameras" (by a DEF) as a list of Viewpoints
* ShapeHints
* Transformation nodes including Translation, Rotation, Scale, and Transform
* 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 +59,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 +81,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.4.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,196 @@ 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/
TransformNode := /^(?:Rotation|Scale|Transform|Translation)$/
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
// Special case from VRML 1: DEF Cameras Switch { ... }
// needs to be hoisted to a list of viewpoints at this level
if currentDefinition is 'Cameras' and held?.value is 'Switch'
held = filtered stream
unless held?.type is 'obrace'
console.error
`DEF Cameras Switch followed by ${held?.value}, ignoring`
continue
{children, ...context} := tree
subTree := parse stream, context
addChild renderList(subTree.children), tree
held = filtered stream
continue
if held?.type is 'word'
clause := `DEF ${currentDefinition}`
role .= held.value
switch role
matches GroupNode
role = 'Child'
matches TransformNode
role = 'Transform'
'Coordinate3'
role = 'Coordinate'
/Cube|Cone|Cylinder|Sphere/
role = 'Shape'
'PerspectiveCamera'
role = 'Viewpoint'
'ShapeHints'
role = 'Dummy' // will fill in when we parse the ShapeHints
'Texture2'
role = 'Texture'
'TextureCoordinate2'
role = 'TextureCoordinate'
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
'Transform'
// VRML97 doesn't allow just the transform part of
// a Transform to be USEd (the whole node must be), so
// we have to recreate the transform. FIXME: dedupe code
content := known[next.value][1..]
{children, ...context} := tree
restOfTree := parse stream, context
if remainingKids := restOfTree.children
newChild := `Transform {\n ${renderList content}\n `
+ `children [\n ${renderList remainingKids}`
+ "] }\n"
addChild newChild, tree
return 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/
parent :=
held.value.endsWith('Separator') ? 'Transform' : 'Group'
matches GroupNode
{children, ...context} := tree
subTree := parse stream, context
if newKids := subTree.children
addChild `${parent} { children [
${renderList newKids} ] }\n`, tree
newChild .= ''
if held.value is 'Switch'
newChild = "Switch {\n "
if 'whichChoice' in subTree
newChild += `whichChoice ${subTree.whichChoice[0]}\n `
newChild += `choice [\n ${renderList newKids} ] }\n`
else newChild =
`Group { children [\n ${renderList newKids} ] }\n`
addChild newChild, tree
matches TransformNode
content := toksUntilClose stream
if (lastDefinition
and tree._definitions?[lastDefinition][0] is 'Transform')
tree._definitions[lastDefinition].push ...content
{children, ...context} := tree
restOfTree := parse stream, context
if remainingKids := restOfTree.children
newChild := `Transform {\n ${renderList content}\n `
+ `children [\n ${renderList remainingKids} ] }\n`
addChild newChild, tree
return tree // used the rest of the tree, so done
'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]
'PerspectiveCamera'
contents := toksUntilClose stream
hasDescription := contents.find (e) =>
e.type?[0] is 'word' and e.value?[0] is 'description'
if lastDefinition and hasDescription is undefined
contents.push type: ['word'], value: ['description']
contents.push type: ['word'], value: [`"${lastDefinition}"`]
addChild {Viewpoint: contents}, tree
'Coordinate3'
tree.Coordinate = toksUntilClose stream
'Normal'
@ -140,7 +307,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 +324,34 @@ 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
{ht: 'word', hv: 'whichChild'}
tree.whichChoice = [ next.value ]
held = filtered stream
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 +365,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