From c31ce69980607db338e77a52237bcdf8f6ea3a44 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sun, 31 May 2026 21:56:17 -0400 Subject: [PATCH] feat(layout): add opt-in RTL layout mirroring Introduce an inherited `rtl` flag on nodes that mirrors child x-positions within the parent's width and reverses `left`/`right` text alignment. The flag resolves by inheritance (own override, else parent), propagated via a new `UpdateType.Direction` pass; the mirror is applied as a temporary shift/restore of the local transform in the world-transform step, so LTR apps pay only a single boolean check on the hot path. This covers RTL layout structure only (rails/nav mirroring, alignment). Bidirectional text reordering (Hebrew/Arabic glyph order) is a separate opt-in follow-up and is intentionally out of scope here. Co-Authored-By: Claude Opus 4.7 --- examples/tests/rtl-layout.ts | 82 +++++++++++ src/core/CoreNode.test.ts | 118 ++++++++++++++++ src/core/CoreNode.ts | 127 +++++++++++++++++- src/core/CoreTextNode.test.ts | 1 + src/core/CoreTextNode.ts | 23 +++- src/core/Stage.ts | 2 + src/core/text-rendering/CanvasTextRenderer.ts | 4 +- src/core/text-rendering/SdfTextRenderer.ts | 12 +- src/core/text-rendering/TextLayoutEngine.ts | 17 +++ .../chromium-ci/rtl-layout-1.png | Bin 0 -> 19685 bytes 10 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 examples/tests/rtl-layout.ts create mode 100644 visual-regression/certified-snapshots/chromium-ci/rtl-layout-1.png diff --git a/examples/tests/rtl-layout.ts b/examples/tests/rtl-layout.ts new file mode 100644 index 0000000..6030373 --- /dev/null +++ b/examples/tests/rtl-layout.ts @@ -0,0 +1,82 @@ +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export async function automation(settings: ExampleSettings) { + await test(settings); + await settings.snapshot(); +} + +/** + * RTL layout mirroring. + * + * Each row places three boxes at the same increasing `x` offsets. In an LTR + * container they read left-to-right; in an RTL container the same offsets are + * mirrored within the container width, so they read right-to-left. + * + * Each box carries a small white marker child at its own `x: 0`. A box's own + * `rtl` flag (not the parent's) controls where that marker lands: under RTL the + * marker mirrors to the box's right edge. The third row keeps the RTL container + * (so box positions still mirror) but sets `rtl: false` on each box, so the + * markers stay on the left edge — demonstrating a sub-tree opting back out. + * + * Default 'left' text alignment also flips to the right edge under RTL. + */ +export default async function test({ renderer, testRoot }: ExampleSettings) { + const CONTAINER_W = 700; + const BOX = 200; + const MARKER = 30; + const GAP = 20; + const colors = [0xff0000ff, 0x00ff00ff, 0x0000ffff]; + + const makeRow = ( + y: number, + containerRtl: boolean | undefined, + boxRtl: boolean | undefined, + label: string, + ) => { + const container = renderer.createNode({ + x: 20, + y, + w: CONTAINER_W, + h: BOX, + color: 0x222222ff, + rtl: containerRtl, + parent: testRoot, + }); + + for (let i = 0; i < 3; i++) { + const box = renderer.createNode({ + x: i * (BOX + GAP), + w: BOX, + h: BOX, + color: colors[i], + rtl: boxRtl, + parent: container, + }); + // Marker at the box's own top-left; mirrors to the right under box RTL. + renderer.createNode({ + x: 0, + y: 0, + w: MARKER, + h: MARKER, + color: 0xffffffff, + parent: box, + }); + } + + renderer.createTextNode({ + x: 0, + y: BOX + 4, + w: CONTAINER_W, + fontFamily: 'Ubuntu', + fontSize: 28, + color: 0xffffffff, + forceLoad: true, + text: label, + parent: container, + }); + }; + + makeRow(20, false, undefined, 'LTR container (markers left)'); + makeRow(300, true, undefined, 'RTL container (mirrored, markers right)'); + makeRow(580, true, false, 'RTL container, boxes rtl:false (markers left)'); +} diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index 90751b0..7681217 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -32,6 +32,7 @@ describe('set color()', () => { pivotX: 0, pivotY: 0, rotation: 0, + rtl: null, rtt: false, scale: 1, scaleX: 1, @@ -1180,4 +1181,121 @@ describe('set color()', () => { expect(node.clippingRect.h).toBe(30); }); }); + + describe('rtl direction', () => { + it('defaults to ltr (false) with no parent', () => { + const node = new CoreNode(stage, defaultProps()); + expect(node.rtl).toBe(false); + }); + + it('reflects an explicit own value', () => { + const node = new CoreNode(stage, defaultProps()); + node.rtl = true; + expect(node.rtl).toBe(true); + node.rtl = false; + expect(node.rtl).toBe(false); + }); + + it('inherits from the parent when own value is null', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.rtl = true; + const child = new CoreNode(stage, defaultProps({ parent })); + expect(child.rtl).toBe(true); + }); + + it('an explicit false on a child overrides an rtl parent', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.rtl = true; + const child = new CoreNode(stage, defaultProps({ parent, rtl: false })); + expect(child.rtl).toBe(false); + }); + + it('resolves _rtl from the parent during update', () => { + const parent = new CoreNode(stage, defaultProps({ w: 200 })); + parent.globalTransform = Matrix3d.identity(); + parent.worldAlpha = 1; + parent._rtl = true; + + const node = new CoreNode(stage, defaultProps({ parent })); + node.update(0, clippingRect); + + expect(node._rtl).toBe(true); + }); + }); + + describe('rtl layout mirroring', () => { + const makeRtlParent = (w: number) => { + const parent = new CoreNode(stage, defaultProps({ w })); + parent.globalTransform = Matrix3d.identity(); + parent.worldAlpha = 1; + parent._rtl = true; + return parent; + }; + + it("mirrors a child's x within the parent width", () => { + const parent = makeRtlParent(200); + const node = new CoreNode(stage, defaultProps({ parent })); + node.x = 20; + node.y = 30; + node.w = 50; + node.h = 40; + + node.update(0, clippingRect); + + // 200 - x(20) - w(50) * scaleX(1) = 130; y is untouched. + expect(node.globalTransform!.tx).toBe(130); + expect(node.globalTransform!.ty).toBe(30); + }); + + it('accounts for scaleX when mirroring', () => { + const parent = makeRtlParent(200); + const node = new CoreNode(stage, defaultProps({ parent })); + node.x = 20; + node.w = 50; + node.scaleX = 2; + + node.update(0, clippingRect); + + // 200 - 20 - 50 * 2 = 80 + expect(node.globalTransform!.tx).toBe(80); + }); + + it('does not mirror when the parent is ltr', () => { + const parent = new CoreNode(stage, defaultProps({ w: 200 })); + parent.globalTransform = Matrix3d.identity(); + parent.worldAlpha = 1; + + const node = new CoreNode(stage, defaultProps({ parent })); + node.x = 20; + node.w = 50; + + node.update(0, clippingRect); + + expect(node.globalTransform!.tx).toBe(20); + }); + + it('does not mirror when the parent width is unknown (0)', () => { + const parent = makeRtlParent(0); + const node = new CoreNode(stage, defaultProps({ parent })); + node.x = 20; + node.w = 50; + + node.update(0, clippingRect); + + expect(node.globalTransform!.tx).toBe(20); + }); + + it('restores the cached local transform after mirroring', () => { + const parent = makeRtlParent(200); + const node = new CoreNode(stage, defaultProps({ parent })); + node.x = 20; + node.w = 50; + + node.update(0, clippingRect); + + // The local transform must stay un-mirrored so the next frame doesn't + // double-apply the mirror. + expect(node.localTransform!.tx).toBe(20); + }); + }); }); diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index d96ccd0..cfd9d13 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -176,6 +176,15 @@ export enum UpdateType { * Autosize update */ Autosize = 8192, + + /** + * Layout direction (RTL) update + * + * @remarks + * CoreNode Properties Updated: + * - `_rtl` (resolved from `_ownRtl` / inherited from parent) + */ + Direction = 16384, /** * None */ @@ -184,7 +193,7 @@ export enum UpdateType { /** * All */ - All = 16383, + All = 32767, } /** @@ -621,6 +630,24 @@ export interface CoreNodeProps { */ rotation: number; + /** + * Right-to-left layout direction. + * + * @remarks + * When `true`, this Node's children are laid out mirrored horizontally: a + * child's `x` is measured from the parent's right edge instead of its left, + * and text alignment (`left`/`right`) is reversed. The flag is inherited by + * descendants, so setting it on the root mirrors the whole application. Set + * an explicit `false` on a sub-tree to opt back into left-to-right. + * + * `null` (the default) means "inherit from parent". + * + * **Caveat:** mirroring requires the parent's width (`w`) to be known. + * + * @default null + */ + rtl: boolean | null; + /** * Whether the Node is rendered to a texture * @@ -844,6 +871,19 @@ export class CoreNode extends EventEmitter { */ public _globalIsTranslate = true; + /** + * Locally-set layout direction: `true`/`false` to force RTL/LTR on this + * sub-tree, or `null` to inherit from the parent. Source of truth for the + * public `rtl` setter; the resolved value lives in `_rtl`. + */ + public _ownRtl: boolean | null = null; + /** + * Resolved layout direction (own override, else inherited from parent). + * Read directly in the world-transform hot path, so it is kept as a plain + * boolean field updated during `update()` on `UpdateType.Direction`. + */ + public _rtl = false; + public worldAlpha = 1; public premultipliedColorTl = 0; public premultipliedColorTr = 0; @@ -876,7 +916,10 @@ export class CoreNode extends EventEmitter { //inital update type let initialUpdateType = - UpdateType.Local | UpdateType.RenderBounds | UpdateType.RenderState; + UpdateType.Local | + UpdateType.RenderBounds | + UpdateType.RenderState | + UpdateType.Direction; // Use the incoming props object directly — resolveNodeDefaults already // creates a fresh object with a consistent shape. Save fields that are @@ -885,6 +928,7 @@ export class CoreNode extends EventEmitter { const { texture, shader, src, rtt, boundsMargin, interactive, parent } = props; const p = (this.props = props); + this._ownRtl = typeof p.rtl === 'boolean' ? p.rtl : null; p.texture = null; p.shader = null; p.src = null; @@ -1273,6 +1317,16 @@ export class CoreNode extends EventEmitter { this.updateType = 0; this.childUpdateType = 0; + if (updateType & UpdateType.Direction) { + if (this.resolveDirection() === true) { + // Our own mirrored position depends on parent direction (unchanged + // here), but our local transform may need to reflow (text alignment) + // and every descendant must re-resolve + re-mirror. + updateType |= UpdateType.Local | UpdateType.Children; + childUpdateType |= UpdateType.Direction; + } + } + if (updateType & UpdateType.Local) { this.updateLocalTransform(); @@ -1289,6 +1343,21 @@ export class CoreNode extends EventEmitter { const gt = this.globalTransform!; let fastPathApplied = false; + // RTL: when the parent lays out right-to-left, flip this node's local x + // within the parent's width. This is a pure horizontal translation, so + // we shift lt.tx for the compose below and restore it afterwards to keep + // the cached local transform pristine for the next frame. + let rtlOrigTx = 0; + let rtlMirrored = false; + if (parent._rtl === true) { + const pw = parent.props.w; + if (pw !== 0) { + rtlOrigTx = lt.tx; + lt.tx += pw - 2 * props.x - props.w * props.scaleX; + rtlMirrored = true; + } + } + if ( USE_RTT && this.parentHasRenderTexture === true && @@ -1360,6 +1429,10 @@ export class CoreNode extends EventEmitter { gt.translateOrMultiply(lt); } } + + if (rtlMirrored === true) { + lt.tx = rtlOrigTx; + } this.calculateRenderCoords(); this.updateBoundingRect(); @@ -2187,6 +2260,56 @@ export class CoreNode extends EventEmitter { this.props.data = d; } + get rtl(): boolean { + if (this._ownRtl !== null) { + return this._ownRtl; + } + const parent = this.props.parent; + return parent !== null ? parent.rtl : false; + } + + set rtl(value: boolean | null) { + const own = typeof value === 'boolean' ? value : null; + if (own === this._ownRtl) { + return; + } + this._ownRtl = own; + this.props.rtl = own; + + if (this.props.parent === null) { + // Root has no parent to inherit from: resolve immediately and push the + // direction down to children (the root is driven by Stage.drawFrame and + // never runs the Direction branch of `update()` itself). + this._rtl = own ?? false; + this.childUpdateType |= UpdateType.Direction; + this.setUpdateType(UpdateType.Children); + } else { + this.setUpdateType(UpdateType.Direction); + } + } + + /** + * Resolve the layout direction (own override, else inherited from parent) + * and apply it. Returns `true` when the resolved value changed. + */ + resolveDirection(): boolean { + const own = this._ownRtl; + const parent = this.props.parent; + const resolved = own !== null ? own : parent !== null ? parent._rtl : false; + if (resolved === this._rtl) { + return false; + } + this._rtl = resolved; + this.onDirectionChanged(resolved); + return true; + } + + /** + * Hook invoked when the resolved layout direction changes. Base + * implementation is a no-op; `CoreTextNode` overrides it to reflow text. + */ + protected onDirectionChanged(_rtl: boolean): void {} + get x(): number { return this.props.x; } diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts index 8031a6b..492d02a 100644 --- a/src/core/CoreTextNode.test.ts +++ b/src/core/CoreTextNode.test.ts @@ -37,6 +37,7 @@ const defaultProps = ( pivotX: 0, pivotY: 0, rotation: 0, + rtl: null, rtt: false, scale: 1, scaleX: 1, diff --git a/src/core/CoreTextNode.ts b/src/core/CoreTextNode.ts index fca319b..8b0543b 100644 --- a/src/core/CoreTextNode.ts +++ b/src/core/CoreTextNode.ts @@ -23,6 +23,7 @@ import type { RectWithValid } from './lib/utils.js'; import type { CoreRenderer } from './renderers/CoreRenderer.js'; import type { TextureLoadedEventHandler } from './textures/Texture.js'; import { Matrix3d } from './lib/Matrix3d.js'; +import { resolveTextAlign } from './text-rendering/TextLayoutEngine.js'; export interface CoreTextNodeProps extends CoreNodeProps, TrProps { /** * Force Text Node to use a specific Text Renderer @@ -126,7 +127,8 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps { let mountTranslateY = p.mountY * h; const tProps = this.textProps; - const { textAlign, verticalAlign, maxWidth, maxHeight } = tProps; + const { verticalAlign, maxWidth, maxHeight } = tProps; + const textAlign = resolveTextAlign(tProps.textAlign, this._rtl); const contain = this._containType; const hasMaxWidth = maxWidth > 0; @@ -192,10 +194,29 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps { } } + protected override onDirectionChanged(rtl: boolean): void { + // Text alignment (left/right) is mirrored under RTL, so the layout must be + // regenerated with the new effective alignment. + this.textProps.rtl = rtl; + this._layoutGenerated = false; + this._cachedLayout = null; + } + /** * Override CoreNode's update method to handle text-specific updates */ override update(delta: number, parentClippingRect: RectWithValid): void { + // Resolve layout direction up-front (CoreNode.update would otherwise do it + // after the text generation below), so the layout is regenerated with the + // correct alignment in the same frame instead of flashing for one frame. + if (this.updateType & UpdateType.Direction) { + if (this.resolveDirection() === true) { + this.updateType |= UpdateType.Local | UpdateType.Children; + this.childUpdateType |= UpdateType.Direction; + } + this.updateType &= ~UpdateType.Direction; + } + if ( (this.textProps.forceLoad === true || this.allowTextGeneration() === true) && diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 7f495e1..8aedc70 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -367,6 +367,7 @@ export class Stage { pivotX: 0.5, pivotY: 0.5, rotation: 0, + rtl: null, parent: null, texture: null, textureOptions: {}, @@ -1056,6 +1057,7 @@ export class Stage { pivotX: props.pivotX ?? pivot, pivotY: props.pivotY ?? pivot, rotation: props.rotation ?? 0, + rtl: props.rtl ?? null, rtt: props.rtt ?? false, data, imageType: props.imageType, diff --git a/src/core/text-rendering/CanvasTextRenderer.ts b/src/core/text-rendering/CanvasTextRenderer.ts index ec1c4ca..b8db141 100644 --- a/src/core/text-rendering/CanvasTextRenderer.ts +++ b/src/core/text-rendering/CanvasTextRenderer.ts @@ -8,7 +8,7 @@ import type { import * as CanvasFontHandler from './CanvasFontHandler.js'; import type { CoreTextNodeProps } from '../CoreTextNode.js'; import { hasZeroWidthSpace } from './Utils.js'; -import { mapTextLayout } from './TextLayoutEngine.js'; +import { mapTextLayout, resolveTextAlign } from './TextLayoutEngine.js'; const MAX_TEXTURE_DIMENSION = 4096; @@ -130,7 +130,7 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => { CanvasFontHandler.measureText, metrics, text, - textAlign, + resolveTextAlign(textAlign, props.rtl === true), fontFamily, lineHeight, overflowSuffix, diff --git a/src/core/text-rendering/SdfTextRenderer.ts b/src/core/text-rendering/SdfTextRenderer.ts index bcfd4cc..bdd2a91 100644 --- a/src/core/text-rendering/SdfTextRenderer.ts +++ b/src/core/text-rendering/SdfTextRenderer.ts @@ -15,7 +15,7 @@ import type { WebGlCtxTexture } from '../renderers/webgl/WebGlCtxTexture.js'; import type { WebGlShaderNode } from '../renderers/webgl/WebGlShaderNode.js'; import { isProductionEnvironment } from '../../utils.js'; import type { TextLayout, GlyphLayout } from './TextRenderer.js'; -import { mapTextLayout } from './TextLayoutEngine.js'; +import { mapTextLayout, resolveTextAlign } from './TextLayoutEngine.js'; import type { RectWithValid } from '../lib/utils.js'; import type { Dimensions } from '../../common/CommonTypes.js'; @@ -37,7 +37,13 @@ const font: FontHandler = SdfFontHandler; const layoutCache = new Map(); const getLayoutCacheKey = (props: CoreTextNodeProps): string => - `${props.fontFamily}-${props.fontStyle}-${props.fontSize}-${props.letterSpacing}-${props.lineHeight}-${props.maxHeight}-${props.maxWidth}-${props.maxLines}-${props.textAlign}-${props.wordBreak}-${props.overflowSuffix}-${props.text}`; + `${props.fontFamily}-${props.fontStyle}-${props.fontSize}-${ + props.letterSpacing + }-${props.lineHeight}-${props.maxHeight}-${props.maxWidth}-${ + props.maxLines + }-${props.textAlign}-${props.rtl === true ? 1 : 0}-${props.wordBreak}-${ + props.overflowSuffix + }-${props.text}`; /** * SDF text renderer using MSDF/SDF fonts with WebGL @@ -255,7 +261,7 @@ const generateTextLayout = ( SdfFontHandler.measureText, metrics, props.text, - props.textAlign, + resolveTextAlign(props.textAlign, props.rtl === true), fontFamily, lineHeight, props.overflowSuffix, diff --git a/src/core/text-rendering/TextLayoutEngine.ts b/src/core/text-rendering/TextLayoutEngine.ts index d64e15a..9712509 100644 --- a/src/core/text-rendering/TextLayoutEngine.ts +++ b/src/core/text-rendering/TextLayoutEngine.ts @@ -44,6 +44,23 @@ type WrapStrategyFn = ( const CAP_HEIGHT_FALLBACK_RATIO = 0.7; const X_HEIGHT_FALLBACK_RATIO = 0.5; +/** + * Resolve the effective horizontal alignment for the layout direction. + * In RTL, `left` and `right` are swapped; `center` is unchanged. + */ +export const resolveTextAlign = (textAlign: string, rtl: boolean): string => { + if (rtl === false) { + return textAlign; + } + if (textAlign === 'left') { + return 'right'; + } + if (textAlign === 'right') { + return 'left'; + } + return textAlign; +}; + export const normalizeFontMetrics = ( metrics: FontMetrics, fontSize: number, diff --git a/visual-regression/certified-snapshots/chromium-ci/rtl-layout-1.png b/visual-regression/certified-snapshots/chromium-ci/rtl-layout-1.png new file mode 100644 index 0000000000000000000000000000000000000000..9d911cc73198a26b775bdbe65e84d0feff4ceccf GIT binary patch literal 19685 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 z zwW01vnt9c&+wY(xf9_@IXzG^kQFD_a9oV#I=RsnNkIK82flP-V2(q+~m&q*=DRUat zKoi|3gXXoElzj~d#9H~ybH`L~-|wh?`|u;z@HR|dYa@c=zi0BOYR}gGidxP3dn=TQQJ_JX3PEfsZ<;RO4Q}RBg#0f!y3qKPXqbEFhsv|-hJjSb;mY78B@v3`zEZE^aP}T&hNnFATk*!-wJfp7 z_gg)~VYXxY8ih9>_I;HAJ>}k>ml!tjRA>HEHv-io#h~L~C##PygrvIt@{8Cearedg z<$tCv!qE#jTcXnVZC(mT?%-jQKjZ~1fBO)8Y%2N~R3n3ybQ(6iJ&{*6_)ZQ#`F#Y` z_PAU7Q>v3GR6%Zfnu9@@>pT?|?)vLiOzpz+jyX2EM`7s|SP2=+-HgKuhX-zs{j5jg9dC*qQ-1g?|7zV2PAN0N^J5~OZm^eXbZxdNg#JUthzml^AJN?T;;R) zu|SmlJHqOa1~h!)h!4;l4lyV8pRMa^uk6b%w@LSIb*&RV!LvweX){u{5>Q`_*1P`6Mlw7N8)@(>K zaaUcvSKB81M6Oq3cDlX`6Sc?sGv=>-tKT7jqjWVJPFyL#_PTVPB80T01pz#$OsPHSozKOZU8G_}_0 z9`kx7UQR8*abWYFkt~C(@1ijHGONjS_V#}6T5Eb566M)T*bYg12!EGaX^CTcA6|m)`{}D%tlU>+S2Bfu*=?+KP&824sT5WzFv8mrCD*7lah1)daS7vCkrn$|9zci1g(-;Fmx&1ZFOwRW^B0mT zx%o+_j#bRlQrK=zPu_~ul~au$X&mSq`l}dGRU}W3zzTnmliQ2A&qU8RRvkR!^Kx zTT@vanAD6W-rEWsnhsh;>l*kBwY9+C9maku*RRc0trGkUiZ`nQZI4<15T;7O%~s^) zr>H%(KvA#^gyFtCXgsKbcHlK^U{VLlM~*V&^qUR?2c+a3ZV z_y#C(*xFA&yDoY{KZ))8DT&|?iv3YF@c=~?vJh-X9IO} z^DXAG3i&1#!~X)!KdoS82Imx99>v|zD7HV6s+5{)dh@U3S)1)xt^NX*U#1V@eSSqqvfgPtku1kldrj3@MAajqP=2!7r3%#M|G4JKs&WhQLL8&puZh zxA+5ebtn5!zj0~oha{KB?eKW7Hb^^&P$S}3L3DErnjDMP>i@YxFUDX7EWM;Jn13ZKW-7TyUU0KlN)#aap#7(i&d(`qNmRnPTgN23cX3W?oP4D}-0qwz<65UF zc+e|HlwX_(Y=!6)=a*I74Zr9gqf7{%9`9;%6xJ_HG3@Boi@?fnJzGZ)EZ3+jpKL

nV=q+8 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 literal 0 HcmV?d00001