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 { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } 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(); 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, }; }