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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion Editor/Sources/AdaEditor/Assets/Localization/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1655,5 +1655,14 @@
"Save annotation": "Save annotation",
"Edit annotation #{0}: {1}": "Edit annotation #{0}: {1}",
"Ready to send together: {0}": "Ready to send together: {0}",
"This note was edited in the composer. Edit its text there or remove it before replacing it.": "This note was edited in the composer. Edit its text there or remove it before replacing it."
"This note was edited in the composer. Edit its text there or remove it before replacing it.": "This note was edited in the composer. Edit its text there or remove it before replacing it.",
"Morph Targets": "Morph Targets",
"MORPH TARGETS": "MORPH TARGETS",
"Loading morph target model…": "Loading morph target model…",
"This model has no named morph targets.": "This model has no named morph targets.",
"Targets without a name cannot be set from a scene.": "Targets without a name cannot be set from a scene.",
"Applied to scene. Save the scene to keep these weights.": "Applied to scene. Save the scene to keep these weights.",
"Morph weights cleared.": "Morph weights cleared.",
"Morph weights applied": "Morph weights applied",
"Morph weights cleared": "Morph weights cleared"
}
11 changes: 10 additions & 1 deletion Editor/Sources/AdaEditor/Assets/Localization/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1655,5 +1655,14 @@
"Save annotation": "Сохранить аннотацию",
"Edit annotation #{0}: {1}": "Редактировать аннотацию №{0}: {1}",
"Ready to send together: {0}": "Будут отправлены вместе: {0}",
"This note was edited in the composer. Edit its text there or remove it before replacing it.": "Текст замечания изменён в композере. Отредактируйте его там или удалите аннотацию перед заменой."
"This note was edited in the composer. Edit its text there or remove it before replacing it.": "Текст замечания изменён в композере. Отредактируйте его там или удалите аннотацию перед заменой.",
"Morph Targets": "Цели морфинга",
"MORPH TARGETS": "ЦЕЛИ МОРФИНГА",
"Loading morph target model…": "Загрузка модели с целями морфинга…",
"This model has no named morph targets.": "У этой модели нет именованных целей морфинга.",
"Targets without a name cannot be set from a scene.": "Цели без имени нельзя задать из сцены.",
"Applied to scene. Save the scene to keep these weights.": "Применено к сцене. Сохраните сцену, чтобы сохранить эти веса.",
"Morph weights cleared.": "Веса морфинга сброшены.",
"Morph weights applied": "Веса морфинга применены",
"Morph weights cleared": "Веса морфинга сброшены"
}
1 change: 1 addition & 0 deletions Editor/Sources/AdaEditor/EditorComponentRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ enum EditorComponentRegistry {
AssetsManager.registerAssetType(SpineSkeleton.self)
RuntimeTypeRegistry.registerComponent(AudioSource.self, names: ["AudioSource"], makeDefault: { AudioSource() })
ModelAnimationPlayback3DComponent.registerComponent()
MorphWeights3DComponent.registerComponent()
CharacterController3DComponent.registerComponent()
CameraCollision3DComponent.registerComponent()
RuntimeTypeRegistry.registerComponent(UIComponent.self, names: ["UIComponent"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ extension EditorComponentRegistry {
if case .null = value { return nil }
return try JSONDecoder().decode(Model3DSourceLOD.self, from: Data(value.jsonString.utf8))
},
voxel: voxelSettings(in: payload)
voxel: voxelSettings(in: payload),
morphWeights: EditorMorphWeightsPayload.decode(payload["morphWeights"])
)
}
)
Expand Down
107 changes: 107 additions & 0 deletions Editor/Sources/AdaEditor/EditorMorphTargetsModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
@_spi(AdaEngine) import AdaEngine
import Foundation
import Observation

/// Scene payloads keep morph weights as a structured map from target name to weight, the shape `Model3DSource.morphWeights` has.
enum EditorMorphWeightsPayload {
static func decode(_ value: EditorSceneValue?) -> [String: Float] {
guard case let .object(entries)? = value else {
return [:]
}
var weights: [String: Float] = [:]
for (name, entry) in entries {
if let number = entry.doubleValue, number.isFinite { weights[name] = Float(number) }
}
return weights
}

static func encode(_ weights: [String: Float]) -> EditorSceneValue {
.object(weights.mapValues { .double(Double($0)) })
}
}

