diff --git a/src/components/ItemEditorPage/CenterPanel/CenterPanel.container.tsx b/src/components/ItemEditorPage/CenterPanel/CenterPanel.container.tsx index f80cbfc77..b67199be6 100644 --- a/src/components/ItemEditorPage/CenterPanel/CenterPanel.container.tsx +++ b/src/components/ItemEditorPage/CenterPanel/CenterPanel.container.tsx @@ -15,7 +15,8 @@ import { setSkinColor, fetchBaseWearablesRequest, setWearablePreviewController, - setItems + setItems, + pushSpringBoneParams } from 'modules/editor/actions' import { getEmote, @@ -124,6 +125,9 @@ const CenterPanelContainer: React.FC = () => { params?: Parameters[1] ) => { dispatch(fetchCollectionItemsRequest(id, params)) + }, + onPushSpringBoneParams: () => { + dispatch(pushSpringBoneParams()) } }), [dispatch] diff --git a/src/components/ItemEditorPage/CenterPanel/CenterPanel.tsx b/src/components/ItemEditorPage/CenterPanel/CenterPanel.tsx index f1b48f281..e547d2056 100644 --- a/src/components/ItemEditorPage/CenterPanel/CenterPanel.tsx +++ b/src/components/ItemEditorPage/CenterPanel/CenterPanel.tsx @@ -263,12 +263,15 @@ export default class CenterPanel extends React.PureComponent { } handleWearablePreviewLoad = () => { - const { wearableController, onSetWearablePreviewController } = this.props + const { wearableController, onSetWearablePreviewController, onPushSpringBoneParams } = this.props if (!wearableController) { onSetWearablePreviewController(WearablePreview.createController('wearable-editor')) } + // Push current spring bone params to the freshly loaded controller via saga + onPushSpringBoneParams() + this.setState({ isLoading: false }) // Run validation once the preview has loaded (item data is ready) @@ -278,7 +281,7 @@ export default class CenterPanel extends React.PureComponent { } handlePlayEmote = () => { - const { wearableController, isPlayingEmote, visibleItems, onSetAvatarAnimation, onSetItems } = this.props + const { wearableController, isPlayingEmote, visibleItems, onSetAvatarAnimation, onSetItems, onPushSpringBoneParams } = this.props const newVisibleItems = visibleItems.filter(item => item.type !== ItemType.EMOTE) if (isPlayingEmote) { @@ -286,6 +289,8 @@ export default class CenterPanel extends React.PureComponent { onSetItems(newVisibleItems) } else { wearableController?.emote.play() as void + // Push spring bone params immediately on emote play start via saga + onPushSpringBoneParams() } } @@ -467,6 +472,8 @@ export default class CenterPanel extends React.PureComponent { profile="default" bodyShape={bodyShape} emote={emote} + // TODO: remove baseUrl before merging. + baseUrl="https://wearable-preview-git-feat-babylon-spring-bones-decentraland1.vercel.app" zoom={zoom} skin={toHex(skinColor)} eyes={toHex(eyeColor)} diff --git a/src/components/ItemEditorPage/CenterPanel/CenterPanel.types.ts b/src/components/ItemEditorPage/CenterPanel/CenterPanel.types.ts index 0ec27f7c7..e6f77e967 100644 --- a/src/components/ItemEditorPage/CenterPanel/CenterPanel.types.ts +++ b/src/components/ItemEditorPage/CenterPanel/CenterPanel.types.ts @@ -46,6 +46,7 @@ export type Props = { onFetchCollectionItems: ActionFunction onSetWearablePreviewController: (controller: Parameters[0]) => void onSetItems: (items: Parameters[0]) => void + onPushSpringBoneParams: () => void } export type CenterPanelContainerProps = Record diff --git a/src/components/ItemEditorPage/RightPanel/RightPanel.container.tsx b/src/components/ItemEditorPage/RightPanel/RightPanel.container.tsx index 2acc1f70f..ae5697074 100644 --- a/src/components/ItemEditorPage/RightPanel/RightPanel.container.tsx +++ b/src/components/ItemEditorPage/RightPanel/RightPanel.container.tsx @@ -11,6 +11,9 @@ import { useGetSelectedItemIdFromCurrentUrl } from 'modules/location/hooks' import { getCollection, hasViewAndEditRights } from 'modules/collection/selectors' import { isWalletCommitteeMember } from 'modules/committee/selectors' import { getIsCampaignEnabled, getIsVrmOptOutEnabled, getIsWearableUtilityEnabled } from 'modules/features/selectors' +import { getBodyShape, getBones, getSpringBoneParams, hasSpringBoneChanges as hasSpringBoneChangesSelector } from 'modules/editor/selectors' +import { setSpringBoneParam, addSpringBoneParams, deleteSpringBoneParams, resetSpringBoneParams } from 'modules/editor/actions' +import { SpringBoneParams } from 'modules/editor/types' import { RightPanelContainerProps } from './RightPanel.types' import RightPanel from './RightPanel' @@ -45,6 +48,10 @@ const RightPanelContainer: React.FC = () => { }) const itemStatus = useMemo(() => (selectedItemId ? statusByItemId[selectedItemId] : null), [selectedItemId, statusByItemId]) + const selectedBodyShape = useSelector((state: RootState) => getBodyShape(state)) + const bones = useSelector((state: RootState) => getBones(state)) + const springBoneParams = useSelector((state: RootState) => getSpringBoneParams(state)) + const hasSpringBoneChanges = useSelector((state: RootState) => hasSpringBoneChangesSelector(state)) const onSaveItem: ActionFunction = useCallback( (item, contents) => dispatch(saveItemRequest(item, contents)), [dispatch] @@ -53,6 +60,23 @@ const RightPanelContainer: React.FC = () => { const onDeleteItem: ActionFunction = useCallback(item => dispatch(deleteItemRequest(item)), [dispatch]) const onOpenModal: ActionFunction = useCallback((name, metadata) => dispatch(openModal(name, metadata)), [dispatch]) const onDownload: ActionFunction = useCallback(itemId => dispatch(downloadItemRequest(itemId)), [dispatch]) + const onSpringBoneParamChange: ActionFunction = useCallback( + (boneName: string, field: keyof SpringBoneParams, value: SpringBoneParams[typeof field]) => + dispatch(setSpringBoneParam(boneName, field, value)), + [dispatch] + ) + const onAddSpringBoneParams: ActionFunction = useCallback( + (boneName: string) => dispatch(addSpringBoneParams(boneName)), + [dispatch] + ) + const onDeleteSpringBoneParams: ActionFunction = useCallback( + (boneName: string) => dispatch(deleteSpringBoneParams(boneName)), + [dispatch] + ) + const onResetSpringBoneParams: ActionFunction = useCallback( + () => dispatch(resetSpringBoneParams()), + [dispatch] + ) return ( = () => { campaignName={campaignName} isVrmOptOutEnabled={isVrmOptOutEnabled} isWearableUtilityEnabled={isWearableUtilityEnabled} + selectedBodyShape={selectedBodyShape} + bones={bones} + springBoneParams={springBoneParams} onSaveItem={onSaveItem} onDeleteItem={onDeleteItem} onOpenModal={onOpenModal} onDownload={onDownload} + onSpringBoneParamChange={onSpringBoneParamChange} + onAddSpringBoneParams={onAddSpringBoneParams} + onDeleteSpringBoneParams={onDeleteSpringBoneParams} + hasSpringBoneChanges={hasSpringBoneChanges} + onResetSpringBoneParams={onResetSpringBoneParams} /> ) } diff --git a/src/components/ItemEditorPage/RightPanel/RightPanel.css b/src/components/ItemEditorPage/RightPanel/RightPanel.css index d951edd46..118523a77 100644 --- a/src/components/ItemEditorPage/RightPanel/RightPanel.css +++ b/src/components/ItemEditorPage/RightPanel/RightPanel.css @@ -29,6 +29,7 @@ overflow-y: auto; max-height: calc(100vh - 135px); flex: 1; + padding-bottom: 48px; /* Space for floating Help button */ } .dcl.text-area { diff --git a/src/components/ItemEditorPage/RightPanel/RightPanel.tsx b/src/components/ItemEditorPage/RightPanel/RightPanel.tsx index 9186106da..df04911ae 100644 --- a/src/components/ItemEditorPage/RightPanel/RightPanel.tsx +++ b/src/components/ItemEditorPage/RightPanel/RightPanel.tsx @@ -68,6 +68,7 @@ import Input from './Input' import Select from './Select' import MultiSelect from './MultiSelect' import Tags from './Tags' +import SpringBonesSection from './SpringBonesSection' import { Props, State } from './RightPanel.types' import './RightPanel.css' @@ -412,8 +413,9 @@ export default class RightPanel extends React.PureComponent { } handleOnSaveItem = async () => { - const { selectedItem, itemStatus, onSaveItem } = this.props - const { name, description, utility, rarity, contents, data, isDirty } = this.state + const { selectedItem, itemStatus, onSaveItem, hasSpringBoneChanges } = this.props + const { name, description, utility, rarity, contents, data, isDirty: isItemDirty } = this.state + const isDirty = isItemDirty || hasSpringBoneChanges if (isDirty && selectedItem) { let itemData = data @@ -463,6 +465,12 @@ export default class RightPanel extends React.PureComponent { } } + handleResetButtonClick = () => { + const { onResetSpringBoneParams } = this.props + this.handleOnResetItem() + onResetSpringBoneParams() + } + handleOpenThumbnailDialog = () => { const { selectedItem, onOpenModal } = this.props @@ -746,9 +754,17 @@ export default class RightPanel extends React.PureComponent { isCampaignEnabled, isVrmOptOutEnabled, campaignName, - campaignTag + campaignTag, + selectedBodyShape, + bones, + springBoneParams, + onSpringBoneParamChange, + onAddSpringBoneParams, + onDeleteSpringBoneParams, + hasSpringBoneChanges } = this.props - const { name, description, utility, rarity, data, isDirty, hasItem } = this.state + const { name, description, utility, rarity, data, isDirty: isItemDirty, hasItem } = this.state + const isDirty = isItemDirty || hasSpringBoneChanges const rarities = Rarity.getRarities() const playModes = getEmotePlayModes() @@ -1109,9 +1125,19 @@ export default class RightPanel extends React.PureComponent { )} + {item?.type === ItemType.WEARABLE && ( + + )}
- onOpenModal: ActionFunction onDownload: ActionFunction + selectedBodyShape: BodyShape + bones: BoneNode[] + springBoneParams: Record + onSpringBoneParamChange: (boneName: string, field: keyof SpringBoneParams, value: SpringBoneParams[typeof field]) => void + onAddSpringBoneParams: (boneName: string) => void + onDeleteSpringBoneParams: (boneName: string) => void + hasSpringBoneChanges: boolean + onResetSpringBoneParams: ActionFunction } export type State = { @@ -64,9 +80,32 @@ export type MapStateProps = Pick< | 'campaignName' | 'isVrmOptOutEnabled' | 'isWearableUtilityEnabled' + | 'selectedBodyShape' + | 'bones' + | 'springBoneParams' + | 'hasSpringBoneChanges' +> +export type MapDispatchProps = Pick< + Props, + | 'onSaveItem' + | 'onDeleteItem' + | 'onOpenModal' + | 'onDownload' + | 'onSpringBoneParamChange' + | 'onAddSpringBoneParams' + | 'onDeleteSpringBoneParams' + | 'onResetSpringBoneParams' +> +export type MapDispatch = Dispatch< + | SaveItemRequestAction + | DeleteItemRequestAction + | OpenModalAction + | DownloadItemRequestAction + | SetSpringBoneParamAction + | AddSpringBoneParamsAction + | DeleteSpringBoneParamsAction + | ResetSpringBoneParamsAction > -export type MapDispatchProps = Pick -export type MapDispatch = Dispatch // New type for the functional component container export type RightPanelContainerProps = Omit diff --git a/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.css b/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.css new file mode 100644 index 000000000..167a43ed4 --- /dev/null +++ b/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.css @@ -0,0 +1,222 @@ +.spring-bones-section { + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 12px; + --base-gray: #5e5b67; +} + +/* Spring Bone Card */ + +.dcl.box.spring-bone-card { + width: 100%; + padding: 0; + border-radius: 6px; + border: 0.5px solid var(--base-gray); +} + +.dcl.box.spring-bone-card .box-header { + margin-bottom: 0; +} + +.dcl.box.spring-bone-card .spring-bone-card-header { + display: flex; + gap: 8px; + align-items: center; + padding: 4px 10px; + cursor: pointer; + height: 35px; + margin: 0; +} + +.spring-bone-card.expanded .spring-bone-card-header { + border-bottom: 0.5px solid var(--base-gray); +} + +.spring-bone-name { + flex: 1; + font-size: 11px; + line-height: 1.2; + font-weight: 700; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.spring-bone-card-header .spring-bone-chevron { + margin: 2px 5px; + scale: 0.7; + transition: 0.2s transform linear; +} + +.expanded .spring-bone-card-header .spring-bone-chevron { + transform: rotate(90deg); +} + +.spring-bone-card-header img.spring-bone-card-menu { + width: 24px; + height: 24px; + padding: 5px; + cursor: pointer; + transform: rotate(90deg); + transition: background-color 0.2s linear; + border-radius: 18px; + margin: auto; + display: block; +} + +.spring-bone-card-header img.spring-bone-card-menu:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.spring-bone-card-children { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; +} + +.spring-bones-slider-row, +.spring-bones-center-row, +.spring-bones-vec3-row, +.spring-bones-vec3-inputs { + display: flex; + align-items: center; + gap: 12px; +} + +.spring-bones-vec3-inputs { + gap: 8px; + justify-content: space-between; + width: 100%; +} + +.spring-bones-label { + font-size: 11px; + color: var(--secondary-text); + min-width: 110px; + flex-shrink: 0; +} + +.spring-bones-number { + width: 45px; + background: transparent; + border: 0.5px solid var(--base-gray); + border-radius: 6px; + color: #ffffff; + padding: 4px 8px; + font-size: 11px; +} + +.spring-bones-vec3-inputs label { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--base-gray); +} + +.spring-bones-center-value { + font-size: 11px; + font-weight: 600; + color: #ffffff; + cursor: pointer; + margin-left: auto; +} + +.spring-bones-center-value.empty { + color: var(--base-gray); +} + +.spring-bones-center-arrow.open { + transform: rotate(180deg); +} + +.spring-bones-center-clear { + transform: scale(0.7); +} + +/* Add Bone Button */ + +.spring-bone-add-button { + width: max-content; +} + +.spring-bone-add-button .Icon { + margin-left: 4px; +} + +/* Bone Hierarchy Picker */ +.bone-hierarchy-picker { + overflow: auto; + width: calc(var(--item-editor-panel-width) - 16px); + height: 430px; + padding: 8px 0; + border-radius: 8px; + background: #43404a; + scrollbar-width: thin; +} + +.bone-tree-node { + display: flex; + align-items: center; + padding: 5px 10px; + font-size: 11px; + color: #ffffff; + min-height: 30px; +} + +.bone-tree-node.disabled { + color: #a0a0a0; +} + +.bone-tree-node.children { + cursor: pointer; +} + +.bone-tree-node:not(.disabled):hover { + background: rgba(0, 0, 0, 0.15); +} + +.bone-tree-chevron.Icon { + flex-shrink: 0; + margin: 2px 5px; + scale: 0.8; + transition: transform 0.2s linear; +} + +.bone-tree-chevron.hidden { + visibility: hidden; + opacity: 0; +} + +.bone-tree-chevron.expanded { + transform: rotate(90deg); +} + +.bone-tree-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 100px; +} + +.bone-tree-checkmark { + margin-left: auto; + flex-shrink: 0; +} + +button.bone-tree-select-button.MuiButton-sizeSmall.MuiButton-textPrimary { + flex-shrink: 0; + text-transform: none; + min-width: max-content; + line-height: 1.5; + display: none; + padding: 0px 5px; +} + +.bone-tree-node:not(.disabled):hover .bone-tree-select-button { + display: block; +} diff --git a/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.tsx b/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.tsx new file mode 100644 index 000000000..450716607 --- /dev/null +++ b/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.tsx @@ -0,0 +1,470 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import classNames from 'classnames' +import { Box, Dropdown, Header } from 'decentraland-ui' +import { Button, Popover, Slider } from 'decentraland-ui2' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { BoneNode, SpringBoneParams } from 'modules/editor/types' +import Collapsable from 'components/Collapsable' +import Icon from 'components/Icon' +import CheckIcon from 'icons/check.svg' +import MenuIcon from 'icons/ellipsis.svg' +import { Props } from './SpringBonesSection.types' +import './SpringBonesSection.css' + +type SpringBoneNode = Extract + +function BoneTreeNode({ + bone, + boneMap, + depth, + expanded, + onToggle, + disableType, + selected, + onSelect +}: { + bone: BoneNode + boneMap: Map + depth: number + expanded: Set + selected?: Set + disableType?: BoneNode['type'] + onToggle: (nodeId: number) => void + onSelect: (bone: BoneNode) => void +}) { + const hasChildren = bone.children.length > 0 + const isExpanded = expanded.has(bone.nodeId) + const isSelected = !!selected?.has(bone.name) + const isDisabled = isSelected || (!!disableType && bone.type === disableType) + + return ( + <> +
onToggle(bone.nodeId) : undefined} + > + + + {bone.name} + + {isSelected && Already added} + {!isDisabled && ( + + )} +
+ {hasChildren && + isExpanded && + bone.children.map(childId => { + const child = boneMap.get(childId) + if (!child) return null + return ( + + ) + })} + + ) +} + +function BoneHierarchyPicker({ + open, + bones, + selected, + disableType, + anchorEl, + onSelect, + onClose +}: { + open: boolean + bones: BoneNode[] + selected?: Set + disableType?: BoneNode['type'] + anchorEl: HTMLElement | null + onSelect: (bone: BoneNode) => void + onClose: () => void +}) { + const [expanded, setExpanded] = useState>(() => new Set(bones.map(b => b.nodeId))) // Start with all expanded + + const { boneMap, roots } = useMemo(() => { + const boneMap = new Map() + const childIds = new Set() + for (const bone of bones) { + boneMap.set(bone.nodeId, bone) + for (const childId of bone.children) { + childIds.add(childId) + } + } + const roots = bones.filter(b => !childIds.has(b.nodeId)) + return { boneMap, roots } + }, [bones]) + + const handleToggle = useCallback((nodeId: number) => { + setExpanded(prev => { + const next = new Set(prev) + next.has(nodeId) ? next.delete(nodeId) : next.add(nodeId) + return next + }) + }, []) + + return ( + + {roots.map(root => ( + + ))} + + ) +} + +function InputNumber({ max, min, value, onChange }: { max?: number; min?: number; value: number; onChange: (value: number) => void }) { + const [localValue, setLocalValue] = useState(`${value}`) + + useEffect(() => { + setLocalValue(`${value}`) + }, [value]) + + const handleChange = (e: React.ChangeEvent) => { + const raw = e.target.value + if (raw === '' || /^-?\d*[.,]?\d*$/.test(raw)) { + setLocalValue(raw) + } + } + + const handleBlur = () => { + let v = parseFloat((localValue || '0').replace(',', '.')) + if (!isNaN(v)) { + if (max !== undefined) v = Math.min(max, v) + if (min !== undefined) v = Math.max(min, v) + onChange(v) + setLocalValue(`${v}`) + } else { + setLocalValue(`${value}`) + } + } + + return ( + + ) +} + +function SliderInput({ + label, + value, + min, + max, + step, + onChange +}: { + label: string + value: number + min: number + max: number + step: number + onChange: (value: number) => void +}) { + const handleSliderChange = (_: Event, newValue: number | number[]) => onChange(newValue as number) + + return ( +
+ {label} + + +
+ ) +} + +function Vec3Input({ + label, + value, + min, + max, + onChange +}: { + label: string + value: [number, number, number] + min?: number + max?: number + onChange: (value: [number, number, number]) => void +}) { + const handleAxis = (index: number) => (v: number) => { + if (!isNaN(v)) { + const next: [number, number, number] = [...value] + next[index] = v + onChange(next) + } + } + + return ( +
+ {label} +
+ + + +
+
+ ) +} + +function CenterDropdown({ + label, + value, + bones, + onChange +}: { + label: string + value: string | undefined + bones: BoneNode[] + onChange: (value: string | undefined) => void +}) { + const [open, setOpen] = useState(false) + const pickerAnchorRef = useRef(null) + const selectedBone = useMemo(() => bones.find(b => b.name === value), [bones, value]) + + const handleCenterChange = (bone: BoneNode) => { + onChange(bone.name) + setOpen(false) + } + + return ( +
+ {label} + setOpen(!open)} + ref={pickerAnchorRef} + > + {selectedBone ? selectedBone.name : t('item_editor.right_panel.spring_bones.center_none')} + + {selectedBone ? ( + onChange(undefined)} /> + ) : ( + setOpen(!open)} /> + )} + setOpen(false)} + /> +
+ ) +} + +function SpringBoneCard({ + nodeName, + params, + allBones, + onParamChange, + onDelete, + onCopy, + onPaste +}: { + nodeName: string + params: SpringBoneParams + allBones: BoneNode[] + onParamChange: (field: keyof SpringBoneParams, value: SpringBoneParams[typeof field]) => void + onDelete: () => void + onCopy: () => void + onPaste: (() => void) | null +}) { + const [isExpanded, setIsExpanded] = useState(true) + + return ( + setIsExpanded(prev => !prev)}> + +
{nodeName}
+ + } inline direction="left"> + + + {})} + disabled={!onPaste} + /> + + + + + } + > + {isExpanded && ( +
+ onParamChange('stiffness', v)} + /> + onParamChange('gravityPower', v)} + /> + onParamChange('gravityDir', v)} + /> + onParamChange('drag', v)} + /> + onParamChange('center', v)} + /> +
+ )} +
+ ) +} + +export default function SpringBonesSection({ + bones, + springBoneParams, + onParamChange, + onAddSpringBoneParams, + onDeleteSpringBoneParams +}: Props) { + const [copiedParams, setCopiedParams] = useState(null) + const [showBonePicker, setShowBonePicker] = useState(false) + const pickerAnchorRef = useRef(null) + const springBones: SpringBoneNode[] = useMemo(() => bones.filter(b => b.type === 'spring'), [bones]) + const selectedSpringBoneNames: Set = useMemo(() => new Set(Object.keys(springBoneParams)), [springBoneParams]) + + /** Sort spring bone params by node ID */ + const sortedSpringBoneParams: [string, SpringBoneParams][] = useMemo(() => { + const springBoneNamesToNodeId = springBones.reduce((map, bone) => { + map[bone.name] = bone.nodeId + return map + }, {} as Record) + return Object.entries(springBoneParams).sort(([boneNameA], [boneNameB]) => { + const nodeIdA = springBoneNamesToNodeId[boneNameA] ?? 0 + const nodeIdB = springBoneNamesToNodeId[boneNameB] ?? 0 + return nodeIdA - nodeIdB // Inverse order, node ids are assigned from the leaf up, we want to show root bones first. + }) + }, [springBoneParams]) + + const handleSelectBone = useCallback( + (bone: BoneNode) => { + onAddSpringBoneParams(bone.name) + setShowBonePicker(false) + }, + [onAddSpringBoneParams] + ) + + const handlePasteParams = useCallback( + (nodeName: string) => { + if (!copiedParams) return + for (const field of Object.keys(copiedParams) as (keyof SpringBoneParams)[]) { + onParamChange(nodeName, field, copiedParams[field]) + } + }, + [copiedParams, onParamChange] + ) + + if (springBones.length === 0) return null + + return ( + +
+ {sortedSpringBoneParams.map(([nodeName, params]) => ( + onParamChange(nodeName, field, value)} + onDelete={() => onDeleteSpringBoneParams(nodeName)} + onCopy={() => setCopiedParams({ ...params })} + onPaste={copiedParams ? () => handlePasteParams(nodeName) : null} + /> + ))} + {sortedSpringBoneParams.length < springBones.length && ( +
+ + setShowBonePicker(false)} + /> +
+ )} +
+
+ ) +} diff --git a/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.types.ts b/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.types.ts new file mode 100644 index 000000000..f57bd8f96 --- /dev/null +++ b/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.types.ts @@ -0,0 +1,9 @@ +import { BoneNode, SpringBoneParams } from 'modules/editor/types' + +export type Props = { + bones: BoneNode[] + springBoneParams: Record + onParamChange: (boneName: string, field: keyof SpringBoneParams, value: SpringBoneParams[typeof field]) => void + onAddSpringBoneParams: (boneName: string) => void + onDeleteSpringBoneParams: (boneName: string) => void +} diff --git a/src/components/ItemEditorPage/RightPanel/SpringBonesSection/index.ts b/src/components/ItemEditorPage/RightPanel/SpringBonesSection/index.ts new file mode 100644 index 000000000..e2c7a1a0f --- /dev/null +++ b/src/components/ItemEditorPage/RightPanel/SpringBonesSection/index.ts @@ -0,0 +1 @@ +export { default } from './SpringBonesSection' diff --git a/src/components/ItemProvider/ItemProvider.container.tsx b/src/components/ItemProvider/ItemProvider.container.tsx index fca65c7d4..7b63efd93 100644 --- a/src/components/ItemProvider/ItemProvider.container.tsx +++ b/src/components/ItemProvider/ItemProvider.container.tsx @@ -9,6 +9,9 @@ import { isLoggingIn } from 'modules/identity/selectors' import { getLoading, getItems } from 'modules/item/selectors' import { getCollections } from 'modules/collection/selectors' import { FETCH_ITEM_REQUEST, fetchItemRequest, SAVE_ITEM_REQUEST, SET_PRICE_AND_BENEFICIARY_REQUEST } from 'modules/item/actions' +import { clearSpringBones, setBones } from 'modules/editor/actions' +import { getBodyShape } from 'modules/editor/selectors' +import { BoneNode } from 'modules/editor/types' import { ContainerProps } from './ItemProvider.types' import ItemProvider from './ItemProvider' @@ -19,6 +22,7 @@ const ItemProviderContainer: React.FC = ({ id: propId, children const items = useSelector(getItems) const collections = useSelector(getCollections) + const bodyShape = useSelector(getBodyShape) const isWalletConnected = useSelector(isConnected) const isLoading = useSelector( (state: RootState) => @@ -43,16 +47,21 @@ const ItemProviderContainer: React.FC = ({ id: propId, children collectionId => dispatch(fetchCollectionRequest(collectionId)), [dispatch] ) + const onClearSpringBones = useCallback(() => dispatch(clearSpringBones()), [dispatch]) + const onSetBones = useCallback((bones: BoneNode[], itemId: string | null) => dispatch(setBones(bones, itemId)), [dispatch]) return ( {children} diff --git a/src/components/ItemProvider/ItemProvider.tsx b/src/components/ItemProvider/ItemProvider.tsx index 8e63953cc..7de6e5e6d 100644 --- a/src/components/ItemProvider/ItemProvider.tsx +++ b/src/components/ItemProvider/ItemProvider.tsx @@ -1,5 +1,7 @@ import * as React from 'react' import { getEmoteData } from 'lib/getModelData' +import { parseSpringBones } from 'lib/parseSpringBones' +import { isWearable, getRepresentationMainFile } from 'modules/item/utils' import { Props, State } from './ItemProvider.types' import { Item } from 'modules/item/types' @@ -26,11 +28,12 @@ export default class ItemProvider extends React.PureComponent { // Load animation data if item is available if (isConnected && id && item) { void this.loadAnimationData(item) + void this.loadSpringBonesData(item) } } componentDidUpdate(prevProps: Props) { - const { id, item, collection, onFetchItem, onFetchCollection, isConnected } = this.props + const { id, item, collection, bodyShape, onFetchItem, onFetchCollection, isConnected } = this.props const { loadedItemId } = this.state if (isConnected && id && !item && loadedItemId !== id) { @@ -44,6 +47,11 @@ export default class ItemProvider extends React.PureComponent { if (isConnected && id && item && item.id !== prevProps.item?.id) { void this.loadAnimationData(item) } + + // Re-parse spring bones when item or body shape changes (different GLB) + if (isConnected && item && (item.id !== prevProps.item?.id || bodyShape !== prevProps.bodyShape)) { + void this.loadSpringBonesData(item) + } } private findGlbFile(contents: Record): { path: string; hash: string } | null { @@ -120,6 +128,30 @@ export default class ItemProvider extends React.PureComponent { } } + private async loadSpringBonesData(item: Item) { + const { bodyShape, onSetBones, onClearSpringBones } = this.props + onClearSpringBones() // Clear previous spring bone data while loading new one + + if (!isWearable(item)) { + return + } + + const mainFile = getRepresentationMainFile(item, bodyShape) + const hash = mainFile ? item.contents[mainFile] : null + if (!mainFile || !hash) { + return + } + + try { + const blob = await this.fetchGlbBlob(hash) + const buffer = await blob.arrayBuffer() + const { bones } = parseSpringBones(buffer) + onSetBones(bones, item.id) + } catch (error) { + console.warn("Failed to parse model's spring bones:", error) + } + } + render() { const { item, collection, isLoading, children } = this.props const { animationData } = this.state diff --git a/src/components/ItemProvider/ItemProvider.types.ts b/src/components/ItemProvider/ItemProvider.types.ts index 29478d7b9..f40cdf6f4 100644 --- a/src/components/ItemProvider/ItemProvider.types.ts +++ b/src/components/ItemProvider/ItemProvider.types.ts @@ -1,6 +1,8 @@ import { AnimationClip, Object3D } from 'three' +import { BodyShape } from '@dcl/schemas' import { Item } from 'modules/item/types' import { Collection } from 'modules/collection/types' +import { BoneNode } from 'modules/editor/types' export type AnimationData = { animations: AnimationClip[] @@ -12,11 +14,14 @@ export type AnimationData = { export type Props = { item: Item | null collection: Collection | null + bodyShape: BodyShape isLoading: boolean isConnected: boolean id: string | null onFetchItem: (id: string) => void onFetchCollection: (id: string) => void + onClearSpringBones: () => void + onSetBones: (bones: BoneNode[], selectedItemId: string | null) => void children: (item: Item | null, collection: Collection | null, isLoading: boolean, animationData: AnimationData) => React.ReactNode } export type State = { diff --git a/src/components/Modals/MintItemsModal/MintableItem/MintableItem.tsx b/src/components/Modals/MintItemsModal/MintableItem/MintableItem.tsx index a177891b9..0b9089cf8 100644 --- a/src/components/Modals/MintItemsModal/MintableItem/MintableItem.tsx +++ b/src/components/Modals/MintItemsModal/MintableItem/MintableItem.tsx @@ -7,6 +7,7 @@ import { isValid } from 'lib/address' import ItemImage from 'components/ItemImage' import Icon from 'components/Icon' import { getMaxSupply } from 'modules/item/utils' +import { Mint } from 'modules/collection/types' import { Props } from './MintableItem.types' import ItemStatus from 'components/ItemStatus' @@ -20,10 +21,12 @@ export default class MintableItem extends React.PureComponent { getChangeAddressHandler(index: number) { const { item, mints, onChange } = this.props - return (_event: React.ChangeEvent, data: InputOnChangeData) => { - const mint = { + return (event: React.ChangeEvent, data: InputOnChangeData) => { + if (!data.error) event.target.blur() // Remove the focus on the input after entering a valid address + const mint: Partial = { ...mints[index], - address: data.value ? data.value : undefined + address: data.value ? data.value : undefined, + amount: !data.error && !mints[index].amount ? 1 : mints[index].amount } const newMints = [...mints.slice(0, index), mint, ...mints.slice(index + 1)] onChange(item, newMints) @@ -33,7 +36,7 @@ export default class MintableItem extends React.PureComponent { getChangeAmountHandler(index: number) { const { item, mints, onChange } = this.props return (_event: React.ChangeEvent, data: InputOnChangeData) => { - const mint = { + const mint: Partial = { ...mints[index], amount: data.value ? Number(data.value) : undefined } diff --git a/src/lib/glbUtils.spec.ts b/src/lib/glbUtils.spec.ts new file mode 100644 index 000000000..937f286e3 --- /dev/null +++ b/src/lib/glbUtils.spec.ts @@ -0,0 +1,156 @@ +import { + extractGlbChunks, + buildGlb, + GLB_MAGIC, + JSON_CHUNK_TYPE, + GLB_HEADER_SIZE, + CHUNK_HEADER_SIZE, + JSON_CHUNK_DATA_OFFSET +} from './glbUtils' + +function buildGlbWithBin(jsonObj: Record, binData: Uint8Array): ArrayBuffer { + const trailing = new ArrayBuffer(CHUNK_HEADER_SIZE + binData.length) + const trailingView = new DataView(trailing) + const trailingBytes = new Uint8Array(trailing) + trailingView.setUint32(0, binData.length, true) + trailingView.setUint32(4, 0x004e4942, true) // 'BIN\0' + trailingBytes.set(binData, CHUNK_HEADER_SIZE) + + return buildGlb(jsonObj, 2, new Uint8Array(trailing)) +} + +describe('when extracting GLB chunks', () => { + describe('and the buffer is smaller than the minimum GLB header size', () => { + it('should return null', () => { + const buffer = new ArrayBuffer(10) + expect(extractGlbChunks(buffer)).toBeNull() + }) + }) + + describe('and the buffer has a wrong magic number', () => { + it('should return null', () => { + const buffer = new ArrayBuffer(JSON_CHUNK_DATA_OFFSET + 4) + const view = new DataView(buffer) + view.setUint32(0, 0x00000000, true) // wrong magic + expect(extractGlbChunks(buffer)).toBeNull() + }) + }) + + describe('and the buffer has a wrong JSON chunk type', () => { + it('should return null', () => { + const buffer = new ArrayBuffer(JSON_CHUNK_DATA_OFFSET + 4) + const view = new DataView(buffer) + view.setUint32(0, GLB_MAGIC, true) + view.setUint32(4, 2, true) + view.setUint32(GLB_HEADER_SIZE, 4, true) + view.setUint32(GLB_HEADER_SIZE + 4, 0x00000000, true) // wrong chunk type + expect(extractGlbChunks(buffer)).toBeNull() + }) + }) + + describe('and the buffer contains invalid JSON in the JSON chunk', () => { + it('should return null', () => { + const buffer = new ArrayBuffer(JSON_CHUNK_DATA_OFFSET + 8) + const view = new DataView(buffer) + const bytes = new Uint8Array(buffer) + view.setUint32(0, GLB_MAGIC, true) + view.setUint32(4, 2, true) + view.setUint32(GLB_HEADER_SIZE, 8, true) + view.setUint32(GLB_HEADER_SIZE + 4, JSON_CHUNK_TYPE, true) + bytes.set(new TextEncoder().encode('not json'), JSON_CHUNK_DATA_OFFSET) + expect(extractGlbChunks(buffer)).toBeNull() + }) + }) + + describe('and the buffer is a valid GLB', () => { + it('should parse and return json, jsonChunkLength, version, and isGlb as true', () => { + const jsonObj = { asset: { version: '2.0' }, nodes: [] } + const buffer = buildGlb(jsonObj) + const result = extractGlbChunks(buffer) + + expect(result).not.toBeNull() + expect(result!.isGlb).toBe(true) + expect(result!.version).toBe(2) + expect(result!.json).toEqual(jsonObj) + expect(result!.jsonChunkLength).toBeGreaterThan(0) + }) + }) + + describe('and the buffer is a valid GLB with a BIN chunk', () => { + it('should parse the JSON chunk and return correct jsonChunkLength', () => { + const jsonObj = { asset: { version: '2.0' } } + const binData = new Uint8Array([1, 2, 3, 4]) + const buffer = buildGlbWithBin(jsonObj, binData) + const result = extractGlbChunks(buffer) + + expect(result).not.toBeNull() + expect(result!.isGlb).toBe(true) + expect(result!.json).toEqual(jsonObj) + }) + }) + + describe('and the buffer is a plain .gltf JSON file', () => { + it('should parse and return json with isGlb as false', () => { + const jsonObj = { asset: { version: '2.0' }, nodes: [] } + const encoder = new TextEncoder() + const buffer = encoder.encode(JSON.stringify(jsonObj)).buffer + + const result = extractGlbChunks(buffer) + expect(result).not.toBeNull() + expect(result!.isGlb).toBe(false) + expect(result!.json).toEqual(jsonObj) + expect(result!.jsonChunkLength).toBe(0) + expect(result!.version).toBe(0) + }) + }) + + describe('and the buffer is a plain text file with invalid JSON', () => { + it('should return null', () => { + const encoder = new TextEncoder() + const buffer = encoder.encode('this is not json at all').buffer + expect(extractGlbChunks(buffer)).toBeNull() + }) + }) +}) + +describe('when building a GLB', () => { + it('should produce a valid GLB that can be parsed by extractGlbChunks', () => { + const jsonObj = { asset: { version: '2.0' }, nodes: [] } + const buffer = buildGlb(jsonObj) + const result = extractGlbChunks(buffer) + + expect(result).not.toBeNull() + expect(result!.isGlb).toBe(true) + expect(result!.json).toEqual(jsonObj) + }) + + it('should use version 2 by default', () => { + const buffer = buildGlb({ asset: {} }) + const view = new DataView(buffer) + expect(view.getUint32(4, true)).toBe(2) + }) + + it('should use the provided version', () => { + const buffer = buildGlb({ asset: {} }, 3) + const view = new DataView(buffer) + expect(view.getUint32(4, true)).toBe(3) + }) + + it('should pad the JSON chunk to 4-byte alignment', () => { + const buffer = buildGlb({ a: 1 }) + const view = new DataView(buffer) + const jsonChunkLength = view.getUint32(GLB_HEADER_SIZE, true) + expect(jsonChunkLength % 4).toBe(0) + }) + + it('should append trailing data after the JSON chunk', () => { + const trailing = new Uint8Array([10, 20, 30, 40]) + const buffer = buildGlb({ a: 1 }, 2, trailing) + const view = new DataView(buffer) + const jsonChunkLength = view.getUint32(GLB_HEADER_SIZE, true) + const trailingOffset = JSON_CHUNK_DATA_OFFSET + jsonChunkLength + + const resultTrailing = new Uint8Array(buffer, trailingOffset) + expect(Array.from(resultTrailing)).toEqual(Array.from(trailing)) + }) +}) diff --git a/src/lib/glbUtils.ts b/src/lib/glbUtils.ts new file mode 100644 index 000000000..dfc51807c --- /dev/null +++ b/src/lib/glbUtils.ts @@ -0,0 +1,91 @@ +export const GLB_MAGIC = 0x46546c67 // 'glTF' +export const JSON_CHUNK_TYPE = 0x4e4f534a // 'JSON' +export const GLB_HEADER_SIZE = 12 +export const CHUNK_HEADER_SIZE = 8 +export const JSON_CHUNK_DATA_OFFSET = GLB_HEADER_SIZE + CHUNK_HEADER_SIZE // 20 + +export type GlbChunks = { + json: Record + jsonChunkLength: number + version: number + isGlb: boolean +} + +function parseGlb(buffer: ArrayBuffer): GlbChunks | null { + if (buffer.byteLength < JSON_CHUNK_DATA_OFFSET) return null + + const view = new DataView(buffer) + + const magic = view.getUint32(0, true) + if (magic !== GLB_MAGIC) return null + + const version = view.getUint32(4, true) + const jsonChunkLength = view.getUint32(GLB_HEADER_SIZE, true) + const jsonChunkType = view.getUint32(GLB_HEADER_SIZE + 4, true) + if (jsonChunkType !== JSON_CHUNK_TYPE) return null + + if (JSON_CHUNK_DATA_OFFSET + jsonChunkLength > buffer.byteLength) return null + + const jsonBytes = new Uint8Array(buffer, JSON_CHUNK_DATA_OFFSET, jsonChunkLength) + try { + const json = JSON.parse(new TextDecoder().decode(jsonBytes)) as Record + return { json, jsonChunkLength, version, isGlb: true } + } catch { + return null + } +} + +function parseGltf(buffer: ArrayBuffer): GlbChunks | null { + try { + const text = new TextDecoder().decode(buffer) + const json = JSON.parse(text) as Record + return { json, jsonChunkLength: 0, version: 0, isGlb: false } + } catch { + return null + } +} + +export function extractGlbChunks(buffer: ArrayBuffer): GlbChunks | null { + return parseGlb(buffer) ?? parseGltf(buffer) +} + +/** + * Builds a GLB (Binary glTF) buffer from a JSON object and optional trailing data (e.g. BIN chunk). + * Handles 4-byte padding of the JSON chunk, header writing, and chunk assembly. + */ +export function buildGlb(jsonObj: Record, version: number = 2, trailingData?: Uint8Array): ArrayBuffer { + const encoder = new TextEncoder() + const jsonBytes = encoder.encode(JSON.stringify(jsonObj)) + + // Pad JSON to 4-byte alignment with 0x20 (space) + const paddedLength = Math.ceil(jsonBytes.length / 4) * 4 + const paddedJsonBytes = new Uint8Array(paddedLength) + paddedJsonBytes.fill(0x20) + paddedJsonBytes.set(jsonBytes) + + const trailingSize = trailingData ? trailingData.length : 0 + const totalLength = GLB_HEADER_SIZE + CHUNK_HEADER_SIZE + paddedLength + trailingSize + + const output = new ArrayBuffer(totalLength) + const view = new DataView(output) + const bytes = new Uint8Array(output) + + // GLB header (12 bytes) + view.setUint32(0, GLB_MAGIC, true) + view.setUint32(4, version, true) + view.setUint32(8, totalLength, true) + + // JSON chunk header (8 bytes) + view.setUint32(GLB_HEADER_SIZE, paddedLength, true) + view.setUint32(GLB_HEADER_SIZE + 4, JSON_CHUNK_TYPE, true) + + // JSON chunk data + bytes.set(paddedJsonBytes, JSON_CHUNK_DATA_OFFSET) + + // Trailing data (e.g. BIN chunk, copied verbatim) + if (trailingData) { + bytes.set(trailingData, JSON_CHUNK_DATA_OFFSET + paddedLength) + } + + return output +} diff --git a/src/lib/parseSpringBones.spec.ts b/src/lib/parseSpringBones.spec.ts new file mode 100644 index 000000000..ce441ec52 --- /dev/null +++ b/src/lib/parseSpringBones.spec.ts @@ -0,0 +1,298 @@ +import { buildGlb } from 'lib/glbUtils' +import { parseSpringBones, DEFAULT_SPRING_BONE_PARAMS, DCL_SPRING_BONE_EXTENSION } from './parseSpringBones' + +function buildGltfBuffer(gltfJson: Record): ArrayBuffer { + return new TextEncoder().encode(JSON.stringify(gltfJson)).buffer +} + +describe('when parsing spring bones', () => { + describe('and the buffer is invalid', () => { + it('should return an empty bones array', () => { + const buffer = new ArrayBuffer(4) + expect(parseSpringBones(buffer)).toEqual({ bones: [] }) + }) + }) + + describe('and the gltf has no nodes', () => { + it('should return an empty bones array', () => { + const buffer = buildGltfBuffer({ asset: { version: '2.0' } }) + expect(parseSpringBones(buffer)).toEqual({ bones: [] }) + }) + }) + + describe('and the gltf has nodes', () => { + describe('and no nodes are spring bones', () => { + it('should classify all nodes as avatar type', () => { + const buffer = buildGltfBuffer({ + nodes: [{ name: 'Hips', children: [1] }, { name: 'Spine' }] + }) + const result = parseSpringBones(buffer) + + expect(result.bones).toHaveLength(2) + expect(result.bones[0]).toEqual({ name: 'Hips', nodeId: 0, type: 'avatar', children: [1] }) + expect(result.bones[1]).toEqual({ name: 'Spine', nodeId: 1, type: 'avatar', children: [] }) + }) + }) + + describe('and nodes contain spring bone names', () => { + it('should classify nodes with "springbone" in the name as spring type', () => { + const buffer = buildGltfBuffer({ + nodes: [{ name: 'Hips' }, { name: 'springbone_hair', children: [2] }, { name: 'Hair_end' }] + }) + const result = parseSpringBones(buffer) + + expect(result.bones).toHaveLength(3) + expect(result.bones[0].type).toBe('avatar') + expect(result.bones[1]).toEqual({ name: 'springbone_hair', nodeId: 1, type: 'spring', children: [2] }) + expect(result.bones[2].type).toBe('avatar') + }) + + it('should be case-insensitive when detecting spring bone prefix', () => { + const buffer = buildGltfBuffer({ + nodes: [{ name: 'SpringBone_Hair' }, { name: 'SPRINGBONE_tail' }] + }) + const result = parseSpringBones(buffer) + + expect(result.bones[0].type).toBe('spring') + expect(result.bones[1].type).toBe('spring') + }) + }) + + describe('and spring bone nodes have DCL_spring_bone_joint extension', () => { + it('should parse spring bone params from the extension', () => { + const buffer = buildGltfBuffer({ + extensionsUsed: [DCL_SPRING_BONE_EXTENSION], + nodes: [ + { + name: 'springbone_hair', + extensions: { + [DCL_SPRING_BONE_EXTENSION]: { + version: 1, + stiffness: 0.5, + gravityPower: 1.2, + gravityDir: [0, -1, 0], + drag: 0.3, + isRoot: true + } + } + } + ] + }) + const result = parseSpringBones(buffer) + + expect(result.bones[0].type).toBe('spring') + const bone = result.bones[0] as { type: 'spring'; params?: unknown } + expect(bone.params).toEqual({ + stiffness: 0.5, + gravityPower: 1.2, + gravityDir: [0, -1, 0], + drag: 0.3, + center: undefined + }) + }) + + it('should use default values when extension fields are missing', () => { + const buffer = buildGltfBuffer({ + extensionsUsed: [DCL_SPRING_BONE_EXTENSION], + nodes: [ + { + name: 'springbone_hair', + extensions: { + [DCL_SPRING_BONE_EXTENSION]: { version: 1, stiffness: 2 } + } + } + ] + }) + const result = parseSpringBones(buffer) + const bone = result.bones[0] as { type: 'spring'; params?: Record } + + expect(bone.params!.stiffness).toBe(2) + expect(bone.params!.gravityPower).toBe(DEFAULT_SPRING_BONE_PARAMS.gravityPower) + expect(bone.params!.gravityDir).toEqual(DEFAULT_SPRING_BONE_PARAMS.gravityDir) + expect(bone.params!.drag).toBe(DEFAULT_SPRING_BONE_PARAMS.drag) + expect(bone.params!.center).toBe(DEFAULT_SPRING_BONE_PARAMS.center) + }) + + it('should parse center as a string node name', () => { + const buffer = buildGltfBuffer({ + extensionsUsed: [DCL_SPRING_BONE_EXTENSION], + nodes: [ + { name: 'Hips' }, + { + name: 'springbone_hair', + extensions: { + [DCL_SPRING_BONE_EXTENSION]: { version: 1, stiffness: 1, center: 'Hips' } + } + } + ] + }) + const result = parseSpringBones(buffer) + const bone = result.bones[1] as { type: 'spring'; params?: Record } + + expect(bone.params!.center).toBe('Hips') + }) + + it('should keep center when it points to an avatar bone', () => { + const buffer = buildGltfBuffer({ + extensionsUsed: [DCL_SPRING_BONE_EXTENSION], + nodes: [ + { name: 'Avatar_Hips' }, + { + name: 'springbone_hair', + extensions: { + [DCL_SPRING_BONE_EXTENSION]: { version: 1, stiffness: 1, center: 'Avatar_Hips' } + } + } + ] + }) + const result = parseSpringBones(buffer) + const bone = result.bones[1] as { type: 'spring'; params?: Record } + + expect(bone.params!.center).toBe('Avatar_Hips') + }) + + it('should drop center to undefined when it points to a spring bone', () => { + const buffer = buildGltfBuffer({ + extensionsUsed: [DCL_SPRING_BONE_EXTENSION], + nodes: [ + { + name: 'springbone_a', + extensions: { + [DCL_SPRING_BONE_EXTENSION]: { version: 1, stiffness: 1, center: 'springbone_b' } + } + }, + { + name: 'springbone_b', + extensions: { + [DCL_SPRING_BONE_EXTENSION]: { version: 1, stiffness: 1 } + } + } + ] + }) + const result = parseSpringBones(buffer) + const bone = result.bones[0] as { type: 'spring'; params?: Record } + + expect(bone.params!.center).toBeUndefined() + }) + + it('should format numbers to 3 decimal places', () => { + const buffer = buildGltfBuffer({ + extensionsUsed: [DCL_SPRING_BONE_EXTENSION], + nodes: [ + { + name: 'springbone_hair', + extensions: { + [DCL_SPRING_BONE_EXTENSION]: { version: 1, stiffness: 1.23456789, gravityPower: 0.1 } + } + } + ] + }) + const result = parseSpringBones(buffer) + const bone = result.bones[0] as { type: 'spring'; params?: Record } + + expect(bone.params!.stiffness).toBe(1.235) + expect(bone.params!.gravityPower).toBe(0.1) + }) + + it('should parse gravityDir as a 3-element number array', () => { + const buffer = buildGltfBuffer({ + extensionsUsed: [DCL_SPRING_BONE_EXTENSION], + nodes: [ + { + name: 'springbone_hair', + extensions: { + [DCL_SPRING_BONE_EXTENSION]: { version: 1, stiffness: 1, gravityDir: [1.23456, -0.5, 0] } + } + } + ] + }) + const result = parseSpringBones(buffer) + const bone = result.bones[0] as { type: 'spring'; params?: Record } + + expect(bone.params!.gravityDir).toEqual([1.235, -0.5, 0]) + }) + + it('should fall back to default gravityDir when array is malformed', () => { + const buffer = buildGltfBuffer({ + extensionsUsed: [DCL_SPRING_BONE_EXTENSION], + nodes: [ + { + name: 'springbone_hair', + extensions: { + [DCL_SPRING_BONE_EXTENSION]: { version: 1, stiffness: 1, gravityDir: [1, 2] } + } + } + ] + }) + const result = parseSpringBones(buffer) + const bone = result.bones[0] as { type: 'spring'; params?: Record } + + expect(bone.params!.gravityDir).toEqual(DEFAULT_SPRING_BONE_PARAMS.gravityDir) + }) + + it('should fall back to default gravityDir when elements are not numbers', () => { + const buffer = buildGltfBuffer({ + extensionsUsed: [DCL_SPRING_BONE_EXTENSION], + nodes: [ + { + name: 'springbone_hair', + extensions: { + [DCL_SPRING_BONE_EXTENSION]: { version: 1, stiffness: 1, gravityDir: ['a', 'b', 'c'] } + } + } + ] + }) + const result = parseSpringBones(buffer) + const bone = result.bones[0] as { type: 'spring'; params?: Record } + + expect(bone.params!.gravityDir).toEqual(DEFAULT_SPRING_BONE_PARAMS.gravityDir) + }) + }) + + describe('and spring bone nodes have no extension', () => { + it('should not set params on the bone', () => { + const buffer = buildGltfBuffer({ + nodes: [{ name: 'springbone_hair' }] + }) + const result = parseSpringBones(buffer) + const bone = result.bones[0] as { type: 'spring'; params?: unknown } + + expect(bone.params).toBeUndefined() + }) + }) + + describe('and nodes have no name', () => { + it('should assign a default name based on node index', () => { + const buffer = buildGltfBuffer({ + nodes: [{ children: [1] }, {}] + }) + const result = parseSpringBones(buffer) + + expect(result.bones[0].name).toBe('node_0') + expect(result.bones[1].name).toBe('node_1') + }) + }) + + describe('and the buffer is a valid GLB', () => { + it('should parse bones from a GLB buffer', () => { + const buffer = buildGlb({ + extensionsUsed: [DCL_SPRING_BONE_EXTENSION], + nodes: [ + { name: 'Hips' }, + { + name: 'springbone_hair', + extensions: { + [DCL_SPRING_BONE_EXTENSION]: { version: 1, stiffness: 0.8 } + } + } + ] + }) + const result = parseSpringBones(buffer) + + expect(result.bones).toHaveLength(2) + expect(result.bones[0].type).toBe('avatar') + expect(result.bones[1].type).toBe('spring') + }) + }) + }) +}) diff --git a/src/lib/parseSpringBones.ts b/src/lib/parseSpringBones.ts new file mode 100644 index 000000000..dc1d40af6 --- /dev/null +++ b/src/lib/parseSpringBones.ts @@ -0,0 +1,106 @@ +import { BoneNode, SpringBoneParams } from 'modules/editor/types' +import { extractGlbChunks } from 'lib/glbUtils' + +export const SPRING_BONE_PREFIX = 'springbone' +export const DCL_SPRING_BONE_EXTENSION = 'DCL_spring_bone_joint' + +export const DEFAULT_SPRING_BONE_PARAMS: SpringBoneParams = { + stiffness: 2, + gravityPower: 0, + gravityDir: [0, -1, 0], + drag: 0.5, + center: undefined +} + +export type SpringBonesParseResult = { + bones: BoneNode[] +} + +type GltfExtension = { + version?: number + stiffness?: number + gravityPower?: number + gravityDir?: [number, number, number] + drag?: number + isRoot?: boolean + center?: string +} + +type GltfNode = { + name?: string + extensions?: Record + children?: number[] +} + +function formatNumber(value: number | string): number { + return Number(Number(value).toFixed(3)) +} + +function parseParams(ext: GltfExtension): SpringBoneParams { + const stiffness = typeof ext.stiffness === 'number' ? formatNumber(ext.stiffness) : DEFAULT_SPRING_BONE_PARAMS.stiffness + const gravityPower = typeof ext.gravityPower === 'number' ? formatNumber(ext.gravityPower) : DEFAULT_SPRING_BONE_PARAMS.gravityPower + const drag = typeof ext.drag === 'number' ? formatNumber(ext.drag) : DEFAULT_SPRING_BONE_PARAMS.drag + const center = typeof ext.center === 'string' ? ext.center : DEFAULT_SPRING_BONE_PARAMS.center + + let gravityDir: [number, number, number] = DEFAULT_SPRING_BONE_PARAMS.gravityDir + if (Array.isArray(ext.gravityDir) && ext.gravityDir.length === 3) { + const [x, y, z] = ext.gravityDir + if (typeof x === 'number' && typeof y === 'number' && typeof z === 'number') { + gravityDir = [formatNumber(x), formatNumber(y), formatNumber(z)] + } + } + + return { stiffness, gravityPower, gravityDir, drag, center } +} + +const isSpringBoneNode = (node: GltfNode): boolean => { + return !!node.name && node.name.toLowerCase().includes(SPRING_BONE_PREFIX) +} + +function getSpringBoneExtension(node: GltfNode): GltfExtension | null { + const extension = node.extensions?.[DCL_SPRING_BONE_EXTENSION] + return extension && typeof extension === 'object' ? (extension as GltfExtension) : null +} + +export function parseSpringBones(buffer: ArrayBuffer): SpringBonesParseResult { + const chunks = extractGlbChunks(buffer) + if (!chunks) { + return { bones: [] } + } + const gltf = chunks.json as { nodes?: GltfNode[]; extensionsUsed?: string[] } + if (!gltf.nodes) { + return { bones: [] } + } + + const nodes = gltf.nodes + + // Single pass: build unified bones array + const bones: BoneNode[] = [] + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + const name = node.name ?? `node_${i}` + const children = node.children ?? [] + + if (isSpringBoneNode(node)) { + const bone: BoneNode = { name, nodeId: i, type: 'spring', children } + + const extension = getSpringBoneExtension(node) + if (extension) { + const params = parseParams(extension) + + // Validate center: must not point to a spring bone node + if (typeof params.center === 'string' && params.center.toLowerCase().includes(SPRING_BONE_PREFIX)) { + params.center = undefined + } + + bone.params = params + } + + bones.push(bone) + } else { + bones.push({ name, nodeId: i, type: 'avatar', children }) + } + } + + return { bones } +} diff --git a/src/lib/patchGltfSpringBones.spec.ts b/src/lib/patchGltfSpringBones.spec.ts new file mode 100644 index 000000000..2fa3ddc6e --- /dev/null +++ b/src/lib/patchGltfSpringBones.spec.ts @@ -0,0 +1,246 @@ +import { BoneNode, SpringBoneParams } from 'modules/editor/types' +import { extractGlbChunks, buildGlb, GLB_MAGIC, GLB_HEADER_SIZE, CHUNK_HEADER_SIZE, JSON_CHUNK_DATA_OFFSET } from 'lib/glbUtils' +import { patchGltfSpringBones } from './patchGltfSpringBones' +import { DCL_SPRING_BONE_EXTENSION } from './parseSpringBones' + +function buildGltfBuffer(gltfJson: Record): ArrayBuffer { + return new TextEncoder().encode(JSON.stringify(gltfJson)).buffer +} + +function buildGlbWithBin(jsonObj: Record, binData: Uint8Array): ArrayBuffer { + const trailing = new ArrayBuffer(CHUNK_HEADER_SIZE + binData.length) + const trailingView = new DataView(trailing) + const trailingBytes = new Uint8Array(trailing) + trailingView.setUint32(0, binData.length, true) + trailingView.setUint32(4, 0x004e4942, true) // 'BIN\0' + trailingBytes.set(binData, CHUNK_HEADER_SIZE) + + return buildGlb(jsonObj, 2, new Uint8Array(trailing)) +} + +const springBone: BoneNode = { name: 'springbone_hair', nodeId: 1, type: 'spring', children: [] } +const avatarBone: BoneNode = { name: 'Hips', nodeId: 0, type: 'avatar', children: [1] } + +const defaultParams: SpringBoneParams = { + stiffness: 0.5, + gravityPower: 1, + gravityDir: [0, -1, 0], + drag: 0.3, + center: undefined +} + +describe('when patching spring bones in a GLB', () => { + describe('and the buffer is not a valid GLB or glTF', () => { + it('should return the original buffer', () => { + const buffer = new ArrayBuffer(4) + const result = patchGltfSpringBones(buffer, [springBone], { springbone_hair: defaultParams }) + + expect(result).toBe(buffer) + }) + }) + + describe('and the glTF has no nodes', () => { + it('should return the original buffer', () => { + const buffer = buildGltfBuffer({ asset: { version: '2.0' } }) + const result = patchGltfSpringBones(buffer, [springBone], { springbone_hair: defaultParams }) + + expect(result).toBe(buffer) + }) + }) + + describe('and the buffer is a valid glTF with spring bone nodes', () => { + it('should write params to DCL_spring_bone_joint extension with version and isRoot', () => { + const gltfJson = { + nodes: [{ name: 'Hips' }, { name: 'springbone_hair' }] + } + const buffer = buildGltfBuffer(gltfJson) + const result = patchGltfSpringBones(buffer, [avatarBone, springBone], { springbone_hair: defaultParams }) + + const parsed = JSON.parse(new TextDecoder().decode(result)) + const ext = parsed.nodes[1].extensions[DCL_SPRING_BONE_EXTENSION] + expect(ext.version).toBe(1) + expect(ext.isRoot).toBe(true) + expect(ext.stiffness).toBe(0.5) + expect(ext.gravityPower).toBe(1) + expect(ext.gravityDir).toEqual([0, -1, 0]) + expect(ext.drag).toBe(0.3) + }) + + it('should add DCL_spring_bone_joint to extensionsUsed', () => { + const gltfJson = { + nodes: [{ name: 'Hips' }, { name: 'springbone_hair' }] + } + const buffer = buildGltfBuffer(gltfJson) + const result = patchGltfSpringBones(buffer, [avatarBone, springBone], { springbone_hair: defaultParams }) + + const parsed = JSON.parse(new TextDecoder().decode(result)) + expect(parsed.extensionsUsed).toContain(DCL_SPRING_BONE_EXTENSION) + }) + + it('should not duplicate DCL_spring_bone_joint in extensionsUsed if already present', () => { + const gltfJson = { + extensionsUsed: [DCL_SPRING_BONE_EXTENSION], + nodes: [{ name: 'Hips' }, { name: 'springbone_hair' }] + } + const buffer = buildGltfBuffer(gltfJson) + const result = patchGltfSpringBones(buffer, [avatarBone, springBone], { springbone_hair: defaultParams }) + + const parsed = JSON.parse(new TextDecoder().decode(result)) + const count = parsed.extensionsUsed.filter((e: string) => e === DCL_SPRING_BONE_EXTENSION).length + expect(count).toBe(1) + }) + + it('should set center in extension when params.center is defined', () => { + const gltfJson = { + nodes: [{ name: 'Hips' }, { name: 'springbone_hair' }] + } + const paramsWithCenter: SpringBoneParams = { ...defaultParams, center: 'Hips' } + const buffer = buildGltfBuffer(gltfJson) + const result = patchGltfSpringBones(buffer, [avatarBone, springBone], { springbone_hair: paramsWithCenter }) + + const parsed = JSON.parse(new TextDecoder().decode(result)) + const ext = parsed.nodes[1].extensions[DCL_SPRING_BONE_EXTENSION] + expect(ext.center).toBe('Hips') + }) + + it('should omit center from extension when params.center is undefined', () => { + const gltfJson = { + nodes: [{ name: 'Hips' }, { name: 'springbone_hair' }] + } + const buffer = buildGltfBuffer(gltfJson) + const result = patchGltfSpringBones(buffer, [avatarBone, springBone], { springbone_hair: defaultParams }) + + const parsed = JSON.parse(new TextDecoder().decode(result)) + const ext = parsed.nodes[1].extensions[DCL_SPRING_BONE_EXTENSION] + expect(ext.center).toBeUndefined() + }) + + it('should remove extension when node is not present in the params map', () => { + const gltfJson = { + extensionsUsed: [DCL_SPRING_BONE_EXTENSION], + nodes: [ + { name: 'Hips' }, + { + name: 'springbone_hair', + extensions: { + [DCL_SPRING_BONE_EXTENSION]: { version: 1, stiffness: 99 } + } + } + ] + } + const buffer = buildGltfBuffer(gltfJson) + const result = patchGltfSpringBones(buffer, [avatarBone, springBone], {}) + + const parsed = JSON.parse(new TextDecoder().decode(result)) + expect(parsed.nodes[1].extensions[DCL_SPRING_BONE_EXTENSION]).toBeUndefined() + }) + + it('should not mutate the avatar bone nodes', () => { + const gltfJson = { + nodes: [{ name: 'Hips', extras: { custom: 'value' } }, { name: 'springbone_hair' }] + } + const buffer = buildGltfBuffer(gltfJson) + const result = patchGltfSpringBones(buffer, [avatarBone, springBone], { springbone_hair: defaultParams }) + + const parsed = JSON.parse(new TextDecoder().decode(result)) + expect(parsed.nodes[0].extras).toEqual({ custom: 'value' }) + }) + }) + + describe('and the buffer is a valid GLB', () => { + it('should write correct GLB header with magic, version, and totalLength', () => { + const gltfJson = { + nodes: [{ name: 'springbone_hair' }] + } + const glbBone: BoneNode = { name: 'springbone_hair', nodeId: 0, type: 'spring', children: [] } + const buffer = buildGlb(gltfJson) + const result = patchGltfSpringBones(buffer, [glbBone], { springbone_hair: defaultParams }) + + const view = new DataView(result) + expect(view.getUint32(0, true)).toBe(GLB_MAGIC) + expect(view.getUint32(4, true)).toBe(2) + expect(view.getUint32(8, true)).toBe(result.byteLength) + }) + + it('should pad the JSON chunk to 4-byte alignment', () => { + const gltfJson = { + nodes: [{ name: 'springbone_hair' }] + } + const glbBone: BoneNode = { name: 'springbone_hair', nodeId: 0, type: 'spring', children: [] } + const buffer = buildGlb(gltfJson) + const result = patchGltfSpringBones(buffer, [glbBone], { springbone_hair: defaultParams }) + + const view = new DataView(result) + const jsonChunkLength = view.getUint32(GLB_HEADER_SIZE, true) + expect(jsonChunkLength % 4).toBe(0) + }) + + it('should preserve the BIN chunk verbatim', () => { + const gltfJson = { + nodes: [{ name: 'springbone_hair' }] + } + const binData = new Uint8Array([10, 20, 30, 40, 50, 60, 70, 80]) + const glbBone: BoneNode = { name: 'springbone_hair', nodeId: 0, type: 'spring', children: [] } + const buffer = buildGlbWithBin(gltfJson, binData) + const result = patchGltfSpringBones(buffer, [glbBone], { springbone_hair: defaultParams }) + + const resultView = new DataView(result) + const newJsonChunkLength = resultView.getUint32(GLB_HEADER_SIZE, true) + const binOffset = JSON_CHUNK_DATA_OFFSET + newJsonChunkLength + + const binChunkLength = resultView.getUint32(binOffset, true) + expect(binChunkLength).toBe(binData.length) + + const resultBinData = new Uint8Array(result, binOffset + CHUNK_HEADER_SIZE, binChunkLength) + expect(Array.from(resultBinData)).toEqual(Array.from(binData)) + }) + + it('should produce a valid GLB that can be re-parsed', () => { + const gltfJson = { + nodes: [{ name: 'Hips' }, { name: 'springbone_hair' }] + } + const buffer = buildGlb(gltfJson) + const result = patchGltfSpringBones(buffer, [avatarBone, springBone], { springbone_hair: defaultParams }) + + const chunks = extractGlbChunks(result) + expect(chunks).not.toBeNull() + expect(chunks!.isGlb).toBe(true) + const json = chunks!.json as { nodes: Array<{ extensions?: Record }> } + const ext = json.nodes[1].extensions![DCL_SPRING_BONE_EXTENSION] as Record + expect(ext.stiffness).toBe(0.5) + expect(ext.version).toBe(1) + expect(ext.isRoot).toBe(true) + }) + }) + + describe('and a spring bone nodeId is out of range', () => { + it('should warn and skip the out-of-range node', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation() + const gltfJson = { + nodes: [{ name: 'Hips' }] + } + const outOfRangeBone: BoneNode = { name: 'springbone_missing', nodeId: 99, type: 'spring', children: [] } + const buffer = buildGltfBuffer(gltfJson) + patchGltfSpringBones(buffer, [outOfRangeBone], { springbone_missing: defaultParams }) + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('nodeId 99 out of range')) + warnSpy.mockRestore() + }) + }) + + describe('and the input buffer should not be mutated', () => { + it('should return a new ArrayBuffer without modifying the original', () => { + const gltfJson = { + nodes: [{ name: 'springbone_hair' }] + } + const glbBone: BoneNode = { name: 'springbone_hair', nodeId: 0, type: 'spring', children: [] } + const buffer = buildGlb(gltfJson) + const originalBytes = new Uint8Array(buffer.slice(0)) + + const result = patchGltfSpringBones(buffer, [glbBone], { springbone_hair: defaultParams }) + + expect(result).not.toBe(buffer) + expect(Array.from(new Uint8Array(buffer))).toEqual(Array.from(originalBytes)) + }) + }) +}) diff --git a/src/lib/patchGltfSpringBones.ts b/src/lib/patchGltfSpringBones.ts new file mode 100644 index 000000000..0e319d9bf --- /dev/null +++ b/src/lib/patchGltfSpringBones.ts @@ -0,0 +1,88 @@ +import { BoneNode, SpringBoneParams } from 'modules/editor/types' +import { extractGlbChunks, buildGlb, GLB_HEADER_SIZE, CHUNK_HEADER_SIZE } from 'lib/glbUtils' +import { DCL_SPRING_BONE_EXTENSION } from 'lib/parseSpringBones' + +type GltfNodeWithExtensions = { + name?: string + extras?: Record + extensions?: Record +} + +/** + * Patches spring bone parameters into the `DCL_spring_bone_joint` extension + * of each spring bone node in a GLB binary. + * Only modifies the JSON chunk; BIN chunk is copied verbatim. + * Returns a new ArrayBuffer (does not mutate input). + */ +export function patchGltfSpringBones(buffer: ArrayBuffer, bones: BoneNode[], params: Record): ArrayBuffer { + const springBones = bones.filter(b => b.type === 'spring') + + const chunks = extractGlbChunks(buffer) + if (!chunks) return buffer + + const json = chunks.json as { nodes?: GltfNodeWithExtensions[]; extensionsUsed?: string[] } + if (!json.nodes) return buffer + + // Ensure extensionsUsed includes our extension + if (!json.extensionsUsed) { + json.extensionsUsed = [] + } + if (!json.extensionsUsed.includes(DCL_SPRING_BONE_EXTENSION)) { + json.extensionsUsed.push(DCL_SPRING_BONE_EXTENSION) + } + + // Apply params to spring bone nodes + for (const sbNode of springBones) { + const nodeId = sbNode.nodeId + if (nodeId < 0 || nodeId >= json.nodes.length) { + console.warn(`[patchGltfSpringBones] nodeId ${nodeId} out of range`) + continue + } + + const nodeName = sbNode.name + const updatedParams = params[nodeName] + const node = json.nodes[nodeId] + + if (!updatedParams) { + // Remove extension data if params were deleted + if (node.extensions) { + delete node.extensions[DCL_SPRING_BONE_EXTENSION] + } + continue + } + + // Build the extension object + if (!node.extensions) node.extensions = {} + const extension: Record = { + version: 1, + stiffness: updatedParams.stiffness, + gravityPower: updatedParams.gravityPower, + gravityDir: updatedParams.gravityDir, + drag: updatedParams.drag, + isRoot: true + } + + if (updatedParams.center !== undefined) { + extension.center = updatedParams.center + } + + node.extensions[DCL_SPRING_BONE_EXTENSION] = extension + } + + // Re-serialize JSON + const newJsonString = JSON.stringify(json) + const encoder = new TextEncoder() + const newJsonBytes = encoder.encode(newJsonString) + + // For .gltf (plain JSON), just return the re-serialized JSON + if (!chunks.isGlb) { + return newJsonBytes.buffer + } + + // For .glb, re-pack the binary container + const binChunkOffset = GLB_HEADER_SIZE + CHUNK_HEADER_SIZE + chunks.jsonChunkLength + const hasBinChunk = buffer.byteLength > binChunkOffset + const trailingData = hasBinChunk ? new Uint8Array(buffer, binChunkOffset) : undefined + + return buildGlb(chunks.json, chunks.version, trailingData) +} diff --git a/src/modules/editor/actions.spec.ts b/src/modules/editor/actions.spec.ts index b5da6cf58..aab173875 100644 --- a/src/modules/editor/actions.spec.ts +++ b/src/modules/editor/actions.spec.ts @@ -5,8 +5,23 @@ import { fetchBaseWearablesSuccess, FETCH_BASE_WEARABLES_FAILURE, FETCH_BASE_WEARABLES_REQUEST, - FETCH_BASE_WEARABLES_SUCCESS + FETCH_BASE_WEARABLES_SUCCESS, + clearSpringBones, + setBones, + setSpringBoneParam, + resetSpringBoneParams, + pushSpringBoneParams, + addSpringBoneParams, + deleteSpringBoneParams, + CLEAR_SPRING_BONES, + SET_BONES, + SET_SPRING_BONE_PARAM, + RESET_SPRING_BONE_PARAMS, + PUSH_SPRING_BONE_PARAMS, + ADD_SPRING_BONE_PARAMS, + DELETE_SPRING_BONE_PARAMS } from './actions' +import { BoneNode } from './types' const commonError = 'anError' @@ -33,3 +48,58 @@ describe('when creating the action that signals the unsuccessful fetch of tiers' }) }) }) + +describe('when creating the action that clears spring bones', () => { + it('should return an action with type CLEAR_SPRING_BONES', () => { + expect(clearSpringBones()).toEqual({ type: CLEAR_SPRING_BONES }) + }) +}) + +describe('when creating the action that sets bones', () => { + it('should return an action with bones and selectedItemId payload', () => { + const bones: BoneNode[] = [{ name: 'Hips', nodeId: 0, type: 'avatar', children: [] }] + expect(setBones(bones, 'aHash')).toEqual({ + type: SET_BONES, + payload: { bones, selectedItemId: 'aHash' } + }) + }) +}) + +describe('when creating the action that sets a spring bone param', () => { + it('should return an action with boneName, field, and value payload', () => { + expect(setSpringBoneParam('springbone_hair', 'stiffness', 0.5)).toEqual({ + type: SET_SPRING_BONE_PARAM, + payload: { boneName: 'springbone_hair', field: 'stiffness', value: 0.5 } + }) + }) +}) + +describe('when creating the action that resets spring bone params', () => { + it('should return an action with type RESET_SPRING_BONE_PARAMS', () => { + expect(resetSpringBoneParams()).toEqual({ type: RESET_SPRING_BONE_PARAMS }) + }) +}) + +describe('when creating the action that pushes spring bone params', () => { + it('should return an action with type PUSH_SPRING_BONE_PARAMS', () => { + expect(pushSpringBoneParams()).toEqual({ type: PUSH_SPRING_BONE_PARAMS }) + }) +}) + +describe('when creating the action that adds spring bone params', () => { + it('should return an action with boneName payload', () => { + expect(addSpringBoneParams('springbone_tail')).toEqual({ + type: ADD_SPRING_BONE_PARAMS, + payload: { boneName: 'springbone_tail' } + }) + }) +}) + +describe('when creating the action that deletes spring bone params', () => { + it('should return an action with boneName payload', () => { + expect(deleteSpringBoneParams('springbone_hair')).toEqual({ + type: DELETE_SPRING_BONE_PARAMS, + payload: { boneName: 'springbone_hair' } + }) + }) +}) diff --git a/src/modules/editor/actions.ts b/src/modules/editor/actions.ts index ea9edb54c..4531f977d 100644 --- a/src/modules/editor/actions.ts +++ b/src/modules/editor/actions.ts @@ -279,3 +279,44 @@ export const fetchBaseWearablesFailure = (error: string) => action(FETCH_BASE_WE export type FetchBaseWearablesRequestAction = ReturnType export type FetchBaseWearablesSuccessAction = ReturnType export type FetchBaseWearablesFailureAction = ReturnType + +// Spring Bones +import { BoneNode, SpringBoneParams } from './types' + +export const CLEAR_SPRING_BONES = 'Clear spring bones' +export const SET_BONES = 'Set bones' +export const SET_SPRING_BONE_PARAM = 'Set spring bone param' +export const RESET_SPRING_BONE_PARAMS = 'Reset spring bone params' +export const PUSH_SPRING_BONE_PARAMS = 'Push spring bone params' +export const ADD_SPRING_BONE_PARAMS = 'Add spring bone params' +export const DELETE_SPRING_BONE_PARAMS = 'Delete spring bone params' + +/** Clears all spring bone data from the editor state */ +export const clearSpringBones = () => action(CLEAR_SPRING_BONES) + +/** Sets the full bones array, typically when loading a new wearable model */ +export const setBones = (bones: BoneNode[], selectedItemId: string | null) => action(SET_BONES, { bones, selectedItemId }) + +/** Updates a single field from a spring bone */ +export const setSpringBoneParam = (boneName: string, field: keyof SpringBoneParams, value: SpringBoneParams[typeof field]) => + action(SET_SPRING_BONE_PARAM, { boneName, field, value }) + +/** Resets all spring bone params to their original saved value */ +export const resetSpringBoneParams = () => action(RESET_SPRING_BONE_PARAMS) + +/** Triggers an immediate push of spring bone params to the wearable preview controller */ +export const pushSpringBoneParams = () => action(PUSH_SPRING_BONE_PARAMS) + +/** Initializes default params for a spring bone that has none configured */ +export const addSpringBoneParams = (boneName: string) => action(ADD_SPRING_BONE_PARAMS, { boneName }) + +/** Removes spring bone params for a bone, effectively disabling spring physics on it */ +export const deleteSpringBoneParams = (boneName: string) => action(DELETE_SPRING_BONE_PARAMS, { boneName }) + +export type ClearSpringBonesAction = ReturnType +export type SetBonesAction = ReturnType +export type SetSpringBoneParamAction = ReturnType +export type ResetSpringBoneParamsAction = ReturnType +export type PushSpringBoneParamsAction = ReturnType +export type AddSpringBoneParamsAction = ReturnType +export type DeleteSpringBoneParamsAction = ReturnType diff --git a/src/modules/editor/reducer.spec.ts b/src/modules/editor/reducer.spec.ts index 0e8398f4f..521e4700b 100644 --- a/src/modules/editor/reducer.spec.ts +++ b/src/modules/editor/reducer.spec.ts @@ -1,7 +1,22 @@ import { BodyShape, WearableCategory } from '@dcl/schemas' import type { Wearable } from 'decentraland-ecs' import { anotherWearable, convertWearable, wearable } from 'specs/editor' -import { fetchBaseWearablesFailure, fetchBaseWearablesRequest, fetchBaseWearablesSuccess, setBaseWearable } from './actions' +import { mockedItem } from 'specs/item' +import { saveItemSuccess } from 'modules/item/actions' +import { DEFAULT_SPRING_BONE_PARAMS } from 'lib/parseSpringBones' +import { + fetchBaseWearablesFailure, + fetchBaseWearablesRequest, + fetchBaseWearablesSuccess, + setBaseWearable, + clearSpringBones, + setBones, + setSpringBoneParam, + resetSpringBoneParams, + addSpringBoneParams, + deleteSpringBoneParams +} from './actions' +import { BoneNode } from './types' import { editorReducer, EditorState, INITIAL_STATE } from './reducer' let state: EditorState @@ -109,3 +124,200 @@ describe('when reducing the action that signals a failing fetching of the base w }) }) }) + +describe('when reducing the action that clears spring bones', () => { + beforeEach(() => { + state = { + ...state, + bones: [{ name: 'springbone_hair', nodeId: 0, type: 'spring', children: [] }], + selectedItemId: 'aHash', + springBoneParams: { springbone_hair: { ...DEFAULT_SPRING_BONE_PARAMS } }, + originalSpringBoneParams: { springbone_hair: { ...DEFAULT_SPRING_BONE_PARAMS } } + } + }) + + it('should reset bones, selectedItemId, springBoneParams, and originalSpringBoneParams to initial values', () => { + expect(editorReducer(state, clearSpringBones())).toEqual({ + ...state, + bones: [], + selectedItemId: null, + springBoneParams: {}, + originalSpringBoneParams: {} + }) + }) +}) + +describe('when reducing the action that sets bones', () => { + const springBoneWithParams: BoneNode = { + name: 'springbone_hair', + nodeId: 1, + type: 'spring', + children: [], + params: { stiffness: 0.5, gravityPower: 1, gravityDir: [0, -1, 0], drag: 0.3, center: undefined } + } + const springBoneWithoutParams: BoneNode = { + name: 'springbone_tail', + nodeId: 2, + type: 'spring', + children: [] + } + const avatarBone: BoneNode = { name: 'Hips', nodeId: 0, type: 'avatar', children: [1, 2] } + + it('should set bones and selectedItemId from action payload', () => { + const bones = [avatarBone, springBoneWithParams] + const result = editorReducer(state, setBones(bones, 'glbHash123')) + + expect(result.bones).toEqual(bones) + expect(result.selectedItemId).toBe('glbHash123') + }) + + it('should extract springBoneParams from spring-type bones that have params', () => { + const bones = [avatarBone, springBoneWithParams] + const result = editorReducer(state, setBones(bones, 'glbHash123')) + + expect(result.springBoneParams).toEqual({ + springbone_hair: springBoneWithParams.params + }) + }) + + it('should set originalSpringBoneParams as a copy of the extracted springBoneParams', () => { + const bones = [avatarBone, springBoneWithParams] + const result = editorReducer(state, setBones(bones, 'glbHash123')) + + expect(result.originalSpringBoneParams).toEqual(result.springBoneParams) + }) + + it('should ignore avatar-type bones when building springBoneParams', () => { + const bones = [avatarBone] + const result = editorReducer(state, setBones(bones, 'glbHash123')) + + expect(result.springBoneParams).toEqual({}) + }) + + it('should not add an entry for spring bones without params', () => { + const bones = [avatarBone, springBoneWithoutParams] + const result = editorReducer(state, setBones(bones, 'glbHash123')) + + expect(result.springBoneParams).toEqual({}) + }) +}) + +describe('when reducing the action that sets a spring bone param', () => { + beforeEach(() => { + state = { + ...state, + springBoneParams: { + springbone_hair: { stiffness: 1, gravityPower: 0, gravityDir: [0, -1, 0], drag: 0.4, center: undefined }, + springbone_tail: { stiffness: 0.5, gravityPower: 0, gravityDir: [0, -1, 0], drag: 0.2, center: undefined } + }, + originalSpringBoneParams: { + springbone_hair: { stiffness: 1, gravityPower: 0, gravityDir: [0, -1, 0], drag: 0.4, center: undefined } + } + } + }) + + it('should update a single field on the specified bone params', () => { + const result = editorReducer(state, setSpringBoneParam('springbone_hair', 'stiffness', 0.8)) + + expect(result.springBoneParams.springbone_hair.stiffness).toBe(0.8) + }) + + it('should not affect other bones params', () => { + const result = editorReducer(state, setSpringBoneParam('springbone_hair', 'stiffness', 0.8)) + + expect(result.springBoneParams.springbone_tail).toEqual(state.springBoneParams.springbone_tail) + }) + + it('should not modify originalSpringBoneParams', () => { + const result = editorReducer(state, setSpringBoneParam('springbone_hair', 'stiffness', 0.8)) + + expect(result.originalSpringBoneParams).toEqual(state.originalSpringBoneParams) + }) +}) + +describe('when reducing the action that adds spring bone params', () => { + beforeEach(() => { + state = { + ...state, + springBoneParams: { + springbone_hair: { stiffness: 0.5, gravityPower: 0, gravityDir: [0, -1, 0], drag: 0.4, center: undefined } + } + } + }) + + it('should add DEFAULT_SPRING_BONE_PARAMS for the given bone name', () => { + const result = editorReducer(state, addSpringBoneParams('springbone_tail')) + + expect(result.springBoneParams.springbone_tail).toEqual(DEFAULT_SPRING_BONE_PARAMS) + }) + + it('should not overwrite existing params for other bones', () => { + const result = editorReducer(state, addSpringBoneParams('springbone_tail')) + + expect(result.springBoneParams.springbone_hair).toEqual(state.springBoneParams.springbone_hair) + }) +}) + +describe('when reducing the action that deletes spring bone params', () => { + beforeEach(() => { + state = { + ...state, + springBoneParams: { + springbone_hair: { stiffness: 0.5, gravityPower: 0, gravityDir: [0, -1, 0], drag: 0.4, center: undefined }, + springbone_tail: { stiffness: 1, gravityPower: 0, gravityDir: [0, -1, 0], drag: 0.2, center: undefined } + } + } + }) + + it('should remove params for the given bone name', () => { + const result = editorReducer(state, deleteSpringBoneParams('springbone_hair')) + + expect(result.springBoneParams.springbone_hair).toBeUndefined() + }) + + it('should preserve params for other bones', () => { + const result = editorReducer(state, deleteSpringBoneParams('springbone_hair')) + + expect(result.springBoneParams.springbone_tail).toEqual(state.springBoneParams.springbone_tail) + }) +}) + +describe('when reducing the action that resets spring bone params', () => { + beforeEach(() => { + state = { + ...state, + springBoneParams: { + springbone_hair: { stiffness: 0.8, gravityPower: 2, gravityDir: [1, 0, 0], drag: 0.1, center: undefined } + }, + originalSpringBoneParams: { + springbone_hair: { stiffness: 1, gravityPower: 0, gravityDir: [0, -1, 0], drag: 0.4, center: undefined } + } + } + }) + + it('should restore springBoneParams to match originalSpringBoneParams', () => { + const result = editorReducer(state, resetSpringBoneParams()) + + expect(result.springBoneParams).toEqual(state.originalSpringBoneParams) + }) +}) + +describe('when reducing a save item success action', () => { + beforeEach(() => { + state = { + ...state, + springBoneParams: { + springbone_hair: { stiffness: 0.8, gravityPower: 2, gravityDir: [1, 0, 0], drag: 0.1, center: undefined } + }, + originalSpringBoneParams: { + springbone_hair: { stiffness: 1, gravityPower: 0, gravityDir: [0, -1, 0], drag: 0.4, center: undefined } + } + } + }) + + it('should update originalSpringBoneParams to match current springBoneParams', () => { + const result = editorReducer(state, saveItemSuccess(mockedItem, {})) + + expect(result.originalSpringBoneParams).toEqual(state.springBoneParams) + }) +}) diff --git a/src/modules/editor/reducer.ts b/src/modules/editor/reducer.ts index 6d2ce6569..3fd629166 100644 --- a/src/modules/editor/reducer.ts +++ b/src/modules/editor/reducer.ts @@ -10,10 +10,12 @@ import { ExportProjectRequestAction, ExportProjectSuccessAction } from 'modules/project/actions' -import { DeleteItemSuccessAction, DELETE_ITEM_SUCCESS } from 'modules/item/actions' +import { DeleteItemSuccessAction, DELETE_ITEM_SUCCESS, SaveItemSuccessAction, SAVE_ITEM_SUCCESS } from 'modules/item/actions' import { hasBodyShape } from 'modules/item/utils' import { getEyeColors, getHairColors, getSkinColors } from 'modules/editor/avatar' import { Color4 } from 'lib/colors' +import { DEFAULT_SPRING_BONE_PARAMS } from 'lib/parseSpringBones' +import { BoneNode, SpringBoneParams } from './types' import { SetGizmoAction, TogglePreviewAction, @@ -64,7 +66,19 @@ import { FETCH_BASE_WEARABLES_FAILURE, FETCH_BASE_WEARABLES_REQUEST, FetchBaseWearablesRequestAction, - FetchBaseWearablesFailureAction + FetchBaseWearablesFailureAction, + CLEAR_SPRING_BONES, + SET_BONES, + SET_SPRING_BONE_PARAM, + RESET_SPRING_BONE_PARAMS, + ADD_SPRING_BONE_PARAMS, + DELETE_SPRING_BONE_PARAMS, + ClearSpringBonesAction, + SetBonesAction, + SetSpringBoneParamAction, + ResetSpringBoneParamsAction, + AddSpringBoneParamsAction, + DeleteSpringBoneParamsAction } from './actions' import { Gizmo } from './types' import { pickRandom, filterWearables } from './utils' @@ -99,6 +113,10 @@ export type EditorState = { visibleItemIds: string[] loading: LoadingState fetchingBaseWearablesError: string | null + bones: BoneNode[] + selectedItemId: string | null + springBoneParams: Record + originalSpringBoneParams: Record } export const INITIAL_STATE: EditorState = { @@ -130,7 +148,11 @@ export const INITIAL_STATE: EditorState = { selectedBaseWearablesByBodyShape: null, visibleItemIds: [], loading: [], - fetchingBaseWearablesError: null + fetchingBaseWearablesError: null, + bones: [], + selectedItemId: null, + springBoneParams: {}, + originalSpringBoneParams: {} } export type EditorReducerAction = @@ -164,6 +186,13 @@ export type EditorReducerAction = | FetchBaseWearablesRequestAction | FetchBaseWearablesSuccessAction | FetchBaseWearablesFailureAction + | ClearSpringBonesAction + | SetBonesAction + | SetSpringBoneParamAction + | ResetSpringBoneParamsAction + | AddSpringBoneParamsAction + | DeleteSpringBoneParamsAction + | SaveItemSuccessAction export const editorReducer = (state = INITIAL_STATE, action: EditorReducerAction): EditorState => { switch (action.type) { @@ -394,6 +423,75 @@ export const editorReducer = (state = INITIAL_STATE, action: EditorReducerAction visibleItemIds: state.visibleItemIds.filter(id => id !== action.payload.item.id) } } + case CLEAR_SPRING_BONES: { + return { + ...state, + bones: [], + selectedItemId: null, + springBoneParams: {}, + originalSpringBoneParams: {} + } + } + case SET_BONES: { + const { bones, selectedItemId } = action.payload + const springBoneParams: Record = {} + for (const bone of bones) { + if (bone.type === 'spring' && bone.params) { + springBoneParams[bone.name] = { ...bone.params } + } + } + return { + ...state, + bones, + selectedItemId, + springBoneParams, + originalSpringBoneParams: { ...springBoneParams } + } + } + case SET_SPRING_BONE_PARAM: { + const { boneName, field, value } = action.payload + return { + ...state, + springBoneParams: { + ...state.springBoneParams, + [boneName]: { + ...state.springBoneParams[boneName], + [field]: value + } + } + } + } + case ADD_SPRING_BONE_PARAMS: { + const { boneName } = action.payload + return { + ...state, + springBoneParams: { + ...state.springBoneParams, + [boneName]: { ...DEFAULT_SPRING_BONE_PARAMS } + } + } + } + case DELETE_SPRING_BONE_PARAMS: { + const { boneName } = action.payload + const { [boneName]: _, ...remainingParams } = state.springBoneParams + return { + ...state, + springBoneParams: remainingParams + } + } + case RESET_SPRING_BONE_PARAMS: { + return { + ...state, + springBoneParams: { ...state.originalSpringBoneParams } + } + } + case SAVE_ITEM_SUCCESS: { + // After a successful save, the current params become the new original (clears the "changed" flag) + return { + ...state, + originalSpringBoneParams: { ...state.springBoneParams } + } + } default: return state } diff --git a/src/modules/editor/sagas.ts b/src/modules/editor/sagas.ts index 94a465860..017a181ee 100644 --- a/src/modules/editor/sagas.ts +++ b/src/modules/editor/sagas.ts @@ -55,7 +55,11 @@ import { fetchBaseWearablesSuccess, fetchBaseWearablesFailure, setEmotePlaying, - SET_WEARABLE_PREVIEW_CONTROLLER + SET_WEARABLE_PREVIEW_CONTROLLER, + SET_SPRING_BONE_PARAM, + PUSH_SPRING_BONE_PARAMS, + ADD_SPRING_BONE_PARAMS, + DELETE_SPRING_BONE_PARAMS } from 'modules/editor/actions' import { PROVISION_SCENE, @@ -108,7 +112,10 @@ import { hasLoadedAssetPacks, isMultiselectEnabled, getWearablePreviewController, - getVisibleItemsFromUrl + getVisibleItemsFromUrl, + getSpringBoneParams, + getSpringBones, + getSelectedItemId } from './selectors' import { getNewEditorScene, @@ -150,6 +157,10 @@ export function* editorSaga() { yield takeLatest(CREATE_EDITOR_SCENE, handleCreateEditorScene) yield takeLatest(SET_BODY_SHAPE, handleSetBodyShape) yield takeLatest(FETCH_BASE_WEARABLES_REQUEST, handleFetchBaseWearables) + yield takeLatest(SET_SPRING_BONE_PARAM, handleSpringBoneParamDebounced) + yield takeLatest(PUSH_SPRING_BONE_PARAMS, handlePushSpringBoneParams) + yield takeLatest(ADD_SPRING_BONE_PARAMS, handlePushSpringBoneParams) + yield takeLatest(DELETE_SPRING_BONE_PARAMS, handlePushSpringBoneParams) } function* pollEditor(scene: SceneSDK6) { @@ -690,3 +701,30 @@ function* handleFetchBaseWearables() { yield put(fetchBaseWearablesFailure(isErrorWithMessage(e) ? e.message : 'Unknown error')) } } + +function* pushSpringBoneParamsToPreview() { + const controller: IPreviewController | null = yield select(getWearablePreviewController) + const selectedItemId: string | null = yield select(getSelectedItemId) + const springBones: ReturnType = yield select(getSpringBones) + const springBoneParams: ReturnType = yield select(getSpringBoneParams) + + if (!controller || !selectedItemId || springBones.length === 0) { + return + } + + try { + yield call([controller.physics, 'setSpringBonesParams'], selectedItemId, springBoneParams) + } catch (error) { + console.warn('Failed to push spring bones params to preview:', error) + } +} + +function* handleSpringBoneParamDebounced() { + yield delay(1000) // Debounce: wait 1s after last param change before pushing + yield call(pushSpringBoneParamsToPreview) +} + +function* handlePushSpringBoneParams() { + // Immediate push (used on preview load and emote play) + yield call(pushSpringBoneParamsToPreview) +} diff --git a/src/modules/editor/selectors.spec.ts b/src/modules/editor/selectors.spec.ts index 8f3b19053..fb60839d5 100644 --- a/src/modules/editor/selectors.spec.ts +++ b/src/modules/editor/selectors.spec.ts @@ -3,7 +3,19 @@ import { RootState } from 'modules/common/types' import { wearable } from 'specs/editor' import { FETCH_BASE_WEARABLES_REQUEST } from './actions' import { INITIAL_STATE } from './reducer' -import { getFetchingBaseWearablesError, getSelectedBaseWearablesByBodyShape, isLoadingBaseWearables } from './selectors' +import { BoneNode } from './types' +import { + getFetchingBaseWearablesError, + getSelectedBaseWearablesByBodyShape, + isLoadingBaseWearables, + getBones, + getSpringBones, + getAvatarBones, + getSelectedItemId, + getSpringBoneParams, + getOriginalSpringBoneParams, + hasSpringBoneChanges +} from './selectors' let state: RootState const originalLocation = window.location @@ -144,3 +156,193 @@ describe('when getting the fetching base wearable error', () => { expect(getFetchingBaseWearablesError(state)).toEqual('someError') }) }) + +describe('when getting bones', () => { + const avatarBone: BoneNode = { name: 'Hips', nodeId: 0, type: 'avatar', children: [1] } + const springBone: BoneNode = { name: 'springbone_hair', nodeId: 1, type: 'spring', children: [] } + + beforeEach(() => { + state = { + ...state, + editor: { + ...state.editor, + bones: [avatarBone, springBone] + } + } + }) + + it('should return all bones', () => { + expect(getBones(state)).toEqual([avatarBone, springBone]) + }) +}) + +describe('when getting spring bones', () => { + const avatarBone: BoneNode = { name: 'Hips', nodeId: 0, type: 'avatar', children: [1] } + const springBone: BoneNode = { name: 'springbone_hair', nodeId: 1, type: 'spring', children: [] } + + beforeEach(() => { + state = { + ...state, + editor: { + ...state.editor, + bones: [avatarBone, springBone] + } + } + }) + + it('should return only bones with type spring', () => { + expect(getSpringBones(state)).toEqual([springBone]) + }) +}) + +describe('when getting avatar bones', () => { + const avatarBone: BoneNode = { name: 'Hips', nodeId: 0, type: 'avatar', children: [1] } + const springBone: BoneNode = { name: 'springbone_hair', nodeId: 1, type: 'spring', children: [] } + + beforeEach(() => { + state = { + ...state, + editor: { + ...state.editor, + bones: [avatarBone, springBone] + } + } + }) + + it('should return only bones with type avatar', () => { + expect(getAvatarBones(state)).toEqual([avatarBone]) + }) +}) + +describe('when getting the selected item GLB hash', () => { + beforeEach(() => { + state = { + ...state, + editor: { + ...state.editor, + selectedItemId: 'aGlbHash' + } + } + }) + + it('should return the selected item GLB hash', () => { + expect(getSelectedItemId(state)).toBe('aGlbHash') + }) +}) + +describe('when getting spring bone params', () => { + const params = { + springbone_hair: { + stiffness: 1, + gravityPower: 0, + gravityDir: [0, -1, 0] as [number, number, number], + drag: 0.4, + center: undefined + } + } + + beforeEach(() => { + state = { + ...state, + editor: { + ...state.editor, + springBoneParams: params + } + } + }) + + it('should return the spring bone params', () => { + expect(getSpringBoneParams(state)).toEqual(params) + }) +}) + +describe('when getting original spring bone params', () => { + const params = { + springbone_hair: { + stiffness: 1, + gravityPower: 0, + gravityDir: [0, -1, 0] as [number, number, number], + drag: 0.4, + center: undefined + } + } + + beforeEach(() => { + state = { + ...state, + editor: { + ...state.editor, + originalSpringBoneParams: params + } + } + }) + + it('should return the original spring bone params', () => { + expect(getOriginalSpringBoneParams(state)).toEqual(params) + }) +}) + +describe('when checking if spring bone params have changed', () => { + const originalParams = { + springbone_hair: { + stiffness: 1, + gravityPower: 0, + gravityDir: [0, -1, 0] as [number, number, number], + drag: 0.4, + center: undefined + } + } + + describe('and springBoneParams equals originalSpringBoneParams', () => { + beforeEach(() => { + state = { + ...state, + editor: { + ...state.editor, + springBoneParams: { ...originalParams }, + originalSpringBoneParams: { ...originalParams } + } + } + }) + + it('should return false', () => { + expect(hasSpringBoneChanges(state)).toBe(false) + }) + }) + + describe('and springBoneParams differs from originalSpringBoneParams', () => { + beforeEach(() => { + state = { + ...state, + editor: { + ...state.editor, + springBoneParams: { + springbone_hair: { ...originalParams.springbone_hair, stiffness: 0.5 } + }, + originalSpringBoneParams: { ...originalParams } + } + } + }) + + it('should return true', () => { + expect(hasSpringBoneChanges(state)).toBe(true) + }) + }) + + describe('and both are empty', () => { + beforeEach(() => { + state = { + ...state, + editor: { + ...state.editor, + springBoneParams: {}, + originalSpringBoneParams: {} + } + } + }) + + it('should return false', () => { + expect(hasSpringBoneChanges(state)).toBe(false) + }) + }) +}) diff --git a/src/modules/editor/selectors.ts b/src/modules/editor/selectors.ts index d88b9ff28..1d86cc49e 100644 --- a/src/modules/editor/selectors.ts +++ b/src/modules/editor/selectors.ts @@ -1,4 +1,5 @@ import { createSelector } from 'reselect' +import equal from 'fast-deep-equal' import type { Wearable } from 'decentraland-ecs' import { BodyShape } from '@dcl/schemas' @@ -135,3 +136,16 @@ export const getVisibleItemsFromUrl = (state: RootState, search: string) => { } }) } + +export const getBones = (state: RootState) => getState(state).bones +export const getSpringBones = (state: RootState) => getState(state).bones.filter(b => b.type === 'spring') +export const getAvatarBones = (state: RootState) => getState(state).bones.filter(b => b.type === 'avatar') +export const getSelectedItemId = (state: RootState) => getState(state).selectedItemId +export const getSpringBoneParams = (state: RootState) => getState(state).springBoneParams +export const getOriginalSpringBoneParams = (state: RootState) => getState(state).originalSpringBoneParams + +export const hasSpringBoneChanges = createSelector( + getSpringBoneParams, + getOriginalSpringBoneParams, + (current, original) => !equal(current, original) +) diff --git a/src/modules/editor/types.ts b/src/modules/editor/types.ts index b2e39489b..5443a1759 100644 --- a/src/modules/editor/types.ts +++ b/src/modules/editor/types.ts @@ -1,4 +1,4 @@ -import { BodyShape, HideableWearableCategory, WearableCategory } from '@dcl/schemas' +import { BodyShape, HideableWearableCategory, SpringBoneParams, WearableCategory } from '@dcl/schemas' import type { BodyShapeRespresentation, Wearable } from 'decentraland-ecs' export enum Gizmo { @@ -77,3 +77,18 @@ export type PatchedWearable = Wearable & { hides: string[] representations: BodyShapeRespresentation & { overrideReplaces: string[]; overrideHides: string[] }[] } + +export { SpringBoneParams } + +type BaseBoneNode = { + name: string + nodeId: number + children: number[] +} + +export type BoneNode = + | (BaseBoneNode & { type: 'avatar' }) + | (BaseBoneNode & { + type: 'spring' + params?: SpringBoneParams + }) diff --git a/src/modules/item/sagas.spec.ts b/src/modules/item/sagas.spec.ts index b1de63b26..3415adfd1 100644 --- a/src/modules/item/sagas.spec.ts +++ b/src/modules/item/sagas.spec.ts @@ -29,6 +29,7 @@ import { MAX_ITEMS } from 'modules/collection/constants' import { FromParam } from 'modules/location/types' import { getMethodData } from 'modules/wallet/utils' import { getIsLinkedWearablesV2Enabled } from 'modules/features/selectors' +import { hasSpringBoneChanges } from 'modules/editor/selectors' import { mockedItem, mockedItemContents, mockedLocalItem, mockedRemoteItem } from 'specs/item' import { getCollections, getCollection } from 'modules/collection/selectors' import { updateProgressSaveMultipleItems } from 'modules/ui/createMultipleItems/action' @@ -342,6 +343,7 @@ describe('when handling the save item request action', () => { [select(getItem, item.id), undefined], [select(getAddress), mockAddress], [select(getIsLinkedWearablesV2Enabled), true], + [select(hasSpringBoneChanges), false], [ call(generateCatalystImage, item, { thumbnail: contents[THUMBNAIL_PATH] @@ -378,6 +380,7 @@ describe('when handling the save item request action', () => { [select(getOpenModals), { EditItemURNModal: true }], [select(getItem, item.id), undefined], [select(getIsLinkedWearablesV2Enabled), true], + [select(hasSpringBoneChanges), false], [select(getAddress), mockAddress], [ call(generateCatalystImage, item, { @@ -412,6 +415,7 @@ describe('when handling the save item request action', () => { [select(getItem, item.id), undefined], [select(getAddress), mockAddress], [select(getIsLinkedWearablesV2Enabled), true], + [select(hasSpringBoneChanges), false], [call(calculateModelFinalSize, itemContents, modelContents, item.type, builderAPI), Promise.resolve(1)], [call(calculateFileSize, thumbnailContent), 1], [call([builderAPI, 'saveItem'], item, contents), Promise.resolve(item)], @@ -447,6 +451,7 @@ describe('when handling the save item request action', () => { ], [select(getAddress), mockAddress], [select(getIsLinkedWearablesV2Enabled), true], + [select(hasSpringBoneChanges), false], [ call(generateCatalystImage, item, { thumbnail: contents[THUMBNAIL_PATH] @@ -483,6 +488,7 @@ describe('when handling the save item request action', () => { [select(getItem, item.id), undefined], [select(getAddress), mockAddress], [select(getIsLinkedWearablesV2Enabled), true], + [select(hasSpringBoneChanges), false], [call(calculateModelFinalSize, itemContents, modelContents, item.type, builderAPI), Promise.resolve(1)], [call(calculateFileSize, thumbnailContent), 1], [call([builderAPI, 'saveItem'], item, contents), Promise.resolve(item)], @@ -510,6 +516,7 @@ describe('when handling the save item request action', () => { [select(getItem, item.id), undefined], [select(getAddress), mockAddress], [select(getIsLinkedWearablesV2Enabled), true], + [select(hasSpringBoneChanges), false], [call(calculateModelFinalSize, itemContents, modelContents, item.type, builderAPI), Promise.resolve(1)], [call(calculateFileSize, thumbnailContent), 1], [call([builderAPI, 'saveItem'], item, contents), Promise.resolve(item)], @@ -535,6 +542,7 @@ describe('when handling the save item request action', () => { [select(getItem, item.id), undefined], [select(getAddress), mockAddress], [select(getIsLinkedWearablesV2Enabled), true], + [select(hasSpringBoneChanges), false], [call([builderAPI, 'saveItem'], item, {}), Promise.resolve(item)], [put(saveItemSuccess(item, {})), undefined] ]) @@ -570,6 +578,7 @@ describe('when handling the save item request action', () => { [select(getItem, item.id), item], [select(getAddress), mockAddress], [select(getIsLinkedWearablesV2Enabled), true], + [select(hasSpringBoneChanges), false], [call(calculateModelFinalSize, itemContents, modelContents, item.type, builderAPI), Promise.resolve(1)], [call(calculateFileSize, thumbnailContent), 1], [call([builderAPI, 'saveItem'], itemWithNewHashes, newContents), Promise.resolve(itemWithNewHashes)], diff --git a/src/modules/item/sagas.ts b/src/modules/item/sagas.ts index 593ce6afb..fac85d1f9 100644 --- a/src/modules/item/sagas.ts +++ b/src/modules/item/sagas.ts @@ -4,7 +4,7 @@ import { Contract, ethers, providers } from 'ethers' import { takeEvery, call, put, takeLatest, select, take, delay, fork, race, cancelled, getContext } from 'redux-saga/effects' import { LOCATION_CHANGE, LocationChangeAction } from 'modules/location/actions' import { channel } from 'redux-saga' -import { ChainId, Network, Entity, EntityType, WearableCategory, TradeCreation, Trade, Item as DCLItem } from '@dcl/schemas' +import { BodyShape, ChainId, Network, Entity, EntityType, WearableCategory, TradeCreation, Trade, Item as DCLItem } from '@dcl/schemas' import { ContractName, getContract } from 'decentraland-transactions' import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { ModalState } from 'decentraland-dapps/dist/modules/modal/reducer' @@ -136,6 +136,10 @@ import { getIsLinkedWearablesV2Enabled, getIsOffchainPublicItemOrdersEnabled } f import { getCatalystContentUrl } from 'lib/api/peer' import { downloadZip } from 'lib/zip' import { isErrorWithCode } from 'lib/error' +import { hashV1 } from '@dcl/hashing' +import { hasSpringBoneChanges, getBones, getSpringBoneParams, getBodyShape } from 'modules/editor/selectors' +import { BoneNode, SpringBoneParams } from 'modules/editor/types' +import { patchGltfSpringBones } from 'lib/patchGltfSpringBones' import { calculateModelFinalSize, calculateFileSize, reHashOlderContents } from './export' import { Item, BlockchainRarity, CatalystItem, BodyShapeType, IMAGE_PATH, THUMBNAIL_PATH, WearableData, VIDEO_PATH } from './types' import { getData as getItemsById, getItems, getEntityByItemId, getCollectionItems, getItem, getPaginationData } from './selectors' @@ -503,6 +507,54 @@ export function* itemSaga(legacyBuilder: LegacyBuilderAPI, builder: BuilderClien } } + // Spring bone GLB patching + const springBoneHasChanges: boolean = yield select(hasSpringBoneChanges) + if (isWearable(item) && springBoneHasChanges) { + const bones: BoneNode[] = yield select(getBones) + const springBoneParams: Record = yield select(getSpringBoneParams) + + // When there are separate male/female GLB models, only patch the one + // matching the currently previewed body shape. When all representations + // share the same model (same content hash), patch all of them. + const bodyShape: BodyShape = yield select(getBodyShape) + const mainFileHashes = new Set(item.data.representations.map(r => r.mainFile && item.contents[r.mainFile]).filter(Boolean)) + const hasSeparateModels = mainFileHashes.size > 1 + const targetRepresentations = hasSeparateModels + ? item.data.representations.filter(r => r.bodyShapes.includes(bodyShape)) + : item.data.representations + + // Collect GLB paths that need fetching (not already in contents) + const glbPathsToFetch: Record = {} + for (const representation of targetRepresentations) { + const glbPath = representation.mainFile + if (glbPath && !contents[glbPath] && item.contents[glbPath]) { + glbPathsToFetch[glbPath] = item.contents[glbPath] + } + } + + // Fetch existing GLBs from the builder API + if (Object.keys(glbPathsToFetch).length > 0) { + const fetchedBlobs: Record = yield call([legacyBuilder, 'fetchContents'], glbPathsToFetch) + Object.assign(contents, fetchedBlobs) + } + + // Patch GLB for the target representation(s) + const patchedPaths = new Set() + for (const representation of targetRepresentations) { + const glbPath = representation.mainFile + if (!glbPath || !contents[glbPath] || patchedPaths.has(glbPath)) continue + patchedPaths.add(glbPath) + + const glbBlob: Blob = contents[glbPath] + const buffer: ArrayBuffer = yield call([glbBlob, 'arrayBuffer']) + const patchedBuffer = patchGltfSpringBones(buffer, bones, springBoneParams) + const patchedBlob = new Blob([patchedBuffer], { type: 'model/gltf-binary' }) + const newHash: string = yield call(hashV1, new Uint8Array(patchedBuffer)) + contents[glbPath] = patchedBlob + item.contents[glbPath] = newHash + } + } + const savedItem: Item = yield call([legacyBuilder, 'saveItem'], item, contents) if (isLinkedWearablesV2Enabled) { yield put(saveItemSuccess(savedItem, contents, options)) diff --git a/src/modules/item/utils.spec.ts b/src/modules/item/utils.spec.ts index 6d02074b8..7f64e6458 100644 --- a/src/modules/item/utils.spec.ts +++ b/src/modules/item/utils.spec.ts @@ -12,7 +12,8 @@ import { getFirstWearableOrItem, formatExtensions, hasVideo, - isEmote + isEmote, + getRepresentationMainFile } from './utils' describe('when transforming third party items to be sent to a contract method', () => { @@ -426,3 +427,59 @@ describe('when checking if an item is of emote type', () => { }) }) }) + +describe('when getting the representation main file for a body shape', () => { + let item: Item + + beforeEach(() => { + item = { + type: ItemType.WEARABLE, + data: { + representations: [ + { + bodyShapes: [BodyShape.MALE], + mainFile: 'male.glb', + contents: ['male.glb'], + overrideReplaces: [], + overrideHides: [] + }, + { + bodyShapes: [BodyShape.FEMALE], + mainFile: 'female.glb', + contents: ['female.glb'], + overrideReplaces: [], + overrideHides: [] + } + ] + } + } as unknown as Item + }) + + describe('and the body shape has a matching representation', () => { + it('should return the mainFile for the matching body shape', () => { + expect(getRepresentationMainFile(item, BodyShape.FEMALE)).toBe('female.glb') + }) + }) + + describe('and the body shape does not have a matching representation', () => { + it('should fall back to the first representation mainFile', () => { + item.data.representations = [ + { + bodyShapes: [BodyShape.MALE], + mainFile: 'male.glb', + contents: ['male.glb'], + overrideReplaces: [], + overrideHides: [] + } as WearableRepresentation + ] + expect(getRepresentationMainFile(item, BodyShape.FEMALE)).toBe('male.glb') + }) + }) + + describe('and the item has no representations', () => { + it('should return null', () => { + item.data.representations = [] + expect(getRepresentationMainFile(item, BodyShape.MALE)).toBeNull() + }) + }) +}) diff --git a/src/modules/item/utils.ts b/src/modules/item/utils.ts index b76414700..12806fbc3 100644 --- a/src/modules/item/utils.ts +++ b/src/modules/item/utils.ts @@ -172,6 +172,12 @@ export function hasBodyShape(item: Item, bodyShape: BodyShape) { return item.data.representations.some(representation => representation.bodyShapes.includes(bodyShape)) } +export function getRepresentationMainFile(item: Item, bodyShape: BodyShape): string | null { + if (!Array.isArray(item.data.representations)) return null + const representation = item.data.representations.find(r => r.bodyShapes.includes(bodyShape)) + return representation?.mainFile ?? item.data.representations[0]?.mainFile ?? null +} + export function toWearableBodyShapeType(wearableBodyShape: BodyShape) { // wearableBodyShape looks like "urn:decentraland:off-chain:base-avatars:BaseMale" (BodyShape.MALE) and we just want the "BaseMale" part return decodeURN(wearableBodyShape).suffix as WearableBodyShapeType diff --git a/src/modules/translation/languages/en.json b/src/modules/translation/languages/en.json index 37aa28efc..490a9c95a 100644 --- a/src/modules/translation/languages/en.json +++ b/src/modules/translation/languages/en.json @@ -2201,6 +2201,22 @@ "optional": "optional", "randomize_outcomes": "Randomize outcomes", "add_outcome": "Add an outcome" + }, + "spring_bones": { + "title": "Spring Bones", + "stiffness": "Stiffness Force", + "gravity_power": "Gravity Power", + "gravity_dir": "Gravity Direction", + "drag_force": "Drag Force", + "center": "Center (Optional)", + "center_none": "None", + "actions": { + "select": "Select", + "add_bone": "Add a Bone", + "remove_bone": "Remove Bone", + "copy_params": "Copy Parameters", + "paste_params": "Paste Parameters" + } } } }, diff --git a/src/modules/translation/languages/es.json b/src/modules/translation/languages/es.json index 7db9e68aa..9c933c4f4 100644 --- a/src/modules/translation/languages/es.json +++ b/src/modules/translation/languages/es.json @@ -2219,6 +2219,22 @@ "optional": "opcional", "randomize_outcomes": "Aleatorizar opciones", "add_outcome": "Agregar una opción" + }, + "spring_bones": { + "title": "Huesos de Resorte", + "stiffness": "Fuerza de Rigidez", + "gravity_power": "Fuerza de Gravedad", + "gravity_dir": "Dirección de Gravedad", + "drag_force": "Fuerza de Arrastre", + "center": "Centro (Opcional)", + "center_none": "Ninguno", + "actions": { + "select": "Seleccionar", + "add_bone": "Agregar un Hueso", + "remove_bone": "Eliminar Hueso", + "copy_params": "Copiar Parámetros", + "paste_params": "Pegar Parámetros" + } } } }, diff --git a/src/modules/translation/languages/zh.json b/src/modules/translation/languages/zh.json index bb95e80b1..5bc07b6bd 100644 --- a/src/modules/translation/languages/zh.json +++ b/src/modules/translation/languages/zh.json @@ -1108,7 +1108,10 @@ "title": "您的视频时长超过 {max_duration} 秒。", "message": "请减少持续时间并重试。" }, - "invalid_video": { "title": "您的视频似乎无效或已损坏。", "message": "请检查您的文件并重试。" } + "invalid_video": { + "title": "您的视频似乎无效或已损坏。", + "message": "请检查您的文件并重试。" + } } }, "itemdrawer": { @@ -2200,6 +2203,22 @@ "optional": "可选", "randomize_outcomes": "随机化结果", "add_outcome": "添加结果" + }, + "spring_bones": { + "title": "弹簧骨", + "stiffness": "刚度力", + "gravity_power": "重力强度", + "gravity_dir": "重力方向", + "drag_force": "阻力", + "center": "中心(可选)", + "center_none": "无", + "actions": { + "select": "选择", + "add_bone": "添加骨骼", + "remove_bone": "移除骨骼", + "copy_params": "复制参数", + "paste_params": "粘贴参数" + } } } },