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 { 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 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(); 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(); 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 }; }