500 lines
16 KiB
TypeScript
500 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;
|
|
|
|
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 };
|
|
}
|