/// The named morph targets of a model and the weights an author has set for them.
@Observable
@MainActor
final class EditorMorphTargetsModel {
struct Target: Equatable, Identifiable {
let name: String
/// The weight the file gives the target when nothing overrides it.
let defaultWeight: Float
var id: String { name }
}

/// The slider range. glTF weights are normally 0 to 1; the renderer extrapolates outside that.
static let weightRange: ClosedRange<Float> = 0...1

private(set) var targets: [Target] = []
/// Targets of the model that have no name; they cannot be addressed from a scene.
private(set) var unnamedTargetCount = 0
private(set) var weights: [String: Float] = [:]

func configure(asset: ModelAsset3D, authored: [String: Float]) {
var seen: [String: Float] = [:]
var order: [String] = []
var unnamed = 0
for (index, mesh) in asset.meshes.enumerated() where mesh.hasMorphTargets {
let names = mesh.morphTargetNames
let defaults = asset.meshMorphWeights.indices.contains(index) ? asset.meshMorphWeights[index] : []
for target in 0..<mesh.morphTargetCount {
guard names.indices.contains(target), !names[target].isEmpty else {
unnamed += 1
continue
}
if seen[names[target]] == nil {
order.append(names[target])
seen[names[target]] = defaults.indices.contains(target) ? defaults[target] : 0
}
}
}
targets = order.map { Target(name: $0, defaultWeight: seen[$0] ?? 0) }
unnamedTargetCount = unnamed
weights = authored.filter { entry in seen[entry.key] != nil }
}

/// The weight shown for a target: the authored one, else the file's default.
func weight(of target: Target) -> Float {
weights[target.name] ?? target.defaultWeight
}

func setWeight(_ value: Float, for target: Target) {
guard value.isFinite else { return }
let clamped = min(max(value, Self.weightRange.lowerBound), Self.weightRange.upperBound)
if clamped == target.defaultWeight {
weights.removeValue(forKey: target.name)
} else {
weights[target.name] = clamped
}
}

/// Clears every authored weight, so the model's own defaults apply.
func reset() {
weights.removeAll()
}

var hasAuthoredWeights: Bool { !weights.isEmpty }
}

extension EditorWorkbenchViewModel {
/// One scene-history transaction that sets the weights a Model 3D reference starts with; an empty map removes them.
@discardableResult
func setMorphWeights(_ weights: [String: Float], target: EditorSkeletalAnimationTarget) -> Bool {
guard let document = sceneDocument(id: target.documentID), !document.isReadOnly,
document.sceneModel?.entities.contains(where: { $0.id == target.entityID }) == true
else { return false }
updateSceneModelDocument(id: target.documentID, status: weights.isEmpty ? "Morph weights cleared" : "Morph weights applied") { scene in
guard let index = scene.entities.firstIndex(where: { $0.id == target.entityID }),
scene.entities[index].components[EditorBuiltInComponentType.model3DSource] != nil
else { return }
if weights.isEmpty {
scene.entities[index].components[EditorBuiltInComponentType.model3DSource]?.removeValue(forKey: "morphWeights")
} else {
scene.entities[index].components[EditorBuiltInComponentType.model3DSource]?["morphWeights"] = EditorMorphWeightsPayload.encode(weights)
}
}
return true
}
}
12 changes: 8 additions & 4 deletions Editor/Sources/AdaEditor/UI/Editor/EditorAnimationPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import Math

