Browse Source

Initial commit

master
eater 2 years ago
commit
037306a7a0
Signed by: eater GPG Key ID: AD2560A0F84F0759
  1. 8
      .drone.yml
  2. 3
      .gitignore
  3. 10
      build.gradle
  4. 5
      examples/graph-definition.xml
  5. 3
      gradle.properties
  6. BIN
      gradle/wrapper/gradle-wrapper.jar
  7. 6
      gradle/wrapper/gradle-wrapper.properties
  8. 183
      gradlew
  9. 100
      gradlew.bat
  10. 39
      script/create-swizzle.kts
  11. 5
      settings.gradle
  12. 18
      threedom-kapt/build.gradle
  13. 4
      threedom-kapt/src/main/kotlin/me/eater/threedom/kapt/EventName.kt
  14. 98
      threedom-kapt/src/main/kotlin/me/eater/threedom/kapt/EventNameProcessor.kt
  15. 1
      threedom-kapt/src/main/resources/META-INF/gradle/incremental.annotation.processors
  16. 1
      threedom-kapt/src/main/resources/META-INF/services/javax.annotation.processing.Processor
  17. 37
      threedom/build.gradle
  18. 125
      threedom/src/main/kotlin/me/eater/threedom/dom/Document.kt
  19. 80
      threedom/src/main/kotlin/me/eater/threedom/dom/EventTree.kt
  20. 13
      threedom/src/main/kotlin/me/eater/threedom/dom/IDocument.kt
  21. 41
      threedom/src/main/kotlin/me/eater/threedom/dom/INode.kt
  22. 12
      threedom/src/main/kotlin/me/eater/threedom/dom/INodeContainer.kt
  23. 10
      threedom/src/main/kotlin/me/eater/threedom/dom/INodeQueryCapable.kt
  24. 177
      threedom/src/main/kotlin/me/eater/threedom/dom/Node.kt
  25. 7
      threedom/src/main/kotlin/me/eater/threedom/dom/PlainNode.kt
  26. 18
      threedom/src/main/kotlin/me/eater/threedom/dom/event/DOMTreeUpdate.kt
  27. 10
      threedom/src/main/kotlin/me/eater/threedom/dom/event/NodeClassListUpdate.kt
  28. 5
      threedom/src/main/kotlin/me/eater/threedom/dom/event/NodeIDUpdate.kt
  29. 5
      threedom/src/main/kotlin/me/eater/threedom/dom/query/NodeQuery.kt
  30. 25
      threedom/src/main/kotlin/me/eater/threedom/event/Event.kt
  31. 5
      threedom/src/main/kotlin/me/eater/threedom/event/EventControl.kt
  32. 15
      threedom/src/main/kotlin/me/eater/threedom/event/EventDispatcher.kt
  33. 7
      threedom/src/main/kotlin/me/eater/threedom/event/EventListener.kt
  34. 45
      threedom/src/main/kotlin/me/eater/threedom/utils/MutableOrderedSetListIterator.kt
  35. 85
      threedom/src/main/kotlin/me/eater/threedom/utils/ObservableSet.kt
  36. 95
      threedom/src/main/kotlin/me/eater/threedom/utils/OrderedSet.kt
  37. 12
      threedom/src/main/kotlin/me/eater/threedom/utils/joml/JOML.kt
  38. 134
      threedom/src/test/kotlin/me/eater/test/threedom/dom/DocumentTest.kt
  39. 26
      threedom/src/test/kotlin/me/eater/test/threedom/dom/PositionTest.kt

8
.drone.yml

@ -0,0 +1,8 @@
---
kind: pipeline
name: build
steps:
- name: build
image: d.xr.to/jdk
command: ./gradle build

3
.gitignore

@ -0,0 +1,3 @@
out
build
.gradle

10
build.gradle

@ -0,0 +1,10 @@
group 'me.eater.threedom'
version '1.0-SNAPSHOT'
allprojects {
repositories {
jcenter()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}

5
examples/graph-definition.xml

@ -0,0 +1,5 @@
<graph>
<box diameter="10" position="[33, 40, 3]">
</box>
</graph>

3
gradle.properties

@ -0,0 +1,3 @@
kotlin.code.style=official
# kapt.incremental.apt=true
# kapt.use.worker.api=true

BIN
gradle/wrapper/gradle-wrapper.jar

6
gradle/wrapper/gradle-wrapper.properties

@ -0,0 +1,6 @@
#Fri May 15 20:32:10 CEST 2020
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1-all.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

183
gradlew

@ -0,0 +1,183 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

100
gradlew.bat

@ -0,0 +1,100 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

39
script/create-swizzle.kts

@ -0,0 +1,39 @@
val items = listOf("xyzw", "rgba", "stpq").map { it.toCharArray() }
val map = mutableMapOf<Int, MutableSet<List<Int>>>()
for (firstChar in 0..3) {
for (secondChar in 0..3) {
val char2 = listOf(firstChar, secondChar)
map.getOrPut(char2.max()!!, ::mutableSetOf).add(char2)
for (thirdChar in 0..3) {
val char3 = listOf(firstChar, secondChar, thirdChar)
map.getOrPut(char3.max()!!, ::mutableSetOf)
.add(char3)
for (fourth in 0..3) {
val char4 = listOf(firstChar, secondChar, thirdChar, fourth)
map.getOrPut(char4.max()!!, ::mutableSetOf)
.add(char4)
}
}
}
}
for ((dim, nrs) in map) {
if (dim != 1) {
println("Vec${maxOf(dim + 1, 2)} ->")
}
for (item in nrs) {
for (set in items) {
println(
" val ${item.map(set::get).joinToString("")}\n get() = KVec${item.size}(${item.map(items[0]::get)
.joinToString(",")})\n"
)
}
}
}

