diff --git a/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.css b/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.css index 167a43ed4..b41612230 100644 --- a/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.css +++ b/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.css @@ -1,3 +1,13 @@ +/* Section */ + +.spring-bones-header-label { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding-right: 10px; +} + .spring-bones-section { margin-top: 12px; display: flex; @@ -6,6 +16,28 @@ --base-gray: #5e5b67; } +.bone-icon { + width: 12px; + height: 12px; + background: url('../../../../icons/bone.svg') no-repeat; + object-fit: contain; +} + +/* Spring Bone Counter */ + +.spring-bones-counter { + font-size: 12px; + color: var(--secondary-text); + margin-left: auto; + display: flex; + align-items: center; + gap: 2px; +} + +.spring-bones-counter .bone-icon { + opacity: 0.3; +} + /* Spring Bone Card */ .dcl.box.spring-bone-card { @@ -148,9 +180,11 @@ } /* Bone Hierarchy Picker */ -.bone-hierarchy-picker { +.bone-hierarchy-picker.MuiPaper-root { overflow: auto; - width: calc(var(--item-editor-panel-width) - 16px); + min-width: calc(var(--item-editor-panel-width) - 16px); + max-width: calc(var(--item-editor-panel-width) * 1.5); + width: max-content; height: 430px; padding: 8px 0; border-radius: 8px; @@ -168,7 +202,7 @@ } .bone-tree-node.disabled { - color: #a0a0a0; + color: #a09ba8; } .bone-tree-node.children { @@ -179,6 +213,14 @@ background: rgba(0, 0, 0, 0.15); } +.bone-tree-node .bone-icon { + margin: 0 2px; +} + +.bone-tree-node.disabled .bone-icon { + opacity: 0.4; +} + .bone-tree-chevron.Icon { flex-shrink: 0; margin: 2px 5px; @@ -203,6 +245,12 @@ min-width: 100px; } +.bone-tree-name .children-count { + font-size: 11px; + color: #a09ba8; + margin-left: 4px; +} + .bone-tree-checkmark { margin-left: auto; flex-shrink: 0; diff --git a/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.tsx b/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.tsx index 450716607..8695640a7 100644 --- a/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.tsx +++ b/src/components/ItemEditorPage/RightPanel/SpringBonesSection/SpringBonesSection.tsx @@ -4,6 +4,17 @@ 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 { + MAX_SPRING_BONES, + SPRING_BONE_STIFFNESS_MIN, + SPRING_BONE_STIFFNESS_MAX, + SPRING_BONE_GRAVITY_POWER_MIN, + SPRING_BONE_GRAVITY_POWER_MAX, + SPRING_BONE_DRAG_MIN, + SPRING_BONE_DRAG_MAX, + SPRING_BONE_GRAVITY_DIR_MIN, + SPRING_BONE_GRAVITY_DIR_MAX +} from 'lib/glbValidation/constants' import Collapsable from 'components/Collapsable' import Icon from 'components/Icon' import CheckIcon from 'icons/check.svg' @@ -20,8 +31,10 @@ function BoneTreeNode({ expanded, onToggle, disableType, + boneSubtreeSizes, selected, - onSelect + onSelect, + selectLabel }: { bone: BoneNode boneMap: Map @@ -29,6 +42,8 @@ function BoneTreeNode({ expanded: Set selected?: Set disableType?: BoneNode['type'] + boneSubtreeSizes?: Map + selectLabel?: string onToggle: (nodeId: number) => void onSelect: (bone: BoneNode) => void }) { @@ -45,8 +60,12 @@ function BoneTreeNode({ onClick={hasChildren ? () => onToggle(bone.nodeId) : undefined} > + {bone.type === 'spring' && } {bone.name} + {bone.type === 'spring' && boneSubtreeSizes && (boneSubtreeSizes.get(bone.name) ?? 0) > 1 && ( + ({boneSubtreeSizes.get(bone.name)}) + )} {isSelected && Already added} {!isDisabled && ( @@ -58,7 +77,7 @@ function BoneTreeNode({ onSelect(bone) }} > - {t('item_editor.right_panel.spring_bones.actions.select')} + {selectLabel || t('item_editor.right_panel.spring_bones.actions.select')} )} @@ -75,6 +94,8 @@ function BoneTreeNode({ depth={depth + 1} expanded={expanded} selected={selected} + selectLabel={selectLabel} + boneSubtreeSizes={boneSubtreeSizes} disableType={disableType} onToggle={onToggle} onSelect={onSelect} @@ -91,6 +112,8 @@ function BoneHierarchyPicker({ selected, disableType, anchorEl, + boneSubtreeSizes, + selectLabel, onSelect, onClose }: { @@ -99,6 +122,8 @@ function BoneHierarchyPicker({ selected?: Set disableType?: BoneNode['type'] anchorEl: HTMLElement | null + boneSubtreeSizes?: Map + selectLabel?: string onSelect: (bone: BoneNode) => void onClose: () => void }) { @@ -142,7 +167,9 @@ function BoneHierarchyPicker({ depth={0} expanded={expanded} onToggle={handleToggle} + boneSubtreeSizes={boneSubtreeSizes} selected={selected} + selectLabel={selectLabel} disableType={disableType} onSelect={onSelect} /> @@ -151,6 +178,16 @@ function BoneHierarchyPicker({ ) } +function BonesCounter({ count, limit }: { count: number; limit?: number }) { + return ( + + + {count} + {limit !== undefined && `/${limit}`} + + ) +} + function InputNumber({ max, min, value, onChange }: { max?: number; min?: number; value: number; onChange: (value: number) => void }) { const [localValue, setLocalValue] = useState(`${value}`) @@ -298,6 +335,7 @@ function CenterDropdown({ function SpringBoneCard({ nodeName, params, + boneCount, allBones, onParamChange, onDelete, @@ -306,6 +344,7 @@ function SpringBoneCard({ }: { nodeName: string params: SpringBoneParams + boneCount: number allBones: BoneNode[] onParamChange: (field: keyof SpringBoneParams, value: SpringBoneParams[typeof field]) => void onDelete: () => void @@ -321,6 +360,7 @@ function SpringBoneCard({
setIsExpanded(prev => !prev)}>
{nodeName}
+ } inline direction="left"> @@ -341,31 +381,31 @@ function SpringBoneCard({ onParamChange('stiffness', v)} /> onParamChange('gravityPower', v)} /> onParamChange('gravityDir', v)} /> onParamChange('drag', v)} /> @@ -394,19 +434,42 @@ export default function SpringBonesSection({ const springBones: SpringBoneNode[] = useMemo(() => bones.filter(b => b.type === 'spring'), [bones]) const selectedSpringBoneNames: Set = useMemo(() => new Set(Object.keys(springBoneParams)), [springBoneParams]) + /** Map of spring bone name to the count of bones in its subtree (simulation cost) */ + const boneSubtreeSizes: Map = useMemo(() => { + const boneMap = new Map(bones.map(b => [b.nodeId, b])) + + function countSubtreeSize(nodeId: number): number { + const bone = boneMap.get(nodeId) + if (!bone) return 0 + let count = 1 + for (const childId of bone.children) { + count += countSubtreeSize(childId) + } + return count + } + + return new Map(springBones.map(b => [b.name, countSubtreeSize(b.nodeId)])) + }, [bones, springBones]) + /** 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) + const springBoneNameToNodeId = new Map(springBones.map(bone => [bone.name, bone.nodeId])) 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. + const nodeIdA = springBoneNameToNodeId.get(boneNameA) ?? 0 + const nodeIdB = springBoneNameToNodeId.get(boneNameB) ?? 0 + return nodeIdA - nodeIdB // Sort by node ID ascending (root bones have lower IDs). }) }, [springBoneParams]) + const configuredBonesCount = useMemo(() => { + let total = 0 + for (const name of Object.keys(springBoneParams)) { + total += boneSubtreeSizes.get(name) ?? 1 + } + return total + }, [springBoneParams, boneSubtreeSizes]) + const canAddMore = sortedSpringBoneParams.length < springBones.length && configuredBonesCount < MAX_SPRING_BONES + const handleSelectBone = useCallback( (bone: BoneNode) => { onAddSpringBoneParams(bone.name) @@ -428,13 +491,21 @@ export default function SpringBonesSection({ if (springBones.length === 0) return null return ( - + + {t('item_editor.right_panel.spring_bones.title')} + + + } + >
{sortedSpringBoneParams.map(([nodeName, params]) => ( onParamChange(nodeName, field, value)} onDelete={() => onDeleteSpringBoneParams(nodeName)} @@ -442,7 +513,7 @@ export default function SpringBonesSection({ onPaste={copiedParams ? () => handlePasteParams(nodeName) : null} /> ))} - {sortedSpringBoneParams.length < springBones.length && ( + {canAddMore && (