Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions src/components/ItemEditorPage/CenterPanel/CenterPanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,15 @@
margin-left: 2px;
}

.CenterPanel .footer .option:last-of-type {
margin-right: 16px;
.CenterPanel .footer .option.scene-boundaries {
margin-left: auto;
}

.CenterPanel .footer .option.validation-status {
margin-left: 8px;
margin-right: 16px;
}

.CenterPanel .footer .option.active {
background-color: #ff2d55;
}
Expand Down Expand Up @@ -190,6 +194,46 @@
}
}

.CenterPanel .footer .option.validation-status {
padding: 4px 10px 6px 10px;
}

.CenterPanel .footer .option.validation-status .icon {
font-size: 18px;
line-height: 20px;
margin: 0 !important;
}

.CenterPanel .footer .option.validation-status .validation-icon.pass {
color: #21ba45;
}

.CenterPanel .footer .option.validation-status .validation-icon.fail {
color: #ff4d4d;
}

.CenterPanel .footer .option.validation-status .validation-icon.warn {
color: #f0ad4e;
}

.CenterPanel .footer .option.validation-status .ui.loader {
margin: 0;
width: 18px;
height: 18px;
}

.CenterPanel .footer .option.validation-fail {
background-color: rgba(255, 77, 77, 0.2);
}

.CenterPanel .footer .option.validation-warn {
background-color: rgba(240, 173, 78, 0.2);
}

.CenterPanel .footer .option.validation-pass {
background-color: rgba(33, 186, 69, 0.2);
}

.CenterPanel.import-files-modal-is-open {
pointer-events: none;
}
Expand Down
198 changes: 192 additions & 6 deletions src/components/ItemEditorPage/CenterPanel/CenterPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,50 @@
import * as React from 'react'
import type { Wearable } from 'decentraland-ecs'
import { BodyShape, PreviewEmote, WearableCategory } from '@dcl/schemas'
import { Dropdown, DropdownProps, Popup, Icon, Loader, Center, EmoteControls, DropdownItemProps, Button } from 'decentraland-ui'
import {
Dropdown,
DropdownProps,
Popup,
Icon,
Loader,
Center,
EmoteControls,
DropdownItemProps,
Button,
Modal,
ModalNavigation
} from 'decentraland-ui'
import { AnimationControls, WearablePreview, ZoomControls } from 'decentraland-ui2'
import { SocialEmoteAnimation } from '@dcl/schemas/dist/dapps/preview/social-emote-animation'
import { getAnalytics } from 'decentraland-dapps/dist/modules/analytics/utils'
import { t } from 'decentraland-dapps/dist/modules/translation/utils'
import { Color4 } from 'lib/colors'
import { isDevelopment } from 'lib/environment'
import { extractThirdPartyTokenId, extractTokenId, isThirdParty } from 'lib/urn'
import { loadAndValidateModel, EngineType } from 'lib/getModelData'
import { ValidationSeverity } from 'lib/glbValidation/types'
// Validation uses content storage URLs instead of blob URLs
import { isTPCollection } from 'modules/collection/utils'
import { EmoteData, ItemType } from 'modules/item/types'
import { isEmote } from 'modules/item/utils'
import { EmoteData, ItemType, Item } from 'modules/item/types'
import { isEmote, getModelPath } from 'modules/item/utils'
import { toBase64, toHex } from 'modules/editor/utils'
import { getSkinColors, getEyeColors, getHairColors } from 'modules/editor/avatar'
import BuilderIcon from 'components/Icon'
import ValidationIssuesPanel from 'components/ValidationIssuesPanel/ValidationIssuesPanel'
import AvatarColorDropdown from './AvatarColorDropdown'
import AvatarWearableDropdown from './AvatarWearableDropdown'
import { Props, State } from './CenterPanel.types'
import './CenterPanel.css'

