mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-17 00:13:17 +00:00
Single-object viewer POC (#14)
This commit implements a Sketchfab-style single-object viewer as a minimal proof-of-concept. ### `@storyteller/studio-web` This is a new package to house web components for interfacing with the `@storyteller/studio` WASM library. It currently includes a single component, `<sts-viewer />`, which is exported from `@storyteller/studio-web/viewer`. ### `react-frontend` This is a new application intended as a proof-of-concept and testbed for React interop with `@storyteller/studio-web`. Web components are currently a little bit fiddly to use in a React application. `react-frontend/src/app/studio-viewer.tsx` demonstrates how a React-idiomatic component can be written as a thin wrapper around a web component, and `react-frontend/src/app/app.tsx` demonstrates that wrapper component in use.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>ReactFrontend</title>
|
||||
<base href="/" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/src/styles.scss" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "react-frontend",
|
||||
"$schema": "../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "react-frontend/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/vite:build",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": "production",
|
||||
"options": {
|
||||
"outputPath": "dist/react-frontend"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"mode": "development"
|
||||
},
|
||||
"production": {
|
||||
"mode": "production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/vite:dev-server",
|
||||
"defaultConfiguration": "development",
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": ["studio"],
|
||||
"target": "build:web"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"buildTarget": "react-frontend:build"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"buildTarget": "react-frontend:build:development",
|
||||
"hmr": true
|
||||
},
|
||||
"production": {
|
||||
"buildTarget": "react-frontend:build:production",
|
||||
"hmr": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"executor": "@nx/vite:preview-server",
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"buildTarget": "react-frontend:build"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"buildTarget": "react-frontend:build:development"
|
||||
},
|
||||
"production": {
|
||||
"buildTarget": "react-frontend:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/vite:test",
|
||||
"outputs": ["{options.reportsDirectory}"],
|
||||
"options": {
|
||||
"passWithNoTests": true,
|
||||
"reportsDirectory": "../coverage/react-frontend"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,46 @@
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #2222227F;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 32px;
|
||||
font: unset {
|
||||
family: inherit;
|
||||
size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #222;
|
||||
pointer-events: none;
|
||||
transition: opacity 66.666ms linear;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { type SceneStateEvent, SceneState } from "@storyteller/studio";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
import StudioViewer from "./studio-viewer";
|
||||
|
||||
import style from "./app.module.scss";
|
||||
|
||||
const OBJECTS = [
|
||||
{
|
||||
id: "bookcase",
|
||||
label: "Bookcase",
|
||||
},
|
||||
{
|
||||
id: "couch",
|
||||
label: "Couch",
|
||||
},
|
||||
{
|
||||
id: "desk-chair",
|
||||
label: "Desk Chair",
|
||||
},
|
||||
{
|
||||
id: "desk",
|
||||
label: "Desk",
|
||||
},
|
||||
{
|
||||
id: "file-cabinet",
|
||||
label: "File Cabinet",
|
||||
},
|
||||
{
|
||||
id: "potted-plant-1",
|
||||
label: "Potted Plant #1",
|
||||
},
|
||||
{
|
||||
id: "potted-plant-3",
|
||||
label: "Potted Plant #2",
|
||||
},
|
||||
{
|
||||
id: "room",
|
||||
label: "Room (empty)",
|
||||
},
|
||||
{
|
||||
id: "sample-room",
|
||||
label: "Room (populated)",
|
||||
},
|
||||
{
|
||||
id: "rug",
|
||||
label: "Rug",
|
||||
},
|
||||
{
|
||||
id: "wicker-basket",
|
||||
label: "Wicker Basket",
|
||||
},
|
||||
];
|
||||
const SKYBOXES = [
|
||||
{
|
||||
id: "gum_trees_4k",
|
||||
label: "Gum Trees",
|
||||
},
|
||||
{
|
||||
id: "kloofendal_28d_misty_4k",
|
||||
label: "Kloofendal Misty",
|
||||
},
|
||||
{
|
||||
id: "meadow_4k",
|
||||
label: "Meadow",
|
||||
},
|
||||
{
|
||||
id: "promenade_de_vidy_4k",
|
||||
label: "Promenade de Vidy",
|
||||
},
|
||||
{
|
||||
id: "scythian_tombs_4k",
|
||||
label: "Scythian Tombs",
|
||||
},
|
||||
{
|
||||
id: "test_scene",
|
||||
label: "Desert Sunrise",
|
||||
},
|
||||
];
|
||||
|
||||
export default () => {
|
||||
const [objectId, setObjectId] = useState("desk");
|
||||
const [skyboxId, setSkyboxId] = useState("test_scene");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const onObjectSelect = (event: FormEvent<HTMLSelectElement>) => {
|
||||
setObjectId((event.target as HTMLSelectElement).value)
|
||||
}
|
||||
|
||||
const onSkyboxSelect = (event: FormEvent<HTMLSelectElement>) => {
|
||||
setSkyboxId((event.target as HTMLSelectElement).value)
|
||||
}
|
||||
|
||||
const onSceneStateChange = ({ detail: state }: SceneStateEvent) => {
|
||||
setLoading(state !== SceneState.Active);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.root}>
|
||||
<StudioViewer
|
||||
objectId={objectId}
|
||||
skyboxId={skyboxId}
|
||||
onSceneStateChange={onSceneStateChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={style.loading}
|
||||
style={{
|
||||
opacity: loading ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<h1>Loading...</h1>
|
||||
</div>
|
||||
|
||||
<div className={style.controls}>
|
||||
<label className={style.control}>
|
||||
<span className={style.label}>Object</span>
|
||||
<select
|
||||
className={style.input}
|
||||
name="object"
|
||||
value={objectId}
|
||||
disabled={loading}
|
||||
onChange={onObjectSelect}
|
||||
>
|
||||
{OBJECTS.map(({ id, label }) => (
|
||||
<option
|
||||
key={id}
|
||||
value={id}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className={style.control}>
|
||||
<span className={style.label}>Skybox</span>
|
||||
<select
|
||||
className={style.input}
|
||||
name="skybox"
|
||||
value={skyboxId}
|
||||
disabled={loading}
|
||||
onChange={onSkyboxSelect}
|
||||
>
|
||||
{SKYBOXES.map(({ id, label }) => (
|
||||
<option
|
||||
key={id}
|
||||
value={id}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { SceneStateEvent } from "@storyteller/studio";
|
||||
import { ViewerElement } from "@storyteller/studio-web/viewer";
|
||||
import "@storyteller/studio-web/viewer";
|
||||
import { MutableRefObject, forwardRef, useEffect, useRef } from "react";
|
||||
|
||||
export interface Props {
|
||||
objectId: string;
|
||||
skyboxId: string;
|
||||
onSceneStateChange?(event: SceneStateEvent): void;
|
||||
}
|
||||
|
||||
export default forwardRef<ViewerElement, Props>(({
|
||||
objectId,
|
||||
skyboxId,
|
||||
onSceneStateChange,
|
||||
}, ref) => {
|
||||
const viewerRef = useRef<ViewerElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current;
|
||||
|
||||
if (viewer && onSceneStateChange) {
|
||||
viewer.addEventListener("scene-state", onSceneStateChange);
|
||||
return () => {
|
||||
viewer.removeEventListener("scene-state", onSceneStateChange);
|
||||
}
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
const refCallback = (viewer: ViewerElement) => {
|
||||
(viewerRef as MutableRefObject<ViewerElement>).current = viewer;
|
||||
|
||||
if (typeof ref === "function")
|
||||
ref(viewer);
|
||||
else if (ref != null)
|
||||
ref.current = viewer;
|
||||
}
|
||||
|
||||
return (
|
||||
<sts-viewer
|
||||
ref={refCallback}
|
||||
objectId={objectId}
|
||||
skyboxId={skyboxId}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./app/app";
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement
|
||||
);
|
||||
root.render(<App />);
|
||||
@@ -0,0 +1,8 @@
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
import { ViewerElement } from "@storyteller/studio-web";
|
||||
|
||||
declare global {
|
||||
export namespace JSX {
|
||||
export interface IntrinsicElements {
|
||||
// FIXME: This causes `<sts-viewer />` to be recognized as an HTML
|
||||
// element, but it doesn't carry over its `@property`s
|
||||
"sts-viewer": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<ViewerElement>,
|
||||
ViewerElement,
|
||||
>;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist/out-tsc",
|
||||
"types": [
|
||||
"node",
|
||||
"@nx/react/typings/cssmodule.d.ts",
|
||||
"@nx/react/typings/image.d.ts",
|
||||
"vite/client"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.test.jsx"
|
||||
],
|
||||
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"types": ["vite/client", "vitest"]
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../tsconfig.base.json"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"types": ["@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import fs from "fs";
|
||||
import {NormalizedOutputOptions} from "rollup";
|
||||
|
||||
function copyRecursively(src: string, dst: string) {
|
||||
if (fs.statSync(src).isDirectory()) {
|
||||
// folder
|
||||
if (!fs.existsSync(dst)) {
|
||||
fs.mkdirSync(dst);
|
||||
}
|
||||
for (const file of fs.readdirSync(src)) {
|
||||
copyRecursively(src + "/" + file, dst + "/" + file);
|
||||
}
|
||||
} else {
|
||||
// file
|
||||
fs.copyFileSync(src, dst);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMiddleWare(options: NormalizedOutputOptions, assets: string[] = []) {
|
||||
const dst = options.dir;
|
||||
if (!dst) throw new Error("options.dir is undefined");
|
||||
for (const dir of assets) {
|
||||
copyRecursively(dir, dst)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// FIXME: Oh my god, did Danny seriously just copy and paste this entire folder
|
||||
// from `studio-frontend` instead of creating a separate project for it
|
||||
// and importing it like a decent, law-abiding citizen? Wow, dude really
|
||||
// needs to get his shit together.
|
||||
|
||||
// NOTE: This is an inline fork of this npm package:
|
||||
// https://github.com/nguyenbatranvan/vite-multiple-assets
|
||||
//
|
||||
// The published version at time of writing doesn't include mime types for many
|
||||
// of the asset types Bevy needs to load.
|
||||
|
||||
import {PluginOption, ViteDevServer} from "vite";
|
||||
import {ServerMiddleWare} from "./server";
|
||||
import {buildMiddleWare} from "./build";
|
||||
|
||||
export default function DynamicPublicDirectory(assets: string[]): PluginOption {
|
||||
return {
|
||||
configureServer(server: ViteDevServer) {
|
||||
return ServerMiddleWare(server, assets)
|
||||
},
|
||||
async writeBundle(options) {
|
||||
buildMiddleWare(options, assets)
|
||||
},
|
||||
name: "dynamic assets"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ViteDevServer } from "vite";
|
||||
|
||||
interface IPropsFile {
|
||||
name: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
function getFiles(dir, files_): string[] {
|
||||
files_ = files_ || [];
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const i in files) {
|
||||
const name = dir + "/" + files[i];
|
||||
if (fs.statSync(name).isDirectory()) {
|
||||
getFiles(name, files_);
|
||||
} else {
|
||||
files_.push(name);
|
||||
}
|
||||
}
|
||||
return files_;
|
||||
}
|
||||
|
||||
const mimeTypes = {
|
||||
".7z": "application/x-7z-compressed",
|
||||
".bin": "application/octet-stream",
|
||||
".css": "text/css",
|
||||
".eot": "font/eot",
|
||||
".gif": "image/gif",
|
||||
".gltf": "model/gltf+json",
|
||||
".gz": "application/gzip",
|
||||
".hdr": "image/vnd.radiance",
|
||||
".html": "text/html",
|
||||
".ico": "image/x-icon",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript",
|
||||
".json": "application/json",
|
||||
".ktx": "image/ktx",
|
||||
".ktx2": "image/ktx",
|
||||
".meta": "text/plain",
|
||||
".mjs": "text/javascript",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".otf": "font/otf",
|
||||
".png": "image/png",
|
||||
".rar": "application/x-rar-compressed",
|
||||
".svg": "image/svg+xml",
|
||||
".tiff": "image/tiff",
|
||||
".ttf": "font/ttf",
|
||||
".txt": "text/plain",
|
||||
".wasm": "application/wasm",
|
||||
".webm": "video/webm",
|
||||
".webp": "image/webp",
|
||||
".wgsl": "text/wgsl",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".xml": "text/xml",
|
||||
".zip": "application/zip",
|
||||
}
|
||||
|
||||
export function ServerMiddleWare(server: ViteDevServer, assets: string[] = []) {
|
||||
if (!assets || !assets.length)
|
||||
return;
|
||||
|
||||
const fileObject: IPropsFile[] = [];
|
||||
for (let i = 0; i < assets.length; i++) {
|
||||
const files = getFiles(path.join(process.cwd(), `/${assets[i]}`), []);
|
||||
fileObject.push({
|
||||
name: assets[i],
|
||||
files
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
for (let i = 0; i < fileObject.length; i++) {
|
||||
const file = path.join(process.cwd(), `${fileObject[i].name}/${req.originalUrl}`);
|
||||
if (fileObject[i].files.some(f => path.relative(f, file) === "")) {
|
||||
const extension = file.substring(file.lastIndexOf("."));
|
||||
res.setHeader("Cache-Control", "max-age=31536000, immutable");
|
||||
res.setHeader("Content-Type", mimeTypes[extension]);
|
||||
res.writeHead(200);
|
||||
res.write(fs.readFileSync(path.join(process.cwd(), "/" + fileObject[i].name + req.originalUrl)));
|
||||
res.end();
|
||||
break;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
import mergeAssetsDirs from "./vite-plugins/multiple-assets";
|
||||
|
||||
export default defineConfig({
|
||||
cacheDir: "../node_modules/.vite/react-frontend",
|
||||
|
||||
server: {
|
||||
port: 4200,
|
||||
host: "localhost",
|
||||
},
|
||||
|
||||
preview: {
|
||||
port: 4300,
|
||||
host: "localhost",
|
||||
},
|
||||
|
||||
plugins: [
|
||||
react(),
|
||||
nxViteTsPaths(),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
mergeAssetsDirs([
|
||||
"react-frontend/public",
|
||||
"dist/studio/public",
|
||||
]),
|
||||
],
|
||||
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
|
||||
test: {
|
||||
globals: true,
|
||||
cache: { dir: "../node_modules/.vitest" },
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user