struct EditorAnimationPanel: View {
let viewModel: EditorViewModel
@State private var usesSkeletalGraph = true
private enum Mode { case skeletalGraph, morphTargets, keyframes }
@State private var mode = Mode.skeletalGraph

@Environment(\.metrics) private var metrics
@Environment(\.theme) private var theme
Expand All @@ -13,12 +14,15 @@ struct EditorAnimationPanel: View {
VStack(alignment: .leading, spacing: 0) {
if let target = viewModel.skeletalAnimationTarget {
HStack(spacing: 8) {
cycleButton(title: "Skeletal Graph", identifier: "AdaEditor.Animator.GraphMode") { usesSkeletalGraph = true }
cycleButton(title: "Keyframes", identifier: "AdaEditor.Animator.KeyframeMode") { usesSkeletalGraph = false }
cycleButton(title: "Skeletal Graph", identifier: "AdaEditor.Animator.GraphMode") { mode = .skeletalGraph }
cycleButton(title: "Morph Targets", identifier: "AdaEditor.Animator.MorphMode") { mode = .morphTargets }
cycleButton(title: "Keyframes", identifier: "AdaEditor.Animator.KeyframeMode") { mode = .keyframes }
Spacer()
}.padding(6)
if usesSkeletalGraph {
if mode == .skeletalGraph {
EditorSkeletalAnimatorPanel(viewModel: viewModel, target: target).id(target.documentID + target.entityID)
} else if mode == .morphTargets {
EditorMorphTargetsPanel(viewModel: viewModel, target: target).id(target.documentID + target.entityID)
} else {
panelToolbar
panelContent
Expand Down
16 changes: 16 additions & 0 deletions Editor/Sources/AdaEditor/UI/Editor/EditorModelAssetPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ final class EditorModelAssetPreviewModel {
var playsAnimation = true { didSet { updatePlayback() } }
var animationGraph: AnimationGraph? { didSet { updatePlayback() } }
var graphEventLog: [String] = []
/// Morph target weights by name that the preview shows; every target the entity has is set to its entry, so a target removed from
/// the map needs its default put back by the caller.
var morphWeights: [String: Float] = [:] { didSet { applyMorphWeights() } }
@ObservationIgnored private var loadTask: Task<Void, Never>?
@ObservationIgnored private var normalizationCache: (ObjectIdentifier, Transform, AABB)?
@ObservationIgnored private weak var world: World?
Expand Down Expand Up @@ -56,6 +59,19 @@ final class EditorModelAssetPreviewModel {
animationRoot = world.getEntities().first { $0.components[ModelAnimation3DComponent.self] != nil }?.id
voxelRoot = world.getEntities().first { $0.components[VoxelAnimationPlayback3DComponent.self] != nil }?.id
updatePlayback()
applyMorphWeights()
}

private func applyMorphWeights() {
guard let world, !morphWeights.isEmpty else { return }
for entity in world.getEntities() {
guard var component = entity.components[MorphWeights3DComponent.self] else { continue }
var changed = false
for (name, weight) in morphWeights where weight.isFinite {
if component.weight(named: name) != weight, component.setWeight(weight, named: name) { changed = true }
}
if changed { entity.components[MorphWeights3DComponent.self] = component }
}
}

/// Whether the previewed asset is a MagicaVoxel scene with animation to play.
Expand Down
144 changes: 144 additions & 0 deletions Editor/Sources/AdaEditor/UI/Editor/EditorMorphTargetsPanel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
@_spi(AdaEngine) import AdaEngine
import Foundation

/// One slider row per named morph target. Shared by the Animator panel and its tests.
struct EditorMorphWeightList: View {
let model: EditorMorphTargetsModel
var onChange: () -> Void = {}
@Environment(\.theme) private var theme

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(model.targets, id: \.name) { target in
row(target)
}
}
.padding(4)
}
.accessibilityIdentifier("AdaEditor.Morph.List")
}

private func row(_ target: EditorMorphTargetsModel.Target) -> some View {
HStack(spacing: 8) {
Text(target.name)
.font(.system(size: 11, weight: model.weights[target.name] == nil ? .regular : .semibold))
.frame(width: 130, alignment: .leading)
.accessibilityIdentifier("AdaEditor.Morph.Name.\(target.name)")
Slider(
value: Binding(
get: { Double(model.weight(of: target)) },
set: { value in
model.setWeight(Float(value), for: target)
onChange()
}
),
in: Double(EditorMorphTargetsModel.weightRange.lowerBound)...Double(EditorMorphTargetsModel.weightRange.upperBound)
)
.accessibilityIdentifier("AdaEditor.Morph.Slider.\(target.name)")
Text(String(format: "%.2f", model.weight(of: target)))
.font(.system(size: 11)).foregroundColor(theme.editorColors.muted)
.frame(width: 38, alignment: .trailing)
}
}
}

/// Sets the morph weights a Model 3D reference starts with, with a live preview. Applying is one undoable scene transaction.
struct EditorMorphTargetsPanel: View {
let viewModel: EditorViewModel
let target: EditorSkeletalAnimationTarget
@State private var model = EditorMorphTargetsModel()
@State private var preview = EditorModelAssetPreviewModel()
@State private var camera = EditorModelPreviewCamera()
@State private var status = ""
@Environment(\.theme) private var theme

var body: some View {
VStack(alignment: .leading, spacing: 6) {
toolbar
if preview.isLoading {
Text(L10n.text("Loading morph target model…")).font(.system(size: 12))
} else if let error = preview.error {
Text(error).font(.system(size: 12)).foregroundColor(.red)
} else if preview.asset != nil {
if model.targets.isEmpty {
Text(L10n.text("This model has no named morph targets."))
.font(.system(size: 12)).foregroundColor(theme.editorColors.muted)
.accessibilityIdentifier("AdaEditor.Morph.Empty")
} else {
HStack(spacing: 8) {
EditorModelPreviewViewport(model: preview, camera: camera)
.frame(width: 220, height: 150)
EditorMorphWeightList(model: model, onChange: { syncPreview() })
}
.frame(maxHeight: .infinity)
}
if model.unnamedTargetCount > 0 {
Text(L10n.text("Targets without a name cannot be set from a scene."))
.font(.system(size: 10)).foregroundColor(theme.editorColors.muted)
}
}
if !status.isEmpty { Text(status).font(.system(size: 11)).foregroundColor(theme.editorColors.muted) }
}
.padding(8)
.onAppear { load() }
.onDisappear { preview.cancel() }
.onChange(of: target.source) { old, new in
if old.source != new.source { load() } else { configure() }
}
.onChange(of: preview.asset.map(ObjectIdentifier.init)) { _, _ in configure() }
.accessibilityIdentifier("AdaEditor.Animator.MorphTargets")
}

private var readOnly: Bool { viewModel.workbench.activeSceneDocument?.isReadOnly == true }

private var toolbar: some View {
HStack(spacing: 8) {
Text(L10n.text("MORPH TARGETS")).font(.system(size: 11, weight: .semibold))
Spacer()
action("Apply to Scene", id: "Apply") {
if viewModel.workbench.setMorphWeights(model.weights, target: target) {
status = L10n.tr("Applied to scene. Save the scene to keep these weights.")
}
}
.disabled(readOnly || model.targets.isEmpty || model.weights == target.source.morphWeights)
action("Reset", id: "Reset") {
model.reset()
syncPreview()
if viewModel.workbench.setMorphWeights([:], target: target) {
status = L10n.tr("Morph weights cleared.")
}
}
.disabled(readOnly || (!model.hasAuthoredWeights && target.source.morphWeights.isEmpty))
}
}

private func action(_ title: String, id: String, _ action: @escaping () -> Void) -> some View {
Button(action: action) { Text(L10n.tr(title)).font(.system(size: 11)).padding(4) }
.buttonStyle(DefaultButtonStyle())
.accessibilityIdentifier("AdaEditor.Morph.\(id)")
}

private func load() {
do {
let url = try EditorModelResource.resolve(target.source.source, sourceURL: target.sceneURL, resourceRootURL: viewModel.projectAssetsURL)
preview.load(path: url.path)
} catch {
preview.isLoading = false
preview.error = error.localizedDescription
}
}

private func configure() {
guard let asset = preview.asset else {
return
}
model.configure(asset: asset, authored: target.source.morphWeights)
syncPreview()
}

/// Every target gets its shown weight, so a reset puts the defaults back.
private func syncPreview() {
preview.morphWeights = Dictionary(uniqueKeysWithValues: model.targets.map { ($0.name, model.weight(of: $0)) })
}
}
Loading
Loading