@autarkis/chitin-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ export { parsePhys, selectLodHulls } from "./phys-parser.js";
2
+ export type { PhysFile, PhysHull, PhysBone, PhysLodTier, } from "./phys-parser.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // Format-only entry point: parses/validates .phys with no physics-engine or
2
+ // Three.js dependency. Import the Rapier bindings from "@autarkis/chitin-web/rapier"
3
+ // and the Three.js debug meshes from "@autarkis/chitin-web/three".
4
+ export { parsePhys, selectLodHulls } from "./phys-parser.js";
@@ -0,0 +1,27 @@
1
+ export interface PhysHull {
2
+ vertices: Float32Array;
3
+ indices: Uint16Array;
4
+ aabbMin: [number, number, number];
5
+ aabbMax: [number, number, number];
6
+ boneIndex: number | null;
7
+ }
8
+ export interface PhysBone {
9
+ name: string;
10
+ bindTransform: Float32Array;
11
+ }
12
+ export interface PhysLodTier {
13
+ concavity: number;
14
+ hulls: PhysHull[];
15
+ }
16
+ export interface PhysFile {
17
+ version: number;
18
+ flags: number;
19
+ hulls: PhysHull[];
20
+ bones: PhysBone[];
21
+ lodTiers: PhysLodTier[];
22
+ hasBones: boolean;
23
+ hasBindPoses: boolean;
24
+ hasLod: boolean;
25
+ }
26
+ export declare function selectLodHulls(phys: PhysFile, concavity: number): PhysHull[];
27
+ export declare function parsePhys(buffer: ArrayBuffer): PhysFile;
@@ -0,0 +1,241 @@
1
+ const MAGIC = 0x53594850; // "PHYS" little-endian
2
+ const HEADER_SIZE = 32;
3
+ const FLAG_HAS_BONES = 0x01;
4
+ const FLAG_HAS_BIND_POSES = 0x02;
5
+ const FLAG_HAS_LOD = 0x04;
6
+ const KNOWN_FLAGS = FLAG_HAS_BONES | FLAG_HAS_BIND_POSES | FLAG_HAS_LOD;
7
+ const LOD_TIER_HEADER_SIZE = 24;
8
+ const SUPPORTED_VERSIONS = new Set([2, 3]);
9
+ // Shared hull-table reader for both the LOD0 block and each LOD tier, so the
10
+ // same structural invariants (range, contiguity, finite AABB, bone bounds) hold
11
+ // everywhere.
12
+ function readHulls(blk) {
13
+ const { view, byteLength, hullTableOff, descSize, hasBones, hullCount, vertexDataOff, indexDataOff, totalVerts, totalIdx, boneTableCount, prefix, } = blk;
14
+ const hulls = [];
15
+ let expectedVOff = 0;
16
+ let expectedIOff = 0;
17
+ for (let i = 0; i < hullCount; i++) {
18
+ const off = hullTableOff + i * descSize;
19
+ requireBytes(byteLength, off, descSize, `${prefix} ${i} descriptor`);
20
+ const vOff = view.getUint32(off, true);
21
+ const vCount = view.getUint32(off + 4, true);
22
+ const iOff = view.getUint32(off + 8, true);
23
+ const iCount = view.getUint32(off + 12, true);
24
+ if (vOff + vCount > totalVerts) {
25
+ throw new Error(`${prefix} ${i}: vertex range [${vOff}, ${vOff + vCount}) exceeds total_vertices ${totalVerts}`);
26
+ }
27
+ if (iOff + iCount > totalIdx) {
28
+ throw new Error(`${prefix} ${i}: index range [${iOff}, ${iOff + iCount}) exceeds total_indices ${totalIdx}`);
29
+ }
30
+ if (vOff !== expectedVOff) {
31
+ throw new Error(`${prefix} ${i}: vertex_offset ${vOff} != expected ${expectedVOff} (non-contiguous or overlapping range)`);
32
+ }
33
+ if (iOff !== expectedIOff) {
34
+ throw new Error(`${prefix} ${i}: index_offset ${iOff} != expected ${expectedIOff} (non-contiguous or overlapping range)`);
35
+ }
36
+ const aabbMin = [
37
+ view.getFloat32(off + 16, true),
38
+ view.getFloat32(off + 20, true),
39
+ view.getFloat32(off + 24, true),
40
+ ];
41
+ const aabbMax = [
42
+ view.getFloat32(off + 28, true),
43
+ view.getFloat32(off + 32, true),
44
+ view.getFloat32(off + 36, true),
45
+ ];
46
+ if (![...aabbMin, ...aabbMax].every(Number.isFinite)) {
47
+ throw new Error(`${prefix} ${i}: non-finite aabb`);
48
+ }
49
+ let boneIndex = null;
50
+ if (hasBones) {
51
+ const raw = view.getInt32(off + 40, true);
52
+ if (raw < -1) {
53
+ throw new Error(`${prefix} ${i}: invalid bone_index ${raw}`);
54
+ }
55
+ if (boneTableCount !== null && raw >= boneTableCount) {
56
+ throw new Error(`${prefix} ${i}: bone_index ${raw} >= bone_count ${boneTableCount}`);
57
+ }
58
+ boneIndex = raw === -1 ? null : raw;
59
+ }
60
+ const vertices = new Float32Array(vCount * 3);
61
+ const qOff = vertexDataOff + vOff * 6;
62
+ requireBytes(byteLength, qOff, vCount * 6, `${prefix} ${i} vertices`);
63
+ for (let v = 0; v < vCount; v++) {
64
+ for (let c = 0; c < 3; c++) {
65
+ const q = view.getInt16(qOff + (v * 3 + c) * 2, true);
66
+ const extent = aabbMax[c] - aabbMin[c];
67
+ const e = extent === 0 ? 1.0 : extent;
68
+ vertices[v * 3 + c] = ((q + 32768) / 65535) * e + aabbMin[c];
69
+ }
70
+ }
71
+ const idxOff = indexDataOff + iOff * 2;
72
+ requireBytes(byteLength, idxOff, iCount * 2, `${prefix} ${i} indices`);
73
+ const indices = new Uint16Array(iCount);
74
+ for (let t = 0; t < iCount; t++) {
75
+ indices[t] = view.getUint16(idxOff + t * 2, true);
76
+ }
77
+ hulls.push({ vertices, indices, aabbMin, aabbMax, boneIndex });
78
+ expectedVOff += vCount;
79
+ expectedIOff += iCount;
80
+ }
81
+ return hulls;
82
+ }
83
+ // Pick the tier whose concavity is nearest the target; falls back to LOD0
84
+ // (phys.hulls) when the file carries no additional tiers. Mirrors Python's
85
+ // PhysFile.lod_tier().
86
+ export function selectLodHulls(phys, concavity) {
87
+ if (phys.lodTiers.length === 0)
88
+ return phys.hulls;
89
+ let best = phys.lodTiers[0];
90
+ for (const tier of phys.lodTiers) {
91
+ if (Math.abs(tier.concavity - concavity) < Math.abs(best.concavity - concavity)) {
92
+ best = tier;
93
+ }
94
+ }
95
+ return best.hulls;
96
+ }
97
+ export function parsePhys(buffer) {
98
+ const view = new DataView(buffer);
99
+ const byteLength = buffer.byteLength;
100
+ if (byteLength < HEADER_SIZE) {
101
+ throw new Error(`file too small: ${byteLength} bytes`);
102
+ }
103
+ const magic = view.getUint32(0, true);
104
+ if (magic !== MAGIC) {
105
+ throw new Error(`bad magic: 0x${magic.toString(16)}`);
106
+ }
107
+ const version = view.getUint16(4, true);
108
+ const flags = view.getUint16(6, true);
109
+ const hullCount = view.getUint32(8, true);
110
+ const totalVerts = view.getUint32(12, true);
111
+ const totalIdx = view.getUint32(16, true);
112
+ const hullTableOff = view.getUint32(20, true);
113
+ const vertexDataOff = view.getUint32(24, true);
114
+ const indexDataOff = view.getUint32(28, true);
115
+ if (!SUPPORTED_VERSIONS.has(version)) {
116
+ throw new Error(`unsupported .phys version ${version}`);
117
+ }
118
+ const unknownFlags = flags & ~KNOWN_FLAGS;
119
+ if (unknownFlags !== 0) {
120
+ throw new Error(`unknown flags 0x${unknownFlags.toString(16)}`);
121
+ }
122
+ const hasBones = (flags & FLAG_HAS_BONES) !== 0;
123
+ const hasBindPoses = (flags & FLAG_HAS_BIND_POSES) !== 0;
124
+ const hasLod = (flags & FLAG_HAS_LOD) !== 0;
125
+ const descSize = hasBones ? 44 : 40;
126
+ const expectedVertexOff = hullTableOff + hullCount * descSize;
127
+ const expectedIndexOff = vertexDataOff + totalVerts * 6;
128
+ if (hullTableOff !== HEADER_SIZE) {
129
+ throw new Error(`bad hull table offset: ${hullTableOff}`);
130
+ }
131
+ if (vertexDataOff !== expectedVertexOff) {
132
+ throw new Error(`bad vertex data offset: ${vertexDataOff}`);
133
+ }
134
+ if (indexDataOff !== expectedIndexOff) {
135
+ throw new Error(`bad index data offset: ${indexDataOff}`);
136
+ }
137
+ // Peek the bone count (the bind-pose block trails the index data) so each
138
+ // hull's boneIndex can be range-checked in the loop below.
139
+ let boneTableCount = null;
140
+ if (hasBindPoses) {
141
+ const boneBlockOff = indexDataOff + totalIdx * 2;
142
+ if (boneBlockOff + 4 <= byteLength) {
143
+ boneTableCount = view.getUint32(boneBlockOff, true);
144
+ }
145
+ }
146
+ const hulls = readHulls({
147
+ view,
148
+ byteLength,
149
+ hullTableOff,
150
+ descSize,
151
+ hasBones,
152
+ hullCount,
153
+ vertexDataOff,
154
+ indexDataOff,
155
+ totalVerts,
156
+ totalIdx,
157
+ boneTableCount,
158
+ prefix: "hull",
159
+ });
160
+ const bones = [];
161
+ let nextBlockOff = indexDataOff + totalIdx * 2;
162
+ if (hasBindPoses) {
163
+ let bOff = nextBlockOff;
164
+ requireBytes(byteLength, bOff, 4, "bone count");
165
+ const boneCount = view.getUint32(bOff, true);
166
+ bOff += 4;
167
+ const decoder = new TextDecoder("utf-8");
168
+ for (let b = 0; b < boneCount; b++) {
169
+ requireBytes(byteLength, bOff, 64, `bone ${b} bind transform`);
170
+ const bindTransform = new Float32Array(16);
171
+ for (let f = 0; f < 16; f++) {
172
+ bindTransform[f] = view.getFloat32(bOff + f * 4, true);
173
+ }
174
+ if (!bindTransform.every(Number.isFinite)) {
175
+ throw new Error(`bone ${b}: non-finite bind_transform`);
176
+ }
177
+ bOff += 64;
178
+ requireBytes(byteLength, bOff, 2, `bone ${b} name length`);
179
+ const nameLen = view.getUint16(bOff, true);
180
+ bOff += 2;
181
+ requireBytes(byteLength, bOff, nameLen, `bone ${b} name`);
182
+ const name = decoder.decode(new Uint8Array(buffer, bOff, nameLen));
183
+ bOff += nameLen;
184
+ bones.push({ name, bindTransform });
185
+ }
186
+ nextBlockOff = bOff;
187
+ }
188
+ const lodTiers = [];
189
+ if (hasLod) {
190
+ requireBytes(byteLength, nextBlockOff, 4, "LOD tier count");
191
+ const tierCount = view.getUint32(nextBlockOff, true);
192
+ nextBlockOff += 4;
193
+ for (let tier = 0; tier < tierCount; tier++) {
194
+ requireBytes(byteLength, nextBlockOff, LOD_TIER_HEADER_SIZE, `LOD tier ${tier} header`);
195
+ const concavity = view.getFloat32(nextBlockOff, true);
196
+ const tHullCount = view.getUint32(nextBlockOff + 4, true);
197
+ const tTotalVerts = view.getUint32(nextBlockOff + 8, true);
198
+ const tTotalIdx = view.getUint32(nextBlockOff + 12, true);
199
+ const dataSize = view.getUint32(nextBlockOff + 16, true);
200
+ nextBlockOff += LOD_TIER_HEADER_SIZE;
201
+ requireBytes(byteLength, nextBlockOff, dataSize, `LOD tier ${tier} data`);
202
+ const tHullTableOff = nextBlockOff;
203
+ const tVertexDataOff = tHullTableOff + tHullCount * descSize;
204
+ const tIndexDataOff = tVertexDataOff + tTotalVerts * 6;
205
+ const tierHulls = readHulls({
206
+ view,
207
+ byteLength,
208
+ hullTableOff: tHullTableOff,
209
+ descSize,
210
+ hasBones,
211
+ hullCount: tHullCount,
212
+ vertexDataOff: tVertexDataOff,
213
+ indexDataOff: tIndexDataOff,
214
+ totalVerts: tTotalVerts,
215
+ totalIdx: tTotalIdx,
216
+ boneTableCount,
217
+ prefix: `LOD tier ${tier} hull`,
218
+ });
219
+ lodTiers.push({ concavity, hulls: tierHulls });
220
+ nextBlockOff += dataSize;
221
+ }
222
+ }
223
+ if (byteLength !== nextBlockOff) {
224
+ throw new Error(`${byteLength - nextBlockOff} trailing bytes after end of data`);
225
+ }
226
+ return {
227
+ version,
228
+ flags,
229
+ hulls,
230
+ bones,
231
+ lodTiers,
232
+ hasBones,
233
+ hasBindPoses,
234
+ hasLod,
235
+ };
236
+ }
237
+ function requireBytes(byteLength, offset, size, label) {
238
+ if (offset < 0 || size < 0 || offset + size > byteLength) {
239
+ throw new Error(`${label} truncated`);
240
+ }
241
+ }
@@ -0,0 +1,15 @@
1
+ import type RAPIER from "@dimforge/rapier3d-compat";
2
+ import type { PhysFile } from "./phys-parser.js";
3
+ export interface ColliderResult {
4
+ colliders: RAPIER.ColliderDesc[];
5
+ boneMap: Map<number, RAPIER.ColliderDesc[]>;
6
+ }
7
+ export interface ColliderOptions {
8
+ lodConcavity?: number;
9
+ }
10
+ export declare function createColliders(rapier: typeof RAPIER, phys: PhysFile, opts?: ColliderOptions): ColliderResult;
11
+ export declare function addToWorld(rapier: typeof RAPIER, world: RAPIER.World, phys: PhysFile, position?: {
12
+ x: number;
13
+ y: number;
14
+ z: number;
15
+ }, opts?: ColliderOptions): RAPIER.RigidBody;
package/dist/rapier.js ADDED
@@ -0,0 +1,35 @@
1
+ import { selectLodHulls } from "./phys-parser.js";
2
+ export function createColliders(rapier, phys, opts) {
3
+ const colliders = [];
4
+ const boneMap = new Map();
5
+ const hulls = opts?.lodConcavity !== undefined
6
+ ? selectLodHulls(phys, opts.lodConcavity)
7
+ : phys.hulls;
8
+ for (const hull of hulls) {
9
+ const desc = colliderFromHull(rapier, hull);
10
+ if (!desc)
11
+ continue;
12
+ colliders.push(desc);
13
+ if (hull.boneIndex !== null) {
14
+ const arr = boneMap.get(hull.boneIndex) ?? [];
15
+ arr.push(desc);
16
+ boneMap.set(hull.boneIndex, arr);
17
+ }
18
+ }
19
+ return { colliders, boneMap };
20
+ }
21
+ export function addToWorld(rapier, world, phys, position, opts) {
22
+ const pos = position ?? { x: 0, y: 0, z: 0 };
23
+ const bodyDesc = rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y, pos.z);
24
+ const body = world.createRigidBody(bodyDesc);
25
+ const { colliders } = createColliders(rapier, phys, opts);
26
+ for (const desc of colliders) {
27
+ world.createCollider(desc, body);
28
+ }
29
+ return body;
30
+ }
31
+ function colliderFromHull(rapier, hull) {
32
+ if (hull.vertices.length < 12 || hull.indices.length < 3)
33
+ return null;
34
+ return rapier.ColliderDesc.convexHull(hull.vertices);
35
+ }
@@ -0,0 +1,8 @@
1
+ import { Group } from "three";
2
+ import type { PhysFile } from "./phys-parser.js";
3
+ export interface DebugOptions {
4
+ color?: number;
5
+ opacity?: number;
6
+ wireframe?: boolean;
7
+ }
8
+ export declare function createDebugMeshes(phys: PhysFile, options?: DebugOptions): Group;
package/dist/three.js ADDED
@@ -0,0 +1,57 @@
1
+ import { BufferGeometry, Float32BufferAttribute, Group, LineBasicMaterial, LineSegments, Matrix4, Mesh, MeshBasicMaterial, Uint16BufferAttribute, } from "three";
2
+ export function createDebugMeshes(phys, options) {
3
+ const color = options?.color ?? 0x00ff88;
4
+ const opacity = options?.opacity ?? 0.3;
5
+ const wireframe = options?.wireframe ?? true;
6
+ const group = new Group();
7
+ group.name = "chitin_colliders";
8
+ const boneTransforms = new Map();
9
+ if (phys.bones.length > 0) {
10
+ for (let i = 0; i < phys.bones.length; i++) {
11
+ const m = new Matrix4();
12
+ m.fromArray(phys.bones[i].bindTransform);
13
+ boneTransforms.set(i, m);
14
+ }
15
+ }
16
+ for (let i = 0; i < phys.hulls.length; i++) {
17
+ const hull = phys.hulls[i];
18
+ const geo = new BufferGeometry();
19
+ geo.setAttribute("position", new Float32BufferAttribute(hull.vertices, 3));
20
+ geo.setIndex(new Uint16BufferAttribute(hull.indices, 1));
21
+ geo.computeVertexNormals();
22
+ if (wireframe) {
23
+ const edges = extractEdges(hull.indices);
24
+ const edgeGeo = new BufferGeometry();
25
+ edgeGeo.setAttribute("position", new Float32BufferAttribute(hull.vertices, 3));
26
+ edgeGeo.setIndex(edges);
27
+ const line = new LineSegments(edgeGeo, new LineBasicMaterial({ color }));
28
+ line.name = `hull_${i}_wire`;
29
+ if (hull.boneIndex !== null && boneTransforms.has(hull.boneIndex)) {
30
+ line.applyMatrix4(boneTransforms.get(hull.boneIndex));
31
+ }
32
+ group.add(line);
33
+ }
34
+ const mat = new MeshBasicMaterial({
35
+ color,
36
+ transparent: true,
37
+ opacity,
38
+ depthWrite: false,
39
+ });
40
+ const mesh = new Mesh(geo, mat);
41
+ mesh.name = `hull_${i}`;
42
+ if (hull.boneIndex !== null && boneTransforms.has(hull.boneIndex)) {
43
+ mesh.applyMatrix4(boneTransforms.get(hull.boneIndex));
44
+ }
45
+ group.add(mesh);
46
+ }
47
+ return group;
48
+ }
49
+ function extractEdges(indices) {
50
+ const edges = [];
51
+ for (let i = 0; i < indices.length; i += 3) {
52
+ edges.push(indices[i], indices[i + 1]);
53
+ edges.push(indices[i + 1], indices[i + 2]);
54
+ edges.push(indices[i + 2], indices[i]);
55
+ }
56
+ return edges;
57
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@autarkis/chitin-web",
3
+ "version": "0.1.0",
4
+ "description": "Load chitin .phys collision sidecars in the browser",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Autarkis/chitin.git",
8
+ "directory": "integrations/web"
9
+ },
10
+ "type": "module",
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ },
18
+ "./rapier": {
19
+ "types": "./dist/rapier.d.ts",
20
+ "import": "./dist/rapier.js"
21
+ },
22
+ "./three": {
23
+ "types": "./dist/three.d.ts",
24
+ "import": "./dist/three.js"
25
+ }
26
+ },
27
+ "sideEffects": false,
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsc",
33
+ "test": "vitest run"
34
+ },
35
+ "peerDependencies": {
36
+ "@dimforge/rapier3d-compat": ">=0.13",
37
+ "three": ">=0.160"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "three": {
41
+ "optional": true
42
+ },
43
+ "@dimforge/rapier3d-compat": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@dimforge/rapier3d-compat": "^0.19.3",
49
+ "@types/three": "^0.184.1",
50
+ "three": "^0.184.0",
51
+ "typescript": "^6.0.3",
52
+ "vitest": "^4.1.6"
53
+ },
54
+ "license": "MIT"
55
+ }