diff --git a/packages/@dcl/inspector/package-lock.json b/packages/@dcl/inspector/package-lock.json
index 48414d615..e2137cc04 100644
--- a/packages/@dcl/inspector/package-lock.json
+++ b/packages/@dcl/inspector/package-lock.json
@@ -8,7 +8,7 @@
"name": "@dcl/inspector",
"version": "0.1.0",
"dependencies": {
- "@dcl/asset-packs": "^2.1.1",
+ "@dcl/asset-packs": "^2.1.2",
"ts-deepmerge": "^7.0.0"
},
"devDependencies": {
@@ -294,9 +294,9 @@
"integrity": "sha512-IOur6rSK5vN/oUpfawW6ax6vXPeADPCB44WNudeIYEYER7kwT2akNKUCLLjR19cLo006i/dkdt6UsTQ677uMxA=="
},
"node_modules/@dcl/asset-packs": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/@dcl/asset-packs/-/asset-packs-2.1.1.tgz",
- "integrity": "sha512-DgcRbGODLPxBTw2O6BN4vNBVEwhiDBvuCR6tSIjladb7bqQ5PWZbL/OQX4Ok2V1++gTnNPuaIrXId/ryGaTaKg==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@dcl/asset-packs/-/asset-packs-2.1.2.tgz",
+ "integrity": "sha512-Zwi7EMl0XfQ6JLkytIVk8nFC1fa8uzWMo7K4HAaO8bZSWbkMbp71vaFVyg8uA18dIBukYwrHfF0Loe4z5PA56Q==",
"license": "ISC",
"dependencies": {
"@dcl-sdk/utils": "^1.2.8",
diff --git a/packages/@dcl/inspector/package.json b/packages/@dcl/inspector/package.json
index 173866572..82d3b20d1 100644
--- a/packages/@dcl/inspector/package.json
+++ b/packages/@dcl/inspector/package.json
@@ -2,7 +2,7 @@
"name": "@dcl/inspector",
"version": "0.1.0",
"dependencies": {
- "@dcl/asset-packs": "^2.1.1",
+ "@dcl/asset-packs": "^2.1.2",
"ts-deepmerge": "^7.0.0"
},
"devDependencies": {
diff --git a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx
index fdca09d11..1ceb719e0 100644
--- a/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx
+++ b/packages/@dcl/inspector/src/components/AssetPreview/AssetPreview.tsx
@@ -13,13 +13,13 @@ import { useRef } from 'react'
const WIDTH = 300
const HEIGHT = 300
-export function AssetPreview({ value, onScreenshot }: Props) {
+export function AssetPreview({ value, resources, onScreenshot, onLoad }: Props) {
return (
{isGltf(value.name) ? (
-
+
) : value.name.endsWith('png') ? (
-
+
) : (
)}
@@ -27,26 +27,27 @@ export function AssetPreview({ value, onScreenshot }: Props) {
)
}
-function GltfPreview({ value, onScreenshot }: Props) {
- const onLoad = React.useCallback(() => {
+function GltfPreview({ value, resources, onScreenshot, onLoad }: Props) {
+ const handleLoad = React.useCallback(() => {
+ onLoad?.()
const wp = WearablePreview.createController(value.name)
void wp.scene.getScreenshot(WIDTH, HEIGHT).then(($) => onScreenshot($))
- }, [])
+ }, [onLoad])
return (
)
}
-function PngPreview({ value, onScreenshot }: Props) {
+function PngPreview({ value, onScreenshot, onLoad }: Props) {
const canvasRef = useRef
(null)
const url = URL.createObjectURL(value)
@@ -54,6 +55,7 @@ function PngPreview({ value, onScreenshot }: Props) {
img.src = url
img.onload = () => {
+ onLoad?.()
const canvas = canvasRef.current
const ctx = canvasRef.current?.getContext('2d')
const canvas2 = document.createElement('canvas')
diff --git a/packages/@dcl/inspector/src/components/AssetPreview/types.ts b/packages/@dcl/inspector/src/components/AssetPreview/types.ts
index 04bc31c61..bffc841ad 100644
--- a/packages/@dcl/inspector/src/components/AssetPreview/types.ts
+++ b/packages/@dcl/inspector/src/components/AssetPreview/types.ts
@@ -1,4 +1,6 @@
-export type Props = {
+export interface Props {
value: File
+ resources?: File[]
onScreenshot: (value: string) => void
+ onLoad?: () => void
}
diff --git a/packages/@dcl/inspector/src/components/AssetPreview/utils.ts b/packages/@dcl/inspector/src/components/AssetPreview/utils.ts
index ee5314e9f..f8f071258 100644
--- a/packages/@dcl/inspector/src/components/AssetPreview/utils.ts
+++ b/packages/@dcl/inspector/src/components/AssetPreview/utils.ts
@@ -1,6 +1,6 @@
import { BodyShape, WearableCategory, WearableWithBlobs } from '@dcl/schemas'
-export function toWearableWithBlobs(file: File): WearableWithBlobs {
+export function toWearableWithBlobs(file: File, resources: File[] = []): WearableWithBlobs {
return {
id: file.name,
name: '',
@@ -21,7 +21,11 @@ export function toWearableWithBlobs(file: File): WearableWithBlobs {
{
key: file.name,
blob: file
- }
+ },
+ ...resources.map((resource) => ({
+ key: resource.name,
+ blob: resource
+ }))
],
overrideHides: [],
overrideReplaces: []
diff --git a/packages/@dcl/inspector/src/components/Assets/Assets.css b/packages/@dcl/inspector/src/components/Assets/Assets.css
index 0176711f1..75db5e1de 100644
--- a/packages/@dcl/inspector/src/components/Assets/Assets.css
+++ b/packages/@dcl/inspector/src/components/Assets/Assets.css
@@ -31,6 +31,15 @@
width: 100%;
}
+.Assets .Assets-buttons .icon-custom-assets {
+ background-image: url('./custom-asset-icon.svg');
+ background-size: 16px 16px;
+ background-repeat: no-repeat;
+ background-position: center;
+ width: 16px;
+ height: 12px;
+}
+
.Assets .Assets-buttons > div > div::after {
content: '';
position: absolute;
diff --git a/packages/@dcl/inspector/src/components/Assets/Assets.tsx b/packages/@dcl/inspector/src/components/Assets/Assets.tsx
index 5766126e0..4921ff8aa 100644
--- a/packages/@dcl/inspector/src/components/Assets/Assets.tsx
+++ b/packages/@dcl/inspector/src/components/Assets/Assets.tsx
@@ -6,12 +6,17 @@ import { HiOutlinePlus } from 'react-icons/hi'
import { AssetPack, catalog, isSmart } from '../../lib/logic/catalog'
import { getConfig } from '../../lib/logic/config'
import { useAppDispatch, useAppSelector } from '../../redux/hooks'
+import { selectAssetToRename, selectStagedCustomAsset } from '../../redux/data-layer'
import { getSelectedAssetsTab, selectAssetsTab } from '../../redux/ui'
import { AssetsTab } from '../../redux/ui/types'
import { FolderOpen } from '../Icons/Folder'
import { AssetsCatalog } from '../AssetsCatalog'
import { ProjectAssetExplorer } from '../ProjectAssetExplorer'
import ImportAsset from '../ImportAsset'
+import { CustomAssets } from '../CustomAssets'
+import { selectCustomAssets } from '../../redux/app'
+import { RenameAsset } from '../RenameAsset'
+import { CreateCustomAsset } from '../CreateCustomAsset'
import './Assets.css'
@@ -25,6 +30,7 @@ function removeSmartItems(assetPack: AssetPack) {
function Assets({ isAssetsPanelCollapsed }: { isAssetsPanelCollapsed: boolean }) {
const dispatch = useAppDispatch()
const tab = useAppSelector(getSelectedAssetsTab)
+ const customAssets = useAppSelector(selectCustomAssets)
const handleTabClick = useCallback(
(tab: AssetsTab) => () => {
@@ -38,6 +44,9 @@ function Assets({ isAssetsPanelCollapsed }: { isAssetsPanelCollapsed: boolean })
? catalog.map(removeSmartItems).filter((assetPack) => assetPack.assets.length > 0)
: catalog
+ const assetToRename = useAppSelector(selectAssetToRename)
+ const stagedCustomAsset = useAppSelector(selectStagedCustomAsset)
+
return (
@@ -47,6 +56,14 @@ function Assets({ isAssetsPanelCollapsed }: { isAssetsPanelCollapsed: boolean })
LOCAL ASSETS
+ {customAssets.length > 0 ? (
+
+ ) : null}
@@ -63,6 +80,11 @@ function Assets({ isAssetsPanelCollapsed }: { isAssetsPanelCollapsed: boolean })
{tab === AssetsTab.AssetsPack &&
}
{tab === AssetsTab.FileSystem &&
}
{tab === AssetsTab.Import &&
}
+ {tab === AssetsTab.CustomAssets &&
}
+ {tab === AssetsTab.RenameAsset && assetToRename && (
+
+ )}
+ {tab === AssetsTab.CreateCustomAsset && stagedCustomAsset &&
}
)
diff --git a/packages/@dcl/inspector/src/components/Assets/custom-asset-icon.svg b/packages/@dcl/inspector/src/components/Assets/custom-asset-icon.svg
new file mode 100644
index 000000000..a93adb496
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/Assets/custom-asset-icon.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.css b/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.css
new file mode 100644
index 000000000..193b1d259
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.css
@@ -0,0 +1,67 @@
+.CreateCustomAsset {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+}
+
+.CreateCustomAsset .file-container {
+ display: flex;
+ gap: 16px;
+ padding: 16px;
+ align-items: flex-start;
+}
+
+.CreateCustomAsset .file-container svg {
+ width: 80px;
+ height: 80px;
+ padding: 8px;
+ border-radius: 8px;
+ background: var(--list-item-bg-color);
+}
+
+.CreateCustomAsset .preview-container {
+ width: 80px;
+ height: 80px;
+ border-radius: 8px;
+ background: var(--list-item-bg-color);
+ overflow: hidden;
+ position: relative;
+}
+
+.CreateCustomAsset .preview-container .AssetPreview {
+ width: 100%;
+ height: 100%;
+}
+
+.CreateCustomAsset .loader-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 1;
+}
+
+.CreateCustomAsset .column {
+ width: 300px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.CreateCustomAsset .button-container {
+ display: flex;
+ gap: 8px;
+}
+
+.CreateCustomAsset .create {
+ background-color: var(--primary);
+ color: var(--text-on-primary);
+}
diff --git a/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.tsx b/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.tsx
new file mode 100644
index 000000000..2f4762433
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.tsx
@@ -0,0 +1,142 @@
+import React, { useCallback, useState, useEffect } from 'react'
+import { Loader } from 'decentraland-ui/dist/components/Loader/Loader'
+import { Container } from '../Container'
+import { Block } from '../Block'
+import { TextField } from '../ui/TextField'
+import { Button } from '../Button'
+import { useAppDispatch, useAppSelector } from '../../redux/hooks'
+import { selectAssetsTab } from '../../redux/ui'
+import { AssetsTab } from '../../redux/ui/types'
+import {
+ clearStagedCustomAsset,
+ createCustomAsset,
+ getDataLayerInterface,
+ selectStagedCustomAsset
+} from '../../redux/data-layer'
+import { getResourcesFromModels } from '../../lib/babylon/decentraland/get-resources'
+import { useSdk } from '../../hooks/sdk/useSdk'
+import { AssetPreview } from '../AssetPreview'
+import CustomAssetIcon from '../Icons/CustomAsset'
+
+import './CreateCustomAsset.css'
+
+const CreateCustomAsset: React.FC = () => {
+ const dispatch = useAppDispatch()
+ const sdk = useSdk()
+ const stagedCustomAsset = useAppSelector(selectStagedCustomAsset)
+ const [name, setName] = useState(() => {
+ if (!stagedCustomAsset) return ''
+ return stagedCustomAsset.initialName
+ })
+ const [thumbnail, setThumbnail] = useState(null)
+ const [previewFile, setPreviewFile] = useState(null)
+ const [resources, setResources] = useState(null)
+ const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(true)
+
+ useEffect(() => {
+ const loadPreviewFile = async () => {
+ if (!sdk || !stagedCustomAsset) return
+ const asset = sdk.operations.createCustomAsset(stagedCustomAsset.entities)
+ if (!asset) return
+
+ // Find the first GLB/GLTF file in resources
+ const modelFile = asset.resources.find(
+ (path) => path.toLowerCase().endsWith('.glb') || path.toLowerCase().endsWith('.gltf')
+ )
+ if (!modelFile) return
+
+ try {
+ const dataLayer = getDataLayerInterface()
+ if (!dataLayer) return
+ const { content } = await dataLayer.getFile({ path: modelFile })
+ const resourcesFromModel = await getResourcesFromModels([modelFile])
+ const files: File[] = await Promise.all(
+ resourcesFromModel.map(async (path) => {
+ const { content } = await dataLayer.getFile({ path })
+ return new File([content], path.split('/').pop() || 'model', { type: 'model/gltf-binary' })
+ })
+ )
+ setPreviewFile(new File([content], modelFile.split('/').pop() || 'model', { type: 'model/gltf-binary' }))
+ setResources(files)
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('Failed to load preview file:', error)
+ }
+ }
+ void loadPreviewFile()
+ }, [sdk, stagedCustomAsset])
+
+ const handleNameChange = useCallback((event: React.ChangeEvent) => {
+ setName(event.target.value)
+ }, [])
+
+ const handleCreate = useCallback(() => {
+ if (!sdk || !stagedCustomAsset) return
+ const asset = sdk.operations.createCustomAsset(stagedCustomAsset.entities)
+ if (asset) {
+ dispatch(createCustomAsset({ ...asset, name, thumbnail: thumbnail || undefined }))
+ dispatch(selectAssetsTab({ tab: AssetsTab.CustomAssets }))
+ }
+ }, [dispatch, sdk, stagedCustomAsset, name, thumbnail])
+
+ const handleCancel = useCallback(() => {
+ dispatch(clearStagedCustomAsset())
+ if (stagedCustomAsset) {
+ dispatch(selectAssetsTab({ tab: stagedCustomAsset.previousTab }))
+ }
+ }, [dispatch, stagedCustomAsset])
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ handleCreate()
+ } else if (event.key === 'Escape') {
+ event.preventDefault()
+ handleCancel()
+ }
+ },
+ [handleCreate, handleCancel]
+ )
+
+ const handleScreenshot = useCallback((value: string) => {
+ setIsGeneratingThumbnail(false)
+ setThumbnail(value)
+ }, [])
+
+ if (!stagedCustomAsset) return null
+
+ return (
+
+
+
+ {previewFile && resources !== null ? (
+
+ {isGeneratingThumbnail && (
+
+
+
+ )}
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default CreateCustomAsset
diff --git a/packages/@dcl/inspector/src/components/CreateCustomAsset/index.ts b/packages/@dcl/inspector/src/components/CreateCustomAsset/index.ts
new file mode 100644
index 000000000..8fa97b37d
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/CreateCustomAsset/index.ts
@@ -0,0 +1 @@
+export { default as CreateCustomAsset } from './CreateCustomAsset'
diff --git a/packages/@dcl/inspector/src/components/CustomAssets/ContextMenu/ContextMenu.ts b/packages/@dcl/inspector/src/components/CustomAssets/ContextMenu/ContextMenu.ts
new file mode 100644
index 000000000..e48877424
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/CustomAssets/ContextMenu/ContextMenu.ts
@@ -0,0 +1,19 @@
+import { contextMenu } from 'react-contexify'
+import 'react-contexify/dist/ReactContexify.css'
+
+export const CUSTOM_ASSETS_CONTEXT_MENU_ID = 'custom-assets-context-menu'
+
+export type CustomAssetContextMenuProps = {
+ assetId: string
+ onDelete: (assetId: string) => void
+ onRename: (assetId: string) => void
+}
+
+export function openCustomAssetContextMenu(event: React.MouseEvent, props: CustomAssetContextMenuProps) {
+ event.preventDefault()
+ contextMenu.show({
+ id: CUSTOM_ASSETS_CONTEXT_MENU_ID,
+ event,
+ props
+ })
+}
diff --git a/packages/@dcl/inspector/src/components/CustomAssets/ContextMenu/CustomAssetContextMenu.tsx b/packages/@dcl/inspector/src/components/CustomAssets/ContextMenu/CustomAssetContextMenu.tsx
new file mode 100644
index 000000000..4845c62e1
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/CustomAssets/ContextMenu/CustomAssetContextMenu.tsx
@@ -0,0 +1,27 @@
+import React from 'react'
+import { Item } from 'react-contexify'
+import { ContextMenu } from '../../ContexMenu/ContextMenu'
+import { CUSTOM_ASSETS_CONTEXT_MENU_ID, CustomAssetContextMenuProps } from './ContextMenu'
+
+export function CustomAssetContextMenu() {
+ return (
+
+ )
+}
diff --git a/packages/@dcl/inspector/src/components/CustomAssets/CustomAssetItem.tsx b/packages/@dcl/inspector/src/components/CustomAssets/CustomAssetItem.tsx
new file mode 100644
index 000000000..154f2aa2e
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/CustomAssets/CustomAssetItem.tsx
@@ -0,0 +1,20 @@
+import React from 'react'
+import { openCustomAssetContextMenu } from './ContextMenu/ContextMenu'
+
+type Props = {
+ assetId: string
+ onDelete: (assetId: string) => void
+ onRename: (assetId: string) => void
+}
+
+export function CustomAssetItem({ assetId, onDelete, onRename }: Props) {
+ const handleContextMenu = (event: React.MouseEvent) => {
+ openCustomAssetContextMenu(event, {
+ assetId,
+ onDelete,
+ onRename
+ })
+ }
+
+ return {/* Your custom asset item content */}
+}
diff --git a/packages/@dcl/inspector/src/components/CustomAssets/CustomAssets.css b/packages/@dcl/inspector/src/components/CustomAssets/CustomAssets.css
new file mode 100644
index 000000000..23f32759d
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/CustomAssets/CustomAssets.css
@@ -0,0 +1,50 @@
+.custom-assets {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ gap: 4px;
+ padding: 8px;
+ height: 100%;
+ overflow-y: auto;
+}
+
+.custom-asset-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+}
+
+.custom-asset-item-box {
+ width: 80px;
+ height: 80px;
+ padding: 8px;
+ border-radius: 8px;
+ background: var(--list-item-bg-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: grab;
+}
+
+.custom-asset-item-box:hover {
+ background: var(--list-item-hover-bg-color);
+}
+
+.custom-asset-item-box img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ border-radius: 4px;
+}
+
+.custom-asset-item-box svg {
+ width: 100%;
+ height: 100%;
+}
+
+.custom-asset-item-label {
+ font-size: 12px;
+ text-align: center;
+ word-break: break-word;
+ max-width: 100px;
+}
diff --git a/packages/@dcl/inspector/src/components/CustomAssets/CustomAssets.tsx b/packages/@dcl/inspector/src/components/CustomAssets/CustomAssets.tsx
new file mode 100644
index 000000000..540111b86
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/CustomAssets/CustomAssets.tsx
@@ -0,0 +1,79 @@
+import React, { useCallback } from 'react'
+import { useDrag } from 'react-dnd'
+
+import './CustomAssets.css'
+import { CustomAsset } from '../../lib/logic/catalog'
+import CustomAssetIcon from '../Icons/CustomAsset'
+import { useAppDispatch, useAppSelector } from '../../redux/hooks'
+import { selectCustomAssets } from '../../redux/app'
+import { DropTypesEnum } from '../../lib/sdk/drag-drop'
+import { CustomAssetContextMenu } from './ContextMenu/CustomAssetContextMenu'
+import { openCustomAssetContextMenu } from './ContextMenu/ContextMenu'
+import { deleteCustomAsset, setAssetToRename } from '../../redux/data-layer'
+import { AssetsTab } from '../../redux/ui/types'
+import { selectAssetsTab } from '../../redux/ui'
+
+interface CustomAssetItemProps {
+ value: CustomAsset
+ onDelete: (assetId: string) => void
+ onRename: (assetId: string) => void
+}
+
+const CustomAssetItem: React.FC = ({ value, onDelete, onRename }) => {
+ const [, drag] = useDrag(
+ () => ({
+ type: DropTypesEnum.CustomAsset,
+ item: { value }
+ }),
+ [value]
+ )
+
+ const handleContextMenu = (event: React.MouseEvent) => {
+ openCustomAssetContextMenu(event, {
+ assetId: value.id,
+ onDelete,
+ onRename
+ })
+ }
+
+ return (
+ <>
+
+
+ {value.thumbnail ?
:
}
+
+
{value.name}
+
+ >
+ )
+}
+
+export function CustomAssets() {
+ const customAssets = useAppSelector(selectCustomAssets)
+ const dispatch = useAppDispatch()
+
+ const handleDelete = useCallback((assetId: string) => {
+ dispatch(deleteCustomAsset({ assetId }))
+ }, [])
+
+ const handleRename = useCallback(
+ (assetId: string) => {
+ const asset = customAssets.find((asset) => asset.id === assetId)
+ if (!asset) return
+ dispatch(setAssetToRename({ assetId: asset.id, name: asset.name }))
+ dispatch(selectAssetsTab({ tab: AssetsTab.RenameAsset }))
+ },
+ [customAssets, dispatch]
+ )
+
+ return (
+
+
+ {customAssets.map((asset) => (
+
+ ))}
+
+ )
+}
+
+export default React.memo(CustomAssets)
diff --git a/packages/@dcl/inspector/src/components/CustomAssets/index.ts b/packages/@dcl/inspector/src/components/CustomAssets/index.ts
new file mode 100644
index 000000000..853580744
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/CustomAssets/index.ts
@@ -0,0 +1,2 @@
+import CustomAssets from './CustomAssets'
+export { CustomAssets }
diff --git a/packages/@dcl/inspector/src/components/EntityInspector/EntityHeader/EntityHeader.css b/packages/@dcl/inspector/src/components/EntityInspector/EntityHeader/EntityHeader.css
index e19d530ed..3adbc84c6 100644
--- a/packages/@dcl/inspector/src/components/EntityInspector/EntityHeader/EntityHeader.css
+++ b/packages/@dcl/inspector/src/components/EntityInspector/EntityHeader/EntityHeader.css
@@ -6,23 +6,30 @@
border-bottom: 1px solid var(--border-gray);
display: flex;
- flex-direction: row;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 16px;
+}
+
+.EntityHeader .Title {
+ flex: auto;
+ display: flex;
align-items: center;
gap: 4px;
}
-.EntityHeader .title {
+.EntityHeader .TitleWrapper {
display: flex;
- align-items: center;
+ flex-direction: row;
width: 100%;
}
-.EntityHeader .title > svg {
+.EntityHeader .Title > svg {
margin-left: 5px;
cursor: pointer;
}
-.EntityHeader > .RightContent {
+.EntityHeader .RightContent {
display: flex;
flex-grow: 1;
justify-content: flex-end;
@@ -30,7 +37,7 @@
font-size: 14px;
}
-.EntityHeader > .RightContent > .Dropdown.Trigger > .DropdownContainer.AddComponent > .OptionList {
+.EntityHeader .RightContent > .Dropdown.Trigger > .DropdownContainer.AddComponent > .OptionList {
min-width: 155px;
white-space: nowrap;
right: 0;
@@ -38,12 +45,12 @@
max-height: max-content;
}
-.EntityHeader > .RightContent > .Dropdown.Trigger > .DropdownContainer.AddComponent {
+.EntityHeader .RightContent > .Dropdown.Trigger > .DropdownContainer.AddComponent {
background-color: transparent;
border-color: transparent;
}
-.EntityHeader > .RightContent > .MoreOptionsMenu > .MoreOptionsContent {
+.EntityHeader .RightContent > .MoreOptionsMenu > .MoreOptionsContent {
min-width: 190px;
}
@@ -121,3 +128,36 @@
.EntityHeader.ModalOverlay .ToggleBasicViewModal .ModalBody .ModalActions .Button.primary {
background-color: var(--accent-blue-07);
}
+
+.EntityHeader .subtitle {
+ font-size: 12px;
+ color: #999;
+ margin-top: 2px;
+}
+
+.EntityHeader .InstanceOf {
+ width: 100%;
+ font-size: 11px;
+ font-weight: 400;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 4px;
+}
+
+.EntityHeader .InstanceOf .content {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.EntityHeader .InstanceOf .Chip {
+ font-weight: 500;
+ background-color: var(--base-14);
+ padding: 4px 8px;
+ border-radius: 4px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 4px;
+}
diff --git a/packages/@dcl/inspector/src/components/EntityInspector/EntityHeader/EntityHeader.tsx b/packages/@dcl/inspector/src/components/EntityInspector/EntityHeader/EntityHeader.tsx
index 58ef574d1..368c1e9d7 100644
--- a/packages/@dcl/inspector/src/components/EntityInspector/EntityHeader/EntityHeader.tsx
+++ b/packages/@dcl/inspector/src/components/EntityInspector/EntityHeader/EntityHeader.tsx
@@ -26,6 +26,10 @@ import MoreOptionsMenu from '../MoreOptionsMenu'
import { RemoveButton } from '../RemoveButton'
import './EntityHeader.css'
+import { useAppSelector } from '../../../redux/hooks'
+import { selectCustomAssets } from '../../../redux/app'
+import CustomAssetIcon from '../../Icons/CustomAsset'
+import { Container } from '../../Container'
interface ModalState {
isOpen: boolean
@@ -60,11 +64,19 @@ export default React.memo(
const [label, setLabel] = useState()
const [modal, setModal] = useState({ isOpen: false })
const [editMode, setEditMode] = useState(false)
+ const [instanceOf, setInstanceOf] = useState(null)
+ const customAssets = useAppSelector(selectCustomAssets)
useEffect(() => {
setLabel(getLabel(sdk, entity))
}, [sdk, entity])
+ useEffect(() => {
+ const customAssetId = sdk.components.CustomAsset.getOrNull(entity)?.assetId || null
+ const customAsset = customAssets.find((asset) => asset.id === customAssetId)
+ setInstanceOf(customAsset?.name || null)
+ }, [customAssets, sdk, entity])
+
const handleUpdate = (event: SdkContextEvents['change']) => {
if (event.entity === entity && event.component === sdk.components.Name) {
setLabel(getLabel(sdk, entity))
@@ -419,29 +431,41 @@ export default React.memo(
return (
-
- {!editMode ? (
- <>
- {label}
- {!editMode && !isRoot(entity) ? : null}
- >
- ) : typeof label === 'string' ? (
-
- ) : null}
-
-
- {componentOptions.some((option) => !option.header) ? (
-
} />
- ) : null}
- {!isRoot(entity) ? (
-
- {hasConfigComponent ? renderToggleAdvanceMode() : <>>}
-
- Delete Entity
-
-
- ) : null}
+
+
+ {instanceOf && }
+ {!editMode ? (
+ <>
+ {label}
+ {!editMode && !isRoot(entity) ? : null}
+ >
+ ) : typeof label === 'string' ? (
+
+ ) : null}
+
+
+ {componentOptions.some((option) => !option.header) ? (
+ } />
+ ) : null}
+ {!isRoot(entity) ? (
+
+ {hasConfigComponent ? renderToggleAdvanceMode() : <>>}
+
+ Delete Entity
+
+
+ ) : null}
+
+ {instanceOf && (
+
+ Instance of:
+
+
+ {instanceOf}
+
+
+ )}
(({ sdk, entity }) => {
(field: ConfigComponent['fields'][0], idx: number) => {
switch (field.type) {
case 'core::PointerEvents':
- return
+ return
case 'asset-packs::Actions':
- return
+ return
case 'asset-packs::Triggers':
- return
+ return
case 'core::Tween':
- return
+ return
case 'core::VideoPlayer':
- return
+ return
case 'core::NftShape':
- return
+ return
case 'asset-packs::Counter':
case 'asset-packs::CounterBar':
- return
+ return
default:
return null
}
diff --git a/packages/@dcl/inspector/src/components/Hierarchy/ContextMenu/ContextMenu.tsx b/packages/@dcl/inspector/src/components/Hierarchy/ContextMenu/ContextMenu.tsx
index ec899a546..bf589fe28 100644
--- a/packages/@dcl/inspector/src/components/Hierarchy/ContextMenu/ContextMenu.tsx
+++ b/packages/@dcl/inspector/src/components/Hierarchy/ContextMenu/ContextMenu.tsx
@@ -1,3 +1,4 @@
+import { useCallback } from 'react'
import { Item, Submenu, Separator } from 'react-contexify'
import { Entity } from '@dcl/ecs'
import { useContextMenu } from '../../../hooks/sdk/useContextMenu'
@@ -6,6 +7,13 @@ import { getComponentValue } from '../../../hooks/sdk/useComponentValue'
import { useSdk } from '../../../hooks/sdk/useSdk'
import { analytics, Event } from '../../../lib/logic/analytics'
import { getAssetByModel } from '../../../lib/logic/catalog'
+import CustomAssetIcon from '../../Icons/CustomAsset'
+import { useEntitiesWith } from '../../../hooks/sdk/useEntitiesWith'
+import { useAppDispatch, useAppSelector } from '../../../redux/hooks'
+import { stageCustomAsset } from '../../../redux/data-layer'
+import { getSelectedAssetsTab, selectAssetsTab } from '../../../redux/ui'
+import { AssetsTab } from '../../../redux/ui/types'
+import { useTree } from '../../../hooks/sdk/useTree'
const ContextMenu = (value: Entity) => {
const sdk = useSdk()
@@ -13,8 +21,35 @@ const ContextMenu = (value: Entity) => {
const { handleAction } = useContextMenu()
const components = getComponents(value, true)
const availableComponents = getAvailableComponents(value)
+ const selectedEntities = useEntitiesWith((components) => components.Selection)
+ const hasMultipleSelection = selectedEntities.length > 1
+ const dispatch = useAppDispatch()
+ const currentTab = useAppSelector(getSelectedAssetsTab)
+ const { select } = useTree()
- const handleAddComponent = (id: string) => {
+ const handleCreateCustomAsset = useCallback(async () => {
+ if (!sdk) return
+ // If not a multi-selection, ensure the right-clicked entity is selected
+ if (!hasMultipleSelection) {
+ await select(value)
+ }
+ const initialName = sdk.components.Name.get(value).value
+ dispatch(
+ stageCustomAsset({
+ entities: hasMultipleSelection ? selectedEntities : [value],
+ previousTab: currentTab,
+ initialName
+ })
+ )
+ dispatch(selectAssetsTab({ tab: AssetsTab.CreateCustomAsset }))
+ }, [selectedEntities, dispatch, currentTab, sdk, value, select, hasMultipleSelection])
+
+ const handleAddComponent = async (id: string) => {
+ // Only allow adding components when a single entity is selected
+ if (hasMultipleSelection) return
+
+ // Ensure the right-clicked entity is selected
+ await select(value)
addComponent(value, Number(id))
if (sdk) {
const gltfContainer = getComponentValue(value, sdk.components.GltfContainer)
@@ -27,18 +62,24 @@ const ContextMenu = (value: Entity) => {
}
}
- if (!availableComponents.length) return null
-
return (
<>
-
-
- {availableComponents.map(({ id, name }) => (
- -
- {name}
-
- ))}
-
+ -
+
+ Create Custom Item
+
+ {!hasMultipleSelection && availableComponents.length > 0 && (
+ <>
+
+
+ {availableComponents.map(({ id, name }) => (
+ -
+ {name}
+
+ ))}
+
+ >
+ )}
>
)
}
diff --git a/packages/@dcl/inspector/src/components/Hierarchy/Hierarchy.css b/packages/@dcl/inspector/src/components/Hierarchy/Hierarchy.css
index b0bee1934..b35bd64bb 100644
--- a/packages/@dcl/inspector/src/components/Hierarchy/Hierarchy.css
+++ b/packages/@dcl/inspector/src/components/Hierarchy/Hierarchy.css
@@ -22,6 +22,10 @@
background-image: url(./icons/camera.svg);
}
+.Hierarchy .custom-icon {
+ background-image: url(./icons/custom.svg);
+}
+
.Hierarchy .smart-icon {
background-image: url(./icons/smart.svg);
}
diff --git a/packages/@dcl/inspector/src/components/Hierarchy/Hierarchy.tsx b/packages/@dcl/inspector/src/components/Hierarchy/Hierarchy.tsx
index 6bda74bf2..4fc4e6034 100644
--- a/packages/@dcl/inspector/src/components/Hierarchy/Hierarchy.tsx
+++ b/packages/@dcl/inspector/src/components/Hierarchy/Hierarchy.tsx
@@ -8,8 +8,11 @@ import { Tree } from '../Tree'
import { ContextMenu } from './ContextMenu'
import { withSdk } from '../../hoc/withSdk'
import './Hierarchy.css'
+import { useAppSelector } from '../../redux/hooks'
+import { selectCustomAssets } from '../../redux/app'
const HierarchyIcon = withSdk<{ value: Entity }>(({ sdk, value }) => {
+ const customAssets = useAppSelector(selectCustomAssets)
const isSmart = useMemo(
() =>
sdk.components.Actions.has(value) ||
@@ -21,6 +24,15 @@ const HierarchyIcon = withSdk<{ value: Entity }>(({ sdk, value }) => {
[sdk, value]
)
+ const isCustom = useMemo(() => {
+ if (sdk.components.CustomAsset.has(value)) {
+ const { assetId } = sdk.components.CustomAsset.get(value)
+ const customAsset = customAssets.find((asset) => asset.id === assetId)
+ return !!customAsset
+ }
+ return false
+ }, [sdk, value, customAssets])
+
const isTile = useMemo(() => sdk.components.Tile.has(value), [sdk, value])
const isGroup = useMemo(() => {
@@ -35,12 +47,14 @@ const HierarchyIcon = withSdk<{ value: Entity }>(({ sdk, value }) => {
return
} else if (value === CAMERA) {
return
+ } else if (isCustom) {
+ return
+ } else if (isGroup) {
+ return
} else if (isSmart) {
return
} else if (isTile) {
return
- } else if (isGroup) {
- return
} else {
return
}
diff --git a/packages/@dcl/inspector/src/components/Hierarchy/icons/custom.svg b/packages/@dcl/inspector/src/components/Hierarchy/icons/custom.svg
new file mode 100644
index 000000000..b1f22e394
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/Hierarchy/icons/custom.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/packages/@dcl/inspector/src/components/Icons/CustomAsset/CustomAsset.tsx b/packages/@dcl/inspector/src/components/Icons/CustomAsset/CustomAsset.tsx
new file mode 100644
index 000000000..a4d96a3f6
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/Icons/CustomAsset/CustomAsset.tsx
@@ -0,0 +1,16 @@
+import React from 'react'
+
+function CustomAssetIcon() {
+ return (
+
+ )
+}
+
+export default CustomAssetIcon
diff --git a/packages/@dcl/inspector/src/components/Icons/CustomAsset/index.ts b/packages/@dcl/inspector/src/components/Icons/CustomAsset/index.ts
new file mode 100644
index 000000000..d31975eed
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/Icons/CustomAsset/index.ts
@@ -0,0 +1,3 @@
+import CustomAssetIcon from './CustomAsset'
+
+export default CustomAssetIcon
diff --git a/packages/@dcl/inspector/src/components/RenameAsset/RenameAsset.css b/packages/@dcl/inspector/src/components/RenameAsset/RenameAsset.css
new file mode 100644
index 000000000..0ed76e8e2
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/RenameAsset/RenameAsset.css
@@ -0,0 +1,49 @@
+.RenameAsset {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ padding: 16px;
+}
+
+.RenameAsset .Container {
+ width: 100%;
+ max-width: 400px;
+}
+
+.RenameAsset .file-container {
+ display: flex;
+ flex-direction: row;
+ gap: 16px;
+}
+
+.RenameAsset .file-container .column {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.RenameAsset .Block {
+ width: 100%;
+}
+
+.RenameAsset .button-container {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-start;
+}
+
+.RenameAsset .file-container > svg {
+ grid-row: 1 / 3;
+ align-self: center;
+ width: 80px;
+ height: 80px;
+ padding: 8px;
+ border-radius: 8px;
+ background: var(--list-item-bg-color);
+}
+
+.RenameAsset .rename {
+ background: var(--primary);
+ color: var(--primary-text);
+}
diff --git a/packages/@dcl/inspector/src/components/RenameAsset/RenameAsset.tsx b/packages/@dcl/inspector/src/components/RenameAsset/RenameAsset.tsx
new file mode 100644
index 000000000..276c2a357
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/RenameAsset/RenameAsset.tsx
@@ -0,0 +1,59 @@
+import React, { useCallback, useState } from 'react'
+import { Container } from '../Container'
+import { Block } from '../Block'
+import { TextField } from '../ui/TextField'
+import { Button } from '../Button'
+import { useAppDispatch } from '../../redux/hooks'
+import { selectAssetsTab } from '../../redux/ui'
+import { AssetsTab } from '../../redux/ui/types'
+import { clearAssetToRename, renameCustomAsset } from '../../redux/data-layer'
+
+import './RenameAsset.css'
+import CustomAssetIcon from '../Icons/CustomAsset'
+
+interface PropTypes {
+ assetId: string
+ currentName: string
+}
+
+const RenameAsset: React.FC = ({ assetId, currentName }) => {
+ const dispatch = useAppDispatch()
+ const [name, setName] = useState(currentName)
+
+ const handleNameChange = useCallback((event: React.ChangeEvent) => {
+ setName(event.target.value)
+ }, [])
+
+ const handleSave = useCallback(() => {
+ dispatch(renameCustomAsset({ assetId, newName: name }))
+ dispatch(selectAssetsTab({ tab: AssetsTab.CustomAssets }))
+ }, [dispatch, assetId, name])
+
+ const handleCancel = useCallback(() => {
+ dispatch(clearAssetToRename())
+ dispatch(selectAssetsTab({ tab: AssetsTab.CustomAssets }))
+ }, [dispatch])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default RenameAsset
diff --git a/packages/@dcl/inspector/src/components/RenameAsset/index.ts b/packages/@dcl/inspector/src/components/RenameAsset/index.ts
new file mode 100644
index 000000000..300bf72c5
--- /dev/null
+++ b/packages/@dcl/inspector/src/components/RenameAsset/index.ts
@@ -0,0 +1,3 @@
+import RenameAsset from './RenameAsset'
+
+export { RenameAsset }
diff --git a/packages/@dcl/inspector/src/components/Renderer/Renderer.tsx b/packages/@dcl/inspector/src/components/Renderer/Renderer.tsx
index f9054a715..4a788f95a 100644
--- a/packages/@dcl/inspector/src/components/Renderer/Renderer.tsx
+++ b/packages/@dcl/inspector/src/components/Renderer/Renderer.tsx
@@ -7,8 +7,17 @@ import { Entity } from '@dcl/ecs'
import { DIRECTORY, withAssetDir } from '../../lib/data-layer/host/fs-utils'
import { useAppDispatch, useAppSelector } from '../../redux/hooks'
-import { getReloadAssets, importAsset, saveThumbnail } from '../../redux/data-layer'
-import { getNode, CatalogAssetDrop, DROP_TYPES, IDrop, LocalAssetDrop, isDropType } from '../../lib/sdk/drag-drop'
+import { getDataLayerInterface, getReloadAssets, importAsset, saveThumbnail } from '../../redux/data-layer'
+import {
+ getNode,
+ CatalogAssetDrop,
+ DROP_TYPES,
+ IDrop,
+ LocalAssetDrop,
+ isDropType,
+ DropTypesEnum,
+ CustomAssetDrop
+} from '../../lib/sdk/drag-drop'
import { useRenderer } from '../../hooks/sdk/useRenderer'
import { useSdk } from '../../hooks/sdk/useSdk'
import { getPointerCoords } from '../../lib/babylon/decentraland/mouse-utils'
@@ -16,7 +25,7 @@ import { snapPosition } from '../../lib/babylon/decentraland/snap-manager'
import { loadGltf, removeGltf } from '../../lib/babylon/decentraland/sdkComponents/gltf-container'
import { getConfig } from '../../lib/logic/config'
import { ROOT } from '../../lib/sdk/tree'
-import { Asset, isGround, isSmart } from '../../lib/logic/catalog'
+import { Asset, CustomAsset, isGround, isSmart } from '../../lib/logic/catalog'
import { selectAssetCatalog } from '../../redux/app'
import { areGizmosDisabled, getHiddenPanels, isGroundGridDisabled } from '../../redux/ui'
import { AssetNodeItem } from '../ProjectAssetExplorer/types'
@@ -158,13 +167,13 @@ const Renderer: React.FC = () => {
sdk.editorCamera.resetCamera()
}, [sdk])
- useHotkey([DELETE, BACKSPACE], deleteSelectedEntities, canvasRef.current)
- useHotkey([COPY, COPY_ALT], copySelectedEntities, canvasRef.current)
- useHotkey([PASTE, PASTE_ALT], pasteSelectedEntities, canvasRef.current)
- useHotkey([ZOOM_IN, ZOOM_IN_ALT], zoomIn, canvasRef.current)
- useHotkey([ZOOM_OUT, ZOOM_OUT_ALT], zoomOut, canvasRef.current)
- useHotkey([RESET_CAMERA], resetCamera, canvasRef.current)
- useHotkey([DUPLICATE, DUPLICATE_ALT], duplicateSelectedEntities, canvasRef.current)
+ useHotkey([DELETE, BACKSPACE], deleteSelectedEntities, document.body)
+ useHotkey([COPY, COPY_ALT], copySelectedEntities, document.body)
+ useHotkey([PASTE, PASTE_ALT], pasteSelectedEntities, document.body)
+ useHotkey([ZOOM_IN, ZOOM_IN_ALT], zoomIn, document.body)
+ useHotkey([ZOOM_OUT, ZOOM_OUT_ALT], zoomOut, document.body)
+ useHotkey([RESET_CAMERA], resetCamera, document.body)
+ useHotkey([DUPLICATE, DUPLICATE_ALT], duplicateSelectedEntities, document.body)
// listen to ctrl key to place single tile
useEffect(() => {
@@ -202,7 +211,7 @@ const Renderer: React.FC = () => {
return snapPosition(new Vector3(fixedNumber(pointerCoords.x), 0, fixedNumber(pointerCoords.z)))
}
- const addAsset = async (asset: AssetNodeItem, position: Vector3, basePath: string) => {
+ const addAsset = async (asset: AssetNodeItem, position: Vector3, basePath: string, isCustom: boolean) => {
if (!sdk) return
const { operations } = sdk
operations.addAsset(
@@ -213,14 +222,16 @@ const Renderer: React.FC = () => {
basePath,
sdk.enumEntity,
asset.composite,
- asset.asset.id
+ asset.asset.id,
+ isCustom
)
await operations.dispatch()
analytics.track(Event.ADD_ITEM, {
itemId: asset.asset.id,
itemName: asset.name,
itemPath: asset.asset.src,
- isSmart: isSmart(asset)
+ isSmart: isSmart(asset),
+ isCustom
})
canvasRef.current?.focus()
}
@@ -239,6 +250,57 @@ const Renderer: React.FC = () => {
canvasRef.current?.focus()
}
+ const importCustomAsset = async (asset: CustomAsset) => {
+ const destFolder = 'custom'
+ const assetPackageName = asset.name.trim().replaceAll(' ', '_').toLowerCase()
+ const position = await getDropPosition()
+ const content: Map = new Map()
+
+ const dataLayer = getDataLayerInterface()
+ if (!dataLayer) return
+
+ // Find the common base path from all resources
+ const customAssetBasePath = asset.resources.reduce((basePath, path) => {
+ const pathParts = path.split('/')
+ pathParts.pop() // Remove filename
+ const currentPath = pathParts.join('/')
+ if (!basePath) return currentPath
+
+ // Find common prefix between paths
+ const basePathParts = basePath.split('/')
+ const commonParts = []
+ for (let i = 0; i < basePathParts.length; i++) {
+ if (basePathParts[i] === pathParts[i]) {
+ commonParts.push(basePathParts[i])
+ } else {
+ break
+ }
+ }
+ return commonParts.join('/')
+ }, '')
+
+ const files = await Promise.all(
+ asset.resources.map(async (path) => ({
+ path: path.startsWith(customAssetBasePath) ? path.replace(customAssetBasePath, '') : path,
+ content: await dataLayer.getFile({ path }).then((res) => res.content)
+ }))
+ )
+ for (const file of files) {
+ content.set(file.path, file.content)
+ }
+ const model: AssetNodeItem = {
+ type: 'asset',
+ name: asset.name,
+ parent: null,
+ asset: { type: 'gltf', src: '', id: asset.id },
+ composite: asset.composite
+ }
+ const basePath = withAssetDir(`${destFolder}/${assetPackageName}`)
+
+ dispatch(importAsset({ content, basePath, assetPackageName: '', reload: true }))
+ await addAsset(model, position, basePath, true)
+ }
+
const importCatalogAsset = async (asset: Asset) => {
const position = await getDropPosition()
const fileContent: Record = {}
@@ -309,7 +371,7 @@ const Renderer: React.FC = () => {
if (isGround(asset)) {
position.y += 0.25
}
- await addAsset(model, position, basePath)
+ await addAsset(model, position, basePath, false)
}
}
@@ -320,22 +382,27 @@ const Renderer: React.FC = () => {
if (monitor.didDrop()) return
const itemType = monitor.getItemType()
- if (isDropType(item, itemType, 'catalog-asset')) {
+ if (isDropType(item, itemType, DropTypesEnum.CatalogAsset)) {
void importCatalogAsset(item.value)
return
}
- if (isDropType(item, itemType, 'local-asset')) {
+ if (isDropType(item, itemType, DropTypesEnum.LocalAsset)) {
const node = item.context.tree.get(item.value)!
const model = getNode(node, item.context.tree, isModel)
if (model) {
const position = await getDropPosition()
- await addAsset(model, position, DIRECTORY.ASSETS)
+ await addAsset(model, position, DIRECTORY.ASSETS, false)
}
}
+
+ if (isDropType(item, itemType, DropTypesEnum.CustomAsset)) {
+ void importCustomAsset(item.value)
+ return
+ }
},
hover(item, monitor) {
- if (isDropType(item, monitor.getItemType(), 'catalog-asset')) {
+ if (isDropType(item, monitor.getItemType(), DropTypesEnum.CatalogAsset)) {
const asset = item.value
if (isGround(asset)) {
if (!showSingleTileHint) {
diff --git a/packages/@dcl/inspector/src/components/Tree/Tree.tsx b/packages/@dcl/inspector/src/components/Tree/Tree.tsx
index f9123985d..65190aa30 100644
--- a/packages/@dcl/inspector/src/components/Tree/Tree.tsx
+++ b/packages/@dcl/inspector/src/components/Tree/Tree.tsx
@@ -10,6 +10,7 @@ import { ContextMenu } from './ContextMenu'
import { ActionArea } from './ActionArea'
import { Edit as EditInput } from './Edit'
import { ClickType, DropType, calculateDropType } from './utils'
+import { useSdk } from '../../hooks/sdk/useSdk'
import './Tree.css'
@@ -184,12 +185,39 @@ export function Tree() {
onSetOpen(value, true)
}
+ const sdk = useSdk()
const handleRemove = () => {
- onRemove(value)
+ if (isEntity && sdk) {
+ const selectedEntities = sdk.operations.getSelectedEntities()
+ if (selectedEntities.length > 1) {
+ selectedEntities.forEach((entity) => {
+ if (typeof entity === typeof value) {
+ onRemove(entity as T)
+ }
+ })
+ } else {
+ onRemove(value)
+ }
+ } else {
+ onRemove(value)
+ }
}
const handleDuplicate = () => {
- onDuplicate(value)
+ if (isEntity && sdk) {
+ const selectedEntities = sdk.operations.getSelectedEntities()
+ if (selectedEntities.length > 1) {
+ selectedEntities.forEach((entity) => {
+ if (typeof entity === typeof value) {
+ onDuplicate(entity as T)
+ }
+ })
+ } else {
+ onDuplicate(value)
+ }
+ } else {
+ onDuplicate(value)
+ }
}
const isEntity = useMemo(() => {
@@ -201,7 +229,7 @@ export function Tree() {
const controlsProps = {
id: contextMenuId,
enableAdd: enableAddChild,
- enableEdit: enableRename,
+ enableEdit: (enableRename && (!isEntity || (sdk && sdk.operations.getSelectedEntities().length < 2))) || false,
enableRemove,
enableDuplicate,
onAddChild: handleNewChild,
diff --git a/packages/@dcl/inspector/src/components/Warnings/SocketConnection/SocketConnection.tsx b/packages/@dcl/inspector/src/components/Warnings/SocketConnection/SocketConnection.tsx
index 55b5b5798..3132a9b2c 100644
--- a/packages/@dcl/inspector/src/components/Warnings/SocketConnection/SocketConnection.tsx
+++ b/packages/@dcl/inspector/src/components/Warnings/SocketConnection/SocketConnection.tsx
@@ -16,7 +16,10 @@ const mapError = {
[ErrorType.ImportAsset]: 'Failed to import new asset.',
[ErrorType.RemoveAsset]: 'Failed to remove asset.',
[ErrorType.SaveThumbnail]: 'Failed to save thumbnail.',
- [ErrorType.GetThumbnails]: 'Failed to get thumbnails.'
+ [ErrorType.GetThumbnails]: 'Failed to get thumbnails.',
+ [ErrorType.CreateCustomAsset]: 'Failed to create custom item.',
+ [ErrorType.DeleteCustomAsset]: 'Failed to delete custom item.',
+ [ErrorType.RenameCustomAsset]: 'Failed to rename custom item.'
}
const SocketConnection: React.FC = () => {
diff --git a/packages/@dcl/inspector/src/hooks/sdk/useCustomAsset.ts b/packages/@dcl/inspector/src/hooks/sdk/useCustomAsset.ts
new file mode 100644
index 000000000..32aa44fb1
--- /dev/null
+++ b/packages/@dcl/inspector/src/hooks/sdk/useCustomAsset.ts
@@ -0,0 +1,31 @@
+import { Entity } from '@dcl/ecs'
+import { useCallback } from 'react'
+import { useDispatch } from 'react-redux'
+import { useSdk } from './useSdk'
+import { AssetData } from '../../lib/logic/catalog'
+import { createCustomAsset } from '../../redux/data-layer'
+
+export const useCustomAsset = () => {
+ const sdk = useSdk()
+ const dispatch = useDispatch()
+
+ const create = useCallback(
+ (entities: Entity | Entity[]): { composite: AssetData['composite']; resources: string[] } | undefined => {
+ if (!sdk) return undefined
+ const entityArray = Array.isArray(entities) ? entities : [entities]
+ if (entityArray.length === 0) throw new Error('No entities to create custom asset')
+ const name = sdk.components.Name.get(entityArray[0]).value
+ const asset = sdk.operations.createCustomAsset(entityArray)
+ if (asset) {
+ dispatch(createCustomAsset({ ...asset, name }))
+ }
+
+ return asset
+ },
+ [sdk, dispatch]
+ )
+
+ return { create }
+}
+
+export default useCustomAsset
diff --git a/packages/@dcl/inspector/src/hooks/sdk/useEntityComponent.ts b/packages/@dcl/inspector/src/hooks/sdk/useEntityComponent.ts
index 435e4ea7d..343884e72 100644
--- a/packages/@dcl/inspector/src/hooks/sdk/useEntityComponent.ts
+++ b/packages/@dcl/inspector/src/hooks/sdk/useEntityComponent.ts
@@ -14,7 +14,6 @@ export const DISABLED_COMPONENTS: string[] = [
CoreComponents.NFT_SHAPE,
CoreComponents.VIDEO_PLAYER,
CoreComponents.NETWORK_ENTITY,
- CoreComponents.TWEEN,
CoreComponents.TWEEN_SEQUENCE
]
diff --git a/packages/@dcl/inspector/src/lib/babylon/decentraland/get-resources.ts b/packages/@dcl/inspector/src/lib/babylon/decentraland/get-resources.ts
new file mode 100644
index 000000000..e3e2f9fd8
--- /dev/null
+++ b/packages/@dcl/inspector/src/lib/babylon/decentraland/get-resources.ts
@@ -0,0 +1,41 @@
+import { NullEngine, Scene } from '@babylonjs/core'
+import { getDataLayerInterface } from '../../../redux/data-layer'
+import { loadAssetContainer, resourcesByPath } from './sdkComponents/gltf-container'
+import future from 'fp-future'
+
+// This function takes a path to a gltf or glb file, loads it in Babylon, checks for all the resources loaded by the file, and returns them
+export async function getResourcesFromModel(path: string) {
+ const base = path.split('/').slice(0, -1).join('/')
+ const src = path + '?base=' + encodeURIComponent(base)
+ const engine = new NullEngine()
+ const resources: Set = new Set()
+ const scene = new Scene(engine)
+ const extension = path.toLowerCase().endsWith('.gltf') ? '.gltf' : '.glb'
+ const dataLayer = getDataLayerInterface()
+ if (!dataLayer) {
+ return resources
+ }
+ const { content } = await dataLayer.getFile({ path })
+ const file = new File([content], src)
+
+ const load = future()
+
+ loadAssetContainer(
+ file,
+ scene,
+ () => load.resolve(),
+ () => {},
+ (_scene, _error) => load.reject(new Error(_error)),
+ extension,
+ path
+ )
+
+ await load
+
+ return resourcesByPath.get(path)
+}
+
+export async function getResourcesFromModels(paths: string[]): Promise {
+ const results = await Promise.all(paths.map(getResourcesFromModel))
+ return results.flatMap((resourceSet) => (resourceSet ? Array.from(resourceSet) : []))
+}
diff --git a/packages/@dcl/inspector/src/lib/babylon/decentraland/sdkComponents/gltf-container.ts b/packages/@dcl/inspector/src/lib/babylon/decentraland/sdkComponents/gltf-container.ts
index 924b7aa2f..5eb5499d2 100644
--- a/packages/@dcl/inspector/src/lib/babylon/decentraland/sdkComponents/gltf-container.ts
+++ b/packages/@dcl/inspector/src/lib/babylon/decentraland/sdkComponents/gltf-container.ts
@@ -12,6 +12,8 @@ import { CAMERA, PLAYER } from '../../../sdk/tree'
let sceneContext: WeakRef
+export const resourcesByPath = new Map>()
+
BABYLON.SceneLoader.OnPluginActivatedObservable.add(function (plugin) {
if (plugin instanceof GLTFFileLoader) {
plugin.animationStartMode = GLTFLoaderAnimationStartMode.NONE
@@ -30,7 +32,8 @@ BABYLON.SceneLoader.OnPluginActivatedObservable.add(function (plugin) {
// caches all the files by their name (CIDv1)
const loader: GLTFLoader = (plugin as any)._loader
const file: string = (loader as any)._fileName
- const [_gltfFilename, strParams] = file.split('?')
+ const [gltfFilename, strParams] = file.split('?')
+
if (strParams) {
const params = new URLSearchParams(strParams)
const base = params.get('base') || ''
@@ -40,8 +43,14 @@ BABYLON.SceneLoader.OnPluginActivatedObservable.add(function (plugin) {
console.log(`Fetching ${filePath}`)
const content = await ctx.getFile(filePath)
if (content) {
+ // This is a hack to get the resources loaded by the gltf file
+ if (!resourcesByPath.has(gltfFilename)) {
+ resourcesByPath.set(gltfFilename, new Set())
+ }
+ const resources = resourcesByPath.get(gltfFilename)!
+ resources.add(filePath)
// TODO: this works with File, but it doesn't match the types (it requires string)
- return new File([content], _gltfFilename) as any
+ return new File([content], gltfFilename) as any
}
}
}
diff --git a/packages/@dcl/inspector/src/lib/data-layer/host/fs-utils.ts b/packages/@dcl/inspector/src/lib/data-layer/host/fs-utils.ts
index 47f443543..f6b7f4522 100644
--- a/packages/@dcl/inspector/src/lib/data-layer/host/fs-utils.ts
+++ b/packages/@dcl/inspector/src/lib/data-layer/host/fs-utils.ts
@@ -30,7 +30,8 @@ export async function getFilesInDirectory(
export const DIRECTORY = {
ASSETS: 'assets',
SCENE: 'scene',
- THUMBNAILS: 'thumbnails'
+ THUMBNAILS: 'thumbnails',
+ CUSTOM: 'custom'
}
export const EXTENSIONS = [
diff --git a/packages/@dcl/inspector/src/lib/data-layer/host/rpc-methods.ts b/packages/@dcl/inspector/src/lib/data-layer/host/rpc-methods.ts
index 62a1354b8..591ec671a 100644
--- a/packages/@dcl/inspector/src/lib/data-layer/host/rpc-methods.ts
+++ b/packages/@dcl/inspector/src/lib/data-layer/host/rpc-methods.ts
@@ -1,6 +1,6 @@
import { IEngine, OnChangeFunction } from '@dcl/ecs'
import { DataLayerRpcServer, FileSystemInterface } from '../types'
-import { EXTENSIONS, getCurrentCompositePath, getFilesInDirectory, withAssetDir } from './fs-utils'
+import { DIRECTORY, EXTENSIONS, getCurrentCompositePath, getFilesInDirectory, withAssetDir } from './fs-utils'
import { stream } from './stream'
import { FileOperation, initUndoRedo } from './undo-redo'
import upsertAsset from './upsert-asset'
@@ -8,6 +8,7 @@ import { initSceneProvider } from './scene'
import { readPreferencesFromFile, serializeInspectorPreferences } from '../../logic/preferences/io'
import { compositeAndDirty } from './utils/composite-dirty'
import { installBin } from './utils/install-bin'
+import { AssetData } from '../../logic/catalog'
const INSPECTOR_PREFERENCES_PATH = 'inspector-preferences.json'
@@ -135,6 +136,217 @@ export async function initRpcMethods(
inspectorPreferences = req
await fs.writeFile(INSPECTOR_PREFERENCES_PATH, serializeInspectorPreferences(req))
return {}
+ },
+ async copyFile(req) {
+ const content = await fs.readFile(req.fromPath)
+ const prevValue = (await fs.existFile(req.toPath)) ? await fs.readFile(req.toPath) : null
+ await fs.writeFile(req.toPath, content)
+
+ // Add undo operation for the file copy
+ undoRedoManager.addUndoFile([{ prevValue, newValue: content, path: req.toPath }])
+
+ return {}
+ },
+ async getFile(req) {
+ const content = await fs.readFile(req.path)
+ return { content }
+ },
+ async createCustomAsset(req) {
+ const { name, composite, resources, thumbnail } = req
+
+ // Create a slug from the name
+ const slug = name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/(^_|_$)/g, '')
+
+ // Find a unique path by appending numbers if needed
+ const basePath = `${DIRECTORY.CUSTOM}`
+ let customAssetPath = `${basePath}/${slug}`
+ let counter = 1
+ while (await fs.existFile(`${customAssetPath}/data.json`)) {
+ customAssetPath = `${basePath}/${slug}_${++counter}`
+ }
+
+ // Create and save data.json with metadata and composite
+ const data: Omit = {
+ id: crypto.randomUUID(),
+ name,
+ category: 'custom',
+ tags: []
+ }
+ await fs.writeFile(`${customAssetPath}/data.json`, Buffer.from(JSON.stringify(data, null, 2)) as Buffer)
+ await fs.writeFile(
+ `${customAssetPath}/composite.json`,
+ Buffer.from(JSON.stringify(JSON.parse(new TextDecoder().decode(composite)), null, 2)) // pretty print
+ )
+
+ // Save thumbnail if provided
+ if (thumbnail) {
+ const thumbnailBuffer = Buffer.from(thumbnail)
+ await fs.writeFile(`${customAssetPath}/thumbnail.png`, thumbnailBuffer)
+ }
+
+ // Copy all resources to the custom asset folder
+ const undoAcc: FileOperation[] = []
+ for (const resourcePath of resources) {
+ const fileName = resourcePath.split('/').pop()!
+ const targetPath = `${customAssetPath}/${fileName}`
+ const content = await fs.readFile(resourcePath)
+
+ undoAcc.push({
+ prevValue: null,
+ newValue: content,
+ path: targetPath
+ })
+ await fs.writeFile(targetPath, content)
+ }
+
+ // Add undo operation for the entire asset creation
+ undoRedoManager.addUndoFile([
+ ...undoAcc,
+ {
+ prevValue: null,
+ newValue: Buffer.from(JSON.stringify(data, null, 2)),
+ path: `${customAssetPath}/data.json`
+ }
+ ])
+
+ return {}
+ },
+ async getCustomAssets() {
+ const paths = await getFilesInDirectory(fs, `${DIRECTORY.CUSTOM}`, [], true)
+ const folders = [...new Set(paths.map((path) => path.split('/')[1]))]
+ const assets = (
+ await Promise.all(
+ folders.map(async (path) => {
+ try {
+ const files = await getFilesInDirectory(fs, `${DIRECTORY.CUSTOM}/${path}`, [], true)
+ let dataPath: string | null = null
+ let compositePath: string | null = null
+ let thumbnailPath: string | null = null
+ const resources: string[] = []
+ for (const file of files) {
+ if (file.endsWith('data.json')) {
+ dataPath = file
+ } else if (file.endsWith('composite.json')) {
+ compositePath = file
+ } else if (file.endsWith('thumbnail.png')) {
+ thumbnailPath = file
+ } else {
+ resources.push(file)
+ }
+ }
+ if (!dataPath || !compositePath) {
+ return null
+ }
+ const data = await fs.readFile(dataPath)
+ const composite = await fs.readFile(compositePath)
+ const parsedData = JSON.parse(new TextDecoder().decode(data))
+ const result: AssetData & { thumbnail?: string } = {
+ ...parsedData,
+ composite: JSON.parse(new TextDecoder().decode(composite)),
+ resources
+ }
+
+ // Add thumbnail if it exists
+ if (thumbnailPath) {
+ const thumbnailData = await fs.readFile(thumbnailPath)
+ const thumbnailBuffer = Buffer.from(thumbnailData)
+ result.thumbnail = `data:image/png;base64,${thumbnailBuffer.toString('base64')}`
+ }
+
+ return result
+ } catch {
+ return null
+ }
+ })
+ )
+ ).filter((asset): asset is AssetData & { thumbnail?: string } => asset !== null)
+ return { assets: assets.map((asset) => ({ data: Buffer.from(JSON.stringify(asset)) })) }
+ },
+ async deleteCustomAsset(req) {
+ const { assetId } = req
+ const paths = await getFilesInDirectory(fs, `${DIRECTORY.CUSTOM}`, [], true)
+ const folders = [...new Set(paths.map((path) => path.split('/')[1]))]
+
+ // Keep track of deleted files for undo operation
+ const undoAcc: FileOperation[] = []
+
+ for (const folder of folders) {
+ const dataPath = `${DIRECTORY.CUSTOM}/${folder}/data.json`
+
+ if (await fs.existFile(dataPath)) {
+ try {
+ const data = await fs.readFile(dataPath)
+ const parsedData = JSON.parse(new TextDecoder().decode(data))
+
+ if (parsedData.id === assetId) {
+ // Found the asset to delete - get all files in this folder
+ const folderPath = `${DIRECTORY.CUSTOM}/${folder}`
+ const files = await getFilesInDirectory(fs, folderPath, [], true)
+
+ // Store file contents for undo operation
+ for (const file of files) {
+ const content = await fs.readFile(file)
+ undoAcc.push({
+ prevValue: content,
+ newValue: null,
+ path: file
+ })
+ await fs.rm(file)
+ }
+
+ // Add undo operation for all deleted files
+ undoRedoManager.addUndoFile(undoAcc)
+
+ return {} // Return Empty object as required by the type
+ }
+ } catch (err) {
+ // Skip folders with invalid JSON data
+ continue
+ }
+ }
+ }
+
+ throw new Error(`Custom asset with id ${assetId} not found`)
+ },
+ async renameCustomAsset(req: { assetId: string; newName: string }) {
+ const { assetId, newName } = req
+ const paths = await getFilesInDirectory(fs, `${DIRECTORY.CUSTOM}`, [], true)
+ const folders = [...new Set(paths.map((path) => path.split('/')[1]))]
+
+ const undoAcc: FileOperation[] = []
+
+ for (const folder of folders) {
+ const dataPath = `${DIRECTORY.CUSTOM}/${folder}/data.json`
+
+ if (await fs.existFile(dataPath)) {
+ try {
+ const data = await fs.readFile(dataPath)
+ const parsedData = JSON.parse(new TextDecoder().decode(data))
+
+ if (parsedData.id === assetId) {
+ const updatedData = { ...parsedData, name: newName }
+ const newContent = Buffer.from(JSON.stringify(updatedData, null, 2))
+
+ undoAcc.push({
+ prevValue: data,
+ newValue: newContent,
+ path: dataPath
+ })
+
+ await fs.writeFile(dataPath, newContent)
+ undoRedoManager.addUndoFile(undoAcc)
+ return {}
+ }
+ } catch (err) {
+ continue
+ }
+ }
+ }
+
+ throw new Error(`Custom asset with id ${assetId} not found`)
}
}
}
diff --git a/packages/@dcl/inspector/src/lib/data-layer/proto/data-layer.proto b/packages/@dcl/inspector/src/lib/data-layer/proto/data-layer.proto
index 80b4026bd..10e6ea2b2 100644
--- a/packages/@dcl/inspector/src/lib/data-layer/proto/data-layer.proto
+++ b/packages/@dcl/inspector/src/lib/data-layer/proto/data-layer.proto
@@ -53,6 +53,39 @@ message InspectorPreferencesMessage {
bool autosave_enabled = 2;
}
+message CopyFileRequest {
+ string from_path = 1;
+ string to_path = 2;
+}
+
+message GetFileRequest {
+ string path = 1;
+}
+
+message GetFileResponse {
+ bytes content = 1;
+}
+
+message CreateCustomAssetRequest {
+ string name = 1;
+ bytes composite = 2;
+ repeated string resources = 3;
+ optional bytes thumbnail = 4;
+}
+
+message GetCustomAssetsResponse {
+ repeated AssetData assets = 1;
+}
+
+message DeleteCustomAssetRequest {
+ string asset_id = 1;
+}
+
+message RenameCustomAssetRequest {
+ string asset_id = 1;
+ string new_name = 2;
+}
+
service DataService {
rpc CrdtStream(stream CrdtStreamMessage) returns (stream CrdtStreamMessage) {}
rpc Undo(Empty) returns (UndoRedoResponse) {}
@@ -67,4 +100,10 @@ service DataService {
rpc Save(Empty) returns (Empty) {}
rpc GetInspectorPreferences(Empty) returns (InspectorPreferencesMessage) {}
rpc SetInspectorPreferences(InspectorPreferencesMessage) returns (Empty) {}
+ rpc CopyFile(CopyFileRequest) returns (Empty) {}
+ rpc GetFile(GetFileRequest) returns (GetFileResponse) {}
+ rpc CreateCustomAsset(CreateCustomAssetRequest) returns (Empty) {}
+ rpc GetCustomAssets(Empty) returns (GetCustomAssetsResponse) {}
+ rpc DeleteCustomAsset(DeleteCustomAssetRequest) returns (Empty) {}
+ rpc RenameCustomAsset(RenameCustomAssetRequest) returns (Empty) {}
}
diff --git a/packages/@dcl/inspector/src/lib/logic/analytics.ts b/packages/@dcl/inspector/src/lib/logic/analytics.ts
index cded8050d..e6aa493ae 100644
--- a/packages/@dcl/inspector/src/lib/logic/analytics.ts
+++ b/packages/@dcl/inspector/src/lib/logic/analytics.ts
@@ -19,6 +19,7 @@ export type Events = {
itemName: string
itemPath: string
isSmart: boolean
+ isCustom: boolean
}
[Event.ADD_COMPONENT]: {
componentName: string
diff --git a/packages/@dcl/inspector/src/lib/logic/catalog.ts b/packages/@dcl/inspector/src/lib/logic/catalog.ts
index 0873d9b00..2a7189256 100644
--- a/packages/@dcl/inspector/src/lib/logic/catalog.ts
+++ b/packages/@dcl/inspector/src/lib/logic/catalog.ts
@@ -7,6 +7,11 @@ export const catalog = (_catalog as unknown as Catalog).assetPacks
export { Catalog, AssetPack, Asset, AssetData }
+export type CustomAsset = AssetData & {
+ resources: string[]
+ thumbnail?: string
+}
+
// categories obtained from "builder-items.decentraland.org" catalog
export const CATEGORIES = [
'ground',
diff --git a/packages/@dcl/inspector/src/lib/sdk/components/index.ts b/packages/@dcl/inspector/src/lib/sdk/components/index.ts
index 30f48c6d4..d24a104f0 100644
--- a/packages/@dcl/inspector/src/lib/sdk/components/index.ts
+++ b/packages/@dcl/inspector/src/lib/sdk/components/index.ts
@@ -53,7 +53,8 @@ export enum EditorComponentNames {
Lock = 'inspector::Lock',
Config = 'inspector::Config',
Ground = 'inspector::Ground',
- Tile = 'inspector::Tile'
+ Tile = 'inspector::Tile',
+ CustomAsset = 'inspector::CustomAsset'
}
export enum SceneAgeRating {
@@ -118,6 +119,10 @@ export type GroundComponent = {}
// eslint-disable-next-line @typescript-eslint/ban-types
export type TileComponent = {}
+export type CustomAssetComponent = {
+ assetId: string
+}
+
export enum SceneCategory {
ART = 'art',
GAME = 'game',
@@ -148,6 +153,7 @@ export type EditorComponentsTypes = {
Config: ConfigComponent
Ground: GroundComponent
Tile: TileComponent
+ CustomAsset: CustomAssetComponent
}
export type EditorComponents = {
@@ -166,6 +172,7 @@ export type EditorComponents = {
Config: LastWriteWinElementSetComponentDefinition
Ground: LastWriteWinElementSetComponentDefinition
Tile: LastWriteWinElementSetComponentDefinition
+ CustomAsset: LastWriteWinElementSetComponentDefinition
}
export type SdkComponents = {
@@ -344,6 +351,9 @@ export function createEditorComponents(engine: IEngine): EditorComponents {
const Ground = engine.defineComponent(EditorComponentNames.Ground, {})
const Tile = engine.defineComponent(EditorComponentNames.Tile, {})
+ const CustomAsset = engine.defineComponent(EditorComponentNames.CustomAsset, {
+ assetId: Schemas.String
+ })
return {
Selection,
@@ -362,6 +372,9 @@ export function createEditorComponents(engine: IEngine): EditorComponents {
States: States as unknown as LastWriteWinElementSetComponentDefinition,
CounterBar: CounterBar as unknown as LastWriteWinElementSetComponentDefinition,
Ground: Ground as unknown as LastWriteWinElementSetComponentDefinition,
- Tile: Tile as unknown as LastWriteWinElementSetComponentDefinition
+ Tile: Tile as unknown as LastWriteWinElementSetComponentDefinition,
+ CustomAsset: CustomAsset as unknown as LastWriteWinElementSetComponentDefinition<
+ EditorComponentsTypes['CustomAsset']
+ >
}
}
diff --git a/packages/@dcl/inspector/src/lib/sdk/drag-drop.ts b/packages/@dcl/inspector/src/lib/sdk/drag-drop.ts
index 353ab9b16..8b5422977 100644
--- a/packages/@dcl/inspector/src/lib/sdk/drag-drop.ts
+++ b/packages/@dcl/inspector/src/lib/sdk/drag-drop.ts
@@ -1,5 +1,5 @@
import { Identifier } from 'dnd-core'
-import { Asset } from '../../lib/logic/catalog'
+import { Asset, CustomAsset } from '../../lib/logic/catalog'
import { TreeNode } from '../../components/ProjectAssetExplorer/ProjectView'
import { AssetNodeItem } from '../../components/ProjectAssetExplorer/types'
@@ -10,12 +10,13 @@ interface Drop {
export type LocalAssetDrop = Drop }>
export type CatalogAssetDrop = Drop
-
-export type IDrop = LocalAssetDrop | CatalogAssetDrop
+export type CustomAssetDrop = Drop
+export type IDrop = LocalAssetDrop | CatalogAssetDrop | CustomAssetDrop
export enum DropTypesEnum {
LocalAsset = 'local-asset',
- CatalogAsset = 'catalog-asset'
+ CatalogAsset = 'catalog-asset',
+ CustomAsset = 'custom-asset'
}
export type DropTypes = `${DropTypesEnum}`
diff --git a/packages/@dcl/inspector/src/lib/sdk/operations/add-asset/index.ts b/packages/@dcl/inspector/src/lib/sdk/operations/add-asset/index.ts
index acaa8cbea..644eacf68 100644
--- a/packages/@dcl/inspector/src/lib/sdk/operations/add-asset/index.ts
+++ b/packages/@dcl/inspector/src/lib/sdk/operations/add-asset/index.ts
@@ -5,7 +5,9 @@ import {
GltfContainer as GltfEngine,
Vector3Type,
LastWriteWinElementSetComponentDefinition,
- NetworkEntity as NetworkEntityEngine
+ NetworkEntity as NetworkEntityEngine,
+ TransformType,
+ Name
} from '@dcl/ecs'
import {
ActionType,
@@ -17,12 +19,14 @@ import {
getPayload
} from '@dcl/asset-packs'
-import { CoreComponents, EditorComponentNames } from '../../components'
+import { CoreComponents, EditorComponentNames, EditorComponents } from '../../components'
import updateSelectedEntity from '../update-selected-entity'
import { addChild } from '../add-child'
import { isSelf, parseMaterial, parseSyncComponents } from './utils'
import { EnumEntity } from '../../enum-entity'
import { AssetData } from '../../../logic/catalog'
+import { pushChild, removeChild } from '../../nodes'
+import { ROOT } from '../../tree'
export function addAsset(engine: IEngine) {
return function addAsset(
@@ -33,49 +37,150 @@ export function addAsset(engine: IEngine) {
base: string,
enumEntityId: EnumEntity,
composite?: AssetData['composite'],
- assetId?: string
+ assetId?: string,
+ custom?: boolean
): Entity {
const Transform = engine.getComponent(TransformEngine.componentId) as typeof TransformEngine
const GltfContainer = engine.getComponent(GltfEngine.componentId) as typeof GltfEngine
const NetworkEntity = engine.getComponent(NetworkEntityEngine.componentId) as typeof NetworkEntityEngine
+ const Nodes = engine.getComponent(EditorComponentNames.Nodes) as EditorComponents['Nodes']
+ const CustomAsset = engine.getComponent(EditorComponentNames.CustomAsset) as EditorComponents['CustomAsset']
if (composite) {
// Get all unique entity IDs from components
- const entityIds = new Set()
- for (const component of composite.components) {
- Object.keys(component.data).forEach((id) => entityIds.add(id))
- }
+ const entityIds = new Set()
// Track all created entities
- const entities = new Map()
+ const entities = new Map()
- // If there's only one entity, it becomes the main entity
- // If there are multiple entities, create a new main entity as parent
- const mainEntity =
- entityIds.size === 1 ? addChild(engine)(parent, name) : addChild(engine)(parent, `${name}_root`)
+ // Tranform tree
+ const parentOf = new Map()
+ const transformComponent = composite.components.find((component) => component.name === CoreComponents.TRANSFORM)
+ if (transformComponent) {
+ for (const [entityId, transformData] of Object.entries(transformComponent.data)) {
+ const entity = Number(entityId) as Entity
+ entityIds.add(entity)
+ if (typeof transformData.json.parent === 'number') {
+ parentOf.set(entity, transformData.json.parent)
+ entityIds.add(transformData.json.parent)
+ }
+ }
+ }
- Transform.createOrReplace(mainEntity, { parent, position })
+ // Store names
+ const names = new Map()
+ const nameComponent = composite.components.find((component) => component.name === Name.componentName)
+ if (nameComponent) {
+ for (const [entityId, nameData] of Object.entries(nameComponent.data)) {
+ names.set(Number(entityId) as Entity, nameData.json.value)
+ }
+ }
- // Set up entity hierarchy based on number of entities
- const parentForChildren = entityIds.size === 1 ? parent : mainEntity
+ // Get all entity ids
+ for (const component of composite.components) {
+ for (const id of Object.keys(component.data)) {
+ entityIds.add(Number(id) as Entity)
+ }
+ }
- // Create all entities
+ // Get all roots
+ const roots = new Set()
for (const entityId of entityIds) {
- if (entityIds.size === 1) {
- // Single entity case: use the main entity
- entities.set(entityId, mainEntity)
- } else {
- // Multiple entities case: create child entities
- const entity = entityId === '0' ? mainEntity : addChild(engine)(parentForChildren, `${name}_${entityId}`)
-
- if (entityId !== '0') {
+ if (!parentOf.has(entityId)) {
+ roots.add(entityId)
+ }
+ }
+
+ // Store initial transform values
+ const transformValues = new Map()
+ if (transformComponent) {
+ for (const [entityId, transformData] of Object.entries(transformComponent.data)) {
+ const entity = Number(entityId) as Entity
+ transformValues.set(entity, transformData.json)
+ }
+ }
+
+ if (roots.size === 0) {
+ throw new Error('No roots found in composite')
+ }
+ let defaultParent = parent
+ let mainEntity: Entity | null = null
+
+ // If multiple roots, create a new root as main entity
+ if (roots.size > 1) {
+ mainEntity = addChild(engine)(parent, `${name}_root`)
+ Transform.createOrReplace(mainEntity, { parent, position })
+ defaultParent = mainEntity
+ }
+
+ // If single entity, use it as root and main entity
+ if (entityIds.size === 1) {
+ mainEntity = addChild(engine)(parent, name)
+ Transform.createOrReplace(mainEntity, { parent, position })
+ entities.set(entityIds.values().next().value, mainEntity)
+ } else {
+ // Track orphaned entities that need to be reparented
+ const orphanedEntities = new Map()
+
+ // Create all entities
+ for (const entityId of entityIds) {
+ const isRoot = roots.has(entityId)
+ const intendedParentId = parentOf.get(entityId)
+ const parentEntity = isRoot
+ ? defaultParent
+ : typeof intendedParentId === 'number'
+ ? entities.get(intendedParentId)
+ : undefined
+
+ // If parent doesn't exist yet, temporarily attach to parentForChildren
+ if (!isRoot && typeof intendedParentId === 'number' && typeof parentEntity === 'undefined') {
+ orphanedEntities.set(entityId, intendedParentId)
+ }
+
+ const entity = addChild(engine)(
+ parentEntity || defaultParent,
+ names.get(entityId) || (entityId === ROOT ? name : `${name}_${entityId}`)
+ )
+
+ // Apply transform values from composite
+ const transformValue = transformValues.get(entityId)
+ if (transformValue) {
Transform.createOrReplace(entity, {
- parent: parentForChildren,
- position: { x: 0, y: 0, z: 0 }
+ position: transformValue.position || { x: 0, y: 0, z: 0 },
+ rotation: transformValue.rotation || { x: 0, y: 0, z: 0, w: 1 },
+ scale: transformValue.scale || { x: 1, y: 1, z: 1 },
+ parent: parentEntity || defaultParent
})
}
+
entities.set(entityId, entity)
}
+
+ // Reparent orphaned entities now that all entities exist
+ for (const [entityId, intendedParentId] of orphanedEntities) {
+ const entity = entities.get(entityId)!
+ const parentEntity = entities.get(intendedParentId)!
+ if (parentEntity) {
+ const transformValue = transformValues.get(entityId)
+ Transform.createOrReplace(entity, {
+ parent: parentEntity,
+ position: transformValue?.position || { x: 0, y: 0, z: 0 },
+ rotation: transformValue?.rotation || { x: 0, y: 0, z: 0, w: 1 },
+ scale: transformValue?.scale || { x: 1, y: 1, z: 1 }
+ })
+ Nodes.createOrReplace(engine.RootEntity, { value: removeChild(engine, defaultParent, entity) })
+ Nodes.createOrReplace(engine.RootEntity, { value: pushChild(engine, parentEntity, entity) })
+ } else {
+ console.warn(`Failed to reparent entity ${entityId}: parent ${intendedParentId} not found`)
+ }
+ }
+
+ // If multiple entities but single root, use root as main entity
+ if (roots.size === 1) {
+ const root = Array.from(roots)[0]
+ mainEntity = entities.get(root)!
+ Transform.createOrReplace(mainEntity, { parent, position })
+ }
}
const values = new Map()
@@ -84,22 +189,34 @@ export function addAsset(engine: IEngine) {
const ids = new Map()
for (const component of composite.components) {
const componentName = component.name
- for (const [_entityId, data] of Object.entries(component.data)) {
+ for (const [entityId, data] of Object.entries(component.data)) {
+ // Use composite key of componentName and entityId
+ const key = `${componentName}:${entityId}`
const componentValue = { ...data.json }
if (COMPONENTS_WITH_ID.includes(componentName) && isSelf(componentValue.id)) {
- ids.set(componentName, getNextId(engine as any))
- componentValue.id = ids.get(componentName)
+ ids.set(key, getNextId(engine as any))
+ componentValue.id = ids.get(key)
}
- values.set(componentName, componentValue)
+ values.set(key, componentValue)
}
}
- const mapId = (id: string | number) => {
+ const mapId = (id: string | number, entityId: string) => {
if (typeof id === 'string') {
- const match = id.match(/{self:(.+)}/)
- if (match) {
- const componentName = match[1]
- return ids.get(componentName)
+ // Handle self references
+ const selfMatch = id.match(/{self:(.+)}/)
+ if (selfMatch) {
+ const componentName = selfMatch[1]
+ const key = `${componentName}:${entityId}`
+ return ids.get(key)
+ }
+
+ // Handle cross-entity references
+ const crossEntityMatch = id.match(/{(\d+):(.+)}/)
+ if (crossEntityMatch) {
+ const [_, refEntityId, componentName] = crossEntityMatch
+ const key = `${componentName}:${refEntityId}`
+ return ids.get(key)
}
}
return id
@@ -108,9 +225,11 @@ export function addAsset(engine: IEngine) {
// Process and create components for each entity
for (const component of composite.components) {
const componentName = component.name
- for (const [entityId] of Object.entries(component.data)) {
+ for (const [entityIdStr] of Object.entries(component.data)) {
+ const entityId = Number(entityIdStr) as Entity
const targetEntity = entities.get(entityId)!
- let componentValue = values.get(componentName)
+ const key = `${componentName}:${entityIdStr}`
+ let componentValue = values.get(key)
switch (componentName) {
case CoreComponents.GLTF_CONTAINER: {
@@ -176,18 +295,18 @@ export function addAsset(engine: IEngine) {
...trigger,
conditions: (trigger.conditions || []).map((condition: any) => ({
...condition,
- id: mapId(condition.id)
+ id: mapId(condition.id, entityIdStr)
})),
actions: trigger.actions.map((action: any) => ({
...action,
- id: mapId(action.id)
+ id: mapId(action.id, entityIdStr)
}))
}))
componentValue = { ...componentValue, value: newValue }
break
}
case CoreComponents.SYNC_COMPONENTS: {
- const componentIds = parseSyncComponents(engine, componentValue.value)
+ const componentIds = parseSyncComponents(engine, componentValue.value || componentValue.componentIds)
componentValue = { componentIds }
const NetworkEntityComponent = engine.getComponent(NetworkEntity.componentId) as typeof NetworkEntity
NetworkEntityComponent.create(targetEntity, {
@@ -198,11 +317,23 @@ export function addAsset(engine: IEngine) {
}
}
+ if (componentName === CoreComponents.TRANSFORM || componentName === Name.componentName) {
+ continue
+ }
+
const Component = engine.getComponent(componentName) as LastWriteWinElementSetComponentDefinition
- Component.create(targetEntity, componentValue)
+ Component.createOrReplace(targetEntity, componentValue)
}
}
+ if (!mainEntity) {
+ throw new Error('No main entity found')
+ }
+
+ if (assetId && custom) {
+ CustomAsset.createOrReplace(mainEntity, { assetId })
+ }
+
// update selection
updateSelectedEntity(engine)(mainEntity)
return mainEntity
diff --git a/packages/@dcl/inspector/src/lib/sdk/operations/add-child.spec.ts b/packages/@dcl/inspector/src/lib/sdk/operations/add-child.spec.ts
index 91722a6bb..1d0835d0b 100644
--- a/packages/@dcl/inspector/src/lib/sdk/operations/add-child.spec.ts
+++ b/packages/@dcl/inspector/src/lib/sdk/operations/add-child.spec.ts
@@ -22,12 +22,12 @@ describe('generateUniqueName', () => {
expect(result).toBe('SomeName')
})
- it('should return the base name with _1 when the base name already exists', () => {
+ it('should return the base name with _2 when the base name already exists', () => {
_addChild(engine.RootEntity, 'SomeName')
const result = generateUniqueName(engine, Name, 'SomeName')
- expect(result).toBe('SomeName_1')
+ expect(result).toBe('SomeName_2')
})
it('should return the base name with the next incremented suffix', () => {
diff --git a/packages/@dcl/inspector/src/lib/sdk/operations/add-child.ts b/packages/@dcl/inspector/src/lib/sdk/operations/add-child.ts
index d59c7db9e..8729b27ca 100644
--- a/packages/@dcl/inspector/src/lib/sdk/operations/add-child.ts
+++ b/packages/@dcl/inspector/src/lib/sdk/operations/add-child.ts
@@ -25,7 +25,7 @@ export function generateUniqueName(engine: IEngine, Name: NameComponent, value:
const nodes = getNodes(engine)
let isFirst = true
- let max = 0
+ let max = 1
for (const $ of nodes) {
const name = (Name.getOrNull($.entity)?.value || '').toLowerCase()
if (pattern.test(name)) {
diff --git a/packages/@dcl/inspector/src/lib/sdk/operations/create-custom-asset.spec.ts b/packages/@dcl/inspector/src/lib/sdk/operations/create-custom-asset.spec.ts
new file mode 100644
index 000000000..f89ad98be
--- /dev/null
+++ b/packages/@dcl/inspector/src/lib/sdk/operations/create-custom-asset.spec.ts
@@ -0,0 +1,75 @@
+import { Engine, IEngine, Transform as TransformEngine, Name as NameEngine } from '@dcl/ecs'
+import { createCustomAsset } from './create-custom-asset'
+import { EditorComponents, createEditorComponents } from '../components'
+import * as components from '@dcl/ecs/dist/components'
+
+describe('createCustomAsset', () => {
+ let engine: IEngine
+ let Transform: typeof TransformEngine
+ let Name: typeof NameEngine
+ let Selection: EditorComponents['Selection']
+ let Nodes: EditorComponents['Nodes']
+
+ beforeEach(() => {
+ engine = Engine()
+ Transform = components.Transform(engine)
+ Name = components.Name(engine)
+ const editorComponents = createEditorComponents(engine)
+ Selection = editorComponents.Selection
+ Nodes = editorComponents.Nodes
+
+ // Initialize root node
+ Nodes.create(engine.RootEntity, {
+ value: [{ entity: engine.RootEntity, children: [] }]
+ })
+ })
+
+ it('should create a custom asset from selected entities', () => {
+ // Create test entities
+ const entity1 = engine.addEntity()
+ const entity2 = engine.addEntity()
+
+ // Setup entities
+ Transform.create(entity1, { position: { x: 1, y: 1, z: 1 } })
+ Transform.create(entity2, { parent: entity1, position: { x: 0, y: 1, z: 0 } })
+ Name.create(entity1, { value: 'Parent' })
+ Name.create(entity2, { value: 'Child' })
+
+ // Select entities
+ Selection.create(entity1)
+ Selection.create(entity2)
+
+ // Create custom asset
+ const createCustomAssetFn = createCustomAsset(engine)
+ const result = createCustomAssetFn([entity1, entity2])
+
+ expect(result).toBeDefined()
+ expect(result.composite).toBeDefined()
+ expect(result.composite.components).toBeDefined()
+ expect(result.composite.components.length).toBeGreaterThan(0)
+ })
+
+ it('should handle empty selection', () => {
+ const createCustomAssetFn = createCustomAsset(engine)
+ const result = createCustomAssetFn([])
+
+ expect(result).toBeDefined()
+ expect(result.composite).toBeDefined()
+ expect(result.composite.components).toEqual([])
+ expect(result.resources).toEqual([])
+ })
+
+ it('should preserve component data in the composite', () => {
+ const entity = engine.addEntity()
+ Transform.create(entity, { position: { x: 1, y: 2, z: 3 } })
+ Name.create(entity, { value: 'TestEntity' })
+ Selection.create(entity)
+
+ const createCustomAssetFn = createCustomAsset(engine)
+ const result = createCustomAssetFn([entity])
+ console.log(JSON.stringify(result, null, 2), entity)
+ const nameComponent = result.composite.components.find((c) => c.name === NameEngine.componentName)
+ expect(nameComponent).toBeDefined()
+ expect(nameComponent?.data[0].json.value).toEqual('TestEntity')
+ })
+})
diff --git a/packages/@dcl/inspector/src/lib/sdk/operations/create-custom-asset.ts b/packages/@dcl/inspector/src/lib/sdk/operations/create-custom-asset.ts
new file mode 100644
index 000000000..537ce40c7
--- /dev/null
+++ b/packages/@dcl/inspector/src/lib/sdk/operations/create-custom-asset.ts
@@ -0,0 +1,319 @@
+import {
+ Entity,
+ IEngine,
+ LastWriteWinElementSetComponentDefinition,
+ getComponentEntityTree,
+ Transform as TransformEngine,
+ TransformType
+} from '@dcl/ecs'
+import { Action } from '@dcl/asset-packs'
+import { AssetData } from '../../logic/catalog'
+import { CoreComponents, EditorComponentNames } from '../components'
+import { ActionType, ComponentName as AssetPackComponentNames, COMPONENTS_WITH_ID } from '@dcl/asset-packs'
+
+const BASE_ENTITY_ID = 512 as Entity
+const SINGLE_ENTITY_ID = 0 as Entity
+
+// Components that must be excluded from the asset
+const excludeComponents: string[] = [
+ // Editor components that must be excluded from the asset
+ EditorComponentNames.Selection,
+ EditorComponentNames.Nodes,
+ EditorComponentNames.TransformConfig,
+ EditorComponentNames.Hide,
+ EditorComponentNames.Lock,
+ EditorComponentNames.Ground,
+ EditorComponentNames.Tile,
+ EditorComponentNames.CustomAsset,
+ // Core components that must be excluded from the asset
+ CoreComponents.NETWORK_ENTITY
+]
+
+const componentsWithResources: Record = {}
+
+// Modified handleResource function to be strongly typed with array paths
+function handleResource(type: string, keys: string[]): void {
+ componentsWithResources[type] = (keys as string[]).map(String)
+}
+
+// Update the handlers to use proper typing with array paths
+handleResource(CoreComponents.GLTF_CONTAINER, ['src'])
+handleResource(CoreComponents.AUDIO_SOURCE, ['audioClipUrl'])
+handleResource(CoreComponents.VIDEO_PLAYER, ['src'])
+handleResource(CoreComponents.MATERIAL, ['material', 'pbr', 'texture', 'tex', 'texture', 'src'])
+handleResource(CoreComponents.MATERIAL, ['material', 'pbr', 'alphaTexture', 'tex', 'texture', 'src'])
+handleResource(CoreComponents.MATERIAL, ['material', 'pbr', 'emissiveTexture', 'tex', 'texture', 'src'])
+handleResource(CoreComponents.MATERIAL, ['material', 'pbr', 'bumpTexture', 'tex', 'texture', 'src'])
+
+// Add these action types at the top with other constants
+const RESOURCE_ACTION_TYPES = [ActionType.SHOW_IMAGE, ActionType.PLAY_CUSTOM_EMOTE, ActionType.PLAY_SOUND] as string[]
+
+function createRef(engine: IEngine, componentId: number, currentEntity: Entity, entityIds: Map) {
+ const componentNames = Object.values(AssetPackComponentNames)
+ for (const componentName of componentNames) {
+ const Component = engine.getComponent(componentName) as LastWriteWinElementSetComponentDefinition<{
+ id: number
+ }>
+ const entities = Array.from(engine.getEntitiesWith(Component))
+ const result = entities.find(([_entity, value]) => value.id === componentId)
+ if (Array.isArray(result) && result.length > 0) {
+ const [ownerEntity] = result
+ if (ownerEntity === currentEntity) {
+ return `{self:${componentName}}`
+ } else {
+ const mappedEntityId = entityIds.get(ownerEntity)
+ if (typeof mappedEntityId !== 'undefined' && mappedEntityId !== null) {
+ return `{${mappedEntityId}:${componentName}}`
+ } else {
+ throw new Error(
+ `Component with id ${componentId} not found in entity ${ownerEntity}.\nentityIds: ${JSON.stringify(
+ entityIds,
+ null,
+ 2
+ )}`
+ )
+ }
+ }
+ }
+ }
+ throw new Error(`Component with id ${componentId} not found`)
+}
+
+function calculateCentroid(
+ transformValues: Map,
+ roots: Set
+): { x: number; y: number; z: number } {
+ const positions = Array.from(roots).map((entity) => {
+ const transform = transformValues.get(entity)
+ return transform?.position || { x: 0, y: 0, z: 0 }
+ })
+
+ if (positions.length === 0) return { x: 0, y: 0, z: 0 }
+
+ const sum = positions.reduce(
+ (acc, pos) => ({
+ x: acc.x + pos.x,
+ y: acc.y + pos.y,
+ z: acc.z + pos.z
+ }),
+ { x: 0, y: 0, z: 0 }
+ )
+
+ return {
+ x: sum.x / positions.length,
+ y: sum.y / positions.length,
+ z: sum.z / positions.length
+ }
+}
+
+export function createCustomAsset(engine: IEngine) {
+ return function createCustomAsset(entities: Entity[]): { composite: AssetData['composite']; resources: string[] } {
+ const resources: string[] = []
+ const composite: AssetData['composite'] = {
+ version: 1,
+ components: []
+ }
+
+ // Create a map to store components by their name
+ const componentsByName: Record }> = {}
+
+ // Phase 1: Create the custom asset entities and map the scene entities to them
+ let entityCount = 0
+ const entityIds = new Map() // mappings from scene entities to custom asset entities
+ const allEntities = new Set()
+ const roots = new Set()
+
+ for (const [index, entity] of entities.entries()) {
+ const Transform = engine.getComponent(TransformEngine.componentId) as typeof TransformEngine
+ const tree = Array.from(getComponentEntityTree(engine, entity, Transform))
+ for (const sceneEntity of tree) {
+ allEntities.add(sceneEntity)
+ const isRoot = sceneEntity === entity
+ const assetEntity: Entity =
+ entities.length === 1 && isRoot
+ ? SINGLE_ENTITY_ID
+ : ((BASE_ENTITY_ID + (isRoot ? index : entities.length + entityCount++)) as Entity)
+
+ // set the mapping
+ entityIds.set(sceneEntity, assetEntity)
+ if (isRoot) {
+ roots.add(sceneEntity)
+ }
+ }
+ }
+
+ // Store transforms before processing components
+ const transformValues = new Map()
+ for (const entity of allEntities) {
+ const Transform = engine.getComponent(TransformEngine.componentId) as typeof TransformEngine
+ if (Transform.has(entity)) {
+ const transform = Transform.get(entity)
+ if (transform) {
+ transformValues.set(entity, transform)
+ }
+ }
+ }
+
+ // Calculate centroid for multiple roots
+ let centroid = { x: 0, y: 0, z: 0 }
+ if (roots.size > 1) {
+ centroid = calculateCentroid(transformValues, roots)
+ }
+
+ // Phase 2: Process each component for each scene entity and map it to the custom asset entity
+ for (const entity of allEntities) {
+ const isRoot = roots.has(entity)
+ const assetEntity = entityIds.get(entity)!
+
+ // Process each component for the current entity
+ for (const component of engine.componentsIter()) {
+ const { componentId, componentName } = component
+
+ // Skip editor components that are not part of asset-packs
+ if (excludeComponents.includes(componentName)) {
+ continue
+ }
+
+ // Handle Transform component specially for root entities in multi-root case
+ if (componentName === CoreComponents.TRANSFORM) {
+ if (isRoot && roots.size === 1) {
+ continue // Skip transform for single root as before
+ }
+
+ const Component = engine.getComponent(componentId) as LastWriteWinElementSetComponentDefinition
+ if (!Component.has(entity)) continue
+ const componentValue = Component.get(entity)
+ if (!componentValue) continue
+
+ // Process the component value with a deep copy
+ const processedComponentValue: TransformType = JSON.parse(JSON.stringify(componentValue))
+
+ // Adjust position relative to centroid for root entities
+ if (isRoot && roots.size > 1) {
+ processedComponentValue.position = {
+ x: processedComponentValue.position.x - centroid.x,
+ y: processedComponentValue.position.y - centroid.y,
+ z: processedComponentValue.position.z - centroid.z
+ }
+ }
+
+ // Initialize component in map if it doesn't exist
+ if (!componentsByName[componentName]) {
+ componentsByName[componentName] = { data: {} }
+ }
+
+ // Add the processed value to the component data
+ componentsByName[componentName].data[assetEntity] = { json: processedComponentValue }
+ continue
+ }
+
+ const Component = engine.getComponent(componentId) as LastWriteWinElementSetComponentDefinition
+
+ if (!Component.has(entity)) continue
+ const componentValue = Component.get(entity)
+ if (!componentValue) continue
+
+ // Process the component value with a deep copy
+ let processedComponentValue: any = JSON.parse(JSON.stringify(componentValue))
+
+ // Handle special components
+ if (componentsWithResources[componentName]) {
+ const propertyKeys = componentsWithResources[componentName]
+ let value = processedComponentValue
+
+ // Navigate through the property chain safely
+ for (let i = 0; i < propertyKeys.length - 1; i++) {
+ if (value === undefined || value === null) break
+ value = value[propertyKeys[i]]
+ }
+
+ // Only process if we have a valid value and final key
+ if (value && propertyKeys.length > 0) {
+ const finalKey = propertyKeys[propertyKeys.length - 1]
+ const originalValue: string = value[finalKey]
+ if (originalValue) {
+ value[finalKey] = originalValue.replace(/^.*[/]([^/]+)$/, '{assetPath}/$1')
+ resources.push(originalValue)
+ }
+ }
+ }
+
+ // Handle Actions component resources
+ if (componentName === AssetPackComponentNames.ACTIONS) {
+ if (Array.isArray(processedComponentValue.value)) {
+ const actions = processedComponentValue.value as Action[]
+ processedComponentValue.value = actions.map((action) => {
+ if (RESOURCE_ACTION_TYPES.includes(action.type)) {
+ const payload = JSON.parse(action.jsonPayload)
+ const originalValue: string = payload.src
+ payload.src = originalValue.replace(/^.*[/]([^/]+)$/, '{assetPath}/$1')
+ resources.push(originalValue)
+ action.jsonPayload = JSON.stringify(payload)
+ }
+ return action
+ })
+ }
+ }
+
+ // Replace id with {self}
+ if (COMPONENTS_WITH_ID.includes(componentName)) {
+ processedComponentValue.id = '{self}'
+ }
+
+ if (componentName === AssetPackComponentNames.TRIGGERS) {
+ const newValue = processedComponentValue.value.map((trigger: any) => ({
+ ...trigger,
+ conditions: (trigger.conditions || []).map((condition: any) => {
+ const ref = createRef(engine, condition.id, entity, entityIds)
+ return {
+ ...condition,
+ id: ref
+ }
+ }),
+ actions: trigger.actions.map((action: any) => {
+ const ref = createRef(engine, action.id, entity, entityIds)
+ return {
+ ...action,
+ id: ref
+ }
+ })
+ }))
+ processedComponentValue = { ...processedComponentValue, value: newValue }
+ }
+
+ // Initialize component in map if it doesn't exist
+ if (!componentsByName[componentName]) {
+ componentsByName[componentName] = { data: {} }
+ }
+
+ // Add the processed value to the component data
+ componentsByName[componentName].data[assetEntity] = { json: processedComponentValue }
+ }
+ }
+
+ // Phase 3: Map the entity ids to the target entity ids
+ if (componentsByName[CoreComponents.TRANSFORM]) {
+ const transform = componentsByName[CoreComponents.TRANSFORM] as {
+ data: { [key: Entity]: { json: TransformType } }
+ }
+ for (const transformData of Object.values(transform.data)) {
+ if (transformData.json.parent) {
+ const targetEntityId = entityIds.get(transformData.json.parent)
+ if (typeof targetEntityId !== 'undefined') {
+ transformData.json.parent = targetEntityId
+ }
+ }
+ }
+ }
+
+ // Convert the map to the final composite format
+ composite.components = Object.entries(componentsByName).map(([name, data]) => ({
+ name,
+ data: data.data
+ }))
+
+ return { composite, resources }
+ }
+}
+
+export default createCustomAsset
diff --git a/packages/@dcl/inspector/src/lib/sdk/operations/duplicate-entity.spec.ts b/packages/@dcl/inspector/src/lib/sdk/operations/duplicate-entity.spec.ts
index f949e45d3..f3a877b52 100644
--- a/packages/@dcl/inspector/src/lib/sdk/operations/duplicate-entity.spec.ts
+++ b/packages/@dcl/inspector/src/lib/sdk/operations/duplicate-entity.spec.ts
@@ -59,7 +59,7 @@ describe('duplicateEntity', () => {
expect(duplicateChild).not.toBe(original)
expect(duplicateChild).not.toBe(originalChild)
expect(duplicateChild).not.toBe(duplicate)
- expect(NameComponent.get(duplicateChild!).value).toBe(`${NameComponent.get(originalChild).value}_1`)
+ expect(NameComponent.get(duplicateChild!).value).toBe(`${NameComponent.get(originalChild).value}_2`)
expect(Nodes.get(ROOT).value).toStrictEqual(nodesAfter)
})
})
diff --git a/packages/@dcl/inspector/src/lib/sdk/operations/index.spec.ts b/packages/@dcl/inspector/src/lib/sdk/operations/index.spec.ts
new file mode 100644
index 000000000..96b738c7b
--- /dev/null
+++ b/packages/@dcl/inspector/src/lib/sdk/operations/index.spec.ts
@@ -0,0 +1,59 @@
+import { Engine, IEngine } from '@dcl/ecs'
+import { createOperations } from './index'
+import { store } from '../../../redux/store'
+import { updateCanSave } from '../../../redux/app'
+
+jest.mock('../../../redux/store', () => ({
+ store: {
+ dispatch: jest.fn()
+ }
+}))
+
+jest.mock('../../../redux/app', () => ({
+ updateCanSave: jest.fn()
+}))
+
+describe('createOperations', () => {
+ let engine: IEngine
+
+ beforeEach(() => {
+ engine = Engine()
+ jest.clearAllMocks()
+ })
+
+ it('should create all operations', () => {
+ const operations = createOperations(engine)
+
+ expect(operations.addChild).toBeDefined()
+ expect(operations.addAsset).toBeDefined()
+ expect(operations.setParent).toBeDefined()
+ expect(operations.reorder).toBeDefined()
+ expect(operations.addComponent).toBeDefined()
+ expect(operations.removeComponent).toBeDefined()
+ expect(operations.updateSelectedEntity).toBeDefined()
+ expect(operations.removeSelectedEntities).toBeDefined()
+ expect(operations.duplicateEntity).toBeDefined()
+ expect(operations.createCustomAsset).toBeDefined()
+ expect(operations.getSelectedEntities).toBeDefined()
+ expect(operations.setGround).toBeDefined()
+ expect(operations.lock).toBeDefined()
+ expect(operations.hide).toBeDefined()
+ })
+
+ describe('dispatch', () => {
+ it('should update canSave and call engine.update', async () => {
+ const operations = createOperations(engine)
+ await operations.dispatch()
+
+ expect(store.dispatch).toHaveBeenCalledWith(updateCanSave({ dirty: true }))
+ // Note: We can't easily test engine.update since it's internal to the Engine
+ })
+
+ it('should respect dirty flag', async () => {
+ const operations = createOperations(engine)
+ await operations.dispatch({ dirty: false })
+
+ expect(store.dispatch).toHaveBeenCalledWith(updateCanSave({ dirty: false }))
+ })
+ })
+})
diff --git a/packages/@dcl/inspector/src/lib/sdk/operations/index.ts b/packages/@dcl/inspector/src/lib/sdk/operations/index.ts
index 3bf298cf0..def7b9489 100644
--- a/packages/@dcl/inspector/src/lib/sdk/operations/index.ts
+++ b/packages/@dcl/inspector/src/lib/sdk/operations/index.ts
@@ -15,6 +15,7 @@ import duplicateEntity from './duplicate-entity'
import setGround from './set-ground'
import lock from './lock'
import hide from './hide'
+import createCustomAsset from './create-custom-asset'
import { updateCanSave } from '../../../redux/app'
import { store } from '../../../redux/store'
@@ -35,6 +36,7 @@ export function createOperations(engine: IEngine) {
updateSelectedEntity: updateSelectedEntity(engine),
removeSelectedEntities: removeSelectedEntities(engine),
duplicateEntity: duplicateEntity(engine),
+ createCustomAsset: createCustomAsset(engine),
dispatch: async ({ dirty = true }: Dispatch = {}) => {
store.dispatch(updateCanSave({ dirty }))
await engine.update(1)
diff --git a/packages/@dcl/inspector/src/redux/app/index.ts b/packages/@dcl/inspector/src/redux/app/index.ts
index 0874e6418..a454b321c 100644
--- a/packages/@dcl/inspector/src/redux/app/index.ts
+++ b/packages/@dcl/inspector/src/redux/app/index.ts
@@ -2,6 +2,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'
import { RootState } from '../store'
import { InspectorPreferences } from '../../lib/logic/preferences/types'
import { AssetCatalogResponse, GetFilesResponse } from '../../lib/data-layer/remote-data-layer'
+import { CustomAsset } from '../../lib/logic/catalog'
export interface AppState {
canSave: boolean
@@ -9,6 +10,7 @@ export interface AppState {
assetsCatalog: AssetCatalogResponse | undefined
thumbnails: GetFilesResponse['files']
uploadFile: Record
+ customAssets: CustomAsset[]
}
export const initialState: AppState = {
@@ -17,7 +19,8 @@ export const initialState: AppState = {
preferences: undefined,
assetsCatalog: undefined,
thumbnails: [],
- uploadFile: {}
+ uploadFile: {},
+ customAssets: []
}
export const appState = createSlice({
@@ -32,8 +35,12 @@ export const appState = createSlice({
updatePreferences: (state, { payload }: PayloadAction<{ preferences: InspectorPreferences }>) => {
state.preferences = payload.preferences
},
- updateAssetCatalog: (state, { payload }: PayloadAction<{ assets: AssetCatalogResponse }>) => {
+ updateAssetCatalog: (
+ state,
+ { payload }: PayloadAction<{ assets: AssetCatalogResponse; customAssets: CustomAsset[] }>
+ ) => {
state.assetsCatalog = payload.assets
+ state.customAssets = payload.customAssets
},
updateThumbnails: (state, { payload }: PayloadAction) => {
state.thumbnails = payload.files
@@ -56,6 +63,7 @@ export const selectInspectorPreferences = (state: RootState): InspectorPreferenc
export const selectAssetCatalog = (state: RootState) => state.app.assetsCatalog
export const selectThumbnails = (state: RootState) => state.app.thumbnails
export const selectUploadFile = (state: RootState) => state.app.uploadFile
+export const selectCustomAssets = (state: RootState) => state.app.customAssets
// Reducer
export default appState.reducer
diff --git a/packages/@dcl/inspector/src/redux/data-layer/index.ts b/packages/@dcl/inspector/src/redux/data-layer/index.ts
index 593cd62b6..76b0f2458 100644
--- a/packages/@dcl/inspector/src/redux/data-layer/index.ts
+++ b/packages/@dcl/inspector/src/redux/data-layer/index.ts
@@ -1,8 +1,11 @@
+import { AssetData } from '@dcl/asset-packs'
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
+import { Entity } from '@dcl/ecs'
import { RootState } from '../../redux/store'
import { DataLayerRpcClient } from '../../lib/data-layer/types'
import { InspectorPreferences } from '../../lib/logic/preferences/types'
import { Asset, ImportAssetRequest, SaveFileRequest } from '../../lib/data-layer/remote-data-layer'
+import { AssetsTab } from '../ui/types'
export enum ErrorType {
Disconnected = 'disconnected',
@@ -16,7 +19,10 @@ export enum ErrorType {
ImportAsset = 'import-asset',
RemoveAsset = 'remove-asset',
SaveThumbnail = 'save-thumbnail',
- GetThumbnails = 'get-thumbnails'
+ GetThumbnails = 'get-thumbnails',
+ CreateCustomAsset = 'create-custom-asset',
+ DeleteCustomAsset = 'delete-custom-asset',
+ RenameCustomAsset = 'rename-custom-asset'
}
let dataLayerInterface: DataLayerRpcClient | undefined
@@ -32,13 +38,17 @@ export interface DataLayerState {
error: ErrorType | undefined
removingAsset: Record
reloadAssets: string[]
+ assetToRename: { id: string; name: string } | undefined
+ stagedCustomAsset: { entities: Entity[]; previousTab: AssetsTab; initialName: string } | undefined
}
export const initialState: DataLayerState = {
reconnectAttempts: 0,
error: undefined,
removingAsset: {},
- reloadAssets: []
+ reloadAssets: [],
+ assetToRename: undefined,
+ stagedCustomAsset: undefined
}
export const dataLayer = createSlice({
@@ -81,7 +91,35 @@ export const dataLayer = createSlice({
delete state.removingAsset[payload.payload.path]
},
saveThumbnail: (_state, _payload: PayloadAction) => {},
- getThumbnails: () => {}
+ getThumbnails: () => {},
+ createCustomAsset: (
+ _state,
+ _payload: PayloadAction<{
+ name: string
+ composite: AssetData['composite']
+ resources: string[]
+ thumbnail?: string
+ }>
+ ) => {},
+ deleteCustomAsset: (_state, _payload: PayloadAction<{ assetId: string }>) => {},
+ renameCustomAsset: (state, _payload: PayloadAction<{ assetId: string; newName: string }>) => {
+ state.assetToRename = undefined
+ },
+ setAssetToRename: (state, payload: PayloadAction<{ assetId: string; name: string }>) => {
+ state.assetToRename = { id: payload.payload.assetId, name: payload.payload.name }
+ },
+ clearAssetToRename: (state) => {
+ state.assetToRename = undefined
+ },
+ stageCustomAsset: (
+ state,
+ payload: PayloadAction<{ entities: Entity[]; previousTab: AssetsTab; initialName: string }>
+ ) => {
+ state.stagedCustomAsset = payload.payload
+ },
+ clearStagedCustomAsset: (state) => {
+ state.stagedCustomAsset = undefined
+ }
}
})
@@ -101,7 +139,14 @@ export const {
removeAsset,
clearRemoveAsset,
saveThumbnail,
- getThumbnails
+ getThumbnails,
+ createCustomAsset,
+ deleteCustomAsset,
+ renameCustomAsset,
+ setAssetToRename,
+ clearAssetToRename,
+ stageCustomAsset,
+ clearStagedCustomAsset
} = dataLayer.actions
// Selectors
@@ -109,6 +154,8 @@ export const selectDataLayerError = (state: RootState) => state.dataLayer.error
export const selectDataLayerReconnectAttempts = (state: RootState) => state.dataLayer.reconnectAttempts
export const selectDataLayerRemovingAsset = (state: RootState) => state.dataLayer.removingAsset
export const getReloadAssets = (state: RootState) => state.dataLayer.reloadAssets
+export const selectAssetToRename = (state: RootState) => state.dataLayer.assetToRename
+export const selectStagedCustomAsset = (state: RootState) => state.dataLayer.stagedCustomAsset
// Reducer
export default dataLayer.reducer
diff --git a/packages/@dcl/inspector/src/redux/data-layer/sagas/connect.spec.ts b/packages/@dcl/inspector/src/redux/data-layer/sagas/connect.spec.ts
index b71101518..34db7f9db 100644
--- a/packages/@dcl/inspector/src/redux/data-layer/sagas/connect.spec.ts
+++ b/packages/@dcl/inspector/src/redux/data-layer/sagas/connect.spec.ts
@@ -26,7 +26,9 @@ describe('WebSocket Connection Saga', () => {
error: undefined,
reconnectAttempts: 0,
removingAsset: {},
- reloadAssets: []
+ reloadAssets: [],
+ assetToRename: undefined,
+ stagedCustomAsset: undefined
})
.run()
expect(getDataLayerInterface()).toBe(dataLayer)
diff --git a/packages/@dcl/inspector/src/redux/data-layer/sagas/create-custom-asset.spec.ts b/packages/@dcl/inspector/src/redux/data-layer/sagas/create-custom-asset.spec.ts
new file mode 100644
index 000000000..3c3e25d1e
--- /dev/null
+++ b/packages/@dcl/inspector/src/redux/data-layer/sagas/create-custom-asset.spec.ts
@@ -0,0 +1,101 @@
+import { PayloadAction } from '@reduxjs/toolkit'
+import { expectSaga } from 'redux-saga-test-plan'
+import { call } from 'redux-saga-test-plan/matchers'
+import { throwError } from 'redux-saga-test-plan/providers'
+import { createCustomAssetSaga } from './create-custom-asset'
+import { error, getAssetCatalog, getDataLayerInterface } from '..'
+import { ErrorType } from '../index'
+import { selectAssetsTab } from '../../ui'
+import { AssetsTab } from '../../ui/types'
+import { getResourcesFromModels } from '../../../lib/babylon/decentraland/get-resources'
+import { transformBase64ResourceToBinary } from '../../../lib/data-layer/host/fs-utils'
+
+describe('createCustomAssetSaga', () => {
+ const mockPayload = {
+ name: 'Test Asset',
+ composite: { version: 1, components: [] },
+ resources: ['model.gltf', 'texture.png'],
+ thumbnail: 'base64...'
+ }
+
+ const mockAction: PayloadAction = {
+ type: 'CREATE_CUSTOM_ASSET',
+ payload: mockPayload
+ }
+
+ const mockDataLayer = {
+ createCustomAsset: jest.fn()
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should successfully create a custom asset', async () => {
+ const mockResourcesFromModels = ['texture1.png', 'texture2.png']
+ const gltfResources = mockPayload.resources.filter((r) => r.endsWith('.gltf') || r.endsWith('.glb'))
+
+ return expectSaga(createCustomAssetSaga, mockAction)
+ .provide([
+ [call(getDataLayerInterface), mockDataLayer],
+ [call(getResourcesFromModels, gltfResources), mockResourcesFromModels],
+ [
+ call([mockDataLayer, 'createCustomAsset'], {
+ name: mockPayload.name,
+ composite: Buffer.from(JSON.stringify(mockPayload.composite)),
+ resources: [...mockPayload.resources, ...mockResourcesFromModels],
+ thumbnail: transformBase64ResourceToBinary(mockPayload.thumbnail)
+ }),
+ undefined
+ ]
+ ])
+ .put(getAssetCatalog())
+ .put(selectAssetsTab({ tab: AssetsTab.CustomAssets }))
+ .run()
+ })
+
+ it('should handle case without thumbnail', async () => {
+ const payloadWithoutThumbnail = { ...mockPayload, thumbnail: undefined }
+ const actionWithoutThumbnail = { ...mockAction, payload: payloadWithoutThumbnail }
+ const gltfResources = payloadWithoutThumbnail.resources.filter((r) => r.endsWith('.gltf') || r.endsWith('.glb'))
+
+ return expectSaga(createCustomAssetSaga, actionWithoutThumbnail)
+ .provide([
+ [call(getDataLayerInterface), mockDataLayer],
+ [call(getResourcesFromModels, gltfResources), []],
+ [
+ call([mockDataLayer, 'createCustomAsset'], {
+ name: payloadWithoutThumbnail.name,
+ composite: Buffer.from(JSON.stringify(payloadWithoutThumbnail.composite)),
+ resources: payloadWithoutThumbnail.resources,
+ thumbnail: undefined
+ }),
+ undefined
+ ]
+ ])
+ .put(getAssetCatalog())
+ .put(selectAssetsTab({ tab: AssetsTab.CustomAssets }))
+ .run()
+ })
+
+ it('should handle errors', async () => {
+ const error$ = new Error('Failed to create asset')
+ const gltfResources = mockPayload.resources.filter((r) => r.endsWith('.gltf') || r.endsWith('.glb'))
+
+ return expectSaga(createCustomAssetSaga, mockAction)
+ .provide([
+ [call(getDataLayerInterface), mockDataLayer],
+ [call(getResourcesFromModels, gltfResources), throwError(error$)]
+ ])
+ .put(error({ error: ErrorType.CreateCustomAsset }))
+ .run()
+ })
+
+ it('should do nothing if dataLayer is not available', async () => {
+ return expectSaga(createCustomAssetSaga, mockAction)
+ .provide([[call(getDataLayerInterface), null]])
+ .not.put(getAssetCatalog())
+ .not.put(selectAssetsTab({ tab: AssetsTab.CustomAssets }))
+ .run()
+ })
+})
diff --git a/packages/@dcl/inspector/src/redux/data-layer/sagas/create-custom-asset.ts b/packages/@dcl/inspector/src/redux/data-layer/sagas/create-custom-asset.ts
new file mode 100644
index 000000000..77425b2e2
--- /dev/null
+++ b/packages/@dcl/inspector/src/redux/data-layer/sagas/create-custom-asset.ts
@@ -0,0 +1,39 @@
+import { PayloadAction } from '@reduxjs/toolkit'
+import { call, put } from 'redux-saga/effects'
+import { IDataLayer, error, getAssetCatalog, getDataLayerInterface } from '../index'
+import { ErrorType } from '../index'
+import { AssetData } from '../../../lib/logic/catalog'
+import { selectAssetsTab } from '../../ui'
+import { AssetsTab } from '../../ui/types'
+import { getResourcesFromModels } from '../../../lib/babylon/decentraland/get-resources'
+import { transformBase64ResourceToBinary } from '../../../lib/data-layer/host/fs-utils'
+
+export function* createCustomAssetSaga(
+ action: PayloadAction<{
+ name: string
+ composite: AssetData['composite']
+ resources: string[]
+ thumbnail?: string
+ }>
+) {
+ const dataLayer: IDataLayer = yield call(getDataLayerInterface)
+ if (!dataLayer) return
+ try {
+ const models = action.payload.resources.filter(
+ (resource) => resource.endsWith('.gltf') || resource.endsWith('.glb')
+ )
+ const resourcesFromModels: string[] = yield call(getResourcesFromModels, models)
+ const resources = [...action.payload.resources, ...resourcesFromModels]
+ yield call(dataLayer.createCustomAsset, {
+ name: action.payload.name,
+ composite: Buffer.from(JSON.stringify(action.payload.composite)),
+ resources,
+ thumbnail: action.payload.thumbnail ? transformBase64ResourceToBinary(action.payload.thumbnail) : undefined
+ })
+ // Fetch asset catalog again
+ yield put(getAssetCatalog())
+ yield put(selectAssetsTab({ tab: AssetsTab.CustomAssets }))
+ } catch (e) {
+ yield put(error({ error: ErrorType.CreateCustomAsset }))
+ }
+}
diff --git a/packages/@dcl/inspector/src/redux/data-layer/sagas/delete-custom-asset.spec.ts b/packages/@dcl/inspector/src/redux/data-layer/sagas/delete-custom-asset.spec.ts
new file mode 100644
index 000000000..a9c69b74d
--- /dev/null
+++ b/packages/@dcl/inspector/src/redux/data-layer/sagas/delete-custom-asset.spec.ts
@@ -0,0 +1,45 @@
+import { PayloadAction } from '@reduxjs/toolkit'
+import { expectSaga } from 'redux-saga-test-plan'
+import { call, select } from 'redux-saga-test-plan/matchers'
+import { getAssetCatalog, getDataLayerInterface, IDataLayer } from '..'
+import { error } from '../index'
+import { ErrorType } from '../index'
+import { deleteCustomAssetSaga } from './delete-custom-asset'
+
+describe('deleteCustomAssetSaga', () => {
+ const mockAction: PayloadAction<{ assetId: string }> = {
+ type: 'deleteCustomAsset',
+ payload: { assetId: 'test-asset-id' }
+ }
+
+ const mockDataLayer: Partial = {
+ deleteCustomAsset: jest.fn()
+ }
+
+ it('should delete custom asset and get asset catalog', () => {
+ return expectSaga(deleteCustomAssetSaga, mockAction)
+ .provide([
+ [select(getDataLayerInterface), mockDataLayer],
+ [call([mockDataLayer, 'deleteCustomAsset'], { assetId: 'test-asset-id' }), undefined]
+ ])
+ .put(getAssetCatalog())
+ .run()
+ })
+
+ it('should handle error when deleting custom asset', () => {
+ const testError = new Error('Test error')
+ return expectSaga(deleteCustomAssetSaga, mockAction)
+ .provide([
+ [select(getDataLayerInterface), mockDataLayer],
+ [call([mockDataLayer, 'deleteCustomAsset'], { assetId: 'test-asset-id' }), Promise.reject(testError)]
+ ])
+ .put(error({ error: ErrorType.DeleteCustomAsset }))
+ .run()
+ })
+
+ it('should do nothing if data layer is not available', () => {
+ return expectSaga(deleteCustomAssetSaga, mockAction)
+ .provide([[select(getDataLayerInterface), null]])
+ .run()
+ })
+})
diff --git a/packages/@dcl/inspector/src/redux/data-layer/sagas/delete-custom-asset.ts b/packages/@dcl/inspector/src/redux/data-layer/sagas/delete-custom-asset.ts
new file mode 100644
index 000000000..dd88dd832
--- /dev/null
+++ b/packages/@dcl/inspector/src/redux/data-layer/sagas/delete-custom-asset.ts
@@ -0,0 +1,18 @@
+import { PayloadAction } from '@reduxjs/toolkit'
+import { call, put, select } from 'redux-saga/effects'
+import { getAssetCatalog, getDataLayerInterface, IDataLayer } from '..'
+import { error } from '../index'
+import { ErrorType } from '../index'
+
+export function* deleteCustomAssetSaga(action: PayloadAction<{ assetId: string }>) {
+ try {
+ const dataLayer: IDataLayer = yield select(getDataLayerInterface)
+ if (!dataLayer) return
+
+ yield call([dataLayer, 'deleteCustomAsset'], { assetId: action.payload.assetId })
+ yield put(getAssetCatalog())
+ } catch (e) {
+ yield put(error({ error: ErrorType.DeleteCustomAsset }))
+ console.error(e)
+ }
+}
diff --git a/packages/@dcl/inspector/src/redux/data-layer/sagas/get-asset-catalog.ts b/packages/@dcl/inspector/src/redux/data-layer/sagas/get-asset-catalog.ts
index f9cd7b9dc..718c89be7 100644
--- a/packages/@dcl/inspector/src/redux/data-layer/sagas/get-asset-catalog.ts
+++ b/packages/@dcl/inspector/src/redux/data-layer/sagas/get-asset-catalog.ts
@@ -1,15 +1,22 @@
-import { call, put } from 'redux-saga/effects'
+import { all, call, put } from 'redux-saga/effects'
import { ErrorType, IDataLayer, error, getDataLayerInterface } from '../'
import { updateAssetCatalog } from '../../app'
import { AssetCatalogResponse } from '../../../lib/data-layer/remote-data-layer'
+import { CustomAsset } from '../../../lib/logic/catalog'
export function* getAssetCatalogSaga() {
const dataLayer: IDataLayer = yield call(getDataLayerInterface)
if (!dataLayer) return
try {
- const assets: AssetCatalogResponse = yield call(dataLayer.getAssetCatalog, {})
- yield put(updateAssetCatalog({ assets }))
+ const [assets, customAssetBuffers]: [AssetCatalogResponse, { assets: { data: Uint8Array }[] }] = yield all([
+ call(dataLayer.getAssetCatalog, {}),
+ call(dataLayer.getCustomAssets, {})
+ ])
+ const customAssets: CustomAsset[] = customAssetBuffers.assets.map((buffer) =>
+ JSON.parse(new TextDecoder().decode(buffer.data))
+ )
+ yield put(updateAssetCatalog({ assets, customAssets }))
} catch (e) {
yield put(error({ error: ErrorType.GetAssetCatalog }))
}
diff --git a/packages/@dcl/inspector/src/redux/data-layer/sagas/index.ts b/packages/@dcl/inspector/src/redux/data-layer/sagas/index.ts
index d934a782a..3ddedb00d 100644
--- a/packages/@dcl/inspector/src/redux/data-layer/sagas/index.ts
+++ b/packages/@dcl/inspector/src/redux/data-layer/sagas/index.ts
@@ -1,4 +1,4 @@
-import { takeEvery } from 'redux-saga/effects'
+import { takeEvery, takeLatest } from 'redux-saga/effects'
import {
connect,
@@ -13,7 +13,10 @@ import {
importAsset,
removeAsset,
getThumbnails,
- saveThumbnail
+ saveThumbnail,
+ createCustomAsset,
+ deleteCustomAsset,
+ renameCustomAsset
} from '..'
import { connectSaga } from './connect'
import { reconnectSaga } from './reconnect'
@@ -27,6 +30,9 @@ import { removeAssetSaga } from './remove-asset'
import { connectedSaga } from './connected'
import { getThumbnailsSaga } from './get-thumbnails'
import { saveThumbnailSaga } from './save-thumbnail'
+import { createCustomAssetSaga } from './create-custom-asset'
+import { deleteCustomAssetSaga } from './delete-custom-asset'
+import { renameCustomAssetSaga } from './rename-custom-asset'
export function* dataLayerSaga() {
yield takeEvery(connect.type, connectSaga)
@@ -42,6 +48,9 @@ export function* dataLayerSaga() {
yield takeEvery(removeAsset.type, removeAssetSaga)
yield takeEvery(getThumbnails.type, getThumbnailsSaga)
yield takeEvery(saveThumbnail.type, saveThumbnailSaga)
+ yield takeEvery(createCustomAsset.type, createCustomAssetSaga)
+ yield takeLatest(deleteCustomAsset.type, deleteCustomAssetSaga)
+ yield takeEvery(renameCustomAsset.type, renameCustomAssetSaga)
}
export default dataLayerSaga
diff --git a/packages/@dcl/inspector/src/redux/data-layer/sagas/rename-custom-asset.spec.ts b/packages/@dcl/inspector/src/redux/data-layer/sagas/rename-custom-asset.spec.ts
new file mode 100644
index 000000000..7a3a860af
--- /dev/null
+++ b/packages/@dcl/inspector/src/redux/data-layer/sagas/rename-custom-asset.spec.ts
@@ -0,0 +1,48 @@
+import { PayloadAction } from '@reduxjs/toolkit'
+import { expectSaga } from 'redux-saga-test-plan'
+import { call, select } from 'redux-saga-test-plan/matchers'
+import { getAssetCatalog, getDataLayerInterface, IDataLayer } from '..'
+import { error } from '../index'
+import { ErrorType } from '../index'
+import { renameCustomAssetSaga } from './rename-custom-asset'
+
+describe('renameCustomAssetSaga', () => {
+ const mockAction: PayloadAction<{ assetId: string; newName: string }> = {
+ type: 'renameCustomAsset',
+ payload: { assetId: 'test-asset-id', newName: 'New Asset Name' }
+ }
+
+ const mockDataLayer: Partial = {
+ renameCustomAsset: jest.fn()
+ }
+
+ it('should rename custom asset and get asset catalog', () => {
+ return expectSaga(renameCustomAssetSaga, mockAction)
+ .provide([
+ [select(getDataLayerInterface), mockDataLayer],
+ [call([mockDataLayer, 'renameCustomAsset'], { assetId: 'test-asset-id', newName: 'New Asset Name' }), undefined]
+ ])
+ .put(getAssetCatalog())
+ .run()
+ })
+
+ it('should handle error when renaming custom asset', () => {
+ const testError = new Error('Test error')
+ return expectSaga(renameCustomAssetSaga, mockAction)
+ .provide([
+ [select(getDataLayerInterface), mockDataLayer],
+ [
+ call([mockDataLayer, 'renameCustomAsset'], { assetId: 'test-asset-id', newName: 'New Asset Name' }),
+ Promise.reject(testError)
+ ]
+ ])
+ .put(error({ error: ErrorType.RenameCustomAsset }))
+ .run()
+ })
+
+ it('should do nothing if data layer is not available', () => {
+ return expectSaga(renameCustomAssetSaga, mockAction)
+ .provide([[select(getDataLayerInterface), null]])
+ .run()
+ })
+})
diff --git a/packages/@dcl/inspector/src/redux/data-layer/sagas/rename-custom-asset.ts b/packages/@dcl/inspector/src/redux/data-layer/sagas/rename-custom-asset.ts
new file mode 100644
index 000000000..caedbd1ee
--- /dev/null
+++ b/packages/@dcl/inspector/src/redux/data-layer/sagas/rename-custom-asset.ts
@@ -0,0 +1,21 @@
+import { PayloadAction } from '@reduxjs/toolkit'
+import { call, put, select } from 'redux-saga/effects'
+import { getAssetCatalog, getDataLayerInterface, IDataLayer } from '..'
+import { error } from '../index'
+import { ErrorType } from '../index'
+
+export function* renameCustomAssetSaga(action: PayloadAction<{ assetId: string; newName: string }>) {
+ try {
+ const dataLayer: IDataLayer = yield select(getDataLayerInterface)
+ if (!dataLayer) return
+
+ yield call([dataLayer, 'renameCustomAsset'], {
+ assetId: action.payload.assetId,
+ newName: action.payload.newName
+ })
+ yield put(getAssetCatalog())
+ } catch (e) {
+ yield put(error({ error: ErrorType.RenameCustomAsset }))
+ console.error(e)
+ }
+}
diff --git a/packages/@dcl/inspector/src/redux/ui/types.ts b/packages/@dcl/inspector/src/redux/ui/types.ts
index c9ff524b1..8b25f4907 100644
--- a/packages/@dcl/inspector/src/redux/ui/types.ts
+++ b/packages/@dcl/inspector/src/redux/ui/types.ts
@@ -1,7 +1,10 @@
export enum AssetsTab {
FileSystem = 'FileSystem',
+ CustomAssets = 'CustomAssets',
AssetsPack = 'AssetsPack',
- Import = 'Import'
+ Import = 'Import',
+ RenameAsset = 'RenameAsset',
+ CreateCustomAsset = 'CreateCustomAsset'
}
export enum PanelName {
diff --git a/packages/@dcl/sdk-commands/package-lock.json b/packages/@dcl/sdk-commands/package-lock.json
index 0548d6212..8c5f90a6a 100644
--- a/packages/@dcl/sdk-commands/package-lock.json
+++ b/packages/@dcl/sdk-commands/package-lock.json
@@ -65,7 +65,7 @@
"../inspector": {
"version": "0.1.0",
"dependencies": {
- "@dcl/asset-packs": "^2.1.1",
+ "@dcl/asset-packs": "^2.1.2",
"ts-deepmerge": "^7.0.0"
},
"devDependencies": {
@@ -3139,7 +3139,7 @@
"@babylonjs/inspector": "~6.18.0",
"@babylonjs/loaders": "~6.18.0",
"@babylonjs/materials": "~6.18.0",
- "@dcl/asset-packs": "^2.1.1",
+ "@dcl/asset-packs": "^2.1.2",
"@dcl/ecs": "file:../ecs",
"@dcl/ecs-math": "2.1.0",
"@dcl/mini-rpc": "^1.0.7",