From 392992dd51fe8a48adf7381c5a6b267df0dfda6d Mon Sep 17 00:00:00 2001 From: Juan Cazala Date: Tue, 7 Jan 2025 12:54:10 -0300 Subject: [PATCH] feat: custom assets (#1046) * feat: create custom item * fix: exclude editor components * fix: add children to composite * fix: map resources from within actions * feat: support mapping resources from deeper properties like the case of material * feat: replace ids with {self} notation * fix: make a deep copy of component value * feat: read custom items from data-layer and render tab * feat: instance custom assets * fix: component value key * fix: map ids with multiple entities * fix: preserve names * chore: remove debuggers * fix: use addChild to set unique name * fix: styles * feat: delete custom asset * fix: allow writing over empty item * feat: custom component and icon * fix: enable tween component * feat: show instance of in entity header * fix: include resources loaded by GLTF into the custom asset package * chore: upgrade asset-packs * chore: rebuild * chore: fix tests * chore: roll back change in default formatter * feat: rename custom asset * chore: fix test * fix: smart item basic view re-render * fix: map cross-entity actions, conditions and triggers * fix: context menu multiselect * fix: centroid on custom assets with multiple roots * fix: use first entity name as custom asset name * feat: support naming custom asset before creation * chore: fix test * fix: icon size * feat: generate thumbnail * feat: added spinner while image is being generated * chore: added tests * chore: added more tests * fix: rename custom assets to custom items * fix: allow smart item basic view on custom items * fix: pass other resources necessary to render the model to generate the thumbnails * fix: bind hotkeys to body instead of canvas --- packages/@dcl/inspector/package-lock.json | 8 +- packages/@dcl/inspector/package.json | 2 +- .../components/AssetPreview/AssetPreview.tsx | 20 +- .../src/components/AssetPreview/types.ts | 4 +- .../src/components/AssetPreview/utils.ts | 8 +- .../src/components/Assets/Assets.css | 9 + .../src/components/Assets/Assets.tsx | 22 ++ .../components/Assets/custom-asset-icon.svg | 5 + .../CreateCustomAsset/CreateCustomAsset.css | 67 ++++ .../CreateCustomAsset/CreateCustomAsset.tsx | 142 ++++++++ .../src/components/CreateCustomAsset/index.ts | 1 + .../CustomAssets/ContextMenu/ContextMenu.ts | 19 ++ .../ContextMenu/CustomAssetContextMenu.tsx | 27 ++ .../CustomAssets/CustomAssetItem.tsx | 20 ++ .../components/CustomAssets/CustomAssets.css | 50 +++ .../components/CustomAssets/CustomAssets.tsx | 79 +++++ .../src/components/CustomAssets/index.ts | 2 + .../EntityHeader/EntityHeader.css | 56 ++- .../EntityHeader/EntityHeader.tsx | 68 ++-- .../SmartItemBasicView/SmartItemBasicView.tsx | 14 +- .../Hierarchy/ContextMenu/ContextMenu.tsx | 63 +++- .../src/components/Hierarchy/Hierarchy.css | 4 + .../src/components/Hierarchy/Hierarchy.tsx | 18 +- .../src/components/Hierarchy/icons/custom.svg | 8 + .../Icons/CustomAsset/CustomAsset.tsx | 16 + .../src/components/Icons/CustomAsset/index.ts | 3 + .../components/RenameAsset/RenameAsset.css | 49 +++ .../components/RenameAsset/RenameAsset.tsx | 59 ++++ .../src/components/RenameAsset/index.ts | 3 + .../src/components/Renderer/Renderer.tsx | 103 +++++- .../inspector/src/components/Tree/Tree.tsx | 34 +- .../SocketConnection/SocketConnection.tsx | 5 +- .../inspector/src/hooks/sdk/useCustomAsset.ts | 31 ++ .../src/hooks/sdk/useEntityComponent.ts | 1 - .../lib/babylon/decentraland/get-resources.ts | 41 +++ .../sdkComponents/gltf-container.ts | 13 +- .../src/lib/data-layer/host/fs-utils.ts | 3 +- .../src/lib/data-layer/host/rpc-methods.ts | 214 +++++++++++- .../src/lib/data-layer/proto/data-layer.proto | 39 +++ .../@dcl/inspector/src/lib/logic/analytics.ts | 1 + .../@dcl/inspector/src/lib/logic/catalog.ts | 5 + .../inspector/src/lib/sdk/components/index.ts | 17 +- .../@dcl/inspector/src/lib/sdk/drag-drop.ts | 9 +- .../src/lib/sdk/operations/add-asset/index.ts | 213 +++++++++--- .../src/lib/sdk/operations/add-child.spec.ts | 4 +- .../src/lib/sdk/operations/add-child.ts | 2 +- .../operations/create-custom-asset.spec.ts | 75 ++++ .../lib/sdk/operations/create-custom-asset.ts | 319 ++++++++++++++++++ .../sdk/operations/duplicate-entity.spec.ts | 2 +- .../src/lib/sdk/operations/index.spec.ts | 59 ++++ .../inspector/src/lib/sdk/operations/index.ts | 2 + .../@dcl/inspector/src/redux/app/index.ts | 12 +- .../inspector/src/redux/data-layer/index.ts | 55 ++- .../redux/data-layer/sagas/connect.spec.ts | 4 +- .../sagas/create-custom-asset.spec.ts | 101 ++++++ .../data-layer/sagas/create-custom-asset.ts | 39 +++ .../sagas/delete-custom-asset.spec.ts | 45 +++ .../data-layer/sagas/delete-custom-asset.ts | 18 + .../data-layer/sagas/get-asset-catalog.ts | 13 +- .../src/redux/data-layer/sagas/index.ts | 13 +- .../sagas/rename-custom-asset.spec.ts | 48 +++ .../data-layer/sagas/rename-custom-asset.ts | 21 ++ packages/@dcl/inspector/src/redux/ui/types.ts | 5 +- packages/@dcl/sdk-commands/package-lock.json | 4 +- 64 files changed, 2256 insertions(+), 160 deletions(-) create mode 100644 packages/@dcl/inspector/src/components/Assets/custom-asset-icon.svg create mode 100644 packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.css create mode 100644 packages/@dcl/inspector/src/components/CreateCustomAsset/CreateCustomAsset.tsx create mode 100644 packages/@dcl/inspector/src/components/CreateCustomAsset/index.ts create mode 100644 packages/@dcl/inspector/src/components/CustomAssets/ContextMenu/ContextMenu.ts create mode 100644 packages/@dcl/inspector/src/components/CustomAssets/ContextMenu/CustomAssetContextMenu.tsx create mode 100644 packages/@dcl/inspector/src/components/CustomAssets/CustomAssetItem.tsx create mode 100644 packages/@dcl/inspector/src/components/CustomAssets/CustomAssets.css create mode 100644 packages/@dcl/inspector/src/components/CustomAssets/CustomAssets.tsx create mode 100644 packages/@dcl/inspector/src/components/CustomAssets/index.ts create mode 100644 packages/@dcl/inspector/src/components/Hierarchy/icons/custom.svg create mode 100644 packages/@dcl/inspector/src/components/Icons/CustomAsset/CustomAsset.tsx create mode 100644 packages/@dcl/inspector/src/components/Icons/CustomAsset/index.ts create mode 100644 packages/@dcl/inspector/src/components/RenameAsset/RenameAsset.css create mode 100644 packages/@dcl/inspector/src/components/RenameAsset/RenameAsset.tsx create mode 100644 packages/@dcl/inspector/src/components/RenameAsset/index.ts create mode 100644 packages/@dcl/inspector/src/hooks/sdk/useCustomAsset.ts create mode 100644 packages/@dcl/inspector/src/lib/babylon/decentraland/get-resources.ts create mode 100644 packages/@dcl/inspector/src/lib/sdk/operations/create-custom-asset.spec.ts create mode 100644 packages/@dcl/inspector/src/lib/sdk/operations/create-custom-asset.ts create mode 100644 packages/@dcl/inspector/src/lib/sdk/operations/index.spec.ts create mode 100644 packages/@dcl/inspector/src/redux/data-layer/sagas/create-custom-asset.spec.ts create mode 100644 packages/@dcl/inspector/src/redux/data-layer/sagas/create-custom-asset.ts create mode 100644 packages/@dcl/inspector/src/redux/data-layer/sagas/delete-custom-asset.spec.ts create mode 100644 packages/@dcl/inspector/src/redux/data-layer/sagas/delete-custom-asset.ts create mode 100644 packages/@dcl/inspector/src/redux/data-layer/sagas/rename-custom-asset.spec.ts create mode 100644 packages/@dcl/inspector/src/redux/data-layer/sagas/rename-custom-asset.ts 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 ? ( +
+
+ + CUSTOM ITEMS +
+
+ ) : 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 ( + + { + const { assetId, onRename } = props as CustomAssetContextMenuProps + onRename(assetId) + }} + > + Rename + + { + const { assetId, onDelete } = props as CustomAssetContextMenuProps + onDelete(assetId) + }} + > + Delete + + + ) +} 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} : } +
+ {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",