Added XVM texture viewer.

This commit is contained in:
Daan Vanden Bosch 2019-07-11 17:30:23 +02:00
parent a60c69a3ef
commit 36cb131920
35 changed files with 666 additions and 315 deletions

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<M extends NjModel>(
parse_model: (cursor: Cursor, context: any) => M,
context: any
): NjObject<M>[] {
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.

View File

@ -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);

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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;
}
}

15
src/read_file.ts Normal file
View File

@ -0,0 +1,15 @@
export async function read_file(file: File): Promise<ArrayBuffer> {
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);
});
}

View File

@ -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<PerspectiveCamera> {
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();
}
}

View File

@ -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<PerspectiveCamera> {
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<void> {

View File

@ -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<C extends Camera> {
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();
}

View File

@ -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<OrthographicCamera> {
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;
}
}

View File

@ -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,
})
);

View File

@ -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;
// }
}
}
}

View File

@ -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<string, Promise<NjObject<NjModel>>> = new Map();
@ -36,7 +45,7 @@ class ModelViewerStore {
@observable.ref current_player_model?: PlayerModel;
@observable.ref current_model?: NjObject<NjModel>;
@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<NjModel>, player_model?: PlayerModel) => {
(model: NjObject<NjModel>, 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<NjModel>,
head_part: NjObject<NjModel>,

View File

@ -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);
}
};

View File

@ -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();

View File

@ -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 = <ModelViewer />;
case "viewer":
tool_component = <Viewer />;
break;
case "quest_editor":
tool_component = <QuestEditor />;
@ -47,8 +47,8 @@ export class ApplicationComponent extends React.Component {
selectedKeys={[this.state.tool]}
mode="horizontal"
>
<Menu.Item key="model_viewer">
Model Viewer<sup className="ApplicationComponent-beta">(Beta)</sup>
<Menu.Item key="viewer">
Viewer<sup className="ApplicationComponent-beta">(Beta)</sup>
</Menu.Item>
<Menu.Item key="quest_editor">
Quest Editor<sup className="ApplicationComponent-beta">(Beta)</sup>
@ -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";
}
}

View File

@ -0,0 +1,3 @@
.RendererComponent {
overflow: hidden;
}

View File

@ -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<Camera>;
className?: string;
}> {
render(): ReactNode {
let className = "RendererComponent";
if (this.props.className) className += " " + this.props.className;
return <div className={className} ref={this.modifyDom} />;
}
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);
};
}

View File

@ -2,6 +2,7 @@
flex: 1;
display: flex;
align-items: stretch;
padding-top: 5px;
}
.ho-OptimizerComponent > *:nth-child(2) {

View File

@ -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<Props> {
private renderer = get_model_renderer();
render(): ReactNode {
return <div style={{ overflow: "hidden" }} ref={this.modifyDom} />;
}
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);
};
}

View File

@ -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<
<Toolbar onSaveAsClicked={this.save_as_clicked} />
<div className="qe-QuestEditorComponent-main">
<QuestInfoComponent quest={quest} />
<RendererComponent quest={quest} area={quest_editor_store.current_area} />
<RendererComponent renderer={get_quest_renderer()} />
<EntityInfoComponent entity={quest_editor_store.selected_entity} />
</div>
<SaveAsForm

View File

@ -1,44 +0,0 @@
import React, { ReactNode, Component } from "react";
import { Area, Quest } from "../../domain";
import { get_quest_renderer } from "../../rendering/QuestRenderer";
type Props = {
quest?: Quest;
area?: Area;
};
export class RendererComponent extends Component<Props> {
private renderer = get_quest_renderer();
render(): ReactNode {
return <div style={{ overflow: "hidden" }} ref={this.modifyDom} />;
}
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);
};
}

View File

@ -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;
}
}
}
}

View File

@ -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 (
<section className="v-ViewerComponent">
<Tabs type="card">
<Tabs.TabPane tab="Models" key="models">
<ModelViewerComponent />
</Tabs.TabPane>
<Tabs.TabPane tab="Textures" key="textures">
<TextureViewerComponent />
</Tabs.TabPane>
</Tabs>
</section>
);
}
}

View File

@ -1,4 +1,4 @@
.mv-AnimationSelectionComponent {
.v-m-AnimationSelectionComponent {
margin: 0 10px;
& > ul {

View File

@ -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 (
<section className="mv-AnimationSelectionComponent">
<section className="v-m-AnimationSelectionComponent">
<ul>
{model_viewer_store.animations.map(animation => {
const selected =

View File

@ -1,8 +1,8 @@
.mv-ModelSelectionComponent {
.v-m-ModelSelectionComponent {
margin: 0 10px;
}
.mv-ModelSelectionComponent-model {
.v-m-ModelSelectionComponent-model {
cursor: pointer;
&.selected {

View File

@ -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 (
<section className="mv-ModelSelectionComponent">
<section className="v-m-ModelSelectionComponent">
<List
itemLayout="horizontal"
dataSource={model_viewer_store.models}
@ -20,7 +20,7 @@ export class ModelSelectionComponent extends Component {
title={
<span
className={
"mv-ModelSelectionComponent-model" +
"v-m-ModelSelectionComponent-model" +
(selected ? " selected" : "")
}
>

View File

@ -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;

View File

@ -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 (
<div className="mv-ModelViewerComponent">
<div className="v-m-ModelViewerComponent">
<Toolbar />
<div className="mv-ModelViewerComponent-main">
<div className="v-m-ModelViewerComponent-main">
<ModelSelectionComponent />
<AnimationSelectionComponent />
<RendererComponent model={model_viewer_store.current_obj3d} />
<RendererComponent renderer={get_model_renderer()} />
</div>
</div>
);
@ -39,11 +40,11 @@ class Toolbar extends Component {
render(): ReactNode {
return (
<div className="mv-ModelViewerComponent-toolbar">
<div className="v-m-ModelViewerComponent-toolbar">
<Upload
accept=".nj, .njm, .xj"
accept=".nj, .njm, .xj, .xvm"
showUploadList={false}
onChange={this.set_filename}
onChange={this.load_file}
// Make sure it doesn't do a POST:
customRequest={() => false}
>
@ -94,7 +95,7 @@ class Toolbar extends Component {
);
}
private set_filename = (info: UploadChangeParam<UploadFile>) => {
private load_file = (info: UploadChangeParam<UploadFile>) => {
if (info.file.originFileObj) {
this.setState({ filename: info.file.name });
model_viewer_store.load_file(info.file.originFileObj);

View File

@ -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;
}

View File

@ -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 (
<section className="v-t-TextureViewerComponent">
<Toolbar />
<RendererComponent
renderer={get_texture_renderer()}
className={"v-t-TextureViewerComponent-renderer"}
/>
</section>
);
}
}
@observer
class Toolbar extends Component {
state = {
filename: undefined,
};
render(): ReactNode {
return (
<div className="v-t-TextureViewerComponent-toolbar">
<Upload
accept=".xvm"
showUploadList={false}
onChange={this.load_file}
// Make sure it doesn't do a POST:
customRequest={() => false}
>
<Button icon="file">{this.state.filename || "Open file..."}</Button>
</Upload>
</div>
);
}
private load_file = (info: UploadChangeParam<UploadFile>) => {
if (info.file.originFileObj) {
this.setState({ filename: info.file.name });
texture_viewer_store.load_file(info.file.originFileObj);
}
};
}