5
settings.gradle

@ -0,0 +1,5 @@
rootProject.name = 'threedom'
include 'threedom-kapt'
include 'threedom'

18
threedom-kapt/build.gradle

@ -0,0 +1,18 @@
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.3.72'
}
group 'me.eater.threedom'
version '1.0-SNAPSHOT'
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
implementation 'com.github.yanex:takenoko:0.1'
}
compileKotlin {
kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
kotlinOptions.jvmTarget = "1.8"
}

4
threedom-kapt/src/main/kotlin/me/eater/threedom/kapt/EventName.kt

@ -0,0 +1,4 @@
package me.eater.threedom.kapt
@Target(AnnotationTarget.CLASS)
annotation class EventName(val eventName: String)

98
threedom-kapt/src/main/kotlin/me/eater/threedom/kapt/EventNameProcessor.kt

@ -0,0 +1,98 @@
package me.eater.threedom.kapt
import org.yanex.takenoko.*
import java.io.File
import javax.annotation.processing.*
import javax.lang.model.SourceVersion
import javax.lang.model.element.Element
import javax.lang.model.element.TypeElement
import javax.tools.Diagnostic.Kind.ERROR
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@SupportedAnnotationTypes("me.eater.threedom.kapt.EventName")
@SupportedOptions(EventNameProcessor.KAPT_KOTLIN_GENERATED_OPTION_NAME, "org.gradle.annotation.processing.aggregating")
class EventNameProcessor : AbstractProcessor() {
companion object {
const val KAPT_KOTLIN_GENERATED_OPTION_NAME = "kapt.kotlin.generated"
}
override fun process(annotations: MutableSet<out TypeElement>?, roundEnv: RoundEnvironment): Boolean {
val annotatedElements = roundEnv.getElementsAnnotatedWith(EventName::class.java)
if (annotatedElements.isEmpty()) return false
val kaptKotlinGeneratedDir = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME] ?: run {
processingEnv.messager.printMessage(ERROR, "Can't find the target directory for generated Kotlin files.")
return false
}
val generatedKtFile = kotlinFile("me.eater.threedom.generated") {
objectDeclaration("EventNames") {
val eventNames = mutableMapOf<String, String>()
for (element in annotatedElements) {
val typeElement = element.toTypeElementOrNull() ?: continue
val eventName = typeElement.getAnnotation(EventName::class.java).eventName
if (eventName in eventNames) {
processingEnv.messager.printMessage(
ERROR,
"Class ${eventNames[eventName]} already uses the event name '${eventName}'",
element
)
continue
}
eventNames[typeElement.qualifiedName.toString()] = eventName
property(eventName) {
initializer(typeElement.qualifiedName.toString() + "::class")
}
}
property("EVENT_MAPPING") {
initializer(
"mapOf(\n${eventNames.map { (k, v) -> " \"" + k.replace('"', '\"') + "\" to \"" + v.replace('"', '\"') + '"' }
.joinToString(",\n")}\n)"
)
}
function("getEventName") {
param<String>("eventClass")
returnType<String>()
body {
append("return EVENT_MAPPING[eventClass] ?: eventClass")
}
}
function(
"getEventName", INLINE
) {
typeParam("reified T")
returnType<String>()
body {
append("return getEventName(T::class.java.name)")
}
}
}
}
File("$kaptKotlinGeneratedDir/me/eater/threedom/generated", "EventNames.kt").apply {
parentFile.mkdirs()
writeText(generatedKtFile.accept(PrettyPrinter(PrettyPrinterConfiguration())))
}
return true
}
fun Element.toTypeElementOrNull(): TypeElement? {
if (this !is TypeElement) {
processingEnv.messager.printMessage(ERROR, "Invalid element type, class expected", this)
return null
}
return this
}
}

1
threedom-kapt/src/main/resources/META-INF/gradle/incremental.annotation.processors

@ -0,0 +1 @@
me.eater.threedom.kapt.EventNameProcessor,dynamic

1
threedom-kapt/src/main/resources/META-INF/services/javax.annotation.processing.Processor

@ -0,0 +1 @@
me.eater.threedom.kapt.EventNameProcessor

37
threedom/build.gradle

