diff --git a/src/data_formats/cursor/ArrayBufferCursor.ts b/src/data_formats/cursor/ArrayBufferCursor.ts index bcdf420a..e8b6d92e 100644 --- a/src/data_formats/cursor/ArrayBufferCursor.ts +++ b/src/data_formats/cursor/ArrayBufferCursor.ts @@ -237,7 +237,10 @@ export class ArrayBufferCursor implements Cursor { } array_buffer(size: number = this.size - this.position): ArrayBuffer { - const r = this.buffer.slice(this.offset + this.position, size); + const r = this.buffer.slice( + this.offset + this.position, + this.offset + this.position + size + ); this._position += size; return r; } diff --git a/src/data_formats/cursor/ResizableBufferCursor.ts b/src/data_formats/cursor/ResizableBufferCursor.ts index 3abb0d9d..84fbbaf2 100644 --- a/src/data_formats/cursor/ResizableBufferCursor.ts +++ b/src/data_formats/cursor/ResizableBufferCursor.ts @@ -271,7 +271,10 @@ export class ResizableBufferCursor implements Cursor { array_buffer(size: number = this.size - this.position): ArrayBuffer { this.check_size("size", size, size); - const r = this.buffer.backing_buffer.slice(this.offset + this.position, size); + const r = this.buffer.backing_buffer.slice( + this.offset + this.position, + this.offset + this.position + size + ); this._position += size; return r; } diff --git a/src/data_formats/parsing/iff.ts b/src/data_formats/parsing/iff.ts new file mode 100644 index 00000000..507ead40 --- /dev/null +++ b/src/data_formats/parsing/iff.ts @@ -0,0 +1,34 @@ +import { Cursor } from "../cursor/Cursor"; + +export type IffChunk = { + /** + * 32-bit unsigned integer. + */ + type: number; + data: Cursor; +}; + +/** + * PSO uses a little endian variant of the IFF format. + * IFF files contain chunks preceded by an 8-byte header. + * The header consists of 4 ASCII characters for the "Type ID" and a 32-bit integer specifying the chunk size. + */ +export function parse_iff(cursor: Cursor): IffChunk[] { + const chunks: IffChunk[] = []; + + while (cursor.bytes_left) { + const type = cursor.u32(); + const size = cursor.u32(); + + if (size > cursor.bytes_left) { + break; + } + + chunks.push({ + type, + data: cursor.take(size), + }); + } + + return chunks; +} diff --git a/src/data_formats/parsing/ninja/index.ts b/src/data_formats/parsing/ninja/index.ts index 95c91a0f..df3fbc46 100644 --- a/src/data_formats/parsing/ninja/index.ts +++ b/src/data_formats/parsing/ninja/index.ts @@ -2,6 +2,7 @@ import { Cursor } from "../../cursor/Cursor"; import { Vec3 } from "../../Vec3"; import { NjcmModel, parse_njcm_model } from "./njcm"; import { parse_xj_model, XjModel } from "./xj"; +import { parse_iff } from "../iff"; // TODO: // - deal with multiple NJCM chunks @@ -9,6 +10,8 @@ import { parse_xj_model, XjModel } from "./xj"; export const ANGLE_TO_RAD = (2 * Math.PI) / 0xffff; +const NJCM = 0x4d434a4e; + export type NjVertex = { position: Vec3; normal?: Vec3; @@ -123,25 +126,9 @@ function parse_ninja( parse_model: (cursor: Cursor, context: any) => M, context: any ): NjObject[] { - while (cursor.bytes_left) { - // Ninja uses a little endian variant of the IFF format. - // IFF files contain chunks preceded by an 8-byte header. - // The header consists of 4 ASCII characters for the "Type ID" and a 32-bit integer specifying the chunk size. - const iff_type_id = cursor.string_ascii(4, false, false); - const iff_chunk_size = cursor.u32(); - - if (iff_type_id === "NJCM") { - return parse_sibling_objects(cursor.take(iff_chunk_size), parse_model, context); - } else { - if (iff_chunk_size > cursor.bytes_left) { - break; - } - - cursor.seek(iff_chunk_size); - } - } - - return []; + return parse_iff(cursor) + .filter(chunk => chunk.type === NJCM) + .flatMap(chunk => parse_sibling_objects(chunk.data, parse_model, context)); } // TODO: cache model and object offsets so we don't reparse the same data. diff --git a/src/data_formats/parsing/ninja/motion.ts b/src/data_formats/parsing/ninja/motion.ts index 5767184e..211ce12a 100644 --- a/src/data_formats/parsing/ninja/motion.ts +++ b/src/data_formats/parsing/ninja/motion.ts @@ -2,6 +2,8 @@ import { ANGLE_TO_RAD } from "."; import { Cursor } from "../../cursor/Cursor"; import { Vec3 } from "../../Vec3"; +const NMDM = 0x4d444d4e; + export type NjMotion = { motion_data: NjMotionData[]; frame_count: number; @@ -65,7 +67,7 @@ export type NjKeyframeA = { }; export function parse_njm(cursor: Cursor, bone_count: number): NjMotion { - if (cursor.string_ascii(4, false, true) === "NMDM") { + if (cursor.u32() === NMDM) { return parse_njm_v2(cursor, bone_count); } else { cursor.seek_start(0); diff --git a/src/data_formats/parsing/ninja/texture.ts b/src/data_formats/parsing/ninja/texture.ts new file mode 100644 index 00000000..be5aa3c9 --- /dev/null +++ b/src/data_formats/parsing/ninja/texture.ts @@ -0,0 +1,71 @@ +import Logger from "js-logger"; +import { Cursor } from "../../cursor/Cursor"; +import { parse_iff } from "../iff"; + +const logger = Logger.get("data_formats/parsing/ninja/texture"); + +export type Xvm = { + textures: Texture[]; +}; + +export type Texture = { + id: number; + format: [number, number]; + width: number; + height: number; + size: number; + data: ArrayBuffer; +}; + +type Header = { + texture_count: number; +}; + +const XVMH = 0x484d5658; +const XVRT = 0x54525658; + +export function parse_xvm(cursor: Cursor): Xvm { + const chunks = parse_iff(cursor); + const header_chunk = chunks.find(chunk => chunk.type === XVMH); + const header = header_chunk && parse_header(header_chunk.data); + + const textures = chunks + .filter(chunk => chunk.type === XVRT) + .map(chunk => parse_texture(chunk.data)); + + if (!header) { + logger.warn("No header found."); + } else if (header.texture_count !== textures.length) { + logger.warn( + `Found ${textures.length} textures instead of ${header.texture_count} as defined in the header.` + ); + } + + return { textures }; +} + +function parse_header(cursor: Cursor): Header { + const texture_count = cursor.u16(); + return { + texture_count, + }; +} + +function parse_texture(cursor: Cursor): Texture { + const format_1 = cursor.u32(); + const format_2 = cursor.u32(); + const id = cursor.u32(); + const width = cursor.u16(); + const height = cursor.u16(); + const size = cursor.u32(); + cursor.seek(36); + const data = cursor.array_buffer(size); + return { + id, + format: [format_1, format_2], + width, + height, + size, + data, + }; +} diff --git a/src/data_formats/parsing/ninja/xj.ts b/src/data_formats/parsing/ninja/xj.ts index dda64b75..3d092dd6 100644 --- a/src/data_formats/parsing/ninja/xj.ts +++ b/src/data_formats/parsing/ninja/xj.ts @@ -14,12 +14,12 @@ const logger = Logger.get("data_formats/parsing/ninja/xj"); export type XjModel = { type: "xj"; vertices: NjVertex[]; - strips: XjTriangleStrip[]; + meshes: XjMesh[]; collision_sphere_position: Vec3; collision_sphere_radius: number; }; -export type XjTriangleStrip = { +export type XjMesh = { indices: number[]; }; @@ -37,7 +37,7 @@ export function parse_xj_model(cursor: Cursor): XjModel { const model: XjModel = { type: "xj", vertices: [], - strips: [], + meshes: [], collision_sphere_position, collision_sphere_radius, }; @@ -74,13 +74,13 @@ export function parse_xj_model(cursor: Cursor): XjModel { } if (triangle_strip_table_offset) { - model.strips.push( + model.meshes.push( ...parse_triangle_strip_table(cursor, triangle_strip_table_offset, triangle_strip_count) ); } if (transparent_triangle_strip_table_offset) { - model.strips.push( + model.meshes.push( ...parse_triangle_strip_table( cursor, transparent_triangle_strip_table_offset, @@ -96,20 +96,22 @@ function parse_triangle_strip_table( cursor: Cursor, triangle_strip_list_offset: number, triangle_strip_count: number -): XjTriangleStrip[] { - const strips: XjTriangleStrip[] = []; +): XjMesh[] { + const strips: XjMesh[] = []; for (let i = 0; i < triangle_strip_count; ++i) { cursor.seek_start(triangle_strip_list_offset + i * 20); - cursor.seek(8); // Skip flag_and_texture_id_offset and data_type. + + cursor.seek(8); // Skipping flag_and_texture_id_offset and data_type? const index_list_offset = cursor.u32(); const index_count = cursor.u32(); - // Ignoring 4 bytes. cursor.seek_start(index_list_offset); const indices = cursor.u16_array(index_count); - strips.push({ indices }); + strips.push({ + indices, + }); } return strips; diff --git a/src/index.less b/src/index.less index 776bc304..dfe26c51 100644 --- a/src/index.less +++ b/src/index.less @@ -1,5 +1,5 @@ -@import '~antd/dist/antd.less'; -@import 'ui/theme.less'; +@import "~antd/dist/antd.less"; +@import "ui/theme.less"; #phantasmal-world-root { position: absolute; @@ -41,4 +41,8 @@ & .ReactVirtualized__Table__headerRow { text-transform: none; } + + .ant-tabs-bar { + margin: 0; + } } diff --git a/src/read_file.ts b/src/read_file.ts new file mode 100644 index 00000000..51c48317 --- /dev/null +++ b/src/read_file.ts @@ -0,0 +1,15 @@ +export async function read_file(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.addEventListener("loadend", () => { + if (reader.result instanceof ArrayBuffer) { + resolve(reader.result); + } else { + reject(new Error("Couldn't read file.")); + } + }); + + reader.readAsArrayBuffer(file); + }); +} diff --git a/src/rendering/ModelRenderer.ts b/src/rendering/ModelRenderer.ts index 50856ea3..61861065 100644 --- a/src/rendering/ModelRenderer.ts +++ b/src/rendering/ModelRenderer.ts @@ -1,5 +1,5 @@ import { autorun } from "mobx"; -import { Clock, SkeletonHelper, SkinnedMesh, Vector3 } from "three"; +import { Clock, Object3D, SkeletonHelper, Vector3, PerspectiveCamera } from "three"; import { model_viewer_store } from "../stores/ModelViewerStore"; import { Renderer } from "./Renderer"; @@ -10,14 +10,18 @@ export function get_model_renderer(): ModelRenderer { return renderer; } -export class ModelRenderer extends Renderer { +export class ModelRenderer extends Renderer { private clock = new Clock(); - private model?: SkinnedMesh; + private model?: Object3D; private skeleton_helper?: SkeletonHelper; constructor() { - super(); + super(new PerspectiveCamera(75, 1, 1, 200)); + + autorun(() => { + this.set_model(model_viewer_store.current_obj3d); + }); autorun(() => { const show_skeleton = model_viewer_store.show_skeleton; @@ -41,26 +45,10 @@ export class ModelRenderer extends Renderer { }); } - set_model(model?: SkinnedMesh): void { - if (this.model !== model) { - if (this.model) { - this.scene.remove(this.model); - this.scene.remove(this.skeleton_helper!); - this.skeleton_helper = undefined; - } - - if (model) { - this.scene.add(model); - this.skeleton_helper = new SkeletonHelper(model); - this.skeleton_helper.visible = model_viewer_store.show_skeleton; - (this.skeleton_helper.material as any).linewidth = 3; - this.scene.add(this.skeleton_helper); - this.reset_camera(new Vector3(0, 10, 20), new Vector3(0, 0, 0)); - } - - this.model = model; - this.schedule_render(); - } + set_size(width: number, height: number): void { + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + super.set_size(width, height); } protected render(): void { @@ -76,4 +64,24 @@ export class ModelRenderer extends Renderer { this.schedule_render(); } } + + private set_model(model?: Object3D): void { + if (this.model) { + this.scene.remove(this.model); + this.scene.remove(this.skeleton_helper!); + this.skeleton_helper = undefined; + } + + if (model) { + this.scene.add(model); + this.skeleton_helper = new SkeletonHelper(model); + this.skeleton_helper.visible = model_viewer_store.show_skeleton; + (this.skeleton_helper.material as any).linewidth = 3; + this.scene.add(this.skeleton_helper); + this.reset_camera(new Vector3(0, 10, 20), new Vector3(0, 0, 0)); + } + + this.model = model; + this.schedule_render(); + } } diff --git a/src/rendering/QuestRenderer.ts b/src/rendering/QuestRenderer.ts index 002a108e..d4603292 100644 --- a/src/rendering/QuestRenderer.ts +++ b/src/rendering/QuestRenderer.ts @@ -8,6 +8,7 @@ import { Raycaster, Vector2, Vector3, + PerspectiveCamera, } from "three"; import { Vec3 } from "../data_formats/Vec3"; import { Area, Quest, QuestEntity, QuestNpc, Section } from "../domain"; @@ -43,7 +44,7 @@ type EntityUserData = { entity: QuestEntity; }; -export class QuestRenderer extends Renderer { +export class QuestRenderer extends Renderer { private raycaster = new Raycaster(); private quest?: Quest; @@ -59,7 +60,7 @@ export class QuestRenderer extends Renderer { private selected_data?: PickEntityResult; constructor() { - super(); + super(new PerspectiveCamera(75, 1, 0.1, 5000)); this.dom_element.addEventListener("mousedown", this.on_mouse_down); this.dom_element.addEventListener("mouseup", this.on_mouse_up); @@ -67,24 +68,25 @@ export class QuestRenderer extends Renderer { this.scene.add(this.obj_geometry); this.scene.add(this.npc_geometry); + + autorun(() => { + this.set_quest_and_area( + quest_editor_store.current_quest, + quest_editor_store.current_area + ); + }); } set_quest_and_area(quest?: Quest, area?: Area): void { - let update = false; + this.area = area; + this.quest = quest; + this.update_geometry(); + } - if (this.area !== area) { - this.area = area; - update = true; - } - - if (this.quest !== quest) { - this.quest = quest; - update = true; - } - - if (update) { - this.update_geometry(); - } + set_size(width: number, height: number): void { + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + super.set_size(width, height); } private async update_geometry(): Promise { diff --git a/src/rendering/Renderer.ts b/src/rendering/Renderer.ts index 6fe5085a..9420f8ee 100644 --- a/src/rendering/Renderer.ts +++ b/src/rendering/Renderer.ts @@ -1,21 +1,21 @@ import * as THREE from "three"; import { + Camera, Color, + Group, HemisphereLight, MOUSE, - PerspectiveCamera, Scene, + Vector2, Vector3, WebGLRenderer, - Vector2, - Group, } from "three"; import OrbitControlsCreator from "three-orbit-controls"; const OrbitControls = OrbitControlsCreator(THREE); -export class Renderer { - protected camera: PerspectiveCamera; +export class Renderer { + protected camera: C; protected controls: any; protected scene = new Scene(); protected light_holder = new Group(); @@ -24,10 +24,10 @@ export class Renderer { private render_scheduled = false; private light = new HemisphereLight(0xffffff, 0x505050, 1); - constructor() { + constructor(camera: C) { this.renderer.setPixelRatio(window.devicePixelRatio); - this.camera = new PerspectiveCamera(75, 1, 0.1, 5000); + this.camera = camera; this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.mouseButtons.ORBIT = MOUSE.RIGHT; @@ -46,8 +46,6 @@ export class Renderer { set_size(width: number, height: number): void { this.renderer.setSize(width, height); - this.camera.aspect = width / height; - this.camera.updateProjectionMatrix(); this.schedule_render(); } diff --git a/src/rendering/TextureRenderer.ts b/src/rendering/TextureRenderer.ts new file mode 100644 index 00000000..5732e8ba --- /dev/null +++ b/src/rendering/TextureRenderer.ts @@ -0,0 +1,136 @@ +import { autorun } from "mobx"; +import { + CompressedTexture, + LinearFilter, + Mesh, + MeshBasicMaterial, + OrthographicCamera, + PlaneGeometry, + RGBA_S3TC_DXT1_Format, + RGBA_S3TC_DXT3_Format, + Vector2, + Vector3, +} from "three"; +import { Texture, Xvm } from "../data_formats/parsing/ninja/texture"; +import { texture_viewer_store } from "../stores/TextureViewerStore"; +import { Renderer } from "./Renderer"; + +let renderer: TextureRenderer | undefined; + +export function get_texture_renderer(): TextureRenderer { + if (!renderer) renderer = new TextureRenderer(); + return renderer; +} + +export class TextureRenderer extends Renderer { + private quad_meshes: Mesh[] = []; + + constructor() { + super(new OrthographicCamera(-400, 400, 300, -300, 1, 10)); + + this.controls.enableRotate = false; + + autorun(() => { + this.scene.remove(...this.quad_meshes); + + const xvm = texture_viewer_store.current_xvm; + + if (xvm) { + this.render_textures(xvm); + } + + this.reset_camera(new Vector3(0, 0, 5), new Vector3()); + this.schedule_render(); + }); + } + + set_size(width: number, height: number): void { + this.camera.left = -Math.floor(width / 2); + this.camera.right = Math.ceil(width / 2); + this.camera.top = Math.floor(height / 2); + this.camera.bottom = -Math.ceil(height / 2); + this.camera.updateProjectionMatrix(); + super.set_size(width, height); + } + + private render_textures = (xvm: Xvm) => { + let total_width = 10 * (xvm.textures.length - 1); // 10px spacing between textures. + let total_height = 0; + + for (const tex of xvm.textures) { + total_width += tex.width; + total_height = Math.max(total_height, tex.height); + } + + let x = -Math.floor(total_width / 2); + const y = -Math.floor(total_height / 2); + + for (const tex of xvm.textures) { + const tex_3js = this.create_texture(tex); + const quad_mesh = new Mesh( + this.create_quad( + x, + y + Math.floor((total_height - tex.height) / 2), + tex.width, + tex.height + ), + new MeshBasicMaterial({ + map: tex_3js, + color: tex_3js ? undefined : 0xff00ff, + transparent: true, + }) + ); + + this.quad_meshes.push(quad_mesh); + this.scene.add(quad_mesh); + + x += 10 + tex.width; + } + }; + + private create_texture(tex: Texture): CompressedTexture | undefined { + const texture_3js = new CompressedTexture( + [ + { + data: new Uint8Array(tex.data) as any, + width: tex.width, + height: tex.height, + }, + ], + tex.width, + tex.height + ); + + switch (tex.format[1]) { + case 6: + texture_3js.format = RGBA_S3TC_DXT1_Format as any; + break; + case 7: + if (tex.format[0] === 2) { + texture_3js.format = RGBA_S3TC_DXT3_Format as any; + } else { + return undefined; + } + break; + default: + return undefined; + } + + texture_3js.minFilter = LinearFilter; + texture_3js.needsUpdate = true; + + return texture_3js; + } + + private create_quad(x: number, y: number, width: number, height: number): PlaneGeometry { + const quad = new PlaneGeometry(width, height, 1, 1); + quad.faceVertexUvs = [ + [ + [new Vector2(0, 0), new Vector2(0, 1), new Vector2(1, 0)], + [new Vector2(0, 1), new Vector2(1, 1), new Vector2(1, 0)], + ], + ]; + quad.translate(x + width / 2, y + height / 2, -5); + return quad; + } +} diff --git a/src/rendering/areas.ts b/src/rendering/areas.ts index b018c83d..8f46d87a 100644 --- a/src/rendering/areas.ts +++ b/src/rendering/areas.ts @@ -132,7 +132,7 @@ export function area_geometry_to_sections_and_object_3d( new MeshLambertMaterial({ color: 0x44aaff, transparent: true, - opacity: 0.25, + opacity: 0.75, side: DoubleSide, }) ); diff --git a/src/rendering/xj_model_to_geometry.ts b/src/rendering/xj_model_to_geometry.ts index 45f14160..75cdb9af 100644 --- a/src/rendering/xj_model_to_geometry.ts +++ b/src/rendering/xj_model_to_geometry.ts @@ -12,8 +12,6 @@ export function xj_model_to_geometry( indices: number[] ): void { const index_offset = positions.length / 3; - let clockwise = true; - const normal_matrix = new Matrix3().getNormalMatrix(matrix); for (let { position, normal } of model.vertices) { @@ -25,13 +23,13 @@ export function xj_model_to_geometry( normals.push(n.x, n.y, n.z); } - for (const mesh of model.strips) { - const strip_indices = mesh.indices; + for (const mesh of model.meshes) { + let clockwise = true; - for (let j = 2; j < strip_indices.length; ++j) { - const a = index_offset + strip_indices[j - 2]; - const b = index_offset + strip_indices[j - 1]; - const c = index_offset + strip_indices[j]; + for (let j = 2; j < mesh.indices.length; ++j) { + const a = index_offset + mesh.indices[j - 2]; + const b = index_offset + mesh.indices[j - 1]; + const c = index_offset + mesh.indices[j]; const pa = new Vector3(positions[3 * a], positions[3 * a + 1], positions[3 * a + 2]); const pb = new Vector3(positions[3 * b], positions[3 * b + 1], positions[3 * b + 2]); const pc = new Vector3(positions[3 * c], positions[3 * c + 1], positions[3 * c + 2]); @@ -70,25 +68,6 @@ export function xj_model_to_geometry( } clockwise = !clockwise; - - // The following switch statement fixes model 180.xj (zanba). - // switch (j) { - // case 17: - // case 52: - // case 70: - // case 92: - // case 97: - // case 126: - // case 140: - // case 148: - // case 187: - // case 200: - // console.warn(`swapping winding at: ${j}, (${a}, ${b}, ${c})`); - // break; - // default: - // ccw = !ccw; - // break; - // } } } } diff --git a/src/stores/ModelViewerStore.ts b/src/stores/ModelViewerStore.ts index b053fab6..56067556 100644 --- a/src/stores/ModelViewerStore.ts +++ b/src/stores/ModelViewerStore.ts @@ -1,14 +1,23 @@ import Logger from "js-logger"; import { action, observable } from "mobx"; -import { AnimationAction, AnimationClip, AnimationMixer, SkinnedMesh } from "three"; +import { + AnimationAction, + AnimationClip, + AnimationMixer, + DoubleSide, + Mesh, + MeshLambertMaterial, + SkinnedMesh, +} from "three"; +import { Endianness } from "../data_formats"; +import { ArrayBufferCursor } from "../data_formats/cursor/ArrayBufferCursor"; import { NjModel, NjObject, parse_nj, parse_xj } from "../data_formats/parsing/ninja"; import { NjMotion, parse_njm } from "../data_formats/parsing/ninja/motion"; import { PlayerAnimation, PlayerModel } from "../domain"; +import { read_file } from "../read_file"; import { create_animation_clip, PSO_FRAME_RATE } from "../rendering/animation"; -import { ninja_object_to_skinned_mesh } from "../rendering/models"; +import { ninja_object_to_buffer_geometry, ninja_object_to_skinned_mesh } from "../rendering/models"; import { get_player_animation_data, get_player_data } from "./binary_assets"; -import { ArrayBufferCursor } from "../data_formats/cursor/ArrayBufferCursor"; -import { Endianness } from "../data_formats"; const logger = Logger.get("stores/ModelViewerStore"); const nj_object_cache: Map>> = new Map(); @@ -36,7 +45,7 @@ class ModelViewerStore { @observable.ref current_player_model?: PlayerModel; @observable.ref current_model?: NjObject; @observable.ref current_bone_count: number = 0; - @observable.ref current_obj3d?: SkinnedMesh; + @observable.ref current_obj3d?: Mesh; @observable.ref animation?: { player_animation?: PlayerAnimation; @@ -70,7 +79,7 @@ class ModelViewerStore { load_model = async (model: PlayerModel) => { const object = await this.get_player_ninja_object(model); - this.set_model(object, model); + this.set_model(object, true, model); // Ignore the bones from the head parts. this.current_bone_count = 64; }; @@ -83,12 +92,29 @@ class ModelViewerStore { } }; - load_file = (file: File) => { - const reader = new FileReader(); - reader.addEventListener("loadend", () => { - this.loadend(file, reader); - }); - reader.readAsArrayBuffer(file); + // TODO: notify user of problems. + load_file = async (file: File) => { + try { + const buffer = await read_file(file); + const cursor = new ArrayBufferCursor(buffer, Endianness.Little); + + if (file.name.endsWith(".nj")) { + const model = parse_nj(cursor)[0]; + this.set_model(model, true); + } else if (file.name.endsWith(".xj")) { + const model = parse_xj(cursor)[0]; + this.set_model(model, false); + } else if (file.name.endsWith(".njm")) { + if (this.current_model) { + const njm = parse_njm(cursor, this.current_bone_count); + this.set_animation(create_animation_clip(this.current_model, njm)); + } + } else { + logger.error(`Unknown file extension in filename "${file.name}".`); + } + } catch (e) { + logger.error("Couldn't read file.", e); + } }; toggle_animation_playing = action("toggle_animation_playing", () => { @@ -106,7 +132,7 @@ class ModelViewerStore { }); set_animation = action("set_animation", (clip: AnimationClip, animation?: PlayerAnimation) => { - if (!this.current_obj3d) return; + if (!this.current_obj3d || !(this.current_obj3d instanceof SkinnedMesh)) return; let mixer: AnimationMixer; @@ -131,7 +157,7 @@ class ModelViewerStore { private set_model = action( "set_model", - (model: NjObject, player_model?: PlayerModel) => { + (model: NjObject, skeleton: boolean, player_model?: PlayerModel) => { if (this.current_obj3d && this.animation) { this.animation.mixer.stopAllAction(); this.animation.mixer.uncacheRoot(this.current_obj3d); @@ -142,37 +168,25 @@ class ModelViewerStore { this.current_model = model; this.current_bone_count = model.bone_count(); - const mesh = ninja_object_to_skinned_mesh(this.current_model); + let mesh: Mesh; + + if (skeleton) { + mesh = ninja_object_to_skinned_mesh(this.current_model); + } else { + mesh = new Mesh( + ninja_object_to_buffer_geometry(this.current_model), + new MeshLambertMaterial({ + color: 0xff00ff, + side: DoubleSide, + }) + ); + } + mesh.translateY(-mesh.geometry.boundingSphere.radius); this.current_obj3d = mesh; } ); - // TODO: notify user of problems. - private loadend = async (file: File, reader: FileReader) => { - if (!(reader.result instanceof ArrayBuffer)) { - logger.error("Couldn't read file."); - return; - } - - const cursor = new ArrayBufferCursor(reader.result, Endianness.Little); - - if (file.name.endsWith(".nj")) { - const model = parse_nj(cursor)[0]; - this.set_model(model); - } else if (file.name.endsWith(".xj")) { - const model = parse_xj(cursor)[0]; - this.set_model(model); - } else if (file.name.endsWith(".njm")) { - if (this.current_model) { - const njm = parse_njm(cursor, this.current_bone_count); - this.set_animation(create_animation_clip(this.current_model, njm)); - } - } else { - logger.error(`Unknown file extension in filename "${file.name}".`); - } - }; - private add_to_bone( object: NjObject, head_part: NjObject, diff --git a/src/stores/QuestEditorStore.ts b/src/stores/QuestEditorStore.ts index 4f21c799..6ecb9dc0 100644 --- a/src/stores/QuestEditorStore.ts +++ b/src/stores/QuestEditorStore.ts @@ -8,6 +8,7 @@ import { area_store } from "./AreaStore"; import { entity_store } from "./EntityStore"; import { ArrayBufferCursor } from "../data_formats/cursor/ArrayBufferCursor"; import { Endianness } from "../data_formats"; +import { read_file } from "../read_file"; const logger = Logger.get("stores/QuestEditorStore"); @@ -48,58 +49,50 @@ class QuestEditorStore { } }); - load_file = (file: File) => { - const reader = new FileReader(); - reader.addEventListener("loadend", () => { - this.loadend(reader); - }); - reader.readAsArrayBuffer(file); - }; - // TODO: notify user of problems. - private loadend = async (reader: FileReader) => { - if (!(reader.result instanceof ArrayBuffer)) { - logger.error("Couldn't read file."); - return; - } + load_file = async (file: File) => { + try { + const buffer = await read_file(file); + const quest = parse_quest(new ArrayBufferCursor(buffer, Endianness.Little)); + this.set_quest(quest); - const quest = parse_quest(new ArrayBufferCursor(reader.result, Endianness.Little)); - this.set_quest(quest); + if (quest) { + // Load section data. + for (const variant of quest.area_variants) { + const sections = await area_store.get_area_sections( + quest.episode, + variant.area.id, + variant.id + ); + variant.sections = sections; - if (quest) { - // Load section data. - for (const variant of quest.area_variants) { - const sections = await area_store.get_area_sections( - quest.episode, - variant.area.id, - variant.id - ); - variant.sections = sections; + // Generate object geometry. + for (const object of quest.objects.filter(o => o.area_id === variant.area.id)) { + try { + const object_geom = await entity_store.get_object_geometry(object.type); + this.set_section_on_visible_quest_entity(object, sections); + object.object_3d = create_object_mesh(object, object_geom); + } catch (e) { + logger.error(e); + } + } - // Generate object geometry. - for (const object of quest.objects.filter(o => o.area_id === variant.area.id)) { - try { - const object_geom = await entity_store.get_object_geometry(object.type); - this.set_section_on_visible_quest_entity(object, sections); - object.object_3d = create_object_mesh(object, object_geom); - } catch (e) { - logger.error(e); - } - } - - // Generate NPC geometry. - for (const npc of quest.npcs.filter(npc => npc.area_id === variant.area.id)) { - try { - const npc_geom = await entity_store.get_npc_geometry(npc.type); - this.set_section_on_visible_quest_entity(npc, sections); - npc.object_3d = create_npc_mesh(npc, npc_geom); - } catch (e) { - logger.error(e); + // Generate NPC geometry. + for (const npc of quest.npcs.filter(npc => npc.area_id === variant.area.id)) { + try { + const npc_geom = await entity_store.get_npc_geometry(npc.type); + this.set_section_on_visible_quest_entity(npc, sections); + npc.object_3d = create_npc_mesh(npc, npc_geom); + } catch (e) { + logger.error(e); + } } } + } else { + logger.error("Couldn't parse quest file."); } - } else { - logger.error("Couldn't parse quest file."); + } catch (e) { + logger.error("Couldn't read file.", e); } }; diff --git a/src/stores/TextureViewerStore.ts b/src/stores/TextureViewerStore.ts new file mode 100644 index 00000000..1de228a5 --- /dev/null +++ b/src/stores/TextureViewerStore.ts @@ -0,0 +1,24 @@ +import { observable } from "mobx"; +import { Xvm, parse_xvm } from "../data_formats/parsing/ninja/texture"; +import { ArrayBufferCursor } from "../data_formats/cursor/ArrayBufferCursor"; +import { read_file } from "../read_file"; +import { Endianness } from "../data_formats"; +import Logger from "js-logger"; + +const logger = Logger.get("stores/TextureViewerStore"); + +class TextureViewStore { + @observable.ref current_xvm?: Xvm; + + // TODO: notify user of problems. + load_file = async (file: File) => { + try { + const buffer = await read_file(file); + this.current_xvm = parse_xvm(new ArrayBufferCursor(buffer, Endianness.Little)); + } catch (e) { + logger.error("Couldn't read file.", e); + } + }; +} + +export const texture_viewer_store = new TextureViewStore(); diff --git a/src/ui/ApplicationComponent.tsx b/src/ui/ApplicationComponent.tsx index 234c1c0d..7586a6e5 100644 --- a/src/ui/ApplicationComponent.tsx +++ b/src/ui/ApplicationComponent.tsx @@ -2,15 +2,15 @@ import { Menu, Select } from "antd"; import { ClickParam } from "antd/lib/menu"; import { observer } from "mobx-react"; import React, { ReactNode } from "react"; +import { Server } from "../domain"; import "./ApplicationComponent.less"; +import { DpsCalcComponent } from "./dps_calc/DpsCalcComponent"; import { with_error_boundary } from "./ErrorBoundary"; import { HuntOptimizerComponent } from "./hunt_optimizer/HuntOptimizerComponent"; import { QuestEditorComponent } from "./quest_editor/QuestEditorComponent"; -import { DpsCalcComponent } from "./dps_calc/DpsCalcComponent"; -import { Server } from "../domain"; -import { ModelViewerComponent } from "./model_viewer/ModelViewerComponent"; +import { ViewerComponent } from "./viewer/ViewerComponent"; -const ModelViewer = with_error_boundary(ModelViewerComponent); +const Viewer = with_error_boundary(ViewerComponent); const QuestEditor = with_error_boundary(QuestEditorComponent); const HuntOptimizer = with_error_boundary(HuntOptimizerComponent); const DpsCalc = with_error_boundary(DpsCalcComponent); @@ -23,8 +23,8 @@ export class ApplicationComponent extends React.Component { let tool_component; switch (this.state.tool) { - case "model_viewer": - tool_component = ; + case "viewer": + tool_component = ; break; case "quest_editor": tool_component = ; @@ -47,8 +47,8 @@ export class ApplicationComponent extends React.Component { selectedKeys={[this.state.tool]} mode="horizontal" > - - Model Viewer(Beta) + + Viewer(Beta) Quest Editor(Beta) @@ -79,6 +79,6 @@ export class ApplicationComponent extends React.Component { .slice(1) .split("&") .find(p => p.startsWith("tool=")); - return param ? param.slice(5) : "model_viewer"; + return param ? param.slice(5) : "viewer"; } } diff --git a/src/ui/RendererComponent.less b/src/ui/RendererComponent.less new file mode 100644 index 00000000..8e96a712 --- /dev/null +++ b/src/ui/RendererComponent.less @@ -0,0 +1,3 @@ +.RendererComponent { + overflow: hidden; +} diff --git a/src/ui/RendererComponent.tsx b/src/ui/RendererComponent.tsx new file mode 100644 index 00000000..f16f6677 --- /dev/null +++ b/src/ui/RendererComponent.tsx @@ -0,0 +1,40 @@ +import React, { Component, ReactNode } from "react"; +import { Renderer } from "../rendering/Renderer"; +import "./RendererComponent.less"; +import { Camera } from "three"; + +export class RendererComponent extends Component<{ + renderer: Renderer; + className?: string; +}> { + render(): ReactNode { + let className = "RendererComponent"; + if (this.props.className) className += " " + this.props.className; + + return
; + } + + componentDidMount(): void { + window.addEventListener("resize", this.onResize); + } + + componentWillUnmount(): void { + window.removeEventListener("resize", this.onResize); + } + + shouldComponentUpdate(): boolean { + return false; + } + + private modifyDom = (div: HTMLDivElement | null) => { + if (div) { + this.props.renderer.set_size(div.clientWidth, div.clientHeight); + div.appendChild(this.props.renderer.dom_element); + } + }; + + private onResize = () => { + const wrapper_div = this.props.renderer.dom_element.parentNode as HTMLDivElement; + this.props.renderer.set_size(wrapper_div.clientWidth, wrapper_div.clientHeight); + }; +} diff --git a/src/ui/hunt_optimizer/OptimizerComponent.css b/src/ui/hunt_optimizer/OptimizerComponent.css index 1c53b948..389668e9 100644 --- a/src/ui/hunt_optimizer/OptimizerComponent.css +++ b/src/ui/hunt_optimizer/OptimizerComponent.css @@ -2,6 +2,7 @@ flex: 1; display: flex; align-items: stretch; + padding-top: 5px; } .ho-OptimizerComponent > *:nth-child(2) { diff --git a/src/ui/model_viewer/RendererComponent.tsx b/src/ui/model_viewer/RendererComponent.tsx deleted file mode 100644 index 7ac51881..00000000 --- a/src/ui/model_viewer/RendererComponent.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React, { ReactNode, Component } from "react"; -import { SkinnedMesh } from "three"; -import { get_model_renderer } from "../../rendering/ModelRenderer"; - -type Props = { - model?: SkinnedMesh; -}; - -export class RendererComponent extends Component { - private renderer = get_model_renderer(); - - render(): ReactNode { - return
; - } - - componentDidMount(): void { - window.addEventListener("resize", this.onResize); - } - - componentWillUnmount(): void { - window.removeEventListener("resize", this.onResize); - } - - componentWillReceiveProps({ model }: Props): void { - this.renderer.set_model(model); - } - - shouldComponentUpdate(): boolean { - return false; - } - - private modifyDom = (div: HTMLDivElement | null) => { - if (div) { - this.renderer.set_size(div.clientWidth, div.clientHeight); - div.appendChild(this.renderer.dom_element); - } - }; - - private onResize = () => { - const wrapper_div = this.renderer.dom_element.parentNode as HTMLDivElement; - this.renderer.set_size(wrapper_div.clientWidth, wrapper_div.clientHeight); - }; -} diff --git a/src/ui/quest_editor/QuestEditorComponent.tsx b/src/ui/quest_editor/QuestEditorComponent.tsx index b604ed3e..684e4dbb 100644 --- a/src/ui/quest_editor/QuestEditorComponent.tsx +++ b/src/ui/quest_editor/QuestEditorComponent.tsx @@ -7,7 +7,8 @@ import { quest_editor_store } from "../../stores/QuestEditorStore"; import { EntityInfoComponent } from "./EntityInfoComponent"; import "./QuestEditorComponent.css"; import { QuestInfoComponent } from "./QuestInfoComponent"; -import { RendererComponent } from "./RendererComponent"; +import { RendererComponent } from "../RendererComponent"; +import { get_quest_renderer } from "../../rendering/QuestRenderer"; @observer export class QuestEditorComponent extends Component< @@ -31,7 +32,7 @@ export class QuestEditorComponent extends Component<
- +
{ - private renderer = get_quest_renderer(); - - render(): ReactNode { - return
; - } - - componentDidMount(): void { - window.addEventListener("resize", this.onResize); - } - - componentWillUnmount(): void { - window.removeEventListener("resize", this.onResize); - } - - componentWillReceiveProps({ quest, area }: Props): void { - this.renderer.set_quest_and_area(quest, area); - } - - shouldComponentUpdate(): boolean { - return false; - } - - private modifyDom = (div: HTMLDivElement | null) => { - if (div) { - this.renderer.set_size(div.clientWidth, div.clientHeight); - div.appendChild(this.renderer.dom_element); - } - }; - - private onResize = () => { - const wrapper_div = this.renderer.dom_element.parentNode as HTMLDivElement; - this.renderer.set_size(wrapper_div.clientWidth, wrapper_div.clientHeight); - }; -} diff --git a/src/ui/viewer/ViewerComponent.less b/src/ui/viewer/ViewerComponent.less new file mode 100644 index 00000000..7cba6343 --- /dev/null +++ b/src/ui/viewer/ViewerComponent.less @@ -0,0 +1,23 @@ +.v-ViewerComponent { + display: flex; + padding-top: 10px; + overflow: hidden; + + & > .ant-tabs { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + + & > .ant-tabs-content { + flex: 1; + overflow: hidden; + + & > .ant-tabs-tabpane-active { + width: 100%; + height: 100%; + overflow: hidden; + } + } + } +} diff --git a/src/ui/viewer/ViewerComponent.tsx b/src/ui/viewer/ViewerComponent.tsx new file mode 100644 index 00000000..9f35f156 --- /dev/null +++ b/src/ui/viewer/ViewerComponent.tsx @@ -0,0 +1,22 @@ +import React, { Component, ReactNode } from "react"; +import { Tabs } from "antd"; +import { ModelViewerComponent } from "./models/ModelViewerComponent"; +import "./ViewerComponent.less"; +import { TextureViewerComponent } from "./textures/TextureViewerComponent"; + +export class ViewerComponent extends Component { + render(): ReactNode { + return ( +
+ + + + + + + + +
+ ); + } +} diff --git a/src/ui/model_viewer/AnimationSelectionComponent.less b/src/ui/viewer/models/AnimationSelectionComponent.less similarity index 93% rename from src/ui/model_viewer/AnimationSelectionComponent.less rename to src/ui/viewer/models/AnimationSelectionComponent.less index e4bf36f7..f1f375ae 100644 --- a/src/ui/model_viewer/AnimationSelectionComponent.less +++ b/src/ui/viewer/models/AnimationSelectionComponent.less @@ -1,4 +1,4 @@ -.mv-AnimationSelectionComponent { +.v-m-AnimationSelectionComponent { margin: 0 10px; & > ul { diff --git a/src/ui/model_viewer/AnimationSelectionComponent.tsx b/src/ui/viewer/models/AnimationSelectionComponent.tsx similarity index 89% rename from src/ui/model_viewer/AnimationSelectionComponent.tsx rename to src/ui/viewer/models/AnimationSelectionComponent.tsx index 87961e00..5388dc37 100644 --- a/src/ui/model_viewer/AnimationSelectionComponent.tsx +++ b/src/ui/viewer/models/AnimationSelectionComponent.tsx @@ -1,5 +1,5 @@ import React, { Component, ReactNode } from "react"; -import { model_viewer_store } from "../../stores/ModelViewerStore"; +import { model_viewer_store } from "../../../stores/ModelViewerStore"; import "./AnimationSelectionComponent.less"; import { observer } from "mobx-react"; @@ -7,7 +7,7 @@ import { observer } from "mobx-react"; export class AnimationSelectionComponent extends Component { render(): ReactNode { return ( -
+
    {model_viewer_store.animations.map(animation => { const selected = diff --git a/src/ui/model_viewer/ModelSelectionComponent.less b/src/ui/viewer/models/ModelSelectionComponent.less similarity index 72% rename from src/ui/model_viewer/ModelSelectionComponent.less rename to src/ui/viewer/models/ModelSelectionComponent.less index 99ecb082..72d17c17 100644 --- a/src/ui/model_viewer/ModelSelectionComponent.less +++ b/src/ui/viewer/models/ModelSelectionComponent.less @@ -1,8 +1,8 @@ -.mv-ModelSelectionComponent { +.v-m-ModelSelectionComponent { margin: 0 10px; } -.mv-ModelSelectionComponent-model { +.v-m-ModelSelectionComponent-model { cursor: pointer; &.selected { diff --git a/src/ui/model_viewer/ModelSelectionComponent.tsx b/src/ui/viewer/models/ModelSelectionComponent.tsx similarity index 85% rename from src/ui/model_viewer/ModelSelectionComponent.tsx rename to src/ui/viewer/models/ModelSelectionComponent.tsx index 9733eae2..62a09abb 100644 --- a/src/ui/model_viewer/ModelSelectionComponent.tsx +++ b/src/ui/viewer/models/ModelSelectionComponent.tsx @@ -1,12 +1,12 @@ import { List } from "antd"; import React, { Component, ReactNode } from "react"; -import { model_viewer_store } from "../../stores/ModelViewerStore"; +import { model_viewer_store } from "../../../stores/ModelViewerStore"; import "./ModelSelectionComponent.less"; export class ModelSelectionComponent extends Component { render(): ReactNode { return ( -
    +
    diff --git a/src/ui/model_viewer/ModelViewerComponent.less b/src/ui/viewer/models/ModelViewerComponent.less similarity index 72% rename from src/ui/model_viewer/ModelViewerComponent.less rename to src/ui/viewer/models/ModelViewerComponent.less index cde4cbfb..ed01420b 100644 --- a/src/ui/model_viewer/ModelViewerComponent.less +++ b/src/ui/viewer/models/ModelViewerComponent.less @@ -1,14 +1,16 @@ -.mv-ModelViewerComponent { +.v-m-ModelViewerComponent { display: flex; flex-direction: column; + width: 100%; + height: 100%; } -.mv-ModelViewerComponent-toolbar { +.v-m-ModelViewerComponent-toolbar { display: flex; padding: 10px 5px; align-items: center; - & > * { + & > * { margin: 0 5px; } @@ -22,7 +24,7 @@ } } -.mv-ModelViewerComponent-main { +.v-m-ModelViewerComponent-main { flex: 1; display: flex; overflow: hidden; diff --git a/src/ui/model_viewer/ModelViewerComponent.tsx b/src/ui/viewer/models/ModelViewerComponent.tsx similarity index 85% rename from src/ui/model_viewer/ModelViewerComponent.tsx rename to src/ui/viewer/models/ModelViewerComponent.tsx index 50dc719a..1f3dca56 100644 --- a/src/ui/model_viewer/ModelViewerComponent.tsx +++ b/src/ui/viewer/models/ModelViewerComponent.tsx @@ -3,11 +3,12 @@ import { UploadChangeParam } from "antd/lib/upload"; import { UploadFile } from "antd/lib/upload/interface"; import { observer } from "mobx-react"; import React, { Component, ReactNode } from "react"; -import { model_viewer_store } from "../../stores/ModelViewerStore"; +import { model_viewer_store } from "../../../stores/ModelViewerStore"; import { AnimationSelectionComponent } from "./AnimationSelectionComponent"; import { ModelSelectionComponent } from "./ModelSelectionComponent"; import "./ModelViewerComponent.less"; -import { RendererComponent } from "./RendererComponent"; +import { get_model_renderer } from "../../../rendering/ModelRenderer"; +import { RendererComponent } from "../../RendererComponent"; @observer export class ModelViewerComponent extends Component { @@ -19,12 +20,12 @@ export class ModelViewerComponent extends Component { render(): ReactNode { return ( -
    +
    -
    +
    - +
    ); @@ -39,11 +40,11 @@ class Toolbar extends Component { render(): ReactNode { return ( -
    +
    false} > @@ -94,7 +95,7 @@ class Toolbar extends Component { ); } - private set_filename = (info: UploadChangeParam) => { + private load_file = (info: UploadChangeParam) => { if (info.file.originFileObj) { this.setState({ filename: info.file.name }); model_viewer_store.load_file(info.file.originFileObj); diff --git a/src/ui/viewer/textures/TextureViewerComponent.less b/src/ui/viewer/textures/TextureViewerComponent.less new file mode 100644 index 00000000..b5d7a084 --- /dev/null +++ b/src/ui/viewer/textures/TextureViewerComponent.less @@ -0,0 +1,18 @@ +.v-t-TextureViewerComponent { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.v-t-TextureViewerComponent-toolbar { + margin: 10px; +} + +.v-t-TextureViewerComponent-renderer { + flex: 1; +} + +.v-t-TextureViewerComponent-canvas { + flex: 1; +} diff --git a/src/ui/viewer/textures/TextureViewerComponent.tsx b/src/ui/viewer/textures/TextureViewerComponent.tsx new file mode 100644 index 00000000..31d646c9 --- /dev/null +++ b/src/ui/viewer/textures/TextureViewerComponent.tsx @@ -0,0 +1,52 @@ +import { Button, Upload } from "antd"; +import { UploadChangeParam, UploadFile } from "antd/lib/upload/interface"; +import { observer } from "mobx-react"; +import React, { Component, ReactNode } from "react"; +import { get_texture_renderer } from "../../../rendering/TextureRenderer"; +import { texture_viewer_store } from "../../../stores/TextureViewerStore"; +import { RendererComponent } from "../../RendererComponent"; +import "./TextureViewerComponent.less"; + +export class TextureViewerComponent extends Component { + render(): ReactNode { + return ( +
    + + +
    + ); + } +} + +@observer +class Toolbar extends Component { + state = { + filename: undefined, + }; + + render(): ReactNode { + return ( +
    + false} + > + + +
    + ); + } + + private load_file = (info: UploadChangeParam) => { + if (info.file.originFileObj) { + this.setState({ filename: info.file.name }); + texture_viewer_store.load_file(info.file.originFileObj); + } + }; +}