feat(web): add audio speaker and particle authoring
This commit is contained in:
@@ -703,6 +703,9 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
|
||||
const activeCurve = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "CURVE") : undefined;
|
||||
const activeFont = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "FONT") : undefined;
|
||||
const activeText = activeNode?.dataId ? snapshot?.texts?.find((data) => data.id === activeNode.dataId) : undefined;
|
||||
const activeSound = activeNode?.dataId ? snapshot?.sounds?.find((data) => data.id === activeNode.dataId) : undefined;
|
||||
const activeSpeaker = activeNode?.dataId ? snapshot?.speakers?.find((data) => data.id === activeNode.dataId) : undefined;
|
||||
const activeParticleSettings = activeNode?.dataId ? snapshot?.particleSettings?.find((data) => data.id === activeNode.dataId) : undefined;
|
||||
const activeVolume = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "VOLUME") : undefined;
|
||||
const activeMetaball = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "METABALL") : undefined;
|
||||
const activeGreasePencil = activeNode?.dataId ? snapshot?.greasePencils?.find((data) => data.id === activeNode.dataId) : undefined;
|
||||
@@ -907,6 +910,9 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
|
||||
{packedImages.length > 0 ? <div className="property-section" data-testid="resource-workbench"><h3>Resources</h3><output>{packedImages.length} packed image assets</output>{packedImages.map((image) => <button key={image.assetId} type="button" onClick={() => onRequestAsset(image.assetId)}>加载 {image.name}</button>)}</div> : null}
|
||||
{(snapshot?.vfonts?.length ?? 0) > 0 || (snapshot?.sounds?.length ?? 0) > 0 || (snapshot?.speakers?.length ?? 0) > 0 || (snapshot?.texts?.length ?? 0) > 0 || (snapshot?.textures?.length ?? 0) > 0 || (snapshot?.particleSettings?.length ?? 0) > 0 ? <div className="property-section" data-testid="resource-scene-workbench"><h3>Scene Resources</h3><output data-testid="resource-scene-counts">VFonts {snapshot?.vfonts?.length ?? 0} · Sounds {snapshot?.sounds?.length ?? 0} · Speakers {snapshot?.speakers?.length ?? 0} · Texts {snapshot?.texts?.length ?? 0} · Textures {snapshot?.textures?.length ?? 0} · Particles {snapshot?.particleSettings?.length ?? 0}</output>{snapshot?.vfonts?.map((font) => <output key={font.id} data-resource-id={font.id}>VFont: {font.name} {font.packed ? "(packed)" : "(external)"}</output>)}{snapshot?.sounds?.map((sound) => <output key={sound.id} data-resource-id={sound.id}>Sound: {sound.name} · {sound.audioChannels}ch @ {sound.sampleRate}Hz</output>)}{snapshot?.speakers?.map((speaker) => <output key={speaker.id} data-resource-id={speaker.id}>Speaker: {speaker.name} · volume {speaker.volume.toFixed(2)}</output>)}{snapshot?.texts?.map((text) => <output key={text.id} data-resource-id={text.id}>Text: {text.name} · {text.lineCount} lines{ text.internal ? " (internal)" : ""}</output>)}{snapshot?.textures?.map((texture) => <output key={texture.id} data-resource-id={texture.id}>Texture: {texture.name}</output>)}{snapshot?.particleSettings?.map((settings) => <output key={settings.id} data-resource-id={settings.id}>Particles: {settings.name} · {settings.totalParticles} total</output>)}</div> : null}
|
||||
{activeText ? <div className="property-section" data-testid="text-datablock-workbench"><h3>Text Data-Block</h3><output>{activeText.name} · {activeText.lineCount} lines · {activeText.byteLength} bytes</output><textarea aria-label="Text source" value={textSource} maxLength={1_048_576} onChange={(event) => setTextSource(event.target.value)} /><div className="property-actions"><button type="button" disabled={textSource === activeText.source || new TextEncoder().encode(textSource).byteLength > 1_048_576} onClick={() => onCommand({ type: "setTextSource", dataId: activeText.id, source: textSource })}>Apply Text Source</button></div></div> : null}
|
||||
{activeSound ? <div className="property-section" data-testid="audio-workbench"><h3>Sound</h3><output>{activeSound.name} · {activeSound.audioChannels}ch @ {activeSound.sampleRate}Hz</output><output>Volume {activeSound.volume.toFixed(2)} · Pitch {activeSound.pitch.toFixed(2)}</output><div className="property-actions"><button type="button" onClick={() => onCommand(ioMediaCommands.setSoundProperties(activeSound.id, { volume: Math.min(1000000, activeSound.volume + 0.1) }))}>Increase Volume</button><button type="button" onClick={() => onCommand(ioMediaCommands.setSoundProperties(activeSound.id, { pitch: Math.min(1000000, activeSound.pitch + 0.05) }))}>Increase Pitch</button></div></div> : null}
|
||||
{activeSpeaker ? <div className="property-section" data-testid="speaker-workbench"><h3>Speaker</h3><output>{activeSpeaker.name} · {activeSpeaker.soundId ?? "No sound"}</output><output>Volume {activeSpeaker.volume.toFixed(2)} · Pitch {activeSpeaker.pitch.toFixed(2)}</output><div className="property-actions"><button type="button" onClick={() => onCommand(ioMediaCommands.setSpeakerProperties(activeSpeaker.id, { volume: Math.min(1000000, activeSpeaker.volume + 0.1) }))}>Increase Volume</button><button type="button" onClick={() => onCommand(ioMediaCommands.setSpeakerProperties(activeSpeaker.id, { coneVolumeOuter: Math.max(0, Math.min(1, activeSpeaker.coneVolumeOuter - 0.05)) }))}>Reduce Cone</button></div></div> : null}
|
||||
{activeParticleSettings ? <div className="property-section" data-testid="particle-settings-workbench"><h3>Particle Settings</h3><output>{activeParticleSettings.name} · {activeParticleSettings.totalParticles} particles</output><output>Lifetime {activeParticleSettings.lifetime.toFixed(2)} · Size {activeParticleSettings.size.toFixed(3)}</output><div className="property-actions"><button type="button" onClick={() => onCommand(ioMediaCommands.setParticleSettings(activeParticleSettings.id, { totalParticles: Math.min(1000000, activeParticleSettings.totalParticles + 100) }))}>Increase Count</button><button type="button" onClick={() => onCommand(ioMediaCommands.setParticleSettings(activeParticleSettings.id, { size: Math.min(1000000, activeParticleSettings.size + 0.01) }))}>Increase Size</button></div></div> : null}
|
||||
{activeCamera ? <div className="property-section" data-testid="camera-workbench"><h3>Camera</h3><output>{activeCamera.projection} · {activeCamera.lensMm.toFixed(1)}mm</output><button type="button" onClick={() => onCommand(animationRenderCommands.setCameraProperties(activeCamera.id, { projection: activeCamera.projection === "ORTHOGRAPHIC" ? "PERSPECTIVE" : "ORTHOGRAPHIC" }))}>切换投影</button><button type="button" onClick={() => onCommand(animationRenderCommands.setCameraProperties(activeCamera.id, { lensMm: Math.min(10000, activeCamera.lensMm + 5) }))}>增加焦距</button></div> : null}
|
||||
{activeLight ? <div className="property-section" data-testid="light-workbench"><h3>Light</h3><output>{activeLight.energy.toFixed(1)} energy</output><button type="button" onClick={() => onCommand(animationRenderCommands.setLightProperties(activeLight.id, { energy: Math.min(1e9, activeLight.energy + 10) }))}>增加能量</button><button type="button" onClick={() => onCommand(animationRenderCommands.setLightProperties(activeLight.id, { castsShadow: !(activeLight.castsShadow ?? true) }))}>切换阴影</button></div> : null}
|
||||
{activeWorld ? <div className="property-section" data-testid="world-workbench"><h3>World</h3><output>Exposure {activeWorld.exposure.toFixed(2)}</output><button type="button" onClick={() => onCommand(animationRenderCommands.setWorldProperties(activeWorld.id, { exposure: Math.max(-20, Math.min(20, activeWorld.exposure + 0.5)) }))}>增加曝光</button></div> : null}
|
||||
|
||||
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
@@ -420,6 +420,33 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
if (typeof payload.source !== "string" || new TextEncoder().encode(payload.source).byteLength > 1_048_576) throw report("NON_MESH_PROPERTY_INVALID", "Text source must be valid UTF-8 within the 1 MiB budget");
|
||||
return;
|
||||
}
|
||||
case "setSoundProperties": {
|
||||
const sound = currentSnapshot?.sounds?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!sound) throw report("NON_MESH_DATA_UNSUPPORTED", `Sound data block is unavailable: ${payload.dataId}`);
|
||||
if (!validPropertyKeys(payload.properties, ["volume", "attenuation", "pitch", "minGain", "maxGain", "distance", "offsetTime"])) throw report("NON_MESH_PROPERTY_INVALID", "Sound properties are empty or contain unsupported fields");
|
||||
for (const value of Object.values(payload.properties)) if (!boundedNumber(value, 0, 1_000_000)) throw report("NON_MESH_PROPERTY_INVALID", "Sound properties are outside the bounded range");
|
||||
return;
|
||||
}
|
||||
case "setSpeakerProperties": {
|
||||
const speaker = currentSnapshot?.speakers?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!speaker) throw report("NON_MESH_DATA_UNSUPPORTED", `Speaker data block is unavailable: ${payload.dataId}`);
|
||||
if (!validPropertyKeys(payload.properties, ["volumeMax", "volumeMin", "distanceMax", "distanceReference", "attenuation", "coneAngleOuter", "coneAngleInner", "coneVolumeOuter", "volume", "pitch"])) throw report("NON_MESH_PROPERTY_INVALID", "Speaker properties are empty or contain unsupported fields");
|
||||
for (const [key, value] of Object.entries(payload.properties)) {
|
||||
const maximum = key.startsWith("coneAngle") ? 360 : key === "coneVolumeOuter" ? 1 : 1_000_000;
|
||||
if (!boundedNumber(value, 0, maximum)) throw report("NON_MESH_PROPERTY_INVALID", "Speaker properties are outside the bounded range");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "setParticleSettings": {
|
||||
const settings = currentSnapshot?.particleSettings?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!settings) throw report("NON_MESH_DATA_UNSUPPORTED", `ParticleSettings data block is unavailable: ${payload.dataId}`);
|
||||
if (!validPropertyKeys(payload.properties, ["type", "from", "distribution", "physicsType", "totalParticles", "start", "end", "lifetime", "size", "drawSize"])) throw report("NON_MESH_PROPERTY_INVALID", "Particle settings are empty or contain unsupported fields");
|
||||
for (const [key, value] of Object.entries(payload.properties)) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1_000_000) throw report("NON_MESH_PROPERTY_INVALID", `Particle setting ${key} is outside the bounded range`);
|
||||
if (["type", "from", "distribution", "physicsType", "totalParticles"].includes(key) && !Number.isSafeInteger(value)) throw report("NON_MESH_PROPERTY_INVALID", `Particle setting ${key} must be an integer`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "setVolumeProperties": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || data.type !== "VOLUME") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Volume data block is unavailable: ${payload.dataId}`);
|
||||
|
||||
Reference in New Issue
Block a user