@ -0,0 +1,37 @@
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.3.72'
id "org.jetbrains.kotlin.kapt" version "1.3.72"
}
group 'me.eater.threedom'
version '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
test {
useJUnitPlatform()
}
dependencies {
kapt project(':threedom-kapt')
compileOnly project(':threedom-kapt')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
implementation 'org.joml:joml:1.9.24'
testImplementation 'io.kotest:kotest-runner-junit5-jvm:4.0.5'
testImplementation 'io.kotest:kotest-assertions-core-jvm:4.0.5'
testImplementation 'io.kotest:kotest-property-jvm:4.0.5'
}
compileKotlin {
kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
kotlinOptions.jvmTarget = "1.8"
}

125
threedom/src/main/kotlin/me/eater/threedom/dom/Document.kt

@ -0,0 +1,125 @@
package me.eater.threedom.dom
import me.eater.threedom.dom.event.DOMTreeUpdate
import me.eater.threedom.dom.event.NodeClassListUpdate
import me.eater.threedom.dom.event.NodeIDUpdate
import me.eater.threedom.event.Event
import me.eater.threedom.generated.EventNames
import kotlin.reflect.KClass
class Document : IDocument {
val root: PlainNode = PlainNode(this)
private val eventTree = EventTree {
on<DOMTreeUpdate.Remove> { (event) ->
removeNodeFromSearch(event.child)
}
on<DOMTreeUpdate.Insert> { (event) ->
addNodeToSearch(event.child)
}
on<NodeIDUpdate> { (event) ->
event.old?.let {
byId.remove(it, event.node.nodeId)
}
event.new?.let {
byId.putIfAbsent(it, event.node.nodeId)
}
}
on<NodeClassListUpdate.Added> { (event) ->
for (className in event.classNames) {
byClass.getOrPut(className, ::mutableSetOf).add(event.node.nodeId)
}
}
on<NodeClassListUpdate.Removed> { (event) ->
for (className in event.classNames) {
val set = byClass[className] ?: continue
set.remove(event.node.nodeId)
if (set.size == 0) {
byClass.remove(className)
}
}
}
}
private val allNodes: MutableMap<Long, INode<*>> = mutableMapOf()
private val byId: MutableMap<String, Long> = mutableMapOf()
private val byClass: MutableMap<String, MutableSet<Long>> = mutableMapOf()
override fun <T> addEventListener(eventName: String, refNode: INode<*>, block: (Event<T>) -> Unit) {
@Suppress("UNCHECKED_CAST")
eventTree.addEventListener(eventName, refNode, block as (Event<*>) -> Unit)
}
fun <T> addEventListener(eventName: String, block: (Event<T>) -> Unit) = addEventListener(eventName, root, block)
fun <T> addTopLevelEventListener(eventName: String, block: (Event<T>) -> Unit) =
@Suppress("UNCHECKED_CAST")
eventTree.addTopLevelEventListener(eventName, block as (Event<*>) -> Unit)
override fun trigger(eventName: String, targetNode: INode<*>, event: Event<*>) =
eventTree.trigger(eventName, targetNode, event)
override fun addNode(newNode: INode<*>) = root.addNode(newNode)
override fun removeNode(refNode: INode<*>) = root.removeNode(refNode)
override fun deleteNode(refNode: INode<*>) {
refNode.detachFromDocument()
eventTree.removeNode(refNode)
}
override fun removeAll() = root.removeAll()
override fun replaceNode(newNode: INode<*>, refNode: INode<*>): Boolean = root.replaceNode(newNode, refNode)
override fun hasChild(refNode: INode<*>): Boolean = root.hasChild(refNode)
override fun sequence(): Sequence<INode<*>> = root.sequence()
override fun iterator(): Iterator<INode<*>> = root.iterator()
override fun getNodeById(id: String): INode<*>? = byId[id]?.let(allNodes::get)
override fun getNodesByClassName(className: String): Sequence<INode<*>> =
byClass[className]?.asSequence()?.mapNotNull(allNodes::get) ?: emptySequence()
private fun addNodeToSearch(node: INode<*>) {
allNodes[node.nodeId] = node
node.id?.let { byId.putIfAbsent(it, node.nodeId) }
for (className in node.classList) {
byClass.getOrPut(className, ::mutableSetOf).add(node.nodeId)
}
}
private fun removeNodeFromSearch(node: INode<*>) {
allNodes.remove(node.nodeId)
node.id?.let { byId.remove(it, node.nodeId) }
for (className in node.classList) {
val set = byClass.get(className) ?: continue
set.remove(node.nodeId)
if (set.size == 0) {
byClass.remove(className)
}
}
}
inline fun <reified T> on(topLevel: Boolean = false, noinline block: (Event<T>) -> Unit) = if (topLevel)
addTopLevelEventListener(EventNames.getEventName<T>(), block)
else
addEventListener(EventNames.getEventName<T>(), block)
override fun <T : INode<T>> createNode(nodeType: KClass<T>): T =
nodeType.java.getConstructor(IDocument::class.java).newInstance(this)
}

80
threedom/src/main/kotlin/me/eater/threedom/dom/EventTree.kt

