@series-inc/rundot-syncplay 6.0.0-rc.43 → 6.0.0-rc.44
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.
- package/MIGRATING-RC41-TO-RC42.md +9 -0
- package/MIGRATING-RC42-TO-RC43.md +32 -0
- package/MIGRATING-RC43-TO-RC44.md +19 -0
- package/core/src/physics/physics-profile-wasm-module.generated.mjs +1 -1
- package/core/src/physics/physics-wasm-module.generated.mjs +1 -1
- package/core/src/physics2d/physics2d-wasm-module.generated.mjs +1 -1
- package/dist/browser.d.ts +4 -1
- package/dist/browser.js +2 -1
- package/dist/collider-preparation.d.ts +31 -0
- package/dist/collider-preparation.js +506 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/navmesh.d.ts +32 -0
- package/dist/navmesh.js +237 -0
- package/dist/physics/3d.d.ts +2 -0
- package/dist/physics/3d.js +2 -0
- package/dist/physics/debug-overlay3d.d.ts +118 -0
- package/dist/physics/debug-overlay3d.js +566 -0
- package/dist/presentation-ik.d.ts +44 -0
- package/dist/presentation-ik.js +153 -0
- package/dist/r3f-render-adapter.d.ts +2 -21
- package/dist/r3f-render-adapter.js +7 -60
- package/package.json +5 -2
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Syncplay rc.42
|
|
2
|
+
|
|
3
|
+
This release adds multiplayer physical assemblies and a 3D physics runtime bridge:
|
|
4
|
+
- `createSyncplayPhysicalAssemblySlice`, `stepSyncplayPhysicalAssembly`, and `projectSyncplayPhysicalAssembly` under `@series-inc/rundot-syncplay/modules/physical-assembly`.
|
|
5
|
+
- Authoritative Ultrahand glue, detach, and rotate operations with deterministic events.
|
|
6
|
+
- Recall trajectory recording and kinematic rewinds via `createSyncplayBodyPoseTrack3D`.
|
|
7
|
+
- Orientation hold controller (`createOrientationHold3D`) and kinematic path drive (`createKinematicPathDrive3D`) in 3D physics.
|
|
8
|
+
- Authoritative Rapier physics bridge (`createPhysicalAssemblyBridge3D`) linking assembly events to 3D world state.
|
|
9
|
+
- Disconnect recovery to release holds and active recalls on slot departure.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Syncplay rc.43
|
|
2
|
+
|
|
3
|
+
This release introduces deterministic dynamic seating and player-keyed secret authority:
|
|
4
|
+
|
|
5
|
+
## Dynamic Seat Control
|
|
6
|
+
- Rooms can configure dynamic slot policies via `deterministic.seatPolicy`:
|
|
7
|
+
- `recycleVacatedSeats`: boolean (default `false`). When `true`, vacated seats can be claimed by new joining players.
|
|
8
|
+
- `allowHumanClaimBot`: boolean (default `false`). When `true`, new human players can take over bot-controlled slots (transition S3). Returning original owners with retained claims can always reclaim their own seat regardless of this setting.
|
|
9
|
+
- `onHumanLeave`: `'empty' | 'bot'` (default `'empty'`). Dictates slot controller when a human player disconnects or departs.
|
|
10
|
+
- `initialBotSlots`: array of slot numbers controlled by bots at match start.
|
|
11
|
+
- Full state machine supporting transitions S1 through S6:
|
|
12
|
+
- `S1_empty_to_bot`
|
|
13
|
+
- `S2_empty_to_human`
|
|
14
|
+
- `S3_bot_to_human`
|
|
15
|
+
- `S4_bot_to_empty`
|
|
16
|
+
- `S5_human_to_bot`
|
|
17
|
+
- `S6_human_to_empty`
|
|
18
|
+
- Authority-issued controller leases: each human and bot controller receives an immutable, room-scoped lease (`claimId`, `botLeaseId`, and `controlEpoch`). Outgoing leases are revoked prior to successor admission.
|
|
19
|
+
|
|
20
|
+
## Player-Keyed Secret Authority
|
|
21
|
+
- Private holdings (cards, items, secret roles, and choice commitments) are strictly bound to authenticated `playerId` and `claimId`, rather than bare slot indices.
|
|
22
|
+
- Unconsumed holdings are permanently revoked upon final departure from recyclable seats.
|
|
23
|
+
- Successor players occupying recycled slots never inherit prior secrets or assigned hidden roles.
|
|
24
|
+
- Role arrays are sized dynamically by simulation capacity `X`, removing the previous 16-player secret limit while preserving byte and snapshot budgets.
|
|
25
|
+
|
|
26
|
+
## Durable Recovery & Fencing
|
|
27
|
+
- Critical seat transitions and claim modifications use monotonic revision fencing in `StateStore.saveCritical`.
|
|
28
|
+
- Stored snapshots round-trip version 4 records containing explicit `seatCheckpoint` states.
|
|
29
|
+
- Storage read errors fail closed to prevent accidental overwrites or fresh worker starts over existing persistent rooms.
|
|
30
|
+
|
|
31
|
+
## Match Log v2
|
|
32
|
+
- Match logs now support version 2 formatting with explicit `claimAttributions` mapping each confirmed input frame to the active controller claim.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Syncplay rc.44
|
|
2
|
+
|
|
3
|
+
This candidate release introduces authoring and tooling gap closures across physics and presentation:
|
|
4
|
+
|
|
5
|
+
## Physics Debug Overlay 3D
|
|
6
|
+
- Introduced `createPhysicsDebugOverlay3D` in `@series-inc/rundot-syncplay/physics/3d` and `@series-inc/rundot-syncplay`.
|
|
7
|
+
- Supports read-only extraction of wireframe geometry, contact records, joint state, and sensors from `PhysicsWorld3D` without mutating physics state or invalidating simulation digests.
|
|
8
|
+
- Provides strict type definitions: `PhysicsDebugOverlay3D`, `PhysicsDebugOverlayOptions3D`, `PhysicsDebugWireframeShape3D`, `PhysicsDebugContactRecord3D`, and `PhysicsDebugJointRecord3D`.
|
|
9
|
+
- Supports R3F wireframe rendering adapters and budgeting controls (`maxShapes`, `maxVertices`, `maxContacts`, `maxJoints`, `maxSensors`).
|
|
10
|
+
|
|
11
|
+
## Collider Preparation
|
|
12
|
+
- Introduced pure deterministic geometry cooking and validation in `collider-preparation.ts`.
|
|
13
|
+
- Validates coplanar faces, non-manifold edges, slivers, and degenerate polygons before registration with the native physics solver.
|
|
14
|
+
|
|
15
|
+
## Navmesh Geometry Cooking
|
|
16
|
+
- Added headless geometry cooking tools for dynamic navmesh generation in `navmesh.ts`.
|
|
17
|
+
|
|
18
|
+
## Presentation Inverse Kinematics
|
|
19
|
+
- Added two-bone presentation inverse kinematics solver in `presentation-ik.ts` with joint constraints and pole targets.
|
|
@@ -23,7 +23,7 @@ export const physicsProfileWasmMetadata = Object.freeze({
|
|
|
23
23
|
"sourceHash": "095061366ce3734601d06d48fa61d88a8538e1ba01e9c47663a7303ddb18fee7",
|
|
24
24
|
"wasmSha256": "4b2ac3ff7d0f7a15eb31f5742a90f16f458a906d5d41bdc5fbde03e5589f94bf",
|
|
25
25
|
"artifact": "profile",
|
|
26
|
-
"packageVersion": "6.0.0-rc.
|
|
26
|
+
"packageVersion": "6.0.0-rc.44",
|
|
27
27
|
"compilerFlags": [
|
|
28
28
|
"-std=c++20",
|
|
29
29
|
"-O3",
|
|
@@ -23,7 +23,7 @@ export const physicsWasmMetadata = Object.freeze({
|
|
|
23
23
|
"sourceHash": "38d5f2cde9e8d448f029fd2921a743e4a40e19dc84c03abf7bed829003e2469d",
|
|
24
24
|
"wasmSha256": "5f6a3d861cf3eb79a2f8060d6c4b5512673a943044a52a9d20b39f0f2fef985f",
|
|
25
25
|
"artifact": "production",
|
|
26
|
-
"packageVersion": "6.0.0-rc.
|
|
26
|
+
"packageVersion": "6.0.0-rc.44",
|
|
27
27
|
"compilerFlags": [
|
|
28
28
|
"-std=c++20",
|
|
29
29
|
"-O3",
|
|
@@ -50,7 +50,7 @@ export const physics2dWasmMetadata = Object.freeze({
|
|
|
50
50
|
"sourceHash": "faa090a03a6fddaf79c704fb1d76f810955ff35597449c8cec9541a75905a37e",
|
|
51
51
|
"wasmSha256": "49cb90541deed40f5fff1015bb578e13ec9a793d51931c1227e3e5a11bbfa3f7",
|
|
52
52
|
"artifact": "production",
|
|
53
|
-
"packageVersion": "6.0.0-rc.
|
|
53
|
+
"packageVersion": "6.0.0-rc.44",
|
|
54
54
|
"compilerFlags": [
|
|
55
55
|
"-std=c++20",
|
|
56
56
|
"-O3",
|
package/dist/browser.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export { createDeterministicMath } from './math.js';
|
|
|
19
19
|
export { rewindDeterministicTargets, runDeterministicMovementCrowdSimulation, runDeterministicMovementSimulation, stepDeterministicFpsAim, stepDeterministicKcc2D, stepDeterministicTopDownMover, validateDeterministicLagCompensatedHit } from './movement.js';
|
|
20
20
|
export { stepDeterministicCarry3D, stepDeterministicKcc3D } from './movement3d.js';
|
|
21
21
|
export { applyDeterministicReciprocalAvoidance, cookDeterministicNavigationGraph, cookTilemapNavigationGraph, createDeterministicFlowField, findDeterministicPath, smoothDeterministicPath, stepDeterministicNavigationAgents } from './navigation.js';
|
|
22
|
-
export { cookDeterministicNavmesh, cookImportedDeterministicNavmesh, findDeterministicNavmeshPath, findDeterministicNavmeshPointPath, locateDeterministicNavmeshPolygon, runDeterministicNavmeshStress, smoothDeterministicNavmeshPath, stepDeterministicNavmeshAgents, stepDeterministicNavmeshOffMeshTraversal } from './navmesh.js';
|
|
22
|
+
export { cookDeterministicNavmesh, cookDeterministicNavmeshFromGeometry, cookImportedDeterministicNavmesh, findDeterministicNavmeshPath, findDeterministicNavmeshPointPath, locateDeterministicNavmeshPolygon, runDeterministicNavmeshStress, smoothDeterministicNavmeshPath, stepDeterministicNavmeshAgents, stepDeterministicNavmeshOffMeshTraversal } from './navmesh.js';
|
|
23
23
|
export { createNetworkedSyncplayClient, prepareNetworkedSyncplayTransport } from './networked-client.js';
|
|
24
24
|
export { createSyncplayRunner } from './runner.js';
|
|
25
25
|
export { fbm2D, hash2, hashUint32, valueNoise1D, valueNoise2D } from './noise.js';
|
|
@@ -84,3 +84,6 @@ export type { MultiplayerApi, JoinTicketRequest } from '@series-inc/rundot-game-
|
|
|
84
84
|
export { openSyncplayBrowserRoom, resolveSyncplayBrowserIntent, shareSyncplayRoom, } from './browserSession.js';
|
|
85
85
|
export type { OpenSyncplayBrowserRoomOptions, OpenSyncplayBrowserRoomResult, ResolveSyncplayBrowserIntentOptions, SyncplayBrowserApi, SyncplayBrowserIntent, } from './browserSession.js';
|
|
86
86
|
export { SyncplayRuntimeLeaseRequiredError } from './runtime-cleanup.js';
|
|
87
|
+
export type { DeterministicNavmeshGeometrySource, DeterministicNavmeshGeometryOptions } from './navmesh.js';
|
|
88
|
+
export { solveTwoBoneIk3D, solveFootPlanting3D, } from './presentation-ik.js';
|
|
89
|
+
export type { TwoBoneIkInput3D, TwoBoneIkOutput3D, FootPlantingInput3D, FootPlantingOutput3D, Vec3PresentationIK, QuatPresentationIK, } from './presentation-ik.js';
|
package/dist/browser.js
CHANGED
|
@@ -20,7 +20,7 @@ export { createDeterministicMath } from './math.js';
|
|
|
20
20
|
export { rewindDeterministicTargets, runDeterministicMovementCrowdSimulation, runDeterministicMovementSimulation, stepDeterministicFpsAim, stepDeterministicKcc2D, stepDeterministicTopDownMover, validateDeterministicLagCompensatedHit } from './movement.js';
|
|
21
21
|
export { stepDeterministicCarry3D, stepDeterministicKcc3D } from './movement3d.js';
|
|
22
22
|
export { applyDeterministicReciprocalAvoidance, cookDeterministicNavigationGraph, cookTilemapNavigationGraph, createDeterministicFlowField, findDeterministicPath, smoothDeterministicPath, stepDeterministicNavigationAgents } from './navigation.js';
|
|
23
|
-
export { cookDeterministicNavmesh, cookImportedDeterministicNavmesh, findDeterministicNavmeshPath, findDeterministicNavmeshPointPath, locateDeterministicNavmeshPolygon, runDeterministicNavmeshStress, smoothDeterministicNavmeshPath, stepDeterministicNavmeshAgents, stepDeterministicNavmeshOffMeshTraversal } from './navmesh.js';
|
|
23
|
+
export { cookDeterministicNavmesh, cookDeterministicNavmeshFromGeometry, cookImportedDeterministicNavmesh, findDeterministicNavmeshPath, findDeterministicNavmeshPointPath, locateDeterministicNavmeshPolygon, runDeterministicNavmeshStress, smoothDeterministicNavmeshPath, stepDeterministicNavmeshAgents, stepDeterministicNavmeshOffMeshTraversal } from './navmesh.js';
|
|
24
24
|
export { createNetworkedSyncplayClient, prepareNetworkedSyncplayTransport } from './networked-client.js';
|
|
25
25
|
export { createSyncplayRunner } from './runner.js';
|
|
26
26
|
export { fbm2D, hash2, hashUint32, valueNoise1D, valueNoise2D } from './noise.js';
|
|
@@ -49,3 +49,4 @@ export { WsMultiplayerApi } from '@series-inc/rundot-game-sdk/mp-client';
|
|
|
49
49
|
// (RundotGameAPI comes from the SDK — syncplay may depend on the SDK).
|
|
50
50
|
export { openSyncplayBrowserRoom, resolveSyncplayBrowserIntent, shareSyncplayRoom, } from './browserSession.js';
|
|
51
51
|
export { SyncplayRuntimeLeaseRequiredError } from './runtime-cleanup.js';
|
|
52
|
+
export { solveTwoBoneIk3D, solveFootPlanting3D, } from './presentation-ik.js';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ColliderSourceMesh } from './collider-cooking.js';
|
|
2
|
+
export interface Point3D {
|
|
3
|
+
readonly x: number;
|
|
4
|
+
readonly y: number;
|
|
5
|
+
readonly z: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ColliderPreparationOptions {
|
|
8
|
+
readonly minArea?: number;
|
|
9
|
+
readonly minEdge?: number;
|
|
10
|
+
readonly resolution?: number;
|
|
11
|
+
readonly quantum?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ColliderPreparedPart {
|
|
14
|
+
readonly normal: Point3D;
|
|
15
|
+
readonly boundaries: readonly (readonly Point3D[])[];
|
|
16
|
+
readonly area: number;
|
|
17
|
+
readonly isHole?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface ColliderPreparationRemovedStats {
|
|
20
|
+
readonly degenerate: number;
|
|
21
|
+
readonly interior: number;
|
|
22
|
+
readonly coplanar: number;
|
|
23
|
+
readonly contained: number;
|
|
24
|
+
}
|
|
25
|
+
export interface ColliderPreparationResult {
|
|
26
|
+
readonly triangles: readonly (readonly [Point3D, Point3D, Point3D])[];
|
|
27
|
+
readonly parts: readonly ColliderPreparedPart[];
|
|
28
|
+
readonly digest: string;
|
|
29
|
+
readonly removed: ColliderPreparationRemovedStats;
|
|
30
|
+
}
|
|
31
|
+
export declare function prepareColliderMesh(source: ColliderSourceMesh, options?: ColliderPreparationOptions): ColliderPreparationResult;
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { defaultChecksum } from './canonical.js';
|
|
2
|
+
import { __colliderCookingInternals } from './collider-cooking.js';
|
|
3
|
+
function normalizeZero(v) {
|
|
4
|
+
return Object.is(v, -0) ? 0 : v;
|
|
5
|
+
}
|
|
6
|
+
function quantize(value, q) {
|
|
7
|
+
return normalizeZero(Math.round(value / q) * q);
|
|
8
|
+
}
|
|
9
|
+
function quantizePoint(p, q) {
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
x: quantize(p.x, q),
|
|
12
|
+
y: quantize(p.y, q),
|
|
13
|
+
z: quantize(p.z, q),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function comparePoints(a, b) {
|
|
17
|
+
if (Math.abs(a.x - b.x) > 1e-9)
|
|
18
|
+
return a.x - b.x;
|
|
19
|
+
if (Math.abs(a.y - b.y) > 1e-9)
|
|
20
|
+
return a.y - b.y;
|
|
21
|
+
if (Math.abs(a.z - b.z) > 1e-9)
|
|
22
|
+
return a.z - b.z;
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
function pointKey(p) {
|
|
26
|
+
return `${p.x},${p.y},${p.z}`;
|
|
27
|
+
}
|
|
28
|
+
function sortTriangleVertices(tri) {
|
|
29
|
+
const sorted = [...tri].sort(comparePoints);
|
|
30
|
+
return [sorted[0], sorted[1], sorted[2]];
|
|
31
|
+
}
|
|
32
|
+
function canonicalizeTriangle(tri) {
|
|
33
|
+
let minIdx = 0;
|
|
34
|
+
if (comparePoints(tri[1], tri[minIdx]) < 0)
|
|
35
|
+
minIdx = 1;
|
|
36
|
+
if (comparePoints(tri[2], tri[minIdx]) < 0)
|
|
37
|
+
minIdx = 2;
|
|
38
|
+
if (minIdx === 1)
|
|
39
|
+
return [tri[1], tri[2], tri[0]];
|
|
40
|
+
if (minIdx === 2)
|
|
41
|
+
return [tri[2], tri[0], tri[1]];
|
|
42
|
+
return tri;
|
|
43
|
+
}
|
|
44
|
+
function compareTriangles(a, b) {
|
|
45
|
+
const sA = sortTriangleVertices(a);
|
|
46
|
+
const sB = sortTriangleVertices(b);
|
|
47
|
+
for (let i = 0; i < 3; i += 1) {
|
|
48
|
+
const cmp = comparePoints(sA[i], sB[i]);
|
|
49
|
+
if (cmp !== 0)
|
|
50
|
+
return cmp;
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
function triangleCross(p0, p1, p2) {
|
|
55
|
+
const e1x = p1.x - p0.x, e1y = p1.y - p0.y, e1z = p1.z - p0.z;
|
|
56
|
+
const e2x = p2.x - p0.x, e2y = p2.y - p0.y, e2z = p2.z - p0.z;
|
|
57
|
+
return {
|
|
58
|
+
x: e1y * e2z - e1z * e2y,
|
|
59
|
+
y: e1z * e2x - e1x * e2z,
|
|
60
|
+
z: e1x * e2y - e1y * e2x,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function triangleArea(p0, p1, p2) {
|
|
64
|
+
const c = triangleCross(p0, p1, p2);
|
|
65
|
+
return 0.5 * Math.hypot(c.x, c.y, c.z);
|
|
66
|
+
}
|
|
67
|
+
function triangleShortestEdge(p0, p1, p2) {
|
|
68
|
+
const d0 = Math.hypot(p1.x - p0.x, p1.y - p0.y, p1.z - p0.z);
|
|
69
|
+
const d1 = Math.hypot(p2.x - p1.x, p2.y - p1.y, p2.z - p1.z);
|
|
70
|
+
const d2 = Math.hypot(p0.x - p2.x, p0.y - p2.y, p0.z - p2.z);
|
|
71
|
+
return Math.min(d0, d1, d2);
|
|
72
|
+
}
|
|
73
|
+
function planeKey(normal, d, normalQuantum = 1e-4, distQuantum = 1e-3) {
|
|
74
|
+
const qnx = quantize(normal.x, normalQuantum);
|
|
75
|
+
const qny = quantize(normal.y, normalQuantum);
|
|
76
|
+
const qnz = quantize(normal.z, normalQuantum);
|
|
77
|
+
const qd = quantize(d, distQuantum);
|
|
78
|
+
return `${qnx},${qny},${qnz}:${qd}`;
|
|
79
|
+
}
|
|
80
|
+
export function prepareColliderMesh(source, options = {}) {
|
|
81
|
+
const vertices = source.vertices;
|
|
82
|
+
if (!Array.isArray(vertices) || vertices.length % 3 !== 0) {
|
|
83
|
+
throw new Error('prepareColliderMesh: source requires triangle-list vertices (a positive multiple of 3)');
|
|
84
|
+
}
|
|
85
|
+
if (vertices.length === 0) {
|
|
86
|
+
return Object.freeze({
|
|
87
|
+
triangles: Object.freeze([]),
|
|
88
|
+
parts: Object.freeze([]),
|
|
89
|
+
digest: defaultChecksum([]),
|
|
90
|
+
removed: Object.freeze({
|
|
91
|
+
degenerate: 0,
|
|
92
|
+
interior: 0,
|
|
93
|
+
coplanar: 0,
|
|
94
|
+
contained: 0,
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
for (const v of vertices) {
|
|
99
|
+
if (!Number.isFinite(v.x) || !Number.isFinite(v.y) || !Number.isFinite(v.z)) {
|
|
100
|
+
throw new Error('prepareColliderMesh: source contains a non-finite vertex');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const quantum = options.quantum ?? (1 / 1024);
|
|
104
|
+
const resolution = options.resolution ?? 16;
|
|
105
|
+
const minArea = options.minArea ?? 0;
|
|
106
|
+
const minEdge = options.minEdge ?? 0;
|
|
107
|
+
let removedDegenerate = 0;
|
|
108
|
+
let removedInterior = 0;
|
|
109
|
+
let removedCoplanar = 0;
|
|
110
|
+
let removedContained = 0;
|
|
111
|
+
// Stage 1: Degenerate removal
|
|
112
|
+
const rawTriangles = [];
|
|
113
|
+
for (let i = 0; i < vertices.length; i += 3) {
|
|
114
|
+
const p0 = quantizePoint(vertices[i], quantum);
|
|
115
|
+
const p1 = quantizePoint(vertices[i + 1], quantum);
|
|
116
|
+
const p2 = quantizePoint(vertices[i + 2], quantum);
|
|
117
|
+
const area = triangleArea(p0, p1, p2);
|
|
118
|
+
const shortestEdge = triangleShortestEdge(p0, p1, p2);
|
|
119
|
+
if (area < minArea || shortestEdge < minEdge || area <= 1e-12 || shortestEdge <= 1e-12) {
|
|
120
|
+
removedDegenerate += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
rawTriangles.push([p0, p1, p2]);
|
|
124
|
+
}
|
|
125
|
+
if (rawTriangles.length === 0) {
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
triangles: Object.freeze([]),
|
|
128
|
+
parts: Object.freeze([]),
|
|
129
|
+
digest: defaultChecksum([]),
|
|
130
|
+
removed: Object.freeze({
|
|
131
|
+
degenerate: removedDegenerate,
|
|
132
|
+
interior: 0,
|
|
133
|
+
coplanar: 0,
|
|
134
|
+
contained: 0,
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// Detect and remove contained components (nested parts)
|
|
139
|
+
// 1. Group triangles into connected components via vertex sharing
|
|
140
|
+
const vertToTriangles = new Map();
|
|
141
|
+
for (let triIdx = 0; triIdx < rawTriangles.length; triIdx += 1) {
|
|
142
|
+
const tri = rawTriangles[triIdx];
|
|
143
|
+
for (const v of tri) {
|
|
144
|
+
const vk = pointKey(v);
|
|
145
|
+
let list = vertToTriangles.get(vk);
|
|
146
|
+
if (!list) {
|
|
147
|
+
list = [];
|
|
148
|
+
vertToTriangles.set(vk, list);
|
|
149
|
+
}
|
|
150
|
+
list.push(triIdx);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const visitedTriangles = new Uint8Array(rawTriangles.length);
|
|
154
|
+
const components = [];
|
|
155
|
+
for (let i = 0; i < rawTriangles.length; i += 1) {
|
|
156
|
+
if (visitedTriangles[i] !== 0)
|
|
157
|
+
continue;
|
|
158
|
+
const compIndices = [];
|
|
159
|
+
const queue = [i];
|
|
160
|
+
visitedTriangles[i] = 1;
|
|
161
|
+
let cMinX = Infinity, cMinY = Infinity, cMinZ = Infinity;
|
|
162
|
+
let cMaxX = -Infinity, cMaxY = -Infinity, cMaxZ = -Infinity;
|
|
163
|
+
while (queue.length > 0) {
|
|
164
|
+
const curr = queue.pop();
|
|
165
|
+
compIndices.push(curr);
|
|
166
|
+
const tri = rawTriangles[curr];
|
|
167
|
+
for (const v of tri) {
|
|
168
|
+
cMinX = Math.min(cMinX, v.x);
|
|
169
|
+
cMaxX = Math.max(cMaxX, v.x);
|
|
170
|
+
cMinY = Math.min(cMinY, v.y);
|
|
171
|
+
cMaxY = Math.max(cMaxY, v.y);
|
|
172
|
+
cMinZ = Math.min(cMinZ, v.z);
|
|
173
|
+
cMaxZ = Math.max(cMaxZ, v.z);
|
|
174
|
+
const neighbors = vertToTriangles.get(pointKey(v));
|
|
175
|
+
if (neighbors) {
|
|
176
|
+
for (const nb of neighbors) {
|
|
177
|
+
if (visitedTriangles[nb] === 0) {
|
|
178
|
+
visitedTriangles[nb] = 1;
|
|
179
|
+
queue.push(nb);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
components.push({
|
|
186
|
+
indices: compIndices,
|
|
187
|
+
minX: cMinX, maxX: cMaxX,
|
|
188
|
+
minY: cMinY, maxY: cMaxY,
|
|
189
|
+
minZ: cMinZ, maxZ: cMaxZ,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
// Check which components are strictly contained within another component's bounding box
|
|
193
|
+
const containedComponentSet = new Set();
|
|
194
|
+
for (let i = 0; i < components.length; i += 1) {
|
|
195
|
+
const compB = components[i];
|
|
196
|
+
for (let j = 0; j < components.length; j += 1) {
|
|
197
|
+
if (i === j)
|
|
198
|
+
continue;
|
|
199
|
+
const compA = components[j];
|
|
200
|
+
const eps = 1e-4;
|
|
201
|
+
if (compA.minX < compB.minX - eps && compB.maxX + eps < compA.maxX &&
|
|
202
|
+
compA.minY < compB.minY - eps && compB.maxY + eps < compA.maxY &&
|
|
203
|
+
compA.minZ < compB.minZ - eps && compB.maxZ + eps < compA.maxZ) {
|
|
204
|
+
containedComponentSet.add(i);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
removedContained = containedComponentSet.size;
|
|
210
|
+
const survivingTriangles = [];
|
|
211
|
+
for (let c = 0; c < components.length; c += 1) {
|
|
212
|
+
if (containedComponentSet.has(c))
|
|
213
|
+
continue;
|
|
214
|
+
for (const idx of components[c].indices) {
|
|
215
|
+
survivingTriangles.push(rawTriangles[idx]);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Stage 2: Interior removal
|
|
219
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
220
|
+
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
221
|
+
for (const tri of survivingTriangles) {
|
|
222
|
+
for (const v of tri) {
|
|
223
|
+
minX = Math.min(minX, v.x);
|
|
224
|
+
maxX = Math.max(maxX, v.x);
|
|
225
|
+
minY = Math.min(minY, v.y);
|
|
226
|
+
maxY = Math.max(maxY, v.y);
|
|
227
|
+
minZ = Math.min(minZ, v.z);
|
|
228
|
+
maxZ = Math.max(maxZ, v.z);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const extent = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
|
|
232
|
+
const voxelSize = extent > 0 ? extent / resolution : 1;
|
|
233
|
+
const originX = minX - voxelSize;
|
|
234
|
+
const originY = minY - voxelSize;
|
|
235
|
+
const originZ = minZ - voxelSize;
|
|
236
|
+
const nx = Math.max(1, Math.ceil((maxX - minX) / voxelSize)) + 2;
|
|
237
|
+
const ny = Math.max(1, Math.ceil((maxY - minY) / voxelSize)) + 2;
|
|
238
|
+
const nz = Math.max(1, Math.ceil((maxZ - minZ) / voxelSize)) + 2;
|
|
239
|
+
const cells = new Uint8Array(nx * ny * nz);
|
|
240
|
+
const at = (x, y, z) => (z * ny + y) * nx + x;
|
|
241
|
+
const clampIndex = (val, max) => Math.max(0, Math.min(max - 1, val));
|
|
242
|
+
const SURFACE = 1;
|
|
243
|
+
const EXTERIOR = 2;
|
|
244
|
+
const half = voxelSize / 2;
|
|
245
|
+
for (const tri of survivingTriangles) {
|
|
246
|
+
const x0 = clampIndex(Math.floor((Math.min(tri[0].x, tri[1].x, tri[2].x) - originX) / voxelSize), nx);
|
|
247
|
+
const x1 = clampIndex(Math.floor((Math.max(tri[0].x, tri[1].x, tri[2].x) - originX) / voxelSize), nx);
|
|
248
|
+
const y0 = clampIndex(Math.floor((Math.min(tri[0].y, tri[1].y, tri[2].y) - originY) / voxelSize), ny);
|
|
249
|
+
const y1 = clampIndex(Math.floor((Math.max(tri[0].y, tri[1].y, tri[2].y) - originY) / voxelSize), ny);
|
|
250
|
+
const z0 = clampIndex(Math.floor((Math.min(tri[0].z, tri[1].z, tri[2].z) - originZ) / voxelSize), nz);
|
|
251
|
+
const z1 = clampIndex(Math.floor((Math.max(tri[0].z, tri[1].z, tri[2].z) - originZ) / voxelSize), nz);
|
|
252
|
+
for (let z = z0; z <= z1; z += 1) {
|
|
253
|
+
for (let y = y0; y <= y1; y += 1) {
|
|
254
|
+
for (let x = x0; x <= x1; x += 1) {
|
|
255
|
+
const center = {
|
|
256
|
+
x: originX + (x + 0.5) * voxelSize,
|
|
257
|
+
y: originY + (y + 0.5) * voxelSize,
|
|
258
|
+
z: originZ + (z + 0.5) * voxelSize,
|
|
259
|
+
};
|
|
260
|
+
if (__colliderCookingInternals.triangleIntersectsBox(tri, center, half)) {
|
|
261
|
+
cells[at(x, y, z)] = SURFACE;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Flood fill exterior
|
|
268
|
+
const stack = [];
|
|
269
|
+
const pushIfOpen = (x, y, z) => {
|
|
270
|
+
const idx = at(x, y, z);
|
|
271
|
+
if (cells[idx] === 0) {
|
|
272
|
+
cells[idx] = EXTERIOR;
|
|
273
|
+
stack.push(idx);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
for (let z = 0; z < nz; z += 1) {
|
|
277
|
+
for (let y = 0; y < ny; y += 1) {
|
|
278
|
+
for (let x = 0; x < nx; x += 1) {
|
|
279
|
+
if (x === 0 || x === nx - 1 || y === 0 || y === ny - 1 || z === 0 || z === nz - 1) {
|
|
280
|
+
pushIfOpen(x, y, z);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
while (stack.length > 0) {
|
|
286
|
+
const curr = stack.pop();
|
|
287
|
+
const cx = curr % nx;
|
|
288
|
+
const cy = Math.floor(curr / nx) % ny;
|
|
289
|
+
const cz = Math.floor(curr / (nx * ny));
|
|
290
|
+
if (cx > 0)
|
|
291
|
+
pushIfOpen(cx - 1, cy, cz);
|
|
292
|
+
if (cx + 1 < nx)
|
|
293
|
+
pushIfOpen(cx + 1, cy, cz);
|
|
294
|
+
if (cy > 0)
|
|
295
|
+
pushIfOpen(cx, cy - 1, cz);
|
|
296
|
+
if (cy + 1 < ny)
|
|
297
|
+
pushIfOpen(cx, cy + 1, cz);
|
|
298
|
+
if (cz > 0)
|
|
299
|
+
pushIfOpen(cx, cy, cz - 1);
|
|
300
|
+
if (cz + 1 < nz)
|
|
301
|
+
pushIfOpen(cx, cy, cz + 1);
|
|
302
|
+
}
|
|
303
|
+
// Filter out triangles whose surrounding neighborhood touches no EXTERIOR cells
|
|
304
|
+
const surfaceTriangles = [];
|
|
305
|
+
for (const tri of survivingTriangles) {
|
|
306
|
+
const cross = triangleCross(tri[0], tri[1], tri[2]);
|
|
307
|
+
const len = Math.hypot(cross.x, cross.y, cross.z);
|
|
308
|
+
if (len <= 1e-12) {
|
|
309
|
+
removedInterior += 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const n = { x: cross.x / len, y: cross.y / len, z: cross.z / len };
|
|
313
|
+
const mid = {
|
|
314
|
+
x: (tri[0].x + tri[1].x + tri[2].x) / 3,
|
|
315
|
+
y: (tri[0].y + tri[1].y + tri[2].y) / 3,
|
|
316
|
+
z: (tri[0].z + tri[1].z + tri[2].z) / 3,
|
|
317
|
+
};
|
|
318
|
+
const p1 = {
|
|
319
|
+
x: Math.floor((mid.x + n.x * voxelSize - originX) / voxelSize),
|
|
320
|
+
y: Math.floor((mid.y + n.y * voxelSize - originY) / voxelSize),
|
|
321
|
+
z: Math.floor((mid.z + n.z * voxelSize - originZ) / voxelSize),
|
|
322
|
+
};
|
|
323
|
+
const p2 = {
|
|
324
|
+
x: Math.floor((mid.x - n.x * voxelSize - originX) / voxelSize),
|
|
325
|
+
y: Math.floor((mid.y - n.y * voxelSize - originY) / voxelSize),
|
|
326
|
+
z: Math.floor((mid.z - n.z * voxelSize - originZ) / voxelSize),
|
|
327
|
+
};
|
|
328
|
+
const c1 = (p1.x >= 0 && p1.x < nx && p1.y >= 0 && p1.y < ny && p1.z >= 0 && p1.z < nz) ? cells[at(p1.x, p1.y, p1.z)] : EXTERIOR;
|
|
329
|
+
const c2 = (p2.x >= 0 && p2.x < nx && p2.y >= 0 && p2.y < ny && p2.z >= 0 && p2.z < nz) ? cells[at(p2.x, p2.y, p2.z)] : EXTERIOR;
|
|
330
|
+
if (c1 === EXTERIOR || c2 === EXTERIOR) {
|
|
331
|
+
surfaceTriangles.push(tri);
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
removedInterior += 1;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Stage 3: Coplanar merge
|
|
338
|
+
// Group triangles by plane
|
|
339
|
+
const planeGroups = new Map();
|
|
340
|
+
for (const tri of surfaceTriangles) {
|
|
341
|
+
const cross = triangleCross(tri[0], tri[1], tri[2]);
|
|
342
|
+
const len = Math.hypot(cross.x, cross.y, cross.z);
|
|
343
|
+
if (len <= 1e-12)
|
|
344
|
+
continue;
|
|
345
|
+
const normal = {
|
|
346
|
+
x: normalizeZero(cross.x / len),
|
|
347
|
+
y: normalizeZero(cross.y / len),
|
|
348
|
+
z: normalizeZero(cross.z / len),
|
|
349
|
+
};
|
|
350
|
+
const d = normal.x * tri[0].x + normal.y * tri[0].y + normal.z * tri[0].z;
|
|
351
|
+
const key = planeKey(normal, d);
|
|
352
|
+
let group = planeGroups.get(key);
|
|
353
|
+
if (!group) {
|
|
354
|
+
const flippedKey = planeKey({ x: -normal.x, y: -normal.y, z: -normal.z }, -d);
|
|
355
|
+
if (planeGroups.has(flippedKey)) {
|
|
356
|
+
group = planeGroups.get(flippedKey);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (!group) {
|
|
360
|
+
group = { normal, d, triangles: [] };
|
|
361
|
+
planeGroups.set(key, group);
|
|
362
|
+
}
|
|
363
|
+
group.triangles.push(tri);
|
|
364
|
+
}
|
|
365
|
+
const parts = [];
|
|
366
|
+
const finalTriangles = [];
|
|
367
|
+
// Sort plane keys deterministically
|
|
368
|
+
const sortedPlaneKeys = Array.from(planeGroups.keys()).sort();
|
|
369
|
+
for (const pKey of sortedPlaneKeys) {
|
|
370
|
+
const group = planeGroups.get(pKey);
|
|
371
|
+
const groupTriangles = group.triangles;
|
|
372
|
+
// Build directed edge map
|
|
373
|
+
const edgeCounts = new Map();
|
|
374
|
+
const edgeKey = (a, b) => `${pointKey(a)}->${pointKey(b)}`;
|
|
375
|
+
for (const tri of groupTriangles) {
|
|
376
|
+
for (let i = 0; i < 3; i += 1) {
|
|
377
|
+
const from = tri[i];
|
|
378
|
+
const to = tri[(i + 1) % 3];
|
|
379
|
+
const k = edgeKey(from, to);
|
|
380
|
+
const existing = edgeCounts.get(k);
|
|
381
|
+
if (existing) {
|
|
382
|
+
existing.count += 1;
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
edgeCounts.set(k, { from, to, count: 1 });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Cancel shared interior edges (a->b and b->a)
|
|
390
|
+
let sharedEdges = 0;
|
|
391
|
+
const boundaryEdges = [];
|
|
392
|
+
const processedPairs = new Set();
|
|
393
|
+
for (const [k, e] of edgeCounts.entries()) {
|
|
394
|
+
if (processedPairs.has(k))
|
|
395
|
+
continue;
|
|
396
|
+
const reverseK = edgeKey(e.to, e.from);
|
|
397
|
+
const rev = edgeCounts.get(reverseK);
|
|
398
|
+
if (rev) {
|
|
399
|
+
processedPairs.add(k);
|
|
400
|
+
processedPairs.add(reverseK);
|
|
401
|
+
const shared = Math.min(e.count, rev.count);
|
|
402
|
+
sharedEdges += shared;
|
|
403
|
+
const diffForward = e.count - shared;
|
|
404
|
+
const diffRev = rev.count - shared;
|
|
405
|
+
for (let i = 0; i < diffForward; i += 1)
|
|
406
|
+
boundaryEdges.push({ from: e.from, to: e.to });
|
|
407
|
+
for (let i = 0; i < diffRev; i += 1)
|
|
408
|
+
boundaryEdges.push({ from: rev.from, to: rev.to });
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
processedPairs.add(k);
|
|
412
|
+
for (let i = 0; i < e.count; i += 1)
|
|
413
|
+
boundaryEdges.push({ from: e.from, to: e.to });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (groupTriangles.length > 1 && sharedEdges > 0) {
|
|
417
|
+
removedCoplanar += sharedEdges;
|
|
418
|
+
}
|
|
419
|
+
// Chain boundary edges into loops
|
|
420
|
+
const adj = new Map();
|
|
421
|
+
for (const e of boundaryEdges) {
|
|
422
|
+
const fk = pointKey(e.from);
|
|
423
|
+
let list = adj.get(fk);
|
|
424
|
+
if (!list) {
|
|
425
|
+
list = [];
|
|
426
|
+
adj.set(fk, list);
|
|
427
|
+
}
|
|
428
|
+
list.push(e.to);
|
|
429
|
+
}
|
|
430
|
+
const visitedEdges = new Set();
|
|
431
|
+
const loops = [];
|
|
432
|
+
// Sort starting points deterministically
|
|
433
|
+
const sortedStarts = Array.from(adj.keys()).sort();
|
|
434
|
+
for (const startKey of sortedStarts) {
|
|
435
|
+
const targets = adj.get(startKey);
|
|
436
|
+
for (const target of targets) {
|
|
437
|
+
const ek = `${startKey}->${pointKey(target)}`;
|
|
438
|
+
if (visitedEdges.has(ek))
|
|
439
|
+
continue;
|
|
440
|
+
const loop = [];
|
|
441
|
+
let currStr = startKey;
|
|
442
|
+
let nextPt = target;
|
|
443
|
+
while (nextPt) {
|
|
444
|
+
const nextStr = pointKey(nextPt);
|
|
445
|
+
const currentEdgeKey = `${currStr}->${nextStr}`;
|
|
446
|
+
if (visitedEdges.has(currentEdgeKey))
|
|
447
|
+
break;
|
|
448
|
+
visitedEdges.add(currentEdgeKey);
|
|
449
|
+
loop.push(nextPt);
|
|
450
|
+
currStr = nextStr;
|
|
451
|
+
const nextList = adj.get(currStr);
|
|
452
|
+
if (!nextList || nextList.length === 0)
|
|
453
|
+
break;
|
|
454
|
+
nextPt = nextList.find((cand) => !visitedEdges.has(`${currStr}->${pointKey(cand)}`));
|
|
455
|
+
if (nextStr === startKey)
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
if (loop.length >= 3) {
|
|
459
|
+
loops.push(loop);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// Compute total area for the plane
|
|
464
|
+
let totalPlaneArea = 0;
|
|
465
|
+
for (const tri of groupTriangles) {
|
|
466
|
+
totalPlaneArea += triangleArea(tri[0], tri[1], tri[2]);
|
|
467
|
+
finalTriangles.push(canonicalizeTriangle(tri));
|
|
468
|
+
}
|
|
469
|
+
if (loops.length > 0) {
|
|
470
|
+
parts.push(Object.freeze({
|
|
471
|
+
normal: Object.freeze(group.normal),
|
|
472
|
+
boundaries: Object.freeze(loops.map((l) => Object.freeze(l))),
|
|
473
|
+
area: totalPlaneArea,
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// Sort triangles and parts deterministically
|
|
478
|
+
finalTriangles.sort(compareTriangles);
|
|
479
|
+
parts.sort((a, b) => {
|
|
480
|
+
const cmpN = comparePoints(a.normal, b.normal);
|
|
481
|
+
if (cmpN !== 0)
|
|
482
|
+
return cmpN;
|
|
483
|
+
if (Math.abs(b.area - a.area) > 1e-4)
|
|
484
|
+
return b.area - a.area;
|
|
485
|
+
return a.boundaries.length - b.boundaries.length;
|
|
486
|
+
});
|
|
487
|
+
const digest = defaultChecksum({
|
|
488
|
+
triangles: finalTriangles.map((t) => sortTriangleVertices(t)),
|
|
489
|
+
parts: parts.map((p) => ({
|
|
490
|
+
normal: p.normal,
|
|
491
|
+
area: quantize(p.area, 1e-4),
|
|
492
|
+
boundaryCount: p.boundaries.length,
|
|
493
|
+
})),
|
|
494
|
+
});
|
|
495
|
+
return Object.freeze({
|
|
496
|
+
triangles: Object.freeze(finalTriangles),
|
|
497
|
+
parts: Object.freeze(parts),
|
|
498
|
+
digest,
|
|
499
|
+
removed: Object.freeze({
|
|
500
|
+
degenerate: removedDegenerate,
|
|
501
|
+
interior: removedInterior,
|
|
502
|
+
coplanar: removedCoplanar,
|
|
503
|
+
contained: removedContained,
|
|
504
|
+
}),
|
|
505
|
+
});
|
|
506
|
+
}
|