290 lines
8.3 KiB
TypeScript
290 lines
8.3 KiB
TypeScript
export type OpenClawModelVector = {
|
|
x: number;
|
|
y: number;
|
|
z: number;
|
|
};
|
|
|
|
export type OpenClawModelInteractionPart = {
|
|
nodeName: string;
|
|
partId: string;
|
|
partName: string;
|
|
instanceIndex: number | null;
|
|
dimensionsMm: OpenClawModelVector | null;
|
|
assembledPositionMm: OpenClawModelVector | null;
|
|
explodedPositionMm: OpenClawModelVector | null;
|
|
explodedOffsetMm: OpenClawModelVector | null;
|
|
color: string | null;
|
|
draggable: boolean;
|
|
};
|
|
|
|
export type OpenClawModelInteractionAction = {
|
|
id: string;
|
|
label: string;
|
|
toState: "assembled" | "exploded" | "custom";
|
|
offsetField: string | null;
|
|
durationMs: number;
|
|
easing: string;
|
|
interruptible: boolean;
|
|
};
|
|
|
|
export type OpenClawModelInteractionManifest = {
|
|
schema: string;
|
|
scaleMmToGlb: number;
|
|
defaultVariant: "assembled" | "exploded";
|
|
parts: OpenClawModelInteractionPart[];
|
|
actions: OpenClawModelInteractionAction[];
|
|
drag: {
|
|
axes: Array<"x" | "y" | "z">;
|
|
snapDistanceMm: number;
|
|
maxDistanceMm: number;
|
|
};
|
|
capabilities: {
|
|
oneClickExplode: boolean;
|
|
oneClickAssemble: boolean;
|
|
animatedTransition: boolean;
|
|
explodeSlider: boolean;
|
|
};
|
|
};
|
|
|
|
const MAX_PARTS = 5000;
|
|
const MAX_ACTIONS = 32;
|
|
const MAX_TEXT_LENGTH = 500;
|
|
const MAX_ABSOLUTE_COORDINATE_MM = 10_000_000;
|
|
|
|
function readRecord(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function readText(value: unknown, maxLength = MAX_TEXT_LENGTH) {
|
|
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
|
|
}
|
|
|
|
function readBoolean(value: unknown, fallback: boolean) {
|
|
return typeof value === "boolean" ? value : fallback;
|
|
}
|
|
|
|
function readFiniteNumber(
|
|
value: unknown,
|
|
fallback: number,
|
|
limits?: { min?: number; max?: number }
|
|
) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) return fallback;
|
|
if (limits?.min !== undefined && parsed < limits.min) return fallback;
|
|
if (limits?.max !== undefined && parsed > limits.max) return fallback;
|
|
return parsed;
|
|
}
|
|
|
|
function normalizeVector(value: unknown): OpenClawModelVector | null {
|
|
const raw = readRecord(value);
|
|
const vector = {
|
|
x: Number(raw.x),
|
|
y: Number(raw.y),
|
|
z: Number(raw.z),
|
|
};
|
|
if (
|
|
Object.values(vector).some(
|
|
(coordinate) =>
|
|
!Number.isFinite(coordinate) ||
|
|
Math.abs(coordinate) > MAX_ABSOLUTE_COORDINATE_MM
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
return vector;
|
|
}
|
|
|
|
function normalizePart(value: unknown): OpenClawModelInteractionPart | null {
|
|
const raw = readRecord(value);
|
|
const nodeName = readText(raw.node_name ?? raw.nodeName);
|
|
if (!nodeName) return null;
|
|
const instanceIndex = Number(raw.instance_index ?? raw.instanceIndex);
|
|
const color = readText(raw.color, 32);
|
|
|
|
return {
|
|
nodeName,
|
|
partId: readText(raw.part_id ?? raw.partId) || nodeName,
|
|
partName: readText(raw.part_name ?? raw.partName) || "未命名配件",
|
|
instanceIndex:
|
|
Number.isInteger(instanceIndex) && instanceIndex >= 0
|
|
? instanceIndex
|
|
: null,
|
|
dimensionsMm: normalizeVector(raw.dimensions_mm ?? raw.dimensionsMm),
|
|
assembledPositionMm: normalizeVector(
|
|
raw.assembled_position_mm ?? raw.assembledPositionMm
|
|
),
|
|
explodedPositionMm: normalizeVector(
|
|
raw.exploded_position_mm ?? raw.explodedPositionMm
|
|
),
|
|
explodedOffsetMm: normalizeVector(
|
|
raw.exploded_offset_mm ?? raw.explodedOffsetMm
|
|
),
|
|
color: color || null,
|
|
draggable: readBoolean(raw.draggable, true),
|
|
};
|
|
}
|
|
|
|
function normalizeAction(
|
|
value: unknown
|
|
): OpenClawModelInteractionAction | null {
|
|
const raw = readRecord(value);
|
|
const id = readText(raw.id, 160);
|
|
if (!id) return null;
|
|
const toState = readText(raw.to_state ?? raw.toState, 32).toLowerCase();
|
|
return {
|
|
id,
|
|
label: readText(raw.label, 160) || id,
|
|
toState:
|
|
toState === "exploded" || toState === "custom"
|
|
? toState
|
|
: "assembled",
|
|
offsetField: readText(raw.offset_field ?? raw.offsetField, 160) || null,
|
|
durationMs: readFiniteNumber(
|
|
raw.duration_ms ?? raw.durationMs,
|
|
700,
|
|
{ min: 0, max: 30_000 }
|
|
),
|
|
easing: readText(raw.easing, 80) || "easeInOutCubic",
|
|
interruptible: readBoolean(raw.interruptible, true),
|
|
};
|
|
}
|
|
|
|
function hasExplodedTransform(part: OpenClawModelInteractionPart) {
|
|
return Boolean(
|
|
part.explodedOffsetMm ||
|
|
(part.assembledPositionMm && part.explodedPositionMm)
|
|
);
|
|
}
|
|
|
|
function defaultActions(): OpenClawModelInteractionAction[] {
|
|
return [
|
|
{
|
|
id: "explode_all",
|
|
label: "一键炸开",
|
|
toState: "exploded",
|
|
offsetField: "exploded_offset_mm",
|
|
durationMs: 700,
|
|
easing: "easeInOutCubic",
|
|
interruptible: true,
|
|
},
|
|
{
|
|
id: "assemble_all",
|
|
label: "一键还原",
|
|
toState: "assembled",
|
|
offsetField: null,
|
|
durationMs: 700,
|
|
easing: "easeInOutCubic",
|
|
interruptible: true,
|
|
},
|
|
];
|
|
}
|
|
|
|
export function normalizeOpenClawModelInteractionManifest(
|
|
value: unknown
|
|
): OpenClawModelInteractionManifest | null {
|
|
const raw = readRecord(value);
|
|
if (Object.keys(raw).length === 0) return null;
|
|
|
|
const seenNodeNames = new Set<string>();
|
|
const parts = (Array.isArray(raw.parts) ? raw.parts : [])
|
|
.slice(0, MAX_PARTS)
|
|
.map(normalizePart)
|
|
.filter((part): part is OpenClawModelInteractionPart => {
|
|
if (!part || seenNodeNames.has(part.nodeName)) return false;
|
|
seenNodeNames.add(part.nodeName);
|
|
return true;
|
|
});
|
|
if (parts.length === 0) return null;
|
|
|
|
const parsedActions = (Array.isArray(raw.actions) ? raw.actions : [])
|
|
.slice(0, MAX_ACTIONS)
|
|
.map(normalizeAction)
|
|
.filter((action): action is OpenClawModelInteractionAction => Boolean(action));
|
|
const supportsTransforms = parts.some(hasExplodedTransform);
|
|
const actions =
|
|
parsedActions.length > 0
|
|
? parsedActions
|
|
: supportsTransforms
|
|
? defaultActions()
|
|
: [];
|
|
const coordinates = readRecord(raw.coordinates);
|
|
const drag = readRecord(raw.drag);
|
|
const capabilities = readRecord(raw.capabilities);
|
|
const axes = (Array.isArray(drag.axes) ? drag.axes : ["x", "y", "z"]).filter(
|
|
(axis): axis is "x" | "y" | "z" =>
|
|
axis === "x" || axis === "y" || axis === "z"
|
|
);
|
|
const defaultVariant = readText(
|
|
raw.default_variant ?? raw.defaultVariant,
|
|
32
|
|
).toLowerCase();
|
|
|
|
return {
|
|
schema: readText(raw.schema) || "dalongxia.furniture-model-interaction.v1",
|
|
scaleMmToGlb: readFiniteNumber(
|
|
coordinates.scale_mm_to_glb ?? coordinates.scaleMmToGlb,
|
|
0.001,
|
|
{ min: 0.000_000_1, max: 1 }
|
|
),
|
|
defaultVariant: defaultVariant === "exploded" ? "exploded" : "assembled",
|
|
parts,
|
|
actions,
|
|
drag: {
|
|
axes: axes.length > 0 ? axes : ["x", "y", "z"],
|
|
snapDistanceMm: readFiniteNumber(
|
|
drag.snap_distance_mm ?? drag.snapDistanceMm,
|
|
25,
|
|
{ min: 0, max: 100_000 }
|
|
),
|
|
maxDistanceMm: readFiniteNumber(
|
|
drag.max_distance_mm ?? drag.maxDistanceMm,
|
|
1800,
|
|
{ min: 0, max: MAX_ABSOLUTE_COORDINATE_MM }
|
|
),
|
|
},
|
|
capabilities: {
|
|
oneClickExplode: readBoolean(
|
|
capabilities.one_click_explode ?? capabilities.oneClickExplode,
|
|
supportsTransforms
|
|
),
|
|
oneClickAssemble: readBoolean(
|
|
capabilities.one_click_assemble ?? capabilities.oneClickAssemble,
|
|
supportsTransforms
|
|
),
|
|
animatedTransition: readBoolean(
|
|
capabilities.animated_transition ?? capabilities.animatedTransition,
|
|
supportsTransforms
|
|
),
|
|
explodeSlider: readBoolean(
|
|
capabilities.explode_slider ?? capabilities.explodeSlider,
|
|
supportsTransforms
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function getOpenClawExplodedOffsetMm(
|
|
part: OpenClawModelInteractionPart
|
|
): OpenClawModelVector | null {
|
|
if (part.explodedOffsetMm) return part.explodedOffsetMm;
|
|
if (!part.assembledPositionMm || !part.explodedPositionMm) return null;
|
|
return {
|
|
x: part.explodedPositionMm.x - part.assembledPositionMm.x,
|
|
y: part.explodedPositionMm.y - part.assembledPositionMm.y,
|
|
z: part.explodedPositionMm.z - part.assembledPositionMm.z,
|
|
};
|
|
}
|
|
|
|
export function mapOpenClawCadOffsetToGlb(
|
|
offset: OpenClawModelVector,
|
|
scaleMmToGlb: number
|
|
): OpenClawModelVector {
|
|
return {
|
|
x: offset.x * scaleMmToGlb,
|
|
y: offset.z * scaleMmToGlb,
|
|
z: -offset.y * scaleMmToGlb,
|
|
};
|
|
}
|