Skip to content

Commit 7299c72

Browse files
committed
fix(compositor): steep roll-free Left and Right, iso folded into Left
The maintainer picked the steep look for the fixed angles: Left [-23, -25, 0] (the old iso look) and its mirror Right [-23, 25, 0], zero roll. Both sit in the high pitch band, so the dynamic budget is back to +-1.9 X / +-3 Y and the click impact to 1.9 degrees. iso is no longer offered. A stored iso reads as left at the document schema (every load parses through it) and in the legacy reader (readRotation3DPreset); the compositor also maps it. The picker, the label map and the 15 locales drop iso; Left and Right read "turned left/right and seen from above". Tests: the budget-tightness test is re-derived for the combined sweep (Y alone stays tight), and the 15 px corner-click floor is back. The modelled cursor's box bounds its penumbra by the shader's real walk: under Right the camera-fixed light grazes the plane (lz 0.2) and the shadow overran the box.
1 parent 97ae20c commit 7299c72

28 files changed

Lines changed: 172 additions & 169 deletions

‎crates/compositor/src/frame_geometry.rs‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3767,7 +3767,10 @@ impl ModelView {
37673767
])
37683768
});
37693769
let top = corners.iter().fold(0.0f32, |m, p| m.max(p[2])) / self.unit;
3770-
let penumbra = (top / lz / MODEL_SOFTNESS).min(MODEL_SHADOW_PAD);
3770+
// La marche du shader court jusqu'à la sortie de la boîte ÉLARGIE de `MODEL_SHADOW_PAD` :
3771+
// sous une lumière rasante (`right`, `lz` ≈ 0,2), le rayon y va bien au-delà de `top / lz`,
3772+
// et la pénombre avec lui.
3773+
let penumbra = ((top + MODEL_SHADOW_PAD) / lz / MODEL_SOFTNESS).min(MODEL_SHADOW_PAD);
37713774
let reach = (penumbra / lz + MODEL_CONTACT_RADIUS) * self.unit;
37723775
let mut b = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
37733776
let mut add = |p: [f32; 2]| {
@@ -6743,7 +6746,7 @@ mod tests {
67436746
}
67446747

67456748
const ISO: [f32; 3] = [-23.0, -25.0, 0.0];
6746-
const LEFT: [f32; 3] = [-6.5, -17.0, 0.0];
6749+
const LEFT: [f32; 3] = [-23.0, -25.0, 0.0];
67476750

67486751
/// Le `LayerCB` du sprite incliné est, octet pour octet, celui que chaque backend construisait
67496752
/// avant de le partager. La référence est le corps d'origine de `draw_cursor_sprite`.
@@ -6801,7 +6804,7 @@ mod tests {
68016804
let len = std::mem::size_of::<LayerCB>();
68026805
unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, len) }.to_vec()
68036806
};
6804-
for rot in [ISO, LEFT, [-6.5, 17.0, 0.0]] {
6807+
for rot in [ISO, LEFT, [-23.0, 25.0, 0.0]] {
68056808
let plan = plan_with(rot);
68066809
let clip = [0.1, 0.2, 0.7, 0.6];
68076810
let got = cursor_sprite_cb(
@@ -7182,7 +7185,7 @@ mod tests {
71827185
assert!(model_plan([0.0; 3], &bare, &track, 0.3, true).expect("plan").model.is_none());
71837186
}
71847187

7185-
const LEFT_ROT: [f32; 3] = [-6.5, -17.0, 0.0];
7188+
const LEFT_ROT: [f32; 3] = [-23.0, -25.0, 0.0];
71867189
const ISO_ROT: [f32; 3] = [-23.0, -25.0, 0.0];
71877190

71887191
/// Les poses d'essai, pour chaque état de `MODEL_STATES` : au repos, posé, tourné.
@@ -7196,7 +7199,7 @@ mod tests {
71967199
vec![],
71977200
vec![(0.0, key.to_string())],
71987201
);
7199-
for (name, rot) in [("flat", [0.0; 3]), ("iso", ISO_ROT), ("left", LEFT_ROT), ("right", [-6.5, 17.0, 0.0])] {
7202+
for (name, rot) in [("flat", [0.0; 3]), ("iso", ISO_ROT), ("left", LEFT_ROT), ("right", [-23.0, 25.0, 0.0])] {
72007203
for (pose, track, t) in [("hover", &clicked, 0.3), ("touch", &clicked, 0.5 + CONTACT_S), ("yaw", &moving, 1.0)] {
72017204
let plan = model_plan(rot, &scene, track, t, true).expect("plan");
72027205
let (sdf, shape) = sprite_model(key);

‎crates/compositor/src/regions.rs‎

Lines changed: 59 additions & 54 deletions
Large diffs are not rendered by default.

‎docs/3d-effects-v2.md‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,9 @@ Ce document tranche les décisions que la v1 laissait ouvertes et découpe les P
1515

1616
### A.1 Ce qui existe
1717

18-
Un zoom porte **une attitude figée** (`rotationPreset` : `iso`, `left`, `right`), animée par
18+
Un zoom porte **une attitude figée** (`rotationPreset` : `left`, `right`), animée par
1919
deux effets qui partagent un unique budget d'angle dynamique (`DYNAMIC_TILT_BUDGET`
20-
= ±1,2° X, ±1,8° Y, 0° Z, `clamp_dynamic_tilt` ; ±1,9° X et ±3° Y tant que les angles fixes
21-
roulaient) :
20+
= ±1,9° X, ±3° Y, 0° Z, `clamp_dynamic_tilt`) :
2221

2322
- **la parallaxe** (`dynamic_tilt`, PR 1) : pilotée par la **vitesse** lissée du curseur ;
2423
- **l'impact du clic** (PR 2b) : piloté par la **position** du clic, `regions::tap`.
@@ -40,24 +39,26 @@ mobile, qui tourne autour de l'écran.
4039
| groupe | valeur | libellé (EN) | ce que ça fait |
4140
|---|---|---|---|
4241
| — | absent | Off | écran droit |
43-
| Angle fixe | `iso` | Angled from above | tourné vers la gauche, plongée marquée |
44-
| Angle fixe | `left` | Turned left | tourné vers la gauche |
45-
| Angle fixe | `right` | Turned right | tourné vers la droite |
42+
| Angle fixe | `left` | Screen turned left | tourné vers la gauche, vu d'en haut |
43+
| Angle fixe | `right` | Screen turned right | tourné vers la droite, vu d'en haut |
4644
| Caméra mobile | `follow-cursor` | Orbits with the cursor | l'écran est immobile, une vraie caméra tourne autour de lui avec le curseur |
4745

4846
`swing-clicks` et `orbit` sont retirés (jamais livrés). La caméra mobile qui reste est l'orbite
4947
ci-dessous, sous l'identifiant `follow-cursor`. Un projet qui les porte encore s'ouvre à plat
5048
(valeur inconnue).
5149

52-
Les trois angles fixes ne roulent plus : Z vaut 0, parce que le tilt entre avec le zoom et qu'une
50+
Les angles fixes ne roulent plus : Z vaut 0, parce que le tilt entre avec le zoom et qu'une
5351
caméra qui bouge ne roule jamais le métrage (l'ancien roulis de −2°/±1° penchait l'image pendant
54-
chaque zoom). Sans roulis, la règle des 2° ne laisse que deux bandes de tangage, ≤ 9° ou ≥ 15° :
55-
`left` [−6,5, −17, 0], `right` [−6,5, 17, 0], `iso` [−23, −25, 0] (`regions::rotation3d_for`). Ils
56-
se dessinent au warp **projectif** exact, comme la caméra réelle : le warp bilinéaire penchait à
57-
lui seul la verticale du milieu de l'écran.
58-
59-
« Turned right » veut dire que la face de l'écran regarde vers la droite : le bord droit
60-
recule. C'est ce que fait `right` [−6,5, 17, 0].
52+
chaque zoom). Sans roulis, la règle des 2° ne laisse que deux bandes de tangage, ≤ 9° ou ≥ 15°.
53+
Les deux angles prennent la bande haute, le regard de l'ancien `iso` : `left` [−23, −25, 0] et son
54+
miroir `right` [−23, 25, 0] (`regions::rotation3d_for`), qui rendent au budget dynamique ses
55+
±1,9° / ±3°. `iso` n'est plus proposé : c'était déjà ce regard, tourné à gauche, et un projet qui
56+
le porte se lit comme `left` (`readRotation3DPreset` et le schéma du document). Ils se dessinent
57+
au warp **projectif** exact, comme la caméra réelle : le warp bilinéaire penchait à lui seul la
58+
verticale du milieu de l'écran.
59+
60+
« Screen turned right » veut dire que la face de l'écran regarde vers la droite : le bord droit
61+
recule. C'est ce que fait `right` [−23, 25, 0], vu d'en haut.
6162

6263
### A.3 `follow-cursor` : une caméra en orbite
6364

‎src/components/ai-edition/depthOfFieldToggle.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { VideoEffectsPane } from "./RightPanes";
1313

1414
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }));
1515

