Repository navigation
Conversation
…tays drift-free." This reverts commit b87bcd9.
…ps down by reason.
5f90058 to
02e7c32
Compare
| return origin; | ||
| } | ||
|
|
||
| std::string tokenX = tokens[0]; |
There was a problem hiding this comment.
Critical|transform-origin 两值「垂直关键字在前」时 X/Y 互换
两值/三值这里无条件把 tokens[0] 当 X、tokens[1] 当 Y,只有单值才做了 top/bottom→Y 的修正;而 ResolveOriginAxis(:209) 又把 top→0、bottom→dimension 当作 X 值接受,于是不告警、直接给出错误支点。以 100×60 盒推演:bottom left→(100,0)(CSS 应为 (0,60))、top center→(0,30)(应为 (50,0))、top right→(0,60)(应为 (100,0))。同一函数还用于 perspective-origin(HTMLStyleCascade.cpp:806),消失点一起错。旧实现是「告警 + 回退中心」,新实现变成静默错误。
建议:先按轴归类(left/right→x,top/bottom→y,center 与长度按位置补位)再赋值;补 bottom left / top center / bottom right 20px 用例(现有用例只用了 right bottom,恰好正确)。
| // The static pose is already un-pivoted (the pivot lives in `anchor`), so the keyframe baseline | ||
| // is exactly `layer->matrix` / `layer->matrix3D`. | ||
| const Matrix matrixChannelBaseline = baselineMatrix; | ||
| const Matrix3D matrix3DChannelBaseline = layer->matrix3D; |
There was a problem hiding this comment.
Critical|matrix3D 通道的 fill-mode 基线取 layer->matrix3D,静态 2D 变换在 delay/结束后被抹掉
通道路由用 staticPoseIs3D = !layer->matrix3D.isIdentity()(:1399);当元素有静态 2D matrix(如 transform: rotate(20deg))而关键帧含 3D 姿态时走 matrix3D 通道,但基线是 layer->matrix3D(单位阵)。ApplyFillMode(:636)会在 frame 0(delay 期)与 activeEnd+1(Once 结束后)插入单位阵 Hold,运行时 applyMatrix3D 以 mix=1 覆盖并 publish(LayerBuilder.cpp:420-440 / 495),静态 matrix 被彻底遮蔽。
触发:transform: rotate(20deg) + @keyframes{0%{rotateY(0)} 100%{rotateY(60deg)}},默认 fill-mode: none,播完后静态旋转永久丢失。
建议:基线改为 ComposeLayerTransform3DWithoutAnchor(layer)(LayerTransformUtils.h:69,本文件已 include),可同时覆盖静态 matrix3D / matrix / components 三种来源。
| // A transform that carries `perspective()` is split so the projection can move onto a wrapper | ||
| // (`perspective` property), which is the encoding PAGX reproduces exactly. | ||
| const split = splitProjectiveTransform(boxTransform.transform); | ||
| const forwarded = split |
There was a problem hiding this comment.
Major|「跳过相机」分支在投影已被丢弃之后才回退,注释与文档与实际相反
这里 forwarded.transform 已是 split.affine(投影行已去掉),而 withPerspectiveCamera 的 depth-pivot / not-a-sized-div 分支只是 return html(:4719/:4724),注释却写 "the function form is kept instead"。关键帧路径更严重:rewritePerspectiveKeyframes(:2056)在任何元素渲染前就把投影拍平,元素若走 skip 分支,输出里就完全没有 perspective。
已确认实例:49_rotate3d_depth_pivot.html 用内联 transform-origin: 0px 80px 30px → depth-pivot skip,但其 keyframes 已被拍平,实际是正交投影,而非 spec 13.7 所述「只差 origin×projection 项、约几像素」。
建议:确认能包装成功后再剥离投影;关键帧改写前先确认所有引用该动画名的元素都可包装(或复制一份带投影的 keyframes);同时把 cameraSkips 的文案/列名改成真实语义。
| const camera = split ? split.distance : 0; | ||
| return withStrippedTransform(el, () => render(el, parentRect, { | ||
| ...opts, _strippedTransform: true, transform: boxTransform, | ||
| ...opts, _strippedTransform: true, transform: forwarded, _cameraDistance: camera, |
There was a problem hiding this comment.
Major|用「已剥离 transform/origin」的 computed 取相机几何:perspective-origin 被重置、深度守卫失效
getComputedStyle(el) 在 withStrippedTransform 闭包内求值,而该函数会清空内联 transform-origin(:2130)。于是 withPerspectiveCamera 拿到的 transformOrigin 是 50% 50%:①perspective-origin 被重置为盒中心,消失点偏移;②内联 transform-origin: … 30px 的 depth 读成 0,depth-pivot 守卫被绕过,本该回退的元素被强行包装。
建议:相机几何一律用剥离前的值(opts.transform.transformOrigin 就是剥离前读到的 computed origin),只改取值时机。
| const computed = precomputed || getComputedStyle(el); | ||
| // Name the element's own box before the camera wrapper is added: an author `name` identifies the | ||
| // layer that carries the content and the animation, not the structural camera layer above it. | ||
| const named = applyLayerName(el, renderElementBox(el, parentRect, opts, computed), opts, computed); |
There was a problem hiding this comment.
Major|静态 perspective() 元素的 name 落到相机包装层
renderElementBox 返回的已是包装后的 html,外层 applyLayerName 会把 name 插到第一个标签(相机 wrapper)上;内层因 _strippedTransform 直接 stripNameAnchor(:4631)。liftBoxOntoCameraWrapper 的「属性留在元素自身盒子」只对动画路径成立(name 先加、后包装)。结果 layer->name 指向无内容、无动画的结构层,与本次提交 "Keep the authored layer name on the element box under a camera wrapper" 相反;perspective-rewrite.test.js 只覆盖了动画路径。
建议:包装成功后把 name 从 wrapper 移交内层 div,或由内层 render 负责注入。
| } | ||
| out->push_back(Trim(value.substr(nameStart, closeParen - nameStart + 1))); | ||
| index = closeParen + 1; | ||
| if (index < size && !std::isspace(static_cast<unsigned char>(value[index]))) { |
There was a problem hiding this comment.
Major|拒绝合法的「函数间无空格」写法,整条 transform 被丢弃
CSS <transform-list> 是 <transform-function>+,) 后不需要空白:transform:rotate(45deg)scale(2)(压缩后的 CSS 很常见)完全合法,这里判为 malformed;subset 阶段直接 DropProperty(HTMLSubsetPropertyTable.cpp:328),resolver 再告警一次。
另::552 用 value.find(')') 而非深度匹配,translateX(calc(100% - 10px)) 会被切成残片,诊断退化成 malformed。
建议:去掉空格强制;括号改深度匹配,可直接复用 HTMLDetail 的 SplitTopLevel* 系列。
|
|
||
| // `ParseAngle` maps unparsable input to 0, which would silently turn `rotate(abc)` into a no-op, so | ||
| // the numeric prefix is validated first. | ||
| bool ParseRotationDegrees(const std::string& token, float& out) { |
There was a problem hiding this comment.
Major|角度单位只校验「数字前缀」,非法/错误单位被静默当度
suffix 未 ToLower、无 grad、未知单位一律 return v(HTMLDetail.cpp:577-588)。结果:rotate(45px)→45°(CSS 应整条声明失效)、rotate(100grad)→100°(应 90°)、rotate(0.5TURN)→0.5°(CSS 单位大小写不敏感,应 180°)。skewX/skewY/skew/rotate3d 全族共用 AngleAt。
建议:在此严格校验 suffix ∈ {'', deg, grad, rad, turn}(先小写),无单位仅允许 0。
| // pure translation, so the runtime can apply `T(anchor) * pose * T(-anchor)` once, after the | ||
| // keyframes have been interpolated. A pose whose linear part is the identity (pure translation or | ||
| // pure projection) is unaffected by the pivot, so it keeps the legacy matrix-only form. | ||
| const bool needsAnchor = box.transform.originResolved && !IsLinearIdentity(pose); |
There was a problem hiding this comment.
Major|「姿态→通道/锚点」的决策链在两个 Builder 中重复实现且已分叉
静态侧(本函数 697-734)与关键帧侧(HTMLAnimationBuilder.cpp:1250-1280、1372-1422)各自实现 PushPerspective → IsFlatTransform → IsInvertible → IsLinearIdentity → ToAffine2D/LiftMatrixToMatrix3D,行为已经不同:静态侧不可逆时丢弃整条 transform,关键帧侧只丢该 stop;基线处理也不同(见另一条 matrix3D 基线问题)。
建议:抽到 HTMLTransform3D.h 的纯函数(如 ResolvePosePlacement 返回 {flat, affine, pose3D, needsAnchor}),两个 Builder 只保留「写入 Layer / 映射 Channel」的差异。
| diagnostics.warn("html: skewX expects 1 argument; got '" + token + "'"); | ||
| return false; | ||
| } | ||
| *out = MakeShear(-degrees, 0.0f); |
There was a problem hiding this comment.
Major|skewX/skew 的 X 分量符号与 CSS 及同仓 SVG 解析相反
CSS skewX(α) = matrix(1,0,tanα,1,0,0)(c = +tanα),这里传 -degrees 得到 c = -tanα;同仓 SVGTransformParser.cpp:130 对同一语法用 +tan,而 HTMLWriterUtils 按 matrix(a,b,c,d) 原样回写,HTML→PAGX→HTML 会镜像翻转。skewX 属 main 既有行为,但新增的 skew(θ) 沿用了同一错误,且 :132-133 的注释把它固化成「一直如此」。
现有 PAGXHTMLImporterTest.cpp:8770 只断言非单位矩阵,抓不住符号。建议:确认符号约定后统一本文件 / SVG 解析 / 回写三处,并补 c 的数值断言。
| diagnostics.warn("html: rotate3d expects 3 axis numbers and 1 angle; ignored"); | ||
| return false; | ||
| } | ||
| const float length = std::sqrt(x * x + y * y + z * z); |
There was a problem hiding this comment.
Minor|rotate3d 轴向量平方溢出时退化为均匀缩放
rotate3d(1e20,0,0,45deg):x*x 溢出为 inf → length = inf → 通过 > 1e-6 检查 → 归一化得 (0,0,0) → MakeRotationAxis 产出 cos·I,且被判为合法 3D 姿态写入 matrix3D。
建议:用 double 累加平方和,或显式校验 std::isfinite(length)。
| for (const auto& ts : transformStops) { | ||
| matrix3DKeys.push_back({ts.first, | ||
| IsFlatTransform(ts.second) | ||
| ? LiftMatrixToMatrix3D(ToAffine2D(ts.second), 0.0f) |
There was a problem hiding this comment.
Minor|平面奇异 stop 被 lift 进 matrix3D 后未做可逆校验
ParseCssTransformList 明确允许平面奇异姿态,静态侧也不校验;但整条动画一旦判为 3D,平面 stop 会被 LiftMatrixToMatrix3D 提升写入 matrix3D 通道,这条路径没有可逆检查。PAGXImporter.cpp:1820 对 matrix3D 关键帧强制可逆,失败即报错并替换为单位阵,HTML→PAGX→再导入会丢值(触发:同一动画混有 scale(0) 与任意 3D 关键帧)。
建议:lift 前校验可逆,不可逆时按 dropped 处理。
|
|
||
| // CSS `perspective` projects the direct children, so the bag handed down carries it only one level. | ||
| HTMLInheritedStyle childInherited = inherited; | ||
| childInherited.parentPerspectivePx = box.perspectivePx; |
There was a problem hiding this comment.
Minor|body 上的 perspective 不会下推
convertContainer 在这里把 box.perspectivePx 传给子层,但 convertBody(:647-704)没有对应的 childInherited 构造,body { perspective: 800px } 被完全静默丢弃,所有子元素的 3D 变换按无相机渲染。这是本次新增能力里唯一没接上的容器路径。
建议:convertBody 补一段与这里相同的下推逻辑。
| "has no resolved offset, so the perspective centre follows its layout position"); | ||
| warnedFlowChild = true; | ||
| } | ||
| box.parentPerspectiveOriginX = originX - (std::isnan(box.leftPx) ? 0.0f : box.leftPx); |
There was a problem hiding this comment.
Minor|相机原点换算只减 leftPx/topPx
①absolute 子元素的 left 相对父的 padding box,而 perspective-origin 在父的 border box 里度量,应减 borderWidth + left,现在少减一个边框宽度;②right/bottom 定位时 leftPx 为 NaN 被当作 0,消失点落到父左上角,且因 box.absolute 为真连 flow 告警也不会触发。
建议:能确定偏移时用 borderWidth + left;leftPx 不可用时并入 flow child 的告警。
| // The declaring box had no absolute size; the projection step reports that once. | ||
| return; | ||
| } | ||
| // The camera only changes the result when the child's pose leaves the z = 0 plane (or its pivot |
There was a problem hiding this comment.
Minor|只有动画里含 3D 的子层不会触发相机告警
cameraAffectsPose 只看静态 transform:元素仅通过 @keyframes 得到 3D 姿态时 box.transform.valid == false,不告警;但 buildForElement 仍会把 flow 近似原点推进每一帧,等于「已知会偏却不报」,与 spec 宣称的 one diagnostic 不符。
建议:判据扩展到「静态 transform 或含 transform 动画」,或在首次折叠投影时告警一次。
| const auto& style = _styleCascade->getResolvedStyle(entry.element); | ||
| _animationBuilder->buildForElement( | ||
| style, entry.layer, _styleCascade->getTransformReferenceSize(entry.element), | ||
| entry.parentPerspectivePx, entry.parentPerspectiveOriginX, entry.parentPerspectiveOriginY); |
There was a problem hiding this comment.
Minor|inline-SVG 形状动画完全不带相机
注意下面的 buildForInlineSvgShape 调用没有相机参数(signature 里也没有)。父级声明 perspective 时,<svg> 自身无 transform 就不会带 P,形状动画的投影完全丢失。
建议:明确纳入,或写进 spec 13.7 的限制列表。
| parsed.originResolved = origin.resolved; | ||
| if (!origin.resolved) { | ||
| _diagnostics.warn("html: transform-origin '" + originValue + "' on <" + elementName + | ||
| "> cannot be resolved against the box size; pivoting at the top-left " |
There was a problem hiding this comment.
Minor|origin 无法解析时无条件告警,纯平移也会误报
origin.resolved == false 就告警,但线性部分为单位阵的姿态(纯平移/纯投影)支点对结果没有影响,HTMLLayerBuilder.cpp:717 已用 IsLinearIdentity 表达这一点。<div style="transform:translateX(10px)"> 无显式宽高时会报 transform-origin '' on <div> …(空串也不友好)。
建议:加 && !IsLinearIdentity(pose),文案回退 50% 50% (default)。
| // supported single-function subset; compound chains and matrix(...) forms are dropped. | ||
| {"transform", PropAction::Transform, &TransformTransform, nullptr}, | ||
| {"transform-origin", PropAction::Transform, &TransformTransformOrigin, nullptr}, | ||
| // `transform-style: preserve-3d` maps onto `Layer.preserve3D`; the flat default is a no-op. |
There was a problem hiding this comment.
Minor|多处过时/矛盾注释
本表上方(:445-447)仍写「handler 只支持单函数子集,复合链与 matrix(...) 会被丢弃」,与 TransformTransform 现在的实现相反;同文件 :344-348 还写「downstream 只理解默认 50% 50%」,已被新的 ResolveTransformOrigin 推翻;:477 的「Explicit drops」小节标题压在两行 Keep 之上。另有 HTMLStyleCascade.cpp:793「perspective-origin 无 PAGX 对应」、HTMLAnimationBuilder.cpp:1663「origin 逐帧烘焙 / wrapper」、HTMLAnimationBuilder.h:35-47 通道列表缺 matrix3D。其中两处描述的机制已被本 PR 删除。
建议:逐条更新。
| Disallowed (warning + skip): `border-{top,right,bottom,left}`, per-corner `border-*-radius`, | ||
| `outline`, `perspective`, geometric `clip-path` forms (`inset`/`circle`/`ellipse`/`polygon`/ | ||
| `path`), and compound / non-`matrix3d` 3D `transform` chains. | ||
| `outline`, the `perspective` property (use `transform: perspective()` instead), and geometric |
There was a problem hiding this comment.
Minor|spec 与实现矛盾:perspective 属性
这里仍把 perspective 属性列为「禁用(请改用 transform: perspective())」,但本 PR 已把 HTMLSubsetPropertyTable 中的 perspective 改为 Keep 并实现了解析,同一份 spec 的 13.7 又要求 snapshot 把 perspective() 改写成父级 perspective 属性,并在用例注释里作为做法引用。三处不能同时成立。
建议:从 Disallowed 删除,并把 perspective / perspective-origin 加入 4.4 支持表。中文版 :188-189 同步。
| | `transform` single fn + origin | supported | `13-transform__rotate/scale/scale-xy/skew-x/translate/matrix/transform-origin-center` | | ||
| | `transform` chained / 3d / perspective | dropped | `13-transform__rotate-chained`, `...rotate3d`, `...perspective` | | ||
| | `transform` functions + origin | supported; `transform-origin` lands on `Layer.anchor` | `13-transform__rotate/scale/scale-xy/skew-x/translate/matrix/transform-origin-center` | | ||
| | `transform-origin` px / % / keyword | honoured as the pivot (`Layer.anchor`) | `13-transform__transform-origin-px-noncenter`, `...percent`, `...keywords`, `...skew-corner` | |
There was a problem hiding this comment.
Minor|引用了 6 个不存在的用例
13-transform__transform-origin-px-noncenter / ...percent / ...keywords / ...skew-corner / ...transform-origin-three-value / ...transform-origin-translate 在 resources/html/cases/13-transform/ 与 manifest.csv 中均不存在(该目录只有 10 个文件,只有 transform-origin-center.html;manifest 187 行 ↔ 187 个 html 是一致的)。
建议:补用例并登记 manifest,或改回真实覆盖范围。
| return std::isfinite(out); | ||
| } | ||
|
|
||
| std::vector<std::string> SplitCommaArgs(const std::string& args) { |
There was a problem hiding this comment.
Minor|重复实现仓库已有的分隔工具
SplitCommaArgs 与 HTMLDetail.cpp:49 SplitTopLevelCommas 等价,SplitWhitespaceTokens 与 HTMLDetail.cpp:68 SplitTopLevelWhitespace 等价(后者还处理括号嵌套),而本文件已 include HTMLDetail.h;另 ParseStrictFloat 与 HTMLStyleCascade.cpp:70 ParseScalarFloat 重复。
建议:删掉这 34 行副本直接用 HTMLDetail 的实现,顺带改善 calc 嵌套处理。
| #include <cctype> | ||
| #include <cmath> | ||
| #include <cstdlib> | ||
| #include <utility> |
There was a problem hiding this comment.
Nit|<utility> 未使用(全文件无 std::move/pair/swap)。另见 test/src/PAGXHTML3DTest.cpp:47-48 的 Point.h、ExporterUtils.h 同样未被引用。
| * its bottom row set to `(0, 0, -1/distance, 1)`. `Layer.perspective` builds the same matrix, but a | ||
| * CSS `perspective` declaration lives on the parent and must be pushed into the child pose instead. | ||
| */ | ||
| Matrix3D PerspectiveMatrix(float distance); |
There was a problem hiding this comment.
Nit|PerspectiveMatrix 无需导出:唯一调用方是同文件的 PushPerspective(HTMLBoxAttributes.h:323 只是注释引用),可下沉匿名命名空间。IsInvertible(:93) 仅是 IsInvertibleMatrix3D 的一行转发,若无额外语义也可直接调用原名。
|
|
||
| namespace { | ||
|
|
||
| constexpr float kDegToRad = 3.14159265358979323846f / 180.0f; |
There was a problem hiding this comment.
Nit|kDegToRad 与已 include 的 Matrix3DDecompose.h:30 DEGREES_TO_RADIANS 重复,且精度不同(float vs double),建议复用后者。
| // `translate` stops at two lengths, so only `translate3d` reaches the depth axis. | ||
| if (!transformArgs.LengthAt(0, x)) { | ||
| diagnostics.warn("html: translate first argument '" + parts[0] + | ||
| "' is not a px length; ignored"); |
There was a problem hiding this comment.
Nit|长度失败会产生两条诊断:parseAbsoluteLengthPx 已在单位非法时 warn(HTMLValueParser.cpp:341),这里再 warn 一次,translate(10foo) 会输出两条。建议加静默变体,或在调用方去重(仓库里的 ReferenceBoxResolutionDoesNotDuplicateDiagnostics 说明团队在意这一点)。
| // Composing rotations leaves values like 0.99999994 on the Z axis, so the plane test needs a | ||
| // tolerance instead of exact comparisons. | ||
| return NearlyZero(matrix.getRowColumn(2, 0)) && NearlyZero(matrix.getRowColumn(2, 1)) && | ||
| NearlyOne(matrix.getRowColumn(2, 2)) && NearlyZero(matrix.getRowColumn(2, 3)) && |
There was a problem hiding this comment.
Nit|IsFlatTransform 与 HasPerspective 对同一语义用了两套阈值:这里 1e-6 容差,LayerTransformUtils.h:36 用精确 != 0,perspective(1000000px) 两者结论相反。建议共用常量,或在注释里说明阈值来源。
| // so the transform write and the animation registration read the same value regardless of which | ||
| // element happens to be converting when they run. `parentPerspectiveOrigin*` is the parent's | ||
| // `perspective-origin` re-expressed in this element's local coordinates. | ||
| float parentPerspectivePx = 0.0f; |
There was a problem hiding this comment.
Nit|同名字段两种坐标系:HTMLInheritedStyle:78-80 与这里都有 parentPerspectiveOriginX/Y,前者是父本地坐标、后者是本元素本地坐标,仅靠注释区分;转换发生在 HTMLParserContext.cpp:74-75 且不改名,后续容易误用。建议 box 侧改为 localPerspectiveOriginX/Y。
| return parts.length === 2 ? parts[0] + ' ' + parts[1] : '50% 50%'; | ||
| } | ||
|
|
||
| function withPerspectiveCamera(el, html, opts, computed) { |
There was a problem hiding this comment.
Nit|el 参数未使用;另外 cameraDistanceForElement(:4672)对每个元素都做 String().split(',').map 并调用 pagxAnimCameras(),animationName 为空或 none 时可提前返回。
| // Per-glyph / TextModifier / TextPath container host: convert the whole container into a | ||
| // single semantic Text layer instead of recursing into its PUA glyph spans. | ||
| HTMLBoxAttributes box = _styleCascade->computeBoxAttributes(element); | ||
| ApplyParentPerspective(box, inherited.parentPerspectivePx, inherited.parentPerspectiveOriginX, |
There was a problem hiding this comment.
Nit|四处完全相同的 ApplyParentPerspective 调用(731 / 756 / 763 / 772),可挪到 computeBoxAttributes 之后、分支之前统一调用一次(<br> 分支除外),减少漏调风险。
| float heightPct = NAN; | ||
| ParseSizingDimension(LookupProperty(props, "width"), reference.widthPx, widthPct); | ||
| ParseSizingDimension(LookupProperty(props, "height"), reference.heightPx, heightPct); | ||
| if (element == nullptr || element->name != "svg") { |
There was a problem hiding this comment.
Nit|不可达的空指针防御:这里与 :474 的 element == nullptr 判断不可达——:444 的 getResolvedStyle(element) 与 :484 的 element->findAttribute 已无条件解引用。建议删除或改为断言。
总结
HTML 导入器的变换管线从 2D 仿射 升级为 4×4 + 锚点:
transform全函数支持、transform-origin改写Layer.anchor、动画按平面/深度分流matrix/matrix3D,并接住 CSSperspective属性。3D 从「完全丢失」提升到 7/8 用例 ≥ 0.9968。行为变化
transformrotate3d/skew/matrix3d)matrix3d(...)Layer.matrix3D原值透传,仅真平面时降级为matrixtransform-originmatrix,无anchoranchor/anchorZtransformx/y→matrix→matrix3D三通道,关键帧不带支点perspective属性perspective-origin)关键实现
HTMLTransform3D.{h,cpp}(新增)HTMLLayerBuilder/HTMLAnimationBuildermatrix,非平面 →matrix3D+anchor」HTMLStyleCascade/HTMLParserContextperspective/perspective-origin解析与相机下推;<svg>属性尺寸只作支点参考盒tools/html-snapshotmatrix3d关键帧不再压平;函数式perspective()改写为父层相机;文本 span 不再重复带动画评测(8 用例 × 5 帧,对比 Chromium 真值)
43_rotate_offcenter_pivot44_scale_offcenter_pivot45_rotate3d_center46_rotate3d_offcenter_pivot47_text_rotate_pivot48_svg_rotate_pivot50_perspective_propertyperspective属性以上 7 例均值 0.9961、像素差 0.02%;8/8 动画均被捕获。
49_rotate3d_depth_pivot(支点带 30px 深度)为 0.9280 / 2.24%,是 CSS 语义本身的限制而非实现缺陷:CSS 让transform-origin连perspective()一起共轭(T(a)·P·L·T(-a)),而 PAGX / AE / Lottie 把投影留在支点之外(P·T(a)·L·T(-a));perspective-origin只有 x/y 两个分量,无法表达"支点在平面外",因此任何换相机的写法都无法对齐。该用例作为已知差异保留。测试
PAGFullTest_OpenGLtools/html-snapshotjestcases/validate.sh兼容性与已知限制
anchor,纯 3D 输出matrix3D,纯旋转动画少一层 → 结构敏感的基准需重新 seed49的说明,属 CSS 语义限制范围说明
不拆分 PR:3D 解析、anchor 迁移、
perspective属性、快照改写共用同一份姿态管线,拆开后任一半都无法独立验证;