@ -0,0 +1,80 @@
package me.eater.threedom.dom
import me.eater.threedom.event.Event
import me.eater.threedom.generated.EventNames
class EventTree(block: EventTree.() -> Unit = {}) {
private val topLevel: MutableMap<String, MutableSet<(Event<*>) -> Unit>> =
mutableMapOf()
private val listeners: MutableMap<String, MutableMap<Long, MutableSet<(Event<*>) -> Unit>>> =
mutableMapOf()
private val nodes: MutableMap<Long, MutableSet<String>> = mutableMapOf()
init {
block(this)
}
fun addTopLevelEventListener(eventName: String, handler: (Event<*>) -> Unit) {
this.topLevel.getOrPut(eventName, ::mutableSetOf).add(handler)
}
fun addEventListener(eventName: String, target: INode<*>, handler: (Event<*>) -> Unit) {
this.listeners.getOrPut(eventName, ::mutableMapOf).getOrPut(target.nodeId, ::mutableSetOf).add(handler)
this.nodes.getOrPut(target.nodeId, ::mutableSetOf).add(eventName)
}
fun removeNode(node: INode<*>) {
val events = this.nodes.remove(node.nodeId) ?: return
for (event in events) {
this.listeners[event]?.remove(node.nodeId)
}
}
fun trigger(eventName: String, target: INode<*>, event: Event<*>) {
this.topLevel[eventName]?.let {
for (handler in it) {
handler(event)
if (!event.propagate) {
return
}
}
if (!event.bubble) {
return
}
}
val map = this.listeners[eventName] ?: return
var current: INode<*>? = target
while (current != null) {
val set = map[current.nodeId]
if (set != null) {
for (eventHandler in set) {
eventHandler(event)
if (!event.propagate) {
break
}
}
}
if (!event.bubble) {
break
}
current = current.parentNode
}
}
@Suppress("UNCHECKED_CAST")
inline fun <reified T> on(target: INode<*>, noinline block: (Event<T>) -> Unit) =
addEventListener(EventNames.getEventName<T>(), target, block as (Event<*>) -> Unit)
@Suppress("UNCHECKED_CAST")
inline fun <reified T> on(noinline block: (Event<T>) -> Unit) =
addTopLevelEventListener(EventNames.getEventName<T>(), block as (Event<*>) -> Unit)
}

13
threedom/src/main/kotlin/me/eater/threedom/dom/IDocument.kt

@ -0,0 +1,13 @@
package me.eater.threedom.dom
import me.eater.threedom.event.Event
import me.eater.threedom.event.EventDispatcher
import kotlin.reflect.KClass
interface IDocument : EventDispatcher, INodeContainer {
fun <T : INode<T>> createNode(nodeType: KClass<T>): T
fun deleteNode(refNode: INode<*>)
fun <T> addEventListener(eventName: String, refNode: INode<*>, block: (Event<T>) -> Unit)
}
inline fun <reified T : INode<T>> IDocument.createNode() = createNode(T::class)

41
threedom/src/main/kotlin/me/eater/threedom/dom/INode.kt

@ -0,0 +1,41 @@
package me.eater.threedom.dom
import org.joml.Matrix4d
import org.joml.Vector3d
import java.util.concurrent.atomic.AtomicLong
interface INode<T : INode<T>> : Comparable<INode<*>>, INodeContainer {
var id: String?
val classList: MutableSet<String>
val nodeId: Long
val parentNode: INode<*>?
val document: IDocument?
val absolute: Matrix4d
get() = (parentNode?.absolute ?: Matrix4d()).mul(model)
var model: Matrix4d
fun clone(deep: Boolean): T
fun updateParent(refNode: INode<*>?): Boolean
fun detachFromDocument()
fun hasParent(node: INode<*>): Boolean {
var current: INode<*>? = parentNode;
while (current != null) {
if (node.nodeId == current.nodeId) {
return true
}
current = current.parentNode
}
return false
}
override fun compareTo(other: INode<*>): Int = this.nodeId.compareTo(other.nodeId)
companion object {
private val atomicNodeId = AtomicLong(0)
fun getNextNodeId() = atomicNodeId.getAndIncrement()
}
}

12
threedom/src/main/kotlin/me/eater/threedom/dom/INodeContainer.kt

@ -0,0 +1,12 @@
package me.eater.threedom.dom
interface INodeContainer : INodeQueryCapable {
fun addNode(newNode: INode<*>)
fun removeNode(refNode: INode<*>)
fun removeAll()
fun replaceNode(newNode: INode<*>, refNode: INode<*>): Boolean
fun hasChild(refNode: INode<*>): Boolean
fun sequence(): Sequence<INode<*>>
operator fun iterator(): Iterator<INode<*>>
}

10
threedom/src/main/kotlin/me/eater/threedom/dom/INodeQueryCapable.kt

