├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── __mock__ ├── README ├── jpngirl01.mdl ├── leet.mdl └── ratamahatta.md2 ├── const ├── constants.ts └── structs.ts ├── dat ├── DatButton.tsx ├── DatColor.tsx ├── DatFile.tsx ├── DatFolder.tsx ├── DatInput.tsx ├── DatItem.tsx ├── DatLabelText.tsx ├── DatNumber.tsx ├── DatRange.tsx ├── DatSelect.tsx └── DatWrapper.tsx ├── index.tsx ├── lib ├── __tests__ │ ├── __image_snapshots__ │ │ ├── texture-renderer-ts-test-textures-building-should-build-valid-backpack-texture-1-snap.png │ │ └── texture-renderer-ts-test-textures-building-should-build-valid-skin-texture-1-snap.png │ ├── __snapshots__ │ │ ├── binaryReader.ts.snap │ │ ├── geometryBuilder.ts.snap │ │ ├── geometryTransformer.ts.snap │ │ └── modelDataParser.ts.snap │ ├── binaryReader.ts │ ├── geometryBuilder.ts │ ├── geometryTransformer.ts │ ├── modelDataParser.ts │ └── textureRenderer.ts ├── binaryReader.ts ├── dataTypes.ts ├── geometryBuilder.ts ├── geometryTransformer.ts ├── modelController.ts ├── modelDataParser.ts ├── modelRenderer.ts ├── screneRenderer.ts └── textureBuilder.ts ├── modules.d.ts ├── package.json ├── screenshot.png ├── template.html ├── tsconfig.json ├── ui ├── App.tsx ├── BackgroundContainer.tsx ├── Controller.tsx ├── ControllerContainer.tsx ├── Dropzone.tsx ├── FileContainer.tsx ├── GithubButton.tsx ├── GlobalStyles.tsx ├── LoadingScreen.tsx ├── Renderer.tsx └── StartScreen.tsx ├── webpack.config.ts └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'typescript-eslint-parser', 3 | extends: ['standard', 'plugin:react/recommended'], 4 | plugins: ['typescript', 'prettier', 'arca'], 5 | rules: { 6 | indent: 'off', 7 | 'indent-legacy': ['error', 2], 8 | 'max-len': ['error', 120], 9 | 'no-undef': 'off', 10 | 'space-before-function-paren': ['error', { anonymous: 'always', named: 'never', asyncArrow: 'always' }], 11 | 'comma-dangle': 'off', 12 | 'key-spacing': ['error', { align: 'value' }], 13 | 'operator-linebreak': ['error', 'before'], 14 | 'no-unused-vars': 'off', 15 | 'typescript/no-unused-vars': 'error', 16 | 'arca/import-align': 'error', 17 | 'no-multi-spaces': ['error', { exceptions: { ImportDeclaration: true } }], 18 | 'no-dupe-class-members': 'off', 19 | 'no-useless-constructor': 'off' 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dirs 2 | node_modules/ 3 | dist/ 4 | .vscode/ 5 | coverage/ 6 | *.bak/ 7 | 8 | # files 9 | package-lock.json 10 | *.temp 11 | TODO.md -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | 5 | cache: 6 | directories: 7 | - "node_modules" 8 | 9 | script: 10 | - yarn test 11 | - yarn build 12 | 13 | deploy: 14 | provider: pages 15 | github-token: $GITHUB_TOKEN 16 | committer-from-gh: true 17 | skip-cleanup: true 18 | keep-history: true 19 | local-dir: dist 20 | repo: danakt/web-hlmv 21 | target-branch: gh-pages 22 | on: 23 | branch: master 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Danakt Frost 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web Half-Life Model Viewer [![Build Status](https://travis-ci.org/danakt/web-hlmv.svg?branch=master)](https://travis-ci.org/danakt/web-hlmv) 2 | 3 | This repo contains the source code powering [danakt.com/web-hlmv](https://danakt.com/web-hlmv). The tool was made as a simple multiplatform alternative to [Half-Life Model Viewer](https://github.com/ValveSoftware/halflife/tree/master/utils/mdlviewer). 4 | 5 |
6 | 7 |
8 | 9 | ## Todo 10 | 11 | - Fix bone positions calculation (resolve problems with weapons rendering) 12 | - Add first person weapons viewing and mirroring model 13 | - Add viewing textures 14 | — Add chrome textures 15 | - Make parsing and processing models in worker 16 | - Make mobile interface 17 | 18 | Create an [issue](https://github.com/danakt/web-hlmv/issues) to offer new features. 19 | 20 | ## License 21 | 22 | [MIT](LICENSE) © 2019 23 | This product was made using technologies licensed from id Software and Valve 24 | Corporation. 25 | -------------------------------------------------------------------------------- /__mock__/README: -------------------------------------------------------------------------------- 1 | Here are some mock models :) -------------------------------------------------------------------------------- /__mock__/jpngirl01.mdl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danakt/web-hlmv/2979a66eb3ccc5db8e59523c9c837cb2a5a502b9/__mock__/jpngirl01.mdl -------------------------------------------------------------------------------- /__mock__/leet.mdl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danakt/web-hlmv/2979a66eb3ccc5db8e59523c9c837cb2a5a502b9/__mock__/leet.mdl -------------------------------------------------------------------------------- /__mock__/ratamahatta.md2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danakt/web-hlmv/2979a66eb3ccc5db8e59523c9c837cb2a5a502b9/__mock__/ratamahatta.md2 -------------------------------------------------------------------------------- /const/constants.ts: -------------------------------------------------------------------------------- 1 | /** Supported model format version */ 2 | export const VERSION = 10 3 | 4 | /** Maximum number of bone controllers per bone */ 5 | export const MAX_PER_BONE_CONTROLLERS = 6 6 | 7 | /** Flag of texture masking */ 8 | export const NF_MASKED = 0x0040 9 | 10 | /** Number of colors */ 11 | export const PALETTE_ENTRIES = 256 12 | 13 | /** Number of channels for RGB color. Was "PALETTE_CHANNELS" */ 14 | export const RGB_SIZE = 3 15 | 16 | /** Number of channels for RGBA color. Was "PALETTE_CHANNELS_ALPHA" */ 17 | export const RGBA_SIZE = 4 18 | 19 | /** Total size of a palette, in bytes. */ 20 | export const PALETTE_SIZE = PALETTE_ENTRIES * RGB_SIZE 21 | 22 | /** The index in a palette where the alpha color is stored. Used for transparent textures. */ 23 | export const PALETTE_ALPHA_INDEX = 255 * RGB_SIZE 24 | 25 | /** Number of bones allowed at source movement */ 26 | export const MAX_SRCBONES = 512 27 | 28 | /** Number of axles in 3d space */ 29 | export const AXLES_NUM = 3 30 | 31 | /** Animation value items index constants */ 32 | export const enum ANIM_VALUE { 33 | VALUE = 0, 34 | VALID, 35 | TOTAL 36 | } 37 | 38 | /** Triangle fan type */ 39 | export const TRIANGLE_FAN = 0 40 | 41 | /** Triangle strip type */ 42 | export const TRIANGLE_STRIP = 1 43 | 44 | /** Motion flag X */ 45 | export const MOTION_X = 0x0001 46 | 47 | /** Motion flag Y */ 48 | export const MOTION_Y = 0x0002 49 | 50 | /** Motion flag Z */ 51 | export const MOTION_Z = 0x0004 52 | 53 | /** Controller that wraps shortest distance */ 54 | export const RLOOP = 0x8000 55 | 56 | /** Default interface background color */ 57 | export const INITIAL_UI_BACKGROUND = '#4d7f7e' 58 | -------------------------------------------------------------------------------- /const/structs.ts: -------------------------------------------------------------------------------- 1 | import { StructResult, int, float, string, array, vec3, ushort } from '../lib/dataTypes' 2 | import { MAX_PER_BONE_CONTROLLERS } from './constants' 3 | 4 | /** 5 | * Head of mdl-file 6 | */ 7 | export const header = { 8 | /** Model format ID */ 9 | id: int, 10 | /** Format version number */ 11 | version: int, 12 | /** The internal name of the model */ 13 | name: string(64), 14 | /** Data size of MDL file in bytes */ 15 | length: int, 16 | /** Position of player viewpoint relative to model origin */ 17 | eyePosition: vec3, 18 | /** Corner of model hull box with the least X/Y/Z values */ 19 | max: vec3, 20 | /** Opposite corner of model hull box */ 21 | min: vec3, 22 | /** Min position of view bounding box */ 23 | bbmin: vec3, 24 | /** Max position of view bounding box */ 25 | bbmax: vec3, 26 | /** 27 | * Binary flags in little-endian order. 28 | * ex (00000001, 00000000, 00000000, 11000000) means flags for position 29 | * 0, 30, and 31 are set. Set model flags section for more information 30 | */ 31 | flags: int, 32 | 33 | // After this point, the header contains many references to offsets 34 | // within the MDL file and the number of items at those offsets. 35 | // Offsets are from the very beginning of the file. 36 | // Note that indexes/counts are not always paired and ordered consistently. 37 | 38 | /** Number of bones */ 39 | numBones: int, 40 | /** Offset of first data section */ 41 | boneIndex: int, 42 | /** Number of bone controllers */ 43 | numBoneControllers: int, 44 | /** Offset of bone controllers */ 45 | boneControllerIndex: int, 46 | /** Number of complex bounding boxes */ 47 | numHitboxes: int, 48 | /** Offset of hit boxes */ 49 | hitBoxIndex: int, 50 | /** Number of sequences */ 51 | numSeq: int, 52 | /** Offset of sequences */ 53 | seqIndex: int, 54 | /** Number of demand loaded sequences */ 55 | numSeqGroups: int, 56 | /** Offset of demand loaded sequences */ 57 | seqGroupIndex: int, 58 | /** Number of raw textures */ 59 | numTextures: int, 60 | /** Offset of raw textures */ 61 | textureIndex: int, 62 | /** Offset of textures data */ 63 | textureDataIndex: int, 64 | /** Number of replaceable textures */ 65 | numSkinRef: int, 66 | numSkinFamilies: int, 67 | skinIndex: int, 68 | /** Number of body parts */ 69 | numBodyParts: int, 70 | /** Index of body parts */ 71 | bodyPartIndex: int, 72 | /** Number queryable attachable points */ 73 | numAttachments: int, 74 | attachmentIndex: int, 75 | // This seems to be obsolete. 76 | // Probably replaced by events that reference external sounds? 77 | soundTable: int, 78 | soundIndex: int, 79 | soundGroups: int, 80 | soundGroupIndex: int, 81 | /** Animation node to animation node transition graph */ 82 | numTransitions: int, 83 | transitionIndex: int 84 | } 85 | 86 | export type Header = StructResult 87 | 88 | /** 89 | * Bone description 90 | */ 91 | export const bone = { 92 | /** Bone name for symbolic links */ 93 | name: string(32), 94 | /** Parent bone */ 95 | parent: int, 96 | /** ?? */ 97 | flags: int, 98 | /** Bone controller index, -1 == none */ 99 | boneController: array(MAX_PER_BONE_CONTROLLERS, int), 100 | /** Default DoF values */ 101 | value: array(MAX_PER_BONE_CONTROLLERS, float), 102 | /** Scale for delta DoF values */ 103 | scale: array(MAX_PER_BONE_CONTROLLERS, float) 104 | } 105 | 106 | export type Bone = StructResult 107 | 108 | /** 109 | * Bone controllers 110 | */ 111 | export const boneController = { 112 | bone: int, 113 | type: int, 114 | start: float, 115 | end: float, 116 | rest: int, 117 | index: int 118 | } 119 | 120 | export type BoneController = StructResult 121 | 122 | /** 123 | * Attachment 124 | */ 125 | export const attachment = { 126 | name: string(32), 127 | type: int, 128 | bone: int, 129 | /** Attachment point */ 130 | org: vec3, 131 | vectors: array(3, vec3) 132 | } 133 | 134 | export type Attachment = StructResult 135 | 136 | /** 137 | * Bounding boxes 138 | */ 139 | export const boundingBox = { 140 | bone: int, 141 | /** Intersection group */ 142 | group: int, 143 | /** Bounding box */ 144 | bbmin: vec3, 145 | bbmax: vec3 146 | } 147 | 148 | export type BoundingBox = StructResult 149 | 150 | /** 151 | * Sequence description 152 | */ 153 | export const seqDesc = { 154 | /** Sequence label */ 155 | label: string(32), 156 | 157 | /** Frames per second */ 158 | fps: float, 159 | /** Looping/non-looping flags */ 160 | flags: int, 161 | 162 | activity: int, 163 | actWeight: int, 164 | 165 | numEvents: int, 166 | eventIndex: int, 167 | 168 | /** Number of frames per sequence */ 169 | numFrames: int, 170 | 171 | /** Number of foot pivots */ 172 | numPivots: int, 173 | pivotIndex: int, 174 | 175 | motionType: int, 176 | motionBone: int, 177 | linearMovement: vec3, 178 | autoMovePosIndex: int, 179 | autoMoveAngleIndex: int, 180 | 181 | /** Per sequence bounding box */ 182 | bbmin: vec3, 183 | bbmax: vec3, 184 | 185 | numBlends: int, 186 | /** "anim" pointer relative to start of sequence group data */ 187 | animIndex: int, 188 | 189 | // [blend][bone][X, Y, Z, XR, YR, ZR] 190 | /** X, Y, Z, XR, YR, ZR */ 191 | blendType: array(2, int), 192 | /** Starting value */ 193 | blendStart: array(2, float), 194 | /** Ending value */ 195 | blendEnd: array(2, float), 196 | blendParent: int, 197 | 198 | /** Sequence group for demand loading */ 199 | seqGroup: int, 200 | 201 | /** Transition node at entry */ 202 | entryNode: int, 203 | /** Transition node at exit */ 204 | exitNode: int, 205 | /** Transition rules */ 206 | nodeFlags: int, 207 | 208 | /** Auto advancing sequences */ 209 | nextSeq: int 210 | } 211 | 212 | export type SequenceDesc = StructResult 213 | 214 | /** 215 | * Demand loaded sequence groups 216 | */ 217 | export const seqGroup = { 218 | /** Textual name */ 219 | label: string(32), 220 | /** File name */ 221 | name: string(64), 222 | /** Was "cache" - index pointer */ 223 | unused1: int, 224 | /** Was "data" - hack for group 0 */ 225 | unused2: int 226 | } 227 | 228 | export type SequenceGroup = StructResult 229 | 230 | /** 231 | * Body part index 232 | */ 233 | export const bodyPart = { 234 | name: string(64), 235 | numModels: int, 236 | base: int, 237 | /** Index into models array */ 238 | modelIndex: int 239 | } 240 | 241 | export type BodyPart = StructResult 242 | 243 | /** 244 | * Texture info 245 | */ 246 | export const texture = { 247 | /** Texture name */ 248 | name: string(64), 249 | /** Flags */ 250 | flags: int, 251 | /** Texture width */ 252 | width: int, 253 | /** Texture height */ 254 | height: int, 255 | /** Texture data offset */ 256 | index: int 257 | } 258 | 259 | export type Texture = StructResult 260 | 261 | /** 262 | * Sub models 263 | */ 264 | export const subModel = { 265 | name: string(64), 266 | 267 | type: int, 268 | 269 | boundingRadius: float, 270 | 271 | numMesh: int, 272 | meshIndex: int, 273 | 274 | /** Number of unique vertices */ 275 | numVerts: int, 276 | /** Vertex bone info */ 277 | vertInfoIndex: int, 278 | /** Vertex vec3 */ 279 | vertIndex: int, 280 | /** Number of unique surface normals */ 281 | numNorms: int, 282 | /** Normal bone info */ 283 | normInfoIndex: int, 284 | /** Normal vec3 */ 285 | normIndex: int, 286 | 287 | /** Deformation groups */ 288 | numGroups: int, 289 | groupIndex: int 290 | } 291 | 292 | export type SubModel = StructResult 293 | 294 | /** 295 | * Mesh info 296 | */ 297 | export const mesh = { 298 | numTris: int, 299 | triIndex: int, 300 | skinRef: int, 301 | /** Per mesh normals */ 302 | numNorms: int, 303 | /** Normal vec3_t */ 304 | normIndex: int 305 | } 306 | 307 | export type Mesh = StructResult 308 | 309 | /** 310 | * Animation description 311 | */ 312 | export const animation = { 313 | offset: array(6, ushort) 314 | } 315 | 316 | export type Animation = StructResult 317 | -------------------------------------------------------------------------------- /dat/DatButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { DatItem } from './DatItem' 4 | 5 | const DatItemButton = styled(DatItem)` 6 | border-left-color: #e61d5f; 7 | cursor: pointer; 8 | 9 | &:hover { 10 | background: #111; 11 | } 12 | ` 13 | 14 | type Props = { 15 | onClick?: () => void 16 | children?: React.ReactNode 17 | } 18 | 19 | export const DatButton = (props: Props) => {props.children} 20 | -------------------------------------------------------------------------------- /dat/DatColor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { DatItem } from './DatItem' 4 | import { DatLabelText } from './DatLabelText' 5 | import { ChromePicker } from 'react-color' 6 | 7 | const Color = styled.div` 8 | width: 60%; 9 | text-align: center; 10 | font-weight: 700; 11 | color: #fff; 12 | text-shadow: rgba(0, 0, 0, 0.7) 0 1px 1px; 13 | vertical-align: middle; 14 | border: 3px solid #1a1a1a; 15 | cursor: pointer; 16 | ` 17 | 18 | const Overlay = styled.div` 19 | position: fixed; 20 | z-index: 99; 21 | left: 0; 22 | top: 0; 23 | right: 0; 24 | bottom: 0; 25 | ` 26 | 27 | const Picker = styled.div` 28 | z-index: 100; 29 | position: absolute; 30 | right: 10px; 31 | top: 35px; 32 | ` 33 | 34 | type Props = { 35 | label: React.ReactNode 36 | value: string 37 | onChange: (color: string) => void 38 | } 39 | 40 | export const DatColor = (props: Props) => { 41 | const [isPickerShown, showPicker] = React.useState(false) 42 | 43 | return ( 44 |
45 | 46 | {props.label} 47 | 48 | showPicker(true)}> 49 | {props.value} 50 | 51 | 52 | 53 | {isPickerShown && ( 54 | 55 | showPicker(false)} /> 56 | 57 | 58 | props.onChange(result.hex)} /> 59 | 60 | 61 | )} 62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /dat/DatFile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { DatItem } from './DatItem' 4 | import { DatLabelText } from './DatLabelText' 5 | import { DatInput } from './DatInput' 6 | 7 | type Props = { 8 | onChange?: (value: File) => void 9 | label: React.ReactNode 10 | disabled?: boolean 11 | } 12 | 13 | const DatItemNumber = styled(DatItem)` 14 | /* border-left-color: #2fa1d6; */ 15 | ` 16 | 17 | const InputWrapper = styled.div` 18 | width: 60%; 19 | position: relative; 20 | overflow: hidden; 21 | display: inline-block; 22 | cursor: pointer; 23 | ` 24 | 25 | const Button = styled(DatInput)` 26 | display: block; 27 | color: #fff; 28 | left: 0; 29 | top: 0; 30 | width: 100%; 31 | height: 100%; 32 | ` 33 | 34 | const File = styled.input` 35 | position: absolute; 36 | font-size: 100px; 37 | left: 0; 38 | top: 0; 39 | opacity: 0; 40 | cursor: pointer; 41 | ` 42 | 43 | export const DatFile = (props: Props) => ( 44 | 45 | {props.label} 46 | 47 | 48 |