From 7042c53678f8c2f9ca7b83d30e33d25d81133d9c Mon Sep 17 00:00:00 2001 From: Lanius Trolling Date: Thu, 26 Dec 2024 15:31:13 -0500 Subject: [PATCH] Add fullscreen model view --- src/main/kotlin/info/mechyrdia/Factbooks.kt | 1 + .../info/mechyrdia/data/ViewsComment.kt | 6 +- .../kotlin/info/mechyrdia/lore/ParserHtml.kt | 13 ++++ .../kotlin/info/mechyrdia/lore/ParserRaw.kt | 10 ++- .../kotlin/info/mechyrdia/lore/ViewsMesh.kt | 66 +++++++++++++++++ .../kotlin/info/mechyrdia/lore/ViewsQuote.kt | 5 +- .../info/mechyrdia/route/ResourceLimiter.kt | 29 ++++++++ .../info/mechyrdia/route/ResourceTypes.kt | 39 ++++++---- src/main/resources/static/admin.js | 2 +- src/main/resources/static/init.js | 45 +++++------ src/main/resources/static/mesh.css | 9 +++ src/main/resources/static/mesh.js | 74 +++++++++++++++++++ 12 files changed, 254 insertions(+), 45 deletions(-) create mode 100644 src/main/kotlin/info/mechyrdia/lore/ViewsMesh.kt create mode 100644 src/main/kotlin/info/mechyrdia/route/ResourceLimiter.kt create mode 100644 src/main/resources/static/mesh.css create mode 100644 src/main/resources/static/mesh.js diff --git a/src/main/kotlin/info/mechyrdia/Factbooks.kt b/src/main/kotlin/info/mechyrdia/Factbooks.kt index 20e0bbc..c4094a3 100644 --- a/src/main/kotlin/info/mechyrdia/Factbooks.kt +++ b/src/main/kotlin/info/mechyrdia/Factbooks.kt @@ -250,6 +250,7 @@ fun Application.factbooks() { get() get() get() + get() get() get() get() diff --git a/src/main/kotlin/info/mechyrdia/data/ViewsComment.kt b/src/main/kotlin/info/mechyrdia/data/ViewsComment.kt index 280fafd..174a48e 100644 --- a/src/main/kotlin/info/mechyrdia/data/ViewsComment.kt +++ b/src/main/kotlin/info/mechyrdia/data/ViewsComment.kt @@ -223,7 +223,7 @@ suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commentin td { +"[u]Text goes here[/u]" } td { span { - style = "text-decoration: underline" + style = "text-decoration:underline" +"Underlines" } +" text" @@ -233,7 +233,7 @@ suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commentin td { +"[s]Text goes here[/s]" } td { span { - style = "text-decoration: line-through" + style = "text-decoration:line-through" +"Strikes out" } +" text" @@ -306,7 +306,7 @@ suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commentin td { +"e.g. [align=center]Text goes here[/align]" } td { div { - style = "text-align: center" + style = "text-align:center" +"Center-aligns text" } } diff --git a/src/main/kotlin/info/mechyrdia/lore/ParserHtml.kt b/src/main/kotlin/info/mechyrdia/lore/ParserHtml.kt index fea9d1e..f7cddf7 100644 --- a/src/main/kotlin/info/mechyrdia/lore/ParserHtml.kt +++ b/src/main/kotlin/info/mechyrdia/lore/ParserHtml.kt @@ -118,11 +118,13 @@ object HtmlLexerProcessor : LexerTagFallback, tag: String, param: String?, subNodes: ParserTree): HtmlBuilderSubject { @@ -457,6 +459,17 @@ enum class FactbookFormattingTag(val type: HtmlLexerTag) { style = sizeStyle attributes["data-model"] = url } + br() + a(href = "/mesh/$url") { + style = "font-size:0.8em" + +"View this model in fullscreen" + } + span { + style = "font-size:1.2em" + br + +Entities.nbsp + br + } }) }), AUDIO(HtmlTextBodyLexerTag { _, _, content -> diff --git a/src/main/kotlin/info/mechyrdia/lore/ParserRaw.kt b/src/main/kotlin/info/mechyrdia/lore/ParserRaw.kt index 28d9cde..c0b5b39 100644 --- a/src/main/kotlin/info/mechyrdia/lore/ParserRaw.kt +++ b/src/main/kotlin/info/mechyrdia/lore/ParserRaw.kt @@ -109,7 +109,15 @@ enum class RawFactbookFormattingTag(val type: HtmlLexerTag) { } }) }), - MODEL(HtmlNotSupportedInRawViewTag("Unfortunately, raw view does not support interactive 3D model views")), + MODEL(HtmlTextBodyLexerTag { _, _, content -> + val url = content.sanitizeLink() + + ({ + a(href = "/mesh/$url") { + +"View interactive 3D model in separate page" + } + }) + }), QUIZ(HtmlNotSupportedInRawViewTag("Unfortunately, raw view does not support interactive quizzes")), MOMENT(HtmlTextBodyLexerTag { _, _, content -> val epochMilli = content.toLongOrNull()?.let { Instant.ofEpochMilli(it).toString() } ?: content diff --git a/src/main/kotlin/info/mechyrdia/lore/ViewsMesh.kt b/src/main/kotlin/info/mechyrdia/lore/ViewsMesh.kt new file mode 100644 index 0000000..c94f2d1 --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/lore/ViewsMesh.kt @@ -0,0 +1,66 @@ +package info.mechyrdia.lore + +import info.mechyrdia.MainDomainName +import info.mechyrdia.Utf8 +import info.mechyrdia.data.FileStorage +import info.mechyrdia.data.StoragePath +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.path +import kotlinx.html.* + +private val meshDir = StoragePath.assetDir / "meshes" + +suspend fun ApplicationCall.meshView(mesh: String): HTML.() -> Unit { + val pageTitle = "Viewing Mesh $mesh" + + val meshObj = String(FileStorage.instance.readFile(meshDir / "$mesh.obj")!!, Utf8) + val meshMtl = String(FileStorage.instance.readFile(meshDir / "$mesh.mtl")!!, Utf8) + + return { + lang = "en" + + head { + meta(charset = "utf-8") + meta(name = "viewport", content = "width=device-width, initial-scale=1.0") + + meta(name = "theme-color", content = "#FFCC33") + + ogProperty("title", pageTitle) + ogProperty("type", "website") + ogProperty("url", "$MainDomainName/${request.path().removePrefix("/")}") + + link(rel = "icon", type = "image/png", href = "/static/images/icon.png") + link(rel = "icon", type = "image/svg+xml", href = "/static/images/icon.svg") + + title { +pageTitle } + + script(src = "/static/obj-viewer/three.js") {} + script(src = "/static/obj-viewer/three-examples.js") {} + + script(type = "text/plain") { + attributes["id"] = "mesh-mtl" + unsafe { + +"\n" + +meshMtl + +"\n" + } + } + + script(type = "text/plain") { + attributes["id"] = "mesh-obj" + unsafe { + +"\n" + +meshObj + +"\n" + } + } + + link(rel = "stylesheet", type = "text/css", href = "/static/mesh.css") + + script(src = "/static/mesh.js") {} + } + body { + canvas {} + } + } +} diff --git a/src/main/kotlin/info/mechyrdia/lore/ViewsQuote.kt b/src/main/kotlin/info/mechyrdia/lore/ViewsQuote.kt index 26b94d2..55d6e5c 100644 --- a/src/main/kotlin/info/mechyrdia/lore/ViewsQuote.kt +++ b/src/main/kotlin/info/mechyrdia/lore/ViewsQuote.kt @@ -11,7 +11,6 @@ import info.mechyrdia.data.respondXml import info.mechyrdia.data.root import info.mechyrdia.route.KeyedEnumSerializer import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall import io.ktor.server.html.respondHtml import io.ktor.server.response.respondText @@ -55,12 +54,12 @@ suspend fun randomQuote(): Quote = getQuotesList().random() enum class QuoteFormat(val format: String?) { HTML(null) { override suspend fun ApplicationCall.respondQuote(quote: Quote) { - respondHtml(HttpStatusCode.OK, block = quote.toHtml(RANDOM_QUOTE_HTML_TITLE, this)) + respondHtml(block = quote.toHtml(RANDOM_QUOTE_HTML_TITLE, this)) } }, RAW_HTML("raw") { override suspend fun ApplicationCall.respondQuote(quote: Quote) { - respondHtml(HttpStatusCode.OK, block = quote.toRawHtml(RANDOM_QUOTE_HTML_TITLE, this)) + respondHtml(block = quote.toRawHtml(RANDOM_QUOTE_HTML_TITLE, this)) } }, JSON("json") { diff --git a/src/main/kotlin/info/mechyrdia/route/ResourceLimiter.kt b/src/main/kotlin/info/mechyrdia/route/ResourceLimiter.kt new file mode 100644 index 0000000..a47174d --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/route/ResourceLimiter.kt @@ -0,0 +1,29 @@ +package info.mechyrdia.route + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.produce +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class TickingLimiter @OptIn(DelicateCoroutinesApi::class) constructor(interval: Duration, scope: CoroutineScope = GlobalScope) { + @OptIn(ExperimentalCoroutinesApi::class) + private val producer = scope.produce(capacity = Channel.RENDEZVOUS) { + while (currentCoroutineContext().isActive) { + send(Unit) + delay(interval) + } + } + + suspend fun limit() = producer.receive() +} + +object ResourceLimiters { + val utils = TickingLimiter(75.milliseconds) +} diff --git a/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt b/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt index 24da667..8898895 100644 --- a/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt +++ b/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt @@ -49,6 +49,7 @@ import info.mechyrdia.lore.generateRecentPageEdits import info.mechyrdia.lore.loadFontsJson import info.mechyrdia.lore.loreArticlePage import info.mechyrdia.lore.loreIntroPage +import info.mechyrdia.lore.meshView import info.mechyrdia.lore.parseAs import info.mechyrdia.lore.randomQuote import info.mechyrdia.lore.recentCommentsRssFeedGenerator @@ -76,7 +77,6 @@ import io.ktor.server.response.respondTextWriter import io.ktor.server.routing.RoutingContext import io.ktor.server.websocket.DefaultWebSocketServerSession import io.ktor.util.AttributeKey -import kotlinx.coroutines.delay const val ErrorMessageCookieName = "ERROR_MSG" @@ -90,7 +90,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { call.filterCall() - call.respondHtml(HttpStatusCode.OK, call.loreIntroPage()) + call.respondHtml(block = call.loreIntroPage()) } @Resource("assets/{path...}") @@ -119,7 +119,16 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { with(root) { call.filterCall() } - call.respondHtml(HttpStatusCode.OK, call.loreArticlePage(path, format)) + call.respondHtml(block = call.loreArticlePage(path, format)) + } + } + + @Resource("mesh/{mesh}") + class MeshView(val mesh: String, val root: Root = Root()) : ResourceHandler { + override suspend fun RoutingContext.handleCall() { + with(root) { call.filterCall() } + + call.respondHtml(block = call.meshView(mesh)) } } @@ -185,7 +194,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { with(root) { call.filterCall() } - call.respondHtml(HttpStatusCode.OK, call.clientSettingsPage()) + call.respondHtml(block = call.clientSettingsPage()) } } @@ -200,7 +209,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { with(auth) { call.filterCall() } - call.respondHtml(HttpStatusCode.OK, call.loginPage()) + call.respondHtml(block = call.loginPage()) } } @@ -233,7 +242,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { call.filterCall() - call.respondHtml(HttpStatusCode.OK, call.robotPage()) + call.respondHtml(block = call.robotPage()) } @Resource("ws") @@ -257,7 +266,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { with(comments) { call.filterCall() } - call.respondHtml(HttpStatusCode.OK, call.commentHelpPage()) + call.respondHtml(block = call.commentHelpPage()) } } @@ -266,7 +275,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { with(comments) { call.filterCall() } - call.respondHtml(HttpStatusCode.OK, call.recentCommentsPage(limit)) + call.respondHtml(block = call.recentCommentsPage(limit)) } } @@ -304,7 +313,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { with(comments) { call.filterCall() } - call.respondHtml(HttpStatusCode.OK, call.deleteCommentPage(id)) + call.respondHtml(block = call.deleteCommentPage(id)) } } @@ -335,7 +344,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { with(user) { call.filterCall() } - call.respondHtml(HttpStatusCode.OK, call.userPage(slug)) + call.respondHtml(block = call.userPage(slug)) } } } @@ -375,7 +384,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { call.filterCall() - call.respondHtml(HttpStatusCode.OK, call.robotManagementPage()) + call.respondHtml(block = call.robotManagementPage()) } @Resource("update") @@ -434,7 +443,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { with(vfs) { call.filterCall() } - call.respondHtml(HttpStatusCode.OK, call.adminViewVfs(StoragePath(path))) + call.respondHtml(block = call.adminViewVfs(StoragePath(path))) } } @@ -443,7 +452,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { with(vfs) { call.filterCall() } - call.respondHtml(HttpStatusCode.OK, call.adminRequestWebDavToken()) + call.respondHtml(block = call.adminRequestWebDavToken()) } } @@ -472,7 +481,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun RoutingContext.handleCall() { with(vfs) { call.filterCall() } - call.respondHtml(HttpStatusCode.OK, call.adminShowCopyFile(StoragePath(path))) + call.respondHtml(block = call.adminShowCopyFile(StoragePath(path))) } } @@ -567,7 +576,7 @@ class Root : ResourceHandler, ResourceFilter { override suspend fun ApplicationCall.filterCall() { with(root) { filterCall() } - delay(250L) + ResourceLimiters.utils.limit() } @Resource("mechyrdia-sans") diff --git a/src/main/resources/static/admin.js b/src/main/resources/static/admin.js index c935f7c..2afe405 100644 --- a/src/main/resources/static/admin.js +++ b/src/main/resources/static/admin.js @@ -4,7 +4,7 @@ .split(";") .reduce((obj, entry) => { const trimmed = entry.trim(); - const eqI = trimmed.indexOf('='); + const eqI = trimmed.indexOf("="); const key = trimmed.substring(0, eqI).trimEnd(); const value = trimmed.substring(eqI + 1).trimStart(); return {...obj, [key]: value}; diff --git a/src/main/resources/static/init.js b/src/main/resources/static/init.js index 253018b..0ce652b 100644 --- a/src/main/resources/static/init.js +++ b/src/main/resources/static/init.js @@ -264,7 +264,7 @@ .split(";") .reduce((obj, entry) => { const trimmed = entry.trim(); - const eqI = trimmed.indexOf('='); + const eqI = trimmed.indexOf("="); const key = trimmed.substring(0, eqI).trimEnd(); const value = trimmed.substring(eqI + 1).trimStart(); return {...obj, [key]: value}; @@ -292,7 +292,7 @@ */ function loadScript(url) { return new Promise((resolve, reject) => { - const script = document.createElement('script'); + const script = document.createElement("script"); script.addEventListener("load", () => resolve()); script.addEventListener("error", e => reject(e)); script.src = url; @@ -496,7 +496,7 @@ const secondCell = document.createElement("td"); secondCell.style.textAlign = "center"; secondCell.appendChild(document.createElement("img")).src = quiz.image; - for (const paragraph of quiz.intro.split('\n')) { + for (const paragraph of quiz.intro.split("\n")) { secondCell.appendChild(document.createElement("p")).append(paragraph); } secondRow.appendChild(secondCell); @@ -535,7 +535,7 @@ const secondCell = document.createElement("td"); secondCell.style.textAlign = "center"; secondCell.appendChild(document.createElement("img")).src = outcome.img; - for (const paragraph of outcome.desc.split('\n')) { + for (const paragraph of outcome.desc.split("\n")) { secondCell.appendChild(document.createElement("p")).append(paragraph); } secondRow.appendChild(secondCell); @@ -696,10 +696,10 @@ for (const line of inText.split("\n")) urlParams.append("lines", line.trim()); - outBlob = await (await fetch('/utils/mechyrdia-sans', { - method: 'POST', + outBlob = await (await fetch("/utils/mechyrdia-sans", { + method: "POST", headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + "Content-Type": "application/x-www-form-urlencoded", }, body: urlParams, })).blob(); @@ -746,10 +746,10 @@ for (const line of inText.split("\n")) urlParams.append("lines", line.trim()); - const outText = await (await fetch('/utils/tylan-lang', { - method: 'POST', + const outText = await (await fetch("/utils/tylan-lang", { + method: "POST", headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + "Content-Type": "application/x-www-form-urlencoded", }, body: urlParams, })).text(); @@ -810,10 +810,10 @@ for (const line of inText.split("\n")) urlParams.append("lines", line.trim()); - const outText = await (await fetch('/utils/pokhwal-lang', { - method: 'POST', + const outText = await (await fetch("/utils/pokhwal-lang", { + method: "POST", headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + "Content-Type": "application/x-www-form-urlencoded", }, body: urlParams, })).text(); @@ -954,13 +954,14 @@ } function onResize() { - const dim = canvas.getBoundingClientRect(); - camera.aspect = dim.width / dim.height; + const {width, height} = canvas.getBoundingClientRect(); + + camera.aspect = width / height; camera.updateProjectionMatrix(); - renderer.setSize(dim.width, dim.height, false); + renderer.setSize(width, height, false); } - window.addEventListener('resize', onResize); + window.addEventListener("resize", onResize); await frame(); onResize(); @@ -1073,10 +1074,10 @@ for (const line of inText.split("\n")) urlParams.append("lines", line.trim()); - const outText = await (await fetch('/utils/preview-comment', { - method: 'POST', + const outText = await (await fetch("/utils/preview-comment", { + method: "POST", headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + "Content-Type": "application/x-www-form-urlencoded", }, body: urlParams, })).text(); @@ -1342,9 +1343,9 @@ enterBtn.disabled = true; }); - webSock.addEventListener("open", _ => { + webSock.addEventListener("open", () => { webSock.send(nukeBox.getAttribute("data-ws-csrf-token")); - }) + }); } })(); } diff --git a/src/main/resources/static/mesh.css b/src/main/resources/static/mesh.css new file mode 100644 index 0000000..3eb3e83 --- /dev/null +++ b/src/main/resources/static/mesh.css @@ -0,0 +1,9 @@ +canvas { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + + background: linear-gradient(to bottom, #081C30, #040608); +} diff --git a/src/main/resources/static/mesh.js b/src/main/resources/static/mesh.js new file mode 100644 index 0000000..e6c0c2b --- /dev/null +++ b/src/main/resources/static/mesh.js @@ -0,0 +1,74 @@ +(function () { + /** + * @return {Promise} + */ + function frame() { + return new Promise(resolve => window.requestAnimationFrame(resolve)); + } + + window.addEventListener("load", () => { + const THREE = window.THREE; + + (async () => { + const modelObj = document.getElementById("mesh-obj").text; + const modelMtl = document.getElementById("mesh-mtl").text; + + const canvas = document.querySelector("canvas"); + + const model = (() => { + const mtlLib = (new THREE.MTLLoader()).parse(modelMtl, "/assets/meshes/"); + mtlLib.preload(); + return (new THREE.OBJLoader()).setMaterials(mtlLib).parse(modelObj); + })(); + + const camera = new THREE.PerspectiveCamera(69, 1, 0.01, 1000.0); + + const scene = new THREE.Scene(); + scene.add(new THREE.AmbientLight("#555555", 1.0)); + + const renderer = new THREE.WebGLRenderer({"canvas": canvas, "antialias": true}); + + const controls = new THREE.OrbitControls(camera, canvas); + + function render() { + controls.update(); + renderer.render(scene, camera); + window.requestAnimationFrame(render); + } + + function onResize() { + const width = document.documentElement.clientWidth; + const height = document.documentElement.clientHeight; + + camera.aspect = width / height; + camera.updateProjectionMatrix(); + renderer.setSize(width, height, false); + } + + window.addEventListener("resize", onResize); + await frame(); + onResize(); + + scene.add(model); + + const bbox = new THREE.Box3().setFromObject(scene); + bbox.dimensions = { + x: bbox.max.x - bbox.min.x, + y: bbox.max.y - bbox.min.y, + z: bbox.max.z - bbox.min.z + }; + model.position.sub(new THREE.Vector3(bbox.min.x + bbox.dimensions.x / 2, bbox.min.y + bbox.dimensions.y / 2, bbox.min.z + bbox.dimensions.z / 2)); + + camera.position.set(bbox.dimensions.x / 2, bbox.dimensions.y / 2, Math.max(bbox.dimensions.x, bbox.dimensions.y, bbox.dimensions.z)); + + const light = new THREE.PointLight("#AAAAAA", 1.0); + scene.add(camera); + camera.add(light); + light.position.set(0, 0, 0); + + render(); + })().catch(reason => { + console.error("Error rendering model!", reason); + }); + }); +})(); -- 2.25.1