commit b5c0adca22463dfce5b6cc3723072a19b0fa4931 Author: Evert Prants Date: Sun Feb 18 12:42:09 2024 +0200 code stash diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..491fc35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +lib diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..9e4c08a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ad92582 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9fcb27 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Mson -> THREE + +WIP do not use + +Parse [Mson](https://github.com/MineLittlePony/Mson). + +## Setup + +1. copy the folder at https://github.com/MineLittlePony/Mson/tree/1.20.2/src/main/resources/assets/mson/models/entity as `inputs/mson` +2. build `npm run build` +3. export `node lib/test-node.js` + +THREE object will be placed in `outputs` diff --git a/inputs/.gitignore b/inputs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/inputs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/outputs/.gitignore b/outputs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/outputs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5de20df --- /dev/null +++ b/package-lock.json @@ -0,0 +1,106 @@ +{ + "name": "mson-three", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mson-three", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "three": "^0.161.0" + }, + "devDependencies": { + "@types/node": "^20.11.19", + "@types/three": "^0.161.2", + "prettier": "^3.2.5", + "typescript": "^5.3.3" + } + }, + "node_modules/@types/node": { + "version": "20.11.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz", + "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", + "integrity": "sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==", + "dev": true + }, + "node_modules/@types/three": { + "version": "0.161.2", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.161.2.tgz", + "integrity": "sha512-DazpZ+cIfBzbW/p0zm6G8CS03HBMd748A3R1ZOXHpqaXZLv2I5zNgQUrRG//UfJ6zYFp2cUoCQaOLaz8ubH07w==", + "dev": true, + "dependencies": { + "@types/stats.js": "*", + "@types/webxr": "*", + "fflate": "~0.6.10", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.14.tgz", + "integrity": "sha512-UEMMm/Xn3DtEa+gpzUrOcDj+SJS1tk5YodjwOxcqStNhCfPcwgyC5Srg2ToVKyg2Fhq16Ffpb0UWUQHqoT9AMA==", + "dev": true + }, + "node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "dev": true + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true + }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/three": { + "version": "0.161.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.161.0.tgz", + "integrity": "sha512-LC28VFtjbOyEu5b93K0bNRLw1rQlMJ85lilKsYj6dgTu+7i17W+JCCEbvrpmNHF1F3NAUqDSWq50UD7w9H2xQw==" + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2a6a8af --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "mson-three", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "tsc", + "watch": "tsc -w" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/node": "^20.11.19", + "@types/three": "^0.161.2", + "prettier": "^3.2.5", + "typescript": "^5.3.3" + }, + "dependencies": { + "three": "^0.161.0" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..cefdf70 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export * from './mson'; diff --git a/src/mson/eval.ts b/src/mson/eval.ts new file mode 100644 index 0000000..4c20da8 --- /dev/null +++ b/src/mson/eval.ts @@ -0,0 +1,304 @@ +import { deepClone } from '../util/deep-clone'; +import { isObject } from '../util/merge-deep'; +import { + MsonComponent, + MsonData, + MsonEvaluatedLocals, + MsonEvaluatedModel, + MsonExpression, + MsonLocals, + MsonSlot, + MsonVariable, +} from './mson.type'; +import { ModelStore } from './store'; + +type MsonVariables = Record; + +/** + * This class is used to evaluate all variables within a MSON model definition. + */ +export class MsonEvaluate { + constructor(protected readonly store: ModelStore) {} + + /** + * Evaluate a model, resolving all of its references and parameters. + * @param model MSON data to evaluate + * @param useLocals Override locals + * @returns Evaluated model, with (hopefully) all variables replaced. + */ + evaluateModel( + model: string, + useLocals?: MsonEvaluatedLocals, + ): MsonEvaluatedModel { + const modelData = this.store.getModelByName(model); + if (!modelData) { + throw new Error(`Model ${model} not found for evaluation`); + } + + // Deep copy the model + const localCopy = deepClone(modelData) as MsonEvaluatedModel; + localCopy.name = model; + localCopy._locals = { ...(localCopy.locals || {}) }; + localCopy._data = { ...localCopy.data }; + + // Evaluate parents + if (modelData.parent) { + localCopy._parent = this.evaluateModel(modelData.parent); + + // Overwrite / combine parent locals with current locals + localCopy._locals = { + ...(localCopy._parent._locals || {}), + ...localCopy._locals, + }; + + // Overwrite / combine parent data with current data + localCopy._data = { + ...(localCopy._parent._data || {}), + ...localCopy._data, + }; + + // Inherit texture parameters + localCopy.texture = { + ...(localCopy._parent?.texture || {}), + ...(localCopy.texture || {}), + }; + } + + // Overwrite / merge locals + if (useLocals) { + localCopy._locals = { + ...localCopy._locals, + ...useLocals, + }; + } + + // Evaluate locals with globally combined parameters + this.evaluateLocals(localCopy); + + // Recursively substitute all locals within the data. + // Also, fill all slots. + localCopy._dataEvaluated = this.recursiveSubstitute( + localCopy._data, + localCopy._localsEvaluated, + ); + + return localCopy; + } + + /** + * Evaluate a MSON expression (arithmetic) + * @param expression Mson Expression definition + * @param variables Scope variables + */ + protected evaluateExpression( + expression: MsonExpression, + variables: MsonVariables = {}, + ) { + if (!this.isExpression(expression)) { + throw new Error('Invalid expression'); + } + + let [src, oper, trg] = expression; + + if (this.isExpression(src)) { + // TODO: resolve as unknown, probably with a tuple of some kind + src = this.evaluateExpression( + src as unknown as MsonExpression, + variables, + ); + } + + if (this.isExpression(trg)) { + // TODO: resolve as unknown, probably with a tuple of some kind + trg = this.evaluateExpression( + trg as unknown as MsonExpression, + variables, + ); + } + + src = this.substitute(src, variables); + trg = this.substitute(trg, variables); + + // Assertions + if (isNaN(src)) { + throw new Error( + `Expression evaluation [${expression}] failed: ${src} is NaN`, + ); + } + + if (isNaN(trg)) { + throw new Error( + `Expression evaluation [${expression}] failed: ${trg} is NaN`, + ); + } + + if (!['+', '-', '*', '/', '%', '^'].includes(oper)) { + throw new Error( + `Expression evaluation [${expression}] failed: ${oper} is not supported`, + ); + } + + // Operators + switch (oper) { + case '+': + return src + trg; + case '-': + return src - trg; + case '*': + return src * trg; + case '/': + return src / trg; + case '%': + return src % trg; + case '^': + return Math.pow(src, trg); + } + } + + /** + * Determine if input is an expression or not. + * @param input Questionable input + * @returns Is expression or not + */ + protected isExpression(input: MsonVariable | MsonExpression) { + return ( + Array.isArray(input) && + input.length === 3 && + typeof input[1] === 'string' && + !input[1].startsWith('#') + ); + } + + /** + * Substitute input with variables or return input. + * @param input Input to substitute + * @param variables Pool of variables + * @returns Substituted value + */ + protected substitute(input: MsonVariable, variables: MsonVariables = {}) { + if (typeof input !== 'string') return input; + const key = input.replace('#', ''); + return variables[key] ?? input; + } + + /** + * Substitute ALL variables recursively. + * Also fills slots. (TODO: separate) + * @param input Model data, a component or component metadata + * @param variables Variables to use + * @returns Substituted object + */ + protected recursiveSubstitute( + input: MsonData | MsonComponent, + variables: MsonVariables = {}, + ) { + const keyList = Object.keys(input); + + // Handle slots, include the child model with current locals already applied. + const asSlot = input as MsonSlot; + if (keyList.includes('data') && typeof asSlot.data === 'string') { + const evaluateChild = this.evaluateModel( + asSlot.data as string, + asSlot.locals + ? this.evaluateLocalsObject(asSlot.locals, variables) + : variables, + ); + + return { + type: 'mson:slot', + implementation: asSlot.implementation, + data: evaluateChild._dataEvaluated, + }; + } + + return keyList.reduce((item, key) => { + const value = item[key]; + + // Do not substitute locals or generated values here. + if (key === 'locals' || key.startsWith('_')) return item; + + if (isObject(value)) { + item[key] = this.recursiveSubstitute(value, variables); + } + + if (typeof value === 'string' && value.startsWith('#')) { + item[key] = this.substitute(value, variables); + } + + if (this.isExpression(value)) { + item[key] = this.evaluateExpression(value, variables); + } + + if (Array.isArray(value)) { + item[key] = value.map((entry) => + isObject(entry) + ? this.recursiveSubstitute(entry, variables) + : this.substitute(entry, variables), + ); + } + + return item; + }, input); + } + + /** + * Evaluate locals on model + * @param localCopy Local evaluated model + */ + protected evaluateLocals(localCopy: MsonEvaluatedModel) { + if (!localCopy._locals) { + if (localCopy._parent?._localsEvaluated) { + // Copy parent's globals + localCopy._localsEvaluated = deepClone( + localCopy._parent._localsEvaluated, + ); + } + // Nothing more to evaluate + return; + } + + localCopy._localsEvaluated = this.evaluateLocalsObject( + localCopy._locals, + localCopy._parent?._localsEvaluated, + ); + } + + /** + * Substitute locals' variables and evaluate expressions. + * @param locals Locals to evaluate + * @param parentEvals Parents evaluated locals, falls back to these. + * @returns Locals with variables substituted. + */ + protected evaluateLocalsObject( + locals: MsonLocals, + parentEvals: MsonEvaluatedLocals = {}, + ) { + return Object.keys(locals).reduce( + (pool, local) => { + const currentValue = locals?.[local]; + if (currentValue === undefined) return pool; + + // Number type just pass on + if (typeof currentValue === 'number') { + pool[local] = currentValue; + } + + // Substitute strings, those are variables + if (typeof currentValue === 'string') { + pool[local] = this.substitute(currentValue, pool); + } + + // Evaluate expressions + if (this.isExpression(currentValue)) { + pool[local] = this.evaluateExpression( + currentValue as MsonExpression, + pool, + ); + } + + return pool; + }, + { ...parentEvals }, + ); + } +} diff --git a/src/mson/index.ts b/src/mson/index.ts new file mode 100644 index 0000000..1a5d691 --- /dev/null +++ b/src/mson/index.ts @@ -0,0 +1,3 @@ +export * from './mson.type'; +export * from './store'; +export * from './eval'; diff --git a/src/mson/mson.type.ts b/src/mson/mson.type.ts new file mode 100644 index 0000000..9356922 --- /dev/null +++ b/src/mson/mson.type.ts @@ -0,0 +1,269 @@ +import { PartialBy } from '../util/deep-partial.type'; + +export interface MsonTexture { + w?: number; + h?: number; + u?: number; + v?: number; +} + +export type MsonOperand = + | '+' /* ADD */ + | '-' /* SUBTRACT */ + | '*' /* MULTIPLY */ + | '/' /* DIVIDE */ + | '%' /* MODULUS */ + | '^' /* EXPONENT */; + +export type MsonVariable = string | number; + +export type Vec3 = [number, number, number]; +export type Vec2 = [number, number]; +export type MsonVec3 = Vec3 | [MsonVariable, MsonVariable, MsonVariable]; +export type MsonVec2 = Vec2 | [MsonVariable, MsonVariable]; + +/** + * Mson allows you to use variables and do basic calculations within the model file. + * Unless otherwise stated, it can be assumed that every place where a numerical value + * is expected can also accept a reference to a variable (see below). + * + * Expressions with calculations can only be done in the `local` blocks where veriables are defined. + */ +export type MsonExpression = [MsonVariable, MsonOperand, MsonVariable]; + +export type MsonLocals = Record; + +export type MsonEvaluatedLocals = Record; + +export type MsonComponentType = + | 'mson:compound' + | 'mson:box' + | 'mson:plane' + | 'mson:planar' + | 'mson:slot' + | 'mson:cone' + | 'mson:quads' + | string; + +export interface MsonBaseComponent { + type?: MsonComponentType; + /** + * Whether or not this part is visible. You shouldn't have to use this in most circumstances. + * @default true + */ + visible?: boolean; + /** + * The XYZ center of rotation of this part. + */ + pivot?: MsonVec3; + /** + * The XYZ rotation angle of this part, given in degrees. + */ + rotate?: MsonVec3; + texture?: MsonTexture; + /** + * A map of child components to load as part of this one. + */ + children?: MsonData; + __comment?: any; +} + +export interface MsonBox extends MsonBaseComponent { + type: 'mson:box'; + /** + * The relative XYZ position of the box + */ + from: MsonVec3; + /** + * The width, height, and depth of the box + */ + size: MsonVec3; + /** + * Part-specific dilation applied to any cubes created by components defined by this part. + */ + dilate?: Vec3 | number; +} + +export interface MsonCompound extends MsonBaseComponent { + type: 'mson:compound'; + dilate?: MsonVec3; + /** + * Whether to flip this part's textures. + * @default false + */ + mirror?: boolean | MsonVec3; + /** + * default type for components (if omitted): mson:box + * allowed types: + */ + cubes?: MsonCompoundComponent[]; +} + +export type MsonFace = 'up' | 'down' | 'east' | 'west' | 'south' | 'north'; + +export type MsonPlanarXYZWH = [ + MsonVariable, + MsonVariable, + MsonVariable, + MsonVariable, + MsonVariable, +]; +export type MsonPlanarXYZWHUV = [ + MsonVariable, + MsonVariable, + MsonVariable, + MsonVariable, + MsonVariable, + MsonVariable, + MsonVariable, +]; +export type MsonPlanarXYZWHUVXY = [ + MsonVariable, + MsonVariable, + MsonVariable, + MsonVariable, + MsonVariable, + MsonVariable, + MsonVariable, + boolean, + boolean, +]; +/** + * First 3 are the position, + * Next two are the size, + * Last two are an optional texture coordinate. If not given, the parent's texture will be used. + * + * Two booleans indicate whether to flip the texture vertically and horizontally. + */ +export type MsonPlanarPlane = + | MsonPlanarXYZWH + | MsonPlanarXYZWHUV + | MsonPlanarXYZWHUVXY; +export interface MsonPlanar extends MsonBaseComponent { + type: 'mson:planar'; + dilate?: MsonVec3; + /** + * Whether to flip this part's textures. + * @default false + */ + mirror?: boolean | MsonVec3; + up?: MsonPlanarPlane; + down?: MsonPlanarPlane; + east?: MsonPlanarPlane; + west?: MsonPlanarPlane; + south?: MsonPlanarPlane; + north?: MsonPlanarPlane; +} + +export interface MsonPlane extends MsonBaseComponent { + type: 'mson:plane'; + face?: MsonFace; + position?: MsonVec3; + size?: MsonVec2; + mirror?: [boolean, boolean]; + dilate?: MsonVec3; +} + +export interface MsonSlot extends MsonBaseComponent { + type: 'mson:slot'; + implementation: string; + name: string; + data: MsonData | string; + locals?: MsonLocals; +} + +export interface MsonCone extends MsonBaseComponent { + type: 'mson:cone'; + size?: MsonVec3; + from?: MsonVec3; + dilate?: MsonVariable; + taper?: MsonVariable; +} + +export interface MsonQuads extends MsonBaseComponent { + type: 'mson:quads'; + // TODO: dunno +} + +export type MsonComponent = + | MsonBox + | PartialBy + | MsonPlanar + | MsonPlane + | MsonSlot + | MsonCone + | MsonQuads; + +export type MsonCompoundComponent = + | PartialBy + | MsonCone + | MsonQuads + | MsonPlane + | MsonSlot; + +export type MsonData = Record; + +export interface MsonModel { + /** + * The parent file to extend from. + */ + parent?: MsonComponentType; + + /** + * Texture definition specifying the default width and height for the file. + * This property is inherited by all components defined in this file + * and defining it will function similarly to defining variables in the locals block. + */ + texture?: MsonTexture; + + /** + * The default growth (dilation) applied to all boxes defined by components in this file. + * Note that components may have their own dilation parameter, in which instance that value is *added* + * to this one + * + * ** DOES NOT SUPPORT VARIABLES ** + */ + dilate?: Vec3; + + /** + * A block of local variables that may be references by components within the data block. + * Values defined in here are typically applied over what is inherited from the parent file (if specified) + * and become available for use by the parent's components as well. + */ + locals?: MsonLocals; + + /** + * default type for components (if omitted): mson:compound + * allowed types: + */ + data: MsonData; +} + +export interface MsonEvaluatedModel extends MsonModel { + name: string; + + /** + * Combined locals from whole dependency tree + */ + _locals?: MsonLocals; + + /** + * Evaluated locals + */ + _localsEvaluated?: MsonEvaluatedLocals; + + /** + * Combined data from whole dependency tree + */ + _data?: MsonData; + + /** + * Evaluated data with evaluated locals + */ + _dataEvaluated?: MsonData; + + /** + * Evaluated parent + */ + _parent?: MsonEvaluatedModel; +} diff --git a/src/mson/store.ts b/src/mson/store.ts new file mode 100644 index 0000000..f252c8d --- /dev/null +++ b/src/mson/store.ts @@ -0,0 +1,24 @@ +import { DeepPartial } from '../util/deep-partial.type'; +import { mergeDeep } from '../util/merge-deep'; +import { MsonModel } from './mson.type'; + +export class ModelStore { + public componentStore = new Map(); + + getModelByName(name: string) { + return this.componentStore.get(name); + } + + updateModel(name: string, upsert: DeepPartial) { + const existing = this.getModelByName(name); + this.componentStore.set(name, mergeDeep(existing || {}, upsert)); + } + + insertModel(name: string, model: MsonModel) { + this.componentStore.set(name, model); + } + + listModels() { + return Array.from(this.componentStore.keys()); + } +} diff --git a/src/test-node.ts b/src/test-node.ts new file mode 100644 index 0000000..3de583d --- /dev/null +++ b/src/test-node.ts @@ -0,0 +1,27 @@ +import { resolve } from 'path'; +import { ModelStore, MsonEvaluate } from '.'; +import { fillStoreFromFilesystem, saveGeometry } from './util/node'; +import { ThreeBuilder } from './three'; +import { MeshBasicMaterial } from 'three'; + +async function init() { + const store = new ModelStore(); + const evaluate = new MsonEvaluate(store); + const mat = new MeshBasicMaterial(); + const builder = new ThreeBuilder(mat); + + await fillStoreFromFilesystem(store, resolve(process.cwd(), 'inputs')); + + // mson:steve + // minelittlepony:steve_pony + const final = evaluate.evaluateModel('mson:biped'); + // console.log(final.texture); + console.dir(final._dataEvaluated, { + depth: 20, + }); + + const geometry = builder.buildGeometry(final); + await saveGeometry(resolve(process.cwd(), 'outputs'), 'steve', geometry); +} + +init().catch(console.error); diff --git a/src/three/builder.ts b/src/three/builder.ts new file mode 100644 index 0000000..741d3b7 --- /dev/null +++ b/src/three/builder.ts @@ -0,0 +1,155 @@ +import { + BoxGeometry, + Euler, + Material, + MathUtils, + Mesh, + Object3D, + Vector3, +} from 'three'; +import { + MsonBaseComponent, + MsonBox, + MsonComponent, + MsonComponentType, + MsonCompound, + MsonCompoundComponent, + MsonEvaluatedModel, + Vec3, +} from '../mson'; + +export class ThreeBuilder { + constructor(private readonly material: Material) {} + + /** + * Create a THREE.js object from MSON evaluated model data. + * @param model MSON Evaluated model + * @returns THREE.js object + */ + buildGeometry(model: MsonEvaluatedModel) { + if (!model._dataEvaluated) { + throw new Error( + 'Please evaluate the MSON model before building a geometry.', + ); + } + + const wrapper = new Object3D(); + wrapper.name = model.name; + + for (const [name, component] of Object.entries(model._dataEvaluated)) { + this.makeGeometry(name, component, wrapper); + } + + return wrapper; + } + + /** + * Generate geometry from a MSON component + * @param component Component type + * @param parent Parent object + * @returns Geometry object + */ + protected makeGeometry( + name: string, + component: MsonComponent, + parent: Object3D, + parentComponent?: MsonComponent, + ) { + // Compound objects + if (!component.type || component.type === 'mson:compound') { + return this.makeMsonCompound( + name, + component as MsonCompound, + parent, + parentComponent, + ); + } + } + + protected makeMsonCompound( + name: string, + component: MsonCompound, + parent: Object3D, + parentComponent?: MsonComponent, + ) { + const wrapper = this.createWrapper(name, component); + parent.add(wrapper); + + component.cubes?.forEach((part: MsonCompoundComponent) => { + if (!part.type || part.type === 'mson:box') { + this.makeMsonBox(name, part as MsonBox, wrapper, component); + return; + } + }); + } + + protected makeMsonBox( + name: string, + component: MsonBox, + parent: Object3D, + parentComponent?: MsonComponent, + ) { + const offset = new Vector3(); + if (parentComponent?.pivot) { + offset.sub(new Vector3().fromArray(parentComponent.pivot as Vec3)); + } + + const size = new Vector3().fromArray(component.size as Vec3); + const pos = new Vector3().fromArray(component.from as Vec3); + + const dilate = new Vector3(); + if (component.dilate) { + if (Array.isArray(component.dilate)) dilate.fromArray(component.dilate); + else dilate.set(component.dilate, component.dilate, component.dilate); + } + + const rotate = new Vector3(); + if (parentComponent?.rotate) { + rotate.fromArray( + (parentComponent.rotate as Vec3).map((entry) => + MathUtils.degToRad(entry), + ), + ); + } + + const halfOffset = offset.clone().divideScalar(2); + const halfSize = size.clone().divideScalar(2); + const halfDilation = dilate.clone().divideScalar(2); + + const geometry = new BoxGeometry( + size.x + dilate.x, + size.y + dilate.y, + size.z + dilate.z, + 1, + 1, + 1, + ); + + geometry.translate(halfOffset.x, halfOffset.y, halfOffset.z); + geometry.rotateX(rotate.x); + geometry.rotateY(rotate.y); + geometry.rotateZ(rotate.z); + + // FIXME: hack toJSON + (geometry as any).type = 'BufferGeometry'; + delete (geometry as any).parameters; + + // TODO: apply UVs + pos.add(halfOffset).add(halfSize); + + const mesh = new Mesh(geometry, this.material); + mesh.name = `${name}__mesh`; + mesh.position.copy(pos); + mesh.updateMatrix(); + + parent.add(mesh); + } + + protected createWrapper(name: string, component: MsonBaseComponent) { + let wrapper = new Object3D(); + wrapper.name = name; + wrapper.userData.type = 'mson:compound'; + wrapper.visible = component.visible ?? true; + return wrapper; + } +} diff --git a/src/three/index.ts b/src/three/index.ts new file mode 100644 index 0000000..ecea700 --- /dev/null +++ b/src/three/index.ts @@ -0,0 +1 @@ +export * from './builder'; diff --git a/src/util/deep-clone.ts b/src/util/deep-clone.ts new file mode 100644 index 0000000..2b3c92b --- /dev/null +++ b/src/util/deep-clone.ts @@ -0,0 +1,2 @@ +export const deepClone = (input: T): T => + JSON.parse(JSON.stringify(input)); diff --git a/src/util/deep-partial.type.ts b/src/util/deep-partial.type.ts new file mode 100644 index 0000000..c2e54fe --- /dev/null +++ b/src/util/deep-partial.type.ts @@ -0,0 +1,6 @@ +export type PartialBy = Omit & Partial>; +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; diff --git a/src/util/merge-deep.ts b/src/util/merge-deep.ts new file mode 100644 index 0000000..3e100b6 --- /dev/null +++ b/src/util/merge-deep.ts @@ -0,0 +1,31 @@ +/** + * Simple object check. + * @param item + * @returns {boolean} + */ +export function isObject(item: any) { + return item && typeof item === 'object' && !Array.isArray(item); +} + +/** + * Deep merge two objects. + * @param target + * @param ...sources + */ +export function mergeDeep(target: any, ...sources: any[]) { + if (!sources.length) return target; + const source = sources.shift(); + + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) Object.assign(target, { [key]: {} }); + mergeDeep(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + } + } + + return mergeDeep(target, ...sources); +} diff --git a/src/util/node.ts b/src/util/node.ts new file mode 100644 index 0000000..ea7ab15 --- /dev/null +++ b/src/util/node.ts @@ -0,0 +1,56 @@ +import { ModelStore } from '../mson'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { Object3D } from 'three'; + +export const fillStoreFromFilesystem = async ( + store: ModelStore, + root: string, +) => { + // Get all mod prefixes from the input directory. + const prefixes = (await fs.readdir(root, { withFileTypes: true })) + .filter((item) => item.isDirectory()) + .map((item) => item.name); + + // Loop through all of the prefixes + for (const prefix of prefixes) { + // Prefix absolute path + const prefixPath = join(root, prefix); + + // Recurse all of the files in the mod directory + const allFiles = await fs.readdir(prefixPath, { + recursive: true, + withFileTypes: true, + }); + + // Loop through all JSON files + for (const dirent of allFiles) { + if (!dirent.isFile()) continue; + if (!dirent.name.endsWith('.json')) continue; + const absPath = join(dirent.path, dirent.name); + + // Relative path of model + const relPath = absPath.replace(prefixPath, ''); + + // Model file contents + const readFile = await fs.readFile(absPath, { encoding: 'utf-8' }); + const parsed = JSON.parse(readFile); + + // Component name referenced from other models + const componentName = `${prefix}:${relPath.replace('.json', '').substring(1)}`; + + // Insert component into the store + store.insertModel(componentName, parsed); + } + } +}; + +export const saveGeometry = async ( + root: string, + name: string, + object: Object3D, +) => + await fs.writeFile( + join(root, `${name}.json`), + JSON.stringify(object.toJSON()), + ); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7fe3b65 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,101 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./lib", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}