Files
workinf_Blender_Wasm/web/protocol/simplify.ts
2026-08-12 04:47:48 -04:00

311 lines
14 KiB
TypeScript

export type SimplifyMode = "COLLAPSE" | "UNSUBDIV" | "DISSOLVE_PLANAR";
export type SimplifyDelimit = "NORMAL" | "MATERIAL" | "SEAM" | "SHARP" | "UV" | "ALL_BOUNDARIES";
export type SimplifyAttributePolicy = "PRESERVE" | "RECOMPUTE_NORMALS" | "DROP";
export interface SkinSimplifyPolicy {
maxInfluences: number;
minWeight: number;
maxPositionError: number;
shapeKeys: "PRESERVE" | "REJECT";
}
export type SimplifyErrorCode =
| "INVALID_SCHEMA"
| "INVALID_PARAMETER"
| "MISSING_VERTEX_GROUP"
| "UNSUPPORTED_PARAMETER"
| "INVALID_LOD_MANIFEST";
export class SimplifyValidationError extends Error {
readonly code: SimplifyErrorCode;
constructor(code: SimplifyErrorCode, message: string) {
super(message);
this.name = "SimplifyValidationError";
this.code = code;
}
}
interface SimplifyCommon {
schemaVersion: 1;
sourceMeshRevision: number;
triangleBudget?: number;
maxGeometricError?: number;
screenSpaceError?: number;
attributePolicy: SimplifyAttributePolicy;
skinPolicy?: SkinSimplifyPolicy;
}
export type CollapseSimplifyProfile = SimplifyCommon & {
mode: "COLLAPSE";
ratio: number;
vertexGroup?: string | null;
vertexGroupFactor?: number;
vertexGroupInvert?: boolean;
triangulate: boolean;
useSymmetry: boolean;
symmetryAxis?: 0 | 1 | 2;
symmetryTolerance?: number;
};
export type UnsubdivideSimplifyProfile = SimplifyCommon & {
mode: "UNSUBDIV";
iterations: number;
};
export type DissolvePlanarSimplifyProfile = SimplifyCommon & {
mode: "DISSOLVE_PLANAR";
angleLimit: number;
useDissolveBoundaries: boolean;
delimit: SimplifyDelimit[];
};
export type SimplifyProfile = CollapseSimplifyProfile | UnsubdivideSimplifyProfile | DissolvePlanarSimplifyProfile;
export interface SimplifyResult {
schemaVersion: 1;
status: "applied" | "rejected";
sourceMeshRevision: number;
mode: SimplifyMode;
originalFaceCount: number;
originalTriangleCount: number;
outputFaceCount: number;
outputTriangleCount: number;
ratio: number;
triangleBudget?: number;
maxGeometricError?: number;
screenSpaceError?: number;
evaluatedMeshId?: string;
warnings?: string[];
error?: { code: SimplifyErrorCode; message: string };
}
export interface LODLevel {
level: number;
sourceMeshRevision: number;
triangleBudget: number;
meshId?: string;
maxGeometricError?: number;
screenSpaceError?: number;
}
export interface LODManifest {
schemaVersion: 1;
meshId: string;
sourceMeshRevision: number;
levels: LODLevel[];
}
function record(value: unknown, field: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new SimplifyValidationError("INVALID_SCHEMA", `${field} must be an object`);
}
return value as Record<string, unknown>;
}
function finite(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new SimplifyValidationError("INVALID_PARAMETER", `${field} must be finite`);
}
return value;
}
function integer(value: unknown, field: string, minimum = 0): number {
const result = finite(value, field);
if (!Number.isInteger(result) || result < minimum) {
throw new SimplifyValidationError("INVALID_PARAMETER", `${field} must be an integer >= ${minimum}`);
}
return result;
}
function optionalNonNegative(value: unknown, field: string): number | undefined {
if (value === undefined) return undefined;
const result = finite(value, field);
if (result < 0) throw new SimplifyValidationError("INVALID_PARAMETER", `${field} must be >= 0`);
return result;
}
function boolean(value: unknown, field: string, fallback: boolean): boolean {
if (value === undefined) return fallback;
if (typeof value !== "boolean") throw new SimplifyValidationError("INVALID_PARAMETER", `${field} must be boolean`);
return value;
}
function optionalBudget(value: unknown): number | undefined {
if (value === undefined) return undefined;
return integer(value, "triangleBudget", 1);
}
function attributePolicy(value: unknown): SimplifyAttributePolicy {
if (value === undefined) return "PRESERVE";
if (value !== "PRESERVE" && value !== "RECOMPUTE_NORMALS" && value !== "DROP") {
throw new SimplifyValidationError("INVALID_PARAMETER", "attributePolicy must be PRESERVE, RECOMPUTE_NORMALS or DROP");
}
return value;
}
function skinPolicy(value: unknown): SkinSimplifyPolicy | undefined {
if (value === undefined) return undefined;
const input = record(value, "skinPolicy");
const maxInfluences = integer(input.maxInfluences, "skinPolicy.maxInfluences", 1);
if (maxInfluences > 8) throw new SimplifyValidationError("INVALID_PARAMETER", "skinPolicy.maxInfluences must be <= 8");
const minWeight = finite(input.minWeight, "skinPolicy.minWeight");
if (minWeight < 0 || minWeight > 1) throw new SimplifyValidationError("INVALID_PARAMETER", "skinPolicy.minWeight must be between 0 and 1");
const maxPositionError = optionalNonNegative(input.maxPositionError, "skinPolicy.maxPositionError");
if (maxPositionError === undefined) throw new SimplifyValidationError("INVALID_PARAMETER", "skinPolicy.maxPositionError is required");
const shapeKeys = input.shapeKeys;
if (shapeKeys !== "PRESERVE" && shapeKeys !== "REJECT") {
throw new SimplifyValidationError("INVALID_PARAMETER", "skinPolicy.shapeKeys must be PRESERVE or REJECT");
}
return { maxInfluences, minWeight, maxPositionError, shapeKeys };
}
function common(input: Record<string, unknown>): SimplifyCommon {
if (input.schemaVersion !== 1) throw new SimplifyValidationError("INVALID_SCHEMA", "Unsupported SimplifyProfile schema");
return {
schemaVersion: 1,
sourceMeshRevision: integer(input.sourceMeshRevision, "sourceMeshRevision"),
triangleBudget: optionalBudget(input.triangleBudget),
maxGeometricError: optionalNonNegative(input.maxGeometricError, "maxGeometricError"),
screenSpaceError: optionalNonNegative(input.screenSpaceError, "screenSpaceError"),
attributePolicy: attributePolicy(input.attributePolicy),
skinPolicy: skinPolicy(input.skinPolicy),
};
}
function rejectFields(input: Record<string, unknown>, fields: string[]): void {
const present = fields.find((field) => input[field] !== undefined);
if (present) throw new SimplifyValidationError("UNSUPPORTED_PARAMETER", `${present} is not valid for this simplify mode`);
}
const delimitValues: SimplifyDelimit[] = ["NORMAL", "MATERIAL", "SEAM", "SHARP", "UV", "ALL_BOUNDARIES"];
export function parseSimplifyProfile(value: unknown): SimplifyProfile {
const input = record(value, "SimplifyProfile");
const base = common(input);
if (input.mode === "COLLAPSE") {
const ratio = finite(input.ratio, "ratio");
if (ratio <= 0 || ratio > 1) throw new SimplifyValidationError("INVALID_PARAMETER", "ratio must be > 0 and <= 1");
const vertexGroup = input.vertexGroup === undefined || input.vertexGroup === null ? null : input.vertexGroup;
if (vertexGroup !== null && typeof vertexGroup !== "string") {
throw new SimplifyValidationError("INVALID_PARAMETER", "vertexGroup must be a string or null");
}
const vertexGroupFactor = finite(input.vertexGroupFactor ?? 1, "vertexGroupFactor");
if (vertexGroupFactor < 0 || vertexGroupFactor > 1) {
throw new SimplifyValidationError("INVALID_PARAMETER", "vertexGroupFactor must be between 0 and 1");
}
const vertexGroupInvert = boolean(input.vertexGroupInvert, "vertexGroupInvert", false);
if (vertexGroup === null && (input.vertexGroupFactor !== undefined || vertexGroupInvert)) {
throw new SimplifyValidationError("MISSING_VERTEX_GROUP", "vertexGroupFactor/invert requires vertexGroup");
}
const useSymmetry = boolean(input.useSymmetry, "useSymmetry", false);
const symmetryAxis = integer(input.symmetryAxis ?? 0, "symmetryAxis");
if (symmetryAxis > 2) throw new SimplifyValidationError("INVALID_PARAMETER", "symmetryAxis must be 0, 1 or 2");
const symmetryTolerance = finite(input.symmetryTolerance ?? 1e-4, "symmetryTolerance");
if (symmetryTolerance <= 0) throw new SimplifyValidationError("INVALID_PARAMETER", "symmetryTolerance must be > 0");
if (!useSymmetry && (input.symmetryAxis !== undefined || input.symmetryTolerance !== undefined)) {
throw new SimplifyValidationError("UNSUPPORTED_PARAMETER", "symmetryAxis/tolerance requires useSymmetry");
}
return {
...base,
mode: "COLLAPSE",
ratio,
vertexGroup,
vertexGroupFactor,
vertexGroupInvert,
triangulate: boolean(input.triangulate, "triangulate", false),
useSymmetry,
symmetryAxis: symmetryAxis as 0 | 1 | 2,
symmetryTolerance,
};
}
if (input.mode === "UNSUBDIV") {
rejectFields(input, ["ratio", "vertexGroup", "vertexGroupFactor", "vertexGroupInvert", "triangulate", "useSymmetry", "symmetryAxis", "symmetryTolerance", "angleLimit", "delimit"]);
return { ...base, mode: "UNSUBDIV", iterations: integer(input.iterations, "iterations", 1) };
}
if (input.mode === "DISSOLVE_PLANAR") {
rejectFields(input, ["ratio", "vertexGroup", "vertexGroupFactor", "vertexGroupInvert", "triangulate", "useSymmetry", "symmetryAxis", "symmetryTolerance", "iterations"]);
const angleLimit = finite(input.angleLimit, "angleLimit");
if (angleLimit < 0 || angleLimit > Math.PI) throw new SimplifyValidationError("INVALID_PARAMETER", "angleLimit must be between 0 and PI");
const delimitInput = input.delimit ?? [];
if (!Array.isArray(delimitInput) || delimitInput.some((item) => typeof item !== "string" || !delimitValues.includes(item as SimplifyDelimit))) {
throw new SimplifyValidationError("INVALID_PARAMETER", "delimit contains an unsupported boundary type");
}
const delimit = [...new Set(delimitInput as SimplifyDelimit[])];
return {
...base,
mode: "DISSOLVE_PLANAR",
angleLimit,
useDissolveBoundaries: boolean(input.useDissolveBoundaries, "useDissolveBoundaries", false),
delimit,
};
}
throw new SimplifyValidationError("INVALID_PARAMETER", "mode must be COLLAPSE, UNSUBDIV or DISSOLVE_PLANAR");
}
export function parseSimplifyResult(value: unknown): SimplifyResult {
const input = record(value, "SimplifyResult");
if (input.schemaVersion !== 1 || (input.status !== "applied" && input.status !== "rejected")) {
throw new SimplifyValidationError("INVALID_SCHEMA", "SimplifyResult envelope is invalid");
}
const mode = input.mode;
if (mode !== "COLLAPSE" && mode !== "UNSUBDIV" && mode !== "DISSOLVE_PLANAR") {
throw new SimplifyValidationError("INVALID_SCHEMA", "SimplifyResult.mode is invalid");
}
for (const field of ["originalFaceCount", "originalTriangleCount", "outputFaceCount", "outputTriangleCount"]) {
integer(input[field], field);
}
const ratio = finite(input.ratio, "ratio");
if (ratio < 0 || ratio > 1) throw new SimplifyValidationError("INVALID_PARAMETER", "result ratio must be between 0 and 1");
const result: SimplifyResult = {
schemaVersion: 1,
status: input.status,
sourceMeshRevision: integer(input.sourceMeshRevision, "sourceMeshRevision"),
mode,
originalFaceCount: input.originalFaceCount as number,
originalTriangleCount: input.originalTriangleCount as number,
outputFaceCount: input.outputFaceCount as number,
outputTriangleCount: input.outputTriangleCount as number,
ratio,
triangleBudget: optionalBudget(input.triangleBudget),
maxGeometricError: optionalNonNegative(input.maxGeometricError, "maxGeometricError"),
screenSpaceError: optionalNonNegative(input.screenSpaceError, "screenSpaceError"),
evaluatedMeshId: input.evaluatedMeshId === undefined ? undefined : String(input.evaluatedMeshId),
warnings: input.warnings === undefined ? undefined : input.warnings as string[],
error: input.error === undefined ? undefined : record(input.error, "error") as SimplifyResult["error"],
};
if (result.status === "rejected" && !result.error) throw new SimplifyValidationError("INVALID_SCHEMA", "rejected result must include error");
return result;
}
export function parseLODManifest(value: unknown): LODManifest {
const input = record(value, "LODManifest");
if (input.schemaVersion !== 1 || typeof input.meshId !== "string" || !input.meshId) {
throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LODManifest envelope is invalid");
}
if (!Array.isArray(input.levels) || input.levels.length === 0) {
throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LODManifest.levels must not be empty");
}
const sourceMeshRevision = integer(input.sourceMeshRevision, "sourceMeshRevision");
const levels = input.levels.map((raw, index) => {
const level = record(raw, `levels[${index}]`);
const parsed: LODLevel = {
level: integer(level.level, `levels[${index}].level`),
sourceMeshRevision: integer(level.sourceMeshRevision, `levels[${index}].sourceMeshRevision`),
triangleBudget: integer(level.triangleBudget, `levels[${index}].triangleBudget`, 1),
meshId: level.meshId === undefined ? undefined : String(level.meshId),
maxGeometricError: optionalNonNegative(level.maxGeometricError, `levels[${index}].maxGeometricError`),
screenSpaceError: optionalNonNegative(level.screenSpaceError, `levels[${index}].screenSpaceError`),
};
if (parsed.sourceMeshRevision !== sourceMeshRevision) throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "all LOD levels must share sourceMeshRevision");
return parsed;
});
for (let index = 0; index < levels.length; index++) {
if (levels[index].level !== index) throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LOD levels must be contiguous from level 0");
if (index > 0 && levels[index].triangleBudget >= levels[index - 1].triangleBudget) {
throw new SimplifyValidationError("INVALID_LOD_MANIFEST", "LOD triangle budgets must strictly decrease");
}
}
return { schemaVersion: 1, meshId: input.meshId, sourceMeshRevision, levels };
}