16-
function documentWithZoom(rotationPreset?: "iso"): AxcutDocument {
16+
function documentWithZoom(rotationPreset?: "left"): AxcutDocument {
1717
const base = createEmptyDocument({ title: "T", projectId: "p1" });
1818
return {
1919
...base,
@@ -61,7 +61,7 @@ describe("depth of field toggle", () => {
6161
});
6262

6363
it("is on by default with a tilted zoom, and turns off then on again", async () => {
64-
const toggle = mount(documentWithZoom("iso"));
64+
const toggle = mount(documentWithZoom("left"));
6565
expect(toggle).toBeEnabled();
6666
expect(toggle).toHaveAttribute("aria-pressed", "true");
6767
expect(screen.getByText("3D zooms")).toBeInTheDocument();

‎src/components/ai-edition/v4/FloatingInspector.test.tsx‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe("FloatingInspector", () => {
147147
});
148148

149149
it("is disabled with its reason when the region hides the cursor", () => {
150-
const { tl } = zoomTl({ rotationPreset: "iso", hideCursor: true });
150+
const { tl } = zoomTl({ rotationPreset: "left", hideCursor: true });
151151
render(<FloatingInspector {...defaultProps} tl={tl} />);
152152
expect(
153153
screen.getByRole("button", { name: "settings.zoom.clickImpact.title" }),
@@ -168,7 +168,7 @@ describe("FloatingInspector", () => {
168168
it("is disabled with its reason when the cursor is hidden globally", () => {
169169
editorSettings.cursorShow = false;
170170
try {
171-
const { tl } = zoomTl({ rotationPreset: "iso" });
171+
const { tl } = zoomTl({ rotationPreset: "left" });
172172
render(<FloatingInspector {...defaultProps} tl={tl} />);
173173
expect(
174174
screen.getByRole("button", { name: "settings.zoom.clickImpact.title" }),
@@ -180,7 +180,7 @@ describe("FloatingInspector", () => {
180180
});
181181

182182
it("toggles the region's clickImpact under a 3D preset", () => {
183-
const { tl, updateZoomClickImpact } = zoomTl({ rotationPreset: "iso" });
183+
const { tl, updateZoomClickImpact } = zoomTl({ rotationPreset: "left" });
184184
render(<FloatingInspector {...defaultProps} tl={tl} />);
185185
const box = screen.getByRole("button", { name: "settings.zoom.clickImpact.title" });
186186
expect(box).toBeEnabled();
@@ -226,7 +226,7 @@ describe("FloatingInspector", () => {
226226
[...g.querySelectorAll("option")].map((o) => o.value),
227227
]);
228228
expect(groups).toEqual([
229-
["settings.zoom.camera.fixed", ["iso", "left", "right"]],
229+
["settings.zoom.camera.fixed", ["left", "right"]],
230230
["settings.zoom.camera.moving", ["follow-cursor"]],
231231
]);
232232
});
@@ -237,8 +237,8 @@ describe("FloatingInspector", () => {
237237
const select = screen.getByRole("combobox", { name: "settings.zoom.camera.title" });
238238
expect(select).toHaveValue("follow-cursor");
239239
expect(screen.getByText("settings.zoom.camera.description.followCursor")).toBeInTheDocument();
240-
fireEvent.change(select, { target: { value: "iso" } });
241-
expect(updateZoomRotation).toHaveBeenCalledWith("z", "iso");
240+
fireEvent.change(select, { target: { value: "left" } });
241+
expect(updateZoomRotation).toHaveBeenCalledWith("z", "left");
242242
fireEvent.change(select, { target: { value: "off" } });
243243
expect(updateZoomRotation).toHaveBeenLastCalledWith("z", undefined);
244244
});

‎src/components/ai-edition/v4/FloatingInspector.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,6 @@ function paneStack(label: string, control: React.ReactNode) {
344344

345345
/** Clé i18n (`zoom.camera.preset.*` / `zoom.camera.description.*`) de chaque caméra 3D. */
346346
const CAMERA_KEYS: Record<Rotation3DPreset, string> = {
347-
iso: "iso",
348347
left: "left",
349348
right: "right",
350349
"follow-cursor": "followCursor",

‎src/components/video-editor/projectPersistence.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ describe("projectPersistence media compatibility", () => {
203203
const zoom = { startMs: 0, endMs: 1000, depth: 3 as const, focus: { cx: 0.5, cy: 0.5 } };
204204
const [on, off, junk] = normalizeProjectEditor({
205205
zoomRegions: [
206-
{ ...zoom, id: "on", rotationPreset: "iso", clickImpact: true },
206+
{ ...zoom, id: "on", rotationPreset: "left", clickImpact: true },
207207
{ ...zoom, id: "off" },
208208
{ ...zoom, id: "junk", clickImpact: "yes" as never },
209209
],
@@ -213,6 +213,18 @@ describe("projectPersistence media compatibility", () => {
213213
expect("clickImpact" in junk).toBe(false);
214214
});
215215

216+
it("reads the retired iso camera as Left, which kept its look", () => {
217+
const zoom = { startMs: 0, endMs: 1000, depth: 3 as const, focus: { cx: 0.5, cy: 0.5 } };
218+
const [iso, unknown] = normalizeProjectEditor({
219+
zoomRegions: [
220+
{ ...zoom, id: "iso", rotationPreset: "iso" as never },
221+
{ ...zoom, id: "unknown", rotationPreset: "orbit" as never },
222+
],
223+
}).zoomRegions;
224+
expect(iso.rotationPreset).toBe("left");
225+
expect("rotationPreset" in unknown).toBe(false);
226+
});
227+
216228
it("accepts the dual frame webcam layout preset", () => {
217229
expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe(
218230
"dual-frame",

‎src/components/video-editor/projectPersistence.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ import {
3939
DEFAULT_WEBCAM_REACTIVE_ZOOM,
4040
DEFAULT_ZOOM_DEPTH,
4141
DEFAULT_ZOOM_MOTION_BLUR,
42-
isRotation3DPreset,
4342
isWallpaperMotion,
4443
MAX_BLUR_BLOCK_SIZE,
4544
MAX_BLUR_INTENSITY,
4645
MIN_BLUR_BLOCK_SIZE,
4746
MIN_BLUR_INTENSITY,
47+
readRotation3DPreset,
4848
type SpeedRegion,
4949
type TrimRegion,
5050
type WallpaperMotion,
@@ -278,9 +278,8 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
278278
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
279279
const endMs = Math.max(startMs + 1, rawEnd);
280280

281-
const validPreset = isRotation3DPreset(region.rotationPreset)
282-
? region.rotationPreset
283-
: undefined;
281+
// A retired angle reads as the one that kept its look (`iso` → Left).
282+
const validPreset = readRotation3DPreset(region.rotationPreset);
284283
return {
285284
id: region.id,
286285
startMs,

‎src/components/video-editor/types.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const DEFAULT_ROTATION_3D: Rotation3D = {
7777
};
7878

7979
/** A fixed 3D angle: the screen holds one pose for the whole zoom. */
80-
export const FIXED_ROTATION_3D_PRESETS = ["iso", "left", "right"] as const;
80+
export const FIXED_ROTATION_3D_PRESETS = ["left", "right"] as const;
8181
export type FixedRotation3DPreset = (typeof FIXED_ROTATION_3D_PRESETS)[number];
8282

8383
/**
@@ -101,23 +101,31 @@ export function isRotation3DPreset(value: unknown): value is Rotation3DPreset {
101101
return typeof value === "string" && (ROTATION_3D_PRESET_ORDER as string[]).includes(value);
102102
}
103103

104+
/**
105+
* A stored 3D camera, read: the presets that no longer exist map to the one that kept their look.
106+
* `iso` (turned left, seen from above) became Left. Anything else unknown reads as a flat screen.
107+
*/
108+
export function readRotation3DPreset(value: unknown): Rotation3DPreset | undefined {
109+
if (value === "iso") return "left";
110+
return isRotation3DPreset(value) ? value : undefined;
111+
}
112+
104113
// No preset rolls (Z is 0): the tilt enters with the zoom, and a camera that moves never rolls the
105114
// footage. X and Y are chosen so that no edge of the projected quad comes within 2° of an axis — a
106115
// perfectly vertical edge cutting through text is indistinguishable from `overflow: hidden`, which
107116
// got reported three times as "the recording is truncated". A pure Y rotation keeps both vertical
108117
// edges vertical, so every preset also pitches; and between 10° and 14° of pitch the top edge of a
109-
// turned screen reads level, so a preset pitches either less or more than that. `regions.rs` holds
110-
// the same numbers and the tests.
118+
// turned screen reads level, so both presets pitch well beyond it: turned and seen from above,
119+
// the old `iso` look. `regions.rs` holds the same numbers and the tests.
111120
//
112121
// A moving camera has no single pose. Its entry is its resting angle (`ELEVATION_DEG` in
113122
// `camera.rs`: cursor centred, the camera 4° above the screen, facing it), what a renderer without
114123
// the cursor track draws. A camera above tips the top edge toward the viewer, which is a negative X
115124
// rotation of the screen. It is a camera angle, not a screen rotation, so this is the nearest
116125
// equivalent rather than the same picture.
117126
export const ROTATION_3D_PRESETS: Record<Rotation3DPreset, Rotation3D> = {
118-
iso: { rotationX: -23, rotationY: -25, rotationZ: 0 },
119-
left: { rotationX: -6.5, rotationY: -17, rotationZ: 0 },
120-
right: { rotationX: -6.5, rotationY: 17, rotationZ: 0 },
127+
left: { rotationX: -23, rotationY: -25, rotationZ: 0 },
128+
right: { rotationX: -23, rotationY: 25, rotationZ: 0 },
121129
"follow-cursor": { rotationX: -4, rotationY: 0, rotationZ: 0 },
122130
};
123131

‎src/i18n/locales/ar/settings.json‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -362,16 +362,14 @@
362362
"fixed": "زاوية ثابتة",
363363
"moving": "كاميرا متحركة",
364364
"preset": {
365-
"iso": "مائلة من الأعلى",
366365
"left": "الشاشة مستديرة لليسار",
367366
"right": "الشاشة مستديرة لليمين",
368367
"followCursor": "يدور مع المؤشر"
369368
},
370369
"description": {
371370
"off": "تبقى الشاشة مسطحة.",
372-
"iso": "وضعية واحدة ثابتة: مستديرة لليسار ومرئية من الأعلى.",
373-
"left": "وضعية واحدة ثابتة: الشاشة تتجه إلى اليسار وحافتها اليمنى تتقدم.",
374-
"right": "وضعية واحدة ثابتة: الشاشة تتجه إلى اليمين وحافتها اليسرى تتقدم.",
371+
"left": "وضعية واحدة ثابتة: مستديرة لليسار ومرئية من الأعلى.",
372+
"right": "وضعية واحدة ثابتة: مستديرة لليمين ومرئية من الأعلى.",
375373
"followCursor": "تبقى الشاشة ثابتة، وتدور الكاميرا حولها نحو جهة المؤشر وترتفع أو تنخفض معه."
376374
},
377375
"needsCursor": "المؤشر مخفي: لا شيء تتبعه هذه الكاميرا، فتحافظ على زاويتها الابتدائية."

0 commit comments

Comments
 (0)