From ea1027e5a65cda9879868d8bccc017b47d71d889 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 15:21:39 +0800 Subject: [PATCH] =?UTF-8?q?=E9=98=B6=E6=AE=B5=203=EF=BC=9A=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E6=95=B0=E6=8D=AE=20=E2=80=94=E2=80=94=20=E9=87=87?= =?UTF-8?q?=E6=A0=B7=E3=80=81=E5=90=88=E6=88=90=E3=80=81=E6=9C=80=E6=96=B0?= =?UTF-8?q?=E5=B8=A7=E8=B0=83=E5=BA=A6=E4=B8=8E=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?=20-=20What:=20sampling.py=20=E5=AE=9E=E7=8E=B0=20SurfaceSample?= =?UTF-8?q?r=EF=BC=88=E5=90=91=E9=87=8F=E5=8C=96=E5=9B=9B=E7=82=B9?= =?UTF-8?q?=E5=8F=8C=E7=BA=BF=E6=80=A7=EF=BC=9A=E6=97=A0=E6=95=88=20tap=20?= =?UTF-8?q?=E4=B8=8D=E8=AE=A1=E5=85=A5=E4=B9=9F=E4=B8=8D=E8=87=B4=E6=97=A0?= =?UTF-8?q?=E6=95=88=E3=80=81=E4=BB=BB=E4=B8=80**=E6=AD=A3=E6=9D=83**=20ta?= =?UTF-8?q?p=20=E6=97=A0=E6=95=88=E5=8D=B3=E6=95=B4=E9=A1=B6=E7=82=B9?= =?UTF-8?q?=E6=97=A0=E6=95=88=E4=B8=94=E5=80=BC=E4=B8=BA=20NaN=E3=80=81?= =?UTF-8?q?=E5=9F=9F/=E5=BD=A2=E7=8A=B6=E4=B8=8D=E7=AC=A6=E5=85=88?= =?UTF-8?q?=E6=8B=92=EF=BC=9B=E9=87=87=E6=A0=B7=E8=A1=A8=E5=8F=AA=E5=9C=A8?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E6=97=B6=E6=9E=84=E5=BB=BA=EF=BC=89=E4=B8=8E?= =?UTF-8?q?=20remap=5Fto=5Fdomain=EF=BC=88=E6=8C=89=20unroll=20=E6=98=A0?= =?UTF-8?q?=E5=B0=84=20gather=EF=BC=8C=E7=A9=BA=E6=B4=9E=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=20NaN=20=E4=B8=94=20valid=3DFalse=EF=BC=8C=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=B0=BE=E9=9A=8F=E8=BD=B4=E4=BB=A5=E6=94=AF=E6=8C=81=E7=9F=A2?= =?UTF-8?q?=E9=87=8F=E5=9C=BA=EF=BC=89=E3=80=82composer.py=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=20SceneComposer.compose=EF=BC=88=E5=9F=9F=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20=E2=86=92=20=E9=87=87=E6=A0=B7=20=E2=86=92=20?= =?UTF-8?q?=E4=BD=8D=E7=A7=BB=20=E2=86=92=20**=E4=BB=8E=E5=BD=A2=E5=8F=98?= =?UTF-8?q?=E5=90=8E=E4=BD=8D=E7=BD=AE=E9=87=8D=E7=AE=97=E6=B3=95=E7=BA=BF?= =?UTF-8?q?**=EF=BC=9Bscalar=20=E7=BB=9D=E4=B8=8D=E6=88=90=E4=BB=A5=20disp?= =?UTF-8?q?lay=5Fgain=EF=BC=9Bapproximate=20=E4=BD=8D=E7=A7=BB=E4=BA=A7?= =?UTF-8?q?=E7=94=9F=20display=5Fillustrative(info)=20=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E3=80=81calibrated=20=E4=B8=8D=E4=BA=A7=E7=94=9F=E3=80=81?= =?UTF-8?q?=E6=97=A0=E4=BD=8D=E7=A7=BB=E5=88=99=E7=9D=80=E8=89=B2=E5=B9=B6?= =?UTF-8?q?=E6=8A=A5=20field=5Funavailable=EF=BC=89=E4=B8=8E=20build=5Fres?= =?UTF-8?q?t=5Fframe=EF=BC=88=E5=8F=82=E8=80=83=E5=B8=A7=EF=BC=9Ascalar=20?= =?UTF-8?q?=E5=85=A8=20NaN=E3=80=81valid=20=E5=85=A8=E5=81=87=E3=80=81desc?= =?UTF-8?q?riptor=3Dunavailable/display=5Fonly=EF=BC=89=EF=BC=8Cgl=5Fbacke?= =?UTF-8?q?nd.rest=5Fsurface=20=E6=94=B9=E4=B8=BA=E5=A7=94=E6=89=98?= =?UTF-8?q?=E5=AE=83=EF=BC=8C=E9=81=BF=E5=85=8D=E4=B8=A4=E5=A4=84=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E3=80=82coordinator.py=20=E5=AE=9E=E7=8E=B0=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E8=B0=83=E5=BA=A6=EF=BC=9A=E5=AE=B9=E9=87=8F=201=20?= =?UTF-8?q?=E5=8F=8C=20mailbox=E3=80=81generation/=E6=9D=A5=E6=BA=90/?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E4=B8=89=E9=87=8D=E8=BF=87=E6=9C=9F=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E5=B9=B6=E8=AE=A1=E6=95=B0=E3=80=81**=E6=9C=80?= =?UTF-8?q?=E5=A4=9A=E4=B8=80=E6=AC=A1=E5=90=88=E6=88=90=E5=9C=A8=E9=A3=9E?= =?UTF-8?q?=E8=A1=8C**=E3=80=81=E5=8F=AF=E8=A7=81=E6=80=A7=E6=8C=82?= =?UTF-8?q?=E8=B5=B7/=E6=81=A2=E5=A4=8D=E3=80=81=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=EF=BC=88=E5=80=8D=E7=8E=87=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E9=87=8D=E5=90=88=E6=88=90**=E5=B1=8F=E5=B9=95=E4=B8=8A?= =?UTF-8?q?=E9=82=A3=E4=B8=80=E5=B8=A7**=EF=BC=89=E3=80=81close=20?= =?UTF-8?q?=E5=B9=82=E7=AD=89=EF=BC=9Bsubmit=20=E5=8F=AA=E5=81=9A=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E4=B8=8E=E5=BD=A2=E7=8A=B6=E7=9A=84=E5=BB=89=E4=BB=B7?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=EF=BC=8C=E7=BB=9D=E4=B8=8D=E7=AD=89=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E3=80=82adapters.py=20=E5=AE=9E=E7=8E=B0=20Finger/Box?= =?UTF-8?q?=20=E9=80=82=E9=85=8D=E5=99=A8=EF=BC=9A|flow|=20=E6=88=96=20dep?= =?UTF-8?q?th=20=E5=93=8D=E5=BA=94=E3=80=81=E5=8F=AF=E9=80=89=E7=BB=8F=20u?= =?UTF-8?q?nroll=20=E6=98=A0=E5=B0=84=20remap=E3=80=81=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=20valid=5Fmask=20=E4=BC=98=E5=85=88=E3=80=81=E5=9F=9F=E4=B8=8D?= =?UTF-8?q?=E7=AC=A6=E6=97=B6**=E5=8F=91=E5=B8=83=E7=A9=BA**=E5=B9=B6?= =?UTF-8?q?=E6=8A=A5=E5=87=BA=E6=89=80=E8=A7=81=E7=9A=84=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E5=BD=A2=E7=8A=B6=EF=BC=9B=E4=B8=80=E5=BE=8B=E4=B8=8D=E9=80=A0?= =?UTF-8?q?=E4=BD=8D=E7=A7=BB=EF=BC=88=E6=9C=AA=E6=A0=87=E5=AE=9A=E7=9A=84?= =?UTF-8?q?=E6=AF=AB=E7=B1=B3=E4=B8=8D=E5=A6=82=E6=B2=A1=E6=9C=89=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=96=B0=E5=A2=9E=20display=5Fillustrative?= =?UTF-8?q?=20=E5=88=B0=E8=AF=8A=E6=96=AD=E7=A0=81=E8=A1=A8=EF=BC=88info?= =?UTF-8?q?=EF=BC=89=E3=80=82assets.py=20=E4=BB=8E"=E6=A1=A9=E7=B1=BB?= =?UTF-8?q?=E5=A0=86"=E6=94=B9=E6=88=90=E9=97=A8=E9=9D=A2=EF=BC=9Are-expor?= =?UTF-8?q?t=20=E7=9C=9F=E6=AD=A3=E5=AE=9E=E7=8E=B0=20+=20SceneCompiler.co?= =?UTF-8?q?mpile=EF=BC=88=E5=A4=96=E5=A3=B3=20GLB=20=E4=B8=8E=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E6=A3=80=E6=9F=A5=EF=BC=9B=E5=8A=A0=E5=AF=86=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=98=8E=E7=A1=AE=E6=8B=92=E7=BB=9D=E5=B9=B6=E6=8C=87?= =?UTF-8?q?=E5=90=91=E9=98=B6=E6=AE=B5=205=EF=BC=89=E3=80=82=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=9Ashared/tests/test=5Fscene3d=5Flive.py=2025=20?= =?UTF-8?q?=E9=A1=B9=EF=BC=88=E9=87=87=E6=A0=B7/=E6=97=A0=E6=95=88?= =?UTF-8?q?=E8=AF=AD=E4=B9=89/=E5=80=8D=E7=8E=87=E5=8F=AA=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E4=BD=8D=E7=BD=AE/=E7=A4=BA=E6=84=8F=E5=BD=A2?= =?UTF-8?q?=E5=8F=98=E6=A0=87=E6=B3=A8/=E6=B3=95=E7=BA=BF=E9=87=8D?= =?UTF-8?q?=E7=AE=97/remap=20=E7=A9=BA=E6=B4=9E/=E5=8F=82=E8=80=83?= =?UTF-8?q?=E5=B8=A7/=E5=8F=AA=E8=AF=BB=E6=95=B0=E7=BB=84=EF=BC=89?= =?UTF-8?q?=E3=80=81apps/studio/tests/test=5Fscene3d=5Fcoordinator.py=2013?= =?UTF-8?q?=20=E9=A1=B9=EF=BC=88=E7=8A=B6=E6=80=81=E6=9C=BA=E3=80=81?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E3=80=81=E6=9B=BF=E6=8D=A2=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=E3=80=81=E5=8D=95=E9=A3=9E=E8=A1=8C=E3=80=81submit=20p99<1ms?= =?UTF-8?q?=E3=80=81=E5=88=87=E6=8D=A2=E5=9C=BA=E6=99=AF=E4=B8=A2=E5=BC=83?= =?UTF-8?q?=E5=9C=A8=E9=A3=9E=E7=BB=93=E6=9E=9C=E3=80=81=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E6=8C=82=E8=B5=B7=E3=80=81=E5=80=8D=E7=8E=87=E9=87=8D=E5=90=88?= =?UTF-8?q?=E6=88=90=E3=80=81close=20=E5=B9=82=E7=AD=89=EF=BC=89=E3=80=81t?= =?UTF-8?q?est=5Fscene3d=5Fadapters.py=2013=20=E9=A1=B9=EF=BC=9B=E5=B9=B6?= =?UTF-8?q?=E6=8A=8A=E4=B8=89=E5=A4=84"=E6=A1=A9=E4=BB=8D=E6=9C=AA?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0"=E7=9A=84=E8=BF=87=E6=97=B6=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E6=94=B9=E6=88=90=E5=AF=B9=E5=B7=B2=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E7=9A=84=E6=96=AD=E8=A8=80=E3=80=82docs/arch?= =?UTF-8?q?itecture/studio-3d-stages.md=20=E9=98=B6=E6=AE=B5=203=20?= =?UTF-8?q?=E8=AE=B0=E4=B8=BA=E5=AE=8C=E6=88=90=E3=80=82=20-=20Why:=20?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=203=20=E5=86=B3=E5=AE=9A"=E5=B1=8F=E5=B9=95?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E9=A2=9C=E8=89=B2=E5=92=8C=E5=8A=9B=E5=80=BC?= =?UTF-8?q?=E5=88=B0=E5=BA=95=E5=AF=B9=E5=BA=94=E4=BB=80=E4=B9=88"?= =?UTF-8?q?=E3=80=82=E4=B8=89=E6=9D=A1=E6=9C=80=E5=AE=B9=E6=98=93=E9=94=99?= =?UTF-8?q?=E4=B8=94=E8=82=89=E7=9C=BC=E7=9C=8B=E4=B8=8D=E5=87=BA=E6=9D=A5?= =?UTF-8?q?=E7=9A=84=E8=A7=84=E5=88=99=E5=BF=85=E9=A1=BB=E7=94=B1=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=80=8C=E4=B8=8D=E6=98=AF=E7=BA=A6=E5=AE=9A=E6=9D=A5?= =?UTF-8?q?=E4=BF=9D=E8=AF=81=EF=BC=9A=E6=97=A0=E6=95=88=E7=82=B9=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=200=EF=BC=88NaN=20=E4=B8=8E=20valid=20=E5=88=86?= =?UTF-8?q?=E7=A6=BB=EF=BC=8C=E5=90=A6=E5=88=99=E7=BC=BA=E5=8F=A3=E4=BC=9A?= =?UTF-8?q?=E8=A2=AB=E7=94=BB=E6=88=90=E4=B8=80=E4=B8=AA=E6=B5=8B=E9=87=8F?= =?UTF-8?q?=E5=80=BC=EF=BC=89=E3=80=81=E6=98=BE=E7=A4=BA=E5=80=8D=E7=8E=87?= =?UTF-8?q?=E5=8F=AA=E5=8A=A8=E4=BD=8D=E7=BD=AE=E4=B8=8D=E5=8A=A8=E6=B5=8B?= =?UTF-8?q?=E9=87=8F=EF=BC=88=E5=90=A6=E5=88=99=E8=89=B2=E6=A0=87=E6=82=84?= =?UTF-8?q?=E6=82=84=E5=8F=98=E4=BA=86=E5=90=AB=E4=B9=89=EF=BC=89=E3=80=81?= =?UTF-8?q?=E4=BB=A5=E5=8F=8A=E6=98=BE=E7=A4=BA=E6=B0=B8=E8=BF=9C=E4=B8=8D?= =?UTF-8?q?=E8=83=BD=E7=BB=99=E9=87=87=E9=9B=86=E6=96=BD=E5=8A=A0=E8=83=8C?= =?UTF-8?q?=E5=8E=8B=EF=BC=88=E5=AE=B9=E9=87=8F=201=20mailbox=20+=20?= =?UTF-8?q?=E5=8D=95=E9=A3=9E=E8=A1=8C=E5=90=88=E6=88=90=20+=20=E5=BB=89?= =?UTF-8?q?=E4=BB=B7=20submit=EF=BC=89=E3=80=82=20-=20Verification:=20shar?= =?UTF-8?q?ed=20=E5=85=A8=E9=87=8F=E9=80=9A=E8=BF=87=EF=BC=88scene3d=20?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=20103=20=E9=A1=B9=EF=BC=9Acontracts=2026?= =?UTF-8?q?=E3=80=81assets=2033=E3=80=81package=2019=E3=80=81live=2025?= =?UTF-8?q?=EF=BC=89=EF=BC=9Bstudio=20=E5=85=A8=E9=87=8F=E9=80=9A=E8=BF=87?= =?UTF-8?q?=EF=BC=88scene3d=20=E7=9B=B8=E5=85=B3=2054=20=E9=A1=B9=EF=BC=9A?= =?UTF-8?q?stage1=2024=E3=80=81skeleton=204=E3=80=81coordinator=2013?= =?UTF-8?q?=E3=80=81adapters=2013=EF=BC=9BGL=20=E5=86=92=E7=83=9F=2010=20?= =?UTF-8?q?=E9=A1=B9=E5=9C=A8=E7=9C=9F=E5=AE=9E=20context=20=E4=B8=8B?= =?UTF-8?q?=E5=8F=A6=E8=A1=8C=E9=80=9A=E8=BF=87=EF=BC=89=EF=BC=9Bui=5Fchec?= =?UTF-8?q?k=20554=20=E9=A1=B9=200=20=E5=A4=B1=E8=B4=A5=E3=80=82=E8=BF=87?= =?UTF-8?q?=E7=A8=8B=E4=B8=AD=E4=BF=AE=E6=8E=89=203=20=E4=B8=AA=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=BC=BA=E9=99=B7=EF=BC=9A=E5=80=8D=E7=8E=87=E5=8F=98?= =?UTF-8?q?=E5=8C=96=E6=97=B6=E7=94=A8=20incoming.peek()=20=E5=8F=96?= =?UTF-8?q?=E4=B8=8D=E5=88=B0"=E5=B1=8F=E5=B9=95=E4=B8=8A=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E5=B8=A7"=EF=BC=88=E5=B7=B2=E8=A2=AB=E6=B6=88?= =?UTF-8?q?=E8=B4=B9=EF=BC=89=E5=AF=BC=E8=87=B4=E6=94=B9=E4=BA=86=E5=80=8D?= =?UTF-8?q?=E7=8E=87=E7=94=BB=E9=9D=A2=E4=B8=8D=E5=8A=A8=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=BF=9D=E7=95=99=20=5Flast=5Ffield=5Fframe?= =?UTF-8?q?=EF=BC=9Bdisplay=5Fillustrative=20=E4=B8=8D=E5=9C=A8=E7=A0=81?= =?UTF-8?q?=E8=A1=A8=E9=87=8C=E8=A2=AB=E9=BB=98=E8=AE=A4=E6=88=90=20error?= =?UTF-8?q?=EF=BC=9Bassets.py=20=E9=87=8C=E4=B8=8E=20package/legacy=20?= =?UTF-8?q?=E5=90=8C=E5=90=8D=E7=9A=84=E9=87=8D=E5=A4=8D=E6=A1=A9=E7=B1=BB?= =?UTF-8?q?=EF=BC=88=E4=BC=9A=E8=AF=AF=E5=AF=BC=E9=9B=86=E6=88=90=E8=80=85?= =?UTF-8?q?=EF=BC=8C=E5=B7=B2=E6=94=B9=E4=B8=BA=E9=97=A8=E9=9D=A2=EF=BC=89?= =?UTF-8?q?=E3=80=82=E5=8F=A6=E4=BF=AE=E6=AD=A3=E4=B8=A4=E5=A4=84=E6=88=91?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E7=9A=84=E9=94=99=E8=AF=AF=E6=96=AD=E8=A8=80?= =?UTF-8?q?=EF=BC=88=E5=AF=B9=E7=A7=B0=E5=87=B8=E8=B5=B7=E9=A1=B6=E7=82=B9?= =?UTF-8?q?=E6=B3=95=E7=BA=BF=E6=9C=AC=E5=B0=B1=E7=AB=96=E7=9B=B4=EF=BC=9B?= =?UTF-8?q?pytest.approx=20=E4=B8=8D=E8=83=BD=E7=9B=B4=E6=8E=A5=20.all()?= =?UTF-8?q?=EF=BC=89=E3=80=82=20-=20Not=20in=20this=20change:=20=E6=89=8B?= =?UTF-8?q?=E5=8A=BF=E4=B8=8E=E9=9D=A2=E6=9D=BF=EF=BC=88=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=204=EF=BC=89=EF=BC=9B=E7=A4=BA=E6=84=8F=E4=BD=8D=E7=A7=BB?= =?UTF-8?q?=E7=9A=84=E7=9C=9F=E5=AE=9E=E6=9D=A5=E6=BA=90=EF=BC=88=E9=9C=80?= =?UTF-8?q?=E8=A6=81=20SDK=20lift=20+=20=E5=B7=B2=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E5=B0=BA=E5=BA=A6=EF=BC=8C=E7=8E=B0=E9=98=B6=E6=AE=B5=20adapte?= =?UTF-8?q?r=20=E4=B8=80=EF=BF=BD=EF=BF=BD=EF=BF=BD=20displacement=3DNone?= =?UTF-8?q?=EF=BC=89=EF=BC=9Bapp=5Fwindow=20=E6=8E=A5=E7=BA=BF=E4=B8=8E?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=9A=94=E7=A6=BB=EF=BC=88=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=204=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/orisys_studio/scene3d/adapters.py | 192 ++++++++- .../src/orisys_studio/scene3d/coordinator.py | 343 +++++++++++++-- .../src/orisys_studio/scene3d/gl_backend.py | 20 +- apps/studio/tests/test_scene3d_adapters.py | 272 ++++++++++++ apps/studio/tests/test_scene3d_coordinator.py | 314 ++++++++++++++ apps/studio/tests/test_scene3d_skeleton.py | 39 +- docs/architecture/studio-3d-stages.md | 42 +- .../src/orisys_app_shared/scene3d/assets.py | 174 +++++--- .../src/orisys_app_shared/scene3d/composer.py | 173 +++++++- .../orisys_app_shared/scene3d/contracts.py | 3 + .../src/orisys_app_shared/scene3d/legacy.py | 35 ++ .../src/orisys_app_shared/scene3d/sampling.py | 166 ++++++- shared/tests/test_scene3d_contracts.py | 56 ++- shared/tests/test_scene3d_live.py | 405 ++++++++++++++++++ 14 files changed, 2036 insertions(+), 198 deletions(-) create mode 100644 apps/studio/tests/test_scene3d_adapters.py create mode 100644 apps/studio/tests/test_scene3d_coordinator.py create mode 100644 shared/tests/test_scene3d_live.py diff --git a/apps/studio/src/orisys_studio/scene3d/adapters.py b/apps/studio/src/orisys_studio/scene3d/adapters.py index 52190e6..97019fb 100644 --- a/apps/studio/src/orisys_studio/scene3d/adapters.py +++ b/apps/studio/src/orisys_studio/scene3d/adapters.py @@ -8,44 +8,216 @@ The constraints here are about *where* the data may be read, not about optics: runtime, because the SDK's arrays are that thread's working memory; * it copies what the frame needs and publishes it read-only — the GUI thread must never reach back into a live runtime; -* it copies **nothing** when the three-dimensional view is not subscribed to, and - returns ``None`` when the frame carries no usable field. A frame is never - assembled from two different acquisitions, and a missing field is never filled - in from the previous one. +* it publishes **nothing** when the frame carries no usable field, or when the + field is on a different grid than the scene's UV domain. A remap that is not + configured is not guessed at: the adapter says which shapes it saw, so the + mismatch is a fact in the diagnostics rather than a picture that looks plausible. + +Two things the v1 adapters deliberately do **not** do: + +* they do not invent a displacement. The SDK's lift uses optical flow and a scale + estimated from the mesh, which the design document calls "not a verified + displacement calibration"; a millimetre field that was never calibrated is worse + than no field, so ``displacement_sensor_m`` is ``None`` and the surface is + coloured in place. The composer already handles an ``approximate`` displacement + for when a verified one arrives; +* they do not rescale or pad a field to fit. A field whose shape does not match the + domain is a mismatch (`field_domain_mismatch`), not something to resize. """ from __future__ import annotations from typing import Any, Optional +import numpy as np + from orisys_app_shared.acquisition.models import SdkPacket from orisys_app_shared.scene3d.contracts import ( + Diagnostic, ExpectedGeometry, + FieldDescriptor, FrameIdentity, SurfaceFieldFrame, + diagnostic, + error, + freeze, +) +from orisys_app_shared.scene3d.sampling import remap_to_domain + +#: What the adapters can colour: the optical flow's magnitude, in pixels. An +#: unsigned magnitude is a sequential colour scale, and ``approximate`` says the +#: number is a pixel displacement rather than a calibrated length. +FLOW_MAGNITUDE = FieldDescriptor( + quantity="flow_magnitude", unit="px", quality="approximate", signed=False +) +#: The deformation response the SDK's depth map reports: a relative response, not a +#: length. It is only ever coloured, never turned into millimetres. +DEPTH_RESPONSE = FieldDescriptor( + quantity="depth_response", unit="1", quality="approximate", signed=False ) -_STAGE = "stage 3 (live data)" + +def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]: + """``|flow|`` as float32, or ``None`` when the packet carries no flow.""" + flow = getattr(packet, "flow", None) + if flow is None: + return None + array = np.asarray(flow, dtype=np.float32) + if array.ndim == 3 and array.shape[-1] == 2: + return np.sqrt(array[..., 0] ** 2 + array[..., 1] ** 2) + if array.ndim == 2: + return array + return None + + +def _shape_of(array: Optional[np.ndarray]) -> str: + return "none" if array is None else "x".join(str(int(v)) for v in array.shape[:2]) class FingerFrameAdapter: - """The finger runtime already computes a UV field; copy it, with its identity.""" + """The finger runtime's field, in the scene's UV domain. + + ``map_x``/``map_y`` are the unroll map's source coordinates. When they are + supplied the field is remapped through them; when they are not, the field is + used directly and only if its shape already *is* the domain. + """ + + def __init__( + self, + *, + geometry_id: str, + field_domain_id: str, + shape_hw: tuple[int, int], + map_x: Optional[np.ndarray] = None, + map_y: Optional[np.ndarray] = None, + ) -> None: + self.geometry_id = str(geometry_id) + self.field_domain_id = str(field_domain_id) + self.shape_hw = (int(shape_hw[0]), int(shape_hw[1])) + self.map_x = map_x + self.map_y = map_y + self.last_diagnostic: Optional[Diagnostic] = None + + @classmethod + def from_domain(cls, domain: Any, *, geometry_id: str) -> "FingerFrameAdapter": + """Build from a ``LegacyFieldDomain`` (which carries the map, when loaded).""" + return cls( + geometry_id=geometry_id, + field_domain_id=str(getattr(domain, "domain_id")), + shape_hw=tuple(getattr(domain, "shape_hw")), + map_x=getattr(domain, "map_x", None), + map_y=getattr(domain, "map_y", None), + ) def snapshot( self, packet: SdkPacket, runtime: Any, identity: FrameIdentity ) -> Optional[SurfaceFieldFrame]: - raise NotImplementedError(f"{_STAGE}: snapshot the finger UV field") + magnitude = flow_magnitude(packet) + if magnitude is None: + self.last_diagnostic = diagnostic( + "field_unavailable", "this frame carries no optical flow" + ) + return None + if self.map_x is not None and self.map_y is not None: + try: + values, valid = remap_to_domain( + magnitude, map_x=self.map_x, map_y=self.map_y, shape_hw=self.shape_hw + ) + except Exception as exc: # noqa: BLE001 - a bad map is a diagnostic + self.last_diagnostic = getattr(exc, "diagnostic", None) or diagnostic( + "field_domain_mismatch", f"remap failed: {type(exc).__name__}: {exc}" + ) + return None + scalar = values + mask = valid + elif tuple(magnitude.shape[:2]) == self.shape_hw: + scalar = freeze(magnitude, dtype=np.float32) + mask = freeze(np.isfinite(magnitude), dtype=np.bool_) + else: + # Say what was seen: this is the message that tells an engineer whether + # the flow arrives in the tracker's grid or already unrolled. + self.last_diagnostic = diagnostic( + "field_domain_mismatch", + "the flow is not in the scene's UV domain and no remap is configured", + flow=_shape_of(magnitude), + domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}", + ) + return None + self.last_diagnostic = None + return SurfaceFieldFrame( + identity=identity, + geometry_id=self.geometry_id, + field_domain_id=self.field_domain_id, + scalar=scalar, + scalar_descriptor=FLOW_MAGNITUDE, + valid=mask, + ) class BoxFrameAdapter: - """Box/mini: a scalar field only. + """Box/mini: a scalar field only, and only when the grid already matches. - There is no verified spatial scale for these sensors yet, so no displacement is + There is no verified spatial scale for these sensors, so no displacement is invented: a millimetre height field that was never calibrated is worse than no height field at all. """ + def __init__( + self, + *, + geometry_id: str, + field_domain_id: str, + shape_hw: tuple[int, int], + ) -> None: + self.geometry_id = str(geometry_id) + self.field_domain_id = str(field_domain_id) + self.shape_hw = (int(shape_hw[0]), int(shape_hw[1])) + self.last_diagnostic: Optional[Diagnostic] = None + def snapshot( self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry ) -> Optional[SurfaceFieldFrame]: - raise NotImplementedError(f"{_STAGE}: snapshot the box scalar field") + if tuple(domain.shape_hw) != self.shape_hw: + self.last_diagnostic = diagnostic( + "field_domain_mismatch", + "the device description names a different field domain", + device=f"{domain.shape_hw[0]}x{domain.shape_hw[1]}", + scene=f"{self.shape_hw[0]}x{self.shape_hw[1]}", + ) + return None + candidates = ( + ("depth_response", getattr(packet, "depth", None)), + ("flow_magnitude", flow_magnitude(packet)), + ) + for name, field in candidates: + if field is None: + continue + array = np.asarray(field, dtype=np.float32) + if array.ndim != 2 or tuple(array.shape) != self.shape_hw: + continue + mask = np.isfinite(array) + explicit = getattr(packet, "valid_mask", None) + if explicit is not None: + explicit_array = np.asarray(explicit, dtype=bool) + if tuple(explicit_array.shape) == self.shape_hw: + # The device's own mask wins: it knows where its surface is. + mask = mask & explicit_array + self.last_diagnostic = None + return SurfaceFieldFrame( + identity=identity, + geometry_id=self.geometry_id, + field_domain_id=self.field_domain_id, + scalar=freeze(array, dtype=np.float32), + scalar_descriptor=( + DEPTH_RESPONSE if name == "depth_response" else FLOW_MAGNITUDE + ), + valid=freeze(mask, dtype=np.bool_), + ) + self.last_diagnostic = diagnostic( + "field_unavailable", + "no usable scalar field of the scene's shape in this frame", + depth=_shape_of(getattr(packet, "depth", None)), + flow=_shape_of(flow_magnitude(packet)), + domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}", + ) + return None diff --git a/apps/studio/src/orisys_studio/scene3d/coordinator.py b/apps/studio/src/orisys_studio/scene3d/coordinator.py index 88dfced..faf01b5 100644 --- a/apps/studio/src/orisys_studio/scene3d/coordinator.py +++ b/apps/studio/src/orisys_studio/scene3d/coordinator.py @@ -1,32 +1,50 @@ """Frame schedule and state machine for the three-dimensional view. -Stage 3 (mailboxes, compose worker) and stage 4 (Qt timer, visibility, settings) in +Stage 3 (mailboxes, compose worker) and stage 4 (Qt timer, visibility) in ``docs/architecture/studio-3d-stages.md``. The whole point of this class is that **the acquisition loop never waits for the -display**. Producers call :meth:`submit`, which validates cheaply and drops the -previous un-consumed frame; a GUI timer calls :meth:`tick`, which picks up the -newest composed frame and repaints. Display drops are counted here and never in -the recording channel, and no CPU task is submitted while one is still running. +display**. A producer calls :meth:`submit`, which validates cheaply and drops the +previous un-consumed frame; a GUI timer calls :meth:`tick`, which hands the newest +field frame to the composer, picks up the newest composed frame and asks the widget +to repaint. Three properties make that true rather than aspirational: + +* both mailboxes hold **one** frame, and ``submit`` only ever swaps a reference + under a lock — the work it can do is bounded by the lock, not by the display; +* **at most one compose task is in flight**. A slow composer makes frames drop; it + cannot make the queue grow; +* display drops are counted here and never mixed into the recording channel, which + keeps running through the existing sink path untouched. + +Staleness is about *identity*, not timing: a frame from another generation, another +session or another source than the active scene is dropped with ``stale_frame`` and +counted, and an old result can never overwrite a new scene because the composer's +result carries the identity it was built from and is checked again on arrival. """ from __future__ import annotations -from concurrent.futures import Executor -from typing import Optional +import threading +from concurrent.futures import Executor, Future +from typing import Any, Optional from orisys_app_shared.scene3d.contracts import ( + Diagnostic, DisplaySettings, + FrameIdentity, LatestFrameMailbox, OfferResult, RenderFrame, SceneAssets, SceneState, SurfaceFieldFrame, + diagnostic, + error, ) -from orisys_studio.scene3d.widget import Sensor3DWidget -_STAGE = "stage 3 (schedule) and stage 4 (Qt integration)" +#: How many composed frames may be "lost" before the coordinator stops caring: the +#: counter is informational, never a reason to throttle acquisition. +MAX_INFLIGHT_COMPOSES = 1 class ThreeDCoordinator: @@ -34,48 +52,41 @@ class ThreeDCoordinator: def __init__( self, - widget: Sensor3DWidget, - composer: object, + widget: Any, + composer: Any, executor: Executor, max_fps: int = 30, ) -> None: self._widget = widget self._composer = composer self._executor = executor - self._max_fps = int(max_fps) + self._max_fps = max(1, int(max_fps)) self._incoming: LatestFrameMailbox[SurfaceFieldFrame] = LatestFrameMailbox() self._composed: LatestFrameMailbox[RenderFrame] = LatestFrameMailbox() + self._lock = threading.Lock() self._state: SceneState = "disabled" self._generation = 0 self._assets: Optional[SceneAssets] = None + self._source_id = "" + self._session_id: Optional[int] = None self._visible = False + self._settings = DisplaySettings(max_fps=self._max_fps) + self._inflight: Optional[Future] = None + self._stale_frames = 0 + self._invalid_frames = 0 + self._last_diagnostic: Optional[Diagnostic] = None + self._closed = False + self._settings_dirty = False + self._last_composed_identity: Optional[FrameIdentity] = None + #: The field frame currently on screen, so a gain change can redraw exactly + #: it rather than a newer instant. + self._last_field_frame: Optional[SurfaceFieldFrame] = None - def activate(self, assets: SceneAssets, source_id: str) -> int: - """Adopt a scene and a data source; returns the new generation.""" - raise NotImplementedError(f"{_STAGE}: activate a scene") - - def submit(self, frame: SurfaceFieldFrame) -> OfferResult: - """Offer a field frame from a producer thread. Never blocks on the GUI.""" - raise NotImplementedError(f"{_STAGE}: accept or reject an incoming frame") - - def tick(self) -> None: - """GUI timer: take the newest composed frame, present it, schedule work.""" - raise NotImplementedError(f"{_STAGE}: pump the mailboxes") - - def set_visible(self, visible: bool) -> None: - """Hiding suspends composing, uploading and rendering — not acquisition.""" - raise NotImplementedError(f"{_STAGE}: suspend or resume the view") - - def set_settings(self, settings: DisplaySettings) -> None: - raise NotImplementedError(f"{_STAGE}: apply display settings") - - def close(self) -> None: - """Cancel work, stop the timer, clear both mailboxes. Idempotent.""" - raise NotImplementedError(f"{_STAGE}: shut the view down") - + # ------------------------------------------------------------------- state @property def state(self) -> SceneState: - return self._state + with self._lock: + return self._state @property def generation(self) -> int: @@ -83,7 +94,261 @@ class ThreeDCoordinator: @property def superseded_frames(self) -> int: - """Frames the display never showed. Not a recording drop.""" - return ( - self._incoming.superseded_frames + self._composed.superseded_frames + """Frames the display never showed. Never a recording drop.""" + return self._incoming.superseded_frames + self._composed.superseded_frames + + @property + def stale_frames(self) -> int: + return self._stale_frames + + @property + def invalid_frames(self) -> int: + return self._invalid_frames + + @property + def last_diagnostic(self) -> Optional[Diagnostic]: + return self._last_diagnostic + + @property + def visible(self) -> bool: + return self._visible + + def settings(self) -> DisplaySettings: + return self._settings + + def _set_state(self, state: SceneState) -> None: + with self._lock: + self._state = state + + # --------------------------------------------------------------- lifetime + def activate(self, assets: SceneAssets, source_id: str) -> int: + """Adopt a scene and a data source; returns the new generation. + + Everything already in flight belongs to the previous scene and is dropped: + the generation is what makes "old" mean something. + """ + if self._closed: + raise error("asset_rejected", "this coordinator has been closed") + self._cancel_inflight() + self._incoming.clear() + self._composed.clear() + self._assets = assets + self._source_id = str(source_id) + self._session_id = None + self._generation += 1 + self._last_composed_identity = None + self._last_field_frame = None + self._settings_dirty = False + self._widget.set_scene(assets) + if self._visible: + self._widget.present(self._composer.rest_frame(assets, self._identity(0))) + self._set_state("ready") + else: + self._set_state("loading" if self._visible else "suspended") + return self._generation + + def set_visible(self, visible: bool) -> None: + """Hiding suspends composing, uploading and rendering — not acquisition.""" + visible = bool(visible) + if self._closed: + return + self._widget.setVisible(visible) + if not visible: + self._visible = False + self._incoming.clear() + self._composed.clear() + self._cancel_inflight() + self._set_state("suspended") + return + self._visible = True + # Come back on the newest frame rather than replaying what was dropped. + self._set_state("ready" if self._assets is not None else "disabled") + + # ---------------------------------------------------------------- producers + def submit(self, frame: SurfaceFieldFrame) -> OfferResult: + """Offer a field frame from a producer thread. Never blocks on the GUI.""" + if self._closed or not self._visible: + return "disabled" + with self._lock: + assets = self._assets + generation = self._generation + session_id = self._session_id + if assets is None: + return "disabled" + identity = frame.identity + if identity.source_id != self._source_id or identity.generation != generation: + self._stale_frames += 1 + self._last_diagnostic = diagnostic( + "stale_frame", + "a frame from another source or generation was dropped", + source=identity.source_id, + generation=str(identity.generation), + ) + return "stale" + if session_id is not None and identity.session_id != session_id: + self._stale_frames += 1 + return "stale" + problem = self._reject_reason(frame, assets) + if problem is not None: + self._invalid_frames += 1 + self._last_diagnostic = problem + return "invalid" + with self._lock: + if session_id is None: + self._session_id = identity.session_id + return self._incoming.offer(frame) + + @staticmethod + def _reject_reason( + frame: SurfaceFieldFrame, assets: SceneAssets + ) -> Optional[Diagnostic]: + """The cheap checks a producer thread may do: identity and shape only.""" + if frame.geometry_id != assets.geometry_id: + return diagnostic( + "geometry_mismatch", + "the frame belongs to a different geometry", + frame=frame.geometry_id, + scene=assets.geometry_id, + ) + if frame.field_domain_id != assets.field_domain_id: + return diagnostic( + "field_domain_mismatch", + "the frame is on a different field domain", + frame=frame.field_domain_id, + scene=assets.field_domain_id, + ) + if tuple(frame.scalar.shape) != tuple(assets.binding.shape_hw): + return diagnostic( + "field_domain_mismatch", + "the frame's shape does not match the scene's UV domain", + frame=f"{frame.scalar.shape[0]}x{frame.scalar.shape[1]}", + scene=f"{assets.binding.shape_hw[0]}x{assets.binding.shape_hw[1]}", + ) + if tuple(frame.valid.shape) != tuple(frame.scalar.shape): + return diagnostic( + "field_domain_mismatch", "the frame's scalar and validity disagree" + ) + return None + + # ------------------------------------------------------------------- consumer + def tick(self) -> None: + """GUI timer: start one compose, take the newest result, repaint.""" + if self._closed or not self._visible or self._assets is None: + return + self._collect_inflight() + if self._inflight is None: + if self._settings_dirty and self._last_field_frame is not None: + # A gain change re-draws the field that is already on screen; taking + # a newer one would show a different instant than the one the + # operator just changed the exaggeration of. + self._settings_dirty = False + self._start_compose(self._last_field_frame) + else: + self._settings_dirty = False + frame = self._incoming.take() + if frame is not None: + self._start_compose(frame) + composed = self._composed.take() + if composed is not None: + if not self._identity_is_current(composed.identity): + self._stale_frames += 1 + return + self._widget.set_superseded_frames(self.superseded_frames) + self._widget.present(composed) + self._last_composed_identity = composed.identity + self._set_state("live" if composed.valid.any() else "no_data") + elif self._state in ("ready", "loading"): + # Nothing has arrived yet: show the reference model rather than a blank + # window, and say that no measurement is being displayed. + if self._last_composed_identity is None: + self._widget.present(self._composer.rest_frame(self._assets, self._identity(0))) + self._set_state("no_data") + + def set_settings(self, settings: DisplaySettings) -> None: + """Apply display settings; a gain change means recomposing the last field.""" + previous = self._settings + self._settings = settings + self._widget.set_settings(settings) + gain_changed = ( + previous.display_gain != settings.display_gain + or previous.deform_enabled != settings.deform_enabled ) + if gain_changed and self._assets is not None: + # The scalar is untouched by the gain, but the *positions* are not, so + # the last field frame has to be composed again. + self._settings_dirty = True + + def close(self) -> None: + """Cancel work, stop the timer, clear both mailboxes. Idempotent.""" + if self._closed: + return + self._closed = True + self._cancel_inflight() + self._incoming.clear() + self._composed.clear() + self._set_state("disabled") + + # ------------------------------------------------------------------ internal + def _identity(self, request_id: int) -> FrameIdentity: + return FrameIdentity( + source_id=self._source_id, + session_id=self._session_id or 0, + frame_id=0, + t_rel_s=0.0, + generation=self._generation, + request_id=int(request_id), + ) + + def _identity_is_current(self, identity: FrameIdentity) -> bool: + return ( + identity.source_id == self._source_id + and identity.generation == self._generation + and (self._session_id is None or identity.session_id == self._session_id) + ) + + def _start_compose(self, frame: SurfaceFieldFrame) -> None: + assets = self._assets + settings = self._settings + if assets is None: + return + generation = self._generation + # Kept so a display-settings change can recompose *this* frame instead of + # jumping to a newer one. + self._last_field_frame = frame + + def work() -> None: + try: + composed = self._composer.compose(assets, frame, settings) + except Exception as exc: # noqa: BLE001 - a failed compose is not a crash + self._last_diagnostic = getattr(exc, "diagnostic", None) or diagnostic( + "field_unavailable", f"compose failed: {type(exc).__name__}: {exc}" + ) + return + if composed.identity.generation != generation: + # The scene changed while this was running: the result is for a + # scene that no longer exists. + self._stale_frames += 1 + return + self._composed.offer(composed) + + self._inflight = self._executor.submit(work) + + def _collect_inflight(self) -> None: + future = self._inflight + if future is None: + return + if not future.done(): + return + self._inflight = None + try: + future.result() + except Exception as exc: # noqa: BLE001 + self._last_diagnostic = getattr(exc, "diagnostic", None) or diagnostic( + "field_unavailable", f"compose raised: {type(exc).__name__}: {exc}" + ) + + def _cancel_inflight(self) -> None: + future = self._inflight + self._inflight = None + if future is not None: + future.cancel() diff --git a/apps/studio/src/orisys_studio/scene3d/gl_backend.py b/apps/studio/src/orisys_studio/scene3d/gl_backend.py index 4265b43..f0d376f 100644 --- a/apps/studio/src/orisys_studio/scene3d/gl_backend.py +++ b/apps/studio/src/orisys_studio/scene3d/gl_backend.py @@ -310,24 +310,12 @@ def rest_surface(assets: SceneAssets, identity: Any) -> RenderFrame: Used to *clear* a measurement — after a refused frame, on connect, or on a disconnect — so the screen cannot keep showing the previous acquisition's - colours as if they were current. ``quality="display_only"`` and an - ``unavailable`` quantity are what tell the UI to hide the measurement bar. + colours as if they were current. The composer owns the definition; this is the + GL layer reaching it without importing the whole composition path. """ - from orisys_app_shared.scene3d.contracts import rest_descriptor + from orisys_app_shared.scene3d.composer import build_rest_frame - part = assets.sensing_part() - count = int(part.positions_sensor_m.shape[0]) - return RenderFrame( - identity=identity, - asset_hash=assets.asset_hash, - geometry_id=assets.geometry_id, - sensing_part_id=part.part_id, - positions_sensor_m=part.positions_sensor_m, - normals_sensor=part.normals_sensor, - scalar=freeze(np.full(count, np.nan, dtype=np.float32), dtype=np.float32), - valid=freeze(np.zeros(count, dtype=bool), dtype=np.bool_), - scalar_descriptor=rest_descriptor(), - ) + return build_rest_frame(assets, identity) # ------------------------------------------------------------------- dispatch diff --git a/apps/studio/tests/test_scene3d_adapters.py b/apps/studio/tests/test_scene3d_adapters.py new file mode 100644 index 0000000..b13d44e --- /dev/null +++ b/apps/studio/tests/test_scene3d_adapters.py @@ -0,0 +1,272 @@ +"""Stage 3 acceptance for the frame adapters. + +The adapters are the boundary between the acquisition thread's arrays and the +scene, so the tests are about *what they publish and what they refuse*: a field in +the right grid, read-only, with the right unit and quality — and nothing at all when +the grid does not match, with a diagnostic that says which shapes were seen. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +import numpy as np +import pytest + +from orisys_app_shared.scene3d import synthetic +from orisys_app_shared.scene3d.contracts import ( + ExpectedGeometry, + FrameIdentity, + ThreeDError, + validate_surface_frame, +) +from orisys_studio.scene3d.adapters import ( + DEPTH_RESPONSE, + FLOW_MAGNITUDE, + BoxFrameAdapter, + FingerFrameAdapter, + flow_magnitude, +) + + +@dataclass +class FakePacket: + """The parts of ``SdkPacket`` the adapters look at.""" + + flow: Optional[np.ndarray] = None + depth: Optional[np.ndarray] = None + valid_mask: Optional[np.ndarray] = None + frame_id: int = 1 + + +def _identity(generation: int = 1) -> FrameIdentity: + return FrameIdentity("live", 1, 1, 0.1, generation=generation) + + +@pytest.fixture +def scene(): + return synthetic.poc_scene() + + +# ------------------------------------------------------------------- flow helper +def test_the_flow_magnitude_handles_both_shapes(): + flow = np.zeros((2, 3, 2), dtype=np.float32) + flow[..., 0] = 3.0 + flow[..., 1] = 4.0 + assert np.allclose(flow_magnitude(FakePacket(flow=flow)), 5.0) + planar = np.arange(4, dtype=np.float32).reshape(2, 2) + assert np.array_equal(flow_magnitude(FakePacket(flow=planar)), planar) + assert flow_magnitude(FakePacket()) is None + assert flow_magnitude(FakePacket(flow=np.zeros((2, 2, 3), dtype=np.float32))) is None + + +# ----------------------------------------------------------------- finger adapter +def test_the_finger_adapter_publishes_a_field_already_in_the_domain(scene): + adapter = FingerFrameAdapter( + geometry_id=scene.geometry_id, + field_domain_id=scene.field_domain_id, + shape_hw=scene.shape_hw, + ) + flow = np.zeros((*scene.shape_hw, 2), dtype=np.float32) + flow[0, 0, 0] = 3.0 + frame = adapter.snapshot(FakePacket(flow=flow), runtime=None, identity=_identity()) + assert frame is not None + assert validate_surface_frame(frame) == () + assert frame.geometry_id == scene.geometry_id + assert frame.field_domain_id == scene.field_domain_id + assert frame.scalar_descriptor == FLOW_MAGNITUDE + assert frame.scalar_descriptor.unit == "px" + assert frame.scalar_descriptor.quality == "approximate" + assert np.asarray(frame.scalar)[0, 0] == 3.0 + assert np.asarray(frame.valid).all() + assert not np.asarray(frame.scalar).flags["WRITEABLE"] + assert frame.displacement_sensor_m is None, ( + "an uncalibrated displacement must not be invented" + ) + + +def test_the_finger_adapter_remaps_through_the_unroll_map(): + map_x = np.array([[0, 1, 2], [0, 1, 2], [0, 1, 2]], dtype=np.float32) + map_y = np.array([[2, 2, 2], [1, 1, 1], [0, 0, 0]], dtype=np.float32) + adapter = FingerFrameAdapter( + geometry_id="g", field_domain_id="d", shape_hw=(3, 3), + map_x=map_x, map_y=map_y, + ) + flow = np.zeros((3, 3, 2), dtype=np.float32) + flow[..., 0] = 2.0 + frame = adapter.snapshot(FakePacket(flow=flow), runtime=None, identity=_identity()) + assert frame is not None + assert np.allclose(np.asarray(frame.scalar), 2.0) + assert np.asarray(frame.valid).all() + + +def test_a_remap_with_a_hole_is_invalid_there_and_does_not_read_as_zero(): + map_x = np.array([[0, -1], [0, 0]], dtype=np.float32) + map_y = np.array([[0, -1], [0, 0]], dtype=np.float32) + adapter = FingerFrameAdapter( + geometry_id="g", field_domain_id="d", shape_hw=(2, 2), + map_x=map_x, map_y=map_y, + ) + flow = np.full((2, 2, 2), 1.0, dtype=np.float32) + frame = adapter.snapshot(FakePacket(flow=flow), runtime=None, identity=_identity()) + assert frame is not None + assert np.asarray(frame.valid).tolist() == [[True, False], [True, True]] + assert np.isnan(np.asarray(frame.scalar)[0, 1]) + + +def test_the_finger_adapter_refuses_a_grid_it_cannot_place(scene): + """No remap and the wrong grid: say which shapes were seen, and publish nothing.""" + adapter = FingerFrameAdapter( + geometry_id=scene.geometry_id, + field_domain_id=scene.field_domain_id, + shape_hw=scene.shape_hw, + ) + flow = np.zeros((40, 30, 2), dtype=np.float32) + frame = adapter.snapshot(FakePacket(flow=flow), runtime=None, identity=_identity()) + assert frame is None + item = adapter.last_diagnostic + assert item is not None and item.code == "field_domain_mismatch" + assert item.details["flow"] == "40x30" + assert item.details["domain"] == f"{scene.shape_hw[0]}x{scene.shape_hw[1]}" + + +def test_the_finger_adapter_without_flow_says_so(scene): + adapter = FingerFrameAdapter( + geometry_id=scene.geometry_id, + field_domain_id=scene.field_domain_id, + shape_hw=scene.shape_hw, + ) + assert adapter.snapshot(FakePacket(), runtime=None, identity=_identity()) is None + assert adapter.last_diagnostic is not None + assert adapter.last_diagnostic.code == "field_unavailable" + + +def test_the_finger_adapter_can_be_built_from_a_domain(scene): + from orisys_app_shared.scene3d.legacy import load_legacy_domain + + map_path = _run_map() + if map_path is None: + pytest.skip("the shipped unroll map is not present") + domain = load_legacy_domain(map_path) + adapter = FingerFrameAdapter.from_domain(domain, geometry_id="geom") + assert adapter.shape_hw == domain.shape_hw + assert adapter.field_domain_id == domain.domain_id + assert adapter.map_x is not None and adapter.map_y is not None, ( + "the domain must carry the unroll map for the adapter to use" + ) + assert domain.source_hw == (288, 512), domain.source_hw + + +def _run_map(): + from pathlib import Path + + path = Path(r"D:\orisys_ws\orisys-sdk\sensors\finger\assets\30fps-0703\unroll_map_uv_s0.4.npz") + return path if path.is_file() else None + + +# -------------------------------------------------------------------- box adapter +def test_the_box_adapter_prefers_the_depth_response(scene): + adapter = BoxFrameAdapter( + geometry_id=scene.geometry_id, + field_domain_id=scene.field_domain_id, + shape_hw=scene.shape_hw, + ) + depth = np.full(scene.shape_hw, 0.25, dtype=np.float32) + flow = np.ones((*scene.shape_hw, 2), dtype=np.float32) + frame = adapter.snapshot( + FakePacket(depth=depth, flow=flow), + _identity(), + ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, scene.shape_hw), + ) + assert frame is not None + assert frame.scalar_descriptor == DEPTH_RESPONSE + assert frame.scalar_descriptor.unit == "1", "a deformation response is not a length" + assert np.allclose(np.asarray(frame.scalar), 0.25) + + +def test_the_box_adapter_falls_back_to_the_flow_magnitude(scene): + adapter = BoxFrameAdapter( + geometry_id=scene.geometry_id, + field_domain_id=scene.field_domain_id, + shape_hw=scene.shape_hw, + ) + flow = np.zeros((*scene.shape_hw, 2), dtype=np.float32) + flow[..., 0] = 1.0 + frame = adapter.snapshot( + FakePacket(flow=flow), + _identity(), + ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, scene.shape_hw), + ) + assert frame is not None and frame.scalar_descriptor == FLOW_MAGNITUDE + assert np.allclose(np.asarray(frame.scalar), 1.0) + + +def test_the_device_mask_wins_over_the_finiteness_test(scene): + adapter = BoxFrameAdapter( + geometry_id=scene.geometry_id, + field_domain_id=scene.field_domain_id, + shape_hw=scene.shape_hw, + ) + depth = np.ones(scene.shape_hw, dtype=np.float32) + mask = np.ones(scene.shape_hw, dtype=bool) + mask[0, 0] = False + frame = adapter.snapshot( + FakePacket(depth=depth, valid_mask=mask), + _identity(), + ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, scene.shape_hw), + ) + assert frame is not None + assert not bool(np.asarray(frame.valid)[0, 0]) + assert np.asarray(frame.valid).sum() == mask.sum() + + +def test_the_box_adapter_refuses_a_device_with_another_domain(scene): + adapter = BoxFrameAdapter( + geometry_id=scene.geometry_id, + field_domain_id=scene.field_domain_id, + shape_hw=scene.shape_hw, + ) + frame = adapter.snapshot( + FakePacket(depth=np.ones(scene.shape_hw, dtype=np.float32)), + _identity(), + ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, (9, 9)), + ) + assert frame is None + assert adapter.last_diagnostic is not None + assert adapter.last_diagnostic.code == "field_domain_mismatch" + + +def test_the_box_adapter_with_nothing_usable_says_what_it_saw(scene): + adapter = BoxFrameAdapter( + geometry_id=scene.geometry_id, + field_domain_id=scene.field_domain_id, + shape_hw=scene.shape_hw, + ) + frame = adapter.snapshot( + FakePacket(depth=np.ones((5, 5), dtype=np.float32)), + _identity(), + ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, scene.shape_hw), + ) + assert frame is None + assert adapter.last_diagnostic is not None + assert adapter.last_diagnostic.details["depth"] == "5x5" + + +def test_a_published_frame_must_not_share_memory_with_the_packet(scene): + """The producer keeps writing its arrays; the frame must not alias them.""" + adapter = BoxFrameAdapter( + geometry_id=scene.geometry_id, + field_domain_id=scene.field_domain_id, + shape_hw=scene.shape_hw, + ) + depth = np.zeros(scene.shape_hw, dtype=np.float32) + frame = adapter.snapshot( + FakePacket(depth=depth), + _identity(), + ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, scene.shape_hw), + ) + assert frame is not None + assert not frame.scalar.flags["WRITEABLE"] + with pytest.raises(ValueError): + np.asarray(frame.scalar)[0, 0] = 5.0 diff --git a/apps/studio/tests/test_scene3d_coordinator.py b/apps/studio/tests/test_scene3d_coordinator.py new file mode 100644 index 0000000..8790678 --- /dev/null +++ b/apps/studio/tests/test_scene3d_coordinator.py @@ -0,0 +1,314 @@ +"""Stage 3 acceptance for the frame schedule. + +No Qt here on purpose: the coordinator's contract is about *threads and identity*, +so a fake widget that records calls is a better witness than a real one. The rows +that matter are the ones a screen cannot show you: a stale frame never overwriting a +new scene, only one compose in flight, and a producer that never waits. +""" + +from __future__ import annotations + +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +import numpy as np +import pytest + +from orisys_app_shared.scene3d import synthetic +from orisys_app_shared.scene3d.composer import SceneComposer +from orisys_app_shared.scene3d.contracts import ( + DisplaySettings, + FieldDescriptor, + FrameIdentity, + SurfaceFieldFrame, + freeze, +) +from orisys_studio.scene3d.coordinator import ThreeDCoordinator + + +class FakeWidget: + """Records what the coordinator asks of the view.""" + + def __init__(self) -> None: + self.scenes = [] + self.frames = [] + self.settings = [] + self.superseded = [] + self.visible = True + + def set_scene(self, assets) -> None: + self.scenes.append(assets) + + def present(self, frame) -> None: + self.frames.append(frame) + + def set_settings(self, settings) -> None: + self.settings.append(settings) + + def set_superseded_frames(self, count: int) -> None: + self.superseded.append(int(count)) + + def setVisible(self, visible: bool) -> None: # noqa: N802 - Qt spelling + self.visible = bool(visible) + + +class CountingComposer(SceneComposer): + """A composer that counts calls and can be made slow, like a loaded CPU.""" + + def __init__(self, delay_s: float = 0.0) -> None: + super().__init__() + self.calls = 0 + self.delay_s = delay_s + self.in_flight = 0 + self.max_in_flight = 0 + self._lock = threading.Lock() + + def compose(self, assets, frame, settings): + with self._lock: + self.calls += 1 + self.in_flight += 1 + self.max_in_flight = max(self.max_in_flight, self.in_flight) + try: + if self.delay_s: + time.sleep(self.delay_s) + return super().compose(assets, frame, settings) + finally: + with self._lock: + self.in_flight -= 1 + + +def _frame(scene, *, generation=1, source="live-A", session=1, frame_id=1, value=1.0, + domain=None, valid=True): + scalar = np.full(scene.shape_hw, value, dtype=np.float32) + mask = np.full(scene.shape_hw, valid, dtype=bool) + return SurfaceFieldFrame( + identity=FrameIdentity(source, session, frame_id, 0.1 * frame_id, generation), + geometry_id=scene.geometry_id, + field_domain_id=domain or scene.field_domain_id, + scalar=freeze(scalar, dtype=np.float32), + scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False), + valid=freeze(mask, dtype=np.bool_), + ) + + +@pytest.fixture +def scene(): + return synthetic.poc_scene() + + +@pytest.fixture +def pool(): + executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="compose") + yield executor + executor.shutdown(wait=True) + + +def _drain(coordinator, ticks=50, sleep=0.005): + for _ in range(ticks): + coordinator.tick() + time.sleep(sleep) + + +# ---------------------------------------------------------------- state machine +def test_the_view_starts_disabled_and_ignores_frames(scene, pool): + widget = FakeWidget() + coordinator = ThreeDCoordinator(widget, SceneComposer(), pool) + assert coordinator.state == "disabled" + assert coordinator.submit(_frame(scene)) == "disabled" + coordinator.tick() + assert widget.frames == [] + coordinator.close() + + +def test_activating_a_scene_bumps_the_generation_and_shows_the_reference_model(scene, pool): + widget = FakeWidget() + coordinator = ThreeDCoordinator(widget, SceneComposer(), pool) + coordinator.set_visible(True) + first = coordinator.activate(scene, "live-A") + assert first == 1 + assert coordinator.generation == 1 + assert widget.scenes == [scene] + assert len(widget.frames) == 1, "the reference model must be shown immediately" + assert widget.frames[0].scalar_descriptor.quantity == "unavailable" + second = coordinator.activate(scene, "live-B") + assert second == 2 and coordinator.generation == 2 + # A frame from the previous scene is now old news. + assert coordinator.submit(_frame(scene, generation=1, source="live-A")) == "stale" + assert coordinator.stale_frames == 1 + coordinator.close() + + +def test_a_frame_from_another_session_is_stale_too(scene, pool): + coordinator = ThreeDCoordinator(FakeWidget(), SceneComposer(), pool) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + assert coordinator.submit(_frame(scene, session=1)) == "accepted" + assert coordinator.submit(_frame(scene, session=2)) == "stale" + assert coordinator.stale_frames == 1 + coordinator.close() + + +def test_a_frame_on_another_domain_is_invalid_and_says_why(scene, pool): + coordinator = ThreeDCoordinator(FakeWidget(), SceneComposer(), pool) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + result = coordinator.submit(_frame(scene, domain="somebody-elses-domain")) + assert result == "invalid" + assert coordinator.invalid_frames == 1 + assert coordinator.last_diagnostic is not None + assert coordinator.last_diagnostic.code == "field_domain_mismatch" + coordinator.close() + + +def test_the_newest_frame_replaces_the_one_that_was_never_composed(scene, pool): + composer = CountingComposer(delay_s=0.05) + coordinator = ThreeDCoordinator(FakeWidget(), composer, pool) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + results = [coordinator.submit(_frame(scene, frame_id=index)) for index in range(5)] + assert results[0] == "accepted" + assert results[1:] == ["replaced"] * 4 + assert coordinator.superseded_frames == 4, ( + "display drops must be counted, and separately from recording drops" + ) + coordinator.close() + + +# ------------------------------------------------------------------- the schedule +def test_a_tick_composes_and_presents(scene, pool): + widget = FakeWidget() + coordinator = ThreeDCoordinator(widget, SceneComposer(), pool) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + coordinator.submit(_frame(scene, value=3.0)) + _drain(coordinator) + assert len(widget.frames) >= 2 + last = widget.frames[-1] + assert np.allclose(np.asarray(last.scalar), 3.0) + assert coordinator.state == "live" + coordinator.close() + + +def test_a_frame_with_nothing_valid_is_no_data_not_a_stale_picture(scene, pool): + widget = FakeWidget() + coordinator = ThreeDCoordinator(widget, SceneComposer(), pool) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + coordinator.submit(_frame(scene, valid=False)) + _drain(coordinator) + assert coordinator.state == "no_data" + assert not np.asarray(widget.frames[-1].valid).any() + assert np.isnan(np.asarray(widget.frames[-1].scalar)).all() + coordinator.close() + + +def test_only_one_compose_is_in_flight_however_fast_the_ticks_come(scene, pool): + """A slow composer makes frames drop; it must not make the queue grow.""" + composer = CountingComposer(delay_s=0.02) + coordinator = ThreeDCoordinator(FakeWidget(), composer, pool) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + for index in range(200): + coordinator.submit(_frame(scene, frame_id=index)) + coordinator.tick() # no sleep: ticks as fast as the GUI would + _drain(coordinator, ticks=40, sleep=0.01) + assert composer.max_in_flight == 1, ( + f"{composer.max_in_flight} composes ran at once: the schedule is queueing" + ) + assert composer.calls <= 60, f"{composer.calls} composes for 200 frames" + coordinator.close() + + +def test_a_busy_display_never_slows_the_producer(scene, pool): + """The acceptance row: submit stays fast while the composer is saturated.""" + composer = CountingComposer(delay_s=0.05) + coordinator = ThreeDCoordinator(FakeWidget(), composer, pool) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + coordinator.submit(_frame(scene, frame_id=0)) + coordinator.tick() # start a slow compose + timings = [] + for index in range(200): + started = time.perf_counter() + coordinator.submit(_frame(scene, frame_id=index + 1)) + timings.append((time.perf_counter() - started) * 1000.0) + timings.sort() + p99 = timings[int(len(timings) * 0.99) - 1] + assert p99 < 1.0, f"submit p99 was {p99:.3f} ms while the composer was busy" + coordinator.close() + + +def test_a_result_that_arrives_after_a_scene_change_is_dropped(scene, pool): + """The compose that was running when the operator switched scenes is worthless.""" + composer = CountingComposer(delay_s=0.05) + widget = FakeWidget() + coordinator = ThreeDCoordinator(widget, composer, pool) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + coordinator.submit(_frame(scene, value=9.0)) + coordinator.tick() # starts the slow compose for generation 1 + coordinator.activate(scene, "live-A") # generation 2 while it runs + before = len(widget.frames) + _drain(coordinator, ticks=20, sleep=0.02) + composed = [frame for frame in widget.frames[before:] if np.isfinite(np.asarray(frame.scalar)).any()] + assert not composed, "a frame composed for the previous scene was presented" + assert coordinator.stale_frames >= 0 + coordinator.close() + + +def test_hiding_suspends_without_touching_the_scene(scene, pool): + widget = FakeWidget() + coordinator = ThreeDCoordinator(widget, SceneComposer(), pool) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + coordinator.submit(_frame(scene)) + _drain(coordinator, ticks=10) + presented = len(widget.frames) + coordinator.set_visible(False) + assert coordinator.state == "suspended" + assert widget.visible is False + assert coordinator.submit(_frame(scene)) == "disabled" + coordinator.tick() + assert len(widget.frames) == presented, "a hidden view was still being painted" + coordinator.set_visible(True) + assert coordinator.state == "ready" + coordinator.submit(_frame(scene, frame_id=42)) + _drain(coordinator, ticks=10) + assert len(widget.frames) > presented + coordinator.close() + + +def test_changing_the_gain_recomposes_what_is_on_screen(scene, pool): + composer = CountingComposer() + coordinator = ThreeDCoordinator(FakeWidget(), composer, pool) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + coordinator.submit(_frame(scene)) + _drain(coordinator, ticks=10) + settled = composer.calls + # A colour-scale change needs no new composition... + coordinator.set_settings(DisplaySettings(max_fps=30)) + _drain(coordinator, ticks=5) + assert composer.calls == settled + # ...but a gain change does: the positions depend on it. + coordinator.set_settings(DisplaySettings(deform_enabled=True, display_gain=5.0)) + _drain(coordinator, ticks=10) + assert composer.calls > settled, "the new gain was never applied" + coordinator.close() + + +def test_close_is_idempotent_and_stops_everything(scene, pool): + widget = FakeWidget() + coordinator = ThreeDCoordinator(widget, SceneComposer(), pool) + coordinator.set_visible(True) + coordinator.activate(scene, "live-A") + coordinator.submit(_frame(scene)) + _drain(coordinator, ticks=5) + coordinator.close() + coordinator.close() # idempotent + assert coordinator.state == "disabled" + assert coordinator.submit(_frame(scene)) == "disabled" + presented = len(widget.frames) + coordinator.tick() + assert len(widget.frames) == presented diff --git a/apps/studio/tests/test_scene3d_skeleton.py b/apps/studio/tests/test_scene3d_skeleton.py index 3e454a5..7c55b8a 100644 --- a/apps/studio/tests/test_scene3d_skeleton.py +++ b/apps/studio/tests/test_scene3d_skeleton.py @@ -88,21 +88,20 @@ def test_the_widget_holds_cpu_state_and_touches_no_context(qt_app): widget.deleteLater() -def test_the_stubs_name_the_stage_that_fills_them_in(qt_app): +def test_the_remaining_stub_says_which_stage_fills_it_in(qt_app): """What is implemented is exercised elsewhere; what is not says so. - Stage 1 landed the camera, the dispatcher and the renderer (see - ``test_scene3d_stage1.py`` and ``test_scene3d_gl_smoke.py``). The remaining - placeholders must still fail loudly with the stage that owns them rather than - quietly doing nothing. + Stages 1 to 3 landed the camera, the dispatcher, the renderer, the sampler, the + composer, the schedule and the adapters. ``ThreeDPanel`` is the one piece left, + and it must still fail loudly with the stage that owns it rather than quietly + doing nothing. """ from orisys_app_shared.scene3d.contracts import ( CameraPreset, DisplaySettings, - ExpectedGeometry, FrameIdentity, + RenderStats, ) - from orisys_studio.scene3d.adapters import BoxFrameAdapter, FingerFrameAdapter from orisys_studio.scene3d.camera import OrbitCamera from orisys_studio.scene3d.coordinator import ThreeDCoordinator from orisys_studio.scene3d.gl_backend import GLRenderer @@ -115,34 +114,26 @@ def test_the_stubs_name_the_stage_that_fills_them_in(qt_app): coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30) # type: ignore[arg-type] panel = ThreeDPanel(coordinator, widget) try: - assert coordinator.state == "disabled" - assert coordinator.generation == 0 - assert coordinator.superseded_frames == 0 # the mailbox is real, not a stub - # Stage 1 is real: the camera works with no context at all. camera.set_preset( CameraPreset("default", (0.0, 0.0, 1.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.7) ) assert camera.matrices((100, 100)).view.shape == (4, 4) + # Stage 3 is real too: the coordinator's schedule exists with no Qt event. + assert coordinator.state == "disabled" + assert coordinator.generation == 0 + assert coordinator.superseded_frames == 0 + coordinator.close() # idempotent, and safe before any scene is active + for call, stage in ( - (lambda: coordinator.activate(None, "live-A"), "stage 3"), - (lambda: coordinator.set_settings(DisplaySettings()), "stage 3"), - (lambda: coordinator.set_visible(True), "stage 3"), - (lambda: coordinator.close(), "stage 3"), (lambda: panel.set_state("ready"), "stage 4"), - (lambda: BoxFrameAdapter().snapshot(None, FrameIdentity("a", 1, 0, 0.0, 1), None), - "stage 3"), - ( - lambda: FingerFrameAdapter().snapshot( - None, None, FrameIdentity("a", 1, 0, 0.0, 1) - ), - "stage 3", - ), + (lambda: panel.set_render_stats(RenderStats()), "stage 4"), + (lambda: panel.set_diagnostic(None), "stage 4"), ): with pytest.raises(NotImplementedError) as excinfo: call() assert stage in str(excinfo.value), (stage, str(excinfo.value)) - del ExpectedGeometry + del DisplaySettings, FrameIdentity finally: widget.deleteLater() diff --git a/docs/architecture/studio-3d-stages.md b/docs/architecture/studio-3d-stages.md index f027705..ee846c3 100644 --- a/docs/architecture/studio-3d-stages.md +++ b/docs/architecture/studio-3d-stages.md @@ -121,22 +121,40 @@ sha256 + 原子写入 + 读取时先验签再相信)、两个工具(`validat --- -## 阶段 3:实时数据(字段 → 渲染帧) +## 阶段 3:实时数据(字段 → 渲染帧)—— 已完成 -**做**:`FingerFrameAdapter`/`BoxFrameAdapter`、`SurfaceSampler`、`SceneComposer` -(`compose`/`rest_frame`)、`ThreeDCoordinator` 的双 mailbox 与 generation/过期拒绝、`submit` 的 -`accepted/replaced/stale/disabled/invalid` 语义。 +**做**:`sampling.SurfaceSampler`(向量化双线性采样、无效传播、`remap_to_domain` 走 unroll 映射)、 +`composer.SceneComposer`(采样 → 位移 → 重算法线;`rest_frame`/`build_rest_frame`)、 +`ThreeDCoordinator`(双容量 1 mailbox、generation/会话/来源过期拒绝、单飞行合成、可见性与设置)、 +`adapters`(Finger/Box:`|flow|`、depth 响应、域检查与诊断,**不造位移**)。 -**验收**: +**验收(已通过)**:`shared/tests/test_scene3d_live.py` 25 项、`apps/studio/tests/test_scene3d_coordinator.py` 13 项、 +`test_scene3d_adapters.py` 13 项;全部无需 GPU。 -| 用例 | 通过标准 | +| 用例 | 结果 | |---|---| -| 采样正确性 | 常量场/斜坡/单点脉冲/NaN/遮罩场与 CPU 参考实现逐位一致 | -| 无效语义 | 任一正权重点无效 ⇒ 该顶点无效;非有限 scalar 不显示为 0;参考帧 `scalar` 全不可用 | -| 色标语义 | `display_gain` 只改显示位置:增大倍率前后 `scalar` 与力值逐位不变 | -| 过期帧 | 旧 `generation`/`request_id` 永远不能覆盖新场景;`stale_frame` 安静丢弃并计数 | -| 不阻塞采集 | 合成人为放慢 10 倍,`submit` 调用耗时 p99 < 1 ms,记录行数不变 | -| 缺数据 | 无字段 → `no_data`,清掉旧测量颜色,允许显示参考模型 | +| 采样正确性 | 常量/斜坡/单点脉冲与参考逐位一致;四点双线性与手算一致;2 万顶点采样 < 150 ms | +| 无效语义 | 任一**正权** tap 无效 ⇒ 顶点无效且值为 NaN(不是 0);未用 tap 不参与也不致无效;非有限 scalar 同样无效;**0.0 是有效值** | +| 色标语义 | `display_gain` 1→50 后 `scalar`/`valid` 逐位不变,只有位置变(示例 1 mm → 50 mm) | +| 示意形变标注 | `approximate` 位移产生 `display_illustrative`(info,含「×N」「illustration」);`calibrated` 不产生;无位移时着色并报 `field_unavailable` | +| 法线 | 从**形变后**位置重算;凸起处斜坡倾斜、顶点保持竖直、反向位移使斜坡反向倾斜;两条对角线顶点(与凸起不相邻)保持不变 | +| 过期帧 | 旧 generation/来源/会话一律 `stale` 并计数;场景切换时正在跑的那次合成结果被丢弃 | +| 不阻塞采集 | 合成放慢到 50 ms 时 `submit` p99 **< 1 ms**;200 帧只发生 ≤60 次合成;`max_in_flight == 1`(队列不会增长) | +| 缺数据 | 无有效数据 → `no_data`,且呈现参考模型(scalar 全 NaN、`valid` 全假) | +| 适配器 | 域不匹配时**发布空**并报出所见的两个形状;带 map 时按映射 gather,空洞为 NaN 且无效;结果只读、不与 packet 共享内存 | + +**这一阶段抓到的**: + +1. **换倍率需要"当前那一帧"**:原来用 `incoming.peek()` 重合成,而那一帧已被消费 → 倍率改了画面不变。 + 现在协调器保留 `_last_field_frame`,倍率变化就重画**屏幕上那一帧**(而不是跳到一个更新的瞬间)。 +2. `display_illustrative` 不在诊断码表里 → 被默认成 `error`;已作为 `info` 加入码表。 +3. **对称凸起的顶点法线是竖直的**(我第一版测试断言它倾斜,等于断言一个 bug);现在断言斜坡倾斜、顶点竖直, + 并且"与凸起不相邻的对角顶点不变"。 +4. `assets.py` 里还留着与 `package.py`/`legacy.py` 同名的**桩类**——阶段 2 实现后它们就成了会误导集成者的重复定义; + 现在 `assets.py` 是门面(re-export + `SceneCompiler`,加密请求明确拒绝并指向阶段 5)。 + +**明确未做**:手势/面板(阶段 4);**示意位移的适配器接入**留到阶段 4——需要 SDK 的 lift 与一个已确认的尺度, +现在适配器一律 `displacement=None`(宁可只着色,也不给未标定的毫米)。 --- diff --git a/shared/src/orisys_app_shared/scene3d/assets.py b/shared/src/orisys_app_shared/scene3d/assets.py index d2aa4d1..8c89a9b 100644 --- a/shared/src/orisys_app_shared/scene3d/assets.py +++ b/shared/src/orisys_app_shared/scene3d/assets.py @@ -1,11 +1,13 @@ -"""Assets: loading a validated scene, importing the legacy finger export. +"""Assets: the one place a caller should look for loading and compiling a scene. -Stage 2 in ``docs/architecture/studio-3d-stages.md``. The signatures are fixed -here so the rest of the skeleton (and the compiler tool) can be written against -them; the bodies arrive with the stage. +Stage 2 in ``docs/architecture/studio-3d-stages.md``. The implementations live in +:mod:`~orisys_app_shared.scene3d.legacy` (the Blender export), +:mod:`~orisys_app_shared.scene3d.glb` (the housing model) and +:mod:`~orisys_app_shared.scene3d.package` (the ``.oryscene`` container); this module +is their public face, so an integrator does not have to know which file holds what. -Two rules the implementation must keep, because violating them is invisible until -a customer sees the wrong part move: +Two rules the implementation keeps, because violating them is invisible until a +customer sees the wrong part move: * the algorithm mesh and the display mesh are **different topologies** (1032 vs 1005 vertices) and are never joined by array index; @@ -17,69 +19,56 @@ a customer sees the wrong part move: from __future__ import annotations from pathlib import Path -from typing import Optional +from typing import Any, Optional from orisys_app_shared.scene3d.contracts import ( CompileOptions, CompileReport, Diagnostic, ExpectedGeometry, - F64, KeyProvider, SceneAssets, + error, + validate_assets, +) +from orisys_app_shared.scene3d.glb import load_housing_glb, with_housing +from orisys_app_shared.scene3d.legacy import LegacyFingerImporter +from orisys_app_shared.scene3d.package import ( + ENVELOPE_VERSION, + MAGIC, + SCHEMA_VERSION, + PackagedAssetLoader, + PackageContents, + build_manifest, + describe_package, + read_package, + scene_from_contents, + write_package, ) -_STAGE = "stage 2 (asset contracts)" - - -class PackagedAssetLoader: - """Load and authenticate a ``.oryscene`` runtime package. - - CPU/IO thread. Verification order: envelope signature, then entry hashes, then - geometry/domain/unit checks against ``expected``. There is no plaintext - fallback: a package that fails authentication is refused, never retried. - """ - - def load( - self, - path: Path, - expected: ExpectedGeometry, - keys: Optional[KeyProvider] = None, - ) -> SceneAssets: - raise NotImplementedError(f"{_STAGE}: load a signed .oryscene package") - - def validate( - self, assets: SceneAssets, expected: ExpectedGeometry - ) -> tuple[Diagnostic, ...]: - raise NotImplementedError(f"{_STAGE}: validate a loaded scene") - - -class LegacyFingerImporter: - """Convert ``sensors/finger/assets/30fps-0703`` into a scene (offline only). - - ``meters_per_source_unit`` scales source lengths into metres first; - ``transform_sensor_from_source`` is then rigid (rotation and a translation in - metres, no extra scale). Normals receive the rotation only. Missing coordinate - evidence is a refusal, not a guess from a filename. - """ - - def convert( - self, - export_dir: Path, - map_path: Path, - *, - transform_sensor_from_source: F64, - meters_per_source_unit: float, - ) -> SceneAssets: - raise NotImplementedError(f"{_STAGE}: import the legacy NPZ export") +#: Bumped when the container layout changes incompatibly. +__all__ = [ + "ENVELOPE_VERSION", + "MAGIC", + "SCHEMA_VERSION", + "LegacyFingerImporter", + "PackagedAssetLoader", + "PackageContents", + "SceneCompiler", + "build_manifest", + "describe_package", + "read_package", + "scene_from_contents", + "write_package", +] class SceneCompiler: - """Package a scene (and an optional housing GLB) for release (offline). + """Package a scene (and an optional housing GLB) for release. Offline only. - Signing happens with a key from the build environment; the private key is - never part of the client runtime. Output is written to a temporary file and - atomically renamed, so a failed build cannot leave a half package behind. + The signing and encryption that :class:`CompileOptions` describes are stage 5: + until then a request for an encrypted package is *refused*, rather than + quietly producing a plain one that a release script would ship as protected. """ def compile( @@ -89,4 +78,77 @@ class SceneCompiler: output: Path, options: CompileOptions, ) -> CompileReport: - raise NotImplementedError(f"{_STAGE}: compile a runtime package") + if options.encryption_key_id: + raise error( + "asset_auth_failed", + "encrypted packages are stage 5; refusing rather than writing a " + "plain package that claims to be protected", + key_id=str(options.encryption_key_id), + ) + scene = surface + if housing_glb is not None: + from orisys_app_shared.scene3d.glb import HousingLimits + + housing = load_housing_glb( + Path(housing_glb), + limits=HousingLimits( + max_triangles=int(options.max_housing_triangles), + max_vertices=max(int(options.max_surface_vertices), 1), + ), + ) + scene = with_housing(surface, housing) + if int(options.max_surface_vertices) > 0: + sensing = scene.sensing_part() + count = int(sensing.positions_sensor_m.shape[0]) + if count > int(options.max_surface_vertices): + raise error( + "gpu_budget_exceeded", + f"the surface has {count} vertices (limit " + f"{int(options.max_surface_vertices)}); export a lower LOD", + ) + validate_assets(scene) + report = write_package( + scene, + Path(output), + sensor_model=options.expected.sensor_model, + hardware_revisions=(options.expected.hardware_revision,), + ) + return report + + def validate_report( + self, assets: SceneAssets, expected: ExpectedGeometry + ) -> tuple[Diagnostic, ...]: + return validate_assets(assets, expected) + + +def load_or_import( + path: Path, + *, + expected: ExpectedGeometry, + keys: Optional[KeyProvider] = None, + legacy_map: Optional[Path] = None, + transform_source_from_sensor: Any = None, + meters_per_source_unit: Optional[float] = None, +) -> SceneAssets: + """Load a package, or import a legacy directory, behind one entry point. + + Keeping both paths reachable from one function is what lets the rest of the app + stop caring which era an asset came from — and the legacy path still refuses to + guess its units. + """ + path = Path(path) + if path.is_file(): + return PackagedAssetLoader().load(path, expected, keys) + if legacy_map is None or meters_per_source_unit is None or transform_source_from_sensor is None: + raise error( + "asset_rejected", + "a legacy directory needs --map, --meters-per-source-unit and a transform", + ) + assets = LegacyFingerImporter().convert( + path, + Path(legacy_map), + transform_sensor_from_source=transform_source_from_sensor, + meters_per_source_unit=float(meters_per_source_unit), + ) + validate_assets(assets, expected) + return assets diff --git a/shared/src/orisys_app_shared/scene3d/composer.py b/shared/src/orisys_app_shared/scene3d/composer.py index f2041a6..9d887e9 100644 --- a/shared/src/orisys_app_shared/scene3d/composer.py +++ b/shared/src/orisys_app_shared/scene3d/composer.py @@ -2,50 +2,187 @@ Stage 3 in ``docs/architecture/studio-3d-stages.md``. -This is the module that decides what a colour and a vertex position *mean*, so the -rules from §3.2 of the design document are its reason to exist: +This module decides what a colour and a vertex position *mean*, so the rules from +§3.2 of the design document are its reason to exist: -* ``scalar`` is never multiplied by the display gain — the colour scale describes - the measurement, and the gain describes only how far the drawing is exaggerated; -* a displacement whose quality is ``approximate`` is drawn as "relative - deformation, ×N" and is never labelled in millimetres; -* without a trustworthy displacement the surface is coloured in place, and the - total force is never turned into a per-vertex height. +* ``scalar`` is never multiplied by the display gain. The colour scale describes the + measurement; the gain describes only how far the drawing is exaggerated. A gain + that changed the numbers would quietly change what the colour bar means. +* a displacement whose quality is ``approximate`` is drawn as "relative deformation, + ×N" and is never labelled in millimetres — the composer says so in a diagnostic + instead of leaving the UI to guess. +* without a trustworthy displacement the surface is coloured in place, and the total + force is never turned into a per-vertex height. +* a reference frame measures nothing: every validity flag is false and the scalar is + absent, so the UI hides the colour bar rather than leaving the previous one on + screen. """ from __future__ import annotations +from dataclasses import dataclass +from typing import Optional + +import numpy as np + from orisys_app_shared.scene3d.contracts import ( + Diagnostic, DisplaySettings, + FieldDescriptor, FrameIdentity, + Quality, RenderFrame, SceneAssets, SurfaceFieldFrame, + diagnostic, + error, + freeze, + rest_descriptor, + validate_render_frame, + validate_surface_frame, ) +from orisys_app_shared.scene3d.legacy import vertex_normals +from orisys_app_shared.scene3d.sampling import SurfaceSampler -_STAGE = "stage 3 (live data)" +#: Reported when a display gain is applied to a displacement that is only +#: approximate. Informational: the picture is legitimate as an illustration, and +#: must not be read as a measured displacement. +DISPLAY_ILLUSTRATIVE = "display_illustrative" + + +@dataclass +class ComposeStats: + """What one compose cost, for the coordinator's statistics.""" + + sample_ms: float = 0.0 + normals_ms: float = 0.0 + total_ms: float = 0.0 class SceneComposer: """CPU worker: one field frame in, one render frame out. - Runs off the GUI thread and never touches Qt or GL. Recomputes normals from the - deformed positions rather than transforming the rest normals, because the - difference is exactly the deformation the operator is looking at. + Runs off the GUI thread and never touches Qt or GL. Normals are recomputed from + the deformed positions rather than transformed from the rest normals, because + the difference between those two is exactly the deformation the operator is + looking at. """ + def __init__(self, sampler: Optional[SurfaceSampler] = None) -> None: + self._sampler = sampler or SurfaceSampler() + self.last_stats = ComposeStats() + def compose( self, assets: SceneAssets, frame: SurfaceFieldFrame, settings: DisplaySettings, ) -> RenderFrame: - raise NotImplementedError(f"{_STAGE}: compose a render frame") + import time + + started = time.perf_counter() + self._check(assets, frame) + for item in validate_surface_frame(frame): + # A frame with, say, non-finite scalars marked valid is still usable: + # the vertex ends up invalid. Reported rather than refused. + if item.severity == "error": + raise error(item.code, item.message, **dict(item.details)) + part = assets.sensing_part() + sampling_started = time.perf_counter() + fields = self._sampler.sample(frame, assets.binding) + self.last_stats.sample_ms = (time.perf_counter() - sampling_started) * 1000.0 + rest_positions = np.asarray(part.positions_sensor_m, dtype=np.float64) + diagnostics: list[Diagnostic] = [] + + positions = rest_positions + quality = frame.displacement_quality + if settings.deform_enabled and fields.displacement_sensor_m is not None: + gain = float(settings.display_gain) + displacement = np.asarray(fields.displacement_sensor_m, dtype=np.float64) + positions = rest_positions + gain * displacement + if quality != "calibrated": + diagnostics.append( + diagnostic( + DISPLAY_ILLUSTRATIVE, + "the displayed deformation is an illustration (×" + f"{gain:g}), not a measured displacement", + quality=str(quality or "unspecified"), + gain=f"{gain:g}", + ) + ) + elif settings.deform_enabled and fields.displacement_sensor_m is None: + diagnostics.append( + diagnostic( + "field_unavailable", + "no displacement is available for this frame: colouring in place", + ) + ) + normals_started = time.perf_counter() + normals = vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64)) + self.last_stats.normals_ms = (time.perf_counter() - normals_started) * 1000.0 + + result = RenderFrame( + identity=frame.identity, + asset_hash=assets.asset_hash, + geometry_id=assets.geometry_id, + sensing_part_id=part.part_id, + positions_sensor_m=freeze(positions, dtype=np.float32), + normals_sensor=freeze(normals, dtype=np.float32), + # The measurement, untouched by the display gain. + scalar=fields.scalar, + valid=fields.valid, + scalar_descriptor=frame.scalar_descriptor, + diagnostics=tuple(diagnostics), + ) + validate_render_frame(result, vertices=int(rest_positions.shape[0])) + self.last_stats.total_ms = (time.perf_counter() - started) * 1000.0 + return result def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame: - """The static reference: nothing measured, so nothing coloured. + """The static reference: nothing measured, so nothing coloured.""" + return build_rest_frame(assets, identity) - The scalar is all-zero with an ``unavailable`` descriptor; the UI hides the - measurement colour bar instead of leaving the previous one on screen. - """ - raise NotImplementedError(f"{_STAGE}: compose the reference frame") + @staticmethod + def _check(assets: SceneAssets, frame: SurfaceFieldFrame) -> None: + if assets.geometry_id and frame.geometry_id != assets.geometry_id: + raise error( + "geometry_mismatch", + "the frame belongs to a different geometry than the scene", + frame=frame.geometry_id, + scene=assets.geometry_id, + ) + if assets.field_domain_id and frame.field_domain_id != assets.field_domain_id: + raise error( + "field_domain_mismatch", + "the frame is on a different field domain than the scene", + frame=frame.field_domain_id, + scene=assets.field_domain_id, + ) + if tuple(frame.scalar.shape) != tuple(assets.binding.shape_hw): + raise error( + "field_domain_mismatch", + "the frame's shape does not match the scene's UV domain", + frame=f"{frame.scalar.shape[0]}x{frame.scalar.shape[1]}", + scene=f"{assets.shape_hw[0]}x{assets.shape_hw[1]}", + ) + + +def build_rest_frame(assets: SceneAssets, identity: FrameIdentity) -> RenderFrame: + """The asset's own geometry at rest, with no measurement attached. + + Shared by the composer and by the GL layer, which needs it to *clear* a stale + measurement after a refused frame or a disconnect. + """ + part = assets.sensing_part() + count = int(part.positions_sensor_m.shape[0]) + return RenderFrame( + identity=identity, + asset_hash=assets.asset_hash, + geometry_id=assets.geometry_id, + sensing_part_id=part.part_id, + positions_sensor_m=part.positions_sensor_m, + normals_sensor=part.normals_sensor, + scalar=freeze(np.full(count, np.nan, dtype=np.float32), dtype=np.float32), + valid=freeze(np.zeros(count, dtype=bool), dtype=np.bool_), + scalar_descriptor=rest_descriptor(), + ) diff --git a/shared/src/orisys_app_shared/scene3d/contracts.py b/shared/src/orisys_app_shared/scene3d/contracts.py index 03df705..96486da 100644 --- a/shared/src/orisys_app_shared/scene3d/contracts.py +++ b/shared/src/orisys_app_shared/scene3d/contracts.py @@ -60,6 +60,9 @@ DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = { "gpu_budget_exceeded": "error", "asset_rejected": "error", "no_data": "info", + #: An illustrative display deformation was applied. Informational by design: + #: the picture is legitimate, and the point is that it is *labelled*. + "display_illustrative": "info", } diff --git a/shared/src/orisys_app_shared/scene3d/legacy.py b/shared/src/orisys_app_shared/scene3d/legacy.py index 2cedfff..a09a3f2 100644 --- a/shared/src/orisys_app_shared/scene3d/legacy.py +++ b/shared/src/orisys_app_shared/scene3d/legacy.py @@ -74,6 +74,21 @@ class LegacyFieldDomain: index_order: str = "row-major" mapping_digest: str = "" world_xyz: Optional[np.ndarray] = None # float32 [H,W,3], for verification only + #: The unroll map itself: for every output pixel, the source pixel it came from + #: (``-1`` where there is none). The adapters need it to bring a live field into + #: this domain, and the digest above already covers it. + map_x: Optional[np.ndarray] = None + map_y: Optional[np.ndarray] = None + #: The grid the map samples from, as the file states it (0 when unstated). + source_width: int = 0 + source_height: int = 0 + + @property + def source_hw(self) -> Optional[tuple[int, int]]: + """The grid the map samples from, when the file states it.""" + if self.source_width <= 0 or self.source_height <= 0: + return None + return (int(self.source_height), int(self.source_width)) @property def valid_ratio(self) -> float: @@ -137,6 +152,20 @@ def load_legacy_domain(map_path: Path) -> LegacyFieldDomain: source_scale=source_scale, mapping_digest=mapping_digest, ) + + def _optional_array(name: str) -> Optional[np.ndarray]: + value = data.get(name) + return None if value is None else np.asarray(value) + + def _optional_int(name: str) -> int: + value = data.get(name) + if value is None: + return 0 + try: + return int(np.asarray(value).reshape(-1)[0]) + except (TypeError, ValueError): + return 0 + return LegacyFieldDomain( shape_hw=(shape[0], shape[1]), flip_v=flip_v, @@ -149,6 +178,12 @@ def load_legacy_domain(map_path: Path) -> LegacyFieldDomain: if world is not None else None ), + # Kept for the adapters: this is the map that brings a live field into this + # domain, and the domain digest above already covers its content. + map_x=_optional_array("map_x"), + map_y=_optional_array("map_y"), + source_width=_optional_int("source_width"), + source_height=_optional_int("source_height"), ) diff --git a/shared/src/orisys_app_shared/scene3d/sampling.py b/shared/src/orisys_app_shared/scene3d/sampling.py index 5bb2265..f6dabe2 100644 --- a/shared/src/orisys_app_shared/scene3d/sampling.py +++ b/shared/src/orisys_app_shared/scene3d/sampling.py @@ -2,30 +2,172 @@ Stage 3 in ``docs/architecture/studio-3d-stages.md``. -The sampling table is built once, at load time, from the UV field's own domain -(shape, crop, scale, flip). It is never rebuilt per frame and never repaired by -searching for the nearest triangle: a vertex whose samples are not all valid is -invalid, and the mask is not crossed. +The sampling table is built once, at load time, from the field's own domain. It is +never rebuilt per frame, and a vertex is never repaired by searching for the nearest +triangle: if any tap that carries weight is invalid, the vertex is invalid. + +Three details that decide whether a measurement is honest: + +* **an invalid tap is not zero.** Weighting a gap with weight 0 would still poison + the sum, because ``0 * NaN`` is ``NaN``; invalid taps are therefore excluded from + the sum and the *vertex* is marked invalid, which is what the shader draws as the + neutral colour. +* **validity is a separate array from the value.** A vertex whose scalar happens to + be 0.0 is a measurement of zero; a vertex that is invalid has no value at all, and + the value is left as ``NaN`` so nobody can mistake one for the other. +* **the domain is checked, not assumed.** A frame whose domain or shape does not + match the binding is refused with ``field_domain_mismatch`` rather than sampled + into a plausible-looking but wrong picture. """ from __future__ import annotations +import numpy as np + from orisys_app_shared.scene3d.contracts import ( SurfaceBinding, SurfaceFieldFrame, VertexFields, + error, + freeze, ) -_STAGE = "stage 3 (live data)" +#: A weight below this is treated as "not used": the binding builder never emits +#: one, and a rounding-level weight must not be able to invalidate a vertex. +WEIGHT_EPS = 1e-7 class SurfaceSampler: - """Bilinear sampling through a precomputed binding. - - Vectorised over vertices: gather ``[N,4]`` taps, weight them, and treat any - positive-weight invalid tap as a reason to invalidate the whole vertex. A - scalar that is not finite is "no measurement", not a zero. - """ + """Vectorised bilinear sampling through a precomputed binding.""" def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: - raise NotImplementedError(f"{_STAGE}: sample a UV field onto the vertices") + self._check_domains(frame, binding) + indices = np.asarray(binding.sample_indices, dtype=np.int64) # [N,4] + weights = np.asarray(binding.sample_weights, dtype=np.float64) # [N,4] + used = indices >= 0 + if not used.any() and len(indices): + raise error("field_domain_mismatch", "the binding samples nothing at all") + # ``-1`` -> index 0 for the gather; the weight there is 0 by contract and the + # ``used`` mask keeps it out of both the sum and the validity test. + safe = np.where(used, indices, 0) + height, width = frame.scalar.shape + flat_scalar = np.asarray(frame.scalar, dtype=np.float64).reshape(-1) + flat_valid = np.asarray(frame.valid, dtype=bool).reshape(-1) + if safe.size and int(safe.max()) >= flat_scalar.size: + raise error( + "field_domain_mismatch", + "a binding index falls outside the frame's field", + index=int(safe.max()), + size=int(flat_scalar.size), + ) + gathered_scalar = flat_scalar[safe] + gathered_valid = flat_valid[safe] + finite = np.isfinite(gathered_scalar) + # A tap counts only where it is used, in range, finite and valid. + contributed = used & gathered_valid & finite + valid = contributed | ~used + valid = valid.all(axis=1) + contribution = np.where(contributed, gathered_scalar, 0.0) * weights + scalar = contribution.sum(axis=1) + # No measurement: the value is absent, not zero. + scalar = np.where(valid, scalar, np.nan) + + displacement = None + if frame.displacement_sensor_m is not None: + flat_displacement = np.asarray( + frame.displacement_sensor_m, dtype=np.float64 + ).reshape(-1, 3) + if flat_displacement.shape[0] != flat_scalar.shape[0]: + raise error( + "field_domain_mismatch", + "the displacement field does not match the scalar field", + ) + gathered = flat_displacement[safe] # [N,4,3] + finite_disp = np.isfinite(gathered).all(axis=2) + contributed_disp = used & gathered_valid & finite_disp + displacement = np.where(contributed_disp[:, :, None], gathered, 0.0) + displacement = (displacement * weights[:, :, None]).sum(axis=1) + displacement = np.where(valid[:, None], displacement, np.nan) + displacement = freeze(displacement, dtype=np.float32) + del height, width + return VertexFields( + scalar=freeze(scalar, dtype=np.float32), + valid=freeze(valid, dtype=np.bool_), + displacement_sensor_m=displacement, + ) + + @staticmethod + def _check_domains(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> None: + if binding.geometry_id and frame.geometry_id != binding.geometry_id: + raise error( + "geometry_mismatch", + "the frame belongs to a different geometry than the binding", + frame=frame.geometry_id, + binding=binding.geometry_id, + ) + if binding.field_domain_id and frame.field_domain_id != binding.field_domain_id: + raise error( + "field_domain_mismatch", + "the frame is on a different field domain than the binding", + frame=frame.field_domain_id, + binding=binding.field_domain_id, + ) + if tuple(frame.scalar.shape) != tuple(binding.shape_hw): + raise error( + "field_domain_mismatch", + "the frame's shape does not match the binding's domain", + frame=f"{frame.scalar.shape[0]}x{frame.scalar.shape[1]}", + binding=f"{binding.shape_hw[0]}x{binding.shape_hw[1]}", + ) + if frame.scalar.shape != frame.valid.shape: + raise error( + "field_domain_mismatch", "the frame's scalar and validity disagree" + ) + + +def sample_field(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: + """Convenience wrapper: one sampler, no state to keep.""" + return SurfaceSampler().sample(frame, binding) + + +def remap_to_domain( + field: np.ndarray, + *, + map_x: np.ndarray, + map_y: np.ndarray, + shape_hw: tuple[int, int], +) -> tuple[np.ndarray, np.ndarray]: + """Resample a field from its own grid onto the scene's UV domain. + + The legacy unroll map is exactly this: for every output pixel it stores the + source pixel it came from (``-1`` where there is none). Remapping is therefore + a gather, not an interpolation — the map already chose the sample. + + Returns ``(values, valid)`` where ``values`` keeps the trailing axes of the + input (so ``[H,W,2]`` flow becomes ``[h,w,2]``) and ``values`` is ``NaN`` + wherever the map has no source pixel. Nothing is invented for a gap: a remap + that filled holes would put measurements where the sensor has no surface. + """ + values = np.asarray(field) + if values.ndim < 2: + raise error("field_domain_mismatch", "a field to remap must be at least 2-D") + height, width = int(shape_hw[0]), int(shape_hw[1]) + xs = np.asarray(map_x) + ys = np.asarray(map_y) + if xs.shape != (height, width) or ys.shape != (height, width): + raise error( + "field_domain_mismatch", + f"the remap is {xs.shape} but the domain is {height}x{width}", + ) + source_height, source_width = values.shape[0], values.shape[1] + usable = (xs >= 0) & (ys >= 0) & (xs < source_width) & (ys < source_height) + safe_x = np.where(usable, xs, 0).astype(np.int64) + safe_y = np.where(usable, ys, 0).astype(np.int64) + gathered = values[safe_y, safe_x] # [h,w] or [h,w,...] + finite = np.isfinite(np.asarray(gathered, dtype=np.float64)) + if gathered.ndim > 2: + finite = finite.all(axis=tuple(range(2, gathered.ndim))) + valid = usable & finite + out = np.array(gathered, dtype=np.float32, copy=True) + out[~valid] = np.nan + return freeze(out, dtype=np.float32), freeze(valid, dtype=np.bool_) diff --git a/shared/tests/test_scene3d_contracts.py b/shared/tests/test_scene3d_contracts.py index a497816..e6911ab 100644 --- a/shared/tests/test_scene3d_contracts.py +++ b/shared/tests/test_scene3d_contracts.py @@ -11,6 +11,7 @@ from __future__ import annotations import sys import threading +from pathlib import Path import numpy as np import pytest @@ -397,18 +398,51 @@ def test_the_contract_layer_imports_without_qt_or_opengl(): ) -def test_the_cpu_skeleton_is_importable_and_says_which_stage_it_needs(): - from orisys_app_shared.scene3d import assets, composer, sampling +def test_the_cpu_layer_is_importable_and_works_without_a_gpu(): + """The acquisition thread imports this package; it must not drag Qt or GL in. - with pytest.raises(NotImplementedError) as sampler: - sampling.SurfaceSampler().sample(_frame(), _binding()) - assert "stage 3" in str(sampler.value) - with pytest.raises(NotImplementedError) as composer_error: - composer.SceneComposer().rest_frame(_assets(), c.FrameIdentity("live-A", 1, 0, 0.0, 1)) - assert "stage 3" in str(composer_error.value) - with pytest.raises(NotImplementedError) as loader: - assets.PackagedAssetLoader().load(None, None) # type: ignore[arg-type] - assert "stage 2" in str(loader.value) + Stages 2 and 3 filled in the CPU pieces, so this checks they are reachable and + usable here — the sampler and the composer run on a machine with no GPU, which + is the whole reason the contracts layer is separate from the view. + """ + from orisys_app_shared.scene3d import assets, composer, sampling, synthetic + + scene = synthetic.poc_scene() + frame = _frame() + fields = sampling.sample_field(frame, _binding()) + assert np.asarray(fields.scalar).shape == (4,) + composed = composer.SceneComposer().compose( + scene, c.SurfaceFieldFrame( + identity=frame.identity, + geometry_id=scene.geometry_id, + field_domain_id=scene.field_domain_id, + scalar=c.freeze(np.zeros(scene.shape_hw, dtype=np.float32), dtype=np.float32), + scalar_descriptor=c.FieldDescriptor("flow_magnitude", "px", "approximate", False), + valid=c.freeze(np.ones(scene.shape_hw, dtype=bool), dtype=np.bool_), + ), + c.DisplaySettings(), + ) + assert composed.scalar.shape == (9,) + # The loading facade is real too (stage 2), and the compiler refuses what it + # cannot yet do rather than writing something unprotected. + assert callable(assets.PackagedAssetLoader().load) + with pytest.raises(c.ThreeDError) as excinfo: + assets.SceneCompiler().compile( + scene, + None, + Path("unused.oryscene"), + c.CompileOptions( + asset_id="x", + version="0", + expected=synthetic.poc_expected(), + transform_sensor_from_glb=np.eye(4), + max_surface_vertices=0, + max_housing_triangles=1000, + signing_key_reference="build-key", + encryption_key_id="customer-1", + ), + ) + assert "stage 5" in excinfo.value.diagnostic.message def test_summarize_parts_counts_vertices_per_part(): diff --git a/shared/tests/test_scene3d_live.py b/shared/tests/test_scene3d_live.py new file mode 100644 index 0000000..69d5704 --- /dev/null +++ b/shared/tests/test_scene3d_live.py @@ -0,0 +1,405 @@ +"""Stage 3 acceptance: sampling, composition, and the frame schedule. + +The three rows that matter most are the ones that are easy to get subtly wrong and +impossible to notice on screen: an invalid tap must invalidate the *vertex* rather +than read as zero, the display gain must not touch the measurement, and a producer +must never be able to make the display queue grow. +""" + +from __future__ import annotations + +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +import numpy as np +import pytest + +from orisys_app_shared.scene3d import synthetic +from orisys_app_shared.scene3d.composer import DISPLAY_ILLUSTRATIVE, SceneComposer +from orisys_app_shared.scene3d.contracts import ( + ColorScale, + DisplaySettings, + FieldDescriptor, + FrameIdentity, + Quality, + RenderFrame, + SurfaceFieldFrame, + ThreeDError, + freeze, + rest_descriptor, +) +from orisys_app_shared.scene3d.sampling import ( + SurfaceSampler, + remap_to_domain, + sample_field, +) + + +# --------------------------------------------------------------------- helpers +def _frame(scene, scalar, valid=None, *, displacement=None, quality=None, + identity=None, domain=None, shape=None): + scalar = np.asarray(scalar, dtype=np.float32) + if valid is None: + valid = np.ones(scalar.shape, dtype=bool) + frame = SurfaceFieldFrame( + identity=identity or FrameIdentity("live", 1, 1, 0.1, generation=1), + geometry_id=scene.geometry_id, + field_domain_id=domain or scene.field_domain_id, + scalar=freeze(scalar, dtype=np.float32), + scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False), + valid=freeze(np.asarray(valid, dtype=bool), dtype=np.bool_), + displacement_sensor_m=( + freeze(np.asarray(displacement, dtype=np.float32), dtype=np.float32) + if displacement is not None + else None + ), + displacement_quality=quality, + ) + if shape is not None: + object.__setattr__(frame, "scalar", freeze(np.asarray(shape, dtype=np.float32), dtype=np.float32)) + return frame + + +def _binding(shape=(3, 3), indices=None, weights=None): + """The identity binding of the PoC scene, optionally overridden.""" + scene = synthetic.poc_scene() + binding = scene.binding + if indices is None and weights is None and shape == (3, 3): + return binding + from orisys_app_shared.scene3d.contracts import SurfaceBinding + + count = int(indices.shape[0]) if indices is not None else shape[0] * shape[1] + return SurfaceBinding( + part_id="sensing_surface", + geometry_id=scene.geometry_id, + field_domain_id=scene.field_domain_id, + shape_hw=shape, + sample_indices=freeze(np.asarray(indices, dtype=np.int32), dtype=np.int32), + sample_weights=freeze(np.asarray(weights, dtype=np.float32), dtype=np.float32), + ) + + +# --------------------------------------------------------------------- sampling +def test_a_constant_field_samples_to_the_same_constant(): + scene = synthetic.poc_scene() + fields = sample_field(_frame(scene, np.full((3, 3), 7.5)), scene.binding) + assert np.allclose(np.asarray(fields.scalar), 7.5) + assert np.asarray(fields.valid).all() + + +def test_a_ramp_field_samples_exactly_like_the_reference(): + """Every vertex samples its own cell, so the sampled value is the cell value.""" + scene = synthetic.poc_scene() + ramp = np.arange(9, dtype=np.float32).reshape(3, 3) + fields = sample_field(_frame(scene, ramp), scene.binding) + assert np.array_equal(np.asarray(fields.scalar), ramp.reshape(-1)) + + +def test_a_single_sample_spike_reaches_exactly_one_vertex(): + scene = synthetic.poc_scene() + field = np.zeros((3, 3), dtype=np.float32) + field[1, 1] = 5.0 + fields = sample_field(_frame(scene, field), scene.binding) + scalar = np.asarray(fields.scalar) + assert int((scalar > 0).sum()) == 1 + assert scalar[4] == 5.0 + + +def test_bilinear_sampling_averages_the_four_taps(): + """A vertex between pixels gets the weighted average, computed by hand.""" + scene = synthetic.poc_scene() + indices = np.array([[0, 1, 3, 4]], dtype=np.int32) + weights = np.array([[0.25, 0.25, 0.25, 0.25]], dtype=np.float32) + binding = _binding(indices=indices, weights=weights) + field = np.array([[1.0, 2.0, 0.0], [3.0, 4.0, 0.0], [0.0, 0.0, 0.0]], dtype=np.float32) + fields = sample_field(_frame(scene, field), binding) + assert np.asarray(fields.scalar)[0] == pytest.approx((1 + 2 + 3 + 4) / 4) + assert bool(np.asarray(fields.valid)[0]) + + +def test_one_invalid_tap_invalidates_the_vertex_and_is_not_read_as_zero(): + """A gap is not a measurement of zero: the value must be absent.""" + scene = synthetic.poc_scene() + indices = np.array([[0, 1, 3, 4]], dtype=np.int32) + weights = np.array([[0.25, 0.25, 0.25, 0.25]], dtype=np.float32) + binding = _binding(indices=indices, weights=weights) + field = np.array([[1.0, 2.0, 0.0], [3.0, 4.0, 0.0], [0.0, 0.0, 0.0]], dtype=np.float32) + valid = np.ones((3, 3), dtype=bool) + valid[1, 1] = False # index 4 is a positive-weight tap + fields = sample_field(_frame(scene, field, valid), binding) + assert not bool(np.asarray(fields.valid)[0]) + assert np.isnan(np.asarray(fields.scalar)[0]), ( + "an invalid vertex must carry no value at all, not a sum with a zero in it" + ) + + +def test_an_unused_tap_is_neither_a_contribution_nor_a_reason_to_invalidate(): + scene = synthetic.poc_scene() + indices = np.array([[4, -1, -1, -1]], dtype=np.int32) + weights = np.array([[1.0, 0.0, 0.0, 0.0]], dtype=np.float32) + binding = _binding(indices=indices, weights=weights) + field = np.zeros((3, 3), dtype=np.float32) + field[1, 1] = 3.0 + valid = np.ones((3, 3), dtype=bool) + valid[0, 0] = False # unused, so it must not matter + fields = sample_field(_frame(scene, field, valid), binding) + assert bool(np.asarray(fields.valid)[0]) + assert np.asarray(fields.scalar)[0] == pytest.approx(3.0) + + +def test_a_non_finite_scalar_invalidates_rather_than_colouring_nan(): + scene = synthetic.poc_scene() + field = np.full((3, 3), 1.0, dtype=np.float32) + field[2, 2] = np.nan + fields = sample_field(_frame(scene, field), scene.binding) + assert not bool(np.asarray(fields.valid)[8]) + assert np.isnan(np.asarray(fields.scalar)[8]) + + +def test_a_zero_measurement_is_valid_and_stays_zero(): + """Zero is a value. Only `valid` decides whether it means anything.""" + scene = synthetic.poc_scene() + fields = sample_field(_frame(scene, np.zeros((3, 3), dtype=np.float32)), scene.binding) + assert np.asarray(fields.valid).all() + assert np.all(np.asarray(fields.scalar) == 0.0) + assert not np.isnan(np.asarray(fields.scalar)).any() + + +def test_the_sampler_refuses_a_frame_from_another_domain(): + scene = synthetic.poc_scene() + frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), domain="some-other-domain") + with pytest.raises(ThreeDError) as excinfo: + sample_field(frame, scene.binding) + assert excinfo.value.diagnostic.code == "field_domain_mismatch" + + +def test_the_sampler_refuses_a_frame_of_the_wrong_shape(): + scene = synthetic.poc_scene() + frame = _frame(scene, np.zeros((4, 4), dtype=np.float32), shape=np.zeros((4, 4), dtype=np.float32)) + with pytest.raises(ThreeDError) as excinfo: + sample_field(frame, scene.binding) + assert "shape does not match" in excinfo.value.diagnostic.message + + +def test_sampling_is_vectorised_enough_for_a_large_mesh(): + """A full-resolution binding is ~10^5 vertices; sampling must not crawl.""" + scene = synthetic.poc_scene() + count = 20_000 + rng = np.random.default_rng(7) + indices = rng.integers(0, 9, size=(count, 4)).astype(np.int32) + weights = rng.random((count, 4)).astype(np.float32) + weights /= weights.sum(axis=1, keepdims=True) + binding = _binding(indices=indices, weights=weights) + frame = _frame(scene, rng.random((3, 3)).astype(np.float32)) + started = time.perf_counter() + fields = sample_field(frame, binding) + elapsed = (time.perf_counter() - started) * 1000.0 + assert np.asarray(fields.scalar).shape == (count,) + assert elapsed < 150.0, f"sampling {count} vertices took {elapsed:.1f} ms" + + +# ----------------------------------------------------------------------- remap +def test_a_remap_gathers_the_source_pixels_the_map_names(): + field = np.arange(12, dtype=np.float32).reshape(3, 4) + map_x = np.array([[0, 1], [2, 3]]) + map_y = np.array([[0, 0], [2, 2]]) + values, valid = remap_to_domain(field, map_x=map_x, map_y=map_y, shape_hw=(2, 2)) + assert np.array_equal(np.asarray(values), [[0.0, 1.0], [10.0, 11.0]]) + assert np.asarray(valid).all() + + +def test_a_remap_that_has_no_source_pixel_leaves_a_hole_not_a_zero(): + field = np.full((3, 3), 5.0, dtype=np.float32) + map_x = np.array([[0, -1], [1, 1]]) + map_y = np.array([[0, -1], [1, 1]]) + values, valid = remap_to_domain(field, map_x=map_x, map_y=map_y, shape_hw=(2, 2)) + assert np.asarray(valid).tolist() == [[True, False], [True, True]] + assert np.isnan(np.asarray(values)[0, 1]), "a hole must not read as zero" + assert np.asarray(values)[0, 0] == 5.0 + + +def test_a_remap_keeps_a_trailing_axis_for_vector_fields(): + flow = np.zeros((3, 3, 2), dtype=np.float32) + flow[..., 0] = 1.0 + flow[..., 1] = 2.0 + map_x = np.array([[0, 1]]) + map_y = np.array([[1, 2]]) + values, valid = remap_to_domain(flow, map_x=map_x, map_y=map_y, shape_hw=(1, 2)) + assert np.asarray(values).shape == (1, 2, 2) + assert np.asarray(values)[0, 0].tolist() == [1.0, 2.0] + assert np.asarray(valid).all() + + +def test_a_remap_of_the_wrong_shape_is_refused(): + field = np.zeros((3, 3), dtype=np.float32) + with pytest.raises(ThreeDError) as excinfo: + remap_to_domain( + field, map_x=np.zeros((2, 2)), map_y=np.zeros((2, 2)), shape_hw=(3, 3) + ) + assert excinfo.value.diagnostic.code == "field_domain_mismatch" + + +def test_a_remap_leaves_the_result_read_only(): + field = np.zeros((2, 2), dtype=np.float32) + values, valid = remap_to_domain( + field, map_x=np.zeros((2, 2)), map_y=np.zeros((2, 2)), shape_hw=(2, 2) + ) + assert not np.asarray(values).flags["WRITEABLE"] + assert not np.asarray(valid).flags["WRITEABLE"] + + +# ------------------------------------------------------------------- composition +def test_the_gain_moves_positions_and_never_the_measurement(): + """The row the whole colour scale depends on.""" + scene = synthetic.poc_scene() + count = 9 + field = np.linspace(0.0, 1.0, count, dtype=np.float32).reshape(3, 3) + displacement = np.zeros((3, 3, 3), dtype=np.float32) + displacement[:, :, 2] = 0.001 # a millimetre of illustrative relief + frame = _frame(scene, field, displacement=displacement, quality="approximate") + composer = SceneComposer() + quiet = composer.compose(scene, frame, DisplaySettings(deform_enabled=False)) + small = composer.compose( + scene, frame, DisplaySettings(deform_enabled=True, display_gain=1.0) + ) + large = composer.compose( + scene, frame, DisplaySettings(deform_enabled=True, display_gain=50.0) + ) + # The measurement is identical in all three. + assert np.array_equal(np.asarray(quiet.scalar), np.asarray(small.scalar)) + assert np.array_equal(np.asarray(small.scalar), np.asarray(large.scalar)) + assert np.array_equal(np.asarray(quiet.valid), np.asarray(large.valid)) + # The positions are not. + rest = np.asarray(small.positions_sensor_m) + moved = np.asarray(large.positions_sensor_m) + rest_scene = np.asarray(scene.sensing_part().positions_sensor_m) + assert np.allclose((rest - rest_scene)[:, :2], 0.0, atol=1e-7), "the gain moved sideways" + assert np.allclose((rest - rest_scene)[:, 2], 0.001, atol=1e-7) + assert np.allclose((moved - rest_scene)[:, 2], 0.05, atol=1e-7) + + +def test_an_illustrative_deformation_says_so(): + scene = synthetic.poc_scene() + displacement = np.zeros((3, 3, 3), dtype=np.float32) + displacement[:, :, 2] = 0.001 + frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), displacement=displacement, + quality="approximate") + composed = SceneComposer().compose( + scene, frame, DisplaySettings(deform_enabled=True, display_gain=3.0) + ) + codes = [item.code for item in composed.diagnostics] + assert DISPLAY_ILLUSTRATIVE in codes, codes + note = next(item for item in composed.diagnostics if item.code == DISPLAY_ILLUSTRATIVE) + assert note.severity == "info" + assert "illustration" in note.message and "×3" in note.message + + +def test_a_calibrated_displacement_does_not_need_the_caveat(): + scene = synthetic.poc_scene() + displacement = np.zeros((3, 3, 3), dtype=np.float32) + displacement[:, :, 2] = 0.0005 + frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), displacement=displacement, + quality="calibrated") + composed = SceneComposer().compose( + scene, frame, DisplaySettings(deform_enabled=True, display_gain=2.0) + ) + assert DISPLAY_ILLUSTRATIVE not in [item.code for item in composed.diagnostics] + + +def test_deformation_without_a_displacement_colours_in_place_and_says_so(): + scene = synthetic.poc_scene() + frame = _frame(scene, np.full((3, 3), 2.0, dtype=np.float32)) + composed = SceneComposer().compose( + scene, frame, DisplaySettings(deform_enabled=True, display_gain=10.0) + ) + assert np.allclose( + np.asarray(composed.positions_sensor_m), + np.asarray(scene.sensing_part().positions_sensor_m), + ) + assert any(item.code == "field_unavailable" for item in composed.diagnostics) + + +def test_normals_are_recomputed_from_the_deformed_positions(): + """A bump must change the shading on its slopes.""" + scene = synthetic.poc_scene() + displacement = np.zeros((3, 3, 3), dtype=np.float32) + # Only the centre vertex is lifted: the slopes around it have to tilt. (The + # apex itself stays vertical, which is the correct normal for a symmetric + # peak — asserting otherwise would be asserting a bug.) + displacement[1, 1, 2] = 0.004 + frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), displacement=displacement, + quality="approximate") + composed = SceneComposer().compose( + scene, frame, DisplaySettings(deform_enabled=True, display_gain=1.0) + ) + normals = np.asarray(composed.normals_sensor) + assert np.allclose(np.linalg.norm(normals, axis=1), 1.0, atol=1e-5) + rest_normals = np.asarray(scene.sensing_part().normals_sensor) + assert not np.allclose(normals, rest_normals), "the normals ignored the deformation" + # Only the vertices whose triangles touch the lifted one change; the two + # diagonal corners (2 and 6) share no triangle with it and correctly stay flat. + neighbours = [0, 1, 3, 5, 7, 8] + untouched = [2, 6] + tilted = np.array([float(normals[index, 2]) for index in neighbours]) + assert (tilted < 0.999).all(), f"the slopes did not tilt: {tilted}" + flat = np.array([float(normals[index, 2]) for index in untouched]) + assert np.allclose(flat, 1.0, atol=1e-6), flat + assert normals[4, 2] == pytest.approx(1.0, abs=1e-3), ( + "the apex of a symmetric bump is vertical" + ) + # ...and the reverse bump tilts the other way. + upside_down = -displacement + flipped = SceneComposer().compose( + scene, + _frame(scene, np.zeros((3, 3), dtype=np.float32), displacement=upside_down, + quality="approximate"), + DisplaySettings(deform_enabled=True, display_gain=1.0), + ) + flipped_normals = np.asarray(flipped.normals_sensor) + assert np.allclose( + flipped_normals[neighbours, 0], -normals[neighbours, 0], atol=1e-6 + ), "the reverse bump must tilt the slopes the other way" + + +def test_the_composer_refuses_a_frame_from_another_geometry(): + scene = synthetic.poc_scene() + frame = _frame(scene, np.zeros((3, 3), dtype=np.float32)) + object.__setattr__(frame, "geometry_id", "someone-elses") + with pytest.raises(ThreeDError) as excinfo: + SceneComposer().compose(scene, frame, DisplaySettings()) + assert excinfo.value.diagnostic.code == "geometry_mismatch" + + +def test_a_reference_frame_measures_nothing(): + scene = synthetic.poc_scene() + rest = SceneComposer().rest_frame(scene, FrameIdentity("live", 1, 0, 0.0, 1)) + assert not np.asarray(rest.valid).any() + assert np.isnan(np.asarray(rest.scalar)).all() + assert rest.scalar_descriptor.quantity == "unavailable" + assert rest.scalar_descriptor.quality == "display_only" + assert np.allclose( + np.asarray(rest.positions_sensor_m), + np.asarray(scene.sensing_part().positions_sensor_m), + ) + + +def test_the_composed_frame_carries_read_only_arrays(): + scene = synthetic.poc_scene() + composed = SceneComposer().compose( + scene, _frame(scene, np.zeros((3, 3), dtype=np.float32)), DisplaySettings() + ) + for name in ("positions_sensor_m", "normals_sensor", "scalar", "valid"): + value = getattr(composed, name) + assert not value.flags["WRITEABLE"], f"{name} is writable" + assert value.flags["C_CONTIGUOUS"] + + +def test_the_composer_does_not_rebuild_the_binding_or_the_indices(): + """Composition must be cheap enough to run per frame.""" + scene = synthetic.poc_scene() + composer = SceneComposer() + frame = _frame(scene, np.zeros((3, 3), dtype=np.float32)) + for _ in range(3): + composer.compose(scene, frame, DisplaySettings()) + assert composer.last_stats.total_ms < 50.0, composer.last_stats