export default class CenterPanel extends React.PureComponent<Props, State> {
state = {
state: State = {
showSceneBoundaries: false,
isShowingAvatarAttributes: false,
isLoading: false,
socialEmote: undefined
socialEmote: undefined,
validationIssues: undefined,
isValidating: false,
isValidationModalOpen: false
}

analytics = getAnalytics()
Expand Down Expand Up @@ -62,11 +81,92 @@ export default class CenterPanel extends React.PureComponent<Props, State> {
this.setState({ isLoading: true })
}

componentDidUpdate(prevProps: Props) {
if (prevProps.selectedItem?.id !== this.props.selectedItem?.id) {
void this.runValidation()
}
}

componentWillUnmount() {
const { onSetWearablePreviewController } = this.props
onSetWearablePreviewController(null)
}

runValidation = async () => {
const { selectedItem } = this.props
if (!selectedItem || !selectedItem.contents) {
this.setState({ validationIssues: undefined, isValidating: false })
return
}

this.setState({ isValidating: true, validationIssues: undefined })

try {
// Find the model file path from the item's representations
const modelPath = this.getItemModelPath(selectedItem)
if (!modelPath) {
console.warn('Validation: no model path found for item', selectedItem.id)
this.setState({ isValidating: false })
return
}

// Get the content hash for the model file
const modelHash = selectedItem.contents[modelPath]
if (!modelHash) {
console.warn('Validation: no content hash for model path', modelPath)
this.setState({ isValidating: false })
return
}

// Fetch the model from the builder's content storage
const { getContentsStorageUrl } = await import('lib/api/builder')
const modelUrl = getContentsStorageUrl(modelHash)

// Also build mappings for textures/resources so the GLTF loader can resolve them
const contentMappings: Record<string, string> = {}
for (const [path, hash] of Object.entries(selectedItem.contents)) {
if (path !== modelPath) {
contentMappings[path] = getContentsStorageUrl(hash)
}
}

const category = selectedItem.data?.category as WearableCategory | undefined
const { validationResult } = await loadAndValidateModel(
modelUrl,
{
mappings: contentMappings,
width: 1024,
height: 1024,
engine: EngineType.BABYLON
},
category
)
this.setState({ validationIssues: validationResult.issues, isValidating: false })
} catch (error) {
console.error('Validation failed:', error)
// On error, show an empty list so the icon doesn't stay as a gray circle
this.setState({ validationIssues: [], isValidating: false })
}
}

getItemModelPath = (item: Item): string | undefined => {
if (item.data && 'representations' in item.data) {
return getModelPath(item.data.representations as any)
}
return undefined
}

handleOpenValidationModal = () => {
const { validationIssues } = this.state
if (validationIssues && validationIssues.length > 0) {
this.setState({ isValidationModalOpen: true })
}
}

handleCloseValidationModal = () => {
this.setState({ isValidationModalOpen: false })
}

handleToggleShowingAvatarAttributes = () => {
this.setState({ isShowingAvatarAttributes: !this.state.isShowingAvatarAttributes })
}
Expand Down Expand Up @@ -152,6 +252,11 @@ export default class CenterPanel extends React.PureComponent<Props, State> {
}

this.setState({ isLoading: false })

// Run validation once the preview has loaded (item data is ready)
if (this.state.validationIssues === undefined && !this.state.isValidating) {
void this.runValidation()
}
}

handlePlayEmote = () => {
Expand Down Expand Up @@ -233,6 +338,85 @@ export default class CenterPanel extends React.PureComponent<Props, State> {
)
}

renderValidationStatus = () => {
const { selectedItem } = this.props
const { validationIssues, isValidating, isLoading: isPreviewLoading } = this.state

// Show loading state only while validation hasn't produced results yet
const isWaiting = !validationIssues && (isPreviewLoading || isValidating)

const hasErrors = validationIssues?.some(i => i.severity === ValidationSeverity.ERROR) ?? false
const hasWarnings = validationIssues?.some(i => i.severity === ValidationSeverity.WARNING) ?? false
const hasIssues = hasErrors || hasWarnings
const isPass = validationIssues !== undefined && !hasIssues
const isClickable = hasIssues

let statusClass = ''
if (hasErrors) statusClass = 'validation-fail'
else if (hasWarnings) statusClass = 'validation-warn'
else if (isPass) statusClass = 'validation-pass'

let tooltipContent: string
if (!selectedItem || isWaiting) {
tooltipContent = t('item_editor.center_panel.validation_running')
} else if (isPass) {
tooltipContent = t('item_editor.center_panel.validation_pass')
} else if (hasErrors) {
tooltipContent = t('item_editor.center_panel.validation_fail')
} else if (hasWarnings) {
tooltipContent = t('item_editor.center_panel.validation_warnings')
} else {
tooltipContent = t('item_editor.center_panel.validation_tooltip')
}

let iconElement: React.ReactNode
if (!selectedItem || isWaiting) {
iconElement = <Loader active inline size="tiny" inverted />
} else if (isPass) {
iconElement = <Icon name="check circle" className="validation-icon pass" />
} else if (hasErrors) {
iconElement = <Icon name="times circle" className="validation-icon fail" />
} else if (hasWarnings) {
iconElement = <Icon name="exclamation circle" className="validation-icon warn" />
} else {
iconElement = <Loader active inline size="tiny" inverted />
}

return (
<Popup
content={tooltipContent}
position="top center"
trigger={
<div
className={`option validation-status ${statusClass}`}
onClick={isClickable ? this.handleOpenValidationModal : undefined}
style={{ cursor: isClickable ? 'pointer' : 'default' }}
>
{iconElement}
</div>
}
hideOnScroll
on="hover"
inverted
/>
)
}

renderValidationModal = () => {
const { validationIssues, isValidationModalOpen } = this.state

if (!isValidationModalOpen || !validationIssues) return null

return (
<Modal open size="small" onClose={this.handleCloseValidationModal}>
<ModalNavigation title={t('item_editor.center_panel.validation_modal_title')} onClose={this.handleCloseValidationModal} />
<Modal.Content>
<ValidationIssuesPanel issues={validationIssues} />
</Modal.Content>
</Modal>
)
}

render() {
const {
bodyShape,
Expand Down Expand Up @@ -324,12 +508,13 @@ export default class CenterPanel extends React.PureComponent<Props, State> {
) : (
this.renderEmoteSelector()
)}
<div className={`option ${showSceneBoundaries ? 'active' : ''}`}>
<div className={`option scene-boundaries ${showSceneBoundaries ? 'active' : ''}`}>
<BuilderIcon
name="cylinder"
onClick={() => this.setState(prevState => ({ showSceneBoundaries: !prevState.showSceneBoundaries }))}
/>
</div>
{this.renderValidationStatus()}
</div>
<div className={`avatar-attributes ${isShowingAvatarAttributes ? 'active' : ''}`}>
<div className="dropdown-container">
Expand Down Expand Up @@ -421,6 +606,7 @@ export default class CenterPanel extends React.PureComponent<Props, State> {
</div>
</div>
</div>
{this.renderValidationModal()}
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Wearable } from 'decentraland-ecs'
import { BodyShape, IPreviewController, PreviewEmote, WearableCategory } from '@dcl/schemas'
import { SocialEmoteAnimation } from '@dcl/schemas/dist/dapps/preview/social-emote-animation'
import type { ValidationIssue } from 'lib/glbValidation/types'
import { Collection } from 'modules/collection/types'
import {
setEmote,
Expand Down Expand Up @@ -53,4 +54,7 @@ export type State = {
showSceneBoundaries: boolean
isLoading: boolean
socialEmote?: SocialEmoteAnimation
validationIssues?: ValidationIssue[]
isValidating: boolean
isValidationModalOpen: boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import Modal from 'decentraland-dapps/dist/containers/Modal'
import { getAnalytics } from 'decentraland-dapps/dist/modules/analytics/utils'
import { T, t } from 'decentraland-dapps/dist/modules/translation/utils'
import { config } from 'config'
import { EngineType, getModelData } from 'lib/getModelData'
import { EngineType, getModelData, loadAndValidateModel } from 'lib/getModelData'
import { ValidationSeverity } from 'lib/glbValidation/types'
import { getExtension, toMB } from 'lib/file'
import {
buildThirdPartyURN,
Expand Down Expand Up @@ -204,6 +205,34 @@ export const CreateAndEditMultipleItemsModal: FC<Props> = (props: Props) => {

itemFactory.withThumbnail(thumbnail)

// Run GLB validation on the model
const modelPath = getModelPath(loadedFile.wearable.data.representations)
if (modelPath && loadedFile.content[modelPath]) {
const modelUrl = URL.createObjectURL(loadedFile.content[modelPath])
try {
const wearableCategory = loadedFile.wearable.data.category
const { validationResult } = await loadAndValidateModel(
modelUrl,
{
width: 1024,
height: 1024,
extension: getExtension(modelPath) || undefined,
engine: EngineType.BABYLON
},
wearableCategory,
loadedFile.content
)

const errorIssues = validationResult.issues.filter(i => i.severity === ValidationSeverity.ERROR)
if (errorIssues.length > 0) {
const errorMessages = errorIssues.map(i => t(i.messageKey, i.messageParams || {})).join('; ')
throw new Error(errorMessages)
}
} finally {
URL.revokeObjectURL(modelUrl)
}
}

// Set the UNIQUE rarity so all items have this rarity as default although TP items don't require rarity
itemFactory.withRarity(Rarity.UNIQUE)

Expand Down
Loading
Loading