ai-chatbot/lib/openclaw-chat/model-interaction.ts

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,
};
}