Added viewer, xj parsing and fixed several bugs.

This commit is contained in:
Daan Vanden Bosch 2020-11-06 22:23:24 +01:00
parent bedc7b07a2
commit 8ec75f8b4a
34 changed files with 824 additions and 158 deletions

View File

@ -65,7 +65,7 @@ private class PrsDecompressor(private val src: Cursor) {
}
return Success(dst.seekStart(0))
} catch (e: Throwable) {
} catch (e: Exception) {
return PwResult.build<Cursor>(logger)
.addProblem(Severity.Error, "PRS-compressed stream is corrupt.", cause = e)
.failure()

View File

@ -6,4 +6,6 @@ class Vec2(val x: Float, val y: Float)
class Vec3(val x: Float, val y: Float, val z: Float)
fun Cursor.vec2Float(): Vec2 = Vec2(float(), float())
fun Cursor.vec3Float(): Vec3 = Vec3(float(), float(), float())

View File

@ -10,11 +10,11 @@ import world.phantasmal.lib.fileFormats.vec3Float
private const val NJCM: Int = 0x4D434A4E
fun parseNj(cursor: Cursor): PwResult<List<NinjaObject<NjcmModel>>> =
parseNinja(cursor, ::parseNjcmModel, mutableMapOf())
fun parseNj(cursor: Cursor): PwResult<List<NinjaObject<NjModel>>> =
parseNinja(cursor, ::parseNjModel, mutableMapOf())
fun parseXj(cursor: Cursor): PwResult<List<NinjaObject<XjModel>>> =
parseNinja(cursor, { _, _ -> XjModel() }, Unit)
parseNinja(cursor, { c, _ -> parseXjModel(c) }, Unit)
private fun <Model : NinjaModel, Context> parseNinja(
cursor: Cursor,

View File

@ -38,17 +38,17 @@ sealed class NinjaModel
/**
* The model type used in .nj files.
*/
class NjcmModel(
class NjModel(
/**
* Sparse list of vertices.
*/
val vertices: List<NjcmVertex?>,
val meshes: List<NjcmTriangleStrip>,
val vertices: List<NjVertex?>,
val meshes: List<NjTriangleStrip>,
val collisionSphereCenter: Vec3,
val collisionSphereRadius: Float,
) : NinjaModel()
class NjcmVertex(
class NjVertex(
val position: Vec3,
val normal: Vec3?,
val boneWeight: Float,
@ -56,7 +56,7 @@ class NjcmVertex(
val calcContinue: Boolean,
)
class NjcmTriangleStrip(
class NjTriangleStrip(
val ignoreLight: Boolean,
val ignoreSpecular: Boolean,
val ignoreAmbient: Boolean,
@ -70,25 +70,25 @@ class NjcmTriangleStrip(
var textureId: UInt?,
var srcAlpha: UByte?,
var dstAlpha: UByte?,
val vertices: List<NjcmMeshVertex>,
val vertices: List<NjMeshVertex>,
)
class NjcmMeshVertex(
val index: UShort,
class NjMeshVertex(
val index: Int,
val normal: Vec3?,
val texCoords: Vec2?,
)
sealed class NjcmChunk(val typeId: UByte) {
class Unknown(typeId: UByte) : NjcmChunk(typeId)
sealed class NjChunk(val typeId: UByte) {
class Unknown(typeId: UByte) : NjChunk(typeId)
object Null : NjcmChunk(0u)
object Null : NjChunk(0u)
class Bits(typeId: UByte, val srcAlpha: UByte, val dstAlpha: UByte) : NjcmChunk(typeId)
class Bits(typeId: UByte, val srcAlpha: UByte, val dstAlpha: UByte) : NjChunk(typeId)
class CachePolygonList(val cacheIndex: UByte, val offset: Int) : NjcmChunk(4u)
class CachePolygonList(val cacheIndex: UByte, val offset: Int) : NjChunk(4u)
class DrawPolygonList(val cacheIndex: UByte) : NjcmChunk(5u)
class DrawPolygonList(val cacheIndex: UByte) : NjChunk(5u)
class Tiny(
typeId: UByte,
@ -100,27 +100,27 @@ sealed class NjcmChunk(val typeId: UByte) {
val filterMode: UInt,
val superSample: Boolean,
val textureId: UInt,
) : NjcmChunk(typeId)
) : NjChunk(typeId)
class Material(
typeId: UByte,
val srcAlpha: UByte,
val dstAlpha: UByte,
val diffuse: NjcmArgb?,
val ambient: NjcmArgb?,
val specular: NjcmErgb?,
) : NjcmChunk(typeId)
val diffuse: NjArgb?,
val ambient: NjArgb?,
val specular: NjErgb?,
) : NjChunk(typeId)
class Vertex(typeId: UByte, val vertices: List<NjcmChunkVertex>) : NjcmChunk(typeId)
class Vertex(typeId: UByte, val vertices: List<NjChunkVertex>) : NjChunk(typeId)
class Volume(typeId: UByte) : NjcmChunk(typeId)
class Volume(typeId: UByte) : NjChunk(typeId)
class Strip(typeId: UByte, val triangleStrips: List<NjcmTriangleStrip>) : NjcmChunk(typeId)
class Strip(typeId: UByte, val triangleStrips: List<NjTriangleStrip>) : NjChunk(typeId)
object End : NjcmChunk(255u)
object End : NjChunk(255u)
}
class NjcmChunkVertex(
class NjChunkVertex(
val index: Int,
val position: Vec3,
val normal: Vec3?,
@ -132,14 +132,14 @@ class NjcmChunkVertex(
/**
* Channels are in range [0, 1].
*/
class NjcmArgb(
class NjArgb(
val a: Float,
val r: Float,
val g: Float,
val b: Float,
)
class NjcmErgb(
class NjErgb(
val e: UByte,
val r: UByte,
val g: UByte,
@ -149,4 +149,30 @@ class NjcmErgb(
/**
* The model type used in .xj files.
*/
class XjModel : NinjaModel()
class XjModel(
val vertices: List<XjVertex>,
val meshes: List<XjMesh>,
val collisionSpherePosition: Vec3,
val collisionSphereRadius: Float,
) : NinjaModel()
class XjVertex(
val position: Vec3,
val normal: Vec3?,
val uv: Vec2?,
)
class XjMesh(
val material: XjMaterial,
val indices: List<Int>,
)
class XjMaterial(
val srcAlpha: Int?,
val dstAlpha: Int?,
val textureId: Int?,
val diffuseR: Int?,
val diffuseG: Int?,
val diffuseB: Int?,
val diffuseA: Int?,
)

View File

@ -17,25 +17,25 @@ private const val ZERO_U8: UByte = 0u
// TODO: Simplify parser by not parsing chunks into vertices and meshes. Do the chunk to vertex/mesh
// conversion at a higher level.
fun parseNjcmModel(cursor: Cursor, cachedChunkOffsets: MutableMap<UByte, Int>): NjcmModel {
fun parseNjModel(cursor: Cursor, cachedChunkOffsets: MutableMap<UByte, Int>): NjModel {
val vlistOffset = cursor.int() // Vertex list
val plistOffset = cursor.int() // Triangle strip index list
val boundingSphereCenter = cursor.vec3Float()
val boundingSphereRadius = cursor.float()
val vertices: MutableList<NjcmVertex?> = mutableListOf()
val meshes: MutableList<NjcmTriangleStrip> = mutableListOf()
val collisionSphereCenter = cursor.vec3Float()
val collisionSphereRadius = cursor.float()
val vertices: MutableList<NjVertex?> = mutableListOf()
val meshes: MutableList<NjTriangleStrip> = mutableListOf()
if (vlistOffset != 0) {
cursor.seekStart(vlistOffset)
for (chunk in parseChunks(cursor, cachedChunkOffsets, true)) {
if (chunk is NjcmChunk.Vertex) {
if (chunk is NjChunk.Vertex) {
for (vertex in chunk.vertices) {
while (vertices.size <= vertex.index) {
vertices.add(null)
}
vertices[vertex.index] = NjcmVertex(
vertices[vertex.index] = NjVertex(
vertex.position,
vertex.normal,
vertex.boneWeight,
@ -56,21 +56,21 @@ fun parseNjcmModel(cursor: Cursor, cachedChunkOffsets: MutableMap<UByte, Int>):
for (chunk in parseChunks(cursor, cachedChunkOffsets, false)) {
when (chunk) {
is NjcmChunk.Bits -> {
is NjChunk.Bits -> {
srcAlpha = chunk.srcAlpha
dstAlpha = chunk.dstAlpha
}
is NjcmChunk.Tiny -> {
is NjChunk.Tiny -> {
textureId = chunk.textureId
}
is NjcmChunk.Material -> {
is NjChunk.Material -> {
srcAlpha = chunk.srcAlpha
dstAlpha = chunk.dstAlpha
}
is NjcmChunk.Strip -> {
is NjChunk.Strip -> {
for (strip in chunk.triangleStrips) {
strip.textureId = textureId
strip.srcAlpha = srcAlpha
@ -87,11 +87,11 @@ fun parseNjcmModel(cursor: Cursor, cachedChunkOffsets: MutableMap<UByte, Int>):
}
}
return NjcmModel(
return NjModel(
vertices,
meshes,
boundingSphereCenter,
boundingSphereRadius,
collisionSphereCenter,
collisionSphereRadius,
)
}
@ -100,8 +100,8 @@ private fun parseChunks(
cursor: Cursor,
cachedChunkOffsets: MutableMap<UByte, Int>,
wideEndChunks: Boolean,
): List<NjcmChunk> {
val chunks: MutableList<NjcmChunk> = mutableListOf()
): List<NjChunk> {
val chunks: MutableList<NjChunk> = mutableListOf()
var loop = true
while (loop) {
@ -113,10 +113,10 @@ private fun parseChunks(
when (typeId.toInt()) {
0 -> {
chunks.add(NjcmChunk.Null)
chunks.add(NjChunk.Null)
}
in 1..3 -> {
chunks.add(NjcmChunk.Bits(
chunks.add(NjChunk.Bits(
typeId,
srcAlpha = ((flagsUInt shr 3).toUByte() and 0b111u),
dstAlpha = flags and 0b111u,
@ -125,7 +125,7 @@ private fun parseChunks(
4 -> {
val offset = cursor.position
chunks.add(NjcmChunk.CachePolygonList(
chunks.add(NjChunk.CachePolygonList(
cacheIndex = flags,
offset,
))
@ -141,7 +141,7 @@ private fun parseChunks(
chunks.addAll(parseChunks(cursor, cachedChunkOffsets, wideEndChunks))
}
chunks.add(NjcmChunk.DrawPolygonList(
chunks.add(NjChunk.DrawPolygonList(
cacheIndex = flags,
))
}
@ -149,7 +149,7 @@ private fun parseChunks(
size = 2
val textureBitsAndId = cursor.uShort().toUInt()
chunks.add(NjcmChunk.Tiny(
chunks.add(NjChunk.Tiny(
typeId,
flipU = (typeId.toUInt() and 0x80u) != 0u,
flipV = (typeId.toUInt() and 0x40u) != 0u,
@ -164,12 +164,12 @@ private fun parseChunks(
in 17..31 -> {
size = 2 + 2 * cursor.short()
var diffuse: NjcmArgb? = null
var ambient: NjcmArgb? = null
var specular: NjcmErgb? = null
var diffuse: NjArgb? = null
var ambient: NjArgb? = null
var specular: NjErgb? = null
if ((flagsUInt and 0b1u) != 0u) {
diffuse = NjcmArgb(
diffuse = NjArgb(
b = cursor.uByte().toFloat() / 255f,
g = cursor.uByte().toFloat() / 255f,
r = cursor.uByte().toFloat() / 255f,
@ -178,7 +178,7 @@ private fun parseChunks(
}
if ((flagsUInt and 0b10u) != 0u) {
ambient = NjcmArgb(
ambient = NjArgb(
b = cursor.uByte().toFloat() / 255f,
g = cursor.uByte().toFloat() / 255f,
r = cursor.uByte().toFloat() / 255f,
@ -187,7 +187,7 @@ private fun parseChunks(
}
if ((flagsUInt and 0b100u) != 0u) {
specular = NjcmErgb(
specular = NjErgb(
b = cursor.uByte(),
g = cursor.uByte(),
r = cursor.uByte(),
@ -195,7 +195,7 @@ private fun parseChunks(
)
}
chunks.add(NjcmChunk.Material(
chunks.add(NjChunk.Material(
typeId,
srcAlpha = ((flagsUInt shr 3).toUByte() and 0b111u),
dstAlpha = flags and 0b111u,
@ -206,32 +206,32 @@ private fun parseChunks(
}
in 32..50 -> {
size = 2 + 4 * cursor.short()
chunks.add(NjcmChunk.Vertex(
chunks.add(NjChunk.Vertex(
typeId,
vertices = parseVertexChunk(cursor, typeId, flags),
))
}
in 56..58 -> {
size = 2 + 2 * cursor.short()
chunks.add(NjcmChunk.Volume(
chunks.add(NjChunk.Volume(
typeId,
))
}
in 64..75 -> {
size = 2 + 2 * cursor.short()
chunks.add(NjcmChunk.Strip(
chunks.add(NjChunk.Strip(
typeId,
triangleStrips = parseTriangleStripChunk(cursor, typeId, flags),
))
}
255 -> {
size = if (wideEndChunks) 2 else 0
chunks.add(NjcmChunk.End)
chunks.add(NjChunk.End)
loop = false
}
else -> {
size = 2 + 2 * cursor.short()
chunks.add(NjcmChunk.Unknown(
chunks.add(NjChunk.Unknown(
typeId,
))
logger.warn { "Unknown chunk type $typeId at offset ${chunkStartPosition}." }
@ -248,14 +248,14 @@ private fun parseVertexChunk(
cursor: Cursor,
chunkTypeId: UByte,
flags: UByte,
): List<NjcmChunkVertex> {
): List<NjChunkVertex> {
val boneWeightStatus = (flags and 0b11u).toInt()
val calcContinue = (flags and 0x80u) != ZERO_U8
val index = cursor.uShort()
val vertexCount = cursor.uShort()
val vertices: MutableList<NjcmChunkVertex> = mutableListOf()
val vertices: MutableList<NjChunkVertex> = mutableListOf()
for (i in (0u).toUShort() until vertexCount) {
var vertexIndex = index + i
@ -317,7 +317,7 @@ private fun parseVertexChunk(
}
}
vertices.add(NjcmChunkVertex(
vertices.add(NjChunkVertex(
vertexIndex.toInt(),
position,
normal,
@ -334,7 +334,7 @@ private fun parseTriangleStripChunk(
cursor: Cursor,
chunkTypeId: UByte,
flags: UByte,
): List<NjcmTriangleStrip> {
): List<NjTriangleStrip> {
val ignoreLight = (flags and 0b1u) != ZERO_U8
val ignoreSpecular = (flags and 0b10u) != ZERO_U8
val ignoreAmbient = (flags and 0b100u) != ZERO_U8
@ -380,17 +380,17 @@ private fun parseTriangleStripChunk(
else -> error("Unexpected chunk type ID: ${chunkTypeId}.")
}
val strips: MutableList<NjcmTriangleStrip> = mutableListOf()
val strips: MutableList<NjTriangleStrip> = mutableListOf()
repeat(stripCount) {
val windingFlagAndIndexCount = cursor.short()
val clockwiseWinding = windingFlagAndIndexCount < 1
val indexCount = abs(windingFlagAndIndexCount.toInt())
val vertices: MutableList<NjcmMeshVertex> = mutableListOf()
val vertices: MutableList<NjMeshVertex> = mutableListOf()
for (j in 0 until indexCount) {
val index = cursor.uShort()
val index = cursor.uShort().toInt()
val texCoords = if (hasTexCoords) {
Vec2(cursor.uShort().toFloat() / 255f, cursor.uShort().toFloat() / 255f)
@ -419,14 +419,14 @@ private fun parseTriangleStripChunk(
cursor.seek(2 * userFlagsSize)
}
vertices.add(NjcmMeshVertex(
vertices.add(NjMeshVertex(
index,
normal,
texCoords,
))
}
strips.add(NjcmTriangleStrip(
strips.add(NjTriangleStrip(
ignoreLight,
ignoreSpecular,
ignoreAmbient,

View File

@ -1,2 +1,174 @@
package world.phantasmal.lib.fileFormats.ninja
import mu.KotlinLogging
import world.phantasmal.lib.cursor.Cursor
import world.phantasmal.lib.fileFormats.Vec2
import world.phantasmal.lib.fileFormats.Vec3
import world.phantasmal.lib.fileFormats.vec2Float
import world.phantasmal.lib.fileFormats.vec3Float
private val logger = KotlinLogging.logger {}
fun parseXjModel(cursor: Cursor): XjModel {
cursor.seek(4) // Flags according to QEdit, seemingly always 0.
val vertexInfoTableOffset = cursor.int()
val vertexInfoCount = cursor.int()
val triangleStripTableOffset = cursor.int()
val triangleStripCount = cursor.int()
val transparentTriangleStripTableOffset = cursor.int()
val transparentTriangleStripCount = cursor.int()
val collisionSpherePosition = cursor.vec3Float()
val collisionSphereRadius = cursor.float()
val vertices = mutableListOf<XjVertex>()
if (vertexInfoCount >= 1) {
// TODO: parse all vertex info tables.
vertices.addAll(parseVertexInfoTable(cursor, vertexInfoTableOffset))
}
val meshes = mutableListOf<XjMesh>()
meshes.addAll(
parseTriangleStripTable(cursor, triangleStripTableOffset, triangleStripCount),
)
meshes.addAll(
parseTriangleStripTable(
cursor,
transparentTriangleStripTableOffset,
transparentTriangleStripCount,
),
)
return XjModel(
vertices,
meshes,
collisionSpherePosition,
collisionSphereRadius,
)
}
private fun parseVertexInfoTable(cursor: Cursor, vertexInfoTableOffset: Int): List<XjVertex> {
cursor.seekStart(vertexInfoTableOffset)
val vertexType = cursor.short().toInt()
cursor.seek(2) // Flags?
val vertexTableOffset = cursor.int()
val vertexSize = cursor.int()
val vertexCount = cursor.int()
return (0 until vertexCount).map { i ->
cursor.seekStart(vertexTableOffset + i * vertexSize)
val position = cursor.vec3Float()
var normal: Vec3? = null
var uv: Vec2? = null
when (vertexType) {
2 -> {
normal = cursor.vec3Float()
}
3 -> {
normal = cursor.vec3Float()
uv = cursor.vec2Float()
}
4 -> {
// Skip 4 bytes.
}
5 -> {
cursor.seek(4)
uv = cursor.vec2Float()
}
6 -> {
normal = cursor.vec3Float()
// Skip 4 bytes.
}
7 -> {
normal = cursor.vec3Float()
uv = cursor.vec2Float()
}
else -> {
logger.warn { "Unknown vertex type $vertexType with size ${vertexSize}." }
}
}
XjVertex(
position,
normal,
uv,
)
}
}
private fun parseTriangleStripTable(
cursor: Cursor,
triangle_strip_list_offset: Int,
triangle_strip_count: Int,
): List<XjMesh> {
return (0 until triangle_strip_count).map { i ->
cursor.seekStart(triangle_strip_list_offset + i * 20)
val materialTableOffset = cursor.int()
val materialTableSize = cursor.int()
val indexListOffset = cursor.int()
val indexCount = cursor.int()
val material = parseTriangleStripMaterial(
cursor,
materialTableOffset,
materialTableSize,
)
cursor.seekStart(indexListOffset)
val indices = cursor.uShortArray(indexCount)
XjMesh(
material,
indices = List(indexCount) { indices[it].toInt() },
)
}
}
private fun parseTriangleStripMaterial(
cursor: Cursor,
offset: Int,
size: Int,
): XjMaterial {
var srcAlpha: Int? = null
var dstAlpha: Int? = null
var textureId: Int? = null
var diffuseR: Int? = null
var diffuseG: Int? = null
var diffuseB: Int? = null
var diffuseA: Int? = null
for (i in 0 until size) {
cursor.seekStart(offset + i * 16)
when (cursor.int()) {
2 -> {
srcAlpha = cursor.int()
dstAlpha = cursor.int()
}
3 -> {
textureId = cursor.int()
}
5 -> {
diffuseR = cursor.uByte().toInt()
diffuseG = cursor.uByte().toInt()
diffuseB = cursor.uByte().toInt()
diffuseA = cursor.uByte().toInt()
}
}
}
return XjMaterial(
srcAlpha,
dstAlpha,
textureId,
diffuseR,
diffuseG,
diffuseB,
diffuseA,
)
}

View File

@ -348,7 +348,7 @@ private fun parseSegment(
SegmentType.String ->
parseStringSegment(offsetToSegment, cursor, endOffset, labels, dcGcFormat)
}
} catch (e: Throwable) {
} catch (e: Exception) {
if (lenient) {
logger.error(e) { "Couldn't fully parse byte code segment." }
} else {
@ -391,7 +391,7 @@ private fun parseInstructionsSegment(
try {
val args = parseInstructionArguments(cursor, opcode, dcGcFormat)
instructions.add(Instruction(opcode, args, null))
} catch (e: Throwable) {
} catch (e: Exception) {
if (lenient) {
logger.error(e) {
"Exception occurred while parsing arguments for instruction ${opcode.mnemonic}."

View File

@ -19,6 +19,7 @@ import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.huntOptimizer.HuntOptimizer
import world.phantasmal.web.questEditor.QuestEditor
import world.phantasmal.web.viewer.Viewer
import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.dom.disposableListener
@ -47,8 +48,8 @@ class Application(
val uiStore = addDisposable(UiStore(scope, applicationUrl))
// Controllers.
val navigationController = addDisposable(NavigationController(scope, uiStore))
val mainContentController = addDisposable(MainContentController(scope, uiStore))
val navigationController = addDisposable(NavigationController(uiStore))
val mainContentController = addDisposable(MainContentController(uiStore))
// Initialize application view.
val applicationWidget = addDisposable(
@ -56,18 +57,24 @@ class Application(
scope,
NavigationWidget(scope, navigationController),
MainContentWidget(scope, mainContentController, mapOf(
PwTool.Viewer to { widgetScope ->
addDisposable(Viewer(
widgetScope,
createEngine,
)).createWidget()
},
PwTool.QuestEditor to { widgetScope ->
addDisposable(QuestEditor(
widgetScope,
assetLoader,
createEngine
createEngine,
)).createWidget()
},
PwTool.HuntOptimizer to { widgetScope ->
addDisposable(HuntOptimizer(
widgetScope,
assetLoader,
uiStore
uiStore,
)).createWidget()
},
))

View File

@ -1,11 +1,10 @@
package world.phantasmal.web.application.controllers
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.observable.value.Val
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.webui.controllers.Controller
class MainContentController(scope: CoroutineScope, uiStore: UiStore) : Controller(scope) {
class MainContentController(uiStore: UiStore) : Controller() {
val tools: Map<PwTool, Val<Boolean>> = uiStore.toolToActive
}

View File

@ -1,15 +1,11 @@
package world.phantasmal.web.application.controllers
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.observable.value.Val
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.webui.controllers.Controller
class NavigationController(
scope: CoroutineScope,
private val uiStore: UiStore,
) : Controller(scope) {
class NavigationController(private val uiStore: UiStore) : Controller() {
val tools: Map<PwTool, Val<Boolean>> = uiStore.toolToActive
fun setCurrentTool(tool: PwTool) {

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.core.controllers
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.webui.controllers.Tab
@ -9,11 +8,10 @@ import world.phantasmal.webui.controllers.TabController
open class PathAwareTab(override val title: String, val path: String) : Tab
open class PathAwareTabController<T : PathAwareTab>(
scope: CoroutineScope,
private val uiStore: UiStore,
private val tool: PwTool,
tabs: List<T>,
) : TabController<T>(scope, tabs) {
) : TabController<T>(tabs) {
init {
observe(uiStore.path) { path ->
if (uiStore.currentTool.value == tool) {

View File

@ -1,29 +1,46 @@
package world.phantasmal.web.core.rendering
import mu.KotlinLogging
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.externals.babylon.Scene
import world.phantasmal.web.externals.babylon.*
import world.phantasmal.webui.DisposableContainer
private val logger = KotlinLogging.logger {}
abstract class Renderer(
protected val canvas: HTMLCanvasElement,
protected val engine: Engine,
) : TrackedDisposable() {
) : DisposableContainer() {
protected val scene = Scene(engine)
private val light = HemisphericLight("Light", Vector3(-1.0, 1.0, 0.0), scene)
protected abstract val camera: Camera
init {
engine.runRenderLoop {
scene.render()
scene.clearColor = Color4(0.09, 0.09, 0.09, 1.0)
}
fun startRendering() {
logger.trace { "${this::class.simpleName} - start rendering." }
engine.runRenderLoop(::render)
}
fun stopRendering() {
logger.trace { "${this::class.simpleName} - stop rendering." }
engine.stopRenderLoop()
}
override fun internalDispose() {
camera.dispose()
light.dispose()
scene.dispose()
engine.dispose()
super.internalDispose()
}
fun scheduleRender() {
// TODO: Remove scheduleRender?
private fun render() {
val lightDirection = Vector3(-1.0, 1.0, 0.0)
lightDirection.rotateByQuaternionToRef(camera.absoluteRotation, lightDirection)
light.direction = lightDirection
scene.render()
}
}

View File

@ -4,7 +4,7 @@ import mu.KotlinLogging
import world.phantasmal.lib.fileFormats.Vec3
import world.phantasmal.lib.fileFormats.ninja.NinjaModel
import world.phantasmal.lib.fileFormats.ninja.NinjaObject
import world.phantasmal.lib.fileFormats.ninja.NjcmModel
import world.phantasmal.lib.fileFormats.ninja.NjModel
import world.phantasmal.lib.fileFormats.ninja.XjModel
import world.phantasmal.web.externals.babylon.*
import kotlin.math.cos
@ -40,7 +40,7 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
if (ef.noTranslate) NO_TRANSLATION else vec3ToBabylon(obj.position),
)
parentMatrix.multiplyToRef(matrix, matrix)
matrix.multiplyToRef(parentMatrix, matrix)
if (!ef.hidden) {
obj.model?.let { model ->
@ -59,11 +59,11 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
private fun modelToVertexData(model: NinjaModel, matrix: Matrix) =
when (model) {
is NjcmModel -> njcmModelToVertexData(model, matrix)
is NjModel -> njModelToVertexData(model, matrix)
is XjModel -> xjModelToVertexData(model, matrix)
}
private fun njcmModelToVertexData(model: NjcmModel, matrix: Matrix) {
private fun njModelToVertexData(model: NjModel, matrix: Matrix) {
val normalMatrix = Matrix.Identity()
matrix.toNormalMatrix(normalMatrix)
@ -93,7 +93,7 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
var i = 0
for (meshVertex in mesh.vertices) {
val vertices = vertexHolder.get(meshVertex.index.toInt())
val vertices = vertexHolder.get(meshVertex.index)
if (vertices.isEmpty()) {
logger.debug {
@ -112,7 +112,7 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
)
if (i >= 2) {
if (i % 2 == if (mesh.clockwiseWinding) 1 else 0) {
if (i % 2 == if (mesh.clockwiseWinding) 0 else 1) {
builder.addIndex(index - 2)
builder.addIndex(index - 1)
builder.addIndex(index)
@ -151,7 +151,87 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
}
}
private fun xjModelToVertexData(model: XjModel, matrix: Matrix) {}
private fun xjModelToVertexData(model: XjModel, matrix: Matrix) {
val indexOffset = builder.vertexCount
val normalMatrix = Matrix.Identity()
matrix.toNormalMatrix(normalMatrix)
for (vertex in model.vertices) {
val p = vec3ToBabylon(vertex.position)
Vector3.TransformCoordinatesToRef(p, matrix, p)
val n = vertex.normal?.let(::vec3ToBabylon) ?: Vector3.Up()
Vector3.TransformCoordinatesToRef(n, normalMatrix, n)
val uv = vertex.uv?.let(::vec2ToBabylon) ?: DEFAULT_UV
builder.addVertex(p, n, uv)
}
var currentMatIdx: Int? = null
var currentSrcAlpha: Int? = null
var currentDstAlpha: Int? = null
for (mesh in model.meshes) {
val startIndexCount = builder.indexCount
var clockwise = true
for (j in 2 until mesh.indices.size) {
val a = indexOffset + mesh.indices[j - 2]
val b = indexOffset + mesh.indices[j - 1]
val c = indexOffset + mesh.indices[j]
val pa = builder.getPosition(a)
val pb = builder.getPosition(b)
val pc = builder.getPosition(c)
val na = builder.getNormal(a)
val nb = builder.getNormal(b)
val nc = builder.getNormal(c)
// Calculate a surface normal and reverse the vertex winding if at least 2 of the
// vertex normals point in the opposite direction. This hack fixes the winding for
// most models.
val normal = pb.subtract(pa).cross(pc.subtract(pa))
if (!clockwise) {
normal.negateInPlace()
}
val oppositeCount =
(if (Vector3.Dot(normal, na) < 0) 1 else 0) +
(if (Vector3.Dot(normal, nb) < 0) 1 else 0) +
(if (Vector3.Dot(normal, nc) < 0) 1 else 0)
if (oppositeCount >= 2) {
clockwise = !clockwise
}
if (clockwise) {
builder.addIndex(b)
builder.addIndex(a)
builder.addIndex(c)
} else {
builder.addIndex(a)
builder.addIndex(b)
builder.addIndex(c)
}
clockwise = !clockwise
}
mesh.material.textureId?.let { currentMatIdx = it }
mesh.material.srcAlpha?.let { currentSrcAlpha = it }
mesh.material.dstAlpha?.let { currentDstAlpha = it }
// TODO: support multiple materials
// builder.addGroup(
// start_index_count,
// this.builder.index_count - start_index_count,
// current_mat_idx,
// true,
// current_src_alpha !== 4 || current_dst_alpha !== 5,
// );
}
}
}
private class Vertex(
@ -164,21 +244,21 @@ private class Vertex(
)
private class VertexHolder {
private val stack = mutableListOf<MutableList<Vertex>>()
private val buffer = mutableListOf<MutableList<Vertex>>()
fun add(vertices: List<Vertex?>) {
vertices.forEachIndexed { i, vertex ->
if (i >= stack.size) {
stack.add(mutableListOf())
if (i >= buffer.size) {
buffer.add(mutableListOf())
}
if (vertex != null) {
stack[i].add(vertex)
buffer[i].add(vertex)
}
}
}
fun get(index: Int): List<Vertex> = stack[index]
fun get(index: Int): List<Vertex> = buffer[index]
}
private fun eulerToQuat(angles: Vec3, zxyRotationOrder: Boolean): Quaternion {

View File

@ -21,6 +21,12 @@ class VertexDataBuilder {
val indexCount: Int
get() = indices.size
fun getPosition(index: Int): Vector3 =
positions[index]
fun getNormal(index: Int): Vector3 =
normals[index]
fun addVertex(position: Vector3, normal: Vector3, uv: Vector2) {
positions.add(position)
normals.add(normal)
@ -48,6 +54,9 @@ class VertexDataBuilder {
// }
fun build(): VertexData {
check(this.positions.size == this.normals.size)
check(this.positions.size == this.uvs.size)
val positions = Float32Array(3 * positions.size)
val normals = Float32Array(3 * normals.size)
val uvs = Float32Array(2 * uvs.size)

View File

@ -12,13 +12,23 @@ class RendererWidget(
scope: CoroutineScope,
private val createRenderer: (HTMLCanvasElement) -> Renderer,
) : Widget(scope) {
private var renderer: Renderer? = null
override fun Node.createElement() =
canvas {
className = "pw-core-renderer"
tabIndex = -1
observeResize()
addDisposable(createRenderer(this))
renderer = addDisposable(createRenderer(this))
observe(selfOrAncestorHidden) { hidden ->
if (hidden) {
renderer?.stopRendering()
} else {
renderer?.startRendering()
}
}
}
override fun resized(width: Double, height: Double) {

View File

@ -1,6 +1,6 @@
@file:JsModule("@babylonjs/core")
@file:JsNonModule
@file:Suppress("FunctionName", "unused")
@file:Suppress("FunctionName", "unused", "CovariantEquals")
package world.phantasmal.web.externals.babylon
@ -13,13 +13,17 @@ external class Vector2(x: Double, y: Double) {
var y: Double
fun addInPlace(otherVector: Vector2): Vector2
fun addInPlaceFromFloats(x: Double, y: Double): Vector2
fun subtract(otherVector: Vector2): Vector2
fun negate(): Vector2
fun negateInPlace(): Vector2
fun clone(): Vector2
fun copyFrom(source: Vector2): Vector2
fun equals(otherVector: Vector2): Boolean
companion object {
fun Zero(): Vector2
fun Dot(left: Vector2, right: Vector2): Double
}
}
@ -29,17 +33,22 @@ external class Vector3(x: Double, y: Double, z: Double) {
var z: Double
fun toQuaternion(): Quaternion
fun addInPlace(otherVector: Vector3): Vector3
fun addInPlaceFromFloats(x: Double, y: Double, z: Double): Vector3
fun subtract(otherVector: Vector3): Vector3
fun negate(): Vector3
fun negateInPlace(): Vector3
fun cross(other: Vector3): Vector3
fun rotateByQuaternionToRef(quaternion: Quaternion, result: Vector3): Vector3
fun clone(): Vector3
fun copyFrom(source: Vector3): Vector3
fun equals(otherVector: Vector3): Boolean
companion object {
fun One(): Vector3
fun Up(): Vector3
fun Zero(): Vector3
fun Dot(left: Vector3, right: Vector3): Double
fun TransformCoordinates(vector: Vector3, transformation: Matrix): Vector3
fun TransformCoordinatesToRef(vector: Vector3, transformation: Matrix, result: Vector3)
fun TransformNormal(vector: Vector3, transformation: Matrix): Vector3
@ -71,6 +80,9 @@ external class Quaternion(
*/
fun multiplyToRef(q1: Quaternion, result: Quaternion): Quaternion
fun clone(): Quaternion
fun copyFrom(other: Quaternion): Quaternion
companion object {
fun Identity(): Quaternion
fun FromEulerAngles(x: Double, y: Double, z: Double): Quaternion
@ -82,6 +94,8 @@ external class Matrix {
fun multiply(other: Matrix): Matrix
fun multiplyToRef(other: Matrix, result: Matrix): Matrix
fun toNormalMatrix(ref: Matrix)
fun copyFrom(other: Matrix): Matrix
fun equals(value: Matrix): Boolean
companion object {
fun Identity(): Matrix
@ -89,6 +103,27 @@ external class Matrix {
}
}
external class EventState
external class Observable<T> {
fun add(
callback: (eventData: T, eventState: EventState) -> Unit,
mask: Int = definedExternally,
insertFirst: Boolean = definedExternally,
scope: Any = definedExternally,
unregisterOnFirstCall: Boolean = definedExternally,
): Observer<T>?
fun remove(observer: Observer<T>?): Boolean
fun removeCallback(
callback: (eventData: T, eventState: EventState) -> Unit,
scope: Any = definedExternally,
): Boolean
}
external class Observer<T>
open external class ThinEngine {
val description: String
@ -98,6 +133,13 @@ open external class ThinEngine {
*/
fun runRenderLoop(renderFunction: () -> Unit)
/**
* stop executing a render loop function and remove it from the execution array
* @param renderFunction defines the function to be removed. If not provided all functions will
* be removed.
*/
fun stopRenderLoop(renderFunction: () -> Unit = definedExternally)
fun dispose()
}
@ -107,6 +149,8 @@ external class Engine(
) : ThinEngine
external class Scene(engine: Engine) {
var clearColor: Color4
fun render()
fun addLight(light: Light)
fun addMesh(newMesh: AbstractMesh, recursive: Boolean? = definedExternally)
@ -120,11 +164,11 @@ external class Scene(engine: Engine) {
open external class Node {
var metadata: Any?
var parent: Node?
var position: Vector3
var rotation: Vector3
var scaling: Vector3
fun setEnabled(value: Boolean)
fun getViewMatrix(force: Boolean = definedExternally): Matrix
fun getProjectionMatrix(force: Boolean = definedExternally): Matrix
fun getTransformationMatrix(): Matrix
/**
* Releases resources associated with this node.
@ -138,6 +182,11 @@ open external class Node {
}
open external class Camera : Node {
val absoluteRotation: Quaternion
val onProjectionMatrixChangedObservable: Observable<Camera>
val onViewMatrixChangedObservable: Observable<Camera>
val onAfterCheckInputsObservable: Observable<Camera>
fun attachControl(noPreventDefault: Boolean = definedExternally)
}
@ -174,16 +223,25 @@ external class ArcRotateCamera(
abstract external class Light : Node
external class HemisphericLight(name: String, direction: Vector3, scene: Scene) : Light
external class HemisphericLight(name: String, direction: Vector3, scene: Scene) : Light {
var direction: Vector3
}
open external class TransformNode(
name: String,
scene: Scene? = definedExternally,
isPure: Boolean = definedExternally,
) : Node {
var position: Vector3
var rotation: Vector3
var rotationQuaternion: Quaternion?
val absoluteRotation: Quaternion
var scaling: Vector3
}
abstract external class AbstractMesh : TransformNode
abstract external class AbstractMesh : TransformNode {
fun getBoundingInfo(): BoundingInfo
}
external class Mesh(
name: String,
@ -198,6 +256,34 @@ external class Mesh(
external class InstancedMesh : AbstractMesh
external class BoundingInfo {
val boundingBox: BoundingBox
val boundingSphere: BoundingSphere
}
external class BoundingBox {
val center: Vector3
val centerWorld: Vector3
val directions: Array<Vector3>
val extendSize: Vector3
val extendSizeWorld: Vector3
val maximum: Vector3
val maximumWorld: Vector3
val minimum: Vector3
val minimumWorld: Vector3
val vectors: Array<Vector3>
val vectorsWorld: Array<Vector3>
}
external class BoundingSphere {
val center: Vector3
val centerWorld: Vector3
val maximum: Vector3
val minimum: Vector3
val radius: Double
val radiusWorld: Double
}
external class MeshBuilder {
companion object {
interface CreateCylinderOptions {
@ -226,3 +312,25 @@ external class VertexData {
fun applyToMesh(mesh: Mesh, updatable: Boolean = definedExternally): VertexData
}
external class Color3(
r: Double = definedExternally,
g: Double = definedExternally,
b: Double = definedExternally,
) {
var r: Double
var g: Double
var b: Double
}
external class Color4(
r: Double = definedExternally,
g: Double = definedExternally,
b: Double = definedExternally,
a: Double = definedExternally,
) {
var r: Double
var g: Double
var b: Double
var a: Double
}

View File

@ -18,9 +18,9 @@ class HuntOptimizer(
) : DisposableContainer() {
private val huntMethodStore = addDisposable(HuntMethodStore(scope, uiStore, assetLoader))
private val huntOptimizerController = addDisposable(HuntOptimizerController(scope, uiStore))
private val huntOptimizerController = addDisposable(HuntOptimizerController(uiStore))
private val methodsController =
addDisposable(MethodsController(scope, uiStore, huntMethodStore))
addDisposable(MethodsController(uiStore, huntMethodStore))
fun createWidget(): Widget =
HuntOptimizerWidget(

View File

@ -1,15 +1,13 @@
package world.phantasmal.web.huntOptimizer.controllers
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.web.core.controllers.PathAwareTab
import world.phantasmal.web.core.controllers.PathAwareTabController
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.huntOptimizer.HuntOptimizerUrls
class HuntOptimizerController(scope: CoroutineScope, uiStore: UiStore) :
class HuntOptimizerController(uiStore: UiStore) :
PathAwareTabController<PathAwareTab>(
scope,
uiStore,
PwTool.HuntOptimizer,
listOf(

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.huntOptimizer.controllers
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.MutableListVal
@ -16,11 +15,9 @@ import world.phantasmal.web.huntOptimizer.stores.HuntMethodStore
class MethodsTab(title: String, path: String, val episode: Episode) : PathAwareTab(title, path)
class MethodsController(
scope: CoroutineScope,
uiStore: UiStore,
huntMethodStore: HuntMethodStore,
) : PathAwareTabController<MethodsTab>(
scope,
uiStore,
PwTool.HuntOptimizer,
listOf(

View File

@ -29,9 +29,9 @@ class QuestEditor(
// Controllers
private val toolbarController =
addDisposable(QuestEditorToolbarController(scope, questLoader, questEditorStore))
private val questInfoController = addDisposable(QuestInfoController(scope, questEditorStore))
private val npcCountsController = addDisposable(NpcCountsController(scope, questEditorStore))
addDisposable(QuestEditorToolbarController(questLoader, questEditorStore))
private val questInfoController = addDisposable(QuestInfoController(questEditorStore))
private val npcCountsController = addDisposable(NpcCountsController(questEditorStore))
fun createWidget(): Widget =
QuestEditorWidget(

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.controllers
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.lib.fileFormats.quest.NpcType
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.list.emptyListVal
@ -8,7 +7,7 @@ import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.webui.controllers.Controller
class NpcCountsController(scope: CoroutineScope, store: QuestEditorStore) : Controller(scope) {
class NpcCountsController(store: QuestEditorStore) : Controller() {
val unavailable: Val<Boolean> = store.currentQuest.map { it == null }
val npcCounts: Val<List<NameWithCount>> = store.currentQuest

View File

@ -1,6 +1,5 @@
package world.phantasmal.web.questEditor.controllers
import kotlinx.coroutines.CoroutineScope
import mu.KotlinLogging
import org.w3c.files.File
import world.phantasmal.core.*
@ -21,10 +20,9 @@ import world.phantasmal.webui.readFile
private val logger = KotlinLogging.logger {}
class QuestEditorToolbarController(
scope: CoroutineScope,
private val questLoader: QuestLoader,
private val questEditorStore: QuestEditorStore,
) : Controller(scope) {
) : Controller() {
private val _resultDialogVisible = mutableVal(false)
private val _result = mutableVal<PwResult<*>?>(null)
@ -72,7 +70,7 @@ class QuestEditorToolbarController(
setCurrentQuest(parseResult.value)
}
}
} catch (e: Throwable) {
} catch (e: Exception) {
setResult(
PwResult.build<Nothing>(logger)
.addProblem(Severity.Error, "Couldn't parse file.", cause = e)

View File

@ -1,12 +1,11 @@
package world.phantasmal.web.questEditor.controllers
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.value
import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.webui.controllers.Controller
class QuestInfoController(scope: CoroutineScope, store: QuestEditorStore) : Controller(scope) {
class QuestInfoController(store: QuestEditorStore) : Controller() {
val unavailable: Val<Boolean> = store.currentQuest.map { it == null }
val disabled: Val<Boolean> = store.questEditingDisabled

View File

@ -54,7 +54,7 @@ class EntityAssetLoader(
mesh
}
} ?: defaultMesh
} catch (e: Throwable) {
} catch (e: Exception) {
logger.error(e) { "Couldn't load mesh for $type (model: $model)." }
defaultMesh
}

View File

@ -102,7 +102,6 @@ class EntityMeshManager(
val disposer = Disposer(
entity.worldPosition.observe { (pos) ->
mesh.position = pos
renderer.scheduleRender()
},
// TODO: Rotation.
@ -126,7 +125,6 @@ class EntityMeshManager(
}
.observe(callNow = true) { (visible) ->
mesh.setEnabled(visible)
renderer.scheduleRender()
},
)
}

View File

@ -15,8 +15,8 @@ class QuestRenderer(
private val meshManager = createMeshManager(this, scene)
private var entityMeshes = TransformNode("Entities", scene)
private val entityToMesh = mutableMapOf<QuestEntityModel<*, *>, AbstractMesh>()
private val camera = ArcRotateCamera("Camera", 0.0, PI / 6, 500.0, Vector3.Zero(), scene)
private val light = HemisphericLight("Light", Vector3(1.0, 1.0, 0.0), scene)
override val camera = ArcRotateCamera("Camera", 0.0, PI / 6, 500.0, Vector3.Zero(), scene)
init {
with(camera) {
@ -41,8 +41,6 @@ class QuestRenderer(
meshManager.dispose()
entityMeshes.dispose()
entityToMesh.clear()
camera.dispose()
light.dispose()
super.internalDispose()
}
@ -51,7 +49,6 @@ class QuestRenderer(
entityToMesh.clear()
entityMeshes = TransformNode("Entities", scene)
scheduleRender()
}
fun addEntityMesh(mesh: AbstractMesh) {
@ -69,15 +66,12 @@ class QuestRenderer(
// if (entity === this.selected_entity) {
// this.mark_selected(model)
// }
this.scheduleRender()
}
fun removeEntityMesh(entity: QuestEntityModel<*, *>) {
entityToMesh.remove(entity)?.let { mesh ->
mesh.parent = null
mesh.dispose()
this.scheduleRender()
}
}
}

View File

@ -0,0 +1,29 @@
package world.phantasmal.web.viewer
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.viewer.controller.ViewerToolbarController
import world.phantasmal.web.viewer.rendering.MeshRenderer
import world.phantasmal.web.viewer.store.ViewerStore
import world.phantasmal.web.viewer.widgets.ViewerToolbar
import world.phantasmal.web.viewer.widgets.ViewerWidget
import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.widgets.Widget
class Viewer(
private val scope: CoroutineScope,
private val createEngine: (HTMLCanvasElement) -> Engine,
) : DisposableContainer() {
// Stores
private val viewerStore = addDisposable(ViewerStore(scope))
// Controllers
private val viewerToolbarController = addDisposable(ViewerToolbarController(viewerStore))
fun createWidget(): Widget =
ViewerWidget(scope, ViewerToolbar(scope, viewerToolbarController), ::createViewerRenderer)
private fun createViewerRenderer(canvas: HTMLCanvasElement): MeshRenderer =
MeshRenderer(viewerStore, canvas, createEngine(canvas))
}

View File

@ -0,0 +1,75 @@
package world.phantasmal.web.viewer.controller
import mu.KotlinLogging
import org.w3c.files.File
import world.phantasmal.core.PwResult
import world.phantasmal.core.Severity
import world.phantasmal.core.Success
import world.phantasmal.lib.Endianness
import world.phantasmal.lib.cursor.cursor
import world.phantasmal.lib.fileFormats.ninja.parseNj
import world.phantasmal.lib.fileFormats.ninja.parseXj
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.viewer.store.ViewerStore
import world.phantasmal.webui.controllers.Controller
import world.phantasmal.webui.readFile
private val logger = KotlinLogging.logger {}
class ViewerToolbarController(private val store: ViewerStore) : Controller() {
private val _resultDialogVisible = mutableVal(false)
private val _result = mutableVal<PwResult<*>?>(null)
val resultDialogVisible: Val<Boolean> = _resultDialogVisible
val result: Val<PwResult<*>?> = _result
suspend fun openFiles(files: List<File>) {
var modelFileFound = false
val result = PwResult.build<Nothing>(logger)
try {
for (file in files) {
if (file.name.endsWith(".nj", ignoreCase = true)) {
if (modelFileFound) continue
modelFileFound = true
val njResult = parseNj(readFile(file).cursor(Endianness.Little))
result.addResult(njResult)
if (njResult is Success) {
store.setCurrentNinjaObject(njResult.value.firstOrNull())
}
} else if (file.name.endsWith(".xj", ignoreCase = true)) {
if (modelFileFound) continue
modelFileFound = true
val xjResult = parseXj(readFile(file).cursor(Endianness.Little))
result.addResult(xjResult)
if (xjResult is Success) {
store.setCurrentNinjaObject(xjResult.value.firstOrNull())
}
} else {
result.addProblem(
Severity.Error,
"""File "${file.name}" has an unsupported file type."""
)
}
}
} catch (e: Exception) {
result.addProblem(Severity.Error, "Couldn't parse files.", cause = e)
}
// Set failure result, because setResult doesn't care about the type.
setResult(result.failure())
}
private fun setResult(result: PwResult<*>) {
_result.value = result
if (result.problems.isNotEmpty()) {
_resultDialogVisible.value = true
}
}
}

View File

@ -0,0 +1,62 @@
package world.phantasmal.web.viewer.rendering
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.lib.fileFormats.ninja.NinjaObject
import world.phantasmal.web.core.rendering.Renderer
import world.phantasmal.web.core.rendering.conversion.ninjaObjectToVertexData
import world.phantasmal.web.externals.babylon.*
import world.phantasmal.web.viewer.store.ViewerStore
import kotlin.math.PI
class MeshRenderer(
store: ViewerStore,
canvas: HTMLCanvasElement,
engine: Engine,
) : Renderer(canvas, engine) {
private var mesh: Mesh? = null
override val camera = ArcRotateCamera("Camera", 0.0, PI / 3, 70.0, Vector3.Zero(), scene)
init {
with(camera) {
attachControl(
canvas,
noPreventDefault = false,
useCtrlForPanning = false,
panningMouseButton = 0
)
inertia = 0.0
angularSensibilityX = 200.0
angularSensibilityY = 200.0
panningInertia = 0.0
panningSensibility = 20.0
panningAxis = Vector3(1.0, 1.0, 0.0)
pinchDeltaPercentage = 0.1
wheelDeltaPercentage = 0.1
}
observe(store.currentNinjaObject, ::ninjaObjectOrXvmChanged)
}
override fun internalDispose() {
mesh?.dispose()
super.internalDispose()
}
private fun ninjaObjectOrXvmChanged(ninjaObject: NinjaObject<*>?) {
mesh?.dispose()
if (ninjaObject != null) {
val mesh = Mesh("Model", scene)
val vertexData = ninjaObjectToVertexData(ninjaObject)
vertexData.applyToMesh(mesh)
// Make sure we rotate around the center of the model instead of its origin.
val bb = mesh.getBoundingInfo().boundingBox
val height = bb.maximum.y - bb.minimum.y
mesh.position = mesh.position.addInPlaceFromFloats(0.0, -height / 2 - bb.minimum.y, 0.0)
this.mesh = mesh
}
}
}

View File

@ -0,0 +1,17 @@
package world.phantasmal.web.viewer.store
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.lib.fileFormats.ninja.NinjaObject
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.webui.stores.Store
class ViewerStore(scope: CoroutineScope) : Store(scope) {
private val _currentNinjaObject = mutableVal<NinjaObject<*>?>(null)
val currentNinjaObject: Val<NinjaObject<*>?> = _currentNinjaObject
fun setCurrentNinjaObject(ninjaObject: NinjaObject<*>?) {
_currentNinjaObject.value = ninjaObject
}
}

View File

@ -0,0 +1,35 @@
package world.phantasmal.web.viewer.widgets
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.w3c.dom.Node
import world.phantasmal.web.viewer.controller.ViewerToolbarController
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.FileButton
import world.phantasmal.webui.widgets.Toolbar
import world.phantasmal.webui.widgets.Widget
class ViewerToolbar(
scope: CoroutineScope,
private val ctrl: ViewerToolbarController,
) : Widget(scope) {
override fun Node.createElement() =
div {
className = "pw-viewer-toolbar"
addChild(Toolbar(
scope,
children = listOf(
FileButton(
scope,
text = "Open file...",
iconLeft = Icon.File,
accept = ".afs, .nj, .njm, .xj, .xvm",
multiple = true,
filesSelected = { files -> scope.launch { ctrl.openFiles(files) } }
)
)
))
}
}

View File

@ -0,0 +1,45 @@
package world.phantasmal.web.viewer.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.Node
import world.phantasmal.web.core.rendering.Renderer
import world.phantasmal.web.core.widgets.RendererWidget
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget
class ViewerWidget(
scope: CoroutineScope,
private val toolbar: Widget,
private val createRenderer: (HTMLCanvasElement) -> Renderer,
) : Widget(scope) {
override fun Node.createElement() =
div {
className = "pw-viewer-viewer"
addChild(toolbar)
div {
className = "pw-viewer-viewer-container"
addChild(RendererWidget(scope, createRenderer))
}
}
companion object {
init {
@Suppress("CssUnusedSymbol")
// language=css
style("""
.pw-viewer-viewer {
display: flex;
flex-direction: column;
}
.pw-viewer-viewer-container {
flex-grow: 1;
display: flex;
flex-direction: row;
}
""".trimIndent())
}
}
}

View File

@ -1,8 +1,5 @@
package world.phantasmal.webui.controllers
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.webui.DisposableContainer
abstract class Controller(protected val scope: CoroutineScope) :
DisposableContainer(),
CoroutineScope by scope
abstract class Controller : DisposableContainer()

View File

@ -1,6 +1,5 @@
package world.phantasmal.webui.controllers
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.observable.value.MutableVal
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
@ -9,7 +8,7 @@ interface Tab {
val title: String
}
open class TabController<T : Tab>(scope: CoroutineScope, val tabs: List<T>) : Controller(scope) {
open class TabController<T : Tab>(val tabs: List<T>) : Controller() {
private val _activeTab: MutableVal<T?> = mutableVal(tabs.firstOrNull())
val activeTab: Val<T?> = _activeTab