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

515 lines
16 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 OpenClawPackagingLayer = {
nodeName: string;
nodeRole: string;
name: string;
packageIndex: number | null;
dimensionsMm: OpenClawModelVector | null;
packedPositionMm: OpenClawModelVector | null;
explodedPositionMm: OpenClawModelVector | null;
explodedOffsetMm: OpenClawModelVector | null;
packedOffsetFromExplodedMm: OpenClawModelVector | null;
color: string | null;
};
export type OpenClawModelInteractionState =
| "assembled"
| "exploded"
| "packaging_exploded"
| "packaging_packed"
| "custom";
export type OpenClawModelInteractionAction = {
id: string;
label: string;
toState: OpenClawModelInteractionState;
target: string | null;
nodesField: string | null;
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;
selectPackagingLayer: boolean;
oneClickPackagingExplode: boolean;
oneClickPackagingPack: boolean;
};
packaging: {
shipmentMode: string | null;
boxStyleLabel: string | null;
packageCount: number | null;
packageDimensionsMm: OpenClawModelVector | null;
packageDimensions: Array<{
packageIndex: number;
dimensionsMm: OpenClawModelVector;
}>;
totalPackagingVolumeM3: number | null;
volumeWeightKg: number | null;
layers: OpenClawPackagingLayer[];
} | null;
};
const MAX_PARTS = 5000;
const MAX_ACTIONS = 32;
const MAX_TEXT_LENGTH = 500;
const MAX_ABSOLUTE_COORDINATE_MM = 10_000_000;
export function getOpenClawPartForSceneNode<T>(
node: { name: string; userData?: Record<string, unknown> },
partsByNodeName: ReadonlyMap<string, T>
): T | undefined {
// GLTFLoader sanitizes names such as SOURCE-001:1 to SOURCE-0011, but
// preserves the original GLB node name in userData.name. The manifest uses
// that original name, including the instance suffix.
const originalName = node.userData?.name;
return (
(typeof originalName === "string"
? partsByNodeName.get(originalName)
: undefined) ?? partsByNodeName.get(node.name)
);
}
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 normalizePackagingLayer(value: unknown): OpenClawPackagingLayer | null {
const raw = readRecord(value);
const nodeName = readText(raw.node_name ?? raw.nodeName);
if (!nodeName) return null;
const packageIndex = Number(raw.package_index ?? raw.packageIndex);
const color = readText(raw.color, 32);
return {
nodeName,
nodeRole: readText(raw.node_role ?? raw.nodeRole) || "packaging_layer",
name: readText(raw.name) || "包装层",
packageIndex:
Number.isInteger(packageIndex) && packageIndex > 0 ? packageIndex : null,
dimensionsMm: normalizeVector(raw.dimensions_mm ?? raw.dimensionsMm),
packedPositionMm: normalizeVector(
raw.packed_position_mm ?? raw.packedPositionMm
),
explodedPositionMm: normalizeVector(
raw.exploded_position_mm ?? raw.explodedPositionMm
),
explodedOffsetMm: normalizeVector(
raw.exploded_offset_mm ?? raw.explodedOffsetMm
),
packedOffsetFromExplodedMm: normalizeVector(
raw.packed_offset_from_exploded_mm ?? raw.packedOffsetFromExplodedMm
),
color: color || null,
};
}
function readOptionalFiniteNumber(value: unknown, min = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= min ? parsed : null;
}
function normalizePackageDimensions(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
const raw = readRecord(item);
const packageIndex = Number(raw.package_index ?? raw.packageIndex);
const dimensionsMm = normalizeVector(
raw.dimensions_mm ?? raw.dimensionsMm ?? raw
);
if (!Number.isInteger(packageIndex) || packageIndex <= 0 || !dimensionsMm) {
return null;
}
return { packageIndex, dimensionsMm };
})
.filter(
(
item
): item is { packageIndex: number; dimensionsMm: OpenClawModelVector } =>
Boolean(item)
);
}
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();
const normalizedToState: OpenClawModelInteractionState =
toState === "exploded" ||
toState === "custom" ||
toState === "packaging_exploded" ||
toState === "packaging_packed"
? toState
: "assembled";
return {
id,
label: readText(raw.label, 160) || id,
toState: normalizedToState,
target: readText(raw.target, 160) || null,
nodesField: readText(raw.nodes_field ?? raw.nodesField, 240) || null,
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,
target: "all_parts",
nodesField: null,
},
{
id: "assemble_all",
label: "一键还原",
toState: "assembled",
offsetField: null,
durationMs: 700,
easing: "easeInOutCubic",
interruptible: true,
target: "all_parts",
nodesField: null,
},
];
}
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;
});
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 packaging = readRecord(raw.packaging);
const seenPackagingNodeNames = new Set<string>();
const packagingLayers = (
Array.isArray(packaging.packaging_layers)
? packaging.packaging_layers
: Array.isArray(packaging.packagingLayers)
? packaging.packagingLayers
: []
)
.slice(0, MAX_PARTS)
.map(normalizePackagingLayer)
.filter((layer): layer is OpenClawPackagingLayer => {
if (!layer || seenPackagingNodeNames.has(layer.nodeName)) return false;
seenPackagingNodeNames.add(layer.nodeName);
return true;
});
if (parts.length === 0 && packagingLayers.length === 0) return null;
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
),
selectPackagingLayer: readBoolean(
capabilities.select_packaging_layer ??
capabilities.selectPackagingLayer,
packagingLayers.length > 0
),
oneClickPackagingExplode: readBoolean(
capabilities.one_click_packaging_explode ??
capabilities.oneClickPackagingExplode,
packagingLayers.length > 0
),
oneClickPackagingPack: readBoolean(
capabilities.one_click_packaging_pack ??
capabilities.oneClickPackagingPack,
packagingLayers.length > 0
),
},
packaging:
Object.keys(packaging).length > 0
? {
shipmentMode:
readText(packaging.shipment_mode ?? packaging.shipmentMode) ||
null,
boxStyleLabel:
readText(
packaging.packaging_box_style_label ?? packaging.boxStyleLabel
) || null,
packageCount: readOptionalFiniteNumber(
packaging.package_count ?? packaging.packageCount,
1
),
packageDimensionsMm: normalizeVector(
packaging.package_dimensions_mm ?? packaging.packageDimensionsMm
),
packageDimensions: normalizePackageDimensions(
packaging.package_dimensions ?? packaging.packageDimensions
),
totalPackagingVolumeM3: readOptionalFiniteNumber(
packaging.total_packaging_volume_m3 ??
packaging.totalPackagingVolumeM3
),
volumeWeightKg: readOptionalFiniteNumber(
packaging.volume_weight_kg ?? packaging.volumeWeightKg
),
layers: packagingLayers,
}
: null,
};
}
export function getOpenClawPackagingInteractionParts(
manifest: OpenClawModelInteractionManifest
): OpenClawModelInteractionPart[] {
return (manifest.packaging?.layers || []).map((layer) => {
const explodedOffsetMm =
layer.explodedOffsetMm ||
(layer.packedOffsetFromExplodedMm
? {
x: -layer.packedOffsetFromExplodedMm.x || 0,
y: -layer.packedOffsetFromExplodedMm.y || 0,
z: -layer.packedOffsetFromExplodedMm.z || 0,
}
: layer.packedPositionMm && layer.explodedPositionMm
? {
x: layer.explodedPositionMm.x - layer.packedPositionMm.x,
y: layer.explodedPositionMm.y - layer.packedPositionMm.y,
z: layer.explodedPositionMm.z - layer.packedPositionMm.z,
}
: null);
return {
nodeName: layer.nodeName,
partId: layer.nodeRole,
partName: layer.name,
instanceIndex: layer.packageIndex,
dimensionsMm: layer.dimensionsMm,
assembledPositionMm: layer.packedPositionMm,
explodedPositionMm: layer.explodedPositionMm,
explodedOffsetMm,
color: layer.color,
draggable: false,
};
});
}
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,
};
}
export function getOpenClawSourceNodeOffset(
part: OpenClawModelInteractionPart,
sourceVariant: "assembled" | "exploded",
scaleMmToGlb: number
): OpenClawModelVector {
if (sourceVariant === "assembled") return { x: 0, y: 0, z: 0 };
const explodedOffset = getOpenClawExplodedOffsetMm(part);
if (!explodedOffset) return { x: 0, y: 0, z: 0 };
const mapped = mapOpenClawCadOffsetToGlb(explodedOffset, scaleMmToGlb);
return { x: -mapped.x, y: -mapped.y, z: -mapped.z };
}