Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -168,7 +202,7 @@
}

.bone-tree-node.disabled {
color: #a0a0a0;
color: #a09ba8;
}

.bone-tree-node.children {
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -20,15 +31,19 @@ function BoneTreeNode({
expanded,
onToggle,
disableType,
boneSubtreeSizes,
selected,
onSelect
onSelect,
selectLabel
}: {
bone: BoneNode
boneMap: Map<number, BoneNode>
depth: number
expanded: Set<number>
selected?: Set<string>
disableType?: BoneNode['type']
boneSubtreeSizes?: Map<string, number>
selectLabel?: string
onToggle: (nodeId: number) => void
onSelect: (bone: BoneNode) => void
}) {
Expand All @@ -45,8 +60,12 @@ function BoneTreeNode({
onClick={hasChildren ? () => onToggle(bone.nodeId) : undefined}
>
<Icon name="chevron-right" className={classNames('bone-tree-chevron', { expanded: isExpanded, hidden: !hasChildren })} />
{bone.type === 'spring' && <span className="bone-icon" />}
<span className="bone-tree-name" title={bone.name}>
{bone.name}
{bone.type === 'spring' && boneSubtreeSizes && (boneSubtreeSizes.get(bone.name) ?? 0) > 1 && (
<span className="children-count">({boneSubtreeSizes.get(bone.name)})</span>
)}
</span>
{isSelected && <img src={CheckIcon} className="bone-tree-checkmark" alt="Already added" />}
{!isDisabled && (
Expand All @@ -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')}
</Button>
)}
</div>
Expand All @@ -75,6 +94,8 @@ function BoneTreeNode({
depth={depth + 1}
expanded={expanded}
selected={selected}
selectLabel={selectLabel}
boneSubtreeSizes={boneSubtreeSizes}
disableType={disableType}
onToggle={onToggle}
onSelect={onSelect}
Expand All @@ -91,6 +112,8 @@ function BoneHierarchyPicker({
selected,
disableType,
anchorEl,
boneSubtreeSizes,
selectLabel,
onSelect,
onClose
}: {
Expand All @@ -99,6 +122,8 @@ function BoneHierarchyPicker({
selected?: Set<string>
disableType?: BoneNode['type']
anchorEl: HTMLElement | null
boneSubtreeSizes?: Map<string, number>
selectLabel?: string
onSelect: (bone: BoneNode) => void
onClose: () => void
}) {
Expand Down Expand Up @@ -142,7 +167,9 @@ function BoneHierarchyPicker({
depth={0}
expanded={expanded}
onToggle={handleToggle}
boneSubtreeSizes={boneSubtreeSizes}
selected={selected}
selectLabel={selectLabel}
disableType={disableType}
onSelect={onSelect}
/>
Expand All @@ -151,6 +178,16 @@ function BoneHierarchyPicker({
)
}

function BonesCounter({ count, limit }: { count: number; limit?: number }) {
return (
<span className="spring-bones-counter">
<span className="bone-icon" />
{count}
{limit !== undefined && `/${limit}`}
</span>
)
}

function InputNumber({ max, min, value, onChange }: { max?: number; min?: number; value: number; onChange: (value: number) => void }) {
const [localValue, setLocalValue] = useState(`${value}`)

Expand Down Expand Up @@ -298,6 +335,7 @@ function CenterDropdown({
function SpringBoneCard({
nodeName,
params,
boneCount,
allBones,
onParamChange,
onDelete,
Expand All @@ -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
Expand All @@ -321,6 +360,7 @@ function SpringBoneCard({
<Header className="spring-bone-card-header" onClick={() => setIsExpanded(prev => !prev)}>
<Icon name="chevron-right" className="spring-bone-chevron" />
<h6 className="spring-bone-name">{nodeName}</h6>
<BonesCounter count={boneCount} />

<Dropdown trigger={<img src={MenuIcon} className="spring-bone-card-menu" alt="Actions" />} inline direction="left">
<Dropdown.Menu>
Expand All @@ -341,31 +381,31 @@ function SpringBoneCard({
<SliderInput
label={t('item_editor.right_panel.spring_bones.stiffness')}
value={params.stiffness}
min={0}
max={5}
min={SPRING_BONE_STIFFNESS_MIN}
max={SPRING_BONE_STIFFNESS_MAX}
step={0.01}
onChange={v => onParamChange('stiffness', v)}
/>
<SliderInput
label={t('item_editor.right_panel.spring_bones.gravity_power')}
value={params.gravityPower}
min={0}
max={10}
min={SPRING_BONE_GRAVITY_POWER_MIN}
max={SPRING_BONE_GRAVITY_POWER_MAX}
step={0.01}
onChange={v => onParamChange('gravityPower', v)}
/>
<Vec3Input
label={t('item_editor.right_panel.spring_bones.gravity_dir')}
value={params.gravityDir}
min={-10}
max={10}
min={SPRING_BONE_GRAVITY_DIR_MIN}
max={SPRING_BONE_GRAVITY_DIR_MAX}
onChange={v => onParamChange('gravityDir', v)}
/>
<SliderInput
label={t('item_editor.right_panel.spring_bones.drag_force')}
value={params.drag}
min={0}
max={1}
min={SPRING_BONE_DRAG_MIN}
max={SPRING_BONE_DRAG_MAX}
step={0.01}
onChange={v => onParamChange('drag', v)}
/>
Expand Down Expand Up @@ -394,19 +434,42 @@ export default function SpringBonesSection({
const springBones: SpringBoneNode[] = useMemo(() => bones.filter(b => b.type === 'spring'), [bones])
const selectedSpringBoneNames: Set<string> = 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<string, number> = useMemo(() => {
const boneMap = new Map<number, BoneNode>(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<string, number>)
const springBoneNameToNodeId = new Map<string, number>(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)
Expand All @@ -428,21 +491,29 @@ export default function SpringBonesSection({
if (springBones.length === 0) return null

return (
<Collapsable label={t('item_editor.right_panel.spring_bones.title')}>
<Collapsable
label={
<div className="spring-bones-header-label">
{t('item_editor.right_panel.spring_bones.title')}
<BonesCounter count={configuredBonesCount} limit={MAX_SPRING_BONES} />
</div>
}
>
<div className="spring-bones-section">
{sortedSpringBoneParams.map(([nodeName, params]) => (
<SpringBoneCard
key={nodeName}
nodeName={nodeName}
params={params}
boneCount={boneSubtreeSizes.get(nodeName) ?? 1}
allBones={bones}
onParamChange={(field, value) => onParamChange(nodeName, field, value)}
onDelete={() => onDeleteSpringBoneParams(nodeName)}
onCopy={() => setCopiedParams({ ...params })}
onPaste={copiedParams ? () => handlePasteParams(nodeName) : null}
/>
))}
{sortedSpringBoneParams.length < springBones.length && (
{canAddMore && (
<div className="spring-bone-add-box">
<Button
className="spring-bone-add-button"
Expand All @@ -459,6 +530,8 @@ export default function SpringBonesSection({
anchorEl={pickerAnchorRef.current}
selected={selectedSpringBoneNames}
disableType="avatar"
selectLabel={t('item_editor.right_panel.spring_bones.actions.add')}
boneSubtreeSizes={boneSubtreeSizes}
onSelect={handleSelectBone}
onClose={() => setShowBonePicker(false)}
/>
Expand Down
7 changes: 7 additions & 0 deletions src/icons/bone.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions src/lib/glbValidation/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,17 @@ export const PROP_ARMATURE_NAME = 'Armature_Prop'

/** Accepted audio file extensions for emote sound effects. */
export const VALID_AUDIO_EXTENSIONS = ['.mp3', '.ogg']

/** Maximum number of spring bones allowed per wearable representation. */
export const MAX_SPRING_BONES = 15

/** Spring bone parameter ranges (from @dcl/schemas). */
export const SPRING_BONE_STIFFNESS_MIN = 0
export const SPRING_BONE_STIFFNESS_MAX = 5
export const SPRING_BONE_GRAVITY_POWER_MIN = 0
export const SPRING_BONE_GRAVITY_POWER_MAX = 10
export const SPRING_BONE_DRAG_MIN = 0
export const SPRING_BONE_DRAG_MAX = 1
export const SPRING_BONE_GRAVITY_DIR_LENGTH = 3
export const SPRING_BONE_GRAVITY_DIR_MIN = -10
export const SPRING_BONE_GRAVITY_DIR_MAX = 10
Loading
Loading