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:
rootandClaude Opus 5 committed 2026-09-08 16:27:38 +02:00
1 parent 4d84231b9f
commit 0b34bdd990
9 files changed
+412 -65

No files matched your search

+47
View File
@@ -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;
}
+49
View File
@@ -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) }];
},
},
];
}
+21 -8
View File
@@ -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));
+18 -5
View File
@@ -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;
+66 -49
View File
@@ -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;
}