@ -0,0 +1,10 @@
package me.eater.threedom.dom
import me.eater.threedom.dom.query.NodeQuery
interface INodeQueryCapable {
fun getNodeById(id: String): INode<*>?
fun getNodesByClassName(className: String): Sequence<INode<*>>
fun find(query: NodeQuery): Sequence<INode<*>> = emptySequence()
fun findOne(query: NodeQuery): INode<*>? = find(query).firstOrNull()
}

177
threedom/src/main/kotlin/me/eater/threedom/dom/Node.kt

@ -0,0 +1,177 @@
package me.eater.threedom.dom
import me.eater.threedom.dom.event.DOMTreeUpdate
import me.eater.threedom.dom.event.NodeClassListUpdate
import me.eater.threedom.dom.event.NodeIDUpdate
import me.eater.threedom.event.Event
import me.eater.threedom.event.EventListener
import me.eater.threedom.event.trigger
import me.eater.threedom.utils.ObservableSet
import org.joml.Matrix4d
abstract class Node<T : INode<T>>(document: IDocument?) : INode<T>, EventListener {
override var document: IDocument? = document
protected set
override val nodeId = INode.getNextNodeId()
private val nodes: MutableSet<INode<*>> = mutableSetOf()
override var parentNode: INode<*>? = null
protected set
private var _id: String? = null
override var id: String?
get() = _id
set(value) {
val old = _id
_id = value
trigger(NodeIDUpdate(this, old, value))
}
override val classList: MutableSet<String> = ObservableSet {
when (it.action) {
ObservableSet.Action.Removed -> trigger(NodeClassListUpdate.Removed(it.elements, this))
ObservableSet.Action.Added -> trigger(NodeClassListUpdate.Added(it.elements, this))
}
}
private inline fun <reified T> trigger(event: T) {
if (parentNode !== null) {
document?.trigger(Event(event, this))
}
}
override var model: Matrix4d = Matrix4d()
var children: List<INode<*>> = nodes.toList()
override fun addNode(newNode: INode<*>) {
nodes.add(newNode)
if (newNode.parentNode != this) {
newNode.updateParent(this)
}
}
override fun removeNode(refNode: INode<*>) {
nodes.remove(refNode)
if (refNode.parentNode == this) {
refNode.updateParent(null)
}
}
override fun removeAll() {
nodes.forEach(::removeNode)
}
override fun replaceNode(newNode: INode<*>, refNode: INode<*>): Boolean {
if (nodes.remove(refNode)) {
nodes.add(newNode)
return true
}
return false
}
override fun sequence(): Sequence<INode<*>> = nodes.asSequence()
override fun iterator(): Iterator<INode<*>> = nodes.iterator()
fun recursiveSequence(): Sequence<INode<*>> = sequence {
val iterators = mutableListOf<Iterator<INode<*>>>()
var current = iterator()
while (true) {
for (node in current) {
yield(node)
iterators.add(node.iterator())
}
current = iterators.firstOrNull() ?: return@sequence
}
}
protected abstract fun cloneSelf(): T
override fun clone(deep: Boolean): T {
val cloned = cloneSelf()
if (!deep) {
return cloned
}
for (node in cloned) {
cloned.addNode(node.clone(true))
}
return cloned
}
override fun updateParent(refNode: INode<*>?): Boolean {
val parent = parentNode
if (parent == refNode) {
return true
}
if (refNode != null && !refNode.hasChild(this)) {
return false
}
parentNode = refNode
parent?.removeNode(this)
val event = when {
parent == null && refNode != null ->
DOMTreeUpdate.Insert(refNode, this).also(::trigger)
refNode == null && parent != null -> {
val data = DOMTreeUpdate.Remove(parent, this)
// Trigger on removed as well as on the parent
// Since you can't bubble from a detached node
var ev = Event(data, this)
document?.trigger(this, ev)
if (ev.bubble) {
document?.trigger(parent, Event(data, this))
}
// Trigger on removed as well as on the parent
// Since you can't bubble from a detached node
ev = Event(data, this)
document?.trigger<DOMTreeUpdate>(this, ev)
if (ev.bubble) {
document?.trigger<DOMTreeUpdate>(parent, Event(data, this))
}
return true
}
refNode != null && parent != null ->
DOMTreeUpdate.Move(parent, refNode, this).also(::trigger)
else -> return true
}
trigger(event)
return true
}
override fun hasChild(refNode: INode<*>) = nodes.contains(refNode)
override fun <T> on(eventName: String, block: (Event<T>) -> Unit) {
document?.addEventListener(eventName, this, block)
}
override fun detachFromDocument() {
val doc = this.document ?: return
this.document = null
doc.removeNode(this)
}
fun finalize() {
document?.removeNode(this)
}
override fun getNodeById(id: String): INode<*>? = document?.getNodeById(id)?.takeIf { it.hasParent(this) }
override fun getNodesByClassName(className: String): Sequence<INode<*>> = if (nodes.isEmpty())
emptySequence()
else
document?.getNodesByClassName(className)?.filter { it.hasParent(this) } ?: emptySequence()
}

7
threedom/src/main/kotlin/me/eater/threedom/dom/PlainNode.kt

