@jgengine/core 0.19.0 → 0.22.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.
- package/CHANGELOG.md +364 -0
- package/dist/ai/decisionGraph.d.ts +2 -0
- package/dist/ai/decisionGraph.js +6 -0
- package/dist/ai/flock.d.ts +1 -1
- package/dist/ai/flock.js +7 -4
- package/dist/anim/animGraph.d.ts +33 -2
- package/dist/anim/animGraph.js +200 -12
- package/dist/anim/locomotionGraph.d.ts +11 -3
- package/dist/anim/locomotionGraph.js +25 -15
- package/dist/audio/audioFalloff.d.ts +4 -0
- package/dist/audio/voiceAllocator.d.ts +46 -0
- package/dist/audio/voiceAllocator.js +76 -0
- package/dist/combat/abilityKit.d.ts +19 -0
- package/dist/combat/abilityKit.js +40 -4
- package/dist/combat/death.d.ts +4 -3
- package/dist/combat/death.js +3 -3
- package/dist/combat/magazine.d.ts +9 -0
- package/dist/combat/magazine.js +24 -2
- package/dist/combat/projectiles.d.ts +94 -2
- package/dist/combat/projectiles.js +282 -61
- package/dist/combat/regenShield.d.ts +14 -0
- package/dist/combat/regenShield.js +23 -7
- package/dist/crafting/recipeCatalog.d.ts +16 -0
- package/dist/crafting/recipeCatalog.js +55 -0
- package/dist/economy/auctionBook.d.ts +1 -1
- package/dist/economy/auctionBook.js +11 -1
- package/dist/economy/listingBook.d.ts +1 -1
- package/dist/economy/listingBook.js +11 -1
- package/dist/economy/resourceLedger.js +3 -3
- package/dist/economy/shopStock.js +7 -6
- package/dist/economy/techTree.js +7 -6
- package/dist/economy/unlockPoints.js +3 -6
- package/dist/editor/commandApply.js +89 -16
- package/dist/editor/commands.d.ts +38 -1
- package/dist/editor/commands.js +320 -3
- package/dist/editor/creatorStorage.d.ts +59 -0
- package/dist/editor/creatorStorage.js +164 -0
- package/dist/editor/document.d.ts +4 -0
- package/dist/editor/document.js +116 -17
- package/dist/editor/index.d.ts +3 -0
- package/dist/editor/index.js +1 -0
- package/dist/editor/liveSync.d.ts +4 -1
- package/dist/editor/liveSync.js +26 -10
- package/dist/editor/materialAuthoring.d.ts +14 -0
- package/dist/editor/materialAuthoring.js +38 -0
- package/dist/editor/modelNodeVisibility.d.ts +11 -0
- package/dist/editor/modelNodeVisibility.js +29 -0
- package/dist/editor/movementCatalog.d.ts +90 -0
- package/dist/editor/movementCatalog.js +144 -0
- package/dist/editor/simulation.d.ts +60 -0
- package/dist/editor/simulation.js +202 -0
- package/dist/editor/staticPrefab.d.ts +17 -0
- package/dist/editor/staticPrefab.js +51 -0
- package/dist/editor/terrainMaterials.d.ts +10 -0
- package/dist/editor/terrainMaterials.js +13 -0
- package/dist/editor/types.d.ts +11 -0
- package/dist/game/cameraConfig.d.ts +3 -1
- package/dist/game/clipRoles.d.ts +2 -1
- package/dist/game/clipRoles.js +7 -3
- package/dist/game/contentValidation.d.ts +69 -0
- package/dist/game/contentValidation.js +118 -0
- package/dist/game/controlGate.d.ts +8 -0
- package/dist/game/controlGate.js +43 -6
- package/dist/game/defineGame.d.ts +32 -3
- package/dist/game/dialogueGraph.d.ts +20 -0
- package/dist/game/dialogueGraph.js +107 -0
- package/dist/game/events.d.ts +5 -0
- package/dist/game/keyValueStore.d.ts +17 -4
- package/dist/game/keyValueStore.js +35 -23
- package/dist/game/lootModifiers.d.ts +17 -0
- package/dist/game/lootModifiers.js +26 -0
- package/dist/game/narrativeAuthoring.d.ts +67 -0
- package/dist/game/narrativeAuthoring.js +122 -0
- package/dist/game/playableGame.d.ts +52 -20
- package/dist/game/quest.d.ts +15 -0
- package/dist/game/quest.js +209 -63
- package/dist/game/questCatalog.d.ts +50 -0
- package/dist/game/questCatalog.js +205 -0
- package/dist/game/saveStore.d.ts +6 -5
- package/dist/game/saveStore.js +57 -57
- package/dist/game/systemRuntime.d.ts +2 -2
- package/dist/game/systemRuntime.js +84 -33
- package/dist/gameplay.d.ts +3 -0
- package/dist/gameplay.js +3 -0
- package/dist/interaction/promptRegistry.d.ts +5 -4
- package/dist/interaction/promptRegistry.js +8 -2
- package/dist/inventory/inventoryModel.js +9 -6
- package/dist/inventory/shapedGrid.d.ts +48 -9
- package/dist/inventory/shapedGrid.js +69 -10
- package/dist/item/durability.js +4 -0
- package/dist/item/itemInstanceRegistry.d.ts +32 -1
- package/dist/item/itemInstanceRegistry.js +111 -7
- package/dist/item/itemgen.d.ts +1 -1
- package/dist/item/itemgen.js +1 -1
- package/dist/item/modularItem.js +7 -1
- package/dist/material/appearanceSignals.d.ts +53 -0
- package/dist/material/appearanceSignals.js +47 -0
- package/dist/material/hairCards.d.ts +47 -0
- package/dist/material/hairCards.js +136 -0
- package/dist/material/materialAsset.d.ts +162 -0
- package/dist/material/materialAsset.js +382 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +348 -1
- package/dist/movement/characterController.d.ts +5 -2
- package/dist/movement/characterController.js +50 -23
- package/dist/movement/freeFlight.d.ts +2 -2
- package/dist/movement/freeFlight.js +3 -3
- package/dist/movement/movementModel.d.ts +24 -1
- package/dist/movement/movementModel.js +74 -7
- package/dist/movement/playerMovement.d.ts +28 -1
- package/dist/movement/playerMovement.js +425 -102
- package/dist/movement/poseState.d.ts +2 -0
- package/dist/movement/poseState.js +29 -1
- package/dist/movement/solidObstacles.d.ts +3 -1
- package/dist/movement/solidObstacles.js +8 -5
- package/dist/movement/terrainGrade.d.ts +13 -0
- package/dist/movement/terrainGrade.js +33 -0
- package/dist/movement/voxelController.d.ts +2 -2
- package/dist/movement/voxelController.js +3 -3
- package/dist/nav/navGrid.d.ts +19 -1
- package/dist/nav/navGrid.js +26 -10
- package/dist/nav/solidRoute.d.ts +50 -0
- package/dist/nav/solidRoute.js +144 -0
- package/dist/physics/ballisticSweep.d.ts +10 -3
- package/dist/physics/ballisticSweep.js +53 -29
- package/dist/physics/forceVolume.d.ts +41 -0
- package/dist/physics/forceVolume.js +88 -0
- package/dist/physics/kinematicVehicle.d.ts +8 -3
- package/dist/physics/kinematicVehicle.js +2 -2
- package/dist/physics/physicsWorldBackend.js +19 -3
- package/dist/physics/spatialGrid.d.ts +1 -1
- package/dist/physics/spatialGrid.js +8 -9
- package/dist/physics/vehicleDynamics.d.ts +3 -2
- package/dist/physics/vehicleDynamics.js +2 -2
- package/dist/physics/vehicleObstacles.d.ts +3 -2
- package/dist/physics/vehicleObstacles.js +29 -5
- package/dist/runtime/cameraDirector.d.ts +39 -2
- package/dist/runtime/cameraDirector.js +162 -0
- package/dist/runtime/commandDispatch.d.ts +14 -0
- package/dist/runtime/commandDispatch.js +19 -0
- package/dist/runtime/context/combat.d.ts +11 -4
- package/dist/runtime/context/combat.js +178 -11
- package/dist/runtime/context/deathLoot.d.ts +9 -8
- package/dist/runtime/context/deathLoot.js +16 -16
- package/dist/runtime/context/scene.d.ts +2 -0
- package/dist/runtime/context/scene.js +13 -21
- package/dist/runtime/descriptors/baseline.js +34 -3
- package/dist/runtime/descriptors/features.d.ts +2 -1
- package/dist/runtime/descriptors/features.js +18 -0
- package/dist/runtime/gameContext.js +79 -13
- package/dist/runtime/gameContextTypes.d.ts +14 -0
- package/dist/runtime/headlessRunner.d.ts +3 -1
- package/dist/runtime/headlessRunner.js +11 -11
- package/dist/runtime/hostedGameRunner.d.ts +24 -1
- package/dist/runtime/hostedGameRunner.js +156 -10
- package/dist/runtime/hostedWorldSession.d.ts +17 -5
- package/dist/runtime/hostedWorldSession.js +154 -16
- package/dist/runtime/hostedWorldStore.d.ts +2 -2
- package/dist/runtime/inputRecorder.d.ts +1 -1
- package/dist/runtime/inputRecorder.js +21 -1
- package/dist/runtime/inputSnapshot.d.ts +16 -4
- package/dist/runtime/inputSnapshot.js +31 -3
- package/dist/runtime/runtimeSave.d.ts +10 -6
- package/dist/runtime/runtimeSave.js +11 -5
- package/dist/runtime/simContext.d.ts +8 -3
- package/dist/runtime/simContext.js +7 -3
- package/dist/runtime/simSnapshot.js +2 -0
- package/dist/runtime/worldReplication.d.ts +2 -0
- package/dist/runtime/worldReplication.js +1 -1
- package/dist/scene/assetCatalog.d.ts +2 -0
- package/dist/scene/behaviorRuntime.d.ts +26 -5
- package/dist/scene/behaviorRuntime.js +263 -16
- package/dist/scene/behaviors.d.ts +30 -1
- package/dist/scene/behaviors.js +29 -0
- package/dist/scene/entityStore.d.ts +14 -3
- package/dist/scene/entityStore.js +45 -9
- package/dist/scene/mount.d.ts +9 -0
- package/dist/scene/mount.js +35 -0
- package/dist/scene/possession.d.ts +2 -0
- package/dist/scene/possession.js +21 -1
- package/dist/scene/sceneRaycast.d.ts +7 -0
- package/dist/scene/sceneRaycast.js +55 -0
- package/dist/scene/spatial.d.ts +20 -1
- package/dist/scene/spatial.js +94 -2
- package/dist/scene/vehicleSeat.d.ts +36 -2
- package/dist/scene/vehicleSeat.js +89 -2
- package/dist/stats/accumulatorMeter.d.ts +10 -0
- package/dist/stats/accumulatorMeter.js +6 -0
- package/dist/stats/eventMeter.d.ts +9 -2
- package/dist/stats/eventMeter.js +5 -2
- package/dist/store/defineStore.d.ts +3 -1
- package/dist/store/defineStore.js +3 -1
- package/dist/store/observableKeyedStore.d.ts +1 -1
- package/dist/store/observableKeyedStore.js +8 -6
- package/dist/time/simClock.d.ts +6 -2
- package/dist/time/simClock.js +5 -0
- package/dist/vfx/particleDirector.d.ts +58 -6
- package/dist/vfx/particleDirector.js +113 -22
- package/dist/vfx/particles.d.ts +68 -5
- package/dist/vfx/particles.js +284 -32
- package/dist/visibility/assetStreaming.d.ts +21 -3
- package/dist/visibility/assetStreaming.js +108 -24
- package/dist/visibility/settings.d.ts +8 -2
- package/dist/visibility/settings.js +3 -0
- package/dist/visibility/visibilitySystem.js +1 -0
- package/dist/work/expedition.d.ts +110 -0
- package/dist/work/expedition.js +195 -0
- package/dist/work/staffedStation.d.ts +29 -0
- package/dist/work/staffedStation.js +27 -0
- package/dist/world/authoredAnimation.d.ts +38 -0
- package/dist/world/authoredAnimation.js +99 -0
- package/dist/world/authoredObjects.d.ts +24 -4
- package/dist/world/authoredObjects.js +43 -16
- package/dist/world/authoredSimulation.d.ts +74 -0
- package/dist/world/authoredSimulation.js +302 -0
- package/dist/world/authoredWeather.d.ts +120 -0
- package/dist/world/authoredWeather.js +314 -0
- package/dist/world/buildingKit.d.ts +8 -3
- package/dist/world/buildingKit.js +21 -8
- package/dist/world/environmentForces.d.ts +21 -0
- package/dist/world/environmentForces.js +42 -0
- package/dist/world/features.d.ts +3 -3
- package/dist/world/footprintGrid.d.ts +55 -0
- package/dist/world/footprintGrid.js +201 -0
- package/dist/world/grassKind.d.ts +10 -0
- package/dist/world/grassKind.js +22 -0
- package/dist/world/pathTerrain.d.ts +1 -1
- package/dist/world/terraform.d.ts +2 -2
- package/dist/world/terraform.js +46 -18
- package/dist/world/weather.d.ts +17 -0
- package/dist/world/weather.js +116 -20
- package/dist/world/worldSolids.d.ts +11 -0
- package/dist/world/worldSolids.js +112 -1
- package/dist/world.d.ts +5 -5
- package/dist/world.js +4 -4
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +23 -2
- package/skills/jgengine/api.md +28 -17
- package/skills/jgengine/recipes/minimal-game.md +7 -2
- package/skills/jgengine-combat/SKILL.md +8 -2
- package/skills/jgengine-combat/api.md +22 -10
- package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
- package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
- package/skills/jgengine-combat/reference.md +24 -2
- package/skills/jgengine-gameplay/SKILL.md +63 -0
- package/skills/jgengine-gameplay/api.md +131 -37
- package/skills/jgengine-gameplay/capabilities.md +68 -0
- package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
- package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
- package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
- package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
- package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
- package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
- package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
- package/skills/jgengine-gameplay/reference-systems.md +119 -0
- package/skills/jgengine-world/SKILL.md +12 -3
- package/skills/jgengine-world/api.md +201 -58
- package/skills/jgengine-world/capabilities.md +116 -3
- package/skills/jgengine-world/recipes/character-animation.md +31 -8
- package/skills/jgengine-world/recipes/controls.md +4 -0
- package/skills/jgengine-world/recipes/npc-senses.md +12 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
- package/skills/jgengine-world/reference.md +167 -5
- package/skills/jgengine-world/visibility.md +28 -1
|
@@ -41,16 +41,28 @@ export function occupiedCells(footprint, origin, rotation) {
|
|
|
41
41
|
return rotated.map(([c, r]) => [c + origin[0], r + origin[1]]);
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
|
-
*
|
|
44
|
+
* An immutable spatial inventory board holding caller-defined multi-cell items.
|
|
45
45
|
*
|
|
46
|
-
* @capability
|
|
47
|
-
|
|
48
|
-
*/
|
|
46
|
+
* @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
|
|
47
|
+
*/
|
|
49
48
|
export function createShapedGrid(width, height) {
|
|
50
|
-
|
|
51
|
-
throw new Error("shaped grid needs positive dimensions");
|
|
49
|
+
requireDimensions(width, height);
|
|
52
50
|
return { width, height, placements: [] };
|
|
53
51
|
}
|
|
52
|
+
function requireDimensions(width, height) {
|
|
53
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
|
|
54
|
+
throw new RangeError("shaped grid needs positive integer dimensions");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function validCell(cell) {
|
|
58
|
+
return Number.isSafeInteger(cell[0]) && Number.isSafeInteger(cell[1]);
|
|
59
|
+
}
|
|
60
|
+
function validFootprint(footprint) {
|
|
61
|
+
return footprint.length > 0 && footprint.every(validCell);
|
|
62
|
+
}
|
|
63
|
+
function validRotation(rotation) {
|
|
64
|
+
return Number.isInteger(rotation) && rotation >= 0 && rotation <= 3;
|
|
65
|
+
}
|
|
54
66
|
function inBounds(grid, cells) {
|
|
55
67
|
return cells.every(([c, r]) => c >= 0 && r >= 0 && c < grid.width && r < grid.height);
|
|
56
68
|
}
|
|
@@ -65,8 +77,16 @@ function occupancyMap(grid, ignoreId) {
|
|
|
65
77
|
}
|
|
66
78
|
return map;
|
|
67
79
|
}
|
|
68
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Check bounds and overlap for a discrete footprint, optionally ignoring the item being moved.
|
|
82
|
+
* @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
|
|
83
|
+
*/
|
|
69
84
|
export function canPlace(grid, footprint, origin, rotation, ignoreId) {
|
|
85
|
+
requireDimensions(grid.width, grid.height);
|
|
86
|
+
if (!validFootprint(footprint) || !validRotation(rotation))
|
|
87
|
+
return "invalid-footprint";
|
|
88
|
+
if (!validCell(origin))
|
|
89
|
+
return "invalid-origin";
|
|
70
90
|
const cells = occupiedCells(footprint, origin, rotation);
|
|
71
91
|
if (!inBounds(grid, cells))
|
|
72
92
|
return "out-of-bounds";
|
|
@@ -77,7 +97,40 @@ export function canPlace(grid, footprint, origin, rotation, ignoreId) {
|
|
|
77
97
|
}
|
|
78
98
|
return null;
|
|
79
99
|
}
|
|
80
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Find the first legal placement without mutating the board; occupancy is indexed once per search.
|
|
102
|
+
*
|
|
103
|
+
* @capability shaped-placement find a shaped inventory item's first fit with ordered rotations, row-major placement, and an explicit search budget
|
|
104
|
+
*/
|
|
105
|
+
export function findShapedPlacement(grid, footprint, options = {}) {
|
|
106
|
+
requireDimensions(grid.width, grid.height);
|
|
107
|
+
const rotations = options.rotations ?? [0, 1, 2, 3];
|
|
108
|
+
const maxChecks = options.maxChecks ?? 4096;
|
|
109
|
+
if (!validFootprint(footprint) || !rotations.every(validRotation))
|
|
110
|
+
throw new RangeError("invalid footprint or rotation");
|
|
111
|
+
if (!Number.isSafeInteger(maxChecks) || maxChecks < 0)
|
|
112
|
+
throw new RangeError("invalid placement search budget");
|
|
113
|
+
const occupied = occupancyMap(grid, options.ignoreId);
|
|
114
|
+
let checks = 0;
|
|
115
|
+
for (const rotation of rotations) {
|
|
116
|
+
const cells = rotateFootprint(footprint, rotation);
|
|
117
|
+
for (let row = 0; row < grid.height; row += 1) {
|
|
118
|
+
for (let column = 0; column < grid.width; column += 1) {
|
|
119
|
+
if (checks >= maxChecks)
|
|
120
|
+
return { status: "budget-exceeded", checks };
|
|
121
|
+
checks += 1;
|
|
122
|
+
if (cells.every(([c, r]) => column + c < grid.width && row + r < grid.height && !occupied.has(cellKey([column + c, row + r])))) {
|
|
123
|
+
return { status: "found", origin: [column, row], rotation, checks };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { status: "no-space", checks };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Place a caller-defined item without mutating the source board; duplicate ids reject.
|
|
132
|
+
* @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
|
|
133
|
+
*/
|
|
81
134
|
export function placeShaped(grid, item, origin, rotation = 0) {
|
|
82
135
|
if (grid.placements.some((p) => p.id === item.id)) {
|
|
83
136
|
return { status: "rejected", reason: "duplicate-id", detail: item.id };
|
|
@@ -94,14 +147,20 @@ export function placeShaped(grid, item, origin, rotation = 0) {
|
|
|
94
147
|
};
|
|
95
148
|
return { status: "ok", grid: { ...grid, placements: [...grid.placements, placement] } };
|
|
96
149
|
}
|
|
97
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Remove one placement without mutating the source board.
|
|
152
|
+
* @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
|
|
153
|
+
*/
|
|
98
154
|
export function removeShaped(grid, id) {
|
|
99
155
|
if (!grid.placements.some((p) => p.id === id)) {
|
|
100
156
|
return { status: "rejected", reason: "unknown-id", detail: id };
|
|
101
157
|
}
|
|
102
158
|
return { status: "ok", grid: { ...grid, placements: grid.placements.filter((p) => p.id !== id) } };
|
|
103
159
|
}
|
|
104
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Move or rotate one placement, ignoring its current cells during overlap checks.
|
|
162
|
+
* @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
|
|
163
|
+
*/
|
|
105
164
|
export function moveShaped(grid, id, origin, rotation) {
|
|
106
165
|
const existing = grid.placements.find((p) => p.id === id);
|
|
107
166
|
if (existing === undefined)
|
package/dist/item/durability.js
CHANGED
|
@@ -21,6 +21,8 @@ export function wearAmount(spec, kind) {
|
|
|
21
21
|
* @capability durability track item wear, breakage, and repair
|
|
22
22
|
*/
|
|
23
23
|
export function applyWear(state, amount) {
|
|
24
|
+
if (Number.isNaN(amount))
|
|
25
|
+
throw new RangeError("Wear amount must not be NaN.");
|
|
24
26
|
if (amount <= 0)
|
|
25
27
|
return state;
|
|
26
28
|
return { current: clamp(state.current - amount, 0, state.max), max: state.max };
|
|
@@ -40,6 +42,8 @@ export function repairQuote(spec, state, options) {
|
|
|
40
42
|
return null;
|
|
41
43
|
if (repair.station !== undefined && repair.station !== options?.station)
|
|
42
44
|
return null;
|
|
45
|
+
if (options?.to !== undefined && !Number.isFinite(options.to))
|
|
46
|
+
return null;
|
|
43
47
|
const loss = repair.qualityLossPerRepair ?? 0;
|
|
44
48
|
const newMax = Math.max(1, state.max - loss);
|
|
45
49
|
const capped = Math.min(state.current, newMax);
|
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
/** Save data for a registry; definitions must be structured-cloneable and use the caller's serialization format. */
|
|
2
|
+
export interface ItemInstanceRegistryState<TDef> {
|
|
3
|
+
prefix: string;
|
|
4
|
+
sequence: number;
|
|
5
|
+
entries: [string, TDef][];
|
|
6
|
+
}
|
|
7
|
+
/** Caller-owned synchronous storage; `entries()` must stream without materializing the collection. */
|
|
8
|
+
export interface ItemInstanceStorage<TDef> {
|
|
9
|
+
get(id: string): TDef | undefined;
|
|
10
|
+
has(id: string): boolean;
|
|
11
|
+
set(id: string, def: TDef): void;
|
|
12
|
+
delete(id: string): void;
|
|
13
|
+
count(): number;
|
|
14
|
+
entries(): Iterable<[string, TDef]>;
|
|
15
|
+
/** Consume entries completely and atomically replace storage; any failure must preserve previous data. */
|
|
16
|
+
replace(entries: Iterable<[string, TDef]>): void;
|
|
17
|
+
}
|
|
18
|
+
/** Inject storage and its saved allocator sequence when attaching an existing collection. */
|
|
19
|
+
export interface ItemInstanceRegistryOptions<TDef> {
|
|
20
|
+
storage?: ItemInstanceStorage<TDef>;
|
|
21
|
+
/** Nonnegative safe integer; defaults to zero. Retain it across releases and storage reattachment. */
|
|
22
|
+
sequence?: number;
|
|
23
|
+
}
|
|
1
24
|
/**
|
|
2
25
|
* A runtime store for procedurally generated item instances — a rolled unique gun, a rolled
|
|
3
26
|
* affixed relic — keyed by a generated id distinct from any static catalog id. The counterpart a
|
|
@@ -5,6 +28,14 @@
|
|
|
5
28
|
* rolls never need a hand-rolled parallel registry (#536.1).
|
|
6
29
|
*/
|
|
7
30
|
export interface ItemInstanceRegistry<TDef> {
|
|
31
|
+
/** Detached definitions and allocator state, including ids of released instances in the sequence. */
|
|
32
|
+
state(): ItemInstanceRegistryState<TDef>;
|
|
33
|
+
/** Detached batches of at most `pageSize` entries; throws if the registry changes during iteration. Keep external storage and definitions unchanged until finished. */
|
|
34
|
+
statePages(pageSize: number): IterableIterator<ItemInstanceRegistryState<TDef>>;
|
|
35
|
+
/** Replace the registry; returns false without mutation when the prefix or allocator is invalid. */
|
|
36
|
+
restore(next: ItemInstanceRegistryState<TDef>): boolean;
|
|
37
|
+
/** Stream detached pages through atomic storage replacement. All pages must share prefix and sequence; malformed pages or storage failures throw without changing state. Empty saves require one empty page. */
|
|
38
|
+
restorePages(pages: Iterable<ItemInstanceRegistryState<TDef>>): boolean;
|
|
8
39
|
/** Stores `def` under a fresh generated id derived from `baseId`; returns that id. */
|
|
9
40
|
register(baseId: string, def: TDef): string;
|
|
10
41
|
get(id: string): TDef | undefined;
|
|
@@ -19,7 +50,7 @@ export interface ItemInstanceRegistry<TDef> {
|
|
|
19
50
|
*
|
|
20
51
|
* @capability item-instance-registry a runtime store for procedurally generated item instances
|
|
21
52
|
*/
|
|
22
|
-
export declare function createItemInstanceRegistry<TDef>(prefix?: string): ItemInstanceRegistry<TDef>;
|
|
53
|
+
export declare function createItemInstanceRegistry<TDef>(prefix?: string, options?: ItemInstanceRegistryOptions<TDef>): ItemInstanceRegistry<TDef>;
|
|
23
54
|
/**
|
|
24
55
|
* Bridges any procedural roller into a `LootEntry.generate` callback: rolls a `{ baseId, def }` pair
|
|
25
56
|
* and registers it, returning the runtime id the loot roll hands back as the drop's `item`.
|
|
@@ -4,20 +4,124 @@
|
|
|
4
4
|
*
|
|
5
5
|
* @capability item-instance-registry a runtime store for procedurally generated item instances
|
|
6
6
|
*/
|
|
7
|
-
export function createItemInstanceRegistry(prefix = "item") {
|
|
8
|
-
let seq = 0;
|
|
9
|
-
|
|
7
|
+
export function createItemInstanceRegistry(prefix = "item", options = {}) {
|
|
8
|
+
let seq = options.sequence ?? 0;
|
|
9
|
+
if (!Number.isSafeInteger(seq) || seq < 0)
|
|
10
|
+
throw new RangeError("Invalid item allocation sequence");
|
|
11
|
+
let revision = 0;
|
|
12
|
+
let memory = new Map();
|
|
13
|
+
const store = options.storage ?? {
|
|
14
|
+
get: (id) => memory.get(id),
|
|
15
|
+
has: (id) => memory.has(id),
|
|
16
|
+
set: (id, def) => void memory.set(id, def),
|
|
17
|
+
delete: (id) => void memory.delete(id),
|
|
18
|
+
count: () => memory.size,
|
|
19
|
+
entries: () => memory.entries(),
|
|
20
|
+
replace: (entries) => { memory = new Map(entries); },
|
|
21
|
+
};
|
|
22
|
+
const validHeader = (next) => next.prefix === prefix && Number.isSafeInteger(next.sequence) && next.sequence >= 0;
|
|
10
23
|
return {
|
|
24
|
+
state: () => ({ prefix, sequence: seq, entries: structuredClone(Array.from(store.entries())) }),
|
|
25
|
+
statePages(pageSize) {
|
|
26
|
+
if (!Number.isSafeInteger(pageSize) || pageSize < 1)
|
|
27
|
+
throw new RangeError("Invalid item page size");
|
|
28
|
+
const sequence = seq;
|
|
29
|
+
const expectedRevision = revision;
|
|
30
|
+
return (function* () {
|
|
31
|
+
const entries = store.entries()[Symbol.iterator]();
|
|
32
|
+
let done = false;
|
|
33
|
+
let first = true;
|
|
34
|
+
try {
|
|
35
|
+
while (!done) {
|
|
36
|
+
if (revision !== expectedRevision)
|
|
37
|
+
throw new Error("Item registry changed during export");
|
|
38
|
+
const batch = [];
|
|
39
|
+
while (batch.length < pageSize) {
|
|
40
|
+
const next = entries.next();
|
|
41
|
+
if (next.done) {
|
|
42
|
+
done = true;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
batch.push(next.value);
|
|
46
|
+
}
|
|
47
|
+
const detached = structuredClone(batch);
|
|
48
|
+
if (revision !== expectedRevision)
|
|
49
|
+
throw new Error("Item registry changed during export");
|
|
50
|
+
if (first || detached.length > 0)
|
|
51
|
+
yield { prefix, sequence, entries: detached };
|
|
52
|
+
first = false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
entries.return?.();
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
59
|
+
},
|
|
60
|
+
restore(next) {
|
|
61
|
+
if (!validHeader(next))
|
|
62
|
+
return false;
|
|
63
|
+
const entries = structuredClone(next.entries);
|
|
64
|
+
store.replace(entries);
|
|
65
|
+
seq = next.sequence;
|
|
66
|
+
revision += 1;
|
|
67
|
+
return true;
|
|
68
|
+
},
|
|
69
|
+
restorePages(pages) {
|
|
70
|
+
const iterator = pages[Symbol.iterator]();
|
|
71
|
+
let closed = false;
|
|
72
|
+
const close = () => {
|
|
73
|
+
if (closed)
|
|
74
|
+
return;
|
|
75
|
+
closed = true;
|
|
76
|
+
iterator.return?.();
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
const first = iterator.next();
|
|
80
|
+
if (first.done || !validHeader(first.value))
|
|
81
|
+
return false;
|
|
82
|
+
const sequence = first.value.sequence;
|
|
83
|
+
store.replace((function* () {
|
|
84
|
+
let page = first;
|
|
85
|
+
try {
|
|
86
|
+
while (!page.done) {
|
|
87
|
+
if (!validHeader(page.value) || page.value.sequence !== sequence) {
|
|
88
|
+
throw new Error("Inconsistent item registry pages");
|
|
89
|
+
}
|
|
90
|
+
yield* structuredClone(page.value.entries);
|
|
91
|
+
page = iterator.next();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
close();
|
|
96
|
+
}
|
|
97
|
+
})());
|
|
98
|
+
seq = sequence;
|
|
99
|
+
revision += 1;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
close();
|
|
104
|
+
}
|
|
105
|
+
},
|
|
11
106
|
register(baseId, def) {
|
|
12
|
-
|
|
13
|
-
|
|
107
|
+
let id;
|
|
108
|
+
do {
|
|
109
|
+
if (seq === Number.MAX_SAFE_INTEGER)
|
|
110
|
+
throw new RangeError("Item allocation sequence exhausted");
|
|
111
|
+
seq += 1;
|
|
112
|
+
revision += 1;
|
|
113
|
+
id = `${prefix}:${baseId}:${seq}`;
|
|
114
|
+
} while (store.has(id));
|
|
14
115
|
store.set(id, def);
|
|
15
116
|
return id;
|
|
16
117
|
},
|
|
17
118
|
get: (id) => store.get(id),
|
|
18
119
|
has: (id) => store.has(id),
|
|
19
|
-
release
|
|
20
|
-
|
|
120
|
+
release(id) {
|
|
121
|
+
store.delete(id);
|
|
122
|
+
revision += 1;
|
|
123
|
+
},
|
|
124
|
+
count: () => store.count(),
|
|
21
125
|
};
|
|
22
126
|
}
|
|
23
127
|
/**
|
package/dist/item/itemgen.d.ts
CHANGED
|
@@ -148,7 +148,7 @@ export interface GenSchema {
|
|
|
148
148
|
}
|
|
149
149
|
/** Per-call options for {@link generate}. */
|
|
150
150
|
export interface GenerateOptions {
|
|
151
|
-
/**
|
|
151
|
+
/** Fix a step to an option id that still must satisfy its pool and constraint, consuming no rng for it. */
|
|
152
152
|
pin?: Readonly<Record<string, string>>;
|
|
153
153
|
}
|
|
154
154
|
/**
|
package/dist/item/itemgen.js
CHANGED
|
@@ -41,7 +41,7 @@ export function generate(schema, rng, options = {}) {
|
|
|
41
41
|
const pinId = pin?.[step.id];
|
|
42
42
|
if (pinId !== undefined) {
|
|
43
43
|
const option = poolFor(step.pool, view).find((candidate) => candidate.id === pinId);
|
|
44
|
-
if (option === undefined)
|
|
44
|
+
if (option === undefined || (step.accept !== undefined && !step.accept(option, view)))
|
|
45
45
|
return false;
|
|
46
46
|
chosen.push({
|
|
47
47
|
step: step.id,
|
package/dist/item/modularItem.js
CHANGED
|
@@ -48,7 +48,13 @@ export function isComplete(def, installed) {
|
|
|
48
48
|
return missingRequiredSlots(def, installed).length === 0;
|
|
49
49
|
}
|
|
50
50
|
export function createModularItem(def, initial = []) {
|
|
51
|
-
let installed =
|
|
51
|
+
let installed = [];
|
|
52
|
+
for (const { slotId, part } of initial) {
|
|
53
|
+
const result = install(def, installed, slotId, part);
|
|
54
|
+
if (result.status === "rejected")
|
|
55
|
+
throw new Error(`Invalid initial part in slot ${slotId}: ${result.reason}`);
|
|
56
|
+
installed = result.installed;
|
|
57
|
+
}
|
|
52
58
|
return {
|
|
53
59
|
def,
|
|
54
60
|
parts() {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { EnvironmentField, EnvironmentSample } from "../world/envField.js";
|
|
2
|
+
import type { MaterialSurfaceParameters } from "./materialAsset.js";
|
|
3
|
+
/** Resolved dry values from one assigned surface, after imported and instance edits. RGB is linear. */
|
|
4
|
+
export interface MaterialAppearanceBaseline {
|
|
5
|
+
roughness: number;
|
|
6
|
+
colorLinear: readonly [number, number, number];
|
|
7
|
+
sheen?: number;
|
|
8
|
+
clearcoat?: number;
|
|
9
|
+
clearcoatRoughness?: number;
|
|
10
|
+
}
|
|
11
|
+
/** Opt-in wet endpoints. Color retention multiplies linear albedo; it never replaces the authored hue. */
|
|
12
|
+
export interface MaterialWetnessPolicy {
|
|
13
|
+
influence?: number;
|
|
14
|
+
roughness?: number;
|
|
15
|
+
colorRetention?: number;
|
|
16
|
+
sheen?: number;
|
|
17
|
+
/** Requires an already physical material; omitting this preserves imported coat without enabling a new one. */
|
|
18
|
+
clearcoat?: {
|
|
19
|
+
strength: number;
|
|
20
|
+
roughness?: number;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Local appearance only. Wind is a separate geometry/simulation input, and no field creates motion. */
|
|
24
|
+
export interface MaterialAppearancePolicy {
|
|
25
|
+
wetness?: MaterialWetnessPolicy;
|
|
26
|
+
/** Sky-exposure attenuation of existing hair-card backlighting; this is not a shadow or scattering model. */
|
|
27
|
+
cardBacklightExposure?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** Sparse edits for one sample. Unrequested imported parameters, textures, coverage and transmission stay intact. */
|
|
30
|
+
export interface MaterialAppearanceResponse {
|
|
31
|
+
surface: MaterialSurfaceParameters;
|
|
32
|
+
colorLinear?: [number, number, number];
|
|
33
|
+
backlightExposure?: number;
|
|
34
|
+
wetness: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve one surface from an immutable dry baseline, avoiding cumulative drift. Wetness already includes
|
|
38
|
+
* field shelter; neither exposure nor sheltered is multiplied into it again. No accumulation or drying simulation.
|
|
39
|
+
* @capability material-appearance-signals adapt selected surface response to sampled wetness and local card exposure
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveMaterialAppearance(sample: EnvironmentSample, baseline: MaterialAppearanceBaseline, policy?: MaterialAppearancePolicy): MaterialAppearanceResponse;
|
|
42
|
+
/** Explicit sample position; callers schedule a bounded set of visible or relevant targets. */
|
|
43
|
+
export interface MaterialAppearanceTarget {
|
|
44
|
+
x: number;
|
|
45
|
+
z: number;
|
|
46
|
+
y?: number;
|
|
47
|
+
time: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Exactly one field sample per target; no traversal, subscription, hidden tick, or full-world scan.
|
|
51
|
+
* @capability material-appearance-signals sample one explicitly scheduled appearance target from the shared environment field
|
|
52
|
+
*/
|
|
53
|
+
export declare function sampleMaterialAppearance(field: Pick<EnvironmentField, "sample">, target: MaterialAppearanceTarget, baseline: MaterialAppearanceBaseline, policy?: MaterialAppearancePolicy): MaterialAppearanceResponse;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
function unit(value, path) {
|
|
2
|
+
if (!Number.isFinite(value) || value < 0 || value > 1)
|
|
3
|
+
throw new TypeError(`${path}: expected a finite value from 0 to 1.`);
|
|
4
|
+
return value;
|
|
5
|
+
}
|
|
6
|
+
function target(baseline, endpoint, wetness, path) {
|
|
7
|
+
if (baseline === undefined)
|
|
8
|
+
throw new TypeError(`${path}: resolve the original physical material value before adapting it.`);
|
|
9
|
+
const start = unit(baseline, `baseline.${path}`);
|
|
10
|
+
const end = unit(endpoint, `policy.${path}`);
|
|
11
|
+
return wetness === 0 ? start : wetness === 1 ? end : start + (end - start) * wetness;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolve one surface from an immutable dry baseline, avoiding cumulative drift. Wetness already includes
|
|
15
|
+
* field shelter; neither exposure nor sheltered is multiplied into it again. No accumulation or drying simulation.
|
|
16
|
+
* @capability material-appearance-signals adapt selected surface response to sampled wetness and local card exposure
|
|
17
|
+
*/
|
|
18
|
+
export function resolveMaterialAppearance(sample, baseline, policy = {}) {
|
|
19
|
+
const wet = policy.wetness;
|
|
20
|
+
const wetness = unit(sample.wetness, "sample.wetness") * unit(wet?.influence ?? 1, "policy.wetness.influence");
|
|
21
|
+
const result = { surface: {}, wetness };
|
|
22
|
+
if (wet?.roughness !== undefined)
|
|
23
|
+
result.surface.roughness = target(baseline.roughness, wet.roughness, wetness, "roughness");
|
|
24
|
+
if (wet?.colorRetention !== undefined) {
|
|
25
|
+
const retention = 1 + (unit(wet.colorRetention, "policy.wetness.colorRetention") - 1) * wetness;
|
|
26
|
+
if (baseline.colorLinear.some(value => !Number.isFinite(value) || value < 0))
|
|
27
|
+
throw new TypeError("baseline.colorLinear: expected finite nonnegative linear RGB.");
|
|
28
|
+
result.colorLinear = baseline.colorLinear.map(value => value * retention);
|
|
29
|
+
}
|
|
30
|
+
if (wet?.sheen !== undefined)
|
|
31
|
+
result.surface.sheen = target(baseline.sheen, wet.sheen, wetness, "sheen");
|
|
32
|
+
if (wet?.clearcoat !== undefined) {
|
|
33
|
+
result.surface.clearcoat = target(baseline.clearcoat, wet.clearcoat.strength, wetness, "clearcoat");
|
|
34
|
+
if (wet.clearcoat.roughness !== undefined)
|
|
35
|
+
result.surface.clearcoatRoughness = target(baseline.clearcoatRoughness, wet.clearcoat.roughness, wetness, "clearcoatRoughness");
|
|
36
|
+
}
|
|
37
|
+
if (policy.cardBacklightExposure)
|
|
38
|
+
result.backlightExposure = unit(sample.lightExposure, "sample.lightExposure");
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Exactly one field sample per target; no traversal, subscription, hidden tick, or full-world scan.
|
|
43
|
+
* @capability material-appearance-signals sample one explicitly scheduled appearance target from the shared environment field
|
|
44
|
+
*/
|
|
45
|
+
export function sampleMaterialAppearance(field, target, baseline, policy = {}) {
|
|
46
|
+
return resolveMaterialAppearance(field.sample(target.x, target.z, target.time, target.y), baseline, policy);
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { MaterialDiagnostic } from "./materialAsset.js";
|
|
2
|
+
/** Tangents follow card width (UV U); strand length follows UV V. Angles are radians. */
|
|
3
|
+
export declare const HAIR_CARD_STRAND_ROTATION: number;
|
|
4
|
+
/** An authored centreline, in model-local metres, with root-to-tip points. */
|
|
5
|
+
export interface HairCardGuide {
|
|
6
|
+
id: string;
|
|
7
|
+
points: readonly (readonly [number, number, number])[];
|
|
8
|
+
width: number;
|
|
9
|
+
tipWidth?: number;
|
|
10
|
+
/** Desired front-facing normal; must not be parallel to the strand direction. */
|
|
11
|
+
facing: readonly [number, number, number];
|
|
12
|
+
/** Atlas rectangle [uMin,vMin,uMax,vMax]; U is width and V is root to tip. */
|
|
13
|
+
uvRect?: readonly [number, number, number, number];
|
|
14
|
+
}
|
|
15
|
+
/** Serializable geometry authoring, independent of materials, grooming physics and collisions. */
|
|
16
|
+
export interface HairCardAuthoring {
|
|
17
|
+
guides: readonly HairCardGuide[];
|
|
18
|
+
subdivisionsPerSegment?: number;
|
|
19
|
+
/** Allocation ceiling for offline/editor generation; default 65,536 vertices. */
|
|
20
|
+
maxVertices?: number;
|
|
21
|
+
}
|
|
22
|
+
/** Renderer-neutral indexed ribbons with an explicit tangent frame and guide ranges. */
|
|
23
|
+
export interface HairCardMesh {
|
|
24
|
+
positions: number[];
|
|
25
|
+
normals: number[];
|
|
26
|
+
tangents: number[];
|
|
27
|
+
uvs: number[];
|
|
28
|
+
indices: number[];
|
|
29
|
+
bounds: {
|
|
30
|
+
min: [number, number, number];
|
|
31
|
+
max: [number, number, number];
|
|
32
|
+
};
|
|
33
|
+
guides: {
|
|
34
|
+
id: string;
|
|
35
|
+
vertexStart: number;
|
|
36
|
+
vertexCount: number;
|
|
37
|
+
indexStart: number;
|
|
38
|
+
indexCount: number;
|
|
39
|
+
}[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Validate finite guides, tangent frames and bounded generation; crossing/collision is not solved.
|
|
43
|
+
* @capability hair-card-geometry validate authored ribbon guides and their vertex allocation ceiling
|
|
44
|
+
*/
|
|
45
|
+
export declare function validateHairCardAuthoring(value: unknown): MaterialDiagnostic[];
|
|
46
|
+
/** Build deterministic model-local ribbon geometry; guides remain separately editable source data. */
|
|
47
|
+
export declare function buildHairCards(input: HairCardAuthoring): HairCardMesh;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** Tangents follow card width (UV U); strand length follows UV V. Angles are radians. */
|
|
2
|
+
export const HAIR_CARD_STRAND_ROTATION = Math.PI / 2;
|
|
3
|
+
function vec(value) { return Array.isArray(value) && value.length === 3 && value.every((item) => typeof item === "number" && Number.isFinite(item)); }
|
|
4
|
+
function subtract(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
|
|
5
|
+
function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
|
|
6
|
+
function length(v) { return Math.hypot(...v); }
|
|
7
|
+
function normalized(v) { const magnitude = length(v); return [v[0] / magnitude, v[1] / magnitude, v[2] / magnitude]; }
|
|
8
|
+
/**
|
|
9
|
+
* Validate finite guides, tangent frames and bounded generation; crossing/collision is not solved.
|
|
10
|
+
* @capability hair-card-geometry validate authored ribbon guides and their vertex allocation ceiling
|
|
11
|
+
*/
|
|
12
|
+
export function validateHairCardAuthoring(value) {
|
|
13
|
+
const diagnostics = [];
|
|
14
|
+
const error = (path, message) => diagnostics.push({ severity: "error", code: "invalid-hair-guide", path, message });
|
|
15
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
16
|
+
return [{ severity: "error", code: "invalid-hair-guide", path: "", message: "Expected hair card authoring data." }];
|
|
17
|
+
const input = value;
|
|
18
|
+
for (const key of Object.keys(input))
|
|
19
|
+
if (!["guides", "subdivisionsPerSegment", "maxVertices"].includes(key))
|
|
20
|
+
error(key, "Unsupported hair geometry field.");
|
|
21
|
+
const subdivisions = input.subdivisionsPerSegment ?? 1;
|
|
22
|
+
const maxVertices = input.maxVertices ?? 65_536;
|
|
23
|
+
if (typeof subdivisions !== "number" || !Number.isInteger(subdivisions) || subdivisions < 1 || subdivisions > 256)
|
|
24
|
+
error("subdivisionsPerSegment", "Expected an integer from 1 to 256.");
|
|
25
|
+
if (typeof maxVertices !== "number" || !Number.isInteger(maxVertices) || maxVertices < 4 || maxVertices > 1_000_000)
|
|
26
|
+
error("maxVertices", "Expected a vertex ceiling from 4 to 1,000,000.");
|
|
27
|
+
if (!Array.isArray(input.guides) || input.guides.length === 0 || input.guides.length > 10_000) {
|
|
28
|
+
error("guides", "Expected 1 to 10,000 authored guides.");
|
|
29
|
+
return diagnostics;
|
|
30
|
+
}
|
|
31
|
+
const ids = new Set();
|
|
32
|
+
let vertexCount = 0;
|
|
33
|
+
for (let index = 0; index < input.guides.length; index++) {
|
|
34
|
+
const guide = input.guides[index];
|
|
35
|
+
const path = `guides.${index}`;
|
|
36
|
+
if (typeof guide !== "object" || guide === null || Array.isArray(guide)) {
|
|
37
|
+
error(path, "Expected a guide object.");
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
for (const key of Object.keys(guide))
|
|
41
|
+
if (!["id", "points", "width", "tipWidth", "facing", "uvRect"].includes(key))
|
|
42
|
+
error(`${path}.${key}`, "Unsupported guide field.");
|
|
43
|
+
if (typeof guide.id !== "string" || guide.id.trim().length === 0 || ids.has(guide.id))
|
|
44
|
+
error(`${path}.id`, "Expected a unique stable guide ID.");
|
|
45
|
+
else
|
|
46
|
+
ids.add(guide.id);
|
|
47
|
+
if (typeof guide.width !== "number" || !Number.isFinite(guide.width) || guide.width <= 0)
|
|
48
|
+
error(`${path}.width`, "Expected a positive width in metres.");
|
|
49
|
+
if (guide.tipWidth !== undefined && (typeof guide.tipWidth !== "number" || !Number.isFinite(guide.tipWidth) || guide.tipWidth < 0))
|
|
50
|
+
error(`${path}.tipWidth`, "Expected a nonnegative tip width in metres.");
|
|
51
|
+
const facingValid = vec(guide.facing) && length(guide.facing) > 1e-8;
|
|
52
|
+
if (!facingValid)
|
|
53
|
+
error(`${path}.facing`, "Expected a finite nonzero facing vector.");
|
|
54
|
+
if (!Array.isArray(guide.points) || guide.points.length < 2 || guide.points.length > 10_000) {
|
|
55
|
+
error(`${path}.points`, "Expected 2 to 10,000 finite model-local points.");
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
vertexCount += 2 * ((guide.points.length - 1) * (typeof subdivisions === "number" ? subdivisions : 1) + 1);
|
|
59
|
+
const ceiling = typeof maxVertices === "number" && Number.isFinite(maxVertices) ? Math.min(maxVertices, 1_000_000) : 65_536;
|
|
60
|
+
if (vertexCount > ceiling) {
|
|
61
|
+
error("maxVertices", `Guides require at least ${vertexCount} vertices, above the ${ceiling} ceiling.`);
|
|
62
|
+
return diagnostics;
|
|
63
|
+
}
|
|
64
|
+
if (!guide.points.every(vec)) {
|
|
65
|
+
error(`${path}.points`, "Expected finite model-local points.");
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
for (let point = 1; point < guide.points.length; point++) {
|
|
69
|
+
const direction = subtract(guide.points[point], guide.points[point - 1]);
|
|
70
|
+
if (length(direction) <= 1e-8)
|
|
71
|
+
error(`${path}.points.${point}`, "Adjacent guide points must be distinct.");
|
|
72
|
+
else if (facingValid && length(cross(normalized(direction), normalized(guide.facing))) <= 1e-6)
|
|
73
|
+
error(`${path}.facing`, "Facing is parallel to a guide segment; choose a different card orientation.");
|
|
74
|
+
if (point < guide.points.length - 1 && length(subtract(guide.points[point + 1], guide.points[point - 1])) <= 1e-8)
|
|
75
|
+
error(`${path}.points.${point}`, "A reversing guide has no stable tangent at its bend.");
|
|
76
|
+
}
|
|
77
|
+
if (guide.uvRect !== undefined && (!Array.isArray(guide.uvRect) || guide.uvRect.length !== 4 || !guide.uvRect.every((item) => typeof item === "number" && Number.isFinite(item) && item >= 0 && item <= 1) || guide.uvRect[0] >= guide.uvRect[2] || guide.uvRect[1] >= guide.uvRect[3]))
|
|
78
|
+
error(`${path}.uvRect`, "Expected an ordered atlas rectangle within 0 to 1.");
|
|
79
|
+
}
|
|
80
|
+
if (typeof maxVertices === "number" && vertexCount > maxVertices)
|
|
81
|
+
error("maxVertices", `Guides require ${vertexCount} vertices, above the ${maxVertices} ceiling.`);
|
|
82
|
+
return diagnostics;
|
|
83
|
+
}
|
|
84
|
+
/** Build deterministic model-local ribbon geometry; guides remain separately editable source data. */
|
|
85
|
+
export function buildHairCards(input) {
|
|
86
|
+
const errors = validateHairCardAuthoring(input);
|
|
87
|
+
if (errors.length > 0)
|
|
88
|
+
throw new TypeError(errors.map((item) => `${item.path}: ${item.message}`).join("; "));
|
|
89
|
+
const mesh = { positions: [], normals: [], tangents: [], uvs: [], indices: [], bounds: { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] }, guides: [] };
|
|
90
|
+
const subdivisions = input.subdivisionsPerSegment ?? 1;
|
|
91
|
+
for (const guide of input.guides) {
|
|
92
|
+
const vertexStart = mesh.positions.length / 3;
|
|
93
|
+
const indexStart = mesh.indices.length;
|
|
94
|
+
const samples = [[...guide.points[0]]];
|
|
95
|
+
for (let segment = 1; segment < guide.points.length; segment++) {
|
|
96
|
+
const from = guide.points[segment - 1], to = guide.points[segment];
|
|
97
|
+
for (let step = 1; step <= subdivisions; step++) {
|
|
98
|
+
const t = step / subdivisions;
|
|
99
|
+
samples.push([from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t, from[2] + (to[2] - from[2]) * t]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const distances = [0];
|
|
103
|
+
for (let index = 1; index < samples.length; index++)
|
|
104
|
+
distances.push(distances[index - 1] + length(subtract(samples[index], samples[index - 1])));
|
|
105
|
+
const totalLength = distances[distances.length - 1];
|
|
106
|
+
const atlas = guide.uvRect ?? [0, 0, 1, 1];
|
|
107
|
+
for (let index = 0; index < samples.length; index++) {
|
|
108
|
+
const tangent = normalized(subtract(samples[Math.min(index + 1, samples.length - 1)], samples[Math.max(index - 1, 0)]));
|
|
109
|
+
const widthDirection = cross(tangent, normalized(guide.facing));
|
|
110
|
+
if (length(widthDirection) <= 1e-6 || !widthDirection.every(Number.isFinite))
|
|
111
|
+
throw new TypeError(`Guide ${guide.id} has an unstable tangent frame at sample ${index}; change its bend or facing.`);
|
|
112
|
+
const across = normalized(widthDirection);
|
|
113
|
+
const normal = normalized(cross(across, tangent));
|
|
114
|
+
const t = distances[index] / totalLength;
|
|
115
|
+
const halfWidth = (guide.width + ((guide.tipWidth ?? guide.width * 0.1) - guide.width) * t) / 2;
|
|
116
|
+
for (let side = 0; side < 2; side++) {
|
|
117
|
+
const sign = side === 0 ? -1 : 1;
|
|
118
|
+
const position = [samples[index][0] + sign * across[0] * halfWidth, samples[index][1] + sign * across[1] * halfWidth, samples[index][2] + sign * across[2] * halfWidth];
|
|
119
|
+
mesh.positions.push(...position);
|
|
120
|
+
mesh.normals.push(...normal);
|
|
121
|
+
mesh.tangents.push(...across, 1);
|
|
122
|
+
mesh.uvs.push(side === 0 ? atlas[0] : atlas[2], atlas[1] + (atlas[3] - atlas[1]) * t);
|
|
123
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
124
|
+
mesh.bounds.min[axis] = Math.min(mesh.bounds.min[axis], position[axis]);
|
|
125
|
+
mesh.bounds.max[axis] = Math.max(mesh.bounds.max[axis], position[axis]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (index < samples.length - 1) {
|
|
129
|
+
const left = vertexStart + index * 2;
|
|
130
|
+
mesh.indices.push(left, left + 1, left + 2, left + 1, left + 3, left + 2);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
mesh.guides.push({ id: guide.id, vertexStart, vertexCount: samples.length * 2, indexStart, indexCount: mesh.indices.length - indexStart });
|
|
134
|
+
}
|
|
135
|
+
return mesh;
|
|
136
|
+
}
|