@solidtv/renderer 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/src/core/CoreNode.d.ts +22 -0
  2. package/dist/src/core/CoreNode.js +131 -25
  3. package/dist/src/core/CoreNode.js.map +1 -1
  4. package/dist/src/core/CoreTextureManager.js +82 -24
  5. package/dist/src/core/CoreTextureManager.js.map +1 -1
  6. package/dist/src/core/Stage.js +3 -2
  7. package/dist/src/core/Stage.js.map +1 -1
  8. package/dist/src/core/TextureError.d.ts +2 -1
  9. package/dist/src/core/TextureError.js +2 -0
  10. package/dist/src/core/TextureError.js.map +1 -1
  11. package/dist/src/core/lib/ImageWorker.d.ts +1 -1
  12. package/dist/src/core/lib/ImageWorker.js +46 -26
  13. package/dist/src/core/lib/ImageWorker.js.map +1 -1
  14. package/dist/src/core/lib/Matrix3d.d.ts +9 -0
  15. package/dist/src/core/lib/Matrix3d.js +29 -3
  16. package/dist/src/core/lib/Matrix3d.js.map +1 -1
  17. package/dist/src/core/textures/ColorTexture.js +3 -1
  18. package/dist/src/core/textures/ColorTexture.js.map +1 -1
  19. package/dist/src/core/textures/ImageTexture.js +24 -16
  20. package/dist/src/core/textures/ImageTexture.js.map +1 -1
  21. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  22. package/dist/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +1 -1
  24. package/src/core/CoreNode.test.ts +438 -0
  25. package/src/core/CoreNode.ts +140 -39
  26. package/src/core/CoreTextureManager.ts +101 -22
  27. package/src/core/Stage.ts +3 -2
  28. package/src/core/TextureError.ts +2 -0
  29. package/src/core/lib/ImageWorker.ts +50 -27
  30. package/src/core/lib/Matrix3d.test.ts +72 -0
  31. package/src/core/lib/Matrix3d.ts +30 -3
  32. package/src/core/textures/ColorTexture.ts +3 -1
  33. package/src/core/textures/ImageTexture.ts +29 -20
@@ -533,4 +533,442 @@ describe('set color()', () => {
533
533
  expect(node.isSimple).toBe(false);
534
534
  });
535
535
  });