@ -0,0 +1,7 @@
package me.eater.threedom.dom
class PlainNode(document: IDocument?) : Node<PlainNode>(document) {
override fun cloneSelf(): PlainNode {
return PlainNode(document)
}
}

18
threedom/src/main/kotlin/me/eater/threedom/dom/event/DOMTreeUpdate.kt

@ -0,0 +1,18 @@
package me.eater.threedom.dom.event
import me.eater.threedom.dom.INode
import me.eater.threedom.kapt.EventName
@EventName("DOMTreeUpdate")
sealed class DOMTreeUpdate {
abstract val child: INode<*>
@EventName("DOMNodeRemove")
data class Remove(val parent: INode<*>, override val child: INode<*>) : DOMTreeUpdate()
@EventName("DOMNodeInsert")
data class Insert(val parent: INode<*>, override val child: INode<*>) : DOMTreeUpdate()
@EventName("DOMNodeMove")
data class Move(val oldParent: INode<*>, val newParent: INode<*>, override val child: INode<*>) : DOMTreeUpdate()
}

10
threedom/src/main/kotlin/me/eater/threedom/dom/event/NodeClassListUpdate.kt

@ -0,0 +1,10 @@
package me.eater.threedom.dom.event
import me.eater.threedom.dom.INode
sealed class NodeClassListUpdate {
abstract val node: INode<*>
class Removed(val classNames: Set<String>, override val node: INode<*>): NodeClassListUpdate()
class Added(val classNames: Set<String>, override val node: INode<*>): NodeClassListUpdate()
}

5
threedom/src/main/kotlin/me/eater/threedom/dom/event/NodeIDUpdate.kt

@ -0,0 +1,5 @@
package me.eater.threedom.dom.event
import me.eater.threedom.dom.INode
data class NodeIDUpdate(val node: INode<*>, val old: String?, val new: String?)

5
threedom/src/main/kotlin/me/eater/threedom/dom/query/NodeQuery.kt

@ -0,0 +1,5 @@
package me.eater.threedom.dom.query
class NodeQuery() {
}

25
threedom/src/main/kotlin/me/eater/threedom/event/Event.kt

@ -0,0 +1,25 @@
package me.eater.threedom.event
import me.eater.threedom.dom.INode
class Event<out T>(val data: T, val source: INode<*>) {
var bubble = true
private set
var propagate = true
private set
fun stopPropagation() {
propagate = false
}
fun stopBubbling() {
bubble = false
}
operator fun component1(): T = data
operator fun component2(): INode<*> = source
}

5
threedom/src/main/kotlin/me/eater/threedom/event/EventControl.kt

@ -0,0 +1,5 @@
package me.eater.threedom.event
interface EventControl {
}

15
threedom/src/main/kotlin/me/eater/threedom/event/EventDispatcher.kt

@ -0,0 +1,15 @@
package me.eater.threedom.event
import me.eater.threedom.dom.INode
import me.eater.threedom.generated.EventNames
interface EventDispatcher {
fun trigger(eventName: String, event: Event<*>) = trigger(eventName, event.source, event)
fun trigger(eventName: String, targetNode: INode<*>, event: Event<*>)
}
inline fun <reified T> EventDispatcher.trigger(event: Event<T>) =
trigger(EventNames.getEventName<T>(), event)
inline fun <reified T> EventDispatcher.trigger(target: INode<*>, event: Event<T>) =
trigger(EventNames.getEventName<T>(), target, event)

7
threedom/src/main/kotlin/me/eater/threedom/event/EventListener.kt

@ -0,0 +1,7 @@
package me.eater.threedom.event
interface EventListener {
fun <T> on(eventName: String, block: (Event<T>) -> Unit)
}
inline fun <reified T> EventListener.on(noinline block: (Event<T>) -> Unit) = on(T::class.java.name, block)

45
threedom/src/main/kotlin/me/eater/threedom/utils/MutableOrderedSetListIterator.kt

@ -0,0 +1,45 @@
package me.eater.threedom.utils
class MutableOrderedSetListIterator<T>(private val collection: OrderedSet<T>, private var index: Int = 0) :
MutableListIterator<T> {
override fun hasPrevious() = collection.size > (index - 1) && index > 1
override fun nextIndex() = index + 1
override fun previous(): T {
if (!hasPrevious()) {
throw NoSuchElementException()
}
index = previousIndex()
return collection[index]
}
override fun previousIndex() = index - 1
override fun add(element: T) {
collection.add(index, element)
}
override fun hasNext() = nextIndex() < collection.size
override fun next(): T {
if (!hasNext()) {
throw NoSuchElementException()
}
index = nextIndex()
return collection[index]
}
override fun remove() {
collection.removeAt(index)
if (index > 0) {
index -= 1
}
}
override fun set(element: T) {
collection[index] = element
}
}

85
threedom/src/main/kotlin/me/eater/threedom/utils/ObservableSet.kt

