Add Chromium-only Blender WebEngine parity work
This commit is contained in:
310
web/protocol/simplify.ts
Normal file
310
web/protocol/simplify.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user