@@ -93,9 +93,12 @@ export interface ViewerOptions {
9393 clips ?: Record < string , string > ;
9494 /**
9595 * Keep the procedural figure visible while a skinned `characterUrl` loads.
96- * This viewer always shows the procedural figure during load (and on load
97- * failure), so the flag is accepted for API compatibility; the default
98- * behavior already matches `true`.
96+ * Defaults to `true` (the procedural figure poses the scene during the load,
97+ * matching callers that never set this). Set `false` alongside a
98+ * `characterUrl` to hide the procedural meshes until the character resolves —
99+ * so a page load shows the skinned figure or nothing, never a blink of the
100+ * crude procedural figure. On load failure the procedural figure is revealed
101+ * regardless, so the scene never stays blank.
99102 */
100103 showProceduralWhileLoading ?: boolean ;
101104}
@@ -182,10 +185,20 @@ export function createViewer(
182185 enableShadows ( mannequin . root ) ;
183186 scene . add ( mannequin . root ) ;
184187
188+ // When a skinned character is requested and the caller opted out of the
189+ // procedural fallback during load, hide the procedural meshes up front so the
190+ // crude figure never flashes for the character's fetch time on a page load.
191+ // The skeleton still drives animation and grounding; only the meshes hide
192+ // (same as the post-load swap). Revealed again if the character fails to load.
193+ const deferProceduralMeshes =
194+ Boolean ( opts . characterUrl ) && opts . showProceduralWhileLoading === false ;
195+ if ( deferProceduralMeshes ) setMeshVisibility ( mannequin . root , false ) ;
196+
185197 // Skinned character layer (optional). While loading (and on failure) the
186- // procedural figure stays; once ready, the driver skeleton is rebuilt with
187- // the character's proportions, its meshes are hidden (they keep feeding the
188- // bounding-box grounding), and the character mirrors it every frame.
198+ // procedural figure stays — unless deferred above; once ready, the driver
199+ // skeleton is rebuilt with the character's proportions, its meshes are hidden
200+ // (they keep feeding the bounding-box grounding), and the character mirrors it
201+ // every frame.
189202 let character : Character | null = null ;
190203
191204 // Mocap-clip layer (optional, character-only). When the loaded document
@@ -637,21 +650,33 @@ export function createViewer(
637650 // L4.2 aliveness: contralateral arm swing during locomotion (free arms only).
638651 swingArms ( mannequin , authoredShoulders , gripSidesOf ( info . grips ) ) ;
639652 // L4.1 aliveness: relax idle hands into a natural curl (grips still wrap).
640- relaxHands ( mannequin , gripSidesOf ( info . grips ) , authoredFingers ) ;
653+ relaxHands (
654+ mannequin ,
655+ gripSidesOf ( info . grips ) ,
656+ authoredFingers ,
657+ floorHandSidesOf ( info . reaches , info . pins ) ,
658+ ) ;
641659 // L4.3 aliveness: turn the head toward the active contact (bar / floor reach).
642660 applyLookAt ( info ) ;
643- // Safety net: nothing above ever intentionally pushes part of the body
644- // below the floor, so clamp the root up whenever the lowest point dips
645- // below y=0, a no-op whenever the pose is legitimately grounded or
646- // elevated (bbox min already ≥ 0). This also catches phases with
647- // neither ground-lock nor a pin (the root stays frozen at the base
648- // pose's grounded height while FK animates freely on top of it, e.g. a
649- // prone "superman" lift), and pinned phases where a fixed-height anchor
650- // (a low chair seat) combined with static leg FK can otherwise let the
651- // feet sink through the floor as the arms fold (e.g. a chair dip).
661+ // Safety net: reconcile the fully-solved pose with the floor.
662+ //
663+ // A ground-locked phase asserts its effectors (feet, and for a plank the
664+ // forearms) are PLANTED, so its lowest mesh point must sit exactly on the
665+ // floor — clamp the root BOTH ways. This is essential because
666+ // levelPlantedFeet() rotates the ankle flat AFTER ground-lock dropped the
667+ // body, which lifts the sole a couple centimetres; an up-only clamp could
668+ // never recover it and the whole figure floated (squat, deadlift,
669+ // good-morning, forward-fold, plank, …).
670+ //
671+ // A phase with NO ground-lock may be intentionally airborne (a prone
672+ // "superman" lift, a jump), so it stays up-only: never yank a lifted body
673+ // down, only rescue parts that dip below y=0. Pinned phases with a
674+ // fixed-height anchor (a low chair seat) also rely on this up-only rescue
675+ // as the legs fold.
652676 mannequin . root . updateMatrixWorld ( true ) ;
653677 const box = new THREE . Box3 ( ) . setFromObject ( mannequin . root ) ;
654- if ( box . min . y < 0 ) {
678+ const planted = info . groundLock . length > 0 ;
679+ if ( box . min . y < 0 || ( planted && box . min . y > 0 ) ) {
655680 mannequin . root . position . y -= box . min . y ;
656681 mannequin . root . updateMatrixWorld ( true ) ;
657682 }
@@ -742,7 +767,12 @@ export function createViewer(
742767 authoredShoulders = new Set ( timeline . bonesUsed . filter ( ( id ) => id . startsWith ( "shoulder_" ) ) ) ;
743768 authoredHead = timeline . bonesUsed . some ( ( id ) => id === "head" || id === "neck" ) ;
744769 swingArms ( mannequin , authoredShoulders , gripSidesOf ( ir . phases [ 0 ] ?. grips ?? [ ] ) ) ;
745- relaxHands ( mannequin , gripSidesOf ( ir . phases [ 0 ] ?. grips ?? [ ] ) , authoredFingers ) ;
770+ relaxHands (
771+ mannequin ,
772+ gripSidesOf ( ir . phases [ 0 ] ?. grips ?? [ ] ) ,
773+ authoredFingers ,
774+ floorHandSidesOf ( ir . phases [ 0 ] ?. reaches ?? [ ] , ir . phases [ 0 ] ?. pins ?? [ ] ) ,
775+ ) ;
746776 applyLookAt ( { grips : ir . phases [ 0 ] ?. grips ?? [ ] , reaches : ir . phases [ 0 ] ?. reaches ?? [ ] } ) ;
747777 captureGroundTargets ( ) ;
748778 baseRootPos . copy ( mannequin . root . position ) ;
@@ -828,9 +858,7 @@ export function createViewer(
828858 scene . remove ( mannequin . root ) ;
829859 disposeTree ( mannequin . root ) ;
830860 mannequin = buildMannequin ( undefined , char . proportions ) ;
831- mannequin . root . traverse ( ( obj ) => {
832- if ( ( obj as THREE . Mesh ) . isMesh ) obj . visible = false ;
833- } ) ;
861+ setMeshVisibility ( mannequin . root , false ) ;
834862 scene . add ( mannequin . root ) ;
835863 scene . add ( char . group ) ;
836864 character = char ;
@@ -842,14 +870,27 @@ export function createViewer(
842870 else char . sync ( mannequin ) ;
843871 } )
844872 . catch ( ( ) => {
845- // Keep the procedural figure. Deliberately silent: an offline embed
846- // or a blocked CDN should degrade, not error.
873+ // Character failed (offline embed, blocked/404 CDN): reveal the
874+ // procedural figure we may have hidden, so the scene degrades to the
875+ // working fallback instead of staying blank. Deliberately silent.
876+ if ( deferProceduralMeshes ) setMeshVisibility ( mannequin . root , true ) ;
847877 } ) ;
848878 }
849879
850880 return api ;
851881}
852882
883+ /**
884+ * Show or hide a figure's meshes. The skeleton keeps driving animation and
885+ * bounding-box grounding regardless, so hiding only the meshes lets a hidden
886+ * procedural figure still pose the scene while its character stand-in loads.
887+ */
888+ function setMeshVisibility ( root : THREE . Object3D , visible : boolean ) : void {
889+ root . traverse ( ( obj ) => {
890+ if ( ( obj as THREE . Mesh ) . isMesh ) obj . visible = visible ;
891+ } ) ;
892+ }
893+
853894/** True for a finger bone id (thumb/index/middle/ring/pinky_left|right). */
854895function isFingerId ( id : string ) : boolean {
855896 return / ^ ( t h u m b | i n d e x | m i d d l e | r i n g | p i n k y ) _ / . test ( id ) ;
@@ -865,6 +906,25 @@ function gripSidesOf(grips: readonly { effector: string }[]): Set<"left" | "righ
865906 return sides ;
866907}
867908
909+ /**
910+ * Hand sides pressed onto the floor this phase (a `reach`/`pin: hands floor`).
911+ * Their fingers rest flat instead of taking the idle inward hook, so a plank or
912+ * push-up hand lies on the ground rather than clawing into it.
913+ */
914+ function floorHandSidesOf (
915+ reaches : readonly { effector : string ; target : string } [ ] ,
916+ pins : readonly { effector : string ; anchor : string } [ ] ,
917+ ) : Set < "left" | "right" > {
918+ const sides = new Set < "left" | "right" > ( ) ;
919+ const add = ( effector : string ) : void => {
920+ if ( effector . endsWith ( "_left" ) || effector === "hands" ) sides . add ( "left" ) ;
921+ if ( effector . endsWith ( "_right" ) || effector === "hands" ) sides . add ( "right" ) ;
922+ } ;
923+ for ( const r of reaches ) if ( r . target === "floor" ) add ( r . effector ) ;
924+ for ( const p of pins ) if ( p . anchor === "floor" ) add ( p . effector ) ;
925+ return sides ;
926+ }
927+
868928function enableShadows ( root : THREE . Object3D ) : void {
869929 root . traverse ( ( obj ) => {
870930 if ( ( obj as THREE . Mesh ) . isMesh ) {
@@ -902,5 +962,5 @@ export {
902962 type ClipSource ,
903963} from "./clips.js" ;
904964export { depenetrate } from "./depenetrate.js" ;
905- export { alignFloorPalms } from "./contacts.js" ;
965+ export { alignFloorPalms , levelPlantedFeet } from "./contacts.js" ;
906966export type { PhaseSegment } from "./timeline.js" ;
0 commit comments