@ -0,0 +1,85 @@
package me.eater.threedom.utils
class ObservableSet<T>(private val onChange: (Changed<T>) -> Unit) : MutableSet<T> {
private val storage: MutableSet<T> = mutableSetOf()
data class Changed<T>(val action: Action, val elements: Set<T>, val set: ObservableSet<T>)
enum class Action {
Removed,
Added;
}
override fun add(element: T): Boolean {
if (storage.add(element)) {
onChange(Changed(Action.Added, setOf(element), this))
return true
}
return false
}
override fun addAll(elements: Collection<T>): Boolean {
val items = elements.filter(storage::add)
if (items.isEmpty()) {
return false
}
onChange(Changed(Action.Added, items.toSet(), this))
return true
}
override fun clear() {
if (this.isEmpty()) {
return
}
onChange(Changed(Action.Removed, storage, this))
storage.clear()
}
override fun iterator(): MutableIterator<T> = storage.iterator()
override fun remove(element: T): Boolean {
if (storage.remove(element)) {
onChange(Changed(Action.Removed, setOf(element), this))
return true
}
return false
}
override fun removeAll(elements: Collection<T>): Boolean {
val items = elements.filter(storage::remove)
if (items.isEmpty()) {
return false
}
onChange(Changed(Action.Removed, items.toSet(), this))
return true
}
override fun retainAll(elements: Collection<T>): Boolean {
val temp = mutableSetOf<T>()
for (item in storage) {
if (!elements.contains(item)) {
temp.add(item)
storage.remove(item)
}
}
onChange(Changed(Action.Removed, temp, this))
return temp.size > 0
}
override val size: Int
get() = storage.size
override fun contains(element: T) = storage.contains(element)
override fun containsAll(elements: Collection<T>) = storage.containsAll(elements)
override fun isEmpty(): Boolean = storage.isEmpty()
}

95
threedom/src/main/kotlin/me/eater/threedom/utils/OrderedSet.kt

@ -0,0 +1,95 @@
package me.eater.threedom.utils
import java.util.*
class OrderedSet<T>() : MutableSet<T>, MutableList<T> {
override val size
get() = items.size
private val set: MutableSet<T> = mutableSetOf()
private val items: MutableList<T> = mutableListOf()
constructor(elements: Collection<T>) : this() {
addAll(elements)
}
override fun contains(element: T) = set.contains(element)
override fun containsAll(elements: Collection<T>) = set.containsAll(elements)
override fun isEmpty() = items.isEmpty()
override fun iterator() = items.iterator()
override operator fun get(index: Int) = items[index]
override fun spliterator(): Spliterator<T> = set.spliterator()
override fun indexOf(element: T): Int = items.indexOf(element)
override fun lastIndexOf(element: T): Int = indexOf(element)
override fun listIterator(): MutableOrderedSetListIterator<T> = MutableOrderedSetListIterator(this)
override fun listIterator(index: Int): MutableOrderedSetListIterator<T> = MutableOrderedSetListIterator(this, index)
override fun subList(fromIndex: Int, toIndex: Int): OrderedSet<T> = OrderedSet(items.subList(fromIndex, toIndex))
override fun add(element: T): Boolean {
if (set.add(element)) {
items.add(element)
return true
}
return false
}
override fun addAll(elements: Collection<T>): Boolean {
return elements.map(::add).any()
}
override fun clear() {
set.clear()
items.clear()
}
override fun remove(element: T): Boolean {
if (set.remove(element)) {
items.remove(element)
return true
}
return false
}
override fun removeAll(elements: Collection<T>): Boolean {
return elements.map(::remove).any()
}
override fun retainAll(elements: Collection<T>): Boolean {
return set.toSet().map {
if (elements.contains(it)) {
false
} else {
remove(it)
}
}.any()
}
override fun add(index: Int, element: T) {
if (set.add(element)) {
items.add(index, element)
}
}
override fun addAll(index: Int, elements: Collection<T>): Boolean {
return items.addAll(index, elements.filter(set::add))
}
override fun removeAt(index: Int): T {
set.remove(items[index])
return items.removeAt(index)
}
override fun set(index: Int, element: T): T {
if (!set.add(element)) {
return element
}
val old = items[index]
items[index] = element
remove(old)
set.add(element)
return old
}
}

12
threedom/src/main/kotlin/me/eater/threedom/utils/joml/JOML.kt

@ -0,0 +1,12 @@
package me.eater.threedom.utils.joml
import org.joml.Matrix4d
import org.joml.Vector3d
fun <T : Number> Matrix4d.setTranslation(x: T, y: T, z: T): Matrix4d =
setTranslation(x.toDouble(), y.toDouble(), z.toDouble())
fun Matrix4d.getTranslation(): Vector3d = getTranslation(Vector3d())
@Suppress("FunctionName")
fun <T : Number> Vector3d(x: T, y: T, z: T) = Vector3d(x.toDouble(), y.toDouble(), z.toDouble())

134
threedom/src/test/kotlin/me/eater/test/threedom/dom/DocumentTest.kt

