Fit sprite size to the texture aspect ratio
Add ↔ next to Width and ↕ next to Height in the inspector. Each sets its field so the sprite matches the ratio of its assigned texture or frame, reading the frame's own rect rather than the sheet's. The buttons reuse the existing FieldActions convention, so they simply do not appear when no texture is assigned, when the sprite is already proportional, or on assets whose schema has no texture property. Comparison is a cross product with a relative tolerance: 3135 of the 40000 size pairs up to 200x200 do not survive a round trip through a division, so an exact check would leave the button standing after its own click. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d84231b9f
commit
0b34bdd990
9 files changed
+412
-65
No files matched your search
@@ -0,0 +1,47 @@
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { resolveTexture } from '../model/texture';
|
||||
|
||||
export type SizeAxis = 'width' | 'height';
|
||||
|
||||
export interface PixelSize {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixel size of the picture a sprite wears: its frame's rectangle, else the whole image.
|
||||
* Null while that is not known – an upload still running has no measured size yet, and a
|
||||
* ratio taken from 0 × 0 would be invented.
|
||||
*/
|
||||
export function texturePixelSize(project: Project, props: PropertyBag): PixelSize | null {
|
||||
const ref = resolveTexture(project, props);
|
||||
if (!ref) return null;
|
||||
const w = ref.rect ? ref.rect.w : ref.sourceWidth;
|
||||
const h = ref.rect ? ref.rect.h : ref.sourceHeight;
|
||||
return w > 0 && h > 0 ? { w, h } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `axis` has to become for the sprite to carry the picture's proportions, leaving the
|
||||
* other side where it is. Null when the other side is not a usable number.
|
||||
*/
|
||||
export function fitAxisToAspect(size: PixelSize, props: PropertyBag, axis: SizeAxis): number | null {
|
||||
const other = Number(props[axis === 'width' ? 'height' : 'width']);
|
||||
if (!Number.isFinite(other) || other <= 0) return null;
|
||||
const fitted = axis === 'width' ? (other * size.w) / size.h : (other * size.h) / size.w;
|
||||
return Number.isFinite(fitted) && fitted > 0 ? fitted : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the sprite already has the picture's proportions. Compared as a cross product with a
|
||||
* relative tolerance, because fitting one side and then asking about the other must not answer
|
||||
* "no" over a rounding error: 3135 of the 40 000 sizes up to 200 × 200 fail an exact test after
|
||||
* their own fit (1 × 11 at height 100 gives 9.090909090909092, whose cross product is 100.000…1).
|
||||
*/
|
||||
export function matchesAspect(size: PixelSize, props: PropertyBag): boolean {
|
||||
const w = Number(props.width);
|
||||
const h = Number(props.height);
|
||||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return false;
|
||||
return Math.abs(w * size.h - h * size.w) <= 1e-9 * w * size.h;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import {
|
||||
fitAxisToAspect,
|
||||
matchesAspect,
|
||||
type SizeAxis,
|
||||
texturePixelSize,
|
||||
} from '../../core/properties/aspect';
|
||||
import { coerceValue, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
|
||||
import type { FieldActionGroup } from './fieldFactory';
|
||||
import type { InspectorHost } from './host';
|
||||
|
||||
/** ↔ sets the width, ↕ the height – the side the button changes, not the one it reads. */
|
||||
const AXIS_GLYPH: Record<SizeAxis, string> = { width: '↔', height: '↕' };
|
||||
|
||||
/**
|
||||
* "Fit this side to the picture's proportions" for the Width/Height rows of an asset that also
|
||||
* wears a texture. Empty for every other row, and – through an action list that comes back
|
||||
* empty – while no texture or frame is assigned, so the button is not drawn at all.
|
||||
*/
|
||||
export function aspectActions(
|
||||
host: InspectorHost,
|
||||
asset: Asset,
|
||||
def: PropertyDef,
|
||||
props: () => PropertyBag | null,
|
||||
commit: (value: number) => void,
|
||||
): FieldActionGroup[] {
|
||||
if (def.kind !== 'number' || (def.key !== 'width' && def.key !== 'height')) return [];
|
||||
// A size without a picture (collider, camera) has no proportions to fit to.
|
||||
if (!schemaForAsset(asset).some((d) => d.kind === 'texture')) return [];
|
||||
const axis: SizeAxis = def.key;
|
||||
return [
|
||||
{
|
||||
glyph: AXIS_GLYPH[axis],
|
||||
available: () => {
|
||||
const bag = props();
|
||||
const size = bag && texturePixelSize(host.project(), bag);
|
||||
if (!bag || !size || matchesAspect(size, bag)) return [];
|
||||
const fitted = fitAxisToAspect(size, bag, axis);
|
||||
if (fitted === null) return [];
|
||||
// Offered only when the schema would store it unchanged: a value its min/max clamps
|
||||
// cannot make the sprite proportional, so the button would never go away again.
|
||||
const next = Number(coerceValue(def, fitted));
|
||||
if (next !== fitted) return [];
|
||||
return [{ label: `Fit to ${size.w} × ${size.h}`, onSelect: () => commit(next) }];
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
|
||||
import { aspectActions } from './aspectAction';
|
||||
import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
@@ -91,15 +92,27 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
|
||||
def,
|
||||
values[def.key] ?? def.default,
|
||||
assetHandlers(host, asset.id, def),
|
||||
() => {
|
||||
const target = findAsset(host.state.project, asset.id);
|
||||
if (!target || !isModified(target, def.key)) return [];
|
||||
return [
|
||||
{
|
||||
label: 'Reset to default',
|
||||
onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)),
|
||||
{
|
||||
extra: aspectActions(
|
||||
host,
|
||||
asset,
|
||||
def,
|
||||
() => {
|
||||
const target = findAsset(host.state.project, asset.id);
|
||||
return target ? resolveAssetProps(target) : null;
|
||||
},
|
||||
];
|
||||
(value) => host.state.execute(new SetAssetPropertyCommand(asset.id, def.key, value)),
|
||||
),
|
||||
reset: () => {
|
||||
const target = findAsset(host.state.project, asset.id);
|
||||
if (!target || !isModified(target, def.key)) return [];
|
||||
return [
|
||||
{
|
||||
label: 'Reset to default',
|
||||
onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
);
|
||||
row.setModified(isModified(asset, def.key));
|
||||
|
||||
@@ -67,6 +67,19 @@ interface FieldAction {
|
||||
*/
|
||||
export type FieldActions = () => FieldAction[];
|
||||
|
||||
/** One trailing button of a row: its glyph, and what it currently offers. */
|
||||
export interface FieldActionGroup {
|
||||
glyph: string;
|
||||
available: FieldActions;
|
||||
}
|
||||
|
||||
export interface RowActions {
|
||||
reset?: FieldActions;
|
||||
apply?: FieldActions;
|
||||
/** Row-specific buttons, drawn left of ↑/↺ – they act on the value rather than on its origin. */
|
||||
extra?: readonly FieldActionGroup[];
|
||||
}
|
||||
|
||||
/** A row starting with the fixed-width, muted label column. */
|
||||
export function labelledRow(text: string): { row: HStack; label: Label } {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
@@ -83,7 +96,7 @@ export function labelledRow(text: string): { row: HStack; label: Label } {
|
||||
* kind means adding a case here, adding a property means adding a schema line.
|
||||
*
|
||||
* A row shows its actions at the right: ↺ resets to the base value, ↑ applies the value to
|
||||
* its source. Which of them exist is asked from `reset`/`apply` on every refresh: no entry
|
||||
* its source, `extra` adds row-specific ones. Each group is asked on every refresh: no entry
|
||||
* means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
|
||||
*/
|
||||
export function createFieldRow(
|
||||
@@ -91,8 +104,7 @@ export function createFieldRow(
|
||||
def: PropertyDef,
|
||||
value: PropertyValue,
|
||||
handlers: FieldHandlers,
|
||||
reset?: FieldActions,
|
||||
apply?: FieldActions,
|
||||
rowActions: RowActions = {},
|
||||
): FieldRow {
|
||||
const { row, label } = labelledRow(def.label);
|
||||
const field = createField(ctx, def, value, handlers);
|
||||
@@ -115,8 +127,9 @@ export function createFieldRow(
|
||||
actions.push({ button, glyph, available });
|
||||
row.add(button);
|
||||
};
|
||||
if (apply) addActions('↑', apply);
|
||||
if (reset) addActions('↺', reset);
|
||||
for (const group of rowActions.extra ?? []) addActions(group.glyph, group.available);
|
||||
if (rowActions.apply) addActions('↑', rowActions.apply);
|
||||
if (rowActions.reset) addActions('↺', rowActions.reset);
|
||||
|
||||
const refreshActions = (): void => {
|
||||
let changed = false;
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import type { Asset, SceneAsset } from '../../core/model/Asset';
|
||||
import { type TemplateLink, templateLinkOf } from '../../core/model/container';
|
||||
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
|
||||
import type { Axis, PropertyValue } from '../../core/model/Properties';
|
||||
import type { Axis, PropertyBag, PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset, findScene, type Project } from '../../core/model/Project';
|
||||
import type { SceneObject } from '../../core/model/SceneObject';
|
||||
import {
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '../../core/properties/resolve';
|
||||
import { Button, Dropdown, Menu, type MenuItem, TextField } from '../../ui';
|
||||
import { addCollider, createContainer, removeObjects } from '../actions';
|
||||
import { aspectActions } from './aspectAction';
|
||||
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
@@ -134,7 +135,7 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
|
||||
// The master switch, right where it is looked for: above everything type-specific.
|
||||
for (const def of COMMON_PROPS)
|
||||
propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object));
|
||||
propertyRow(host, sceneId, objectId, asset, def, resolveSceneObjectProps(asset, object));
|
||||
|
||||
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
|
||||
for (const axis of POSITION_AXES) {
|
||||
@@ -203,7 +204,7 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
if (own.length > 0 || asset.type === 'behavior')
|
||||
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
|
||||
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
|
||||
for (const def of own) propertyRow(host, sceneId, objectId, def, values);
|
||||
for (const def of own) propertyRow(host, sceneId, objectId, asset, def, values);
|
||||
markObjectRows(host, object, asset, link);
|
||||
|
||||
if (asset.type === 'container') {
|
||||
@@ -275,8 +276,7 @@ function componentRow<V extends object>(
|
||||
toModel: (next, object) => withAxis(spec.read(object), next),
|
||||
commit: spec.command,
|
||||
}),
|
||||
spec.reset,
|
||||
spec.apply,
|
||||
{ reset: spec.reset, apply: spec.apply },
|
||||
);
|
||||
host.fields.set(spec.key, row);
|
||||
host.body.add(row.row);
|
||||
@@ -295,6 +295,7 @@ function propertyRow(
|
||||
host: InspectorHost,
|
||||
sceneId: string,
|
||||
objectId: string,
|
||||
owner: Asset,
|
||||
def: PropertyDef,
|
||||
values: Record<string, PropertyValue>,
|
||||
): FieldRow {
|
||||
@@ -310,50 +311,59 @@ function propertyRow(
|
||||
remove: (object) => delete object.overrides[def.key],
|
||||
commit: (value) => new SetOverrideCommand(sceneId, objectId, def.key, value),
|
||||
}),
|
||||
() => {
|
||||
const { template, asset } = bases();
|
||||
return [
|
||||
...(template
|
||||
? [
|
||||
{
|
||||
label: 'Reset to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(asset
|
||||
? [
|
||||
{
|
||||
label: 'Reset to asset',
|
||||
onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
() => {
|
||||
const { template, asset } = bases();
|
||||
return [
|
||||
...(template
|
||||
? [
|
||||
{
|
||||
label: 'Apply to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(asset
|
||||
? [
|
||||
{
|
||||
label: 'Apply to asset',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
{
|
||||
extra: aspectActions(
|
||||
host,
|
||||
owner,
|
||||
def,
|
||||
() => currentProps(host.state, sceneId, objectId),
|
||||
(value) => host.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value)),
|
||||
),
|
||||
reset: () => {
|
||||
const { template, asset } = bases();
|
||||
return [
|
||||
...(template
|
||||
? [
|
||||
{
|
||||
label: 'Reset to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(asset
|
||||
? [
|
||||
{
|
||||
label: 'Reset to asset',
|
||||
onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
apply: () => {
|
||||
const { template, asset } = bases();
|
||||
return [
|
||||
...(template
|
||||
? [
|
||||
{
|
||||
label: 'Apply to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(asset
|
||||
? [
|
||||
{
|
||||
label: 'Apply to asset',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
},
|
||||
);
|
||||
host.fields.set(def.key, row);
|
||||
@@ -457,3 +467,10 @@ function currentObject(
|
||||
const scene = findScene(state.project, sceneId);
|
||||
return scene && findObject(scene, objectId);
|
||||
}
|
||||
|
||||
/** The values the object shows right now, asset defaults and overrides merged. */
|
||||
function currentProps(state: InspectorHost['state'], sceneId: string, objectId: string): PropertyBag | null {
|
||||
const object = currentObject(state, sceneId, objectId);
|
||||
const asset = object && findAsset(state.project, object.assetId);
|
||||
return object && asset ? resolveSceneObjectProps(asset, object) : null;
|
||||
}
|
||||
Reference in new issue
Block a user