536
+
537
+ describe('simple-path localTransform writes', () => {
538
+ it('reuses the same Matrix3d instance across x/y updates', () => {
539
+ const parent = new CoreNode(stage, defaultProps());
540
+ parent.globalTransform = Matrix3d.identity();
541
+ const node = new CoreNode(stage, defaultProps({ parent }));
542
+
543
+ node.x = 10;
544
+ node.y = 20;
545
+ node.update(0, clippingRect);
546
+ const lt = node.localTransform!;
547
+ expect(lt.tx).toBe(10);
548
+ expect(lt.ty).toBe(20);
549
+
550
+ node.x = 100;
551
+ node.y = 200;
552
+ node.update(1, clippingRect);
553
+ // Same instance — no realloc per frame.
554
+ expect(node.localTransform).toBe(lt);
555
+ expect(lt.tx).toBe(100);
556
+ expect(lt.ty).toBe(200);
557
+ // Identity-shape preserved.
558
+ expect(lt.ta).toBe(1);
559
+ expect(lt.tb).toBe(0);
560
+ expect(lt.tc).toBe(0);
561
+ expect(lt.td).toBe(1);
562
+ expect(node._localIsTranslate).toBe(true);
563
+ });
564
+
565
+ it('resets ta/tb/tc/td when transitioning non-simple -> simple', () => {
566
+ const parent = new CoreNode(stage, defaultProps());
567
+ parent.globalTransform = Matrix3d.identity();
568
+ const node = new CoreNode(stage, defaultProps({ parent }));
569
+ node.props.w = 100;
570
+ node.props.h = 100;
571
+ node.pivot = 0.5;
572
+
573
+ // First, become non-simple via rotation — local matrix gets non-identity ta/tb/tc/td.
574
+ node.x = 50;
575
+ node.y = 50;
576
+ node.rotation = Math.PI / 2;
577
+ node.update(0, clippingRect);
578
+ expect(node._localIsTranslate).toBe(false);
579
+ const lt = node.localTransform!;
580
+ // Sanity: matrix is no longer in identity-shape
581
+ expect(lt.ta === 1 && lt.tb === 0 && lt.tc === 0 && lt.td === 1).toBe(
582
+ false,
583
+ );
584
+
585
+ // Clear rotation — now simple again.
586
+ node.rotation = 0;
587
+ node.x = 5;
588
+ node.y = 7;
589
+ node.update(1, clippingRect);
590
+
591
+ // Matrix must be restored to identity-shape, NOT carrying stale rotation.
592
+ expect(node.localTransform).toBe(lt);
593
+ expect(lt.ta).toBe(1);
594
+ expect(lt.tb).toBe(0);
595
+ expect(lt.tc).toBe(0);
596
+ expect(lt.td).toBe(1);
597
+ expect(node._localIsTranslate).toBe(true);
598
+ });
599
+ });
600
+
601
+ describe('translate-only global fast path', () => {
602
+ it('produces the same global translate as parent + local for simple chains', () => {
603
+ const parent = new CoreNode(stage, defaultProps());
604
+ parent.globalTransform = Matrix3d.translate(30, 40);
605
+ // parent is set up by the test as translate-only.
606
+ parent._globalIsTranslate = true;
607
+
608
+ const node = new CoreNode(stage, defaultProps({ parent }));
609
+ node.x = 5;
610
+ node.y = 7;
611
+ node.update(0, clippingRect);
612
+
613
+ expect(node.globalTransform!.tx).toBe(35);
614
+ expect(node.globalTransform!.ty).toBe(47);
615
+ expect(node.globalTransform!.ta).toBe(1);
616
+ expect(node.globalTransform!.tb).toBe(0);
617
+ expect(node.globalTransform!.tc).toBe(0);
618
+ expect(node.globalTransform!.td).toBe(1);
619
+ expect(node._globalIsTranslate).toBe(true);
620
+ });
621
+
622
+ it('propagates _globalIsTranslate through grandchildren', () => {
623
+ const root = new CoreNode(stage, defaultProps());
624
+ root.globalTransform = Matrix3d.identity();
625
+ root._globalIsTranslate = true;
626
+
627
+ const mid = new CoreNode(stage, defaultProps({ parent: root }));
628
+ mid.x = 10;
629
+ mid.y = 20;
630
+ mid.update(0, clippingRect);
631
+ expect(mid._globalIsTranslate).toBe(true);
632
+
633
+ const leaf = new CoreNode(stage, defaultProps({ parent: mid }));
634
+ leaf.x = 3;
635
+ leaf.y = 4;
636
+ leaf.update(0, clippingRect);
637
+ expect(leaf._globalIsTranslate).toBe(true);
638
+ expect(leaf.globalTransform!.tx).toBe(13);
639
+ expect(leaf.globalTransform!.ty).toBe(24);
640
+ });
641
+
642
+ it('does not take the fast path when parent is not translate-only', () => {
643
+ const parent = new CoreNode(stage, defaultProps());
644
+ // Parent global has a rotation baked in.
645
+ parent.globalTransform = Matrix3d.rotate(Math.PI / 2);
646
+ parent._globalIsTranslate = false;
647
+
648
+ const node = new CoreNode(stage, defaultProps({ parent }));
649
+ node.x = 10;
650
+ node.y = 0;
651
+ node.update(0, clippingRect);
652
+ // Child is simple itself but parent has rotation, so the resulting
653
+ // global cannot be translate-only.
654
+ expect(node._globalIsTranslate).toBe(false);
655
+ });
656
+
657
+ it('clears _globalIsTranslate when the node becomes non-simple', () => {
658
+ const parent = new CoreNode(stage, defaultProps());
659
+ parent.globalTransform = Matrix3d.identity();
660
+ parent._globalIsTranslate = true;
661
+
662
+ const node = new CoreNode(stage, defaultProps({ parent }));
663
+ node.props.w = 100;
664
+ node.props.h = 100;
665
+ node.x = 10;
666
+ node.y = 20;
667
+ node.update(0, clippingRect);
668
+ expect(node._globalIsTranslate).toBe(true);
669
+
670
+ // Add rotation -> non-simple -> global is no longer translate-only.
671
+ node.pivot = 0.5;
672
+ node.rotation = Math.PI / 4;
673
+ node.update(1, clippingRect);
674
+ expect(node._globalIsTranslate).toBe(false);
675
+ });
676
+
677
+ it('restores identity-shape on globalTransform when re-entering the fast path', () => {
678
+ const parent = new CoreNode(stage, defaultProps());
679
+ parent.globalTransform = Matrix3d.identity();
680
+ parent._globalIsTranslate = true;
681
+
682
+ const node = new CoreNode(stage, defaultProps({ parent }));
683
+ node.props.w = 100;
684
+ node.props.h = 100;
685
+ node.pivot = 0.5;
686
+ node.x = 10;
687
+ node.y = 20;
688
+ node.rotation = Math.PI / 2;
689
+ node.update(0, clippingRect);
690
+ expect(node._globalIsTranslate).toBe(false);
691
+ const gt = node.globalTransform!;
692
+ // sanity: rotation baked into the global
693
+ expect(gt.ta === 1 && gt.tb === 0 && gt.tc === 0 && gt.td === 1).toBe(
694
+ false,
695
+ );
696
+
697
+ // Remove rotation -> simple again -> fast path applies, must reset ta/tb/tc/td.
698
+ node.rotation = 0;
699
+ node.x = 5;
700
+ node.y = 6;
701
+ node.update(1, clippingRect);
702
+
703
+ expect(node._globalIsTranslate).toBe(true);
704
+ expect(node.globalTransform).toBe(gt);
705
+ expect(gt.ta).toBe(1);
706
+ expect(gt.tb).toBe(0);
707
+ expect(gt.tc).toBe(0);
708
+ expect(gt.td).toBe(1);
709
+ expect(gt.tx).toBe(5);
710
+ expect(gt.ty).toBe(6);
711
+ });
712
+ });
713
+
714
+ describe('updateBoundingRect axis-alignment check', () => {
715
+ it('uses 4-corner bounds when one shear component is non-zero', () => {
716
+ // Without the && fix, the axis-aligned branch fires whenever EITHER
717
+ // tb or tc is 0, which produces wrong bounds for matrices with
718
+ // a single non-zero shear and a sign that places corners outside
719
+ // the (x1,y1)–(x3,y3) diagonal.
720
+ const parent = new CoreNode(stage, defaultProps());
721
+ parent.globalTransform = Matrix3d.identity();
722
+ const node = new CoreNode(stage, defaultProps({ parent }));
723
+ node.props.w = 100;
724
+ node.props.h = 100;
725
+ node.update(0, clippingRect);
726
+
727
+ const gt = node.globalTransform!;
728
+ gt.ta = 1;
729
+ gt.tb = 0;
730
+ gt.tc = -0.5;
731
+ gt.td = 1;
732
+ gt.tx = 0;
733
+ gt.ty = 100;
734
+
735
+ node.calculateRenderCoords();
736
+ node.updateBoundingRect();
737
+
738
+ // Corners with the above matrix:
739
+ // TL (0, 100), TR (100, 50), BR (100, 150), BL (0, 200)
740
+ // Correct bounds: x in [0, 100], y in [50, 200].
741
+ // Axis-aligned diagonal would yield y in [100, 150] — wrong.
742
+ const rb = node.renderBound!;
743
+ expect(rb.x1).toBe(0);
744
+ expect(rb.x2).toBe(100);
745
+ expect(rb.y1).toBe(50);
746
+ expect(rb.y2).toBe(200);
747
+ });
748
+
749
+ it('still uses the diagonal bounds when both shear components are zero', () => {
750
+ const parent = new CoreNode(stage, defaultProps());
751
+ parent.globalTransform = Matrix3d.identity();
752
+ const node = new CoreNode(stage, defaultProps({ parent }));
753
+ node.props.w = 100;
754
+ node.props.h = 100;
755
+ node.x = 10;
756
+ node.y = 20;
757
+ node.update(0, clippingRect);
758
+
759
+ const rb = node.renderBound!;
760
+ expect(rb.x1).toBe(10);
761
+ expect(rb.y1).toBe(20);
762
+ expect(rb.x2).toBe(110);
763
+ expect(rb.y2).toBe(120);
764
+ });
765
+ });
766
+
767
+ describe('updateLocalTransform scale-only fast path', () => {
768
+ it('produces correct ta/td without touching tb/tc for scale-only nodes', () => {
769
+ const parent = new CoreNode(stage, defaultProps());
770
+ parent.globalTransform = Matrix3d.identity();
771
+ const node = new CoreNode(stage, defaultProps({ parent }));
772
+ node.x = 10;
773
+ node.y = 20;
774
+ node.props.w = 100;
775
+ node.props.h = 100;
776
+ node.scaleX = 2;
777
+ node.scaleY = 3;
778
+
779
+ node.update(0, clippingRect);
780
+
781
+ const lt = node.localTransform!;
782
+ expect(lt.ta).toBe(2);
783
+ expect(lt.tb).toBe(0);
784
+ expect(lt.tc).toBe(0);
785
+ expect(lt.td).toBe(3);
786
+ // No pivot configured -> translation is just (x - mountTranslate)
787
+ expect(lt.tx).toBe(10);
788
+ expect(lt.ty).toBe(20);
789
+ });
790
+
791
+ it('applies pivot correctly under scale (no rotation)', () => {
792
+ const parent = new CoreNode(stage, defaultProps());
793
+ parent.globalTransform = Matrix3d.identity();
794
+ const node = new CoreNode(stage, defaultProps({ parent }));
795
+ node.props.w = 100;
796
+ node.props.h = 100;
797
+ node.x = 0;
798
+ node.y = 0;
799
+ node.pivot = 0.5;
800
+ node.scaleX = 2;
801
+ node.scaleY = 2;
802
+
803
+ node.update(0, clippingRect);
804
+
805
+ // Algebraically: pivot scaling around the center.
806
+ // tx = x - mountX*w + pivotX*w*(1 - sx)
807
+ // = 0 - 0 + 50*(1-2) = -50
808
+ // ty similarly = -50
809
+ const lt = node.localTransform!;
810
+ expect(lt.ta).toBe(2);
811
+ expect(lt.tb).toBe(0);
812
+ expect(lt.tc).toBe(0);
813
+ expect(lt.td).toBe(2);
814
+ expect(lt.tx).toBe(-50);
815
+ expect(lt.ty).toBe(-50);
816
+ });
817
+ });
818
+
819
+ describe('eagerly-allocated transforms (Fix 6)', () => {
820
+ it('allocates localTransform and globalTransform on construction', () => {
821
+ const node = new CoreNode(stage, defaultProps());
822
+ expect(node.localTransform).toBeInstanceOf(Matrix3d);
823
+ expect(node.globalTransform).toBeInstanceOf(Matrix3d);
824
+ });
825
+
826
+ it('initial matrices are in identity-shape', () => {
827
+ const node = new CoreNode(stage, defaultProps());
828
+ const lt = node.localTransform!;
829
+ const gt = node.globalTransform!;
830
+ expect(lt.ta).toBe(1);
831
+ expect(lt.tb).toBe(0);
832
+ expect(lt.tc).toBe(0);
833
+ expect(lt.td).toBe(1);
834
+ expect(gt.ta).toBe(1);
835
+ expect(gt.tb).toBe(0);
836
+ expect(gt.tc).toBe(0);
837
+ expect(gt.td).toBe(1);
838
+ });
839
+
840
+ it('reuses the same globalTransform instance across updates', () => {
841
+ const parent = new CoreNode(stage, defaultProps());
842
+ parent.globalTransform = Matrix3d.translate(0, 0);
843
+ parent._globalIsTranslate = true;
844
+ const node = new CoreNode(stage, defaultProps({ parent }));
845
+ const gtBefore = node.globalTransform!;
846
+
847
+ node.x = 10;
848
+ node.y = 20;
849
+ node.update(0, clippingRect);
850
+ expect(node.globalTransform).toBe(gtBefore);
851
+
852
+ node.x = 100;
853
+ node.update(1, clippingRect);
854
+ expect(node.globalTransform).toBe(gtBefore);
855
+
856
+ // And after going into non-simple territory the same instance is still
857
+ // mutated in place rather than reallocated.
858
+ node.props.w = 100;
859
+ node.props.h = 100;
860
+ node.pivot = 0.5;
861
+ node.rotation = Math.PI / 2;
862
+ node.update(2, clippingRect);
863
+ expect(node.globalTransform).toBe(gtBefore);
864
+ });
865
+
866
+ it('_localIsTranslate defaults to true so the first update can take the fast path', () => {
867
+ const node = new CoreNode(stage, defaultProps());
868
+ expect(node._localIsTranslate).toBe(true);
869
+ });
870
+ });
871
+
872
+ describe('cached _hasContainResize (Fix 4)', () => {
873
+ it('starts as false on a fresh node', () => {
874
+ const node = new CoreNode(stage, defaultProps());
875
+ expect(node._hasContainResize).toBe(false);
876
+ });
877
+
878
+ it('flips to true when both texture and contain resizeMode are set', () => {
879
+ const node = new CoreNode(stage, defaultProps());
880
+ const tex = mock<ImageTexture>({ state: 'loaded' });
881
+ node.texture = tex;
882
+ expect(node._hasContainResize).toBe(false);
883
+
884
+ node.textureOptions = { resizeMode: { type: 'contain' } };
885
+ expect(node._hasContainResize).toBe(true);
886
+ });
887
+
888
+ it('flips back to false when texture is cleared or resizeMode changes', () => {
889
+ const node = new CoreNode(stage, defaultProps());
890
+ const tex = mock<ImageTexture>({ state: 'loaded' });
891
+ node.texture = tex;
892
+ node.textureOptions = { resizeMode: { type: 'contain' } };
893
+ expect(node._hasContainResize).toBe(true);
894
+
895
+ node.textureOptions = { resizeMode: { type: 'cover' } };
896
+ expect(node._hasContainResize).toBe(false);
897
+
898
+ node.textureOptions = { resizeMode: { type: 'contain' } };
899
+ expect(node._hasContainResize).toBe(true);
900
+
901
+ node.texture = null;
902
+ expect(node._hasContainResize).toBe(false);
903
+ });
904
+ });
905
+
906
+ describe('children update loop branches', () => {
907
+ it('inherited childUpdateType is applied even to children with updateType=0', () => {
908
+ const root = new CoreNode(stage, defaultProps());
909
+ root.globalTransform = Matrix3d.identity();
910
+ root.worldAlpha = 1;
911
+
912
+ const parent = new CoreNode(stage, defaultProps({ parent: root }));
913
+ parent.alpha = 1;
914
+ parent.w = 100;
915
+ parent.h = 100;
916
+
917
+ const child = new CoreNode(stage, defaultProps({ parent }));
918
+ child.alpha = 1;
919
+ child.w = 100;
920
+ child.h = 100;
921
+
922
+ // Bring both to steady state then clear pending work on the child.
923
+ parent.update(0, clippingRect);
924
+ child.updateType = 0;
925
+
926
+ // Mark parent dirty with WorldAlpha — it should cascade into the
927
+ // child via the `childUpdateType !== 0` loop branch, even though
928
+ // the child started this frame with no pending work.
929
+ parent.alpha = 0.5;
930
+ parent.update(1, clippingRect);
931
+
932
+ expect(child.worldAlpha).toBeCloseTo(0.5, 5);
933
+ });
934
+
935
+ it('skips children with no pending work when there is nothing to inherit', () => {
936
+ const root = new CoreNode(stage, defaultProps());
937
+ root.globalTransform = Matrix3d.identity();
938
+ root.worldAlpha = 1;
939
+
940
+ const parent = new CoreNode(stage, defaultProps({ parent: root }));
941
+ parent.alpha = 1;
942
+ parent.w = 100;
943
+ parent.h = 100;
944
+
945
+ const childA = new CoreNode(stage, defaultProps({ parent }));
946
+ childA.alpha = 1;
947
+ childA.w = 100;
948
+ childA.h = 100;
949
+ const childB = new CoreNode(stage, defaultProps({ parent }));
950
+ childB.alpha = 1;
951
+ childB.w = 100;
952
+ childB.h = 100;
953
+
954
+ parent.update(0, clippingRect);
955
+ childA.updateType = 0;
956
+ childB.updateType = 0;
957
+
958
+ // Mark only childB dirty. Force parent into the Children branch
959
+ // without seeding any inherited bits, so the loop should take the
960
+ // `childUpdateType === 0` branch and only walk dirty children.
961
+ childB.setUpdateType(UpdateType.Local);
962
+ parent.updateType |= UpdateType.Children;
963
+ parent.childUpdateType = 0;
964
+
965
+ const spyA = vi.spyOn(childA, 'update');
966
+ const spyB = vi.spyOn(childB, 'update');
967
+
968
+ parent.update(1, clippingRect);
969
+
970
+ expect(spyA).not.toHaveBeenCalled();
971
+ expect(spyB).toHaveBeenCalledTimes(1);
972
+ });
973
+ });
536
974
  });