diff --git a/src/mson/eval.ts b/src/mson/eval.ts index 4c20da8..cea903f 100644 --- a/src/mson/eval.ts +++ b/src/mson/eval.ts @@ -206,6 +206,7 @@ export class MsonEvaluate { return { type: 'mson:slot', + texture: evaluateChild.texture, implementation: asSlot.implementation, data: evaluateChild._dataEvaluated, }; diff --git a/src/mson/mson.type.ts b/src/mson/mson.type.ts index ad68ca5..f2e3a97 100644 --- a/src/mson/mson.type.ts +++ b/src/mson/mson.type.ts @@ -47,6 +47,7 @@ export type MsonComponentType = export interface MsonBaseComponent { type?: MsonComponentType; + implementation: string; /** * Whether or not this part is visible. You shouldn't have to use this in most circumstances. * @default true @@ -91,7 +92,7 @@ export interface MsonCompound extends MsonBaseComponent { * Whether to flip this part's textures. * @default false */ - mirror?: boolean | MsonVec3; + mirror?: boolean | [boolean, boolean, boolean]; /** * default type for components (if omitted): mson:box * allowed types: @@ -174,7 +175,6 @@ export interface MsonPlane extends MsonBaseComponent { export interface MsonSlot extends MsonBaseComponent { type: 'mson:slot'; - implementation: string; name: string; data: MsonData | string; locals?: MsonLocals; diff --git a/src/test-node.ts b/src/test-node.ts index 5ce5616..f4c7a04 100644 --- a/src/test-node.ts +++ b/src/test-node.ts @@ -1,8 +1,8 @@ import { resolve } from 'path'; import { ModelStore, MsonEvaluate } from '.'; -import { fillStoreFromFilesystem, saveGeometry } from './util/node'; +import { fillStoreFromFilesystem, saveGeometry, saveModel } from './util/node'; import { ThreeBuilder } from './three'; -import { MeshBasicMaterial, MeshLambertMaterial } from 'three'; +import { MeshLambertMaterial } from 'three'; async function init() { const store = new ModelStore(); @@ -16,12 +16,11 @@ async function init() { // minelittlepony:steve_pony const final = evaluate.evaluateModel('minelittlepony:races/steve/alicorn'); // console.log(final.texture); - console.dir(final._dataEvaluated, { - depth: 20, - }); - const geometry = builder.buildGeometry(final); - await saveGeometry(resolve(process.cwd(), 'outputs'), 'steve', geometry); + const outputName = 'alicorn'; + const outputs = resolve(process.cwd(), 'outputs'); + await saveGeometry(outputs, outputName, geometry); + await saveModel(outputs, outputName, final); } init().catch(console.error); diff --git a/src/three/builder.ts b/src/three/builder.ts index 0de6092..3e19c22 100644 --- a/src/three/builder.ts +++ b/src/three/builder.ts @@ -1,6 +1,6 @@ import { BoxGeometry, - Euler, + CylinderGeometry, Material, MathUtils, Mesh, @@ -13,26 +13,26 @@ import { MsonBaseComponent, MsonBox, MsonComponent, - MsonComponentType, MsonCompound, MsonCompoundComponent, - MsonData, + MsonCone, MsonEvaluatedModel, MsonFace, MsonFaces, MsonPlanar, - MsonPlanarPlane, MsonPlanarXYZWHUVXY, MsonPlane, MsonSlot, + MsonTexture, Vec3, } from '../mson'; import { isArrayOfArrays } from '../util/array-of-array'; +import { UVMapper } from './uv'; export class ThreeBuilder { static normals: Record = { - up: new Vector3(0, -1, 0), - down: new Vector3(0, 1, 0), + up: new Vector3(0, 1, 0), + down: new Vector3(0, -1, 0), east: new Vector3(1, 0, 0), west: new Vector3(-1, 0, 0), south: new Vector3(0, 0, 1), @@ -50,16 +50,16 @@ export class ThreeBuilder { return new Vector3(pos.x + size.x / 2, pos.y, pos.z + size.y / 2); }, east: function (pos: Vector3, size: Vector2): Vector3 { - return new Vector3(pos.x, pos.y + size.y / 2, pos.z + size.x / 2); + return new Vector3(pos.x, pos.y - size.y / 2, pos.z + size.x / 2); }, west: function (pos: Vector3, size: Vector2): Vector3 { - return new Vector3(pos.x, pos.y + size.y / 2, pos.z + size.x / 2); + return new Vector3(pos.x, pos.y - size.y / 2, pos.z + size.x / 2); }, south: function (pos: Vector3, size: Vector2): Vector3 { - return new Vector3(pos.x + size.x / 2, pos.y + size.y / 2, pos.z); + return new Vector3(pos.x + size.x / 2, pos.y - size.y / 2, pos.z); }, north: function (pos: Vector3, size: Vector2): Vector3 { - return new Vector3(pos.x + size.x / 2, pos.y + size.y / 2, pos.z); + return new Vector3(pos.x + size.x / 2, pos.y - size.y / 2, pos.z); }, }; @@ -81,7 +81,7 @@ export class ThreeBuilder { wrapper.name = model.name; for (const [name, component] of Object.entries(model._dataEvaluated)) { - this.makeGeometry(name, component, wrapper); + this.makeGeometry(name, component, wrapper, undefined, model.texture); } return wrapper; @@ -98,47 +98,78 @@ export class ThreeBuilder { component: MsonComponent, parent: Object3D, parentComponent?: MsonComponent, + texture?: MsonTexture, ) { - const wrapper = this.createWrapper( - name, - component.type || 'mson:compound', - component, - ); + const wrapper = this.createWrapper(name, component); parent.add(wrapper); // Compound objects if (!component.type || component.type === 'mson:compound') { - this.makeMsonCompound( - name, - component as MsonCompound, - wrapper, - parentComponent, - ); + // mhm, go on ahead } - if (component.type === 'mson:slot') { - for (const [childName, child] of Object.entries(component.data)) { - this.makeGeometry(childName, child, wrapper, component); + if ( + component.type === 'mson:slot' || + (!component.type && (component as unknown as MsonSlot).data) + ) { + for (const [childName, child] of Object.entries( + (component as MsonSlot).data, + )) { + this.makeGeometry(childName, child, wrapper, component, { + ...texture, + ...component.texture, + ...child.texture, + }); } } if (component.type === 'mson:planar') { - this.makeMsonPlanar(name, component, wrapper, parentComponent); + this.makeMsonPlanar(name, component, wrapper, parentComponent, { + ...texture, + ...component.texture, + }); } if (component.type === 'mson:plane') { - this.makeMsonPlane(name, component, wrapper, parentComponent); + this.makeMsonPlane(name, component, wrapper, parentComponent, { + ...texture, + ...component.texture, + }); + } + + if (component.type === 'mson:cone') { + this.makeMsonCone(name, component, wrapper, parentComponent, { + ...texture, + ...component.texture, + }); } (component as MsonCompound).cubes?.forEach( (part: MsonCompoundComponent) => { if (!part.type || part.type === 'mson:box') { - this.makeMsonBox(name, part as MsonBox, wrapper, component); + this.makeMsonBox(name, part as MsonBox, wrapper, component, { + ...texture, + ...component.texture, + ...part.texture, + }); return; } if (part.type === 'mson:plane') { - this.makeMsonPlane(name, part as MsonPlane, wrapper, component); + this.makeMsonPlane(name, part as MsonPlane, wrapper, component, { + ...texture, + ...component.texture, + ...part.texture, + }); + return; + } + + if (part.type === 'mson:cone') { + this.makeMsonCone(name, part as MsonCone, wrapper, component, { + ...texture, + ...component.texture, + ...part.texture, + }); return; } }, @@ -146,23 +177,21 @@ export class ThreeBuilder { if (component.children) { for (const [childName, child] of Object.entries(component.children)) { - this.makeGeometry(childName, child, wrapper, component); + this.makeGeometry(childName, child, wrapper, component, { + ...texture, + ...component.texture, + ...child.texture, + }); } } } - protected makeMsonCompound( - name: string, - component: MsonCompound, - parent: Object3D, - parentComponent?: MsonComponent, - ) {} - protected makeMsonBox( name: string, component: MsonBox, parent: Object3D, parentComponent?: MsonComponent, + texture?: MsonTexture, ) { const size = new Vector3().fromArray(component.size as Vec3); @@ -188,7 +217,7 @@ export class ThreeBuilder { ); geometry.translate( - adjustedTranslate.x, + -adjustedTranslate.x, adjustedTranslate.y - size.y, adjustedTranslate.z, ); @@ -197,7 +226,12 @@ export class ThreeBuilder { (geometry as any).type = 'BufferGeometry'; delete (geometry as any).parameters; - // TODO: apply UVs + UVMapper.mapBoxUVs( + size, + geometry, + texture, + (parentComponent as MsonCompound)?.mirror, + ); const mesh = new Mesh(geometry, this.material); mesh.name = `${name}__mesh`; @@ -206,23 +240,72 @@ export class ThreeBuilder { parent.add(mesh); } + protected makeMsonCone( + name: string, + component: MsonCone, + parent: Object3D, + parentComponent?: MsonComponent, + texture?: MsonTexture, + ) { + const size = new Vector3().fromArray(component.size as Vec3); + + const dilate = new Vector3(); + if (component.dilate) { + if (Array.isArray(component.dilate)) dilate.fromArray(component.dilate); + else + dilate.set( + component.dilate as number, + component.dilate as number, + component.dilate as number, + ); + } + + const offset = new Vector3().fromArray(component.from as Vec3); + const halfSize = size.clone().divideScalar(2); + + offset.setY(offset.y * -1); + const adjustedTranslate = halfSize.clone().add(offset); + const adjustedSize = size.x * (component.taper as number) ?? 1; + + let geometry = new CylinderGeometry( + (adjustedSize + dilate.x) / Math.sqrt(2), + (size.x + dilate.x) / Math.sqrt(2), + size.y + dilate.y, + 4, + 1, + ); + + geometry.rotateY(Math.PI / 4); + geometry.translate( + -adjustedTranslate.x, + adjustedTranslate.y - size.y, + adjustedTranslate.z, + ); + geometry = geometry.toNonIndexed() as CylinderGeometry; + geometry.computeVertexNormals(); + + // FIXME: hack toJSON + (geometry as any).type = 'BufferGeometry'; + delete (geometry as any).parameters; + + const mesh = new Mesh(geometry, this.material); + mesh.name = `${name}__cone`; + mesh.updateMatrix(); + + parent.add(mesh); + } + protected makeMsonPlaneFace( face: MsonFace, parent: Object3D, pos: Vector3, size: Vector2, - uv: Vector2, + texture?: MsonTexture, mirror?: boolean | [boolean, boolean], - invertY = false, ) { const planeGeom = new PlaneGeometry(size.x, size.y); let axisNormal = ThreeBuilder.normals[face]; - if (invertY) { - axisNormal = axisNormal.clone().setY(axisNormal.y * -1); - pos.setY(pos.y * -1); - if (['east', 'west', 'south', 'north'].includes(face)) - size.setY(size.y * -1); - } + pos.setY(pos.y * -1); const adjustedTranslate = ThreeBuilder.pocConvert[face](pos, size); planeGeom.lookAt(axisNormal); planeGeom.translate( @@ -231,12 +314,12 @@ export class ThreeBuilder { adjustedTranslate.z, ); - // TODO: apply UVs - // FIXME: hack toJSON (planeGeom as any).type = 'BufferGeometry'; delete (planeGeom as any).parameters; + UVMapper.mapPlanarUvs(face, size, planeGeom, texture, mirror); + const mesh = new Mesh(planeGeom, this.material); // mesh.position.copy(pos); mesh.name = face; @@ -248,10 +331,12 @@ export class ThreeBuilder { component: MsonPlanar, parent: Object3D, parentComponent?: MsonComponent, + texture?: MsonTexture, ) { const wrapper = new Object3D(); wrapper.name = name; wrapper.userData.type = component.type; + wrapper.userData.implementation = component.implementation; parent.add(wrapper); for (const face of MsonFaces) { @@ -266,7 +351,6 @@ export class ThreeBuilder { let mirror: [boolean, boolean] = [false, false]; planePos.fromArray(faceinfo as number[]); - // planePos.setY(planePos.y * -1); size.fromArray(faceinfo as number[], 3); if (faceinfo.length > 5) { @@ -276,7 +360,20 @@ export class ThreeBuilder { if (faceinfo.length > 7) { mirror = [faceinfo[7] as boolean, faceinfo[8] as boolean]; } - this.makeMsonPlaneFace(face, wrapper, planePos, size, uv, mirror, true); + this.makeMsonPlaneFace( + face, + wrapper, + planePos, + size, + faceinfo.length > 5 + ? { + ...texture, + u: uv.x, + v: uv.y, + } + : texture, + mirror, + ); } } wrapper.updateMatrix(); @@ -287,44 +384,39 @@ export class ThreeBuilder { component: MsonPlane, parent: Object3D, parentComponent?: MsonComponent, + texture?: MsonTexture, ) { const wrapper = new Object3D(); wrapper.name = name; wrapper.userData.type = component.type; + wrapper.userData.implementation = component.implementation; parent.add(wrapper); const planePos = new Vector3(); const size = new Vector2(); - const uv = new Vector2(); let mirror: [boolean, boolean] = [false, false]; if (component.position) planePos.fromArray(component.position as number[]); if (component.size) size.fromArray(component.size as number[]); - uv.set(component.texture?.u ?? 0, component.texture?.v ?? 0); - if (component.mirror) mirror = component.mirror; this.makeMsonPlaneFace( component.face as MsonFace, wrapper, planePos, size, - uv, + texture, mirror, - true, ); } - protected createWrapper( - name: string, - typeName: string, - component: MsonBaseComponent, - ) { + protected createWrapper(name: string, component: MsonBaseComponent) { let wrapper = new Object3D(); wrapper.name = name; - wrapper.userData.type = typeName; + wrapper.userData.type = component.type || 'mson:component'; wrapper.visible = component.visible ?? true; + wrapper.userData.implementation = component.implementation; const rotate = new Vector3(); if (component?.rotate) { diff --git a/src/three/uv.ts b/src/three/uv.ts new file mode 100644 index 0000000..55f9b10 --- /dev/null +++ b/src/three/uv.ts @@ -0,0 +1,276 @@ +import { + BoxGeometry, + BufferAttribute, + PlaneGeometry, + Vector2, + Vector3, + Vector4, +} from 'three'; +import { MsonFace, MsonTexture } from '../mson'; +import { convertRange } from '../util/num-range'; + +export class UVMapper { + // px nx py ny pz nz + static boxFaceOrder = ['west', 'east', 'up', 'down', 'north', 'south']; + static boxWidthSide: Record = { + east: 'z', + west: 'z', + up: 'x', + down: 'x', + south: 'x', + north: 'x', + }; + + static boxHeightSide: Record = { + east: 'y', + west: 'y', + up: 'z', + down: 'z', + south: 'y', + north: 'y', + }; + + static planeWidthSide: Record = { + east: 'x', + west: 'x', + up: 'x', + down: 'x', + south: 'x', + north: 'x', + }; + + static planeHeightSide: Record = { + east: 'y', + west: 'y', + up: 'y', + down: 'y', + south: 'y', + north: 'y', + }; + + /** + * @param sizeU Size of quad U + * @param sizeV Size of quad V + * @param txU Texture width + * @param txV Texture height + * @param setU Specified U offset + * @param setV Specified V offset + * @param invertU Invert U axis + * @param invertV Invert V axis + */ + static uvOffsetConvert( + sizeU: number, + sizeV: number, + txU: number, + txV: number, + setU: number = 0, + setV: number = 0, + invertU = false, + invertV = false, + ) { + /* + * UV coordinate system + * 0,1 1,1 + * V + * | + * | + * | + * | + * +-----------U + * 0,0 1,0 + */ + + /* + * txU equivalent to 1 + * txV equivalent to 1 + * + * 0 txU + * 0 -------------------- 1 + * | | + * setU +sizeU + * u1 u2 + */ + + const uStart = convertRange(setU, [0, txU], [0, 1]); + const uEnd = convertRange(setU + sizeU, [0, txU], [0, 1]); + const vStart = 1 - convertRange(setV, [0, txV], [0, 1]); + const vEnd = 1 - convertRange(setV + sizeV, [0, txV], [0, 1]); + + return new Vector4( + invertU ? uEnd : uStart, + invertU ? uStart : uEnd, + invertV ? vEnd : vStart, + invertV ? vStart : vEnd, + ); + } + + static unwrapBox(size: Vector3) { + const widths = UVMapper.boxFaceOrder.map( + (item) => size[UVMapper.boxWidthSide[item]], + ); + const heights = UVMapper.boxFaceOrder.map( + (item) => size[UVMapper.boxHeightSide[item]], + ); + const layout: any = { + up: { x: 'west', y: 0 }, + down: { x: 'up', y: 0 }, + + west: { x: 0, y: 'up' }, + south: { x: 'west', y: 'up' }, + east: { x: 'south', y: 'down' }, + north: { x: 'east', y: 'down' }, + }; + Object.keys(layout).forEach((key) => { + const entry = layout[key]; + if (typeof entry.x === 'string') { + const prev = layout[entry.x]; + entry.x = prev.x + widths[UVMapper.boxFaceOrder.indexOf(entry.x)]; + } + + if (typeof entry.y === 'string') { + const prev = layout[entry.y]; + entry.y = prev.y + heights[UVMapper.boxFaceOrder.indexOf(entry.y)]; + } + }); + return layout; + } + + static mapBoxUVs( + size: Vector3, + geometry: BoxGeometry, + texture?: MsonTexture, + mirror?: boolean | [boolean, boolean, boolean], + ) { + const uvAttribute = geometry.getAttribute('uv') as BufferAttribute; + const index = geometry.getIndex() as BufferAttribute; + const uv = new Vector2(); + + const layout = UVMapper.unwrapBox(size); + + let textureWidth = texture?.w ?? 64; + let textureHeight = texture?.h ?? 64; + let textureOffsetU = texture?.u ?? 0; + let textureOffsetV = texture?.v ?? 0; + + const invU: string[] = ['up']; + const invV: string[] = ['up']; + + const mirroring = + mirror !== undefined + ? Array.isArray(mirror) + ? mirror + : [mirror, mirror, mirror] + : [false, false, false]; + + // TODO: figure out what rest of the components mirror + const [flipX, flipY, flipZ] = mirroring; + if (flipX) { + invU.push('south', 'north'); + } + + // px nx py ny pz nz + // clockwise count + for (let i = 0; i < index!.count; i += 3) { + const faceName = UVMapper.boxFaceOrder[Math.floor(i / 6)]; + const triIndex = Math.ceil((i / 6) % 1); + const sizeU = size[UVMapper.boxWidthSide[faceName]]; + const sizeV = size[UVMapper.boxHeightSide[faceName]]; + const addVOffset = layout[faceName].y; + const addUOffset = layout[faceName].x; + let finalU = textureOffsetU + addUOffset; + let finalV = textureOffsetV + addVOffset; + const faceCoords = UVMapper.uvOffsetConvert( + sizeU, + sizeV, + textureWidth, + textureHeight, + finalU, + finalV, + invU.includes(faceName), + invV.includes(faceName), + ); + + for (let attrib = 0; attrib < 3; attrib++) { + const a = index?.getX(i + attrib); + uv.fromBufferAttribute(uvAttribute, a); + + // -> bottom left, top left, bottom right + if (triIndex === 0) { + if (attrib === 0) uv.set(faceCoords.x, faceCoords.z); + if (attrib === 1) uv.set(faceCoords.x, faceCoords.w); + if (attrib === 2) uv.set(faceCoords.y, faceCoords.z); + } + + // -> top left, top right, bottom right + if (triIndex === 1) { + if (attrib === 0) uv.set(faceCoords.x, faceCoords.w); + if (attrib === 1) uv.set(faceCoords.y, faceCoords.w); + if (attrib === 2) uv.set(faceCoords.y, faceCoords.z); + } + uvAttribute.setXY(a, uv.x, uv.y); + } + } + } + + static mapPlanarUvs( + face: MsonFace, + size: Vector2, + geometry: PlaneGeometry, + texture?: MsonTexture, + mirror?: boolean | [boolean, boolean], + ) { + const uvAttribute = geometry.getAttribute('uv') as BufferAttribute; + const index = geometry.getIndex() as BufferAttribute; + const uv = new Vector2(); + + let textureWidth = texture?.w ?? 64; + let textureHeight = texture?.h ?? 64; + let textureOffsetU = texture?.u ?? 0; + let textureOffsetV = texture?.v ?? 0; + + const mirroring = + mirror !== undefined + ? Array.isArray(mirror) + ? mirror + : [mirror, mirror] + : [false, false]; + + for (let i = 0; i < index!.count; i += 3) { + const triIndex = Math.ceil((i / 6) % 1); + const sizeU = size[UVMapper.planeWidthSide[face]]; + const sizeV = size[UVMapper.planeHeightSide[face]]; + let finalU = textureOffsetU; + let finalV = textureOffsetV; + const faceCoords = UVMapper.uvOffsetConvert( + sizeU, + sizeV, + textureWidth, + textureHeight, + finalU, + finalV, + !mirroring[0], + mirroring[1], + ); + + for (let attrib = 0; attrib < 3; attrib++) { + const a = index?.getX(i + attrib); + uv.fromBufferAttribute(uvAttribute, a); + + // -> bottom left, top left, bottom right + if (triIndex === 0) { + if (attrib === 0) uv.set(faceCoords.x, faceCoords.z); + if (attrib === 1) uv.set(faceCoords.x, faceCoords.w); + if (attrib === 2) uv.set(faceCoords.y, faceCoords.z); + } + + // -> top left, top right, bottom right + if (triIndex === 1) { + if (attrib === 0) uv.set(faceCoords.x, faceCoords.w); + if (attrib === 1) uv.set(faceCoords.y, faceCoords.w); + if (attrib === 2) uv.set(faceCoords.y, faceCoords.z); + } + uvAttribute.setXY(a, uv.x, uv.y); + } + } + } +} diff --git a/src/util/node.ts b/src/util/node.ts index ea7ab15..8279e94 100644 --- a/src/util/node.ts +++ b/src/util/node.ts @@ -1,4 +1,4 @@ -import { ModelStore } from '../mson'; +import { ModelStore, MsonEvaluatedModel } from '../mson'; import { promises as fs } from 'node:fs'; import { join } from 'node:path'; import { Object3D } from 'three'; @@ -54,3 +54,21 @@ export const saveGeometry = async ( join(root, `${name}.json`), JSON.stringify(object.toJSON()), ); + +export const saveModel = async ( + root: string, + name: string, + data: MsonEvaluatedModel, +) => + await fs.writeFile( + join(root, `${name}.mson.json`), + JSON.stringify( + { + texture: data.texture, + locals: data._localsEvaluated, + data: data._dataEvaluated, + }, + undefined, + 2, + ), + ); diff --git a/src/util/num-range.ts b/src/util/num-range.ts new file mode 100644 index 0000000..5878d3c --- /dev/null +++ b/src/util/num-range.ts @@ -0,0 +1,7 @@ +export function convertRange( + value: number, + r1: [number, number], + r2: [number, number], +) { + return ((value - r1[0]) * (r2[1] - r2[0])) / (r1[1] - r1[0]) + r2[0]; +}