@ -0,0 +1,134 @@
package me.eater.test.threedom.dom
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.sequences.shouldHaveCount
import io.kotest.matchers.sequences.shouldHaveSingleElement
import io.kotest.matchers.shouldBe
import me.eater.threedom.dom.Document
import me.eater.threedom.dom.PlainNode
import me.eater.threedom.dom.createNode
import me.eater.threedom.dom.event.DOMTreeUpdate
import me.eater.threedom.dom.event.NodeIDUpdate
class DocumentTest : StringSpec({
"test insert event" {
val document = Document()
var triggered = 0;
document.on<DOMTreeUpdate.Insert> {
triggered++
}
document.on<DOMTreeUpdate> {
triggered++
}
val newNode = document.createNode<PlainNode>()
document.addNode(newNode)
triggered shouldBe 2
}
"test move event" {
val document = Document()
val newNode = document.createNode<PlainNode>()
document.addNode(newNode)
val secondNode = document.createNode<PlainNode>()
document.addNode(secondNode)
var triggered = 0
document.on<DOMTreeUpdate.Move> { (data) ->
data.newParent shouldBe secondNode
data.oldParent shouldBe document.root
data.child shouldBe newNode
triggered++
}
secondNode.addNode(newNode)
triggered shouldBe 1
}
"test remove event" {
val document = Document()
var triggered = 0
document.on<DOMTreeUpdate.Remove> {
triggered++
}
document.on<DOMTreeUpdate> {
triggered++
}
val newNode = document.createNode<PlainNode>()
document.addNode(newNode)
document.removeNode(newNode)
triggered shouldBe 3
}
"test byId" {
val doc = Document()
val newNode = doc.createNode<PlainNode>()
val secondNewNode = doc.createNode<PlainNode>()
val thirdNewNode = doc.createNode<PlainNode>()
var triggered = 0
doc.on<NodeIDUpdate> {
triggered++
}
newNode.id = ":3"
doc.addNode(secondNewNode)
doc.addNode(thirdNewNode)
doc.getNodeById(":3") shouldBe null
thirdNewNode.addNode(newNode)
doc.getNodeById(":3") shouldBe newNode
secondNewNode.getNodeById(":3") shouldBe null
thirdNewNode.getNodeById(":3") shouldBe newNode
thirdNewNode.removeAll()
thirdNewNode.getNodeById(":3") shouldBe null
doc.getNodeById(":3") shouldBe null
doc.addNode(newNode)
doc.getNodeById(":3") shouldBe newNode
newNode.id = ":/"
doc.getNodeById(":3") shouldBe null
doc.getNodeById(":/") shouldBe newNode
}
"test byClass" {
val doc = Document()
val newNode = doc.createNode<PlainNode>()
val secondNewNode = doc.createNode<PlainNode>()
val thirdNewNode = doc.createNode<PlainNode>()
var triggered = 0
doc.on<NodeIDUpdate> {
triggered++
}
newNode.classList.add(":3")
doc.addNode(secondNewNode)
doc.addNode(thirdNewNode)
doc.getNodesByClassName(":3") shouldHaveCount 0
thirdNewNode.addNode(newNode)
doc.getNodesByClassName(":3") shouldHaveSingleElement newNode
secondNewNode.getNodesByClassName(":3") shouldHaveCount 0
thirdNewNode.getNodesByClassName(":3") shouldHaveSingleElement newNode
thirdNewNode.removeAll()
thirdNewNode.getNodesByClassName(":3") shouldHaveCount 0
doc.getNodesByClassName(":3") shouldHaveCount 0
newNode.classList.clear()
newNode.classList.add(":/")
doc.addNode(newNode)
doc.getNodesByClassName(":3") shouldHaveCount 0
doc.getNodesByClassName(":/") shouldHaveSingleElement newNode
newNode.classList.clear()
newNode.classList.add(":3")
doc.getNodesByClassName(":/") shouldHaveCount 0
doc.getNodesByClassName(":3") shouldHaveSingleElement newNode
}
})

26
threedom/src/test/kotlin/me/eater/test/threedom/dom/PositionTest.kt

@ -0,0 +1,26 @@
package me.eater.test.threedom.dom
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import me.eater.threedom.dom.Document
import me.eater.threedom.dom.PlainNode
import me.eater.threedom.dom.createNode
import me.eater.threedom.utils.joml.Vector3d
import me.eater.threedom.utils.joml.getTranslation
import me.eater.threedom.utils.joml.setTranslation
class PositionTest : StringSpec({
"ensure positioning works" {
val doc = Document()
val node = doc.createNode<PlainNode>()
doc.addNode(node)
node.model.setTranslation(10, 0, 10)
node.absolute.getTranslation() shouldBe Vector3d(10, 0, 10)
val nodeTwo = doc.createNode<PlainNode>()
node.addNode(nodeTwo)
nodeTwo.model.setTranslation(-10, 20, 0)
nodeTwo.absolute.getTranslation() shouldBe Vector3d(0, 20, 10)
doc.addNode(nodeTwo)
nodeTwo.absolute.getTranslation() shouldBe Vector3d(-10, 20, 0)
}
})
Loading…
Cancel
Save