Compare commits
3
Commits
3dc9cc431c
...
4bcd778e16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bcd778e16 | ||
|
|
a7801a942a | ||
|
|
ce1811b5f9 |
@@ -55,6 +55,9 @@ spikes/**/*.blend
|
||||
spikes/**/*.exr
|
||||
spikes/**/*.hdr
|
||||
spikes/**/*.zip
|
||||
# Extracted vendor payload (reproducible from docs/assets/downloads/ archives
|
||||
# via the spike's own extraction script) — includes Unity .asset/.sk metadata.
|
||||
spikes/**/raw/
|
||||
|
||||
# Planet generator intermediates
|
||||
*.tmp.npz
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKE0EW2617K9VE4QHSYSR9HW', '06FKCPBF41RWK9PW9M22Q7961C', '2026-07-06 10:38:33.017', '2026-07-06 10:38:33.017', NULL, '290574b958bce69afc8a81d7487f8fd5', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
@@ -589,3 +589,160 @@ T-1085 STATUS: WS1 (chirality assignment) DONE, WS2 (wiki continuity) DONE, WS3
|
||||
CLOSED 2026-07-05 (PR #172 merged to main @ 895bb83ba). Core deliverable shipped: WS1 chirality assignment, WS2 wiki continuity, WS3 biosphere_class plumbing (stamped regen-db), WS4 grassland biome-awareness + vocab doc. Both blocking review findings (C1 over-vegetation, H1 biosphere gate) fixed + N1 (Wetland gate) folded in; both reviewers approved. Remaining work split into follow-ups: T-1086 (propagate Linnaeus to bodies.proper_name) and T-1087 (Forest/Wetland biome palettes + introduced-Earth register + §8 material). Burnelli food-economy consumer stays a Phase-2 stakeholder follow-on (noted, not filed — cross-phase).', NULL, '2026-07-05 17:46:33', '2026-07-05 17:46:33.511', '2026-07-05 17:46:33.511', NULL, '3cae4321f60cc166089db5d3c656ef6f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSK19FGZAC3H25VRAV4JRFW', 'status', 'review', 'done', NULL, '2026-07-05 17:46:36', '2026-07-05 17:46:36.690', '2026-07-05 17:46:36.690', NULL, '75db59fc46ee90ad66e9c61eadf627ac', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91R8RV38BJXGB2V55FP3B8', 'status', 'review', 'done', NULL, '2026-07-05 17:46:36', '2026-07-05 17:46:36.698', '2026-07-05 17:46:36.698', NULL, '09edd98de1f765ba1a946b57f54533d9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCER95EK266H8RWPW8XT1F0', 'description', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.
|
||||
|
||||
Visual reference (user, 2026-07-06): Xenonauts 2 nails the target tiles/occlusion/character perspective — readable tile grid, low-angle tactical camera with angle options (matches D-148 30deg low-angle + 45deg rotation steps and the spikes/3dpipeline angle presets), and crucially its wall/roof occlusion treatment: camera-facing walls cut down/ghosted so interiors stay readable, floors peel per z-level. One deliberate difference: our camera locks to the player character (D-015/T-116) — no free map panning. Implication for the locomotion sandbox: greybox walls should prototype camera-facing cutaway occlusion early, since wall readability interacts with the locked low-angle camera.', NULL, '2026-07-06 07:10:07', '2026-07-06 07:10:07.799', '2026-07-06 07:10:07.799', NULL, '81c7180578510141745792125328712b', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCER95EK266H8RWPW8XT1F0', 'description', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.
|
||||
|
||||
Visual reference (user, 2026-07-06): Xenonauts 2 nails the target tiles/occlusion/character perspective — readable tile grid, low-angle tactical camera with angle options (matches D-148 30deg low-angle + 45deg rotation steps and the spikes/3dpipeline angle presets), and crucially its wall/roof occlusion treatment: camera-facing walls cut down/ghosted so interiors stay readable, floors peel per z-level. One deliberate difference: our camera locks to the player character (D-015/T-116) — no free map panning. Implication for the locomotion sandbox: greybox walls should prototype camera-facing cutaway occlusion early, since wall readability interacts with the locked low-angle camera.', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.
|
||||
|
||||
Visual reference (user, 2026-07-06): Xenonauts 2 nails the target tiles/occlusion/character perspective — readable tile grid, low-angle tactical camera with angle options (matches D-148 30deg low-angle + 45deg rotation steps and the spikes/3dpipeline angle presets), and crucially its wall/roof occlusion treatment: camera-facing walls cut down/ghosted so interiors stay readable, floors peel per z-level. One deliberate difference: our camera locks to the player character (D-015/T-116) — no free map panning. Implication for the locomotion sandbox: greybox walls should prototype camera-facing cutaway occlusion early, since wall readability interacts with the locked low-angle camera.
|
||||
|
||||
Scoping call (user + lead agreement, 2026-07-06): wall cutaway is CLIENT-SIDE presentation, decoupled from fog-of-perception. Rationale: two occlusion systems with different subjects — perception/fog governs what the CHARACTER knows (server-enforced info boundary; client only receives observer-filtered geometry), cutaway governs what the PLAYER''S CAMERA sees (pure render mode on wall data the client already holds). They compose: cutaway cuts render height; fog overlay still gates what is visible behind. Walls remain first-class client data objects (future destruction/explosion interactions), cutaway is a render mode on them like tint/LOD. Nuance parked for Phase 5 proper: destroyed-while-unobserved walls should render in remembered state (fog-memory applied to geometry). If this holds through implementation, mint a D-record (perception domain) when the sidequest lands.', NULL, '2026-07-06 07:14:14', '2026-07-06 07:14:14.484', '2026-07-06 07:14:14.484', NULL, '307b68ca9c7b56692d32a4c7bb8fe50e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'description', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.', NULL, '2026-07-06 07:39:21', '2026-07-06 07:39:21.760', '2026-07-06 07:39:21.760', NULL, '4a859683cd2d1dfe39692711789a2bb7', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'description', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.', NULL, '2026-07-06 07:42:06', '2026-07-06 07:42:06.637', '2026-07-06 07:42:06.637', NULL, '3698f7e9fa21e699b9bf86b31d76f70d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'description', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.', NULL, '2026-07-06 07:42:24', '2026-07-06 07:42:24.025', '2026-07-06 07:42:24.025', NULL, 'a9974a42f7017c372b5f35eadac019cd', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'description', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.', NULL, '2026-07-06 07:43:12', '2026-07-06 07:43:12.193', '2026-07-06 07:43:12.193', NULL, '22061e346caf924cbf501beb836e802e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'description', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.
|
||||
|
||||
Scope pin (user, 2026-07-06, final word on style): for simplicity, THIS ticket ships ONE style only — cyberpunk/modern. The pluralistic distribution model above remains the long-term truth (keep the manifest style tag as the seam), but the v1 basic set is single-style; other styles are catalogue growth, not scope.', NULL, '2026-07-06 07:43:39', '2026-07-06 07:43:39.155', '2026-07-06 07:43:39.155', NULL, '11a05e0b8646bf168f5866245b3dbdb5', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'description', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.
|
||||
|
||||
Scope pin (user, 2026-07-06, final word on style): for simplicity, THIS ticket ships ONE style only — cyberpunk/modern. The pluralistic distribution model above remains the long-term truth (keep the manifest style tag as the seam), but the v1 basic set is single-style; other styles are catalogue growth, not scope.', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.
|
||||
|
||||
Scope pin (user, 2026-07-06, final word on style): for simplicity, THIS ticket ships ONE style only — cyberpunk/modern. The pluralistic distribution model above remains the long-term truth (keep the manifest style tag as the seam), but the v1 basic set is single-style; other styles are catalogue growth, not scope.
|
||||
|
||||
Route challenge (user, 2026-07-06): not sold on staying Quaternius-primary — ''Quaternius is limited and will move the wall at too slow a pace; I''d rather pick something that has what we need now.'' Fresh store verification (2026-07-06): Sidekick packs are one-time USD199.99, perpetual, engine-agnostic license (Godot legal, unsupported); NO raw FBX — extraction from unitypackage (community-proven: unitypackage_godot, third-party Sidekick Creator Godot plugin incl. body blend shapes); ''Sold out'' labels are a broken-theme artifact per Shopify JSON (needs one browser cart check); SyntyPass rejected (assets stop being developable after cancel). Relevant packs: Starter (FREE, 57+91 parts), Modern Civilians (165 parts = v1 basic set territory), Sci-Fi Civilians (103 parts, cyberpunk accents), Modern Police (uniforms/officials). DECISION NOW GATED ON THE FREE SPIKE: user downloads free Starter Pack -> Bridge-C spike (extract garment -> Robust Weight Transfer onto our rig -> 11-body batch -> animation QA in T-1088 sandbox). Pass -> buy Modern Civilians, Synty = primary garment supply (+UAL1/UAL2 Source ~USD30 for clips — no Synty substitute exists for Godot animations; Fantasy Outfits Source drops to low priority under the single-style pin). Fail -> hand-author route stands. Stay-vs-pivot governance record HELD until spike evidence lands.', NULL, '2026-07-06 08:14:11', '2026-07-06 08:14:11.037', '2026-07-06 08:14:11.037', NULL, '8d8631fb2a7bd74e481972a7728af370', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'description', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.
|
||||
|
||||
Scope pin (user, 2026-07-06, final word on style): for simplicity, THIS ticket ships ONE style only — cyberpunk/modern. The pluralistic distribution model above remains the long-term truth (keep the manifest style tag as the seam), but the v1 basic set is single-style; other styles are catalogue growth, not scope.
|
||||
|
||||
Route challenge (user, 2026-07-06): not sold on staying Quaternius-primary — ''Quaternius is limited and will move the wall at too slow a pace; I''d rather pick something that has what we need now.'' Fresh store verification (2026-07-06): Sidekick packs are one-time USD199.99, perpetual, engine-agnostic license (Godot legal, unsupported); NO raw FBX — extraction from unitypackage (community-proven: unitypackage_godot, third-party Sidekick Creator Godot plugin incl. body blend shapes); ''Sold out'' labels are a broken-theme artifact per Shopify JSON (needs one browser cart check); SyntyPass rejected (assets stop being developable after cancel). Relevant packs: Starter (FREE, 57+91 parts), Modern Civilians (165 parts = v1 basic set territory), Sci-Fi Civilians (103 parts, cyberpunk accents), Modern Police (uniforms/officials). DECISION NOW GATED ON THE FREE SPIKE: user downloads free Starter Pack -> Bridge-C spike (extract garment -> Robust Weight Transfer onto our rig -> 11-body batch -> animation QA in T-1088 sandbox). Pass -> buy Modern Civilians, Synty = primary garment supply (+UAL1/UAL2 Source ~USD30 for clips — no Synty substitute exists for Godot animations; Fantasy Outfits Source drops to low priority under the single-style pin). Fail -> hand-author route stands. Stay-vs-pivot governance record HELD until spike evidence lands.', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.
|
||||
|
||||
Scope pin (user, 2026-07-06, final word on style): for simplicity, THIS ticket ships ONE style only — cyberpunk/modern. The pluralistic distribution model above remains the long-term truth (keep the manifest style tag as the seam), but the v1 basic set is single-style; other styles are catalogue growth, not scope.
|
||||
|
||||
Route challenge (user, 2026-07-06): not sold on staying Quaternius-primary — ''Quaternius is limited and will move the wall at too slow a pace; I''d rather pick something that has what we need now.'' Fresh store verification (2026-07-06): Sidekick packs are one-time USD199.99, perpetual, engine-agnostic license (Godot legal, unsupported); NO raw FBX — extraction from unitypackage (community-proven: unitypackage_godot, third-party Sidekick Creator Godot plugin incl. body blend shapes); ''Sold out'' labels are a broken-theme artifact per Shopify JSON (needs one browser cart check); SyntyPass rejected (assets stop being developable after cancel). Relevant packs: Starter (FREE, 57+91 parts), Modern Civilians (165 parts = v1 basic set territory), Sci-Fi Civilians (103 parts, cyberpunk accents), Modern Police (uniforms/officials). DECISION NOW GATED ON THE FREE SPIKE: user downloads free Starter Pack -> Bridge-C spike (extract garment -> Robust Weight Transfer onto our rig -> 11-body batch -> animation QA in T-1088 sandbox). Pass -> buy Modern Civilians, Synty = primary garment supply (+UAL1/UAL2 Source ~USD30 for clips — no Synty substitute exists for Godot animations; Fantasy Outfits Source drops to low priority under the single-style pin). Fail -> hand-author route stands. Stay-vs-pivot governance record HELD until spike evidence lands.
|
||||
|
||||
Scope guard (user concern, 2026-07-06): Synty intake must NOT displace the create-stuff-yourself pipeline — the user''s goal includes in-house garment creation capability, not just swapping vendor catalogs. Resolution: three-route portfolio sharing one engine — (a) Synty intake = variety bootstrap only; (b) derivation scripting (cuts/merges/retexture, agent-automatable) = create-yourself lite, turns any donor topology into original designs; (c) TRUE AUTHORING on owned UBC Source body .blends = the pipeline proper. GUARANTEE: the colorable uniform and black suit (no donor exists in any pack) are built via route (c) as the authoring pipeline''s end-to-end proof — non-negotiable deliverable of this ticket, so the pipeline exists even if Synty covers everything else. Spike status: extraction done inline by lead (158 FBX, sci-fi civilian theme confirmed); DECISIVE finding — Sidekick rig is UE-Mannequin-named, 52/65 bone-name overlap with our rig, sample garment binds to 12 vgroups of which 11 match ours exactly (only head->Head case rename) => transplant likely rename-and-rebind, far cheaper than the weight-transfer the pivot eval assumed; body blend shapes travel in the FBX. Transplant phase parked pending API recovery / token-window reset.', NULL, '2026-07-06 08:59:05', '2026-07-06 08:59:05.485', '2026-07-06 08:59:05.485', NULL, '18eabf5907931a8feb925b614cc4478c', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'description', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.
|
||||
|
||||
Scope pin (user, 2026-07-06, final word on style): for simplicity, THIS ticket ships ONE style only — cyberpunk/modern. The pluralistic distribution model above remains the long-term truth (keep the manifest style tag as the seam), but the v1 basic set is single-style; other styles are catalogue growth, not scope.
|
||||
|
||||
Route challenge (user, 2026-07-06): not sold on staying Quaternius-primary — ''Quaternius is limited and will move the wall at too slow a pace; I''d rather pick something that has what we need now.'' Fresh store verification (2026-07-06): Sidekick packs are one-time USD199.99, perpetual, engine-agnostic license (Godot legal, unsupported); NO raw FBX — extraction from unitypackage (community-proven: unitypackage_godot, third-party Sidekick Creator Godot plugin incl. body blend shapes); ''Sold out'' labels are a broken-theme artifact per Shopify JSON (needs one browser cart check); SyntyPass rejected (assets stop being developable after cancel). Relevant packs: Starter (FREE, 57+91 parts), Modern Civilians (165 parts = v1 basic set territory), Sci-Fi Civilians (103 parts, cyberpunk accents), Modern Police (uniforms/officials). DECISION NOW GATED ON THE FREE SPIKE: user downloads free Starter Pack -> Bridge-C spike (extract garment -> Robust Weight Transfer onto our rig -> 11-body batch -> animation QA in T-1088 sandbox). Pass -> buy Modern Civilians, Synty = primary garment supply (+UAL1/UAL2 Source ~USD30 for clips — no Synty substitute exists for Godot animations; Fantasy Outfits Source drops to low priority under the single-style pin). Fail -> hand-author route stands. Stay-vs-pivot governance record HELD until spike evidence lands.
|
||||
|
||||
Scope guard (user concern, 2026-07-06): Synty intake must NOT displace the create-stuff-yourself pipeline — the user''s goal includes in-house garment creation capability, not just swapping vendor catalogs. Resolution: three-route portfolio sharing one engine — (a) Synty intake = variety bootstrap only; (b) derivation scripting (cuts/merges/retexture, agent-automatable) = create-yourself lite, turns any donor topology into original designs; (c) TRUE AUTHORING on owned UBC Source body .blends = the pipeline proper. GUARANTEE: the colorable uniform and black suit (no donor exists in any pack) are built via route (c) as the authoring pipeline''s end-to-end proof — non-negotiable deliverable of this ticket, so the pipeline exists even if Synty covers everything else. Spike status: extraction done inline by lead (158 FBX, sci-fi civilian theme confirmed); DECISIVE finding — Sidekick rig is UE-Mannequin-named, 52/65 bone-name overlap with our rig, sample garment binds to 12 vgroups of which 11 match ours exactly (only head->Head case rename) => transplant likely rename-and-rebind, far cheaper than the weight-transfer the pivot eval assumed; body blend shapes travel in the FBX. Transplant phase parked pending API recovery / token-window reset.', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.
|
||||
|
||||
Scope pin (user, 2026-07-06, final word on style): for simplicity, THIS ticket ships ONE style only — cyberpunk/modern. The pluralistic distribution model above remains the long-term truth (keep the manifest style tag as the seam), but the v1 basic set is single-style; other styles are catalogue growth, not scope.
|
||||
|
||||
Route challenge (user, 2026-07-06): not sold on staying Quaternius-primary — ''Quaternius is limited and will move the wall at too slow a pace; I''d rather pick something that has what we need now.'' Fresh store verification (2026-07-06): Sidekick packs are one-time USD199.99, perpetual, engine-agnostic license (Godot legal, unsupported); NO raw FBX — extraction from unitypackage (community-proven: unitypackage_godot, third-party Sidekick Creator Godot plugin incl. body blend shapes); ''Sold out'' labels are a broken-theme artifact per Shopify JSON (needs one browser cart check); SyntyPass rejected (assets stop being developable after cancel). Relevant packs: Starter (FREE, 57+91 parts), Modern Civilians (165 parts = v1 basic set territory), Sci-Fi Civilians (103 parts, cyberpunk accents), Modern Police (uniforms/officials). DECISION NOW GATED ON THE FREE SPIKE: user downloads free Starter Pack -> Bridge-C spike (extract garment -> Robust Weight Transfer onto our rig -> 11-body batch -> animation QA in T-1088 sandbox). Pass -> buy Modern Civilians, Synty = primary garment supply (+UAL1/UAL2 Source ~USD30 for clips — no Synty substitute exists for Godot animations; Fantasy Outfits Source drops to low priority under the single-style pin). Fail -> hand-author route stands. Stay-vs-pivot governance record HELD until spike evidence lands.
|
||||
|
||||
Scope guard (user concern, 2026-07-06): Synty intake must NOT displace the create-stuff-yourself pipeline — the user''s goal includes in-house garment creation capability, not just swapping vendor catalogs. Resolution: three-route portfolio sharing one engine — (a) Synty intake = variety bootstrap only; (b) derivation scripting (cuts/merges/retexture, agent-automatable) = create-yourself lite, turns any donor topology into original designs; (c) TRUE AUTHORING on owned UBC Source body .blends = the pipeline proper. GUARANTEE: the colorable uniform and black suit (no donor exists in any pack) are built via route (c) as the authoring pipeline''s end-to-end proof — non-negotiable deliverable of this ticket, so the pipeline exists even if Synty covers everything else. Spike status: extraction done inline by lead (158 FBX, sci-fi civilian theme confirmed); DECISIVE finding — Sidekick rig is UE-Mannequin-named, 52/65 bone-name overlap with our rig, sample garment binds to 12 vgroups of which 11 match ours exactly (only head->Head case rename) => transplant likely rename-and-rebind, far cheaper than the weight-transfer the pivot eval assumed; body blend shapes travel in the FBX. Transplant phase parked pending API recovery / token-window reset.
|
||||
|
||||
ROUTE DECISION (user, 2026-07-06, final): Synty intake REJECTED despite the spike''s technical PASS — (1) USD199.99/pack is too expensive; (2) Sidekick modularity is baked body-segment swap (clothes fused to body chunks), incompatible with our layered clothing-over-body system (11 bodies, skin tones, slot swapping); (3) user dislikes the Synty style. The spike still paid: transplant/batch scripts are source-agnostic and become the G1-family production pipeline; T-1090 (5 bodies broken bare) discovered; the Synty question is closed with evidence, not parked. OPERATIVE ROUTE: (b) derive from owned Fantasy parts + (c) hand-author — with a new preferred authoring technique: OFFSET-SHELL FROM OUR OWN BODY MESHES (extract body segment surface, offset outward into garment shell, cut neckline/hem/sleeves; weights inherited by construction, style fully ours, USD0). Suit + uniform remain the full hand-author proof. Purchases still open (separate concerns): UAL1/UAL2 Source ~USD30 for animation clips; Fantasy Outfits Source USD20 low-priority convenience.', NULL, '2026-07-06 11:09:40', '2026-07-06 11:09:40.351', '2026-07-06 11:09:40.351', NULL, '0121b9bd7a9fb6c32a4974973a9614d6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCER95EK266H8RWPW8XT1F0', 'description', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.
|
||||
|
||||
Visual reference (user, 2026-07-06): Xenonauts 2 nails the target tiles/occlusion/character perspective — readable tile grid, low-angle tactical camera with angle options (matches D-148 30deg low-angle + 45deg rotation steps and the spikes/3dpipeline angle presets), and crucially its wall/roof occlusion treatment: camera-facing walls cut down/ghosted so interiors stay readable, floors peel per z-level. One deliberate difference: our camera locks to the player character (D-015/T-116) — no free map panning. Implication for the locomotion sandbox: greybox walls should prototype camera-facing cutaway occlusion early, since wall readability interacts with the locked low-angle camera.
|
||||
|
||||
Scoping call (user + lead agreement, 2026-07-06): wall cutaway is CLIENT-SIDE presentation, decoupled from fog-of-perception. Rationale: two occlusion systems with different subjects — perception/fog governs what the CHARACTER knows (server-enforced info boundary; client only receives observer-filtered geometry), cutaway governs what the PLAYER''S CAMERA sees (pure render mode on wall data the client already holds). They compose: cutaway cuts render height; fog overlay still gates what is visible behind. Walls remain first-class client data objects (future destruction/explosion interactions), cutaway is a render mode on them like tint/LOD. Nuance parked for Phase 5 proper: destroyed-while-unobserved walls should render in remembered state (fog-memory applied to geometry). If this holds through implementation, mint a D-record (perception domain) when the sidequest lands.', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.
|
||||
|
||||
Visual reference (user, 2026-07-06): Xenonauts 2 nails the target tiles/occlusion/character perspective — readable tile grid, low-angle tactical camera with angle options (matches D-148 30deg low-angle + 45deg rotation steps and the spikes/3dpipeline angle presets), and crucially its wall/roof occlusion treatment: camera-facing walls cut down/ghosted so interiors stay readable, floors peel per z-level. One deliberate difference: our camera locks to the player character (D-015/T-116) — no free map panning. Implication for the locomotion sandbox: greybox walls should prototype camera-facing cutaway occlusion early, since wall readability interacts with the locked low-angle camera.
|
||||
|
||||
Scoping call (user + lead agreement, 2026-07-06): wall cutaway is CLIENT-SIDE presentation, decoupled from fog-of-perception. Rationale: two occlusion systems with different subjects — perception/fog governs what the CHARACTER knows (server-enforced info boundary; client only receives observer-filtered geometry), cutaway governs what the PLAYER''S CAMERA sees (pure render mode on wall data the client already holds). They compose: cutaway cuts render height; fog overlay still gates what is visible behind. Walls remain first-class client data objects (future destruction/explosion interactions), cutaway is a render mode on them like tint/LOD. Nuance parked for Phase 5 proper: destroyed-while-unobserved walls should render in remembered state (fog-memory applied to geometry). If this holds through implementation, mint a D-record (perception domain) when the sidequest lands.
|
||||
|
||||
Animation supply resolved (2026-07-06): user purchased UAL1[Source+Pro] (120 clips, identical lists) + UAL2[Source] (134 clips), unzipped in docs/assets/downloads/. Gains: Turn90_L/R + Turn180_L/R (turn-in-place), Sprint_Enter/Exit + Crouch_Enter/Exit (stance transitions), full 8-direction Walk/Jog/Crouch sets incl. diagonals, Counter_Idle + talking/phone/sitting idles (life-sim verbs), plus _RM root-motion variants (unused — in-place is correct for server-authoritative interpolation). Still absent everywhere: walk start/stop + stairs — the only remaining custom clips. Integration is NOT a drop-in swap: upstream renamed clips (Walk_Loop -> Walk_Fwd_Loop etc.), so wire via the design''s recorded plan — explicit library names + lib/Clip addressing + gait-table update. NOTE for facing design: the 8-dir walk sets unlock Q-084''s parked walk-vs-aim split (direction-matched clips relative to facing) — revisit during the S9 tuning session.', NULL, '2026-07-06 11:17:25', '2026-07-06 11:17:25.652', '2026-07-06 11:17:25.652', NULL, '5c337857bf33c1a3bbb2bd33152e955e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FJ91R8RV38BJXGB2V55FP3B8', 'T-1085', '2026-07-02 20:30:57.224', '2026-07-02 20:30:57.224', NULL, 'a654aaf34a004714211b088d1ba2ca28', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK6RTBN796N8WZ0GW8BREG0R', 'T-1086', '2026-07-05 17:46:13.289', '2026-07-05 17:46:13.289', NULL, '43700db551218d61b2f78202d07ed71d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FK6RVP1HS8RHN29XQPEXT5QM', 'T-1087', '2026-07-05 17:46:24.140', '2026-07-05 17:46:24.140', NULL, '74a9cd82e7d4d0baa22ff63537ddda14', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCER95EK266H8RWPW8XT1F0', 'T-1088', '2026-07-06 07:01:06.475', '2026-07-06 07:01:06.475', NULL, 'aafbe831174124e9ca1da29255f022f8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'T-1089', '2026-07-06 07:34:18.657', '2026-07-06 07:34:18.657', NULL, '7283f875610cbd67d7dd0d6bc949d3cf', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKE0EW2617K9VE4QHSYSR9HW', 'T-1090', '2026-07-06 10:38:16.595', '2026-07-06 10:38:16.595', NULL, '38d331606c3d8de414dec0c2c971ad71', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKE0J4B8GQ17NFFV8N7D45BW', 'T-1091', '2026-07-06 10:38:43.290', '2026-07-06 10:38:43.290', NULL, 'da0796307c06acd6609ac466d6ea48c3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
|
||||
@@ -556,3 +556,102 @@ Workstream 3 DONE + committed (cc7c5ada0): biosphere_class plumbing via the stam
|
||||
T-1085 STATUS: WS1 (chirality assignment) DONE, WS2 (wiki continuity) DONE, WS3 (biosphere plumbing) DONE, WS4 (vocab→palette) DONE-partial (grassland biome-awareness + vocab doc; Forest/register/§8-material continuation noted). REMAINING: (a) rename completion — propagate Linnaeus to bodies.proper_name in the DB (still Cadwal; proper_name is atlas-CLI-owned, wipe+reimport would drop non-proposal cols → needs a targeted approach); (b) WS4 continuation; (c) Burnelli food-economy consumer (separate stakeholder follow-on).
|
||||
|
||||
CLOSED 2026-07-05 (PR #172 merged to main @ 895bb83ba). Core deliverable shipped: WS1 chirality assignment, WS2 wiki continuity, WS3 biosphere_class plumbing (stamped regen-db), WS4 grassland biome-awareness + vocab doc. Both blocking review findings (C1 over-vegetation, H1 biosphere gate) fixed + N1 (Wetland gate) folded in; both reviewers approved. Remaining work split into follow-ups: T-1086 (propagate Linnaeus to bodies.proper_name) and T-1087 (Forest/Wetland biome palettes + introduced-Earth register + §8 material). Burnelli food-economy consumer stays a Phase-2 stakeholder follow-on (noted, not filed — cross-phase).', 'done', 'medium', 'miri', NULL, 'D-247', '2026-07-02 20:30:57.222', '2026-07-05 17:46:36.698', NULL, '8b1b13167ab0c9f9ef270ce13532778b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCER95EK266H8RWPW8XT1F0', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', '3D character locomotion sidequest — composited character walks the Gauntlet on the live server', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:01:06.475', '2026-07-06 07:01:06.475', NULL, 'b5f4cd0311d6327be4b8cc8226c1c8de', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCER95EK266H8RWPW8XT1F0', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', '3D character locomotion sidequest — composited character walks the Gauntlet on the live server', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.
|
||||
|
||||
Visual reference (user, 2026-07-06): Xenonauts 2 nails the target tiles/occlusion/character perspective — readable tile grid, low-angle tactical camera with angle options (matches D-148 30deg low-angle + 45deg rotation steps and the spikes/3dpipeline angle presets), and crucially its wall/roof occlusion treatment: camera-facing walls cut down/ghosted so interiors stay readable, floors peel per z-level. One deliberate difference: our camera locks to the player character (D-015/T-116) — no free map panning. Implication for the locomotion sandbox: greybox walls should prototype camera-facing cutaway occlusion early, since wall readability interacts with the locked low-angle camera.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:01:06.475', '2026-07-06 07:10:07.799', NULL, '97cd308b84225e379e7399416932ee0f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCER95EK266H8RWPW8XT1F0', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', '3D character locomotion sidequest — composited character walks the Gauntlet on the live server', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.
|
||||
|
||||
Visual reference (user, 2026-07-06): Xenonauts 2 nails the target tiles/occlusion/character perspective — readable tile grid, low-angle tactical camera with angle options (matches D-148 30deg low-angle + 45deg rotation steps and the spikes/3dpipeline angle presets), and crucially its wall/roof occlusion treatment: camera-facing walls cut down/ghosted so interiors stay readable, floors peel per z-level. One deliberate difference: our camera locks to the player character (D-015/T-116) — no free map panning. Implication for the locomotion sandbox: greybox walls should prototype camera-facing cutaway occlusion early, since wall readability interacts with the locked low-angle camera.
|
||||
|
||||
Scoping call (user + lead agreement, 2026-07-06): wall cutaway is CLIENT-SIDE presentation, decoupled from fog-of-perception. Rationale: two occlusion systems with different subjects — perception/fog governs what the CHARACTER knows (server-enforced info boundary; client only receives observer-filtered geometry), cutaway governs what the PLAYER''S CAMERA sees (pure render mode on wall data the client already holds). They compose: cutaway cuts render height; fog overlay still gates what is visible behind. Walls remain first-class client data objects (future destruction/explosion interactions), cutaway is a render mode on them like tint/LOD. Nuance parked for Phase 5 proper: destroyed-while-unobserved walls should render in remembered state (fog-memory applied to geometry). If this holds through implementation, mint a D-record (perception domain) when the sidequest lands.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:01:06.475', '2026-07-06 07:14:14.484', NULL, 'ebbbd5ff1ed11d0ebf37303782e6e1d5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Clothing catalogue engine + basic set — recolorable, logo-capable everyday garments', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:34:18.656', '2026-07-06 07:34:18.656', NULL, 'f95f8855e41f59a6c23ab7adb4dd7b31', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Clothing catalogue engine + basic set — recolorable, logo-capable everyday garments', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:34:18.656', '2026-07-06 07:39:21.760', NULL, '5db4379bf42ee42226f1fb7e212e6348', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Clothing catalogue engine + basic set — recolorable, logo-capable everyday garments', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:34:18.656', '2026-07-06 07:42:06.637', NULL, '6400115dc840a8e65f55b1932a1449c1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Clothing catalogue engine + basic set — recolorable, logo-capable everyday garments', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:34:18.656', '2026-07-06 07:42:24.025', NULL, '990ed335ad3d3f59255ba4f8f8ef1c07', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Clothing catalogue engine + basic set — recolorable, logo-capable everyday garments', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:34:18.656', '2026-07-06 07:43:12.193', NULL, '4fb0958003b1bd349d7e58e07e53690e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Clothing catalogue engine + basic set — recolorable, logo-capable everyday garments', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.
|
||||
|
||||
Scope pin (user, 2026-07-06, final word on style): for simplicity, THIS ticket ships ONE style only — cyberpunk/modern. The pluralistic distribution model above remains the long-term truth (keep the manifest style tag as the seam), but the v1 basic set is single-style; other styles are catalogue growth, not scope.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:34:18.656', '2026-07-06 07:43:39.155', NULL, '35d33cc74fde16fe96e0e4000d26056b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Clothing catalogue engine + basic set — recolorable, logo-capable everyday garments', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.
|
||||
|
||||
Scope pin (user, 2026-07-06, final word on style): for simplicity, THIS ticket ships ONE style only — cyberpunk/modern. The pluralistic distribution model above remains the long-term truth (keep the manifest style tag as the seam), but the v1 basic set is single-style; other styles are catalogue growth, not scope.
|
||||
|
||||
Route challenge (user, 2026-07-06): not sold on staying Quaternius-primary — ''Quaternius is limited and will move the wall at too slow a pace; I''d rather pick something that has what we need now.'' Fresh store verification (2026-07-06): Sidekick packs are one-time USD199.99, perpetual, engine-agnostic license (Godot legal, unsupported); NO raw FBX — extraction from unitypackage (community-proven: unitypackage_godot, third-party Sidekick Creator Godot plugin incl. body blend shapes); ''Sold out'' labels are a broken-theme artifact per Shopify JSON (needs one browser cart check); SyntyPass rejected (assets stop being developable after cancel). Relevant packs: Starter (FREE, 57+91 parts), Modern Civilians (165 parts = v1 basic set territory), Sci-Fi Civilians (103 parts, cyberpunk accents), Modern Police (uniforms/officials). DECISION NOW GATED ON THE FREE SPIKE: user downloads free Starter Pack -> Bridge-C spike (extract garment -> Robust Weight Transfer onto our rig -> 11-body batch -> animation QA in T-1088 sandbox). Pass -> buy Modern Civilians, Synty = primary garment supply (+UAL1/UAL2 Source ~USD30 for clips — no Synty substitute exists for Godot animations; Fantasy Outfits Source drops to low priority under the single-style pin). Fail -> hand-author route stands. Stay-vs-pivot governance record HELD until spike evidence lands.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:34:18.656', '2026-07-06 08:14:11.037', NULL, 'ecbabb557fe69f476b66e9b4ec50f93e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Clothing catalogue engine + basic set — recolorable, logo-capable everyday garments', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.
|
||||
|
||||
Scope pin (user, 2026-07-06, final word on style): for simplicity, THIS ticket ships ONE style only — cyberpunk/modern. The pluralistic distribution model above remains the long-term truth (keep the manifest style tag as the seam), but the v1 basic set is single-style; other styles are catalogue growth, not scope.
|
||||
|
||||
Route challenge (user, 2026-07-06): not sold on staying Quaternius-primary — ''Quaternius is limited and will move the wall at too slow a pace; I''d rather pick something that has what we need now.'' Fresh store verification (2026-07-06): Sidekick packs are one-time USD199.99, perpetual, engine-agnostic license (Godot legal, unsupported); NO raw FBX — extraction from unitypackage (community-proven: unitypackage_godot, third-party Sidekick Creator Godot plugin incl. body blend shapes); ''Sold out'' labels are a broken-theme artifact per Shopify JSON (needs one browser cart check); SyntyPass rejected (assets stop being developable after cancel). Relevant packs: Starter (FREE, 57+91 parts), Modern Civilians (165 parts = v1 basic set territory), Sci-Fi Civilians (103 parts, cyberpunk accents), Modern Police (uniforms/officials). DECISION NOW GATED ON THE FREE SPIKE: user downloads free Starter Pack -> Bridge-C spike (extract garment -> Robust Weight Transfer onto our rig -> 11-body batch -> animation QA in T-1088 sandbox). Pass -> buy Modern Civilians, Synty = primary garment supply (+UAL1/UAL2 Source ~USD30 for clips — no Synty substitute exists for Godot animations; Fantasy Outfits Source drops to low priority under the single-style pin). Fail -> hand-author route stands. Stay-vs-pivot governance record HELD until spike evidence lands.
|
||||
|
||||
Scope guard (user concern, 2026-07-06): Synty intake must NOT displace the create-stuff-yourself pipeline — the user''s goal includes in-house garment creation capability, not just swapping vendor catalogs. Resolution: three-route portfolio sharing one engine — (a) Synty intake = variety bootstrap only; (b) derivation scripting (cuts/merges/retexture, agent-automatable) = create-yourself lite, turns any donor topology into original designs; (c) TRUE AUTHORING on owned UBC Source body .blends = the pipeline proper. GUARANTEE: the colorable uniform and black suit (no donor exists in any pack) are built via route (c) as the authoring pipeline''s end-to-end proof — non-negotiable deliverable of this ticket, so the pipeline exists even if Synty covers everything else. Spike status: extraction done inline by lead (158 FBX, sci-fi civilian theme confirmed); DECISIVE finding — Sidekick rig is UE-Mannequin-named, 52/65 bone-name overlap with our rig, sample garment binds to 12 vgroups of which 11 match ours exactly (only head->Head case rename) => transplant likely rename-and-rebind, far cheaper than the weight-transfer the pivot eval assumed; body blend shapes travel in the FBX. Transplant phase parked pending API recovery / token-window reset.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:34:18.656', '2026-07-06 08:59:05.485', NULL, 'cb85c826871146df718f5cc94ee89c7d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKE0EW2617K9VE4QHSYSR9HW', 'bug', '06FB0TNSRXX29H5X0EB0F0GBW4', '5 of 11 body types misrender bare in CharacterVisual (thin_f/m, heavy_f/m, child)', 'Found by the T-1089 Synty intake spike (2026-07-06), body-only controls with NO garment attached: thin_f/m, heavy_f/m, child misrender in CharacterVisual (exploded/spider arms, detached head/hair; child 303mm max warp, 87 collapsed edges; f-bodies bust-through). These are the newer high-poly bodies (no seg_hips, different segmentation) — exactly the 5 with no production peasant-clothing variants. Upstream of ANY clothing route; blocks T-1089''s 11-of-11 acceptance criterion and resolves part of Q-060. Evidence: spikes/synty-intake/out/bodies_fit_log.json + out/qa_bodies/ (44 screenshots) + out/bodies_recon.json.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 10:38:16.593', '2026-07-06 10:38:16.593', NULL, '3c7e5d110be1dde94aa27379cf3664e4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKE0J4B8GQ17NFFV8N7D45BW', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Silence MCH-*/ik_* skin-bind warnings from production head/hair asset (Rigify control bones)', 'Godot logs ~600 lines of ''Skin bind #N ... Skeleton3D has no bone'' per CharacterVisual load — traced by the T-1089 spike to a production head/hair asset carrying Rigify control-bone names (MCH-*/ik_*), not to any garment. Strip the stale bind targets from the asset (Blender re-export) or filter at import. Pure log hygiene / asset cleanliness.', 'backlog', 'low', NULL, 'client', NULL, '2026-07-06 10:38:43.290', '2026-07-06 10:38:43.290', NULL, 'd01e49191a4e9bcd5ca83866c2d65468', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCPBF41RWK9PW9M22Q7961C', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Clothing catalogue engine + basic set — recolorable, logo-capable everyday garments', 'Fable-5-window sidequest workstream 2 (user directive 2026-07-06, extends T-1088 scope family): build the clothing ENGINE and a basic set now, park variety for later. Target bar: RimWorld''s original apparel breadth — varied enough to not feel limiting. Goal experience: being able to pick and SHOP for clothes — colorize + slap logos on casualwear. Basic set: t-shirt (logo-capable), hoodie (logo-capable), button-down shirt, shorts, jeans, formal pants, jacket, colorable uniform, black suit (moved up so officials are includable). Skipped by explicit call: capes, dresses/skirts (parked — difficulty tier). Engine deliverables: (1) multi-region tint masks — requires Quaternius Source tier files (USD5 Patreon month, one-time download, CC0 retained — user purchase action); (2) logo decal region on garment chest UV — flat 2D artwork per D-244, diegetic hook into economy generated brands (wiki/economics/corporations/generated_brands.toml) for brand-logo shirts; (3) manifest + CharacterVisualDescriptor category coverage (slots/tints already generic); (4) batch-fit each authored garment to all 11 body types via the validated Sprint-28 Blender Surface Deform pipeline (tooling/blender_*.py). Authoring routes per garment TBD by feasibility pass: derive from owned Fantasy pack meshes (62 parts, e.g. tunic->tshirt sleeve cut, pants->shorts hem cut) vs hand-author low-poly on the 65-bone rig (Garment Tool). Related: T-1088 locomotion sandbox doubles as the garment-under-animation QA rig (cycle outfits through Walk/Sprint/Crouch_Fwd).
|
||||
|
||||
Correction (2026-07-06): Source tier is PARTIALLY owned already — docs/assets/downloads/ (gitignored vendor archives, present in both checkouts) holds Universal Base Characters[Source] (630MB: full .blend files for all 6 base bodies incl. FullBody + OnlyHead variants, hairstyle .blends origin-at-0 + rigged-to-head-bone, texture masters, Godot engine project) plus UBC[Standard], UAL 1+2[Standard], Modular Character Outfits Fantasy[Standard]. NOT owned: Fantasy Outfits[Source] — the garment recolor masks + named vertex groups. Consequence: authoring NEW garments is fully unblocked (author against source body .blends, create our own tint masks/vertex groups — no purchase needed); only pack-DERIVED garments'' clean masks would want one more USD5 Patreon month, decision deferred until the feasibility route table says how much derivation we actually use.
|
||||
|
||||
Art direction (user, 2026-07-06): garments read CYBERPUNK/MODERN — near-future everyday civilian + corporate wear — NOT fantasy/medieval. The owned Fantasy pack is at most a topology donor (low-poly base shapes like pants/shirts are genre-neutral; genre lives in silhouette details, textures, trims, and accessories) — no medieval trim/textures may survive into shipped garments. Weight the authoring routes accordingly: texture identity matters as much as mesh. Canon hook: the wiki already ships a garment brand — thrds (wiki/star-systems/GJ-475/index.md, Braemar fiber cooperative, corridor fashion markets) — so branded clothing connects to Phase-1 lore, not only generated_brands.toml. This emphasis also feeds the Quaternius-pivot evaluation: commercial alternatives skew sci-fi/urban while Quaternius clothing is fantasy-only.
|
||||
|
||||
Nuance (user, 2026-07-06): the cyberpunk/modern direction is the DEFAULT, not a prohibition — fashion in the Reach is cyclical, so fantasy/medieval-flavored garments may appear here and there as deliberate fashion statements (subculture pieces, luxury/revival trims, statement outerwear). Supersedes the previous note''s hard ''no medieval trim may survive'' line. Practical consequence: a small number of Fantasy-pack pieces may ship near-as-is where they read as intentional fashion; the basic set itself stays modern.
|
||||
|
||||
Art direction CONSOLIDATED (user, 2026-07-06 — supersedes the two notes above): the Reach has no single clothing style — ALL styles coexist, with modern/cyberpunk probably the most prevalent. Style is a prevalence DISTRIBUTION, not a genre rule, and may eventually vary by culture/settlement (fits the simulation DNA; fashion-as-cultural-data is a later phase concern, not this ticket). Consequences now: (1) the engine is style-agnostic by construction (slots/tints/logos carry no genre); (2) the basic set targets the prevalent center — modern casual + corporate — because it dresses the most NPCs and the shopping experience; (3) garments get a lightweight style tag in the manifest (e.g. style: modern|formal|fantasy|utility) as the cheap seam so per-culture distributions can be authored later without retrofitting; (4) Fantasy-pack pieces are legitimate catalogue members under their own tag, prevalence-weighted low.
|
||||
|
||||
Scope pin (user, 2026-07-06, final word on style): for simplicity, THIS ticket ships ONE style only — cyberpunk/modern. The pluralistic distribution model above remains the long-term truth (keep the manifest style tag as the seam), but the v1 basic set is single-style; other styles are catalogue growth, not scope.
|
||||
|
||||
Route challenge (user, 2026-07-06): not sold on staying Quaternius-primary — ''Quaternius is limited and will move the wall at too slow a pace; I''d rather pick something that has what we need now.'' Fresh store verification (2026-07-06): Sidekick packs are one-time USD199.99, perpetual, engine-agnostic license (Godot legal, unsupported); NO raw FBX — extraction from unitypackage (community-proven: unitypackage_godot, third-party Sidekick Creator Godot plugin incl. body blend shapes); ''Sold out'' labels are a broken-theme artifact per Shopify JSON (needs one browser cart check); SyntyPass rejected (assets stop being developable after cancel). Relevant packs: Starter (FREE, 57+91 parts), Modern Civilians (165 parts = v1 basic set territory), Sci-Fi Civilians (103 parts, cyberpunk accents), Modern Police (uniforms/officials). DECISION NOW GATED ON THE FREE SPIKE: user downloads free Starter Pack -> Bridge-C spike (extract garment -> Robust Weight Transfer onto our rig -> 11-body batch -> animation QA in T-1088 sandbox). Pass -> buy Modern Civilians, Synty = primary garment supply (+UAL1/UAL2 Source ~USD30 for clips — no Synty substitute exists for Godot animations; Fantasy Outfits Source drops to low priority under the single-style pin). Fail -> hand-author route stands. Stay-vs-pivot governance record HELD until spike evidence lands.
|
||||
|
||||
Scope guard (user concern, 2026-07-06): Synty intake must NOT displace the create-stuff-yourself pipeline — the user''s goal includes in-house garment creation capability, not just swapping vendor catalogs. Resolution: three-route portfolio sharing one engine — (a) Synty intake = variety bootstrap only; (b) derivation scripting (cuts/merges/retexture, agent-automatable) = create-yourself lite, turns any donor topology into original designs; (c) TRUE AUTHORING on owned UBC Source body .blends = the pipeline proper. GUARANTEE: the colorable uniform and black suit (no donor exists in any pack) are built via route (c) as the authoring pipeline''s end-to-end proof — non-negotiable deliverable of this ticket, so the pipeline exists even if Synty covers everything else. Spike status: extraction done inline by lead (158 FBX, sci-fi civilian theme confirmed); DECISIVE finding — Sidekick rig is UE-Mannequin-named, 52/65 bone-name overlap with our rig, sample garment binds to 12 vgroups of which 11 match ours exactly (only head->Head case rename) => transplant likely rename-and-rebind, far cheaper than the weight-transfer the pivot eval assumed; body blend shapes travel in the FBX. Transplant phase parked pending API recovery / token-window reset.
|
||||
|
||||
ROUTE DECISION (user, 2026-07-06, final): Synty intake REJECTED despite the spike''s technical PASS — (1) USD199.99/pack is too expensive; (2) Sidekick modularity is baked body-segment swap (clothes fused to body chunks), incompatible with our layered clothing-over-body system (11 bodies, skin tones, slot swapping); (3) user dislikes the Synty style. The spike still paid: transplant/batch scripts are source-agnostic and become the G1-family production pipeline; T-1090 (5 bodies broken bare) discovered; the Synty question is closed with evidence, not parked. OPERATIVE ROUTE: (b) derive from owned Fantasy parts + (c) hand-author — with a new preferred authoring technique: OFFSET-SHELL FROM OUR OWN BODY MESHES (extract body segment surface, offset outward into garment shell, cut neckline/hem/sleeves; weights inherited by construction, style fully ours, USD0). Suit + uniform remain the full hand-author proof. Purchases still open (separate concerns): UAL1/UAL2 Source ~USD30 for animation clips; Fantasy Outfits Source USD20 low-priority convenience.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:34:18.656', '2026-07-06 11:09:40.351', NULL, 'aec99c98e0291eb224f12840fdeb7c7d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKCER95EK266H8RWPW8XT1F0', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', '3D character locomotion sidequest — composited character walks the Gauntlet on the live server', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.
|
||||
|
||||
Visual reference (user, 2026-07-06): Xenonauts 2 nails the target tiles/occlusion/character perspective — readable tile grid, low-angle tactical camera with angle options (matches D-148 30deg low-angle + 45deg rotation steps and the spikes/3dpipeline angle presets), and crucially its wall/roof occlusion treatment: camera-facing walls cut down/ghosted so interiors stay readable, floors peel per z-level. One deliberate difference: our camera locks to the player character (D-015/T-116) — no free map panning. Implication for the locomotion sandbox: greybox walls should prototype camera-facing cutaway occlusion early, since wall readability interacts with the locked low-angle camera.
|
||||
|
||||
Scoping call (user + lead agreement, 2026-07-06): wall cutaway is CLIENT-SIDE presentation, decoupled from fog-of-perception. Rationale: two occlusion systems with different subjects — perception/fog governs what the CHARACTER knows (server-enforced info boundary; client only receives observer-filtered geometry), cutaway governs what the PLAYER''S CAMERA sees (pure render mode on wall data the client already holds). They compose: cutaway cuts render height; fog overlay still gates what is visible behind. Walls remain first-class client data objects (future destruction/explosion interactions), cutaway is a render mode on them like tint/LOD. Nuance parked for Phase 5 proper: destroyed-while-unobserved walls should render in remembered state (fog-memory applied to geometry). If this holds through implementation, mint a D-record (perception domain) when the sidequest lands.
|
||||
|
||||
Animation supply resolved (2026-07-06): user purchased UAL1[Source+Pro] (120 clips, identical lists) + UAL2[Source] (134 clips), unzipped in docs/assets/downloads/. Gains: Turn90_L/R + Turn180_L/R (turn-in-place), Sprint_Enter/Exit + Crouch_Enter/Exit (stance transitions), full 8-direction Walk/Jog/Crouch sets incl. diagonals, Counter_Idle + talking/phone/sitting idles (life-sim verbs), plus _RM root-motion variants (unused — in-place is correct for server-authoritative interpolation). Still absent everywhere: walk start/stop + stairs — the only remaining custom clips. Integration is NOT a drop-in swap: upstream renamed clips (Walk_Loop -> Walk_Fwd_Loop etc.), so wire via the design''s recorded plan — explicit library names + lib/Clip addressing + gait-table update. NOTE for facing design: the 8-dir walk sets unlock Q-084''s parked walk-vs-aim split (direction-matched clips relative to facing) — revisit during the S9 tuning session.', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:01:06.475', '2026-07-06 11:17:25.652', NULL, 'f5d3ba9fab6f37a621d71047b25cc5a9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
[gd_scene load_steps=7 format=3 uid="uid://c61sb2euagj4o"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/sandbox/locomotion_sandbox.gd" id="1_sandbox"]
|
||||
[ext_resource type="Script" path="res://scripts/sandbox/sandbox_debug_hud.gd" id="2_debughud"]
|
||||
[ext_resource type="Script" path="res://scripts/sandbox/greybox_world.gd" id="3_greybox"]
|
||||
[ext_resource type="Script" path="res://scripts/sandbox/locomotion_rig.gd" id="4_rig"]
|
||||
[ext_resource type="Script" path="res://scripts/sandbox/follow_camera_3d.gd" id="5_followcam"]
|
||||
|
||||
; T-1088: 3D locomotion sandbox — live-server-driven CharacterVisual walking the
|
||||
; Gauntlet greybox. Scene tree per the T-1088 design §1.2. Launch:
|
||||
; T1: cd server && cargo run --bin settled-reach-server -- --test-mode
|
||||
; T2: SR_LIVE=1 ~/bin/godot4 --path client res://scenes/locomotion_sandbox.tscn
|
||||
; Lights + environment follow the character_creation.tscn:65-92 recipe (the toon +
|
||||
; inverted-hull outline shaders need lights and a WorldEnvironment in-scene).
|
||||
|
||||
[sub_resource type="Environment" id="Environment_1"]
|
||||
background_mode = 1
|
||||
background_color = Color(0.03, 0.03, 0.06, 1)
|
||||
ambient_light_source = 2
|
||||
ambient_light_color = Color(0.4, 0.45, 0.55, 1)
|
||||
ambient_light_energy = 0.5
|
||||
|
||||
[node name="LocomotionSandbox" type="Node3D"]
|
||||
script = ExtResource("1_sandbox")
|
||||
|
||||
; D-148: THE single static map rotation — 45° yaw (0.785398 rad), set once here.
|
||||
; Sim coords stay axis-aligned inside WorldRoot; the camera never rotates.
|
||||
[node name="WorldRoot" type="Node3D" parent="."]
|
||||
rotation = Vector3(0, 0.785398, 0)
|
||||
|
||||
; greybox_world.gd — accumulating tile store + MultiMesh painter (design §3, §8).
|
||||
; Builds its FloorMM/WallMM MultiMeshInstance3D children in _ready().
|
||||
[node name="Greybox" type="Node3D" parent="WorldRoot"]
|
||||
script = ExtResource("3_greybox")
|
||||
|
||||
; locomotion_rig.gd — local position = interpolated sim position (metres).
|
||||
; Hidden until the first-snapshot latch (locomotion_sandbox.gd); the root installs
|
||||
; the provider Callables and feeds set_wire_target() per consumed snapshot.
|
||||
[node name="PlayerRig" type="Node3D" parent="WorldRoot"]
|
||||
visible = false
|
||||
script = ExtResource("4_rig")
|
||||
|
||||
; ModelRoot.rotation.y = smoothed facing yaw (D-151), in WorldRoot-LOCAL space so
|
||||
; octant→yaw composes with the map rotation exactly once. CharacterVisual is
|
||||
; runtime-instantiated under it; its own rotation.y stays 0.
|
||||
[node name="ModelRoot" type="Node3D" parent="WorldRoot/PlayerRig"]
|
||||
|
||||
; follow_camera_3d.gd — D-148 pivot-orbit rig, D-015 locked follow. The Camera3D
|
||||
; placeholder child is adopted in _ready() and every property is rewritten from
|
||||
; SandboxConstants, so the values below are editor preview only and cannot drift.
|
||||
[node name="CameraRig" type="Node3D" parent="." node_paths=PackedStringArray("follow_target")]
|
||||
script = ExtResource("5_followcam")
|
||||
follow_target = NodePath("../WorldRoot/PlayerRig")
|
||||
|
||||
; Initial pose: gameplay default preset (-30° pitch from horizontal, CAM_DIST 30,
|
||||
; ortho size 9 — SandboxConstants). Yaw 0, always: the diamond view is WorldRoot's 45°.
|
||||
[node name="Camera3D" type="Camera3D" parent="CameraRig"]
|
||||
position = Vector3(0, 15, 25.9808)
|
||||
rotation = Vector3(-0.523599, 0, 0)
|
||||
projection = 1
|
||||
current = true
|
||||
size = 9.0
|
||||
near = 0.1
|
||||
far = 100.0
|
||||
|
||||
; Key light: upper-left-front (character_creation.tscn recipe)
|
||||
[node name="KeyLight" type="DirectionalLight3D" parent="."]
|
||||
transform = Transform3D(0.866, -0.354, 0.354, 0, 0.707, 0.707, -0.5, -0.612, 0.612, 2, 3, 2)
|
||||
light_energy = 1.2
|
||||
shadow_enabled = true
|
||||
|
||||
; Fill light: right side, softer, no shadows
|
||||
[node name="FillLight" type="DirectionalLight3D" parent="."]
|
||||
transform = Transform3D(0.866, -0.259, -0.428, 0, 0.856, -0.517, 0.5, 0.448, 0.741, -2, 2, -1)
|
||||
light_energy = 0.4
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
environment = SubResource("Environment_1")
|
||||
|
||||
[node name="DebugHud" type="CanvasLayer" parent="."]
|
||||
script = ExtResource("2_debughud")
|
||||
@@ -59,6 +59,13 @@ var facing_octant: String = "North" # Derived from facing_angle
|
||||
var _last_sent_octant: String = "North" # Track to avoid redundant sends
|
||||
var _last_move_msec: int = 0
|
||||
|
||||
# T-1088 (design §9): 3D facing seam. A 3D scene installs a provider returning
|
||||
# sim-space radians (0=East, +PI/2=South — same convention as facing_angle), or
|
||||
# NAN = no update (deadzone/degenerate ray). While installed it fully replaces the
|
||||
# 2D canvas-transform path in _update_facing_from_mouse(); unset (default) leaves
|
||||
# the 2D path unchanged. Untyped Callable per the autoload parse-order rule.
|
||||
var facing_angle_provider = Callable()
|
||||
|
||||
|
||||
# Hold-to-move: poll held direction keys each frame, throttled by stance.
|
||||
# D-054: WASD is now mouse-relative. W = toward cursor, A/D = strafe.
|
||||
@@ -180,6 +187,16 @@ func reset_facing_state() -> void:
|
||||
# Intentional coupling: reads GameState.player_position directly — InputMapper is an
|
||||
# autoload that runs before game loop rendering, so position is always current-tick.
|
||||
func _update_facing_from_mouse() -> void:
|
||||
# T-1088 (design §9): provider seam. In a 3D scene the canvas-transform anchor
|
||||
# below is a far-off-screen point, and the screen-space mouse angle is not a
|
||||
# sim-space angle under the 45° map rotation + ortho pitch — an installed
|
||||
# provider (e.g. SandboxMouseAimProvider) replaces this whole path.
|
||||
if facing_angle_provider.is_valid():
|
||||
var a: float = facing_angle_provider.call()
|
||||
if is_finite(a):
|
||||
facing_angle = a
|
||||
facing_octant = _angle_to_octant(a)
|
||||
return
|
||||
var vp := get_viewport()
|
||||
if vp == null:
|
||||
return
|
||||
|
||||
@@ -24,6 +24,7 @@ extends Node3D
|
||||
## get_accessory_node_count() — number of accessory BoneAttachment3D nodes
|
||||
## get_skin_tone_texture_name(index) — skin tone texture filename key for index
|
||||
## get_overhead_anchor() — Marker3D above Head bone for floating UI (#712)
|
||||
## get_animation_player() — AnimationPlayer for external anim drivers (T-1088)
|
||||
##
|
||||
## D-159 (11 body types), D-160 (18 segments), D-161 (head separate),
|
||||
## D-162 (clothing pre-fitted), D-163 (heads via BoneAttachment3D), D-164 (skeleton fork)
|
||||
@@ -771,7 +772,10 @@ func _load_animations() -> void:
|
||||
|
||||
|
||||
## Play a named animation. Searches all libraries for a matching name.
|
||||
func play_animation(anim_name: String) -> void:
|
||||
## blend_time >= 0.0 is passed to AnimationPlayer.play() as custom_blend (crossfade
|
||||
## seconds); the -1.0 default preserves the original hard-cut behavior for existing
|
||||
## callers (T-1088 design §6.3 — the locomotion gait machine is the first blend user).
|
||||
func play_animation(anim_name: String, blend_time: float = -1.0) -> void:
|
||||
if _anim_player == null:
|
||||
return
|
||||
# Search across all libraries for the animation
|
||||
@@ -779,12 +783,15 @@ func play_animation(anim_name: String) -> void:
|
||||
var lib: AnimationLibrary = _anim_player.get_animation_library(lib_name)
|
||||
if lib.has_animation(anim_name):
|
||||
var full_name: String = lib_name + "/" + anim_name if lib_name != "" else anim_name
|
||||
_anim_player.play(full_name)
|
||||
if blend_time >= 0.0:
|
||||
_anim_player.play(full_name, blend_time)
|
||||
else:
|
||||
_anim_player.play(full_name)
|
||||
return
|
||||
# Try common idle variants
|
||||
for variant in ["Idle", "idle_01", "Idle_01", "breathing_idle", "Breathing_Idle"]:
|
||||
if anim_name == "idle" and variant != anim_name:
|
||||
play_animation(variant)
|
||||
play_animation(variant, blend_time)
|
||||
return
|
||||
push_warning("CharacterVisual: animation '%s' not found in any library" % anim_name)
|
||||
|
||||
@@ -795,6 +802,14 @@ func stop_animation() -> void:
|
||||
_anim_player.stop()
|
||||
|
||||
|
||||
## Return the internal AnimationPlayer ("AnimPlayer") — speed_scale and clip-phase
|
||||
## access for external animation drivers (T-1088 gait machine; Q-063 footstep seam).
|
||||
## Null until load_descriptor() has run; every load_descriptor() destroys and
|
||||
## recreates the player, so callers must re-fetch — never cache across reloads.
|
||||
func get_animation_player() -> AnimationPlayer:
|
||||
return _anim_player
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Static helpers
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
class_name FollowCamera3D
|
||||
extends Node3D
|
||||
## T-1088 follow camera (design §7) — the D-148 orthographic rig with D-015 locked
|
||||
## follow. Proto-production: values/technique migrate at the T-962 gate.
|
||||
##
|
||||
## Node contract: attach to a Node3D rig OUTSIDE WorldRoot (the .tscn CameraRig).
|
||||
## The rig's global position is the smoothed pivot; a Camera3D child (adopted if one
|
||||
## named "Camera3D" exists, created in code otherwise — every property is rewritten
|
||||
## from constants in _ready, so .tscn values cannot drift) orbits the pivot via the
|
||||
## spike math: camera.position = pivot + basis * Vector3(0, 0, CAM_DIST), where
|
||||
## basis is a pure pitch rotation. Yaw is locked to 0 ALWAYS — this file has no
|
||||
## rotation input path by design: stepped camera yaw is OUT until a D/Q record
|
||||
## exists (design-input §3); the diamond view is WorldRoot's 45°, never the camera.
|
||||
##
|
||||
## The pivot follow is exponential — deliberately the ONLY soft layer in the stack
|
||||
## (design §0). Against the rig's constant-velocity legs it converges to a constant
|
||||
## trailing offset velocity/CAM_FOLLOW_RATE (Walk ~0.21 m, Sprint ~0.42 m): reads
|
||||
## as speed, zero jitter. The character itself is never eased (locomotion_rig.gd).
|
||||
##
|
||||
## Dev affordances (sanctioned, D-148/D-158): T cycles pitch presets
|
||||
## [-30 gameplay default, -5 frontal, -80 overhead] (the spike's -45 iso preset is
|
||||
## struck by D-148); scroll wheel zooms ortho size 6-14. No "camera_tilt" action
|
||||
## exists in project.godot, so T is a direct physical-key check in
|
||||
## _unhandled_input — UI-safe: events consumed by Control nodes never reach it.
|
||||
##
|
||||
## Teleport snaps: connect the locomotion rig's `teleported` signal to
|
||||
## snap_to_target() (use .unbind(n) if the signal carries arguments); the sandbox
|
||||
## root also calls snap_to_target() from its first-snapshot latch.
|
||||
|
||||
const CAM_NEAR := 0.1
|
||||
const CAM_FAR := 100.0
|
||||
|
||||
# Scroll zoom — sandbox-only dev affordance (design §7: ungoverned and kept that
|
||||
# way), so its knobs live here rather than in SandboxConstants.
|
||||
const ZOOM_MIN := 6.0
|
||||
const ZOOM_MAX := 14.0
|
||||
const ZOOM_STEP := 1.0
|
||||
const ZOOM_RATE := 8.0
|
||||
|
||||
## The node the pivot chases — the sandbox wires WorldRoot/PlayerRig here, so the
|
||||
## followed point is the rig's interpolated position with the 45° map rotation
|
||||
## already applied (design §1.2).
|
||||
@export var follow_target: Node3D = null
|
||||
|
||||
var _camera: Camera3D = null
|
||||
var _pivot: Vector3 = Vector3.ZERO
|
||||
var _preset_idx: int = 0 # index into SandboxConstants.CAM_PITCH_PRESETS; 0 = -30 default
|
||||
var _pitch_deg: float = 0.0
|
||||
var _target_ortho_size: float = 0.0
|
||||
# Target velocity estimate, only consumed by the LOOKAHEAD_S knob (0.0 for now —
|
||||
# design §7 exposes it for the tuning pass).
|
||||
var _last_target_pos: Vector3 = Vector3.ZERO
|
||||
var _target_velocity: Vector3 = Vector3.ZERO
|
||||
var _has_target_history: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_camera = get_node_or_null("Camera3D") as Camera3D
|
||||
if _camera == null:
|
||||
_camera = Camera3D.new()
|
||||
_camera.name = "Camera3D"
|
||||
add_child(_camera)
|
||||
_camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
_camera.size = SandboxConstants.CAM_ORTHO_SIZE
|
||||
_camera.near = CAM_NEAR
|
||||
_camera.far = CAM_FAR
|
||||
_camera.current = true
|
||||
_pitch_deg = float(SandboxConstants.CAM_PITCH_PRESETS[_preset_idx])
|
||||
_target_ortho_size = SandboxConstants.CAM_ORTHO_SIZE
|
||||
_pivot = follow_target.global_position if follow_target != null else global_position
|
||||
global_position = _pivot
|
||||
_apply_orbit()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if follow_target != null:
|
||||
var target_pos := follow_target.global_position
|
||||
if _has_target_history and delta > 0.0:
|
||||
var frame_delta := target_pos - _last_target_pos
|
||||
if frame_delta.length() > SandboxConstants.SNAP_DIST_M:
|
||||
# Teleport-sized jump: don't launch the lookahead; the rig's
|
||||
# `teleported` signal hard-snaps the pivot (design §7).
|
||||
_target_velocity = Vector3.ZERO
|
||||
else:
|
||||
_target_velocity = frame_delta / delta
|
||||
_last_target_pos = target_pos
|
||||
_has_target_history = true
|
||||
var look_point := target_pos + _target_velocity * SandboxConstants.LOOKAHEAD_S
|
||||
# Exponential pivot follow — frame-rate independent (1 - exp(-rate * dt)).
|
||||
_pivot = _pivot.lerp(look_point, 1.0 - exp(-SandboxConstants.CAM_FOLLOW_RATE * delta))
|
||||
global_position = _pivot
|
||||
|
||||
var target_pitch := float(SandboxConstants.CAM_PITCH_PRESETS[_preset_idx])
|
||||
_pitch_deg = lerpf(_pitch_deg, target_pitch, 1.0 - exp(-SandboxConstants.CAM_TILT_RATE * delta))
|
||||
_camera.size = lerpf(_camera.size, _target_ortho_size, 1.0 - exp(-ZOOM_RATE * delta))
|
||||
_apply_orbit()
|
||||
|
||||
|
||||
# Dev affordances only — there is deliberately NO yaw/rotation input path here.
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey:
|
||||
var key := event as InputEventKey
|
||||
# Direct physical-key check: no "camera_tilt" action exists in project.godot.
|
||||
# Physical T (84) is unbound in the input map, so no action conflicts.
|
||||
if key.pressed and not key.echo and key.physical_keycode == KEY_T:
|
||||
_preset_idx = (_preset_idx + 1) % SandboxConstants.CAM_PITCH_PRESETS.size()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
if mb.pressed and mb.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||
_nudge_zoom(-ZOOM_STEP)
|
||||
get_viewport().set_input_as_handled()
|
||||
elif mb.pressed and mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
_nudge_zoom(ZOOM_STEP)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
## Hard pivot snap onto follow_target — no glide (design §7). Called by the sandbox
|
||||
## root's first-snapshot latch and by the rig's `teleported` signal. Pitch and zoom
|
||||
## are deliberately untouched: a cross-map jump must not change the dev view.
|
||||
func snap_to_target() -> void:
|
||||
if follow_target == null:
|
||||
return
|
||||
_pivot = follow_target.global_position
|
||||
global_position = _pivot
|
||||
_last_target_pos = _pivot
|
||||
_target_velocity = Vector3.ZERO
|
||||
_has_target_history = true
|
||||
|
||||
|
||||
## The managed Camera3D child — for the DebugHud / visual-capture harness.
|
||||
func get_camera() -> Camera3D:
|
||||
return _camera
|
||||
|
||||
|
||||
## Spike pivot-orbit math (design §7): the camera's offset from the pivot for a
|
||||
## given pitch, camera.position = pivot + basis * Vector3(0, 0, dist). Static and
|
||||
## pure for golden tests. At -30°/30 m this is (0, 15, 25.98) — the boom rises as
|
||||
## the pitch steepens while the look-at point stays exactly on the pivot.
|
||||
static func orbit_offset(pitch_deg: float, dist: float) -> Vector3:
|
||||
return Basis(Vector3.RIGHT, deg_to_rad(pitch_deg)) * Vector3(0.0, 0.0, dist)
|
||||
|
||||
|
||||
# Position the camera on the orbit and pitch it at the pivot. Yaw stays 0: the
|
||||
# rig node is never rotated and the camera's rotation is pitch-only by
|
||||
# construction.
|
||||
func _apply_orbit() -> void:
|
||||
var pitch_rad := deg_to_rad(_pitch_deg)
|
||||
_camera.position = orbit_offset(_pitch_deg, SandboxConstants.CAM_DIST)
|
||||
_camera.rotation = Vector3(pitch_rad, 0.0, 0.0)
|
||||
|
||||
|
||||
func _nudge_zoom(step: float) -> void:
|
||||
_target_ortho_size = clampf(_target_ortho_size + step, ZOOM_MIN, ZOOM_MAX)
|
||||
@@ -0,0 +1,324 @@
|
||||
class_name GreyboxWorld
|
||||
extends Node3D
|
||||
## T-1088 greybox world — accumulating tile store + MultiMesh painter (design §3).
|
||||
##
|
||||
## Two halves in one file:
|
||||
## - Store (inner RefCounted, headless-testable — test_greybox_store.gd):
|
||||
## accumulates tile knowledge from successive GameState.visible_tiles frames.
|
||||
## Never evicts; last observation wins — the embryo of the Phase-5
|
||||
## "destroyed-while-unobserved renders remembered" fog-memory record. Survives
|
||||
## the Home-key teleport (a feature). Gauntlet worst case 117x125 = 14,625
|
||||
## tiles — trivial against the 16,384 preallocation.
|
||||
## - Painter (this Node3D): builds the FloorMM/WallMM MultiMeshInstance3D
|
||||
## children in _ready() and applies Store diffs ONLY — new tiles get one
|
||||
## transform + one color write, visibility flips get one set_instance_color()
|
||||
## each. No full-buffer rebuilds (contrast the 2D TileRenderer
|
||||
## clear-and-reset, deliberately not copied).
|
||||
##
|
||||
## Mesh strategy (§3.3, Q-079 engaged): two MultiMeshes because the greybox
|
||||
## uniquely needs per-instance state (four-state tint) and per-material shader
|
||||
## uniforms (cutaway) — GridMap has neither. All real 3D geometry per D-244.
|
||||
##
|
||||
## Perception stays server-enforced upstream (D-010): the store only ever sees
|
||||
## tiles the server disclosed; never-seen tiles render as nothing — the void.
|
||||
##
|
||||
## Promotion status (design §1.1): store contract proto-production; meshes
|
||||
## disposable.
|
||||
|
||||
## Preallocated instances per MultiMesh (design §3.1). Resizing a MultiMesh
|
||||
## resets its buffers, so allocate once; unused instances stay zero-scaled.
|
||||
const INSTANCE_CAPACITY := 16384
|
||||
|
||||
## Zero-scaled transform for unused/freed instances — renders nothing.
|
||||
const ZERO_XFORM := Transform3D(Vector3.ZERO, Vector3.ZERO, Vector3.ZERO, Vector3.ZERO)
|
||||
|
||||
const FLOOR_SHADER := preload("res://shaders/sandbox/greybox_tile.gdshader")
|
||||
const WALL_SHADER := preload("res://shaders/sandbox/greybox_wall_cutaway.gdshader")
|
||||
|
||||
|
||||
## Accumulating last-observation-wins tile knowledge (design §3.1). Pure data —
|
||||
## no scene or autoload access — so gdUnit covers it headless.
|
||||
class Store:
|
||||
extends RefCounted
|
||||
|
||||
## Wire tile_kind is walkability-derived (server query.rs:78-82); doors are
|
||||
## entities on the wire — Door/Object collapse to FLOOR.
|
||||
enum Kind { FLOOR, WALL }
|
||||
## Render states (§3.2). REMEMBERED = in store but not in the current LOS
|
||||
## set. Never-seen tiles have no state — nothing rendered, the void.
|
||||
enum Vis { FORWARD, PERIPHERAL, BOUNDARY_WALL, REMEMBERED }
|
||||
|
||||
## Vector3i(x, y, z) sim tile coords -> Kind. Accumulates forever.
|
||||
var _store: Dictionary = {}
|
||||
## Vector3i -> Vis: current render state of every stored tile.
|
||||
var _state: Dictionary = {}
|
||||
## Vector3i -> Vis: the current LOS set, rebuilt each consumed snapshot.
|
||||
var _visible_now: Dictionary = {}
|
||||
|
||||
## Consume one snapshot's GameState.visible_tiles (already merged from both
|
||||
## wire spellings by snapshot_handler.gd:52-64 — live dicts carry
|
||||
## {x, y, z, visibility, type}; TestHarness dicts omit "visibility").
|
||||
## Returns the paint diff — three Array[Vector3i] under fixed keys:
|
||||
## "added": first-ever observations (slot + transform + color)
|
||||
## "rekinded": kind changed under last-observation-wins (slot moves MM)
|
||||
## "recolored": render state flipped only (one set_instance_color)
|
||||
func ingest(tiles: Array) -> Dictionary:
|
||||
var added: Array[Vector3i] = []
|
||||
var rekinded: Array[Vector3i] = []
|
||||
var recolored: Array[Vector3i] = []
|
||||
var new_visible: Dictionary = {}
|
||||
for tile: Variant in tiles:
|
||||
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
|
||||
continue
|
||||
var key := Vector3i(int(tile["x"]), int(tile["y"]), int(tile.get("z", 0)))
|
||||
var kind := _parse_kind(tile)
|
||||
var vis := _parse_vis(tile)
|
||||
new_visible[key] = vis
|
||||
if not _store.has(key):
|
||||
_store[key] = kind
|
||||
_state[key] = vis
|
||||
added.append(key)
|
||||
elif _store[key] != kind:
|
||||
# Last observation wins — e.g. a wall destroyed while observed.
|
||||
_store[key] = kind
|
||||
_state[key] = vis
|
||||
rekinded.append(key)
|
||||
elif _state[key] != vis:
|
||||
_state[key] = vis
|
||||
recolored.append(key)
|
||||
# Tiles that just left LOS dim to REMEMBERED. Tiles remembered on an
|
||||
# earlier snapshot already carry the state — the flip emits exactly once.
|
||||
for key: Vector3i in _visible_now:
|
||||
if new_visible.has(key):
|
||||
continue
|
||||
if _state[key] != Vis.REMEMBERED:
|
||||
_state[key] = Vis.REMEMBERED
|
||||
recolored.append(key)
|
||||
_visible_now = new_visible
|
||||
return {"added": added, "rekinded": rekinded, "recolored": recolored}
|
||||
|
||||
## Total accumulated tiles (never shrinks — eviction: never, §3.1).
|
||||
func size() -> int:
|
||||
return _store.size()
|
||||
|
||||
## Tiles in the current LOS set (DebugHud readout).
|
||||
func visible_count() -> int:
|
||||
return _visible_now.size()
|
||||
|
||||
func has_tile(key: Vector3i) -> bool:
|
||||
return _store.has(key)
|
||||
|
||||
## Kind for a stored tile; -1 for never-seen.
|
||||
func kind_of(key: Vector3i) -> int:
|
||||
return _store.get(key, -1)
|
||||
|
||||
## Current render state (Vis) for a stored tile; -1 for never-seen — the
|
||||
## void (perception is upstream, server-enforced).
|
||||
func state_of(key: Vector3i) -> int:
|
||||
return _state.get(key, -1)
|
||||
|
||||
## Persistence seam (design §0 scope fence — in-memory today): a copy of the
|
||||
## accumulated knowledge, Vector3i -> Kind. Transient visibility state is
|
||||
## not knowledge and is not exported.
|
||||
func to_dict() -> Dictionary:
|
||||
return _store.duplicate()
|
||||
|
||||
static func _parse_kind(tile: Dictionary) -> Kind:
|
||||
return Kind.WALL if str(tile.get("type", "floor")) == "wall" else Kind.FLOOR
|
||||
|
||||
static func _parse_vis(tile: Dictionary) -> Vis:
|
||||
match str(tile.get("visibility", "Forward")):
|
||||
"Peripheral":
|
||||
return Vis.PERIPHERAL
|
||||
"BoundaryWall":
|
||||
return Vis.BOUNDARY_WALL
|
||||
_:
|
||||
# Forward, plus TestHarness "tiles" dicts (no visibility key —
|
||||
# design-input §1.5) and unknown future variants.
|
||||
return Vis.FORWARD
|
||||
|
||||
|
||||
## The accumulating knowledge store — read by the DebugHud (store.size(),
|
||||
## store.visible_count()) and driven by on_snapshot().
|
||||
var store := Store.new()
|
||||
|
||||
var _floor_mm: MultiMeshInstance3D = null
|
||||
var _wall_mm: MultiMeshInstance3D = null
|
||||
var _wall_material: ShaderMaterial = null
|
||||
## Vector3i -> Vector2i(kind, slot index in that kind's MultiMesh).
|
||||
var _slots: Dictionary = {}
|
||||
## Next never-used slot per Kind (high watermark).
|
||||
var _next_slot: Array[int] = [0, 0]
|
||||
## Freed slots per Kind (holes left by rekind moves), reused first.
|
||||
var _free_slots: Array = [[], []]
|
||||
## Per-Vis instance colors, resolved from SandboxConstants.TINTS in _ready().
|
||||
var _state_colors: Array[Color] = []
|
||||
var _last_tick: int = -1
|
||||
var _capacity_warned := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_state_colors = [
|
||||
_tint_to_color(SandboxConstants.TINTS["forward"]),
|
||||
_tint_to_color(SandboxConstants.TINTS["peripheral"]),
|
||||
_tint_to_color(SandboxConstants.TINTS["boundary"]),
|
||||
_tint_to_color(SandboxConstants.TINTS["remembered"]),
|
||||
]
|
||||
|
||||
# FloorMM: PlaneMesh faces +Y natively — no orientation fix needed (§3.2).
|
||||
var floor_mesh := PlaneMesh.new()
|
||||
floor_mesh.size = Vector2(SandboxConstants.SUBTILE_M, SandboxConstants.SUBTILE_M)
|
||||
var floor_material := ShaderMaterial.new()
|
||||
floor_material.shader = FLOOR_SHADER
|
||||
# Grid lines are sampled in sim space: undo this node's global transform
|
||||
# (i.e. the WorldRoot 45 deg) so lines land on tile edges, not world axes.
|
||||
# Deferred: global_transform is not final until the tree is ready.
|
||||
_set_sim_from_world.call_deferred(floor_material)
|
||||
_floor_mm = _make_layer("FloorMM", floor_mesh, floor_material)
|
||||
|
||||
# WallMM: BoxMesh is centered on its origin — the per-instance transform
|
||||
# lifts it +WALL_H/2 so the base sits at y=0 (§3.2, explicit).
|
||||
var wall_mesh := BoxMesh.new()
|
||||
wall_mesh.size = Vector3(
|
||||
SandboxConstants.SUBTILE_M, SandboxConstants.WALL_H, SandboxConstants.SUBTILE_M
|
||||
)
|
||||
_wall_material = ShaderMaterial.new()
|
||||
_wall_material.shader = WALL_SHADER
|
||||
# Cutaway geometry knobs come from SandboxConstants (single source, §12);
|
||||
# the shader defaults mirror them only for standalone/editor use.
|
||||
_wall_material.set_shader_parameter("u_wall_h", SandboxConstants.WALL_H)
|
||||
_wall_material.set_shader_parameter("u_cut_height", SandboxConstants.CUT_HEIGHT)
|
||||
_wall_material.set_shader_parameter("u_cut_radius", SandboxConstants.CUT_RADIUS)
|
||||
_wall_material.set_shader_parameter("u_cut_band", SandboxConstants.CUT_BAND)
|
||||
_wall_mm = _make_layer("WallMM", wall_mesh, _wall_material)
|
||||
|
||||
|
||||
## Push the world->sim transform into the floor grid shader (deferred from
|
||||
## _ready so global_transform reflects the WorldRoot rotation).
|
||||
func _set_sim_from_world(mat: ShaderMaterial) -> void:
|
||||
mat.set_shader_parameter("u_sim_from_world", global_transform.affine_inverse())
|
||||
|
||||
|
||||
## Snapshot consumer — called directly by the sandbox root (no
|
||||
## SnapshotEventRouter, design §13.7). Tick-gated on GameState.current_tick
|
||||
## change — pattern: world_renderer.gd:33-36. Paused ticks repeat the tick and
|
||||
## are skipped here for free.
|
||||
func on_snapshot() -> void:
|
||||
if GameState.current_tick == _last_tick:
|
||||
return
|
||||
_last_tick = GameState.current_tick
|
||||
_apply_diff(store.ingest(GameState.visible_tiles))
|
||||
|
||||
|
||||
## DebugHud duck contract (sandbox_debug_hud.gd): total accumulated tiles.
|
||||
func get_store_size() -> int:
|
||||
return store.size()
|
||||
|
||||
|
||||
## Per-frame cutaway anchor (design §8): pass the rig's INTERPOLATED
|
||||
## world-space position (PlayerRig.global_position — it already carries the
|
||||
## 45 deg WorldRoot rotation, matching the shader's world-space test) so the cut
|
||||
## zone glides with the character and spatial smoothstep becomes temporal
|
||||
## smoothness. One uniform write per frame — the entire CPU cost.
|
||||
func set_cutaway_char_pos(world_pos: Vector3) -> void:
|
||||
_wall_material.set_shader_parameter("u_char_pos_xz", Vector2(world_pos.x, world_pos.z))
|
||||
|
||||
|
||||
func _make_layer(layer_name: String, mesh: Mesh, material: ShaderMaterial) -> MultiMeshInstance3D:
|
||||
var mm := MultiMesh.new()
|
||||
# Format flags must be set before instance_count (buffer layout is fixed at
|
||||
# allocation). use_colors routes set_instance_color() to the shaders' COLOR
|
||||
# built-in, which multiplies albedo (§3.2).
|
||||
mm.transform_format = MultiMesh.TRANSFORM_3D
|
||||
mm.use_colors = true
|
||||
mm.mesh = mesh
|
||||
mm.instance_count = INSTANCE_CAPACITY
|
||||
for i in INSTANCE_CAPACITY:
|
||||
mm.set_instance_transform(i, ZERO_XFORM)
|
||||
var instance := MultiMeshInstance3D.new()
|
||||
instance.name = layer_name
|
||||
instance.multimesh = mm
|
||||
instance.material_override = material
|
||||
add_child(instance)
|
||||
return instance
|
||||
|
||||
|
||||
func _apply_diff(diff: Dictionary) -> void:
|
||||
for key: Vector3i in diff["added"]:
|
||||
_paint_new(key)
|
||||
for key: Vector3i in diff["rekinded"]:
|
||||
# Kind flipped — the instance moves between MultiMeshes: zero-scale the
|
||||
# old slot, allocate in the other mesh.
|
||||
_release_slot(key)
|
||||
_paint_new(key)
|
||||
for key: Vector3i in diff["recolored"]:
|
||||
_repaint_color(key)
|
||||
|
||||
|
||||
func _paint_new(key: Vector3i) -> void:
|
||||
var kind := store.kind_of(key)
|
||||
var slot := _alloc_slot(kind)
|
||||
if slot < 0:
|
||||
return # capacity exhausted — the store still knows the tile (warned once)
|
||||
var mm := _mm_for(kind).multimesh
|
||||
mm.set_instance_transform(slot, _tile_xform(key, kind))
|
||||
mm.set_instance_color(slot, _state_colors[store.state_of(key)])
|
||||
_slots[key] = Vector2i(kind, slot)
|
||||
|
||||
|
||||
func _repaint_color(key: Vector3i) -> void:
|
||||
var slot: Vector2i = _slots.get(key, Vector2i(-1, -1))
|
||||
if slot.y < 0:
|
||||
return # never painted (capacity overflow)
|
||||
_mm_for(slot.x).multimesh.set_instance_color(slot.y, _state_colors[store.state_of(key)])
|
||||
|
||||
|
||||
func _release_slot(key: Vector3i) -> void:
|
||||
var slot: Vector2i = _slots.get(key, Vector2i(-1, -1))
|
||||
if slot.y < 0:
|
||||
return
|
||||
_mm_for(slot.x).multimesh.set_instance_transform(slot.y, ZERO_XFORM)
|
||||
(_free_slots[slot.x] as Array).append(slot.y)
|
||||
_slots.erase(key)
|
||||
|
||||
|
||||
func _alloc_slot(kind: int) -> int:
|
||||
var free: Array = _free_slots[kind]
|
||||
if not free.is_empty():
|
||||
return free.pop_back()
|
||||
if _next_slot[kind] >= INSTANCE_CAPACITY:
|
||||
if not _capacity_warned:
|
||||
_capacity_warned = true
|
||||
push_error(
|
||||
(
|
||||
"GreyboxWorld: MultiMesh capacity %d exhausted — further tiles are "
|
||||
% INSTANCE_CAPACITY
|
||||
)
|
||||
+ "stored but not rendered (Gauntlet worst case is 14,625; investigate)"
|
||||
)
|
||||
return -1
|
||||
var slot := _next_slot[kind]
|
||||
_next_slot[kind] += 1
|
||||
return slot
|
||||
|
||||
|
||||
func _mm_for(kind: int) -> MultiMeshInstance3D:
|
||||
return _wall_mm if kind == Store.Kind.WALL else _floor_mm
|
||||
|
||||
|
||||
## Floors sit on the ground plane at the subtile center (SandboxSpace, §2);
|
||||
## walls lift +WALL_H/2 so the centered BoxMesh base sits at y=0.
|
||||
func _tile_xform(key: Vector3i, kind: int) -> Transform3D:
|
||||
var origin := SandboxSpace.tile_to_world(key)
|
||||
if kind == Store.Kind.WALL:
|
||||
origin.y += SandboxConstants.WALL_H * 0.5
|
||||
return Transform3D(Basis.IDENTITY, origin)
|
||||
|
||||
|
||||
## SandboxConstants.TINTS values are Color (used directly) or float (a greyscale
|
||||
## albedo value — 85% / 70%), per §3.2.
|
||||
func _tint_to_color(tint: Variant) -> Color:
|
||||
if tint is Color:
|
||||
return tint
|
||||
var value := float(tint)
|
||||
return Color(value, value, value)
|
||||
@@ -0,0 +1,182 @@
|
||||
class_name LocomotionAnim
|
||||
extends RefCounted
|
||||
## T-1088 gait state machine (design §6) — proto-production.
|
||||
## Slaves animation to the rig's motion channel: clip selection keys off render
|
||||
## velocity (never input), transitions are edge-triggered crossfades, and playback
|
||||
## rate is cadence-synced so foot fall matches actual ground speed (§6.2).
|
||||
##
|
||||
## Wiring (sandbox root, design §14 group C):
|
||||
## var anim := LocomotionAnim.new()
|
||||
## anim.setup(player_rig, character_visual) # after both exist in-tree
|
||||
## anim.update(delta) # each frame, after the rig moved
|
||||
## Teleports: setup() auto-connects the rig's `teleported` signal to notify_teleport()
|
||||
## when the signal exists; otherwise call notify_teleport() alongside the rig snap.
|
||||
##
|
||||
## The rig ref is duck-typed against the §4.0 contract getters — `stance: String`,
|
||||
## `is_moving: bool`, `current_speed: float` (m/s, constant per leg) — so the machine
|
||||
## needs no rig class and a test stub drives it headless. The visual ref is duck-typed
|
||||
## against the §6.3 additive CharacterVisual API: play_animation(name, blend_time)
|
||||
## and get_animation_player().
|
||||
##
|
||||
## Clip strings live ONLY in SandboxConstants.GAIT_CLIP (§6.1) — never here.
|
||||
|
||||
## Q-063 seam (footstep audio): emitted once per edge-triggered gait change with the
|
||||
## clip now playing (the clip identifies stance + motion; Idle clips = no footsteps).
|
||||
## Clip phase for footstep timing is available via CharacterVisual.get_animation_player().
|
||||
signal gait_changed(clip: StringName)
|
||||
|
||||
## §6.3 pre-flagged caveat (the SKIP_PHASE_SEEK flag): in Godot 4.6, seek() during a
|
||||
## custom_blend crossfade may cancel the blend (unverified against the live scene).
|
||||
## If gait<->gait transitions visibly pop instead of crossfading, set this true to
|
||||
## drop the phase-preserving seek — the 0.15 s blend masks the leg-beat resync
|
||||
## acceptably (tune-by-eye; record the outcome on T-1088 per design §11.9).
|
||||
var skip_phase_seek: bool = false
|
||||
|
||||
var _rig = null # duck-typed rig (locomotion_rig.gd): stance / is_moving / current_speed
|
||||
var _visual = null # duck-typed CharacterVisual: play_animation() / get_animation_player()
|
||||
var _current_clip: StringName = &"" # empty = nothing played yet (first play hard-cuts)
|
||||
var _current_moving: bool = false
|
||||
|
||||
|
||||
## Pure gait table lookup (§6.1). Unknown stances warn and fall back to the Walk row
|
||||
## (mirrors GameState.player_stance's wire default).
|
||||
static func gait(stance: String, is_moving: bool) -> StringName:
|
||||
if not SandboxConstants.GAIT_CLIP.has(stance):
|
||||
push_warning("LocomotionAnim: unknown stance '%s' — defaulting to Walk" % stance)
|
||||
stance = "Walk"
|
||||
var row: Dictionary = SandboxConstants.GAIT_CLIP[stance]
|
||||
return StringName(row["moving"]) if is_moving else StringName(row["idle"])
|
||||
|
||||
|
||||
## Pure blend-table selection (§6.3). Any transition into or out of a Crouch_* clip
|
||||
## takes the crouch blend; otherwise the idle/gait edge decides.
|
||||
static func blend_for(
|
||||
from_clip: StringName, to_clip: StringName, from_moving: bool, to_moving: bool
|
||||
) -> float:
|
||||
var blends: Dictionary = SandboxConstants.BLEND
|
||||
if String(from_clip).begins_with("Crouch") or String(to_clip).begins_with("Crouch"):
|
||||
return float(blends["crouch"])
|
||||
if not from_moving and to_moving:
|
||||
return float(blends["idle_to_gait"])
|
||||
if from_moving and not to_moving:
|
||||
return float(blends["gait_to_idle"])
|
||||
return float(blends["gait_to_gait"])
|
||||
|
||||
|
||||
## The clip currently driven (empty StringName until the first play) — surfaced by
|
||||
## the sandbox root for the DebugHud "clip" readout.
|
||||
func get_current_clip() -> StringName:
|
||||
return _current_clip
|
||||
|
||||
|
||||
## Store the rig + visual refs. Auto-connects the rig's `teleported(pos_m)` signal
|
||||
## (if it exposes one) to the hard reset.
|
||||
func setup(rig, visual) -> void:
|
||||
_rig = rig
|
||||
_visual = visual
|
||||
if rig != null and rig.has_signal("teleported"):
|
||||
rig.teleported.connect(_on_rig_teleported)
|
||||
|
||||
|
||||
# Signal adapter — locomotion_rig.gd emits teleported(pos_m: Vector3); the anim
|
||||
# reset doesn't need the position, only the rig's post-snap state.
|
||||
func _on_rig_teleported(_pos_m: Vector3) -> void:
|
||||
notify_teleport()
|
||||
|
||||
|
||||
## Per-frame drive — call after the rig's own interpolation for the frame.
|
||||
func update(_delta: float) -> void:
|
||||
if _rig == null or _visual == null:
|
||||
return
|
||||
_apply_state(_rig.stance, _rig.is_moving, _rig.current_speed)
|
||||
|
||||
|
||||
## Teleport hard reset (§6.3): a cross-map jump must not smear — replay the correct
|
||||
## clip for the rig's post-snap state with the 0.0 teleport blend, rewound to phase 0.
|
||||
func notify_teleport() -> void:
|
||||
if _rig == null or _visual == null:
|
||||
return
|
||||
var moving: bool = _rig.is_moving
|
||||
var clip := gait(_rig.stance, moving)
|
||||
var changed := clip != _current_clip
|
||||
_visual.play_animation(clip, float(SandboxConstants.BLEND["teleport"]))
|
||||
var player = _visual.get_animation_player()
|
||||
if player != null:
|
||||
# play() on the already-current clip does not rewind — force the hard reset.
|
||||
player.seek(0.0, false)
|
||||
_current_clip = clip
|
||||
_current_moving = moving
|
||||
_update_speed_scale(clip, moving, _rig.current_speed)
|
||||
if changed:
|
||||
gait_changed.emit(clip)
|
||||
|
||||
|
||||
# State application, separated from the rig read so tests drive it directly.
|
||||
func _apply_state(stance: String, moving: bool, speed: float) -> void:
|
||||
var clip := gait(stance, moving)
|
||||
if clip != _current_clip:
|
||||
_transition_to(clip, moving)
|
||||
_update_speed_scale(clip, moving, speed)
|
||||
|
||||
|
||||
# Edge-triggered transition (§6.3): fires only on clip change, so loops never
|
||||
# restart mid-cycle (a stance toggle between Walk/Careful/Sprint idle keeps the
|
||||
# shared Idle clip untouched).
|
||||
func _transition_to(clip: StringName, moving: bool) -> void:
|
||||
var first_play := _current_clip == &""
|
||||
var blend := -1.0 # first-ever play: hard cut (the character just appeared)
|
||||
if not first_play:
|
||||
blend = blend_for(_current_clip, clip, _current_moving, moving)
|
||||
# gait<->gait phase preservation (§6.3): capture the leg beat before switching,
|
||||
# then re-seek into the new clip so feet keep their rhythm across the blend.
|
||||
var phase := -1.0
|
||||
if not first_play and _current_moving and moving and not skip_phase_seek:
|
||||
phase = _capture_phase()
|
||||
_visual.play_animation(clip, blend)
|
||||
if phase >= 0.0:
|
||||
_seek_phase(clip, phase)
|
||||
_current_clip = clip
|
||||
_current_moving = moving
|
||||
gait_changed.emit(clip)
|
||||
|
||||
|
||||
# Cadence sync (§6.2): while moving, speed_scale = clamp(speed / NATIVE_MPS[clip],
|
||||
# 0.6, 1.8). Rig speed is constant per leg (dist/interval, §4.1), so the scale is
|
||||
# constant per leg — no within-step wobble; catch-up bursts push it up so feet chase
|
||||
# instead of skating. Idle clips always run at 1.0.
|
||||
func _update_speed_scale(clip: StringName, moving: bool, speed: float) -> void:
|
||||
var player = _visual.get_animation_player()
|
||||
if player == null:
|
||||
return
|
||||
if not moving:
|
||||
player.speed_scale = 1.0
|
||||
return
|
||||
var native: float = SandboxConstants.NATIVE_MPS.get(String(clip), 0.0)
|
||||
if native <= 0.0:
|
||||
player.speed_scale = 1.0 # moving clip missing from NATIVE_MPS — table drift
|
||||
return
|
||||
var limits: Vector2 = SandboxConstants.SPEED_SCALE_CLAMP
|
||||
player.speed_scale = clampf(speed / native, limits.x, limits.y)
|
||||
|
||||
|
||||
# Normalized phase [0,1) of the currently playing clip, or -1.0 when unavailable.
|
||||
func _capture_phase() -> float:
|
||||
var player = _visual.get_animation_player()
|
||||
if player == null or String(player.current_animation).is_empty():
|
||||
return -1.0
|
||||
var length: float = player.current_animation_length
|
||||
if length <= 0.0:
|
||||
return -1.0
|
||||
return fposmod(player.current_animation_position, length) / length
|
||||
|
||||
|
||||
# Seek the new clip to the preserved phase without flushing the pose (update=false)
|
||||
# so the crossfade started by play() keeps blending. See skip_phase_seek caveat.
|
||||
func _seek_phase(clip: StringName, phase: float) -> void:
|
||||
var player = _visual.get_animation_player()
|
||||
if player == null:
|
||||
return
|
||||
var anim: Animation = player.get_animation(clip)
|
||||
if anim == null:
|
||||
return
|
||||
player.seek(phase * anim.length, false)
|
||||
@@ -0,0 +1,315 @@
|
||||
class_name LocomotionRig
|
||||
extends Node3D
|
||||
## T-1088 locomotion rig (design §4, §5) — the position and yaw channels of the
|
||||
## feel thesis (§0): each channel gets exactly ONE smoothing layer, crisp -> soft.
|
||||
## Thin Node3D wrapper (this node = PlayerRig; local position = interpolated sim
|
||||
## position in WorldRoot-local metres) over a pure static math core, so every
|
||||
## rule is headless-testable (test_locomotion_math.gd, §10.4). Proto-production:
|
||||
## an NPC adapter later feeds set_wire_target() from VisibleEntity rows with no
|
||||
## rig changes.
|
||||
##
|
||||
## Position (§4.1): per-leg constant velocity sized to the step — on a changed
|
||||
## target, cover the distance in exactly one stance interval (dist/interval),
|
||||
## clamped to [base, CATCHUP_MAX_FACTOR * base]. No easing on the character
|
||||
## itself: easing a repeating 0.4 s step reads as scooting; the camera (S5) is
|
||||
## deliberately the only soft position layer.
|
||||
##
|
||||
## Yaw (§5, "server feet, client eyes"): moving (incl. the idle-hysteresis
|
||||
## window) follows the wire octant — the server sets Facing from the move delta,
|
||||
## so it IS the motion direction; idle follows idle_facing_provider (the same
|
||||
## snapped octant that rides the SetFacing wire); frozen while suppressed
|
||||
## (dialogue / free camera). Shortest-arc lerp_angle ease (TURN_SHARPNESS) under
|
||||
## per-stance deg/s budgets (TURN_BUDGET_DEG), written to ModelRoot.rotation.y in
|
||||
## WorldRoot-LOCAL space so octant->yaw composes with the D-148 map rotation
|
||||
## exactly once.
|
||||
##
|
||||
## TRAP (design §2, §5): NEVER call CharacterVisual.set_facing() from this rig
|
||||
## or anything near it. Its internal table (west=+90, east=-90 —
|
||||
## character_visual.gd:184-193) assumes a mirrored axis mapping (East -> -X)
|
||||
## incompatible with THE convention (SandboxSpace: East -> +X); using it makes
|
||||
## the model face west while walking east. This rig owns ModelRoot.rotation.y
|
||||
## exclusively, always through SandboxSpace.octant_to_yaw(); CharacterVisual's
|
||||
## own rotation.y stays 0.
|
||||
##
|
||||
## The rig never reads GameState, InputMapper, or any autoload itself (§4.0) —
|
||||
## the sandbox root adapts snapshot fields into set_wire_target() and installs
|
||||
## the provider Callables below. NPC adapters leave the providers unset.
|
||||
|
||||
## Emitted when a wire target lands beyond SNAP_DIST_M (Home-key / cross-map
|
||||
## teleport, §4.1): all rig channels have already snapped; the camera (S5)
|
||||
## hard-sets its pivot on this, the anim machine (S6) hard-cuts (BLEND.teleport
|
||||
## = 0.0). NOT emitted for the first-ever snapshot (the root's latch owns that).
|
||||
signal teleported(pos_m: Vector3)
|
||||
|
||||
## set_wire_target() outcome — a pure decision, exposed for tests (§10.4).
|
||||
enum TargetAction { SNAP_FIRST, IGNORE, TELEPORT, STEP }
|
||||
|
||||
## Fallback step window when step_window_ms_provider is unset (headless tests,
|
||||
## future NPC adapters before MovementSpeed wiring). Mirrors the Walk default
|
||||
## stance only — the player adapter MUST install a provider reading
|
||||
## InputMapper.MOVE_INTERVAL_MS at runtime: that table is the single source and
|
||||
## is deliberately never copied (design §13.8).
|
||||
const DEFAULT_STEP_WINDOW_MS := 400.0
|
||||
|
||||
## (stance: String) -> float|int step window in ms for one confirmed step.
|
||||
## Player adapter: func(stance): return InputMapper.MOVE_INTERVAL_MS.get(stance, 400).
|
||||
## Called once per accepted step (per-leg recompute, §4.1) with the stance that
|
||||
## arrived in the same wire target.
|
||||
var step_window_ms_provider: Callable = Callable()
|
||||
## () -> octant String for idle facing. The player adapter returns
|
||||
## InputMapper.facing_octant — the SAME snapped octant that rides the SetFacing
|
||||
## wire, so the model never shows an octant the server wasn't told (§5). Leave
|
||||
## unset (NPCs) to collapse the facing split to pure wire facing — single code
|
||||
## path by construction.
|
||||
var idle_facing_provider: Callable = Callable()
|
||||
## () -> bool, true while input is suppressed. The player adapter returns
|
||||
## GameState.dialogue_active or GameState.free_camera_mode — the exact condition
|
||||
## under which InputMapper computes but does not send octants (input_mapper.gd:71).
|
||||
## While idle + suppressed the yaw target freezes (last target held, §5). Leave
|
||||
## unset for never-suppressed (NPCs).
|
||||
var suppression_provider: Callable = Callable()
|
||||
|
||||
## Latest wire facing octant (snapshot player_facing) — the moving yaw source.
|
||||
## Updates on EVERY wire target, including ignored ones: a blocked move (Q-020)
|
||||
## still changes Facing server-side, so bump-to-turn falls out for free (§4.3).
|
||||
var wire_octant: String = "North"
|
||||
## Latest wire stance — selects the step window and the moving turn budget.
|
||||
var stance: String = "Walk"
|
||||
## Tick of the latest consumed wire target (DebugHud "snapshot age" input).
|
||||
var last_wire_tick: int = -1
|
||||
|
||||
## True until at-target for IDLE_ENTER_DELAY_S (§4.2 hysteresis — kills the
|
||||
## walk<->idle flicker from snapshot jitter). Gait selection keys off this and
|
||||
## current_speed, never off input (§0).
|
||||
var is_moving: bool = false
|
||||
## Constant per-leg ground speed (m/s), zero on arrival — cadence-sync input
|
||||
## (§6.2: speed_scale = current_speed / NATIVE_MPS, constant per leg).
|
||||
var current_speed: float = 0.0
|
||||
## Render velocity (m/s), zero on arrival — gait-selection input (§0).
|
||||
var velocity: Vector3 = Vector3.ZERO
|
||||
## Current yaw target (rad, WorldRoot-local) — held while frozen. DebugHud pairs
|
||||
## it with model_root.rotation.y as "yaw target / actual".
|
||||
var yaw_target: float = 0.0
|
||||
|
||||
## Yaw channel output node — resolved from the ModelRoot scene child in _ready();
|
||||
## injectable beforehand for headless tests.
|
||||
var model_root: Node3D = null
|
||||
|
||||
var _has_target: bool = false
|
||||
var _target_pos: Vector3 = Vector3.ZERO
|
||||
var _leg_speed: float = 0.0
|
||||
var _idle_timer_s: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if model_root == null:
|
||||
model_root = get_node_or_null("ModelRoot") as Node3D
|
||||
|
||||
|
||||
## The single rig input (§4.0): the sandbox root (or a future NPC adapter) calls
|
||||
## this once per consumed snapshot with the wire position converted to
|
||||
## WorldRoot-local metres (SandboxSpace.wire_to_world), the wire facing octant,
|
||||
## stance, and tick.
|
||||
func set_wire_target(pos_m: Vector3, facing_octant: String, new_stance: String, tick: int) -> void:
|
||||
wire_octant = facing_octant
|
||||
stance = new_stance
|
||||
last_wire_tick = tick
|
||||
match classify_target(_has_target, position, _target_pos, pos_m):
|
||||
TargetAction.SNAP_FIRST:
|
||||
# First-ever snapshot snaps, no signal (2D precedent
|
||||
# entity_renderer.gd:133-140; the root's first-snapshot latch
|
||||
# handles the camera).
|
||||
_snap_all_channels(pos_m)
|
||||
TargetAction.IGNORE:
|
||||
# Paused-tick identical frames and blocked moves (Q-020) land here:
|
||||
# target-chasing is idempotent, nothing re-triggers. Facing already
|
||||
# updated above — bump-to-turn is the visible blocked-move behavior.
|
||||
pass
|
||||
TargetAction.TELEPORT:
|
||||
_snap_all_channels(pos_m)
|
||||
teleported.emit(pos_m)
|
||||
TargetAction.STEP:
|
||||
_target_pos = pos_m
|
||||
_leg_speed = derive_leg_speed(position.distance_to(pos_m), _step_interval_s())
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _has_target:
|
||||
return
|
||||
# Position channel: constant-velocity chase, exact arrival (§4.1).
|
||||
position = step_position(position, _target_pos, _leg_speed, delta)
|
||||
var at_target := position.is_equal_approx(_target_pos)
|
||||
if at_target:
|
||||
position = _target_pos # kill sub-epsilon residue
|
||||
velocity = Vector3.ZERO
|
||||
current_speed = 0.0
|
||||
else:
|
||||
velocity = (_target_pos - position).normalized() * _leg_speed
|
||||
current_speed = _leg_speed
|
||||
_idle_timer_s = advance_idle_timer(_idle_timer_s, at_target, delta)
|
||||
is_moving = is_moving_state(at_target, _idle_timer_s)
|
||||
# Yaw channel (§5).
|
||||
_update_facing(delta)
|
||||
|
||||
|
||||
## Remaining distance (m) on the current leg — DebugHud "leg distance" readout.
|
||||
func leg_remaining_m() -> float:
|
||||
return position.distance_to(_target_pos)
|
||||
|
||||
|
||||
## DebugHud duck contract (sandbox_debug_hud.gd): render speed in m/s — constant
|
||||
## per leg, zero on arrival.
|
||||
func get_render_speed() -> float:
|
||||
return current_speed
|
||||
|
||||
|
||||
## DebugHud duck contract (sandbox_debug_hud.gd): current yaw target (rad,
|
||||
## WorldRoot-local), paired on screen with ModelRoot.rotation.y as "target/actual".
|
||||
func get_yaw_target_rad() -> float:
|
||||
return yaw_target
|
||||
|
||||
|
||||
## Snap every rig channel to the target (first snapshot / teleport, §4.1):
|
||||
## position, velocity, yaw target AND actual (no ease across a jump), and the
|
||||
## idle hysteresis (pre-expired so the rig is instantly idle).
|
||||
func _snap_all_channels(pos_m: Vector3) -> void:
|
||||
_has_target = true
|
||||
position = pos_m
|
||||
_target_pos = pos_m
|
||||
_leg_speed = 0.0
|
||||
velocity = Vector3.ZERO
|
||||
current_speed = 0.0
|
||||
_idle_timer_s = SandboxConstants.IDLE_ENTER_DELAY_S
|
||||
is_moving = false
|
||||
yaw_target = SandboxSpace.octant_to_yaw(wire_octant)
|
||||
if model_root != null:
|
||||
model_root.rotation.y = yaw_target
|
||||
|
||||
|
||||
func _step_interval_s() -> float:
|
||||
var window_ms := DEFAULT_STEP_WINDOW_MS
|
||||
if step_window_ms_provider.is_valid():
|
||||
window_ms = float(step_window_ms_provider.call(stance))
|
||||
return window_ms / 1000.0
|
||||
|
||||
|
||||
func _update_facing(delta: float) -> void:
|
||||
var suppressed := false
|
||||
if suppression_provider.is_valid():
|
||||
suppressed = bool(suppression_provider.call())
|
||||
var idle_octant := ""
|
||||
if idle_facing_provider.is_valid():
|
||||
idle_octant = str(idle_facing_provider.call())
|
||||
yaw_target = select_yaw_target(is_moving, wire_octant, idle_octant, suppressed, yaw_target)
|
||||
if model_root == null:
|
||||
return
|
||||
var budget_key := stance if is_moving else "Idle"
|
||||
model_root.rotation.y = step_yaw(model_root.rotation.y, yaw_target, budget_key, delta)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure static core — all locomotion math lives below, stateless and
|
||||
# headless-testable (§10.4). The wrapper above only shuttles state.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## Classify an incoming wire target (§4.1): the first-ever snapshot snaps; an
|
||||
## unchanged target is ignored — paused-tick frames (§4.3) and blocked moves
|
||||
## (Q-020) re-trigger nothing by construction, and a mid-leg repeat keeps the
|
||||
## current leg speed (no tail-slowdown re-derivation); beyond SNAP_DIST_M
|
||||
## (5 subtiles — matches the 2D TELEPORT_DISTANCE_THRESHOLD) is a teleport;
|
||||
## otherwise a normal step.
|
||||
static func classify_target(
|
||||
has_target: bool, render_pos: Vector3, current_target: Vector3, new_target: Vector3
|
||||
) -> TargetAction:
|
||||
if not has_target:
|
||||
return TargetAction.SNAP_FIRST
|
||||
if new_target.is_equal_approx(current_target):
|
||||
return TargetAction.IGNORE
|
||||
if render_pos.distance_to(new_target) > SandboxConstants.SNAP_DIST_M:
|
||||
return TargetAction.TELEPORT
|
||||
return TargetAction.STEP
|
||||
|
||||
|
||||
## Per-leg constant velocity (§4.1): cover dist in exactly one stance interval,
|
||||
## clamped to [base, CATCHUP_MAX_FACTOR * base] where base is the cardinal
|
||||
## one-subtile speed (SUBTILE_M / interval). One rule handles everything:
|
||||
## cardinal Walk 0.5/0.4 = 1.25 m/s; diagonal 0.707/0.4 = 1.77 m/s — arrives
|
||||
## exactly on time (no sqrt(2) on the wire, D-053; a fixed speed would lag on
|
||||
## held diagonals); multi-tile latest-wins deltas close within ~one interval
|
||||
## with feet speeding up to match (§6.2).
|
||||
static func derive_leg_speed(dist_m: float, interval_s: float) -> float:
|
||||
var safe_interval := maxf(interval_s, 0.001)
|
||||
var base := SandboxConstants.SUBTILE_M / safe_interval
|
||||
return clampf(dist_m / safe_interval, base, SandboxConstants.CATCHUP_MAX_FACTOR * base)
|
||||
|
||||
|
||||
## One frame of constant-velocity chase — move_toward clamps at the target, so
|
||||
## arrival is exact and velocity is a clean square wave, never a sawtooth (§4.1).
|
||||
static func step_position(
|
||||
render_pos: Vector3, target: Vector3, leg_speed: float, delta: float
|
||||
) -> Vector3:
|
||||
return render_pos.move_toward(target, leg_speed * delta)
|
||||
|
||||
|
||||
## Idle-hysteresis timer (§4.2): accumulates while at-target, resets the moment
|
||||
## a new leg starts.
|
||||
static func advance_idle_timer(timer_s: float, at_target: bool, delta: float) -> float:
|
||||
return timer_s + delta if at_target else 0.0
|
||||
|
||||
|
||||
## is_moving with hysteresis (§4.2): snapshot arrival jitters +-1-2 ticks against
|
||||
## the client throttle, so the rig frequently arrives a few frames early —
|
||||
## staying "moving" until at-target for IDLE_ENTER_DELAY_S kills the walk<->idle
|
||||
## flicker; a real stop still reaches Idle inside the human ~0.3 s settle window.
|
||||
static func is_moving_state(at_target: bool, timer_s: float) -> bool:
|
||||
if not at_target:
|
||||
return true
|
||||
return timer_s < SandboxConstants.IDLE_ENTER_DELAY_S
|
||||
|
||||
|
||||
## Yaw target source per the §5 authority table ("server feet, client eyes"):
|
||||
## moving -> wire octant (server Facing IS the motion direction); idle +
|
||||
## suppressed -> held target frozen; idle with a provider -> the client-local
|
||||
## aim octant; idle without one (NPCs) -> pure wire facing. Empty idle_octant
|
||||
## means "no provider installed".
|
||||
static func select_yaw_target(
|
||||
moving: bool, wire_oct: String, idle_octant: String, suppressed: bool, held_target: float
|
||||
) -> float:
|
||||
if moving:
|
||||
return SandboxSpace.octant_to_yaw(wire_oct)
|
||||
if suppressed:
|
||||
return held_target
|
||||
if idle_octant.is_empty():
|
||||
return SandboxSpace.octant_to_yaw(wire_oct)
|
||||
return SandboxSpace.octant_to_yaw(idle_octant)
|
||||
|
||||
|
||||
## Signed shortest arc from one yaw to another, in [-PI, PI).
|
||||
static func shortest_arc(from_yaw: float, to_yaw: float) -> float:
|
||||
return wrapf(to_yaw - from_yaw, -PI, PI)
|
||||
|
||||
|
||||
## Wrap a yaw into (-PI, PI] — same convention as SandboxSpace (North stays +PI).
|
||||
static func wrap_yaw(yaw: float) -> float:
|
||||
var wrapped := yaw
|
||||
while wrapped > PI:
|
||||
wrapped -= TAU
|
||||
while wrapped <= -PI:
|
||||
wrapped += TAU
|
||||
return wrapped
|
||||
|
||||
|
||||
## One frame of yaw smoothing (§5): shortest-arc lerp_angle ease-out at
|
||||
## TURN_SHARPNESS, with the per-frame change clamped to the per-stance
|
||||
## TURN_BUDGET_DEG (budget_key = stance while moving, "Idle" otherwise; unknown
|
||||
## keys fall back to Idle). A 180 deg reversal at Walk completes in ~0.25 s
|
||||
## (about half a step); a 45 deg corner resolves in ~60 ms.
|
||||
static func step_yaw(current_yaw: float, target_yaw: float, budget_key: String, delta: float) -> float:
|
||||
var eased := lerp_angle(current_yaw, target_yaw, 1.0 - exp(-SandboxConstants.TURN_SHARPNESS * delta))
|
||||
var budget_deg: float = SandboxConstants.TURN_BUDGET_DEG.get(
|
||||
budget_key, SandboxConstants.TURN_BUDGET_DEG["Idle"]
|
||||
)
|
||||
var max_step := deg_to_rad(budget_deg) * delta
|
||||
var step := clampf(shortest_arc(current_yaw, eased), -max_step, max_step)
|
||||
return wrap_yaw(current_yaw + step)
|
||||
@@ -0,0 +1,415 @@
|
||||
extends Node3D
|
||||
## T-1088 3D locomotion sandbox root — boot, connect, poll/pump, local descriptor,
|
||||
## first-snapshot latch, component wiring (design §1.3). Sandbox-only file.
|
||||
##
|
||||
## The session-driver boilerplate below is a deliberate ~40-line copy from main.gd:
|
||||
## D-166 freezes main.gd until Phase 5, so each copied block carries a "Pattern:"
|
||||
## comment naming its source for the T-962 shared-driver extraction.
|
||||
##
|
||||
## Launch recipe (design §10.1):
|
||||
## T1: cd server && cargo run --bin settled-reach-server -- --test-mode
|
||||
## T2: SR_LIVE=1 ~/bin/godot4 --path client res://scenes/locomotion_sandbox.tscn
|
||||
|
||||
const MANIFEST_PATH := "res://assets/characters/manifest.json"
|
||||
|
||||
## Autopilot (design §10.2): sim-space heading per movement token (0 = East,
|
||||
## +PI/2 = South — the InputMapper.facing_angle convention, Y-down radians).
|
||||
const AUTOPILOT_HEADINGS := {
|
||||
"east": 0.0,
|
||||
"south": PI / 2.0,
|
||||
"west": PI,
|
||||
"north": -PI / 2.0,
|
||||
}
|
||||
## Hold time for pulsed discrete actions (stance_up/stance_down) — long enough
|
||||
## for the buffered InputEventAction press to reach InputMapper._unhandled_input.
|
||||
const AUTOPILOT_PULSE_S := 0.1
|
||||
|
||||
@onready var world_root: Node3D = $WorldRoot
|
||||
@onready var greybox: GreyboxWorld = $WorldRoot/Greybox
|
||||
@onready var player_rig: LocomotionRig = $WorldRoot/PlayerRig
|
||||
@onready var model_root: Node3D = $WorldRoot/PlayerRig/ModelRoot
|
||||
@onready var camera_rig: FollowCamera3D = $CameraRig
|
||||
|
||||
var character_visual: CharacterVisual = null
|
||||
## Gait state machine (design §6) — RefCounted, owned and driven by this root.
|
||||
var _anim: LocomotionAnim = null
|
||||
## Mouse-aim facing provider (design §9) — the installed Callable keeps it alive;
|
||||
## referenced here too so the seam's owner is greppable. Cleared in _exit_tree().
|
||||
var _aim_provider: SandboxMouseAimProvider = null
|
||||
var _first_snapshot_seen: bool = false
|
||||
var _gameplay_paused: bool = false # D-170: implant fullscreen occludes gameplay
|
||||
var _autopilot_spec: String = "" # design §10.2: raw SR_AUTOPILOT string (kept for debugging)
|
||||
var _autopilot_steps: Array[Dictionary] = [] # parsed segments, consumed FIFO by _autopilot_tick
|
||||
var _autopilot_active: bool = false # a segment is running (its timer is live)
|
||||
var _autopilot_timer: float = 0.0 # seconds left in the active segment (fixed-fps deltas)
|
||||
var _autopilot_action: String = "" # Input action held/pulsed by the active segment
|
||||
var _autopilot_kind: String = "" # "move" | "pulse" | "wait"
|
||||
var _autopilot_heading: float = NAN # scripted sim-space facing angle (NAN until first move)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# (1) SR_PORT honor — pattern: visual_capture.gd:79-88. A plain boot dials the
|
||||
# default 9876; the live harness starts the server with --port 0 and passes the
|
||||
# parsed port through SR_PORT.
|
||||
var port_env := OS.get_environment("SR_PORT")
|
||||
if not port_env.is_empty():
|
||||
SimBridge.server_port = int(port_env)
|
||||
# Guarded connect — pattern: main.gd:61-62. Handshake (D-192) + StartupMessage
|
||||
# are automatic in SimBridge's state machine.
|
||||
if SimBridge.state == SimBridge.ConnectionState.DISCONNECTED:
|
||||
SimBridge.connect_to_sim()
|
||||
|
||||
# (2)+(3) Local descriptor (GameState.character_visual_descriptor is never
|
||||
# populated — the sandbox builds its own from manifest.json), then CharacterVisual
|
||||
# IN-TREE BEFORE load_descriptor: its _ready() loads the toon/outline shaders;
|
||||
# out-of-tree the materials get null shaders. Pattern: character_creation.gd:386-388.
|
||||
character_visual = CharacterVisual.new()
|
||||
character_visual.name = "CharacterVisual"
|
||||
model_root.add_child(character_visual)
|
||||
character_visual.load_descriptor(_build_descriptor())
|
||||
# TRAP (design §2): never call character_visual.set_facing() — its octant table
|
||||
# assumes the 2D renderer's mirrored axis mapping (East -> -X). ModelRoot.rotation.y,
|
||||
# always via SandboxSpace.octant_to_yaw(), is the only facing authority here;
|
||||
# CharacterVisual's own rotation.y stays 0. The rig enforces this (locomotion_rig.gd).
|
||||
|
||||
# PlayerRig stays hidden (.tscn visible=false) until the first-snapshot latch.
|
||||
|
||||
# (3b) Rig provider adapters (design §4.0, §5): the rig reads zero autoloads —
|
||||
# these Callables are its only view of InputMapper/GameState. NPC adapters later
|
||||
# leave all three unset.
|
||||
player_rig.step_window_ms_provider = _step_window_ms
|
||||
player_rig.idle_facing_provider = _idle_facing_octant
|
||||
player_rig.suppression_provider = _input_suppressed
|
||||
# Teleport fan-out (design §4.1): camera hard-snaps its pivot; the anim machine
|
||||
# connects itself in setup() below (0.0-blend hard cut).
|
||||
player_rig.teleported.connect(_on_rig_teleported)
|
||||
|
||||
# (3c) Gait machine (design §6): slaved to the rig's motion channel, driving
|
||||
# CharacterVisual via the additive play_animation(name, blend) API.
|
||||
_anim = LocomotionAnim.new()
|
||||
_anim.setup(player_rig, character_visual)
|
||||
|
||||
# (4) InputMapper facing provider (design §9).
|
||||
_install_facing_provider()
|
||||
|
||||
# (5) D-170: the occlusion *mechanism* is CanvasItem-only, the signal is not —
|
||||
# a 3D scene connects it directly to a pause flag.
|
||||
HudGroups.gameplay_occluded.connect(_on_gameplay_occluded)
|
||||
|
||||
# (6) SR_AUTOPILOT (design §10.2) — deterministic capture input. Parsed here,
|
||||
# ticked per-frame after the first snapshot. While a script is loaded, a scripted
|
||||
# heading replaces the mouse-aim provider ON THE SAME SEAM (the mouse position is
|
||||
# nondeterministic under xvfb), so the genuine InputMapper octant-snap / SetFacing /
|
||||
# mouse-relative-WASD / throttle path still runs end-to-end.
|
||||
_autopilot_spec = OS.get_environment("SR_AUTOPILOT")
|
||||
_autopilot_steps = _parse_autopilot(_autopilot_spec)
|
||||
if not _autopilot_steps.is_empty():
|
||||
InputMapper.facing_angle_provider = Callable(self, "_autopilot_facing_angle")
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
# Clear the §9 seam so the 2D canvas-transform path resumes for any scene loaded
|
||||
# after this one; the provider's own guards make a stale install safe (NAN).
|
||||
InputMapper.facing_angle_provider = Callable()
|
||||
# Release any autopilot-held action so pressed state never leaks past the scene.
|
||||
if _autopilot_active:
|
||||
_autopilot_end_segment()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Snapshot poll — pattern: main.gd:251-268; extract to a shared session driver in
|
||||
# Phase 5 (T-962). The first snapshot arrives here, never in _ready. Dispatch runs
|
||||
# BEFORE the latch so the rig's SNAP_FIRST has placed the player when the camera snaps.
|
||||
var snapshot: Variant = SimBridge.poll_snapshot()
|
||||
if snapshot != null:
|
||||
GameState.apply_snapshot(snapshot)
|
||||
_dispatch_snapshot()
|
||||
if not _first_snapshot_seen:
|
||||
_first_snapshot_latch()
|
||||
|
||||
# Input pump — pattern: main.gd:295-341. The only pump in the codebase lives in
|
||||
# main.gd; without this the sandbox connects but never moves (design §1.3).
|
||||
for entry in InputMapper.flush_queue():
|
||||
var action: int = entry.get("action", -1)
|
||||
if (
|
||||
action == InputMapper.Action.BUG_REPORT
|
||||
or action == InputMapper.Action.OPEN_JOURNAL
|
||||
or action == InputMapper.Action.OPEN_MENU
|
||||
):
|
||||
continue # client-only actions — no wire mapping (sim_bridge.gd:536-539)
|
||||
SimBridge.send_input(entry)
|
||||
|
||||
if _gameplay_paused:
|
||||
return # D-170: skip per-frame visual work while an implant app occludes
|
||||
|
||||
_per_frame_update(delta)
|
||||
_autopilot_tick(delta)
|
||||
|
||||
|
||||
# First-snapshot latch (design §1.3 step 3): the rig's SNAP_FIRST already placed
|
||||
# position + yaw (dispatch precedes the latch, and the rig snaps on its first
|
||||
# set_wire_target — entity_renderer.gd:133-140 first-appearance precedent); the
|
||||
# latch owns visibility and the camera snap.
|
||||
func _first_snapshot_latch() -> void:
|
||||
_first_snapshot_seen = true
|
||||
player_rig.visible = true
|
||||
_snap_camera_to_player()
|
||||
|
||||
|
||||
# Snapshot fan-out — direct consumer calls, no SnapshotEventRouter (design §13.7:
|
||||
# the router's registration lives in main.gd; two consumers don't justify touching it).
|
||||
func _dispatch_snapshot() -> void:
|
||||
greybox.on_snapshot()
|
||||
var pos := GameState.player_position
|
||||
player_rig.set_wire_target(
|
||||
SandboxSpace.wire_to_world(pos.x, pos.y),
|
||||
GameState.player_facing,
|
||||
GameState.player_stance,
|
||||
GameState.current_tick
|
||||
)
|
||||
|
||||
|
||||
# Local CharacterVisualDescriptor from the first valid manifest.json id per category
|
||||
# — no hardcoded id strings (design §1.3 step 2). Body type and skin tone keep
|
||||
# descriptor defaults; facial hair stays empty (empty string = none is a valid value,
|
||||
# not a manifest id).
|
||||
func _build_descriptor() -> CharacterVisualDescriptor:
|
||||
var manifest := _load_manifest()
|
||||
var descriptor := CharacterVisualDescriptor.new()
|
||||
var heads: Array = manifest.get("heads", [])
|
||||
if not heads.is_empty():
|
||||
descriptor.head_id = str(heads[0])
|
||||
var hair: Array = manifest.get("hair", [])
|
||||
if not hair.is_empty():
|
||||
descriptor.hair_id = str(hair[0])
|
||||
# Clothing manifest maps item_id -> {slot}; wear the first item declared per slot.
|
||||
var clothing: Variant = manifest.get("clothing", {})
|
||||
if clothing is Dictionary:
|
||||
for item_id: String in clothing:
|
||||
var slot := str((clothing[item_id] as Dictionary).get("slot", ""))
|
||||
if not slot.is_empty() and not descriptor.clothing_slots.has(slot):
|
||||
descriptor.clothing_slots[slot] = item_id
|
||||
return descriptor
|
||||
|
||||
|
||||
# Pattern: character_creation.gd:324-338 (manifest load).
|
||||
func _load_manifest() -> Dictionary:
|
||||
var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("locomotion_sandbox: manifest not found at %s" % MANIFEST_PATH)
|
||||
return {}
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if parsed is Dictionary:
|
||||
return parsed as Dictionary
|
||||
push_warning("locomotion_sandbox: manifest parse failed — using empty defaults")
|
||||
return {}
|
||||
|
||||
|
||||
func _on_gameplay_occluded(occluded: bool) -> void:
|
||||
_gameplay_paused = occluded
|
||||
|
||||
|
||||
## True while an implant app occludes gameplay (D-170) — components consult this
|
||||
## instead of connecting to HudGroups themselves.
|
||||
func is_gameplay_paused() -> bool:
|
||||
return _gameplay_paused
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rig provider adapters (design §4.0, §5) — the player-side Callables installed
|
||||
# in _ready(). Each is the exact adapter shape the rig's doc comments name.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Step window for one confirmed step, ms — reads InputMapper.MOVE_INTERVAL_MS at
|
||||
# runtime (single source, never copied — design §13.8). 400 = the Walk default,
|
||||
# matching the rig's own fallback.
|
||||
func _step_window_ms(for_stance: String) -> int:
|
||||
return int(InputMapper.MOVE_INTERVAL_MS.get(for_stance, 400))
|
||||
|
||||
|
||||
# Idle facing (§5): the SAME snapped octant that rides the SetFacing wire, so the
|
||||
# model never shows an octant the server wasn't told.
|
||||
func _idle_facing_octant() -> String:
|
||||
return InputMapper.facing_octant
|
||||
|
||||
|
||||
# Suppression (§5): the exact condition under which InputMapper computes but does
|
||||
# not send octants (input_mapper.gd:78) — idle yaw freezes on it.
|
||||
func _input_suppressed() -> bool:
|
||||
return GameState.dialogue_active or GameState.free_camera_mode
|
||||
|
||||
|
||||
# Teleport (design §4.1/§7): hard camera-pivot snap, no glide. The anim machine's
|
||||
# 0.0-blend reset rides its own connection (LocomotionAnim.setup).
|
||||
func _on_rig_teleported(_pos_m: Vector3) -> void:
|
||||
camera_rig.snap_to_target()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wiring (design §14 groups B/C/D) — one function per attach point.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## Input seam (design §9): install InputMapper.facing_angle_provider — unprojects
|
||||
## the mouse onto the y=0 plane, WorldRoot.to_local() (undoing the 45 degree map
|
||||
## rotation), delta from the rig's local position, atan2(delta.z, delta.x) sim
|
||||
## radians; NAN inside MOUSE_AIM_DEADZONE_M. Without it, 2D-canvas mouse math
|
||||
## makes facing garbage in a 3D scene and WASD unsteerable. CameraRig is a child,
|
||||
## so its _ready() (which adopts the Camera3D) has already run.
|
||||
func _install_facing_provider() -> void:
|
||||
_aim_provider = SandboxMouseAimProvider.new(camera_rig.get_camera(), world_root, player_rig)
|
||||
InputMapper.facing_angle_provider = Callable(_aim_provider, "get_facing_angle")
|
||||
|
||||
|
||||
## Per-frame cross-component work owned by the root, gated on the first snapshot
|
||||
## and the D-170 pause flag: the cutaway anchor (design §8 — the rig's INTERPOLATED
|
||||
## world XZ, so the cut zone glides and spatial smoothstep becomes temporal
|
||||
## smoothness) and the gait machine (after the rig's own _process interpolation).
|
||||
func _per_frame_update(delta: float) -> void:
|
||||
if not _first_snapshot_seen:
|
||||
return
|
||||
greybox.set_cutaway_char_pos(player_rig.global_position)
|
||||
_anim.update(delta)
|
||||
|
||||
|
||||
## Hard camera-pivot snap (design §7) — first-snapshot latch + teleport fan-out.
|
||||
func _snap_camera_to_player() -> void:
|
||||
camera_rig.snap_to_target()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Autopilot (S8, design §10.2) — deterministic capture input. SR_AUTOPILOT
|
||||
# (e.g. "east:2.0,south:1.5,stance_up,east:1.0") drives timed presses of the
|
||||
# REAL input actions — no SimBridge bypass, no test-only paths in the rig.
|
||||
# Movement tokens aim the token's heading through the facing seam and hold
|
||||
# "move_north" (W = toward aim under D-054 mouse-relative WASD); stance tokens
|
||||
# pulse an InputEventAction through the normal event pipeline.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Segment machine, ticked from _process. Gated on the first snapshot so
|
||||
# capture-time connection jitter never eats the schedule. Durations count
|
||||
# process deltas — deterministic frame counts under --fixed-fps.
|
||||
func _autopilot_tick(delta: float) -> void:
|
||||
if not _first_snapshot_seen:
|
||||
return
|
||||
if _autopilot_active:
|
||||
_autopilot_timer -= delta
|
||||
if _autopilot_timer > 0.0:
|
||||
return
|
||||
_autopilot_end_segment()
|
||||
if not _autopilot_steps.is_empty():
|
||||
_autopilot_begin_segment(_autopilot_steps.pop_front())
|
||||
|
||||
|
||||
# Parse "east:2.0,south:1.5,stance_up,east:1.0" into segment dicts. Movement
|
||||
# tokens (north/east/south/west:SECONDS) walk that sim direction; stance_up/
|
||||
# stance_down pulse once (optional :SECONDS hold); "wait:SECONDS" idles.
|
||||
# Unknown tokens warn and are skipped.
|
||||
func _parse_autopilot(spec: String) -> Array[Dictionary]:
|
||||
var steps: Array[Dictionary] = []
|
||||
for raw_token in spec.split(",", false):
|
||||
var parts := raw_token.strip_edges().split(":")
|
||||
var token := parts[0].strip_edges()
|
||||
var duration := parts[1].to_float() if parts.size() > 1 else 0.0
|
||||
if AUTOPILOT_HEADINGS.has(token):
|
||||
(
|
||||
steps
|
||||
. append(
|
||||
{
|
||||
"kind": "move",
|
||||
"action": "move_north", # W = forward = toward the scripted aim (D-054)
|
||||
"duration": maxf(duration, 0.0),
|
||||
"heading": AUTOPILOT_HEADINGS[token],
|
||||
}
|
||||
)
|
||||
)
|
||||
elif token == "stance_up" or token == "stance_down":
|
||||
(
|
||||
steps
|
||||
. append(
|
||||
{
|
||||
"kind": "pulse",
|
||||
"action": token,
|
||||
"duration": maxf(duration, AUTOPILOT_PULSE_S),
|
||||
"heading": NAN,
|
||||
}
|
||||
)
|
||||
)
|
||||
elif token == "wait":
|
||||
steps.append(
|
||||
{"kind": "wait", "action": "", "duration": maxf(duration, 0.0), "heading": NAN}
|
||||
)
|
||||
elif not token.is_empty():
|
||||
push_warning("locomotion_sandbox: unknown SR_AUTOPILOT token '%s'" % token)
|
||||
return steps
|
||||
|
||||
|
||||
func _autopilot_begin_segment(step: Dictionary) -> void:
|
||||
_autopilot_active = true
|
||||
_autopilot_timer = step["duration"]
|
||||
_autopilot_action = step["action"]
|
||||
_autopilot_kind = step["kind"]
|
||||
var heading: float = step["heading"]
|
||||
if is_finite(heading):
|
||||
_autopilot_heading = heading
|
||||
match _autopilot_kind:
|
||||
"move":
|
||||
# Held action state — InputMapper polls Input.is_action_pressed each frame.
|
||||
Input.action_press(_autopilot_action)
|
||||
"pulse":
|
||||
_autopilot_send_event(_autopilot_action, true)
|
||||
|
||||
|
||||
func _autopilot_end_segment() -> void:
|
||||
match _autopilot_kind:
|
||||
"move":
|
||||
Input.action_release(_autopilot_action)
|
||||
"pulse":
|
||||
_autopilot_send_event(_autopilot_action, false)
|
||||
_autopilot_active = false
|
||||
_autopilot_action = ""
|
||||
_autopilot_kind = ""
|
||||
|
||||
|
||||
# InputEventAction through the buffered input pipeline — reaches InputMapper's
|
||||
# _unhandled_input exactly like a keypress (Input.action_press only feeds the
|
||||
# polled is_action_pressed path, which discrete actions never read).
|
||||
func _autopilot_send_event(action_name: String, pressed: bool) -> void:
|
||||
var event := InputEventAction.new()
|
||||
event.action = action_name
|
||||
event.pressed = pressed
|
||||
Input.parse_input_event(event)
|
||||
|
||||
|
||||
# Scripted facing (installed on InputMapper.facing_angle_provider while an
|
||||
# autopilot script is loaded): the heading of the current/last movement segment,
|
||||
# NAN before the first one (facing keeps InputMapper's default). Everything
|
||||
# downstream — octant snap, SetFacing-on-change, WASD rotation — is unchanged.
|
||||
func _autopilot_facing_angle() -> float:
|
||||
return _autopilot_heading
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DebugHud duck contract (sandbox_debug_hud.gd) — the HUD probes this root for
|
||||
# the anim readouts; rig and greybox answer their own.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## Clip currently driven by the gait machine; null until one plays (HUD shows "—").
|
||||
func get_current_clip() -> Variant:
|
||||
if _anim == null:
|
||||
return null
|
||||
var clip: StringName = _anim.get_current_clip()
|
||||
return null if clip == &"" else clip
|
||||
|
||||
|
||||
## Live AnimationPlayer speed_scale (cadence sync, design §6.2); null until the
|
||||
## CharacterVisual exists. Re-fetched every call — load_descriptor() recreates the player.
|
||||
func get_anim_speed_scale() -> Variant:
|
||||
if character_visual == null:
|
||||
return null
|
||||
var player := character_visual.get_animation_player()
|
||||
return null if player == null else player.speed_scale
|
||||
@@ -0,0 +1,98 @@
|
||||
class_name SandboxMouseAimProvider
|
||||
extends RefCounted
|
||||
## T-1088 (design §9) — mouse-aim facing provider for the 3D locomotion sandbox.
|
||||
## Installed by the sandbox root as InputMapper.facing_angle_provider:
|
||||
##
|
||||
## var provider := SandboxMouseAimProvider.new(camera, world_root, player_rig)
|
||||
## InputMapper.facing_angle_provider = Callable(provider, "get_facing_angle")
|
||||
##
|
||||
## Returns sim-space radians (0 = East, +PI/2 = South — the InputMapper.facing_angle
|
||||
## convention), or NAN for "no update" (deadzone / degenerate ray / freed nodes).
|
||||
## Everything downstream in InputMapper — octant snap, SetFacing-on-change,
|
||||
## mouse-relative WASD, dialogue suppression — runs unchanged; D-054 semantics
|
||||
## preserved exactly (only the octant ever crosses the wire).
|
||||
##
|
||||
## RefCounted, not Node: InputMapper pulls the value through the Callable each
|
||||
## frame, so the provider needs no tree presence or lifecycle of its own, and the
|
||||
## pure static core stays headless-testable (design §10.4). The installed Callable
|
||||
## keeps this RefCounted alive; the installer clears the seam back to Callable()
|
||||
## on scene exit, and the is_instance_valid guards below return NAN if the scene
|
||||
## nodes are freed first.
|
||||
##
|
||||
## Math (design §9): unproject the mouse to a ray, intersect the y=0 ground plane
|
||||
## in world space, convert through WorldRoot.to_local() (undoing the single D-148
|
||||
## 45° map rotation — WorldRoot-local space IS sim-aligned space, SandboxSpace §2),
|
||||
## take the delta from the rig's WorldRoot-local position, atan2(delta.z, delta.x).
|
||||
## A screen-space mouse angle is NOT a sim-space angle in the 3D scene (constant
|
||||
## ~45° skew from the map rotation plus up to ~19° ortho-pitch warp) — the ray
|
||||
## unprojection is the only correct D-054 path.
|
||||
|
||||
## Guard against a ray running (near-)parallel to the ground plane.
|
||||
const _RAY_PARALLEL_EPS := 0.000001
|
||||
|
||||
var _camera: Camera3D = null
|
||||
var _world_root: Node3D = null
|
||||
var _rig: Node3D = null
|
||||
|
||||
|
||||
func _init(camera: Camera3D, world_root: Node3D, rig: Node3D) -> void:
|
||||
_camera = camera
|
||||
_world_root = world_root
|
||||
_rig = rig
|
||||
|
||||
|
||||
## Callable target for InputMapper.facing_angle_provider. Gathers the live scene
|
||||
## state (mouse, camera ray, transforms) and defers to the pure static core.
|
||||
func get_facing_angle() -> float:
|
||||
if (
|
||||
not is_instance_valid(_camera)
|
||||
or not is_instance_valid(_world_root)
|
||||
or not is_instance_valid(_rig)
|
||||
):
|
||||
return NAN
|
||||
if not _camera.is_inside_tree() or not _world_root.is_inside_tree() or not _rig.is_inside_tree():
|
||||
return NAN
|
||||
var viewport := _camera.get_viewport()
|
||||
if viewport == null:
|
||||
return NAN
|
||||
var mouse_screen := viewport.get_mouse_position()
|
||||
return compute_facing_angle(
|
||||
_camera.project_ray_origin(mouse_screen),
|
||||
_camera.project_ray_normal(mouse_screen),
|
||||
_world_root.global_transform,
|
||||
_world_root.to_local(_rig.global_position),
|
||||
SandboxConstants.MOUSE_AIM_DEADZONE_M
|
||||
)
|
||||
|
||||
|
||||
## Pure math core (headless-testable, design §10.4): world-space mouse ray ->
|
||||
## y=0 ground-plane hit -> WorldRoot-local delta from the rig -> sim radians.
|
||||
## world_root_transform is WorldRoot's GLOBAL transform (local -> world);
|
||||
## rig_local_pos is the rig's position in WorldRoot-local metres.
|
||||
## Returns NAN when there is no meaningful aim point: ray parallel to the ground,
|
||||
## ground plane behind the ray, or hit inside the deadzone (mirrors the 2D
|
||||
## jitter guard in InputMapper._update_facing_from_mouse).
|
||||
static func compute_facing_angle(
|
||||
ray_origin: Vector3,
|
||||
ray_dir: Vector3,
|
||||
world_root_transform: Transform3D,
|
||||
rig_local_pos: Vector3,
|
||||
deadzone_m: float
|
||||
) -> float:
|
||||
# Ray -> y=0 ground plane, world space.
|
||||
if absf(ray_dir.y) < _RAY_PARALLEL_EPS:
|
||||
return NAN
|
||||
var t := -ray_origin.y / ray_dir.y
|
||||
if t < 0.0:
|
||||
return NAN
|
||||
var hit_world := ray_origin + ray_dir * t
|
||||
# Undo the 45° map rotation: WorldRoot-local axes are sim axes (SandboxSpace §2:
|
||||
# local +X = sim East, local +Z = sim South).
|
||||
var hit_local := world_root_transform.affine_inverse() * hit_world
|
||||
var delta := hit_local - rig_local_pos
|
||||
var planar := Vector2(delta.x, delta.z)
|
||||
if planar.length_squared() < deadzone_m * deadzone_m:
|
||||
return NAN
|
||||
# atan2(z, x) IS the sim convention (0 = East, +PI/2 = South) because local
|
||||
# +Z = sim +y (South, Y-down radians — server vision_cone.rs).
|
||||
return atan2(delta.z, delta.x)
|
||||
@@ -0,0 +1,117 @@
|
||||
class_name SandboxConstants
|
||||
extends RefCounted
|
||||
## T-1088 locomotion sandbox — every feel/tuning knob in one file.
|
||||
## § references point at the T-1088 final design (see ticket). Proto-production:
|
||||
## values migrate to Phase-5 homes at the T-962 gate.
|
||||
##
|
||||
## Stance move intervals are deliberately NOT duplicated here — read
|
||||
## InputMapper.MOVE_INTERVAL_MS at runtime (single source, no drift; design §13.8).
|
||||
|
||||
# -- Coordinate & scale (design §2) -------------------------------------------
|
||||
|
||||
## D-066/D-222 — THE convention: one sim TilePosition step = one 0.5 m subtile.
|
||||
## All sandbox positions and speeds are metres. See SandboxSpace for conversions.
|
||||
const SUBTILE_M := 0.5
|
||||
|
||||
# -- Greybox walls + cutaway (design §3, §8) -----------------------------------
|
||||
|
||||
## Wall height in metres — walls shorter than the ~1.7 m character read wrong.
|
||||
const WALL_H := 2.5
|
||||
## Cutaway stub height (m) camera-side walls drop to.
|
||||
const CUT_HEIGHT := 0.75
|
||||
## Cutaway half-disc radius (m) around the character.
|
||||
const CUT_RADIUS := 5.0
|
||||
## Cutaway smoothstep falloff band (m) at the disc edge.
|
||||
const CUT_BAND := 1.5
|
||||
|
||||
# -- Locomotion interpolation (design §4) --------------------------------------
|
||||
|
||||
# Stance intervals: NOT duplicated — read InputMapper.MOVE_INTERVAL_MS at runtime.
|
||||
|
||||
## Leg-speed clamp for multi-tile latest-wins deltas: close within ~one interval.
|
||||
const CATCHUP_MAX_FACTOR := 3.0
|
||||
## 5 subtiles — matches the 2D TELEPORT_DISTANCE_THRESHOLD (main.gd:3). Beyond this,
|
||||
## snap all channels (position, yaw, camera, 0.0-blend anim reset).
|
||||
const SNAP_DIST_M := 2.5
|
||||
## Walk↔idle flicker hysteresis (§4.2): is_moving stays true until at-target this long.
|
||||
const IDLE_ENTER_DELAY_S := 0.18
|
||||
|
||||
# -- Facing / turn smoothing (design §5, D-151) ---------------------------------
|
||||
|
||||
## Yaw ease rate (1/s): yaw = lerp_angle(yaw, target, 1 - exp(-TURN_SHARPNESS * delta)).
|
||||
const TURN_SHARPNESS := 14.0
|
||||
## Per-stance yaw budget clamp, degrees/second.
|
||||
const TURN_BUDGET_DEG := {
|
||||
"Sprint": 1080.0,
|
||||
"Walk": 720.0,
|
||||
"Careful": 540.0,
|
||||
"Crouch": 420.0,
|
||||
"Idle": 600.0,
|
||||
}
|
||||
|
||||
# -- Animation (design §6) -------------------------------------------------------
|
||||
|
||||
## Gait table (§6.1) — the ONLY place animation clip strings live.
|
||||
## Clip names are the imported bare names: library "", "_Loop" stripped by the glTF
|
||||
## importer, case-sensitive (design-input §2.2). Careful = Walk_Formal (D-053 stance
|
||||
## readability at ortho distance; fallback if it reads "parade march": Walk at 0.6x —
|
||||
## one table cell). Jog_Fwd is the reserve if Sprint reads too aggressive at 2.5 m/s.
|
||||
const GAIT_CLIP := {
|
||||
"Sprint": {"idle": &"Idle", "moving": &"Sprint"},
|
||||
"Walk": {"idle": &"Idle", "moving": &"Walk"},
|
||||
"Careful": {"idle": &"Idle", "moving": &"Walk_Formal"},
|
||||
"Crouch": {"idle": &"Crouch_Idle", "moving": &"Crouch_Fwd"},
|
||||
}
|
||||
|
||||
## Native clip ground speed (m/s) for cadence sync (§6.2):
|
||||
## speed_scale = clamp(rig_speed / NATIVE_MPS[clip], SPEED_SCALE_CLAMP.x, .y).
|
||||
## Initial guesses — tuned live against the DebugHud readout.
|
||||
const NATIVE_MPS := {"Walk": 1.4, "Walk_Formal": 1.2, "Sprint": 3.2, "Crouch_Fwd": 0.9}
|
||||
const SPEED_SCALE_CLAMP := Vector2(0.6, 1.8)
|
||||
|
||||
## Cross-fade seconds by transition kind (§6.3); teleport is a hard cut — a
|
||||
## cross-map jump must not smear.
|
||||
const BLEND := {
|
||||
"idle_to_gait": 0.12,
|
||||
"gait_to_idle": 0.20,
|
||||
"gait_to_gait": 0.15,
|
||||
"crouch": 0.25,
|
||||
"teleport": 0.0,
|
||||
}
|
||||
|
||||
# -- Camera (design §7; D-148, D-015, D-158) -------------------------------------
|
||||
|
||||
## Pitch presets in degrees from horizontal — D-148 preset list is authoritative;
|
||||
## the spike's -45 iso preset is struck. T-key cycles (sanctioned dev affordance).
|
||||
const CAM_PITCH_PRESETS := [-30.0, -5.0, -80.0]
|
||||
const CAM_DIST := 30.0
|
||||
const CAM_ORTHO_SIZE := 9.0
|
||||
## Exponential pivot-follow rate (1/s) — deliberately the ONLY soft layer (§0).
|
||||
const CAM_FOLLOW_RATE := 6.0
|
||||
## Preset tilt lerp rate (1/s).
|
||||
const CAM_TILT_RATE := 3.0
|
||||
## Follow lookahead knob for the tuning pass (seconds of velocity lead).
|
||||
const LOOKAHEAD_S := 0.0
|
||||
|
||||
# -- Scene (design §1.2) -----------------------------------------------------------
|
||||
|
||||
## WorldRoot yaw — the single static D-148 map-rotation Transform3D. Set once in
|
||||
## locomotion_sandbox.tscn; sim coords stay axis-aligned inside WorldRoot and the
|
||||
## camera never rotates.
|
||||
const MAP_ROTATION_DEG := 45.0
|
||||
|
||||
# -- Greybox tints (design §3.2) -----------------------------------------------------
|
||||
|
||||
## Four-state visibility tint: Color values are used as instance color directly;
|
||||
## float values multiply albedo value (85% / 70%).
|
||||
const TINTS := {
|
||||
"forward": Color.WHITE,
|
||||
"peripheral": 0.85,
|
||||
"boundary": 0.70,
|
||||
"remembered": Color(0.42, 0.45, 0.52),
|
||||
}
|
||||
|
||||
# -- Input (design §9) ---------------------------------------------------------------
|
||||
|
||||
## Ground-plane deadzone (m) for the mouse facing provider — mirrors the 2D jitter guard.
|
||||
const MOUSE_AIM_DEADZONE_M := 0.1
|
||||
@@ -0,0 +1,98 @@
|
||||
extends CanvasLayer
|
||||
## T-1088 numeric locomotion instrumentation HUD (design §10.1): tick, tick_rate,
|
||||
## stance, player tile, render speed, clip + speed_scale, snapshot age, yaw
|
||||
## target/actual, store size. Sandbox-only. Cadence sync is verifiable numerically
|
||||
## on screen, not just by eye — tuning NATIVE_MPS is a read-the-HUD job.
|
||||
##
|
||||
## All component reads go through has_method guards so the HUD works from the S1
|
||||
## skeleton and lights up ("—" -> numbers) as later slices land. Duck-typed contract:
|
||||
## PlayerRig (S4): get_render_speed() -> float (m/s),
|
||||
## get_yaw_target_rad() -> float
|
||||
## PlayerRig or root (S6): get_current_clip() -> StringName,
|
||||
## get_anim_speed_scale() -> float
|
||||
## Greybox (S3): get_store_size() -> int
|
||||
|
||||
const FONT_SIZE := 13
|
||||
|
||||
@onready var _rig: Node = get_node_or_null("../WorldRoot/PlayerRig")
|
||||
@onready var _model_root: Node3D = get_node_or_null("../WorldRoot/PlayerRig/ModelRoot")
|
||||
@onready var _greybox: Node = get_node_or_null("../WorldRoot/Greybox")
|
||||
|
||||
var _label: Label = null
|
||||
var _last_tick: int = -1
|
||||
var _last_snapshot_msec: int = -1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 10
|
||||
_label = Label.new()
|
||||
_label.name = "Readout"
|
||||
_label.position = Vector2(8, 8)
|
||||
_label.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
_label.add_theme_color_override("font_color", Color(0.85, 0.92, 1.0))
|
||||
_label.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.9))
|
||||
_label.add_theme_constant_override("outline_size", 4)
|
||||
add_child(_label)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Snapshot age: a consumed-tick change is the arrival signal (world_renderer.gd:33-36
|
||||
# tick-gate pattern). SimBridge delivery is latest-wins, so consumption time is the
|
||||
# honest age reference for interpolation debugging.
|
||||
if GameState.current_tick != _last_tick:
|
||||
_last_tick = GameState.current_tick
|
||||
_last_snapshot_msec = Time.get_ticks_msec()
|
||||
_label.text = _build_text()
|
||||
|
||||
|
||||
func _build_text() -> String:
|
||||
var lines := PackedStringArray()
|
||||
var rate := str(GameState.game_time.get("tick_rate", "—"))
|
||||
lines.append("tick %d rate %s snap age %s" % [GameState.current_tick, rate, _age_text()])
|
||||
|
||||
var tile := SandboxSpace.wire_to_tile(GameState.player_position.x, GameState.player_position.y)
|
||||
lines.append("stance %s tile (%d, %d)" % [GameState.player_stance, tile.x, tile.y])
|
||||
|
||||
var anim_sources: Array = [_rig, get_parent()]
|
||||
lines.append(
|
||||
"speed %s m/s clip %s scale %s"
|
||||
% [
|
||||
_fmt(_call_first([_rig], "get_render_speed"), "%.3f"),
|
||||
_fmt(_call_first(anim_sources, "get_current_clip"), "%s"),
|
||||
_fmt(_call_first(anim_sources, "get_anim_speed_scale"), "%.2f"),
|
||||
]
|
||||
)
|
||||
|
||||
var yaw_target: Variant = _call_first([_rig], "get_yaw_target_rad")
|
||||
if yaw_target != null:
|
||||
yaw_target = rad_to_deg(float(yaw_target))
|
||||
var yaw_actual: Variant = null
|
||||
if _model_root != null:
|
||||
yaw_actual = rad_to_deg(_model_root.rotation.y)
|
||||
lines.append(
|
||||
"yaw target %s actual %s" % [_fmt(yaw_target, "%.1f deg"), _fmt(yaw_actual, "%.1f deg")]
|
||||
)
|
||||
|
||||
lines.append("store %s tiles" % _fmt(_call_first([_greybox], "get_store_size"), "%d"))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
func _age_text() -> String:
|
||||
if _last_snapshot_msec < 0:
|
||||
return "—"
|
||||
return "%.3f s" % (float(Time.get_ticks_msec() - _last_snapshot_msec) / 1000.0)
|
||||
|
||||
|
||||
# First answer from the candidate nodes implementing `method`, else null.
|
||||
static func _call_first(nodes: Array, method: String) -> Variant:
|
||||
for node in nodes:
|
||||
if node != null and (node as Object).has_method(method):
|
||||
return (node as Object).call(method)
|
||||
return null
|
||||
|
||||
|
||||
# Format a duck-typed value; "—" until the providing component lands.
|
||||
static func _fmt(value: Variant, format: String) -> String:
|
||||
if value == null:
|
||||
return "—"
|
||||
return format % value
|
||||
@@ -0,0 +1,100 @@
|
||||
class_name SandboxSpace
|
||||
extends RefCounted
|
||||
## THE conversion authority between sim tile space and sandbox world metres
|
||||
## (T-1088 design §2). Every coordinate/angle conversion in the locomotion sandbox
|
||||
## goes through this file — nothing else converts.
|
||||
##
|
||||
## THE CONVENTION (D-066/D-222):
|
||||
## - One sim TilePosition step = one 0.5 m subtile. All sandbox positions/speeds
|
||||
## are metres.
|
||||
## - Sim +x (East) -> local +X; sim +y (South, Y-down) -> local +Z; sim z-level
|
||||
## -> Y (Gauntlet is single-level z=0 -> Y=0; no metre scale exists for z-levels
|
||||
## yet — no stair mechanic).
|
||||
## - Tile index -> world: Vector3((t.x + 0.5) * 0.5, 0, (t.y + 0.5) * 0.5).
|
||||
## - Sim facing angle theta (0=East, +PI/2=South, Y-down radians — server
|
||||
## vision_cone.rs and InputMapper.facing_angle) -> ModelRoot yaw = PI/2 - theta.
|
||||
## - D-243 "voxel = 1 m" is generation-cascade vocabulary and appears nowhere here;
|
||||
## the 1 m visual tile (D-066) exists only as major grid lines in the floor shader.
|
||||
##
|
||||
## Wire trap: live mode sends tile-center floats (N + 0.5); TestHarness sends
|
||||
## integers (N). Always floori() the wire float to recover the tile index, then
|
||||
## recompute the center — byte-identical in both modes. NEVER round().
|
||||
##
|
||||
## Facing trap: CharacterVisual.set_facing() is never called from sandbox code.
|
||||
## Its internal table (west=+90, east=-90 — character_visual.gd:184-193) assumes a
|
||||
## mirrored axis mapping (East -> -X) incompatible with this convention; using it
|
||||
## makes the model face west while walking east. The rig owns ModelRoot.rotation.y
|
||||
## exclusively, always through octant_to_yaw() below.
|
||||
|
||||
## Sim facing octant -> sim angle theta (Y-down radians: 0 = East, +PI/2 = South).
|
||||
const OCTANT_TO_SIM_ANGLE := {
|
||||
"East": 0.0,
|
||||
"Southeast": PI / 4.0,
|
||||
"South": PI / 2.0,
|
||||
"Southwest": 3.0 * PI / 4.0,
|
||||
"West": PI,
|
||||
"Northwest": -3.0 * PI / 4.0,
|
||||
"North": -PI / 2.0,
|
||||
"Northeast": -PI / 4.0,
|
||||
}
|
||||
|
||||
|
||||
## Tile index -> world metres at the subtile center (+0.25 m on each axis).
|
||||
static func tile_to_world(tile: Vector3i) -> Vector3:
|
||||
return Vector3(
|
||||
(float(tile.x) + 0.5) * SandboxConstants.SUBTILE_M,
|
||||
0.0, # Gauntlet is single-level z=0; z-level -> Y has no metre scale yet
|
||||
(float(tile.y) + 0.5) * SandboxConstants.SUBTILE_M
|
||||
)
|
||||
|
||||
|
||||
## Recover the integer tile index from wire floats. Live wire sends tile centers
|
||||
## (N + 0.5); TestHarness sends integers (N) — floori handles both identically.
|
||||
## NEVER round(): round(N + 0.5) lands on the wrong tile.
|
||||
static func wire_to_tile(wire_x: float, wire_y: float, z: int = 0) -> Vector3i:
|
||||
return Vector3i(floori(wire_x), floori(wire_y), z)
|
||||
|
||||
|
||||
## Wire floats -> world metres: floori to the tile index, then recompute the center.
|
||||
static func wire_to_world(wire_x: float, wire_y: float, z: int = 0) -> Vector3:
|
||||
return tile_to_world(wire_to_tile(wire_x, wire_y, z))
|
||||
|
||||
|
||||
## World metres -> containing tile index (inverse of tile_to_world for any point
|
||||
## inside the tile, not just the center).
|
||||
static func world_to_tile(world: Vector3, z: int = 0) -> Vector3i:
|
||||
return Vector3i(
|
||||
floori(world.x / SandboxConstants.SUBTILE_M),
|
||||
floori(world.z / SandboxConstants.SUBTILE_M),
|
||||
z
|
||||
)
|
||||
|
||||
|
||||
## Octant string -> sim angle theta. Unknown octants warn and default to North
|
||||
## (the GameState.player_facing default).
|
||||
static func octant_to_sim_angle(octant: String) -> float:
|
||||
if not OCTANT_TO_SIM_ANGLE.has(octant):
|
||||
push_warning("SandboxSpace: unknown octant '%s' — defaulting to North" % octant)
|
||||
return -PI / 2.0
|
||||
return float(OCTANT_TO_SIM_ANGLE[octant])
|
||||
|
||||
|
||||
## Sim angle theta -> ModelRoot yaw in WorldRoot-local space: yaw = PI/2 - theta,
|
||||
## wrapped to (-PI, PI] so North stays +PI. Verified against Godot +yaw semantics
|
||||
## (+90 deg yaw turns a +Z-facing model to +X): theta=PI/2 (South) -> 0 -> +Z;
|
||||
## theta=0 (East) -> +PI/2 -> +X; theta=PI (West) -> -PI/2 -> -X;
|
||||
## theta=-PI/2 (North) -> PI -> -Z.
|
||||
static func sim_angle_to_yaw(theta: float) -> float:
|
||||
var yaw := PI / 2.0 - theta
|
||||
while yaw > PI:
|
||||
yaw -= TAU
|
||||
while yaw <= -PI:
|
||||
yaw += TAU
|
||||
return yaw
|
||||
|
||||
|
||||
## Octant string -> ModelRoot yaw radians. Golden table (T-1088 design §2, verified):
|
||||
## South 0, Southeast 45, East +90, Northeast 135, North 180, Northwest -135,
|
||||
## West -90, Southwest -45 (degrees).
|
||||
static func octant_to_yaw(octant: String) -> float:
|
||||
return sim_angle_to_yaw(octant_to_sim_angle(octant))
|
||||
@@ -0,0 +1,55 @@
|
||||
shader_type spatial;
|
||||
render_mode diffuse_lambert, specular_disabled;
|
||||
|
||||
// T-1088 greybox floor (design §3.3) — sandbox-only.
|
||||
//
|
||||
// Per-instance visibility tint x world-space grid lines. The MultiMesh instance
|
||||
// color (use_colors, GreyboxWorld) arrives as the COLOR built-in and multiplies
|
||||
// albedo: white = Forward, 85% = Peripheral, 70% = BoundaryWall, dim cool grey =
|
||||
// Remembered (SandboxConstants.TINTS — §3.2).
|
||||
//
|
||||
// Grid: data is per-0.5 m subtile (D-066/D-222) but the floor must READ as 1 m
|
||||
// visual tiles (2x2 subtiles) — minor lines every 0.5 m (alpha 0.15), major
|
||||
// lines every 1.0 m (alpha 0.45). Lines are sampled in SIM space (u_sim_from_world
|
||||
// undoes the 45 deg WorldRoot rotation) so they land on tile edges; sampling raw
|
||||
// world space would draw the grid rotated 45 deg against the tiles. Continuous
|
||||
// across instances because sim space is shared. D-243's "voxel = 1 m" is
|
||||
// generation-cascade vocabulary — the 1 m visual tile exists ONLY as the major
|
||||
// lines here (§2).
|
||||
|
||||
uniform vec3 u_base_albedo : source_color = vec3(0.52, 0.54, 0.58);
|
||||
uniform vec3 u_line_albedo : source_color = vec3(0.10, 0.11, 0.14);
|
||||
uniform float u_minor_spacing_m = 0.5;
|
||||
uniform float u_major_spacing_m = 1.0;
|
||||
uniform float u_minor_alpha = 0.15;
|
||||
uniform float u_major_alpha = 0.45;
|
||||
uniform float u_line_half_width_m = 0.02;
|
||||
// World -> sim-space transform; set by GreyboxWorld from its own inverse global
|
||||
// transform so the grid rotates WITH the tiles under the WorldRoot 45 deg.
|
||||
uniform mat4 u_sim_from_world = mat4(1.0);
|
||||
|
||||
varying vec2 v_world_xz;
|
||||
|
||||
void vertex() {
|
||||
// MODEL_MATRIX folds in the per-instance MultiMesh transform (incl. WorldRoot
|
||||
// rotation); u_sim_from_world undoes the scene rotation back to sim axes.
|
||||
v_world_xz = (u_sim_from_world * MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
|
||||
}
|
||||
|
||||
// 1.0 on a grid line of the given spacing, 0.0 off it, one-fwidth AA edge.
|
||||
float line_mask(vec2 p, float spacing, float half_width) {
|
||||
vec2 dxy = abs(fract(p / spacing + 0.5) - 0.5) * spacing; // metres to nearest line
|
||||
float d = min(dxy.x, dxy.y);
|
||||
float aa = fwidth(d);
|
||||
return 1.0 - smoothstep(half_width - aa, half_width + aa, d);
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
float minor = line_mask(v_world_xz, u_minor_spacing_m, u_line_half_width_m) * u_minor_alpha;
|
||||
float major = line_mask(v_world_xz, u_major_spacing_m, u_line_half_width_m) * u_major_alpha;
|
||||
float line_a = max(minor, major); // major wins where the grids coincide
|
||||
vec3 tinted = u_base_albedo * COLOR.rgb;
|
||||
// Line color is tinted too, so remembered/peripheral tiles keep dim lines.
|
||||
ALBEDO = mix(tinted, u_line_albedo * COLOR.rgb, line_a);
|
||||
ROUGHNESS = 1.0;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
shader_type spatial;
|
||||
render_mode diffuse_lambert, specular_disabled;
|
||||
|
||||
// T-1088 greybox wall + camera-side cutaway (design §8) — technique
|
||||
// proto-production.
|
||||
//
|
||||
// COLOR-multiplied albedo (same four-state tint contract as the floor shader)
|
||||
// plus a smoothstep height cut: a half-disc of camera-side wall around the
|
||||
// character drops to a CUT_HEIGHT stub with smooth radial falloff — the
|
||||
// Xenonauts read, minus per-building logic the greybox doesn't have. The
|
||||
// cutaway cuts RENDER HEIGHT; the tint gates KNOWLEDGE — independent factors,
|
||||
// so remembered walls can be cut too (the player's camera doesn't care what the
|
||||
// character currently sees). Open box tops are acceptable greybox fidelity;
|
||||
// ghost band / dither / capping are named polish on this same shader.
|
||||
//
|
||||
// u_char_pos_xz is the rig's INTERPOLATED world XZ, pushed once per frame
|
||||
// (GreyboxWorld.set_cutaway_char_pos) — the cut zone glides with the character,
|
||||
// so spatial smoothstep becomes temporal smoothness. Parked far away by default:
|
||||
// no cut until the first push.
|
||||
//
|
||||
// Camera-side predicate: the camera has yaw 0 and looks world -Z (the diamond
|
||||
// view is WorldRoot's 45 deg rotation, never the camera's — D-148), so camera
|
||||
// ground-forward is the constant vec2(0, -1) in world space. A wall fragment
|
||||
// sits between camera and character when to_wall.y > 0; the test runs in world
|
||||
// space, so it stays correct under the WorldRoot rotation.
|
||||
//
|
||||
// Geometry uniforms default to the SandboxConstants values; GreyboxWorld pushes
|
||||
// them at material build so SandboxConstants stays the single source (§12).
|
||||
|
||||
uniform vec2 u_char_pos_xz = vec2(1000000.0, 1000000.0);
|
||||
uniform float u_wall_h = 2.5; // SandboxConstants.WALL_H
|
||||
uniform float u_cut_height = 0.75; // SandboxConstants.CUT_HEIGHT — the stub
|
||||
uniform float u_cut_radius = 5.0; // SandboxConstants.CUT_RADIUS — half-disc radius
|
||||
uniform float u_cut_band = 1.5; // SandboxConstants.CUT_BAND — radial falloff band
|
||||
uniform vec3 u_base_albedo : source_color = vec3(0.66, 0.64, 0.60);
|
||||
|
||||
varying vec3 v_world_pos;
|
||||
|
||||
void vertex() {
|
||||
// MODEL_MATRIX folds in the per-instance MultiMesh transform.
|
||||
v_world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
vec2 to_wall = v_world_pos.xz - u_char_pos_xz;
|
||||
// Soft camera-side gate: fully cut from 0.75 m on the camera side of the
|
||||
// character, fully solid from 0.25 m on the far side — no hard seam.
|
||||
float side_f = smoothstep(-0.25, 0.75, to_wall.y);
|
||||
// Radial falloff: full cut inside (radius - band), fading to none at radius.
|
||||
float rad_f = 1.0 - smoothstep(u_cut_radius - u_cut_band, u_cut_radius, length(to_wall));
|
||||
float allowed = mix(u_wall_h, u_cut_height, side_f * rad_f);
|
||||
if (v_world_pos.y > allowed) {
|
||||
discard;
|
||||
}
|
||||
ALBEDO = u_base_albedo * COLOR.rgb;
|
||||
ROUGHNESS = 1.0;
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
## LocomotionAnim gait machine tests (T-1088 design §6, §10.4).
|
||||
##
|
||||
## Two layers:
|
||||
## 1. END-TO-END CLIP GUARD — every SandboxConstants.GAIT_CLIP cell must exist in a
|
||||
## real headless CharacterVisual's get_animation_list(). play_animation() is a
|
||||
## case-sensitive exact-name search that fails with only a warning, so a typo'd
|
||||
## or renamed clip is a SILENT miss in production — this test is the tripwire,
|
||||
## end-to-end against the imported GLB, not against a constant copy.
|
||||
## 2. State-machine logic on stubs — edge-triggered transitions, the §6.3 blend
|
||||
## table, §6.2 cadence sync, phase preservation + skip_phase_seek, teleport
|
||||
## hard reset, and the gait_changed (Q-063) signal.
|
||||
class_name TestGaitTable
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const EPS := 0.000001
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Stubs — duck-typed against the §4.0 rig getters and §6.3 CharacterVisual API
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class StubRig:
|
||||
extends RefCounted
|
||||
var stance: String = "Walk"
|
||||
var is_moving: bool = false
|
||||
var current_speed: float = 0.0
|
||||
|
||||
|
||||
class StubAnimPlayer:
|
||||
extends RefCounted
|
||||
## Clip lengths mirror the verified import dump (design-input §2.2) — values only
|
||||
## matter for phase arithmetic, not for clip existence (layer 1 covers that).
|
||||
const LENGTHS := {
|
||||
"Idle": 2.5, "Walk": 1.33, "Walk_Formal": 1.33,
|
||||
"Sprint": 0.67, "Crouch_Idle": 2.93, "Crouch_Fwd": 2.0,
|
||||
}
|
||||
var speed_scale: float = 1.0
|
||||
var current_animation: String = ""
|
||||
var current_animation_position: float = 0.0
|
||||
var current_animation_length: float = 0.0
|
||||
var seeks: Array = [] # [seconds, update] per seek() call
|
||||
|
||||
func get_animation(clip_name: StringName) -> Animation:
|
||||
var anim := Animation.new()
|
||||
anim.length = float(LENGTHS.get(String(clip_name), 1.0))
|
||||
return anim
|
||||
|
||||
func seek(seconds: float, update: bool = false, _update_only: bool = false) -> void:
|
||||
seeks.append([seconds, update])
|
||||
current_animation_position = seconds
|
||||
|
||||
|
||||
class StubVisual:
|
||||
extends RefCounted
|
||||
var player := StubAnimPlayer.new()
|
||||
var plays: Array = [] # [{"name": String, "blend": float}] per play_animation call
|
||||
|
||||
func play_animation(anim_name: String, blend_time: float = -1.0) -> void:
|
||||
plays.append({"name": anim_name, "blend": blend_time})
|
||||
player.current_animation = anim_name
|
||||
player.current_animation_length = float(StubAnimPlayer.LENGTHS.get(anim_name, 1.0))
|
||||
player.current_animation_position = 0.0
|
||||
|
||||
func get_animation_player() -> StubAnimPlayer:
|
||||
return player
|
||||
|
||||
|
||||
## Machine wired to fresh stubs; returns [anim, rig, visual].
|
||||
func _make_machine() -> Array:
|
||||
var rig := StubRig.new()
|
||||
var visual := StubVisual.new()
|
||||
var anim := LocomotionAnim.new()
|
||||
anim.setup(rig, visual)
|
||||
return [anim, rig, visual]
|
||||
|
||||
|
||||
func _last_play(visual: StubVisual) -> Dictionary:
|
||||
return {} if visual.plays.is_empty() else visual.plays[-1]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. End-to-end clip guard — real headless CharacterVisual (§6.1)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_every_gait_cell_exists_in_imported_animation_list() -> void:
|
||||
# §2.1 order is load-bearing: .new() -> add_child() -> load_descriptor().
|
||||
# In-tree first, because _ready() loads the toon/outline shaders. A default
|
||||
# descriptor is enough — only the mandatory skeleton + animation library matter.
|
||||
var visual: CharacterVisual = auto_free(CharacterVisual.new())
|
||||
add_child(visual)
|
||||
visual.load_descriptor(CharacterVisualDescriptor.new())
|
||||
|
||||
var player: AnimationPlayer = visual.get_animation_player()
|
||||
assert_object(player).override_failure_message(
|
||||
"get_animation_player() must return the AnimPlayer after load_descriptor()"
|
||||
+ " — null means the skeleton or ual_standard.glb failed to load"
|
||||
).is_not_null()
|
||||
if player == null:
|
||||
return
|
||||
|
||||
var clips := player.get_animation_list()
|
||||
assert_bool(clips.is_empty()).override_failure_message(
|
||||
"imported animation list is empty — ual_standard.glb library copy failed"
|
||||
).is_false()
|
||||
for stance: String in SandboxConstants.GAIT_CLIP:
|
||||
var row: Dictionary = SandboxConstants.GAIT_CLIP[stance]
|
||||
for cell: String in row:
|
||||
var clip := String(row[cell])
|
||||
assert_bool(clips.has(clip)).override_failure_message(
|
||||
(
|
||||
"GAIT_CLIP[%s][%s] = '%s' not in the imported animation list —"
|
||||
+ " play_animation() would miss SILENTLY (case-sensitive; library"
|
||||
+ " '' bare names, '_Loop' stripped by the importer). List: %s"
|
||||
) % [stance, cell, clip, clips]
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gait_table_covers_all_four_stances() -> void:
|
||||
# The wire stance enum (D-053/D-055): every variant must have both cells.
|
||||
for stance: String in ["Sprint", "Walk", "Careful", "Crouch"]:
|
||||
assert_bool(SandboxConstants.GAIT_CLIP.has(stance)).override_failure_message(
|
||||
"GAIT_CLIP missing wire stance '%s'" % stance
|
||||
).is_true()
|
||||
var row: Dictionary = SandboxConstants.GAIT_CLIP[stance]
|
||||
assert_bool(row.has("idle") and row.has("moving")).override_failure_message(
|
||||
"GAIT_CLIP[%s] must have both 'idle' and 'moving' cells" % stance
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2. Pure gait() lookup (§6.1)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_gait_returns_table_cells() -> void:
|
||||
assert_str(String(LocomotionAnim.gait("Walk", false))).is_equal("Idle")
|
||||
assert_str(String(LocomotionAnim.gait("Walk", true))).is_equal("Walk")
|
||||
assert_str(String(LocomotionAnim.gait("Careful", true))).is_equal("Walk_Formal")
|
||||
assert_str(String(LocomotionAnim.gait("Sprint", true))).is_equal("Sprint")
|
||||
assert_str(String(LocomotionAnim.gait("Crouch", false))).is_equal("Crouch_Idle")
|
||||
assert_str(String(LocomotionAnim.gait("Crouch", true))).is_equal("Crouch_Fwd")
|
||||
|
||||
|
||||
func test_gait_unknown_stance_falls_back_to_walk_row() -> void:
|
||||
# Mirrors the wire default (player_stance serde-defaults to Walk).
|
||||
assert_str(String(LocomotionAnim.gait("Prone", true))).is_equal("Walk")
|
||||
assert_str(String(LocomotionAnim.gait("Prone", false))).is_equal("Idle")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3. Blend table (§6.3) — pure blend_for
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_blend_idle_to_gait() -> void:
|
||||
var b := LocomotionAnim.blend_for(&"Idle", &"Walk", false, true)
|
||||
assert_float(b).is_equal_approx(SandboxConstants.BLEND["idle_to_gait"], EPS)
|
||||
|
||||
|
||||
func test_blend_gait_to_idle() -> void:
|
||||
var b := LocomotionAnim.blend_for(&"Walk", &"Idle", true, false)
|
||||
assert_float(b).is_equal_approx(SandboxConstants.BLEND["gait_to_idle"], EPS)
|
||||
|
||||
|
||||
func test_blend_gait_to_gait() -> void:
|
||||
var b := LocomotionAnim.blend_for(&"Walk", &"Sprint", true, true)
|
||||
assert_float(b).is_equal_approx(SandboxConstants.BLEND["gait_to_gait"], EPS)
|
||||
|
||||
|
||||
func test_blend_crouch_overrides_all_edges() -> void:
|
||||
# "<->Crouch_*" takes the crouch blend regardless of the idle/gait edge kind.
|
||||
var crouch: float = SandboxConstants.BLEND["crouch"]
|
||||
assert_float(LocomotionAnim.blend_for(&"Walk", &"Crouch_Fwd", true, true)) \
|
||||
.is_equal_approx(crouch, EPS)
|
||||
assert_float(LocomotionAnim.blend_for(&"Crouch_Fwd", &"Walk", true, true)) \
|
||||
.is_equal_approx(crouch, EPS)
|
||||
assert_float(LocomotionAnim.blend_for(&"Idle", &"Crouch_Idle", false, false)) \
|
||||
.is_equal_approx(crouch, EPS)
|
||||
assert_float(LocomotionAnim.blend_for(&"Crouch_Idle", &"Crouch_Fwd", false, true)) \
|
||||
.is_equal_approx(crouch, EPS)
|
||||
assert_float(LocomotionAnim.blend_for(&"Crouch_Fwd", &"Crouch_Idle", true, false)) \
|
||||
.is_equal_approx(crouch, EPS)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4. Edge-triggered transitions on the stubbed player
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_first_update_plays_idle_with_hard_cut() -> void:
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var visual: StubVisual = m[2]
|
||||
anim.update(0.016)
|
||||
assert_int(visual.plays.size()).is_equal(1)
|
||||
assert_str(_last_play(visual)["name"]).is_equal("Idle")
|
||||
# First-ever play: -1.0 rides play_animation's default hard-cut path.
|
||||
assert_float(_last_play(visual)["blend"]).is_equal_approx(-1.0, EPS)
|
||||
|
||||
|
||||
func test_unchanged_state_never_retriggers_play() -> void:
|
||||
# Loops never restart mid-cycle: same state across frames = exactly one play call.
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var visual: StubVisual = m[2]
|
||||
for i in 5:
|
||||
anim.update(0.016)
|
||||
assert_int(visual.plays.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_idle_to_walk_uses_idle_to_gait_blend() -> void:
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
anim.update(0.016) # settle into Idle
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25
|
||||
anim.update(0.016)
|
||||
assert_str(_last_play(visual)["name"]).is_equal("Walk")
|
||||
assert_float(_last_play(visual)["blend"]) \
|
||||
.is_equal_approx(SandboxConstants.BLEND["idle_to_gait"], EPS)
|
||||
|
||||
|
||||
func test_walk_to_idle_uses_gait_to_idle_blend() -> void:
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25
|
||||
anim.update(0.016)
|
||||
rig.is_moving = false
|
||||
rig.current_speed = 0.0
|
||||
anim.update(0.016)
|
||||
assert_str(_last_play(visual)["name"]).is_equal("Idle")
|
||||
assert_float(_last_play(visual)["blend"]) \
|
||||
.is_equal_approx(SandboxConstants.BLEND["gait_to_idle"], EPS)
|
||||
|
||||
|
||||
func test_stance_toggle_while_idle_keeps_shared_idle_clip() -> void:
|
||||
# Walk-idle and Sprint-idle share the Idle cell — the clip identity is the edge,
|
||||
# so no re-play and no gait_changed re-emit on the stance flip.
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
anim.update(0.016)
|
||||
rig.stance = "Sprint"
|
||||
anim.update(0.016)
|
||||
assert_int(visual.plays.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_gait_changed_emitted_once_per_edge() -> void:
|
||||
# Q-063 seam: one emission per transition, carrying the clip now playing.
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var emitted: Array = []
|
||||
anim.gait_changed.connect(func(clip: StringName) -> void: emitted.append(String(clip)))
|
||||
anim.update(0.016) # -> Idle
|
||||
anim.update(0.016) # no edge
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25
|
||||
anim.update(0.016) # -> Walk
|
||||
anim.update(0.016) # no edge
|
||||
assert_array(emitted).is_equal(["Idle", "Walk"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 5. Cadence sync (§6.2)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_speed_scale_matches_speed_over_native_mps() -> void:
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25 # Walk cardinal leg: 0.5 m / 0.4 s
|
||||
anim.update(0.016)
|
||||
assert_float(visual.player.speed_scale) \
|
||||
.is_equal_approx(1.25 / SandboxConstants.NATIVE_MPS["Walk"], EPS)
|
||||
|
||||
|
||||
func test_speed_scale_clamped_at_catchup_burst() -> void:
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 10.0 # 3x catch-up burst far beyond native
|
||||
anim.update(0.016)
|
||||
assert_float(visual.player.speed_scale) \
|
||||
.is_equal_approx(SandboxConstants.SPEED_SCALE_CLAMP.y, EPS)
|
||||
|
||||
|
||||
func test_speed_scale_clamped_at_floor() -> void:
|
||||
# Hysteresis window: at-target (speed 0) but still "moving" — clamp floor, not 0.
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 0.0
|
||||
anim.update(0.016)
|
||||
assert_float(visual.player.speed_scale) \
|
||||
.is_equal_approx(SandboxConstants.SPEED_SCALE_CLAMP.x, EPS)
|
||||
|
||||
|
||||
func test_idle_runs_at_native_rate() -> void:
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 2.5
|
||||
anim.update(0.016)
|
||||
rig.is_moving = false
|
||||
rig.current_speed = 0.0
|
||||
anim.update(0.016)
|
||||
assert_float(visual.player.speed_scale).is_equal_approx(1.0, EPS)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 6. gait<->gait phase preservation (§6.3)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_gait_to_gait_preserves_phase() -> void:
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25
|
||||
anim.update(0.016) # -> Walk
|
||||
# Mid-stride: half way through the Walk loop.
|
||||
visual.player.current_animation_position = 0.5 * visual.player.current_animation_length
|
||||
rig.stance = "Sprint"
|
||||
rig.current_speed = 2.5
|
||||
anim.update(0.016) # -> Sprint, gait<->gait
|
||||
assert_str(_last_play(visual)["name"]).is_equal("Sprint")
|
||||
assert_int(visual.player.seeks.size()).is_equal(1)
|
||||
# phase 0.5 into Sprint's 0.67 s loop; update=false keeps the crossfade pose.
|
||||
assert_float(visual.player.seeks[0][0]) \
|
||||
.is_equal_approx(0.5 * StubAnimPlayer.LENGTHS["Sprint"], EPS)
|
||||
assert_bool(visual.player.seeks[0][1]).is_false()
|
||||
|
||||
|
||||
func test_skip_phase_seek_flag_disables_the_seek() -> void:
|
||||
# §6.3 caveat fallback: if seek-during-blend cancels the crossfade in the live
|
||||
# scene, the flag drops the seek and the 0.15 s blend masks the resync.
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
anim.skip_phase_seek = true
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25
|
||||
anim.update(0.016)
|
||||
visual.player.current_animation_position = 0.5 * visual.player.current_animation_length
|
||||
rig.stance = "Sprint"
|
||||
rig.current_speed = 2.5
|
||||
anim.update(0.016)
|
||||
assert_str(_last_play(visual)["name"]).is_equal("Sprint")
|
||||
assert_int(visual.player.seeks.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_idle_transitions_do_not_phase_seek() -> void:
|
||||
# Phase preservation is gait<->gait only — an idle edge starts the clip normally.
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
anim.update(0.016) # -> Idle
|
||||
visual.player.current_animation_position = 1.0
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25
|
||||
anim.update(0.016) # Idle -> Walk
|
||||
assert_int(visual.player.seeks.size()).is_equal(0)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 7. Teleport hard reset (§6.3)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_teleport_replays_with_zero_blend_and_rewinds() -> void:
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25
|
||||
anim.update(0.016) # -> Walk, mid-session
|
||||
visual.player.current_animation_position = 0.7
|
||||
rig.is_moving = false # rig snapped at the teleport target
|
||||
rig.current_speed = 0.0
|
||||
anim.notify_teleport()
|
||||
assert_str(_last_play(visual)["name"]).is_equal("Idle")
|
||||
assert_float(_last_play(visual)["blend"]) \
|
||||
.is_equal_approx(SandboxConstants.BLEND["teleport"], EPS)
|
||||
# play() on an already-current clip does not rewind — the reset must seek 0.
|
||||
assert_int(visual.player.seeks.size()).is_equal(1)
|
||||
assert_float(visual.player.seeks[0][0]).is_equal_approx(0.0, EPS)
|
||||
assert_float(visual.player.speed_scale).is_equal_approx(1.0, EPS)
|
||||
|
||||
|
||||
func test_setup_connects_rig_teleported_signal_when_present() -> void:
|
||||
# The rig contract (§4.0/§7) emits `teleported(pos_m)`; setup() auto-wires the
|
||||
# reset. The stub signal signature mirrors locomotion_rig.gd:42.
|
||||
var rig := SignallingStubRig.new()
|
||||
var visual := StubVisual.new()
|
||||
var anim := LocomotionAnim.new()
|
||||
anim.setup(rig, visual)
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25
|
||||
anim.update(0.016)
|
||||
rig.is_moving = false
|
||||
rig.current_speed = 0.0
|
||||
rig.teleported.emit(Vector3(25.25, 0.0, 29.25))
|
||||
assert_str(_last_play(visual)["name"]).is_equal("Idle")
|
||||
assert_float(_last_play(visual)["blend"]) \
|
||||
.is_equal_approx(SandboxConstants.BLEND["teleport"], EPS)
|
||||
|
||||
|
||||
class SignallingStubRig:
|
||||
extends RefCounted
|
||||
signal teleported(pos_m: Vector3)
|
||||
var stance: String = "Walk"
|
||||
var is_moving: bool = false
|
||||
var current_speed: float = 0.0
|
||||
@@ -0,0 +1,264 @@
|
||||
## Greybox tile store contract (T-1088 design §3, §10.4): accumulation across
|
||||
## LOS-filtered snapshots, never-evict / last-observation-wins, and the
|
||||
## four-state visibility flips — on synthetic GameState.visible_tiles dicts,
|
||||
## plus a fixture replay through the full decode pipeline
|
||||
## (tests/fixtures/gauntlet/*.msgpack -> Protocol.decode_snapshot() ->
|
||||
## GameState.apply_snapshot() -> Store), per design-input §1.6.
|
||||
##
|
||||
## Headless by design: only the Store inner class (RefCounted) is exercised —
|
||||
## the GreyboxWorld painter node (MultiMesh writes, shaders) is §10.1/§10.2's
|
||||
## live/visual job.
|
||||
class_name TestGreyboxStore
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const Store := GreyboxWorld.Store
|
||||
|
||||
## Repo-relative fixture dir (outside res:// — resolved via globalize, the
|
||||
## visual_capture.gd:177-179 pattern). Regenerate: make fixtures-gauntlet.
|
||||
const FIXTURE_DIR := "tests/fixtures/gauntlet"
|
||||
|
||||
|
||||
## Live-wire dict shape after the snapshot_handler.gd:52-64 merge:
|
||||
## {x, y, z, visibility, type} with type already lowercased by protocol.gd.
|
||||
func _tile(x: int, y: int, type: String, visibility: String = "Forward") -> Dictionary:
|
||||
return {"x": x, "y": y, "z": 0, "visibility": visibility, "type": type}
|
||||
|
||||
|
||||
## TestHarness "tiles" shape — no visibility key (design-input §1.5).
|
||||
func _harness_tile(x: int, y: int, type: String) -> Dictionary:
|
||||
return {"x": x, "y": y, "z": 0, "type": type}
|
||||
|
||||
|
||||
# -- accumulation + never-evict (§3.1) ------------------------------------------
|
||||
|
||||
|
||||
func test_ingest_accumulates_across_snapshots() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest([_tile(1, 1, "floor"), _tile(2, 1, "wall")])
|
||||
store.ingest([_tile(3, 1, "floor"), _tile(4, 1, "floor")])
|
||||
assert_int(store.size()).is_equal(4)
|
||||
assert_bool(store.has_tile(Vector3i(1, 1, 0))).is_true()
|
||||
assert_bool(store.has_tile(Vector3i(4, 1, 0))).is_true()
|
||||
|
||||
|
||||
func test_added_reported_once_then_diff_stays_empty() -> void:
|
||||
var store := Store.new()
|
||||
var first: Dictionary = store.ingest([_tile(1, 1, "floor")])
|
||||
var second: Dictionary = store.ingest([_tile(1, 1, "floor")])
|
||||
assert_array(first["added"]).contains_exactly([Vector3i(1, 1, 0)])
|
||||
assert_array(second["added"]).is_empty()
|
||||
assert_array(second["rekinded"]).is_empty()
|
||||
assert_array(second["recolored"]).is_empty()
|
||||
|
||||
|
||||
func test_never_evicts() -> void:
|
||||
# Walked away — LOS goes empty for many snapshots; the tile stays known.
|
||||
var store := Store.new()
|
||||
store.ingest([_tile(5, 5, "wall")])
|
||||
for i in 10:
|
||||
store.ingest([])
|
||||
assert_int(store.size()).is_equal(1)
|
||||
assert_bool(store.has_tile(Vector3i(5, 5, 0))).is_true()
|
||||
assert_int(store.kind_of(Vector3i(5, 5, 0))).is_equal(Store.Kind.WALL)
|
||||
|
||||
|
||||
func test_kinds_parse_and_doors_collapse_to_floor() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest(
|
||||
[_tile(1, 0, "floor"), _tile(2, 0, "wall"), _tile(3, 0, "door"), _tile(4, 0, "object")]
|
||||
)
|
||||
assert_int(store.kind_of(Vector3i(1, 0, 0))).is_equal(Store.Kind.FLOOR)
|
||||
assert_int(store.kind_of(Vector3i(2, 0, 0))).is_equal(Store.Kind.WALL)
|
||||
# Wire tile_kind is walkability-derived (query.rs:78-82); doors are entities
|
||||
# on the wire — Door/Object collapse to FLOOR (§3.1).
|
||||
assert_int(store.kind_of(Vector3i(3, 0, 0))).is_equal(Store.Kind.FLOOR)
|
||||
assert_int(store.kind_of(Vector3i(4, 0, 0))).is_equal(Store.Kind.FLOOR)
|
||||
|
||||
|
||||
func test_last_observation_wins_kind_flip() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest([_tile(7, 7, "wall")])
|
||||
var diff: Dictionary = store.ingest([_tile(7, 7, "floor")])
|
||||
assert_int(store.kind_of(Vector3i(7, 7, 0))).is_equal(Store.Kind.FLOOR)
|
||||
assert_array(diff["rekinded"]).contains_exactly([Vector3i(7, 7, 0)])
|
||||
assert_array(diff["added"]).is_empty()
|
||||
assert_int(store.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_z_level_distinguishes_tiles() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest(
|
||||
[
|
||||
{"x": 1, "y": 1, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 1, "y": 1, "z": 1, "visibility": "Forward", "type": "wall"},
|
||||
]
|
||||
)
|
||||
assert_int(store.size()).is_equal(2)
|
||||
assert_int(store.kind_of(Vector3i(1, 1, 0))).is_equal(Store.Kind.FLOOR)
|
||||
assert_int(store.kind_of(Vector3i(1, 1, 1))).is_equal(Store.Kind.WALL)
|
||||
|
||||
|
||||
func test_malformed_entries_are_skipped() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest([42, {"y": 3}, _tile(1, 1, "floor")])
|
||||
assert_int(store.size()).is_equal(1)
|
||||
|
||||
|
||||
# -- four-state visibility (§3.2) -------------------------------------------------
|
||||
|
||||
|
||||
func test_visibility_states_parse() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest(
|
||||
[
|
||||
_tile(1, 0, "floor", "Forward"),
|
||||
_tile(2, 0, "floor", "Peripheral"),
|
||||
_tile(3, 0, "wall", "BoundaryWall"),
|
||||
]
|
||||
)
|
||||
assert_int(store.state_of(Vector3i(1, 0, 0))).is_equal(Store.Vis.FORWARD)
|
||||
assert_int(store.state_of(Vector3i(2, 0, 0))).is_equal(Store.Vis.PERIPHERAL)
|
||||
assert_int(store.state_of(Vector3i(3, 0, 0))).is_equal(Store.Vis.BOUNDARY_WALL)
|
||||
|
||||
|
||||
func test_harness_tiles_without_visibility_read_forward() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest([_harness_tile(1, 1, "floor")])
|
||||
assert_int(store.state_of(Vector3i(1, 1, 0))).is_equal(Store.Vis.FORWARD)
|
||||
|
||||
|
||||
func test_out_of_sight_flips_to_remembered_exactly_once() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest([_tile(1, 1, "floor")])
|
||||
var leave: Dictionary = store.ingest([])
|
||||
assert_int(store.state_of(Vector3i(1, 1, 0))).is_equal(Store.Vis.REMEMBERED)
|
||||
assert_array(leave["recolored"]).contains_exactly([Vector3i(1, 1, 0)])
|
||||
# Still-remembered tiles must not re-emit the flip on later snapshots —
|
||||
# diff-only painting depends on it (§3.1, no full-buffer rebuilds).
|
||||
var later: Dictionary = store.ingest([])
|
||||
assert_array(later["recolored"]).is_empty()
|
||||
|
||||
|
||||
func test_four_state_flip_sequence() -> void:
|
||||
# Forward -> Peripheral -> BoundaryWall -> (absent) Remembered -> Forward,
|
||||
# each flip emitted as exactly one recolor — never a re-add.
|
||||
var store := Store.new()
|
||||
var key := Vector3i(9, 9, 0)
|
||||
store.ingest([_tile(9, 9, "wall", "Forward")])
|
||||
assert_int(store.state_of(key)).is_equal(Store.Vis.FORWARD)
|
||||
var to_peripheral: Dictionary = store.ingest([_tile(9, 9, "wall", "Peripheral")])
|
||||
assert_int(store.state_of(key)).is_equal(Store.Vis.PERIPHERAL)
|
||||
assert_array(to_peripheral["recolored"]).contains_exactly([key])
|
||||
var to_boundary: Dictionary = store.ingest([_tile(9, 9, "wall", "BoundaryWall")])
|
||||
assert_int(store.state_of(key)).is_equal(Store.Vis.BOUNDARY_WALL)
|
||||
assert_array(to_boundary["recolored"]).contains_exactly([key])
|
||||
var to_remembered: Dictionary = store.ingest([])
|
||||
assert_int(store.state_of(key)).is_equal(Store.Vis.REMEMBERED)
|
||||
assert_array(to_remembered["recolored"]).contains_exactly([key])
|
||||
var back: Dictionary = store.ingest([_tile(9, 9, "wall", "Forward")])
|
||||
assert_int(store.state_of(key)).is_equal(Store.Vis.FORWARD)
|
||||
assert_array(back["recolored"]).contains_exactly([key])
|
||||
assert_array(back["added"]).is_empty()
|
||||
assert_int(store.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_never_seen_is_the_void() -> void:
|
||||
# Never-seen tiles have no state — nothing rendered; perception is upstream,
|
||||
# server-enforced (§3.2).
|
||||
var store := Store.new()
|
||||
store.ingest([_tile(1, 1, "floor")])
|
||||
assert_bool(store.has_tile(Vector3i(99, 99, 0))).is_false()
|
||||
assert_int(store.state_of(Vector3i(99, 99, 0))).is_equal(-1)
|
||||
assert_int(store.kind_of(Vector3i(99, 99, 0))).is_equal(-1)
|
||||
|
||||
|
||||
# -- to_dict() persistence seam (design §0 scope fence) ----------------------------
|
||||
|
||||
|
||||
func test_to_dict_exports_knowledge_copy() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest([_tile(1, 1, "floor"), _tile(2, 1, "wall")])
|
||||
var exported: Dictionary = store.to_dict()
|
||||
assert_int(exported.size()).is_equal(2)
|
||||
assert_int(exported[Vector3i(1, 1, 0)]).is_equal(Store.Kind.FLOOR)
|
||||
assert_int(exported[Vector3i(2, 1, 0)]).is_equal(Store.Kind.WALL)
|
||||
# A copy — the seam must not expose live store state.
|
||||
exported.erase(Vector3i(1, 1, 0))
|
||||
assert_int(store.size()).is_equal(2)
|
||||
|
||||
|
||||
# -- fixture replay (design §10.4; full pipeline per design-input §1.6) -------------
|
||||
|
||||
|
||||
func test_fixture_replay_hub_spawn_then_movement() -> void:
|
||||
# Fixture bytes -> Protocol.decode_snapshot() (real wire decode incl. the
|
||||
# tile_kind -> type mapping) -> GameState.apply_snapshot() (wire-spelling
|
||||
# merge) -> Store — the exact path the live sandbox runs. Expectations are
|
||||
# computed from the decoded arrays, not hardcoded, so fixture regeneration
|
||||
# cannot silently skew the assertions.
|
||||
var spawn: Dictionary = _decode_fixture("hub_spawn")
|
||||
var moved: Dictionary = _decode_fixture("hub_after_movement")
|
||||
var spawn_keys := _key_set(spawn["visible_tiles"])
|
||||
var moved_keys := _key_set(moved["visible_tiles"])
|
||||
assert_bool(spawn_keys.is_empty()).is_false()
|
||||
assert_bool(moved_keys.is_empty()).is_false()
|
||||
|
||||
var store := Store.new()
|
||||
GameState.apply_snapshot(spawn)
|
||||
var first: Dictionary = store.ingest(GameState.visible_tiles)
|
||||
assert_int(store.size()).is_equal(spawn_keys.size())
|
||||
assert_int((first["added"] as Array).size()).is_equal(spawn_keys.size())
|
||||
# The Hub spawn frame discloses both kinds — walls fringe the floor cone.
|
||||
assert_bool(_contains_kind(store, spawn_keys, Store.Kind.FLOOR)).is_true()
|
||||
assert_bool(_contains_kind(store, spawn_keys, Store.Kind.WALL)).is_true()
|
||||
# The Hub spawn tile (50, 58) itself is a disclosed floor (design-input §1.6).
|
||||
assert_int(store.kind_of(Vector3i(50, 58, 0))).is_equal(Store.Kind.FLOOR)
|
||||
|
||||
GameState.apply_snapshot(moved)
|
||||
store.ingest(GameState.visible_tiles)
|
||||
# Accumulation: the union of both frames, nothing evicted.
|
||||
var union := spawn_keys.duplicate()
|
||||
union.merge(moved_keys)
|
||||
assert_int(store.size()).is_equal(union.size())
|
||||
# Every tile of the moved frame is visible now (never REMEMBERED)...
|
||||
var visible_marked_remembered := 0
|
||||
for key: Vector3i in moved_keys:
|
||||
if store.state_of(key) == Store.Vis.REMEMBERED:
|
||||
visible_marked_remembered += 1
|
||||
assert_int(visible_marked_remembered).is_equal(0)
|
||||
# ...and every spawn-only tile has dimmed to REMEMBERED.
|
||||
var stale_not_remembered := 0
|
||||
for key: Vector3i in spawn_keys:
|
||||
if not moved_keys.has(key) and store.state_of(key) != Store.Vis.REMEMBERED:
|
||||
stale_not_remembered += 1
|
||||
assert_int(stale_not_remembered).is_equal(0)
|
||||
|
||||
|
||||
func _decode_fixture(fixture_name: String) -> Dictionary:
|
||||
# Fixtures live outside res:// — resolve the repo root from the project dir
|
||||
# (pattern: visual_capture.gd:177-179).
|
||||
var repo_root := ProjectSettings.globalize_path("res://").rstrip("/").get_base_dir()
|
||||
var path := repo_root.path_join(FIXTURE_DIR).path_join(fixture_name + ".msgpack")
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
assert_that(file).is_not_null().override_failure_message(
|
||||
"Gauntlet fixture missing: %s — run 'make fixtures-gauntlet'" % path
|
||||
)
|
||||
var bytes := file.get_buffer(file.get_length())
|
||||
file.close()
|
||||
var snapshot: Variant = Protocol.decode_snapshot(bytes)
|
||||
assert_bool(snapshot is Dictionary).is_true()
|
||||
return snapshot if snapshot is Dictionary else {}
|
||||
|
||||
|
||||
func _key_set(tiles: Array) -> Dictionary:
|
||||
var keys: Dictionary = {}
|
||||
for tile: Dictionary in tiles:
|
||||
keys[Vector3i(int(tile["x"]), int(tile["y"]), int(tile["z"]))] = true
|
||||
return keys
|
||||
|
||||
|
||||
func _contains_kind(store: GreyboxWorld.Store, keys: Dictionary, kind: int) -> bool:
|
||||
for key: Vector3i in keys:
|
||||
if store.kind_of(key) == kind:
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1,237 @@
|
||||
## T-1088 (design §9, §10.4): the InputMapper facing_angle_provider seam and the
|
||||
## sandbox mouse-aim provider's pure angle math.
|
||||
##
|
||||
## The provider math is tested through the static
|
||||
## SandboxMouseAimProvider.compute_facing_angle() with synthetic rays/transforms —
|
||||
## no viewport or camera needed headless. The seam tests drive the InputMapper
|
||||
## autoload directly; the legacy 2D canvas-transform path is made deterministic by
|
||||
## positioning GameState.player_position relative to the CURRENT mouse position
|
||||
## (no assumption about where the headless mouse sits).
|
||||
class_name TestLocomotionInput
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MouseAimProvider := preload("res://scripts/sandbox/mouse_aim_provider.gd")
|
||||
|
||||
const EPS := 0.000001
|
||||
## Deadzone used by the synthetic-math tests (mirrors SandboxConstants.MOUSE_AIM_DEADZONE_M).
|
||||
const DEADZONE := 0.1
|
||||
## Ray pointing straight down at the ground plane.
|
||||
const DOWN := Vector3(0.0, -1.0, 0.0)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
InputMapper.facing_angle_provider = Callable()
|
||||
InputMapper.reset_facing_state()
|
||||
GameState.player_position = Vector2.ZERO
|
||||
|
||||
|
||||
# -- compute_facing_angle: cardinal/diagonal directions (identity WorldRoot) --------
|
||||
|
||||
|
||||
func test_math_hit_east_of_rig_is_zero() -> void:
|
||||
# Straight-down ray 1 m east (+X) of the rig -> sim angle 0 (East).
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(1.0, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_math_hit_south_of_rig_is_plus_half_pi() -> void:
|
||||
# Local +Z = sim South (Y-down radians): hit at +Z -> +PI/2.
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.0, 10.0, 1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_math_hit_west_of_rig_is_pi() -> void:
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(-1.0, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(PI, EPS)
|
||||
|
||||
|
||||
func test_math_hit_north_of_rig_is_minus_half_pi() -> void:
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.0, 10.0, -1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(-PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_math_hit_southeast_of_rig_is_quarter_pi() -> void:
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(1.0, 10.0, 1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(PI / 4.0, EPS)
|
||||
|
||||
|
||||
# -- compute_facing_angle: WorldRoot transform is undone -----------------------------
|
||||
|
||||
|
||||
func test_math_world_rotation_undone() -> void:
|
||||
# The D-148 45° map rotation must NOT skew the sim angle. A world-space hit at
|
||||
# the rotated image of local (1,0,0) must still read as East (0.0).
|
||||
var xf := Transform3D(Basis(Vector3.UP, deg_to_rad(45.0)), Vector3.ZERO)
|
||||
var hit_world := xf * Vector3(1.0, 0.0, 0.0)
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
hit_world + Vector3(0.0, 10.0, 0.0), DOWN, xf, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_math_world_translation_undone() -> void:
|
||||
# A translated WorldRoot: hit at the world image of local (0,0,1) -> South.
|
||||
var xf := Transform3D(Basis.IDENTITY, Vector3(10.0, 0.0, -3.0))
|
||||
var hit_world := xf * Vector3(0.0, 0.0, 1.0)
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
hit_world + Vector3(0.0, 10.0, 0.0), DOWN, xf, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_math_rig_offset_and_rotation_compose() -> void:
|
||||
# Rotated WorldRoot + rig away from the origin: hit at the world image of the
|
||||
# local point 1 m east of the rig -> East, regardless of either offset.
|
||||
var xf := Transform3D(Basis(Vector3.UP, deg_to_rad(45.0)), Vector3(5.0, 0.0, 7.0))
|
||||
var rig_local := Vector3(2.0, 0.0, 3.0)
|
||||
var hit_world := xf * (rig_local + Vector3(1.0, 0.0, 0.0))
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
hit_world + Vector3(0.0, 10.0, 0.0), DOWN, xf, rig_local, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_math_oblique_ray_like_ortho_camera() -> void:
|
||||
# A -30°-pitch-style oblique ray (not straight down) still lands on y=0
|
||||
# correctly: origin (0, 5, 8.66), dir (0, -0.5, -0.866) -> hit (0, 0, 0);
|
||||
# rig 1 m west of the hit -> East.
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.0, 5.0, 8.66),
|
||||
Vector3(0.0, -0.5, -0.866),
|
||||
Transform3D.IDENTITY,
|
||||
Vector3(-1.0, 0.0, 0.0),
|
||||
DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
# -- compute_facing_angle: NAN cases (deadzone + degenerate rays) --------------------
|
||||
|
||||
|
||||
func test_math_inside_deadzone_is_nan() -> void:
|
||||
# 0.05 m from the rig < 0.1 m deadzone -> NAN (no update; the 2D jitter-guard mirror).
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.05, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_bool(is_nan(a)).is_true()
|
||||
|
||||
|
||||
func test_math_just_outside_deadzone_is_finite() -> void:
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.2, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_bool(is_finite(a)).is_true()
|
||||
assert_float(a).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_math_ray_parallel_to_ground_is_nan() -> void:
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.0, 10.0, 0.0),
|
||||
Vector3(1.0, 0.0, 0.0),
|
||||
Transform3D.IDENTITY,
|
||||
Vector3.ZERO,
|
||||
DEADZONE
|
||||
)
|
||||
assert_bool(is_nan(a)).is_true()
|
||||
|
||||
|
||||
func test_math_ground_plane_behind_ray_is_nan() -> void:
|
||||
# Origin below the plane, ray pointing further down -> t < 0 -> NAN.
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.0, -5.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_bool(is_nan(a)).is_true()
|
||||
|
||||
|
||||
func test_math_angle_feeds_octant_snap() -> void:
|
||||
# The provider's output is consumed by InputMapper._angle_to_octant — a
|
||||
# southeast hit must snap to the "Southeast" wire octant.
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(1.0, 10.0, 1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_str(InputMapper._angle_to_octant(a)).is_equal("Southeast")
|
||||
|
||||
|
||||
# -- provider instance guards (no viewport needed) ------------------------------------
|
||||
|
||||
|
||||
func test_provider_with_null_nodes_returns_nan() -> void:
|
||||
var provider := MouseAimProvider.new(null, null, null)
|
||||
assert_bool(is_nan(provider.get_facing_angle())).is_true()
|
||||
|
||||
|
||||
func test_provider_with_out_of_tree_nodes_returns_nan() -> void:
|
||||
var camera: Camera3D = auto_free(Camera3D.new())
|
||||
var world_root: Node3D = auto_free(Node3D.new())
|
||||
var rig: Node3D = auto_free(Node3D.new())
|
||||
var provider := MouseAimProvider.new(camera, world_root, rig)
|
||||
assert_bool(is_nan(provider.get_facing_angle())).is_true()
|
||||
|
||||
|
||||
# -- InputMapper seam ------------------------------------------------------------------
|
||||
|
||||
|
||||
func test_seam_finite_provider_updates_facing_and_octant() -> void:
|
||||
InputMapper.facing_angle_provider = func() -> float: return PI / 4.0
|
||||
InputMapper._update_facing_from_mouse()
|
||||
assert_float(InputMapper.facing_angle).is_equal_approx(PI / 4.0, EPS)
|
||||
assert_str(InputMapper.facing_octant).is_equal("Southeast")
|
||||
|
||||
|
||||
func test_seam_nan_provider_leaves_facing_and_blocks_2d_path() -> void:
|
||||
# Arrange the 2D path so it WOULD rewrite facing if it ran (player 100 px
|
||||
# away from the mouse on screen), then install a NAN provider: the early
|
||||
# return must both skip the update and block the 2D path entirely.
|
||||
_place_player_at_screen_delta(Vector2(100.0, 100.0))
|
||||
InputMapper.facing_angle = 0.42
|
||||
InputMapper.facing_octant = "East"
|
||||
InputMapper.facing_angle_provider = func() -> float: return NAN
|
||||
InputMapper._update_facing_from_mouse()
|
||||
assert_float(InputMapper.facing_angle).is_equal_approx(0.42, EPS)
|
||||
assert_str(InputMapper.facing_octant).is_equal("East")
|
||||
|
||||
|
||||
func test_seam_unset_provider_falls_through_to_2d_path() -> void:
|
||||
# Provider unset (default Callable()): the new branch must not fire and the
|
||||
# legacy 2D canvas-transform path must run unchanged — with the player placed
|
||||
# 100 px up-left of the mouse, it computes atan2(100, 100) = PI/4 (Southeast).
|
||||
_place_player_at_screen_delta(Vector2(100.0, 100.0))
|
||||
InputMapper.facing_angle_provider = Callable()
|
||||
InputMapper._update_facing_from_mouse()
|
||||
assert_float(InputMapper.facing_angle).is_equal_approx(PI / 4.0, 0.001)
|
||||
assert_str(InputMapper.facing_octant).is_equal("Southeast")
|
||||
|
||||
|
||||
func test_seam_unset_provider_leaves_facing_untouched_inside_2d_jitter_guard() -> void:
|
||||
# Provider unset + player exactly under the mouse: neither the new branch nor
|
||||
# the 2D path (its own <= 2 px jitter guard) may touch facing.
|
||||
_place_player_at_screen_delta(Vector2.ZERO)
|
||||
InputMapper.facing_angle = 0.42
|
||||
InputMapper.facing_octant = "East"
|
||||
InputMapper.facing_angle_provider = Callable()
|
||||
InputMapper._update_facing_from_mouse()
|
||||
assert_float(InputMapper.facing_angle).is_equal_approx(0.42, EPS)
|
||||
assert_str(InputMapper.facing_octant).is_equal("East")
|
||||
|
||||
|
||||
# Position GameState.player_position so that (mouse_screen - player_screen) equals
|
||||
# delta_px EXACTLY, inverting the 2D path's own math (player_position * TILE_SIZE
|
||||
# through the canvas transform). This pins the legacy path's outcome without any
|
||||
# assumption about the headless mouse position or canvas transform.
|
||||
func _place_player_at_screen_delta(delta_px: Vector2) -> void:
|
||||
var vp := InputMapper.get_viewport()
|
||||
var canvas_xf := vp.get_canvas_transform()
|
||||
var C := load("res://scripts/constants.gd")
|
||||
var player_screen := vp.get_mouse_position() - delta_px
|
||||
var player_world_px := canvas_xf.affine_inverse() * player_screen
|
||||
GameState.player_position = player_world_px / float(C.TILE_SIZE)
|
||||
@@ -0,0 +1,378 @@
|
||||
## LocomotionRig math (T-1088 design §4, §5, §10.4): per-leg constant-velocity
|
||||
## derivation (cardinal 1.25 m/s / diagonal 1.77 m/s at Walk 400 ms), the 3x
|
||||
## catch-up clamp, the 2.5 m snap threshold, idle hysteresis timing, paused-tick
|
||||
## idempotence, blocked-move zero-motion (Q-020 bump-to-turn), and shortest-arc
|
||||
## yaw easing under the per-stance TURN_BUDGET_DEG clamps.
|
||||
##
|
||||
## Pure static-core functions are tested directly; wrapper tests instantiate the
|
||||
## rig out-of-tree (never added as a child, so the engine never drives _process —
|
||||
## frames are stepped manually for deterministic timing).
|
||||
class_name TestLocomotionMath
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const EPS := 0.000001
|
||||
|
||||
## 60 fps frame used by the yaw tests.
|
||||
const DT := 1.0 / 60.0
|
||||
|
||||
|
||||
func _make_rig() -> LocomotionRig:
|
||||
var rig: LocomotionRig = auto_free(LocomotionRig.new())
|
||||
rig.model_root = auto_free(Node3D.new())
|
||||
return rig
|
||||
|
||||
|
||||
# -- leg-speed derivation (§4.1: dist / interval, clamped) --------------------------
|
||||
|
||||
|
||||
func test_leg_speed_cardinal_walk() -> void:
|
||||
# One cardinal subtile at Walk: 0.5 m / 0.4 s = 1.25 m/s.
|
||||
assert_float(LocomotionRig.derive_leg_speed(0.5, 0.4)).is_equal_approx(1.25, EPS)
|
||||
|
||||
|
||||
func test_leg_speed_diagonal_walk() -> void:
|
||||
# Diagonal step costs the same interval (no sqrt(2) on the wire, D-053):
|
||||
# 0.5 * sqrt(2) / 0.4 = ~1.77 m/s — arrives exactly when the next step lands.
|
||||
var diag := 0.5 * sqrt(2.0)
|
||||
assert_float(LocomotionRig.derive_leg_speed(diag, 0.4)).is_equal_approx(1.767767, 0.0001)
|
||||
|
||||
|
||||
func test_leg_speed_sprint_cardinal() -> void:
|
||||
# Sprint window 200 ms: 0.5 / 0.2 = 2.5 m/s.
|
||||
assert_float(LocomotionRig.derive_leg_speed(0.5, 0.2)).is_equal_approx(2.5, EPS)
|
||||
|
||||
|
||||
func test_leg_speed_catchup_clamped_at_3x() -> void:
|
||||
# 4-tile latest-wins delta at Walk: raw 2.0/0.4 = 5.0 m/s, clamped to
|
||||
# CATCHUP_MAX_FACTOR (3.0) * base = 3.75 m/s — feet speed up, never blur.
|
||||
assert_float(LocomotionRig.derive_leg_speed(2.0, 0.4)).is_equal_approx(3.75, EPS)
|
||||
|
||||
|
||||
func test_leg_speed_floored_at_base() -> void:
|
||||
# Sub-subtile residue (late arrival) still closes at least at base speed —
|
||||
# the tail of a leg never crawls.
|
||||
assert_float(LocomotionRig.derive_leg_speed(0.1, 0.4)).is_equal_approx(1.25, EPS)
|
||||
|
||||
|
||||
# -- target classification (§4.1: first snap / ignore / teleport / step) -------------
|
||||
|
||||
|
||||
func test_classify_first_snapshot_snaps() -> void:
|
||||
var got := LocomotionRig.classify_target(
|
||||
false, Vector3.ZERO, Vector3.ZERO, Vector3(25.25, 0.0, 29.25)
|
||||
)
|
||||
assert_int(got).is_equal(LocomotionRig.TargetAction.SNAP_FIRST)
|
||||
|
||||
|
||||
func test_classify_same_target_ignored() -> void:
|
||||
# Paused ticks keep delivering identical positions (§4.3) — idempotent.
|
||||
var target := Vector3(1.25, 0.0, 2.25)
|
||||
var got := LocomotionRig.classify_target(true, target, target, target)
|
||||
assert_int(got).is_equal(LocomotionRig.TargetAction.IGNORE)
|
||||
|
||||
|
||||
func test_classify_same_target_ignored_mid_leg() -> void:
|
||||
# A repeat while still chasing keeps the current leg speed (no re-derivation
|
||||
# from the shrinking remaining distance).
|
||||
var target := Vector3(1.75, 0.0, 2.25)
|
||||
var render := Vector3(1.5, 0.0, 2.25)
|
||||
var got := LocomotionRig.classify_target(true, render, target, target)
|
||||
assert_int(got).is_equal(LocomotionRig.TargetAction.IGNORE)
|
||||
|
||||
|
||||
func test_classify_step_within_snap_dist() -> void:
|
||||
var render := Vector3(1.25, 0.0, 2.25)
|
||||
var got := LocomotionRig.classify_target(
|
||||
true, render, render, render + Vector3(0.5, 0.0, 0.0)
|
||||
)
|
||||
assert_int(got).is_equal(LocomotionRig.TargetAction.STEP)
|
||||
|
||||
|
||||
func test_classify_snap_threshold_boundary() -> void:
|
||||
# SNAP_DIST_M is strict: exactly 2.5 m (5 subtiles) still glides — matches
|
||||
# the 2D TELEPORT_DISTANCE_THRESHOLD semantics; beyond it teleports.
|
||||
var render := Vector3.ZERO
|
||||
var at_limit := LocomotionRig.classify_target(
|
||||
true, render, render, Vector3(2.5, 0.0, 0.0)
|
||||
)
|
||||
assert_int(at_limit).is_equal(LocomotionRig.TargetAction.STEP)
|
||||
var beyond := LocomotionRig.classify_target(
|
||||
true, render, render, Vector3(2.51, 0.0, 0.0)
|
||||
)
|
||||
assert_int(beyond).is_equal(LocomotionRig.TargetAction.TELEPORT)
|
||||
|
||||
|
||||
func test_classify_teleport_measured_from_render_pos() -> void:
|
||||
# Teleport distance is render-pos -> new target (§4.1), not target -> target.
|
||||
var render := Vector3.ZERO
|
||||
var old_target := Vector3(2.0, 0.0, 0.0)
|
||||
var got := LocomotionRig.classify_target(
|
||||
true, render, old_target, Vector3(2.0, 0.0, 2.0) # 2.83 m from render
|
||||
)
|
||||
assert_int(got).is_equal(LocomotionRig.TargetAction.TELEPORT)
|
||||
|
||||
|
||||
# -- idle hysteresis (§4.2: IDLE_ENTER_DELAY_S = 0.18) --------------------------------
|
||||
|
||||
|
||||
func test_moving_while_not_at_target() -> void:
|
||||
assert_bool(LocomotionRig.is_moving_state(false, 99.0)).is_true()
|
||||
|
||||
|
||||
func test_hysteresis_holds_moving_within_delay() -> void:
|
||||
# Arrived, but only 0.1 s at target — still "moving" (covers 2-3 ticks of
|
||||
# snapshot jitter at every stance).
|
||||
assert_bool(LocomotionRig.is_moving_state(true, 0.1)).is_true()
|
||||
|
||||
|
||||
func test_hysteresis_enters_idle_at_delay() -> void:
|
||||
assert_bool(LocomotionRig.is_moving_state(true, 0.18)).is_false()
|
||||
assert_bool(LocomotionRig.is_moving_state(true, 0.5)).is_false()
|
||||
|
||||
|
||||
func test_idle_timer_accumulates_at_target() -> void:
|
||||
assert_float(LocomotionRig.advance_idle_timer(0.1, true, 0.05)).is_equal_approx(0.15, EPS)
|
||||
|
||||
|
||||
func test_idle_timer_resets_when_leg_starts() -> void:
|
||||
assert_float(LocomotionRig.advance_idle_timer(0.5, false, 0.05)).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
# -- yaw target selection (§5 authority table) -----------------------------------------
|
||||
|
||||
|
||||
func test_facing_moving_uses_wire_octant() -> void:
|
||||
# Moving: server Facing IS the motion direction — mouse aim is visually ignored.
|
||||
var got := LocomotionRig.select_yaw_target(true, "East", "North", false, 0.0)
|
||||
assert_float(got).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_facing_moving_ignores_suppression() -> void:
|
||||
var got := LocomotionRig.select_yaw_target(true, "East", "North", true, 0.0)
|
||||
assert_float(got).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_facing_idle_uses_provider_octant() -> void:
|
||||
var got := LocomotionRig.select_yaw_target(false, "East", "North", false, 0.0)
|
||||
assert_float(got).is_equal_approx(PI, EPS)
|
||||
|
||||
|
||||
func test_facing_idle_without_provider_falls_back_to_wire() -> void:
|
||||
# NPC path: no provider installed -> pure wire facing, single code path.
|
||||
var got := LocomotionRig.select_yaw_target(false, "East", "", false, 0.0)
|
||||
assert_float(got).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_facing_frozen_under_suppression_when_idle() -> void:
|
||||
# Dialogue / free camera: InputMapper stops sending octants — hold the last
|
||||
# target instead of showing an octant the server was never told.
|
||||
var held := 0.123
|
||||
var got := LocomotionRig.select_yaw_target(false, "East", "North", true, held)
|
||||
assert_float(got).is_equal_approx(held, EPS)
|
||||
|
||||
|
||||
# -- yaw stepping (§5: shortest-arc lerp_angle under TURN_BUDGET_DEG) --------------------
|
||||
|
||||
|
||||
func test_shortest_arc_wraps_through_pi() -> void:
|
||||
# -170 deg -> +170 deg is -20 deg through the seam, never +340 deg.
|
||||
var arc := LocomotionRig.shortest_arc(deg_to_rad(-170.0), deg_to_rad(170.0))
|
||||
assert_float(arc).is_equal_approx(deg_to_rad(-20.0), EPS)
|
||||
|
||||
|
||||
func test_yaw_step_unclamped_matches_ease() -> void:
|
||||
# Small 5 deg correction at Walk: eased step (~1 deg at 60 fps) is far under
|
||||
# the 12 deg/frame budget — pure lerp_angle ease, no clamp.
|
||||
var weight := 1.0 - exp(-SandboxConstants.TURN_SHARPNESS * DT)
|
||||
var expected := weight * deg_to_rad(5.0)
|
||||
var got := LocomotionRig.step_yaw(0.0, deg_to_rad(5.0), "Walk", DT)
|
||||
assert_float(got).is_equal_approx(expected, EPS)
|
||||
|
||||
|
||||
func test_yaw_budget_clamps_walk_reversal() -> void:
|
||||
# Near-180 reversal at Walk: eased step (~37 deg) hits the 720 deg/s budget
|
||||
# -> exactly 12 deg this frame (reversal completes in ~0.25 s, half a step).
|
||||
var got := LocomotionRig.step_yaw(0.0, deg_to_rad(179.0), "Walk", DT)
|
||||
assert_float(got).is_equal_approx(deg_to_rad(720.0) * DT, EPS)
|
||||
|
||||
|
||||
func test_yaw_budget_per_stance() -> void:
|
||||
# Same reversal, different stances: Sprint 1080 -> 18 deg/frame,
|
||||
# Crouch 420 -> 7 deg/frame.
|
||||
var target := deg_to_rad(179.0)
|
||||
var sprint := LocomotionRig.step_yaw(0.0, target, "Sprint", DT)
|
||||
assert_float(sprint).is_equal_approx(deg_to_rad(1080.0) * DT, EPS)
|
||||
var crouch := LocomotionRig.step_yaw(0.0, target, "Crouch", DT)
|
||||
assert_float(crouch).is_equal_approx(deg_to_rad(420.0) * DT, EPS)
|
||||
|
||||
|
||||
func test_yaw_budget_idle_and_unknown_key() -> void:
|
||||
# Idle budget 600 -> 10 deg/frame; unknown stance keys fall back to Idle.
|
||||
var target := deg_to_rad(179.0)
|
||||
var idle := LocomotionRig.step_yaw(0.0, target, "Idle", DT)
|
||||
assert_float(idle).is_equal_approx(deg_to_rad(600.0) * DT, EPS)
|
||||
var unknown := LocomotionRig.step_yaw(0.0, target, "Prone", DT)
|
||||
assert_float(unknown).is_equal_approx(deg_to_rad(600.0) * DT, EPS)
|
||||
|
||||
|
||||
func test_yaw_step_takes_shortest_arc() -> void:
|
||||
# From -170 deg toward +170 deg: the step is negative (through the seam).
|
||||
var start := deg_to_rad(-170.0)
|
||||
var weight := 1.0 - exp(-SandboxConstants.TURN_SHARPNESS * DT)
|
||||
var expected := start + weight * deg_to_rad(-20.0)
|
||||
var got := LocomotionRig.step_yaw(start, deg_to_rad(170.0), "Walk", DT)
|
||||
assert_float(got).is_equal_approx(expected, EPS)
|
||||
|
||||
|
||||
func test_yaw_step_wraps_result_across_seam() -> void:
|
||||
# Budget-clamped turn crossing -PI: -175 deg - 12 deg wraps to +173 deg.
|
||||
var got := LocomotionRig.step_yaw(deg_to_rad(-175.0), deg_to_rad(90.0), "Walk", DT)
|
||||
assert_float(got).is_equal_approx(deg_to_rad(173.0), EPS)
|
||||
|
||||
|
||||
# -- wrapper behavior (out-of-tree rig, manually stepped frames) --------------------------
|
||||
|
||||
|
||||
func test_first_wire_target_snaps_without_teleport_signal() -> void:
|
||||
var rig := _make_rig()
|
||||
var emissions: Array = []
|
||||
rig.teleported.connect(func(p: Vector3) -> void: emissions.append(p))
|
||||
rig.set_wire_target(Vector3(25.25, 0.0, 29.25), "East", "Walk", 100)
|
||||
assert_float(rig.position.x).is_equal_approx(25.25, EPS)
|
||||
assert_float(rig.position.z).is_equal_approx(29.25, EPS)
|
||||
assert_bool(rig.is_moving).is_false()
|
||||
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
|
||||
# Yaw snapped straight to the wire octant (East = +90 deg), no ease-in.
|
||||
assert_float(rig.model_root.rotation.y).is_equal_approx(PI / 2.0, EPS)
|
||||
assert_int(emissions.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_paused_repeat_of_same_target_is_idempotent() -> void:
|
||||
# Paused server keeps sending identical-position frames (§4.3): no motion
|
||||
# re-trigger, no drift, rig settles idle through normal hysteresis.
|
||||
var rig := _make_rig()
|
||||
var target := Vector3(1.25, 0.0, 2.25)
|
||||
rig.set_wire_target(target, "North", "Walk", 10)
|
||||
for i in 5:
|
||||
rig.set_wire_target(target, "North", "Walk", 10)
|
||||
rig._process(0.05)
|
||||
assert_bool(rig.is_moving).is_false()
|
||||
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
|
||||
assert_float(rig.velocity.length()).is_equal_approx(0.0, EPS)
|
||||
assert_float(rig.position.distance_to(target)).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_blocked_move_turns_in_place_without_motion() -> void:
|
||||
# Q-020 bump-to-turn (§4.3): the server updates Facing on a blocked move but
|
||||
# not position — the rig turns to face the wall and stands, zero motion.
|
||||
var rig := _make_rig()
|
||||
var target := Vector3(1.25, 0.0, 2.25)
|
||||
rig.set_wire_target(target, "North", "Walk", 1) # snap: yaw = PI (North)
|
||||
rig.set_wire_target(target, "West", "Walk", 2) # blocked: same tile, new facing
|
||||
rig._process(DT)
|
||||
assert_float(rig.position.distance_to(target)).is_equal_approx(0.0, EPS)
|
||||
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
|
||||
assert_bool(rig.is_moving).is_false()
|
||||
# Idle without a provider -> wire facing (West = -90 deg).
|
||||
assert_float(rig.yaw_target).is_equal_approx(-PI / 2.0, EPS)
|
||||
# One Idle-budget frame (600 deg/s -> 10 deg) from North toward West,
|
||||
# shortest arc through the +PI seam: PI + 10 deg wraps to -PI + 10 deg.
|
||||
assert_float(rig.model_root.rotation.y).is_equal_approx(-PI + deg_to_rad(10.0), EPS)
|
||||
|
||||
|
||||
func test_step_moves_at_constant_leg_speed() -> void:
|
||||
# Equal displacement per frame — the M2 milestone's numeric core.
|
||||
var rig := _make_rig()
|
||||
var start := Vector3(1.25, 0.0, 2.25)
|
||||
rig.set_wire_target(start, "East", "Walk", 1)
|
||||
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.0), "East", "Walk", 2)
|
||||
rig._process(0.1)
|
||||
assert_bool(rig.is_moving).is_true()
|
||||
assert_float(rig.current_speed).is_equal_approx(1.25, 0.001)
|
||||
assert_float(rig.velocity.x).is_equal_approx(1.25, 0.001)
|
||||
assert_float(rig.position.x).is_equal_approx(1.375, 0.001)
|
||||
rig._process(0.1)
|
||||
assert_float(rig.position.x).is_equal_approx(1.5, 0.001)
|
||||
|
||||
|
||||
func test_diagonal_step_velocity_components() -> void:
|
||||
# Diagonal leg at Walk: 1.77 m/s along the diagonal = 1.25 m/s per axis —
|
||||
# both axes arrive exactly when a cardinal step would.
|
||||
var rig := _make_rig()
|
||||
var start := Vector3(1.25, 0.0, 2.25)
|
||||
rig.set_wire_target(start, "Southeast", "Walk", 1)
|
||||
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.5), "Southeast", "Walk", 2)
|
||||
rig._process(0.1)
|
||||
assert_float(rig.current_speed).is_equal_approx(1.767767, 0.0001)
|
||||
assert_float(rig.velocity.x).is_equal_approx(1.25, 0.001)
|
||||
assert_float(rig.velocity.z).is_equal_approx(1.25, 0.001)
|
||||
|
||||
|
||||
func test_arrival_holds_moving_through_hysteresis_window() -> void:
|
||||
# Arrive after 0.4 s, then stay "moving" until 0.18 s at target (§4.2).
|
||||
var rig := _make_rig()
|
||||
var start := Vector3(1.25, 0.0, 2.25)
|
||||
rig.set_wire_target(start, "East", "Walk", 1)
|
||||
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.0), "East", "Walk", 2)
|
||||
for i in 4: # 4 x 0.1 s = exactly one Walk window -> arrival
|
||||
rig._process(0.1)
|
||||
assert_float(rig.position.x).is_equal_approx(1.75, 0.001)
|
||||
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
|
||||
assert_bool(rig.is_moving).is_true() # at target 0.1 s < 0.18 s — held
|
||||
rig._process(0.1) # at target 0.2 s >= 0.18 s — idle
|
||||
assert_bool(rig.is_moving).is_false()
|
||||
|
||||
|
||||
func test_teleport_snaps_all_channels_and_emits() -> void:
|
||||
var rig := _make_rig()
|
||||
var emissions: Array = []
|
||||
rig.teleported.connect(func(p: Vector3) -> void: emissions.append(p))
|
||||
rig.set_wire_target(Vector3(1.25, 0.0, 1.25), "North", "Walk", 1)
|
||||
var far := Vector3(25.25, 0.0, 29.25) # Home-key Hub return — cross-map jump
|
||||
rig.set_wire_target(far, "South", "Walk", 2)
|
||||
assert_int(emissions.size()).is_equal(1)
|
||||
assert_float((emissions[0] as Vector3).x).is_equal_approx(far.x, EPS)
|
||||
assert_float(rig.position.distance_to(far)).is_equal_approx(0.0, EPS)
|
||||
assert_bool(rig.is_moving).is_false()
|
||||
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
|
||||
# Yaw hard-snapped to the post-teleport wire octant (South = 0), no glide.
|
||||
assert_float(rig.model_root.rotation.y).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_step_window_provider_overrides_default() -> void:
|
||||
# The player adapter injects InputMapper.MOVE_INTERVAL_MS — the rig itself
|
||||
# never reads the autoload (§4.0).
|
||||
var rig := _make_rig()
|
||||
rig.step_window_ms_provider = func(for_stance: String) -> float:
|
||||
return 200.0 if for_stance == "Sprint" else 400.0
|
||||
var start := Vector3(1.25, 0.0, 1.25)
|
||||
rig.set_wire_target(start, "East", "Sprint", 1)
|
||||
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.0), "East", "Sprint", 2)
|
||||
rig._process(0.1)
|
||||
assert_float(rig.current_speed).is_equal_approx(2.5, 0.001)
|
||||
|
||||
|
||||
func test_idle_facing_provider_steers_idle_yaw() -> void:
|
||||
# Idle aim: the provider's octant (client-local, already sent as SetFacing)
|
||||
# becomes the yaw target — mouse-responsive idle facing without wire lag.
|
||||
var rig := _make_rig()
|
||||
rig.idle_facing_provider = func() -> String: return "East"
|
||||
rig.set_wire_target(Vector3(1.25, 0.0, 2.25), "North", "Walk", 1)
|
||||
rig._process(DT)
|
||||
assert_bool(rig.is_moving).is_false()
|
||||
assert_float(rig.yaw_target).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_suppression_freezes_idle_yaw_target() -> void:
|
||||
# Dialogue / free camera (§5 row 3): the yaw target holds even though the
|
||||
# idle provider says otherwise.
|
||||
var rig := _make_rig()
|
||||
rig.idle_facing_provider = func() -> String: return "East"
|
||||
rig.suppression_provider = func() -> bool: return true
|
||||
rig.set_wire_target(Vector3(1.25, 0.0, 2.25), "North", "Walk", 1)
|
||||
rig._process(DT)
|
||||
assert_float(rig.yaw_target).is_equal_approx(PI, EPS) # frozen at North
|
||||
# Node3D.rotation is float32: written +PI reads back a hair above double PI,
|
||||
# so wrap_yaw lands on the equivalent -PI. Assert angular identity, not sign.
|
||||
var arc := LocomotionRig.shortest_arc(PI, rig.model_root.rotation.y)
|
||||
assert_float(arc).is_equal_approx(0.0, EPS)
|
||||
@@ -0,0 +1,148 @@
|
||||
## SandboxSpace golden conversions (T-1088 design §2, §10.4): tile -> world metres,
|
||||
## wire-float floori recovery (live tile-centers vs TestHarness integers), and the
|
||||
## verified octant -> yaw table (yaw = PI/2 - theta; South 0, East +90, West -90,
|
||||
## North 180 degrees).
|
||||
##
|
||||
## The East/West cells are the REGRESSION GUARD against character_visual.gd's
|
||||
## mirrored 2D table (east=-90/west=+90, character_visual.gd:184-193) — copying that
|
||||
## table makes the model face west while walking east. Never relax these two tests.
|
||||
class_name TestSandboxSpace
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const EPS := 0.000001
|
||||
|
||||
|
||||
# -- tile_to_world ---------------------------------------------------------------
|
||||
|
||||
|
||||
func test_tile_to_world_origin_center() -> void:
|
||||
# Tile (0,0) center: subtile 0.5 m, center at +0.25 m on each axis.
|
||||
var w := SandboxSpace.tile_to_world(Vector3i(0, 0, 0))
|
||||
assert_float(w.x).is_equal_approx(0.25, EPS)
|
||||
assert_float(w.y).is_equal_approx(0.0, EPS)
|
||||
assert_float(w.z).is_equal_approx(0.25, EPS)
|
||||
|
||||
|
||||
func test_tile_to_world_hub_spawn() -> void:
|
||||
# Gauntlet Hub spawn (50, 58): x = (50 + 0.5) * 0.5 = 25.25, z = (58 + 0.5) * 0.5 = 29.25.
|
||||
var w := SandboxSpace.tile_to_world(Vector3i(50, 58, 0))
|
||||
assert_float(w.x).is_equal_approx(25.25, EPS)
|
||||
assert_float(w.y).is_equal_approx(0.0, EPS)
|
||||
assert_float(w.z).is_equal_approx(29.25, EPS)
|
||||
|
||||
|
||||
func test_tile_to_world_sim_south_is_local_plus_z() -> void:
|
||||
# Sim +y (South, Y-down) maps to local +Z: one southward tile step moves +0.5 m in Z only.
|
||||
var a := SandboxSpace.tile_to_world(Vector3i(10, 20, 0))
|
||||
var b := SandboxSpace.tile_to_world(Vector3i(10, 21, 0))
|
||||
assert_float(b.x - a.x).is_equal_approx(0.0, EPS)
|
||||
assert_float(b.z - a.z).is_equal_approx(0.5, EPS)
|
||||
|
||||
|
||||
func test_tile_to_world_sim_east_is_local_plus_x() -> void:
|
||||
# Sim +x (East) maps to local +X: one eastward tile step moves +0.5 m in X only.
|
||||
var a := SandboxSpace.tile_to_world(Vector3i(10, 20, 0))
|
||||
var b := SandboxSpace.tile_to_world(Vector3i(11, 20, 0))
|
||||
assert_float(b.x - a.x).is_equal_approx(0.5, EPS)
|
||||
assert_float(b.z - a.z).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
# -- wire-float recovery (floori, never round) -------------------------------------
|
||||
|
||||
|
||||
func test_wire_to_tile_live_tile_center_floats() -> void:
|
||||
# Live wire sends tile-center floats (N + 0.5) — floori recovers N.
|
||||
# round(50.5) would land on 51: the half-a-tile-off failure mode.
|
||||
assert_object(SandboxSpace.wire_to_tile(50.5, 58.5)).is_equal(Vector3i(50, 58, 0))
|
||||
|
||||
|
||||
func test_wire_to_tile_harness_integer_floats() -> void:
|
||||
# TestHarness sends integer floats — floori is a no-op, byte-identical both modes.
|
||||
assert_object(SandboxSpace.wire_to_tile(50.0, 58.0)).is_equal(Vector3i(50, 58, 0))
|
||||
|
||||
|
||||
func test_wire_to_world_recenters() -> void:
|
||||
# Wire float -> tile index -> recomputed center: 50.5 -> tile 50 -> 25.25 m.
|
||||
var w := SandboxSpace.wire_to_world(50.5, 58.5)
|
||||
assert_float(w.x).is_equal_approx(25.25, EPS)
|
||||
assert_float(w.z).is_equal_approx(29.25, EPS)
|
||||
|
||||
|
||||
func test_wire_to_world_identical_across_modes() -> void:
|
||||
# The same tile must resolve to the same world point from either wire spelling.
|
||||
var live := SandboxSpace.wire_to_world(50.5, 58.5)
|
||||
var harness := SandboxSpace.wire_to_world(50.0, 58.0)
|
||||
assert_float(live.x).is_equal_approx(harness.x, EPS)
|
||||
assert_float(live.z).is_equal_approx(harness.z, EPS)
|
||||
|
||||
|
||||
func test_world_to_tile_round_trip() -> void:
|
||||
# Gauntlet corners + spawn: world_to_tile inverts tile_to_world exactly.
|
||||
for tile: Vector3i in [
|
||||
Vector3i(0, 0, 0), Vector3i(50, 58, 0), Vector3i(116, 124, 0), Vector3i(3, 7, 0)
|
||||
]:
|
||||
assert_object(SandboxSpace.world_to_tile(SandboxSpace.tile_to_world(tile))).is_equal(tile)
|
||||
|
||||
|
||||
# -- octant -> yaw (all 8; E/W are the mirrored-table regression guard) --------------
|
||||
|
||||
|
||||
func test_octant_yaw_south_is_zero() -> void:
|
||||
assert_float(SandboxSpace.octant_to_yaw("South")).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_southeast_is_plus_45() -> void:
|
||||
assert_float(SandboxSpace.octant_to_yaw("Southeast")).is_equal_approx(PI / 4.0, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_east_is_plus_90() -> void:
|
||||
# REGRESSION GUARD: East = +PI/2 (model turns to +X). character_visual.gd's
|
||||
# mirrored table says east = -90 deg — copying it faces the model west while
|
||||
# walking east (the fatal flaw of both rejected design candidates).
|
||||
assert_float(SandboxSpace.octant_to_yaw("East")).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_northeast_is_plus_135() -> void:
|
||||
assert_float(SandboxSpace.octant_to_yaw("Northeast")).is_equal_approx(3.0 * PI / 4.0, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_north_is_180() -> void:
|
||||
assert_float(SandboxSpace.octant_to_yaw("North")).is_equal_approx(PI, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_northwest_is_minus_135() -> void:
|
||||
assert_float(SandboxSpace.octant_to_yaw("Northwest")).is_equal_approx(-3.0 * PI / 4.0, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_west_is_minus_90() -> void:
|
||||
# REGRESSION GUARD: West = -PI/2 (model turns to -X) — the mirror of the
|
||||
# character_visual.gd 2D table (west = +90 deg there). See East guard above.
|
||||
assert_float(SandboxSpace.octant_to_yaw("West")).is_equal_approx(-PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_southwest_is_minus_45() -> void:
|
||||
assert_float(SandboxSpace.octant_to_yaw("Southwest")).is_equal_approx(-PI / 4.0, EPS)
|
||||
|
||||
|
||||
# -- derivation internals ------------------------------------------------------------
|
||||
|
||||
|
||||
func test_sim_angle_convention_matches_input_mapper() -> void:
|
||||
# theta: 0 = East, +PI/2 = South, -PI/2 = North (Y-down radians — server
|
||||
# vision_cone.rs and InputMapper.facing_angle share this convention).
|
||||
assert_float(SandboxSpace.octant_to_sim_angle("East")).is_equal_approx(0.0, EPS)
|
||||
assert_float(SandboxSpace.octant_to_sim_angle("South")).is_equal_approx(PI / 2.0, EPS)
|
||||
assert_float(SandboxSpace.octant_to_sim_angle("North")).is_equal_approx(-PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_sim_angle_to_yaw_wraps_into_signed_pi_range() -> void:
|
||||
# Northwest: theta = -3PI/4 -> PI/2 - theta = 5PI/4 -> wrapped to -3PI/4.
|
||||
var nw := SandboxSpace.sim_angle_to_yaw(-3.0 * PI / 4.0)
|
||||
assert_float(nw).is_equal_approx(-3.0 * PI / 4.0, EPS)
|
||||
# North stays +PI (range (-PI, PI]), matching the golden table's 180 deg.
|
||||
assert_float(SandboxSpace.sim_angle_to_yaw(-PI / 2.0)).is_equal_approx(PI, EPS)
|
||||
|
||||
|
||||
func test_unknown_octant_defaults_to_north() -> void:
|
||||
# Defensive default mirrors GameState.player_facing's "North" default.
|
||||
assert_float(SandboxSpace.octant_to_yaw("Sideways")).is_equal_approx(PI, EPS)
|
||||
@@ -1,5 +1,6 @@
|
||||
## Visual test capture engine — boots main.tscn, runs a named scenario or flow,
|
||||
## captures viewport PNGs for golden comparison or ad-hoc inspection.
|
||||
## Visual test capture engine — boots the entry's "scene" (default main.tscn),
|
||||
## runs a named scenario or flow, captures viewport PNGs for golden comparison
|
||||
## or ad-hoc inspection.
|
||||
##
|
||||
## Usage:
|
||||
## godot --rendering-driver opengl3 --fixed-fps 60 --resolution 960x540 \
|
||||
@@ -66,10 +67,22 @@ func _run(): # gdlint:disable=max-returns
|
||||
# Ensure output directory exists
|
||||
DirAccess.make_dir_recursive_absolute(_output_dir)
|
||||
|
||||
# Load main scene (autoloads already initialized from project.godot)
|
||||
var main_scene = load("res://scenes/main.tscn")
|
||||
# Per-entry overrides (T-1088, design §10.2 — both additive; existing entries
|
||||
# without these keys behave byte-identically):
|
||||
# "scene": alternate root scene (default res://scenes/main.tscn)
|
||||
# "env": env vars set before the scene boots (e.g. SR_AUTOPILOT) — OS-level,
|
||||
# so the scene's _ready() reads them exactly like shell-exported vars;
|
||||
# run-visual has no per-scenario env mechanism, this is it.
|
||||
var entry_cfg := _entry_config()
|
||||
var env_vars: Dictionary = entry_cfg.get("env", {})
|
||||
for env_key: String in env_vars:
|
||||
OS.set_environment(env_key, str(env_vars[env_key]))
|
||||
|
||||
# Load root scene (autoloads already initialized from project.godot)
|
||||
var scene_path: String = entry_cfg.get("scene", "res://scenes/main.tscn")
|
||||
var main_scene = load(scene_path)
|
||||
if main_scene == null:
|
||||
push_error("visual_capture: failed to load res://scenes/main.tscn")
|
||||
push_error("visual_capture: failed to load %s" % scene_path)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
@@ -298,6 +311,19 @@ func _capture(path: String) -> void:
|
||||
print("visual_capture: captured -> %s (%dx%d)" % [path, image.get_width(), image.get_height()])
|
||||
|
||||
|
||||
## Config entry for the active scenario or flow ({} when the name is unknown —
|
||||
## the mode runners report that error themselves).
|
||||
func _entry_config() -> Dictionary:
|
||||
var section: Dictionary = {}
|
||||
if not _scenario.is_empty():
|
||||
section = _config.get("scenarios", {})
|
||||
return section.get(_scenario, {})
|
||||
if not _flow.is_empty():
|
||||
section = _config.get("flows", {})
|
||||
return section.get(_flow, {})
|
||||
return {}
|
||||
|
||||
|
||||
func _load_config() -> Dictionary:
|
||||
# Config is at tests/visual.json relative to repo root.
|
||||
# Repo root = parent of Godot project root (client/).
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
{
|
||||
"garment_glb": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/SK_SCFI_CIVL_09_10TORS_HU01_quaternius.glb",
|
||||
"bodies": {
|
||||
"average_m": {
|
||||
"garment_verts": 2155,
|
||||
"segments_used": [
|
||||
"seg_torso",
|
||||
"seg_hips",
|
||||
"seg_neck",
|
||||
"seg_arm_upper_l",
|
||||
"seg_arm_upper_r",
|
||||
"seg_leg_upper_l",
|
||||
"seg_leg_upper_r"
|
||||
],
|
||||
"affine": {
|
||||
"scale_xy": 1.0,
|
||||
"scale_z": 1.0,
|
||||
"translate": [
|
||||
-0.0,
|
||||
-0.0,
|
||||
-0.0
|
||||
]
|
||||
},
|
||||
"aabb_post_affine": {
|
||||
"min": [
|
||||
-0.2082,
|
||||
-0.1677,
|
||||
0.8251
|
||||
],
|
||||
"max": [
|
||||
0.2305,
|
||||
0.1739,
|
||||
1.59
|
||||
]
|
||||
},
|
||||
"warp": {
|
||||
"max_displacement_mm": 13.2,
|
||||
"smooth_iterations": 20
|
||||
},
|
||||
"zero_weight_verts": 0,
|
||||
"vgroups": 65,
|
||||
"edge_distortion": {
|
||||
"max_stretch": 1.058,
|
||||
"max_shrink": 0.919,
|
||||
"edges_gt_2x": 0,
|
||||
"edges_lt_0.5x": 0,
|
||||
"edges_total": 3799
|
||||
},
|
||||
"aabb_post_fit": {
|
||||
"min": [
|
||||
-0.2083,
|
||||
-0.1707,
|
||||
0.8236
|
||||
],
|
||||
"max": [
|
||||
0.2337,
|
||||
0.1739,
|
||||
1.59
|
||||
]
|
||||
},
|
||||
"signed_distance": {
|
||||
"verts_sampled": 2155,
|
||||
"min_signed_mm": -74.86,
|
||||
"p05_signed_mm": -6.44,
|
||||
"median_signed_mm": 36.03,
|
||||
"max_signed_mm": 96.8,
|
||||
"verts_inside_gt_3mm": 135,
|
||||
"pct_inside": 6.26
|
||||
},
|
||||
"export": {
|
||||
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_average_m.glb",
|
||||
"size_kb": 653
|
||||
},
|
||||
"result": "OK"
|
||||
},
|
||||
"average_f": {
|
||||
"garment_verts": 2155,
|
||||
"segments_used": [
|
||||
"seg_torso",
|
||||
"seg_hips",
|
||||
"seg_neck",
|
||||
"seg_arm_upper_l",
|
||||
"seg_arm_upper_r",
|
||||
"seg_leg_upper_l",
|
||||
"seg_leg_upper_r"
|
||||
],
|
||||
"affine": {
|
||||
"scale_xy": 0.79,
|
||||
"scale_z": 0.96031,
|
||||
"translate": [
|
||||
0.0,
|
||||
0.01173,
|
||||
0.02037
|
||||
]
|
||||
},
|
||||
"aabb_post_affine": {
|
||||
"min": [
|
||||
-0.1645,
|
||||
-0.1207,
|
||||
0.8127
|
||||
],
|
||||
"max": [
|
||||
0.1821,
|
||||
0.1491,
|
||||
1.5473
|
||||
]
|
||||
},
|
||||
"warp": {
|
||||
"max_displacement_mm": 65.3,
|
||||
"smooth_iterations": 20
|
||||
},
|
||||
"zero_weight_verts": 0,
|
||||
"vgroups": 65,
|
||||
"edge_distortion": {
|
||||
"max_stretch": 1.712,
|
||||
"max_shrink": 0.762,
|
||||
"edges_gt_2x": 0,
|
||||
"edges_lt_0.5x": 0,
|
||||
"edges_total": 3799
|
||||
},
|
||||
"aabb_post_fit": {
|
||||
"min": [
|
||||
-0.169,
|
||||
-0.1566,
|
||||
0.8021
|
||||
],
|
||||
"max": [
|
||||
0.2341,
|
||||
0.1404,
|
||||
1.5467
|
||||
]
|
||||
},
|
||||
"signed_distance": {
|
||||
"verts_sampled": 2155,
|
||||
"min_signed_mm": -67.38,
|
||||
"p05_signed_mm": -12.82,
|
||||
"median_signed_mm": 26.3,
|
||||
"max_signed_mm": 85.13,
|
||||
"verts_inside_gt_3mm": 206,
|
||||
"pct_inside": 9.56
|
||||
},
|
||||
"export": {
|
||||
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_average_f.glb",
|
||||
"size_kb": 653
|
||||
},
|
||||
"result": "OK"
|
||||
},
|
||||
"muscular_m": {
|
||||
"garment_verts": 2155,
|
||||
"segments_used": [
|
||||
"seg_torso",
|
||||
"seg_hips",
|
||||
"seg_neck",
|
||||
"seg_arm_upper_l",
|
||||
"seg_arm_upper_r",
|
||||
"seg_leg_upper_l",
|
||||
"seg_leg_upper_r"
|
||||
],
|
||||
"affine": {
|
||||
"scale_xy": 1.1048,
|
||||
"scale_z": 1.0,
|
||||
"translate": [
|
||||
0.0,
|
||||
-0.00451,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"aabb_post_affine": {
|
||||
"min": [
|
||||
-0.23,
|
||||
-0.1898,
|
||||
0.8251
|
||||
],
|
||||
"max": [
|
||||
0.2546,
|
||||
0.1876,
|
||||
1.59
|
||||
]
|
||||
},
|
||||
"warp": {
|
||||
"max_displacement_mm": 54.7,
|
||||
"smooth_iterations": 20
|
||||
},
|
||||
"zero_weight_verts": 0,
|
||||
"vgroups": 65,
|
||||
"edge_distortion": {
|
||||
"max_stretch": 1.489,
|
||||
"max_shrink": 0.638,
|
||||
"edges_gt_2x": 0,
|
||||
"edges_lt_0.5x": 0,
|
||||
"edges_total": 3799
|
||||
},
|
||||
"aabb_post_fit": {
|
||||
"min": [
|
||||
-0.239,
|
||||
-0.2016,
|
||||
0.8217
|
||||
],
|
||||
"max": [
|
||||
0.2662,
|
||||
0.2002,
|
||||
1.5928
|
||||
]
|
||||
},
|
||||
"signed_distance": {
|
||||
"verts_sampled": 2155,
|
||||
"min_signed_mm": -67.99,
|
||||
"p05_signed_mm": -9.31,
|
||||
"median_signed_mm": 38.97,
|
||||
"max_signed_mm": 110.81,
|
||||
"verts_inside_gt_3mm": 177,
|
||||
"pct_inside": 8.21
|
||||
},
|
||||
"export": {
|
||||
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_muscular_m.glb",
|
||||
"size_kb": 653
|
||||
},
|
||||
"result": "OK"
|
||||
},
|
||||
"muscular_f": {
|
||||
"garment_verts": 2155,
|
||||
"segments_used": [
|
||||
"seg_torso",
|
||||
"seg_hips",
|
||||
"seg_neck",
|
||||
"seg_arm_upper_l",
|
||||
"seg_arm_upper_r",
|
||||
"seg_leg_upper_l",
|
||||
"seg_leg_upper_r"
|
||||
],
|
||||
"affine": {
|
||||
"scale_xy": 0.79,
|
||||
"scale_z": 0.96031,
|
||||
"translate": [
|
||||
0.0,
|
||||
0.01173,
|
||||
0.02037
|
||||
]
|
||||
},
|
||||
"aabb_post_affine": {
|
||||
"min": [
|
||||
-0.1645,
|
||||
-0.1207,
|
||||
0.8127
|
||||
],
|
||||
"max": [
|
||||
0.1821,
|
||||
0.1491,
|
||||
1.5473
|
||||
]
|
||||
},
|
||||
"warp": {
|
||||
"max_displacement_mm": 89.1,
|
||||
"smooth_iterations": 20
|
||||
},
|
||||
"zero_weight_verts": 0,
|
||||
"vgroups": 65,
|
||||
"edge_distortion": {
|
||||
"max_stretch": 1.815,
|
||||
"max_shrink": 0.744,
|
||||
"edges_gt_2x": 0,
|
||||
"edges_lt_0.5x": 0,
|
||||
"edges_total": 3799
|
||||
},
|
||||
"aabb_post_fit": {
|
||||
"min": [
|
||||
-0.1747,
|
||||
-0.1679,
|
||||
0.8022
|
||||
],
|
||||
"max": [
|
||||
0.237,
|
||||
0.1415,
|
||||
1.5473
|
||||
]
|
||||
},
|
||||
"signed_distance": {
|
||||
"verts_sampled": 2155,
|
||||
"min_signed_mm": -72.04,
|
||||
"p05_signed_mm": -12.99,
|
||||
"median_signed_mm": 26.65,
|
||||
"max_signed_mm": 85.26,
|
||||
"verts_inside_gt_3mm": 189,
|
||||
"pct_inside": 8.77
|
||||
},
|
||||
"export": {
|
||||
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_muscular_f.glb",
|
||||
"size_kb": 653
|
||||
},
|
||||
"result": "OK"
|
||||
},
|
||||
"thin_m": {
|
||||
"garment_verts": 2155,
|
||||
"segments_used": [
|
||||
"seg_torso",
|
||||
"seg_neck",
|
||||
"seg_arm_upper_l",
|
||||
"seg_arm_upper_r",
|
||||
"seg_leg_upper_l",
|
||||
"seg_leg_upper_r"
|
||||
],
|
||||
"affine": {
|
||||
"scale_xy": 1.0,
|
||||
"scale_z": 1.0,
|
||||
"translate": [
|
||||
-0.0,
|
||||
-0.0,
|
||||
-0.0
|
||||
]
|
||||
},
|
||||
"aabb_post_affine": {
|
||||
"min": [
|
||||
-0.2082,
|
||||
-0.1677,
|
||||
0.8251
|
||||
],
|
||||
"max": [
|
||||
0.2305,
|
||||
0.1739,
|
||||
1.59
|
||||
]
|
||||
},
|
||||
"warp": {
|
||||
"max_displacement_mm": 148.8,
|
||||
"smooth_iterations": 20
|
||||
},
|
||||
"zero_weight_verts": 0,
|
||||
"vgroups": 65,
|
||||
"edge_distortion": {
|
||||
"max_stretch": 2.091,
|
||||
"max_shrink": 0.232,
|
||||
"edges_gt_2x": 1,
|
||||
"edges_lt_0.5x": 14,
|
||||
"edges_total": 3799
|
||||
},
|
||||
"aabb_post_fit": {
|
||||
"min": [
|
||||
-0.1968,
|
||||
-0.1693,
|
||||
0.8205
|
||||
],
|
||||
"max": [
|
||||
0.212,
|
||||
0.1959,
|
||||
1.5056
|
||||
]
|
||||
},
|
||||
"signed_distance": {
|
||||
"verts_sampled": 2155,
|
||||
"min_signed_mm": -63.51,
|
||||
"p05_signed_mm": -8.58,
|
||||
"median_signed_mm": 40.36,
|
||||
"max_signed_mm": 124.52,
|
||||
"verts_inside_gt_3mm": 126,
|
||||
"pct_inside": 5.85
|
||||
},
|
||||
"export": {
|
||||
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_thin_m.glb",
|
||||
"size_kb": 653
|
||||
},
|
||||
"result": "OK"
|
||||
},
|
||||
"thin_f": {
|
||||
"garment_verts": 2155,
|
||||
"segments_used": [
|
||||
"seg_torso",
|
||||
"seg_neck",
|
||||
"seg_arm_upper_l",
|
||||
"seg_arm_upper_r",
|
||||
"seg_leg_upper_l",
|
||||
"seg_leg_upper_r"
|
||||
],
|
||||
"affine": {
|
||||
"scale_xy": 0.79,
|
||||
"scale_z": 0.96031,
|
||||
"translate": [
|
||||
0.0,
|
||||
0.01173,
|
||||
0.02037
|
||||
]
|
||||
},
|
||||
"aabb_post_affine": {
|
||||
"min": [
|
||||
-0.1645,
|
||||
-0.1207,
|
||||
0.8127
|
||||
],
|
||||
"max": [
|
||||
0.1821,
|
||||
0.1491,
|
||||
1.5473
|
||||
]
|
||||
},
|
||||
"warp": {
|
||||
"max_displacement_mm": 105.2,
|
||||
"smooth_iterations": 20
|
||||
},
|
||||
"zero_weight_verts": 0,
|
||||
"vgroups": 65,
|
||||
"edge_distortion": {
|
||||
"max_stretch": 2.589,
|
||||
"max_shrink": 0.409,
|
||||
"edges_gt_2x": 3,
|
||||
"edges_lt_0.5x": 7,
|
||||
"edges_total": 3799
|
||||
},
|
||||
"aabb_post_fit": {
|
||||
"min": [
|
||||
-0.1598,
|
||||
-0.1652,
|
||||
0.824
|
||||
],
|
||||
"max": [
|
||||
0.223,
|
||||
0.1782,
|
||||
1.4946
|
||||
]
|
||||
},
|
||||
"signed_distance": {
|
||||
"verts_sampled": 2155,
|
||||
"min_signed_mm": -50.52,
|
||||
"p05_signed_mm": -18.1,
|
||||
"median_signed_mm": 25.69,
|
||||
"max_signed_mm": 85.58,
|
||||
"verts_inside_gt_3mm": 286,
|
||||
"pct_inside": 13.27
|
||||
},
|
||||
"export": {
|
||||
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_thin_f.glb",
|
||||
"size_kb": 653
|
||||
},
|
||||
"result": "OK"
|
||||
},
|
||||
"heavy_m": {
|
||||
"garment_verts": 2155,
|
||||
"segments_used": [
|
||||
"seg_torso",
|
||||
"seg_neck",
|
||||
"seg_arm_upper_l",
|
||||
"seg_arm_upper_r",
|
||||
"seg_leg_upper_l",
|
||||
"seg_leg_upper_r"
|
||||
],
|
||||
"affine": {
|
||||
"scale_xy": 1.0,
|
||||
"scale_z": 1.0,
|
||||
"translate": [
|
||||
-0.0,
|
||||
-0.0,
|
||||
-0.0
|
||||
]
|
||||
},
|
||||
"aabb_post_affine": {
|
||||
"min": [
|
||||
-0.2082,
|
||||
-0.1677,
|
||||
0.8251
|
||||
],
|
||||
"max": [
|
||||
0.2305,
|
||||
0.1739,
|
||||
1.59
|
||||
]
|
||||
},
|
||||
"warp": {
|
||||
"max_displacement_mm": 34.6,
|
||||
"smooth_iterations": 20
|
||||
},
|
||||
"zero_weight_verts": 0,
|
||||
"vgroups": 65,
|
||||
"edge_distortion": {
|
||||
"max_stretch": 1.236,
|
||||
"max_shrink": 0.839,
|
||||
"edges_gt_2x": 0,
|
||||
"edges_lt_0.5x": 0,
|
||||
"edges_total": 3799
|
||||
},
|
||||
"aabb_post_fit": {
|
||||
"min": [
|
||||
-0.22,
|
||||
-0.1776,
|
||||
0.8241
|
||||
],
|
||||
"max": [
|
||||
0.253,
|
||||
0.1837,
|
||||
1.5938
|
||||
]
|
||||
},
|
||||
"signed_distance": {
|
||||
"verts_sampled": 2155,
|
||||
"min_signed_mm": -88.67,
|
||||
"p05_signed_mm": -6.24,
|
||||
"median_signed_mm": 31.44,
|
||||
"max_signed_mm": 103.76,
|
||||
"verts_inside_gt_3mm": 148,
|
||||
"pct_inside": 6.87
|
||||
},
|
||||
"export": {
|
||||
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_heavy_m.glb",
|
||||
"size_kb": 653
|
||||
},
|
||||
"result": "OK"
|
||||
},
|
||||
"heavy_f": {
|
||||
"garment_verts": 2155,
|
||||
"segments_used": [
|
||||
"seg_torso",
|
||||
"seg_neck",
|
||||
"seg_arm_upper_l",
|
||||
"seg_arm_upper_r",
|
||||
"seg_leg_upper_l",
|
||||
"seg_leg_upper_r"
|
||||
],
|
||||
"affine": {
|
||||
"scale_xy": 0.79,
|
||||
"scale_z": 0.96031,
|
||||
"translate": [
|
||||
0.0,
|
||||
0.01173,
|
||||
0.02037
|
||||
]
|
||||
},
|
||||
"aabb_post_affine": {
|
||||
"min": [
|
||||
-0.1645,
|
||||
-0.1207,
|
||||
0.8127
|
||||
],
|
||||
"max": [
|
||||
0.1821,
|
||||
0.1491,
|
||||
1.5473
|
||||
]
|
||||
},
|
||||
"warp": {
|
||||
"max_displacement_mm": 104.0,
|
||||
"smooth_iterations": 20
|
||||
},
|
||||
"zero_weight_verts": 0,
|
||||
"vgroups": 65,
|
||||
"edge_distortion": {
|
||||
"max_stretch": 1.839,
|
||||
"max_shrink": 0.678,
|
||||
"edges_gt_2x": 0,
|
||||
"edges_lt_0.5x": 0,
|
||||
"edges_total": 3799
|
||||
},
|
||||
"aabb_post_fit": {
|
||||
"min": [
|
||||
-0.1891,
|
||||
-0.1631,
|
||||
0.8005
|
||||
],
|
||||
"max": [
|
||||
0.1965,
|
||||
0.1545,
|
||||
1.549
|
||||
]
|
||||
},
|
||||
"signed_distance": {
|
||||
"verts_sampled": 2155,
|
||||
"min_signed_mm": -69.85,
|
||||
"p05_signed_mm": -15.38,
|
||||
"median_signed_mm": 21.36,
|
||||
"max_signed_mm": 87.94,
|
||||
"verts_inside_gt_3mm": 275,
|
||||
"pct_inside": 12.76
|
||||
},
|
||||
"export": {
|
||||
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_heavy_f.glb",
|
||||
"size_kb": 653
|
||||
},
|
||||
"result": "OK"
|
||||
},
|
||||
"teen_m": {
|
||||
"garment_verts": 2155,
|
||||
"segments_used": [
|
||||
"seg_torso",
|
||||
"seg_hips",
|
||||
"seg_neck",
|
||||
"seg_arm_upper_l",
|
||||
"seg_arm_upper_r",
|
||||
"seg_leg_upper_l",
|
||||
"seg_leg_upper_r"
|
||||
],
|
||||
"affine": {
|
||||
"scale_xy": 0.73705,
|
||||
"scale_z": 0.84515,
|
||||
"translate": [
|
||||
0.0,
|
||||
0.00491,
|
||||
0.00582
|
||||
]
|
||||
},
|
||||
"aabb_post_affine": {
|
||||
"min": [
|
||||
-0.1534,
|
||||
-0.1187,
|
||||
0.7031
|
||||
],
|
||||
"max": [
|
||||
0.1699,
|
||||
0.1331,
|
||||
1.3496
|
||||
]
|
||||
},
|
||||
"warp": {
|
||||
"max_displacement_mm": 42.4,
|
||||
"smooth_iterations": 20
|
||||
},
|
||||
"zero_weight_verts": 0,
|
||||
"vgroups": 65,
|
||||
"edge_distortion": {
|
||||
"max_stretch": 1.747,
|
||||
"max_shrink": 0.819,
|
||||
"edges_gt_2x": 0,
|
||||
"edges_lt_0.5x": 0,
|
||||
"edges_total": 3799
|
||||
},
|
||||
"aabb_post_fit": {
|
||||
"min": [
|
||||
-0.1578,
|
||||
-0.1319,
|
||||
0.7008
|
||||
],
|
||||
"max": [
|
||||
0.1859,
|
||||
0.1363,
|
||||
1.3515
|
||||
]
|
||||
},
|
||||
"signed_distance": {
|
||||
"verts_sampled": 2155,
|
||||
"min_signed_mm": -63.32,
|
||||
"p05_signed_mm": -7.51,
|
||||
"median_signed_mm": 25.93,
|
||||
"max_signed_mm": 74.03,
|
||||
"verts_inside_gt_3mm": 170,
|
||||
"pct_inside": 7.89
|
||||
},
|
||||
"export": {
|
||||
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_teen_m.glb",
|
||||
"size_kb": 653
|
||||
},
|
||||
"result": "OK"
|
||||
},
|
||||
"teen_f": {
|
||||
"garment_verts": 2155,
|
||||
"segments_used": [
|
||||
"seg_torso",
|
||||
"seg_hips",
|
||||
"seg_neck",
|
||||
"seg_arm_upper_l",
|
||||
"seg_arm_upper_r",
|
||||
"seg_leg_upper_l",
|
||||
"seg_leg_upper_r"
|
||||
],
|
||||
"affine": {
|
||||
"scale_xy": 0.68165,
|
||||
"scale_z": 0.82861,
|
||||
"translate": [
|
||||
0.0,
|
||||
0.01012,
|
||||
0.01758
|
||||
]
|
||||
},
|
||||
"aabb_post_affine": {
|
||||
"min": [
|
||||
-0.1419,
|
||||
-0.1042,
|
||||
0.7013
|
||||
],
|
||||
"max": [
|
||||
0.1571,
|
||||
0.1286,
|
||||
1.3351
|
||||
]
|
||||
},
|
||||
"warp": {
|
||||
"max_displacement_mm": 46.5,
|
||||
"smooth_iterations": 20
|
||||
},
|
||||
"zero_weight_verts": 0,
|
||||
"vgroups": 65,
|
||||
"edge_distortion": {
|
||||
"max_stretch": 1.72,
|
||||
"max_shrink": 0.759,
|
||||
"edges_gt_2x": 0,
|
||||
"edges_lt_0.5x": 0,
|
||||
"edges_total": 3799
|
||||
},
|
||||
"aabb_post_fit": {
|
||||
"min": [
|
||||
-0.1403,
|
||||
-0.1322,
|
||||
0.684
|
||||
],
|
||||
"max": [
|
||||
0.1987,
|
||||
0.1183,
|
||||
1.3355
|
||||
]
|
||||
},
|
||||
"signed_distance": {
|
||||
"verts_sampled": 2155,
|
||||
"min_signed_mm": -41.05,
|
||||
"p05_signed_mm": -6.08,
|
||||
"median_signed_mm": 24.43,
|
||||
"max_signed_mm": 70.84,
|
||||
"verts_inside_gt_3mm": 120,
|
||||
"pct_inside": 5.57
|
||||
},
|
||||
"export": {
|
||||
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_teen_f.glb",
|
||||
"size_kb": 653
|
||||
},
|
||||
"result": "OK"
|
||||
},
|
||||
"child": {
|
||||
"garment_verts": 2155,
|
||||
"segments_used": [
|
||||
"seg_torso",
|
||||
"seg_hips",
|
||||
"seg_neck",
|
||||
"seg_arm_upper_l",
|
||||
"seg_arm_upper_r",
|
||||
"seg_leg_upper_l",
|
||||
"seg_leg_upper_r"
|
||||
],
|
||||
"affine": {
|
||||
"scale_xy": 0.73705,
|
||||
"scale_z": 0.84515,
|
||||
"translate": [
|
||||
0.0,
|
||||
0.00491,
|
||||
0.00582
|
||||
]
|
||||
},
|
||||
"aabb_post_affine": {
|
||||
"min": [
|
||||
-0.1534,
|
||||
-0.1187,
|
||||
0.7031
|
||||
],
|
||||
"max": [
|
||||
0.1699,
|
||||
0.1331,
|
||||
1.3496
|
||||
]
|
||||
},
|
||||
"warp": {
|
||||
"max_displacement_mm": 302.7,
|
||||
"smooth_iterations": 20
|
||||
},
|
||||
"zero_weight_verts": 0,
|
||||
"vgroups": 65,
|
||||
"edge_distortion": {
|
||||
"max_stretch": 2.619,
|
||||
"max_shrink": 0.055,
|
||||
"edges_gt_2x": 3,
|
||||
"edges_lt_0.5x": 84,
|
||||
"edges_total": 3799
|
||||
},
|
||||
"aabb_post_fit": {
|
||||
"min": [
|
||||
-0.1403,
|
||||
-0.1072,
|
||||
0.6974
|
||||
],
|
||||
"max": [
|
||||
0.1622,
|
||||
0.1086,
|
||||
1.1344
|
||||
]
|
||||
},
|
||||
"signed_distance": {
|
||||
"verts_sampled": 2155,
|
||||
"min_signed_mm": -54.44,
|
||||
"p05_signed_mm": -13.32,
|
||||
"median_signed_mm": 26.92,
|
||||
"max_signed_mm": 79.3,
|
||||
"verts_inside_gt_3mm": 187,
|
||||
"pct_inside": 8.68
|
||||
},
|
||||
"export": {
|
||||
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_child.glb",
|
||||
"size_kb": 653
|
||||
},
|
||||
"result": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,771 @@
|
||||
{
|
||||
"garment_objects": [
|
||||
{
|
||||
"name": "Armature",
|
||||
"type": "ARMATURE",
|
||||
"scale": [
|
||||
0.01,
|
||||
0.01,
|
||||
0.01
|
||||
],
|
||||
"location": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "SK_SCFI_CIVL_09_10TORS_HU01",
|
||||
"type": "MESH",
|
||||
"scale": [
|
||||
1.0,
|
||||
1.0,
|
||||
1.0
|
||||
],
|
||||
"location": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"sidekick_bones": {
|
||||
"pelvis": {
|
||||
"head": [
|
||||
0.0,
|
||||
0.02817,
|
||||
0.91021
|
||||
],
|
||||
"tail": [
|
||||
0.0,
|
||||
-0.0946,
|
||||
0.91021
|
||||
]
|
||||
},
|
||||
"thigh_l": {
|
||||
"head": [
|
||||
0.09613,
|
||||
0.03111,
|
||||
0.883
|
||||
],
|
||||
"tail": [
|
||||
0.09622,
|
||||
0.36502,
|
||||
0.87676
|
||||
]
|
||||
},
|
||||
"thigh_r": {
|
||||
"head": [
|
||||
-0.09613,
|
||||
0.03111,
|
||||
0.883
|
||||
],
|
||||
"tail": [
|
||||
-0.09604,
|
||||
-0.30279,
|
||||
0.88923
|
||||
]
|
||||
},
|
||||
"spine_01": {
|
||||
"head": [
|
||||
0.0,
|
||||
0.02533,
|
||||
0.9497
|
||||
],
|
||||
"tail": [
|
||||
0.0,
|
||||
0.09286,
|
||||
0.96049
|
||||
]
|
||||
},
|
||||
"spine_02": {
|
||||
"head": [
|
||||
0.0,
|
||||
0.01595,
|
||||
1.01744
|
||||
],
|
||||
"tail": [
|
||||
0.0,
|
||||
0.1409,
|
||||
1.03122
|
||||
]
|
||||
},
|
||||
"spine_03": {
|
||||
"head": [
|
||||
0.0,
|
||||
0.0085,
|
||||
1.14293
|
||||
],
|
||||
"tail": [
|
||||
1e-05,
|
||||
0.26263,
|
||||
1.14473
|
||||
]
|
||||
},
|
||||
"neck_01": {
|
||||
"head": [
|
||||
0.0,
|
||||
0.04285,
|
||||
1.46301
|
||||
],
|
||||
"tail": [
|
||||
0.0,
|
||||
0.13851,
|
||||
1.49219
|
||||
]
|
||||
},
|
||||
"head": {
|
||||
"head": [
|
||||
0.0,
|
||||
0.01367,
|
||||
1.55867
|
||||
],
|
||||
"tail": [
|
||||
0.0,
|
||||
0.13291,
|
||||
1.55867
|
||||
]
|
||||
},
|
||||
"clavicle_l": {
|
||||
"head": [
|
||||
0.0198,
|
||||
0.0427,
|
||||
1.36663
|
||||
],
|
||||
"tail": [
|
||||
0.01704,
|
||||
0.22638,
|
||||
1.37059
|
||||
]
|
||||
},
|
||||
"upperarm_l": {
|
||||
"head": [
|
||||
0.19434,
|
||||
0.04519,
|
||||
1.37329
|
||||
],
|
||||
"tail": [
|
||||
0.18605,
|
||||
0.25779,
|
||||
1.37358
|
||||
]
|
||||
},
|
||||
"clavicle_r": {
|
||||
"head": [
|
||||
-0.0198,
|
||||
0.0427,
|
||||
1.36663
|
||||
],
|
||||
"tail": [
|
||||
-0.02256,
|
||||
-0.14098,
|
||||
1.36267
|
||||
]
|
||||
},
|
||||
"upperarm_r": {
|
||||
"head": [
|
||||
-0.19434,
|
||||
0.04519,
|
||||
1.37329
|
||||
],
|
||||
"tail": [
|
||||
-0.18605,
|
||||
0.25779,
|
||||
1.37358
|
||||
]
|
||||
}
|
||||
},
|
||||
"garment_meshes": {
|
||||
"SK_SCFI_CIVL_09_10TORS_HU01": {
|
||||
"verts": 919,
|
||||
"bbox": {
|
||||
"min": [
|
||||
-0.21081,
|
||||
-0.18519,
|
||||
0.79682
|
||||
],
|
||||
"max": [
|
||||
0.23342,
|
||||
0.1607,
|
||||
1.49631
|
||||
],
|
||||
"dims": [
|
||||
0.44423,
|
||||
0.34589,
|
||||
0.69949
|
||||
]
|
||||
},
|
||||
"vgroups": [
|
||||
"pelvis",
|
||||
"thigh_l",
|
||||
"thigh_r",
|
||||
"spine_01",
|
||||
"spine_02",
|
||||
"spine_03",
|
||||
"neck_01",
|
||||
"head",
|
||||
"clavicle_l",
|
||||
"upperarm_l",
|
||||
"clavicle_r",
|
||||
"upperarm_r"
|
||||
],
|
||||
"shape_keys": [
|
||||
"Basis",
|
||||
"defaultBuff",
|
||||
"masculineFeminine",
|
||||
"defaultHeavy",
|
||||
"defaultSkinny"
|
||||
]
|
||||
}
|
||||
},
|
||||
"our_armature_bone_count": 65,
|
||||
"our_bones": {
|
||||
"pelvis": {
|
||||
"head": [
|
||||
-0.0,
|
||||
0.043,
|
||||
0.9491
|
||||
],
|
||||
"tail": [
|
||||
-0.0,
|
||||
0.01664,
|
||||
1.03883
|
||||
]
|
||||
},
|
||||
"thigh_l": {
|
||||
"head": [
|
||||
0.0906,
|
||||
0.036,
|
||||
0.9712
|
||||
],
|
||||
"tail": [
|
||||
0.0906,
|
||||
0.0361,
|
||||
0.5424
|
||||
]
|
||||
},
|
||||
"thigh_r": {
|
||||
"head": [
|
||||
-0.0906,
|
||||
0.036,
|
||||
0.9712
|
||||
],
|
||||
"tail": [
|
||||
-0.0906,
|
||||
0.0361,
|
||||
0.5424
|
||||
]
|
||||
},
|
||||
"spine_01": {
|
||||
"head": [
|
||||
-0.0,
|
||||
0.0069,
|
||||
1.072
|
||||
],
|
||||
"tail": [
|
||||
-0.0,
|
||||
-0.0039,
|
||||
1.1778
|
||||
]
|
||||
},
|
||||
"spine_02": {
|
||||
"head": [
|
||||
-0.0,
|
||||
-0.0039,
|
||||
1.1778
|
||||
],
|
||||
"tail": [
|
||||
-0.0,
|
||||
-0.0069,
|
||||
1.3109
|
||||
]
|
||||
},
|
||||
"spine_03": {
|
||||
"head": [
|
||||
-0.0,
|
||||
-0.0069,
|
||||
1.3109
|
||||
],
|
||||
"tail": [
|
||||
-0.0,
|
||||
0.03553,
|
||||
1.49501
|
||||
]
|
||||
},
|
||||
"neck_01": {
|
||||
"head": [
|
||||
-0.0,
|
||||
0.0414,
|
||||
1.5205
|
||||
],
|
||||
"tail": [
|
||||
-0.0,
|
||||
0.0174,
|
||||
1.5998
|
||||
]
|
||||
},
|
||||
"Head": {
|
||||
"head": [
|
||||
-0.0,
|
||||
0.0174,
|
||||
1.5998
|
||||
],
|
||||
"tail": [
|
||||
-0.0,
|
||||
0.015,
|
||||
1.68262
|
||||
]
|
||||
},
|
||||
"clavicle_l": {
|
||||
"head": [
|
||||
0.0314,
|
||||
-0.0335,
|
||||
1.4953
|
||||
],
|
||||
"tail": [
|
||||
0.20039,
|
||||
0.05906,
|
||||
1.49517
|
||||
]
|
||||
},
|
||||
"upperarm_l": {
|
||||
"head": [
|
||||
0.1919,
|
||||
0.0654,
|
||||
1.4555
|
||||
],
|
||||
"tail": [
|
||||
0.4429,
|
||||
0.073,
|
||||
1.4555
|
||||
]
|
||||
},
|
||||
"clavicle_r": {
|
||||
"head": [
|
||||
-0.0314,
|
||||
-0.0335,
|
||||
1.4953
|
||||
],
|
||||
"tail": [
|
||||
-0.20039,
|
||||
0.05906,
|
||||
1.49517
|
||||
]
|
||||
},
|
||||
"upperarm_r": {
|
||||
"head": [
|
||||
-0.1919,
|
||||
0.0654,
|
||||
1.4555
|
||||
],
|
||||
"tail": [
|
||||
-0.4429,
|
||||
0.073,
|
||||
1.4555
|
||||
]
|
||||
}
|
||||
},
|
||||
"bone_head_deltas": {
|
||||
"pelvis": {
|
||||
"delta": [
|
||||
0.0,
|
||||
-0.01483,
|
||||
-0.03889
|
||||
],
|
||||
"magnitude_m": 0.04162
|
||||
},
|
||||
"thigh_l": {
|
||||
"delta": [
|
||||
0.00553,
|
||||
-0.00489,
|
||||
-0.0882
|
||||
],
|
||||
"magnitude_m": 0.08851
|
||||
},
|
||||
"thigh_r": {
|
||||
"delta": [
|
||||
-0.00553,
|
||||
-0.00489,
|
||||
-0.0882
|
||||
],
|
||||
"magnitude_m": 0.08851
|
||||
},
|
||||
"spine_01": {
|
||||
"delta": [
|
||||
0.0,
|
||||
0.01843,
|
||||
-0.1223
|
||||
],
|
||||
"magnitude_m": 0.12368
|
||||
},
|
||||
"spine_02": {
|
||||
"delta": [
|
||||
0.0,
|
||||
0.01985,
|
||||
-0.16036
|
||||
],
|
||||
"magnitude_m": 0.16158
|
||||
},
|
||||
"spine_03": {
|
||||
"delta": [
|
||||
0.0,
|
||||
0.0154,
|
||||
-0.16797
|
||||
],
|
||||
"magnitude_m": 0.16867
|
||||
},
|
||||
"neck_01": {
|
||||
"delta": [
|
||||
0.0,
|
||||
0.00145,
|
||||
-0.05749
|
||||
],
|
||||
"magnitude_m": 0.05751
|
||||
},
|
||||
"head": {
|
||||
"delta": [
|
||||
0.0,
|
||||
-0.00373,
|
||||
-0.04113
|
||||
],
|
||||
"magnitude_m": 0.0413
|
||||
},
|
||||
"clavicle_l": {
|
||||
"delta": [
|
||||
-0.0116,
|
||||
0.0762,
|
||||
-0.12867
|
||||
],
|
||||
"magnitude_m": 0.14999
|
||||
},
|
||||
"upperarm_l": {
|
||||
"delta": [
|
||||
0.00244,
|
||||
-0.02021,
|
||||
-0.08221
|
||||
],
|
||||
"magnitude_m": 0.08469
|
||||
},
|
||||
"clavicle_r": {
|
||||
"delta": [
|
||||
0.0116,
|
||||
0.0762,
|
||||
-0.12867
|
||||
],
|
||||
"magnitude_m": 0.14999
|
||||
},
|
||||
"upperarm_r": {
|
||||
"delta": [
|
||||
-0.00244,
|
||||
-0.02021,
|
||||
-0.08221
|
||||
],
|
||||
"magnitude_m": 0.08469
|
||||
}
|
||||
},
|
||||
"our_body_segments": {
|
||||
"seg_torso": {
|
||||
"verts": 375,
|
||||
"bbox": {
|
||||
"min": [
|
||||
-0.15826,
|
||||
-0.10161,
|
||||
1.03118
|
||||
],
|
||||
"max": [
|
||||
0.15826,
|
||||
0.12067,
|
||||
1.35323
|
||||
],
|
||||
"dims": [
|
||||
0.31652,
|
||||
0.22228,
|
||||
0.32205
|
||||
]
|
||||
},
|
||||
"vgroups": [
|
||||
"root",
|
||||
"pelvis",
|
||||
"spine_01",
|
||||
"spine_02",
|
||||
"spine_03",
|
||||
"neck_01",
|
||||
"Head",
|
||||
"clavicle_l",
|
||||
"upperarm_l",
|
||||
"lowerarm_l",
|
||||
"hand_l",
|
||||
"index_01_l",
|
||||
"index_02_l",
|
||||
"index_03_l",
|
||||
"index_04_leaf_l",
|
||||
"middle_01_l",
|
||||
"middle_02_l",
|
||||
"middle_03_l",
|
||||
"middle_04_leaf_l",
|
||||
"pinky_01_l",
|
||||
"pinky_02_l",
|
||||
"pinky_03_l",
|
||||
"pinky_04_leaf_l",
|
||||
"ring_01_l",
|
||||
"ring_02_l",
|
||||
"ring_03_l",
|
||||
"ring_04_leaf_l",
|
||||
"thumb_01_l",
|
||||
"thumb_02_l",
|
||||
"thumb_03_l",
|
||||
"thumb_04_leaf_l",
|
||||
"clavicle_r",
|
||||
"upperarm_r",
|
||||
"lowerarm_r",
|
||||
"hand_r",
|
||||
"index_01_r",
|
||||
"index_02_r",
|
||||
"index_03_r",
|
||||
"index_04_leaf_r",
|
||||
"middle_01_r",
|
||||
"middle_02_r",
|
||||
"middle_03_r",
|
||||
"middle_04_leaf_r",
|
||||
"pinky_01_r",
|
||||
"pinky_02_r",
|
||||
"pinky_03_r",
|
||||
"pinky_04_leaf_r",
|
||||
"ring_01_r",
|
||||
"ring_02_r",
|
||||
"ring_03_r",
|
||||
"ring_04_leaf_r",
|
||||
"thumb_01_r",
|
||||
"thumb_02_r",
|
||||
"thumb_03_r",
|
||||
"thumb_04_leaf_r",
|
||||
"thigh_l",
|
||||
"calf_l",
|
||||
"foot_l",
|
||||
"ball_l",
|
||||
"ball_leaf_l",
|
||||
"thigh_r",
|
||||
"calf_r",
|
||||
"foot_r",
|
||||
"ball_r",
|
||||
"ball_leaf_r"
|
||||
]
|
||||
},
|
||||
"seg_hips": {
|
||||
"verts": 42,
|
||||
"bbox": {
|
||||
"min": [
|
||||
-0.95106,
|
||||
-1.0,
|
||||
-1.0
|
||||
],
|
||||
"max": [
|
||||
0.95106,
|
||||
1.0,
|
||||
1.0
|
||||
],
|
||||
"dims": [
|
||||
1.90212,
|
||||
2.0,
|
||||
2.0
|
||||
]
|
||||
},
|
||||
"vgroups": []
|
||||
},
|
||||
"seg_neck": {
|
||||
"verts": 144,
|
||||
"bbox": {
|
||||
"min": [
|
||||
-0.07635,
|
||||
-0.02606,
|
||||
1.53878
|
||||
],
|
||||
"max": [
|
||||
0.07635,
|
||||
0.10504,
|
||||
1.61028
|
||||
],
|
||||
"dims": [
|
||||
0.1527,
|
||||
0.1311,
|
||||
0.0715
|
||||
]
|
||||
},
|
||||
"vgroups": [
|
||||
"root",
|
||||
"pelvis",
|
||||
"spine_01",
|
||||
"spine_02",
|
||||
"spine_03",
|
||||
"neck_01",
|
||||
"Head",
|
||||
"clavicle_l",
|
||||
"upperarm_l",
|
||||
"lowerarm_l",
|
||||
"hand_l",
|
||||
"index_01_l",
|
||||
"index_02_l",
|
||||
"index_03_l",
|
||||
"index_04_leaf_l",
|
||||
"middle_01_l",
|
||||
"middle_02_l",
|
||||
"middle_03_l",
|
||||
"middle_04_leaf_l",
|
||||
"pinky_01_l",
|
||||
"pinky_02_l",
|
||||
"pinky_03_l",
|
||||
"pinky_04_leaf_l",
|
||||
"ring_01_l",
|
||||
"ring_02_l",
|
||||
"ring_03_l",
|
||||
"ring_04_leaf_l",
|
||||
"thumb_01_l",
|
||||
"thumb_02_l",
|
||||
"thumb_03_l",
|
||||
"thumb_04_leaf_l",
|
||||
"clavicle_r",
|
||||
"upperarm_r",
|
||||
"lowerarm_r",
|
||||
"hand_r",
|
||||
"index_01_r",
|
||||
"index_02_r",
|
||||
"index_03_r",
|
||||
"index_04_leaf_r",
|
||||
"middle_01_r",
|
||||
"middle_02_r",
|
||||
"middle_03_r",
|
||||
"middle_04_leaf_r",
|
||||
"pinky_01_r",
|
||||
"pinky_02_r",
|
||||
"pinky_03_r",
|
||||
"pinky_04_leaf_r",
|
||||
"ring_01_r",
|
||||
"ring_02_r",
|
||||
"ring_03_r",
|
||||
"ring_04_leaf_r",
|
||||
"thumb_01_r",
|
||||
"thumb_02_r",
|
||||
"thumb_03_r",
|
||||
"thumb_04_leaf_r",
|
||||
"thigh_l",
|
||||
"calf_l",
|
||||
"foot_l",
|
||||
"ball_l",
|
||||
"ball_leaf_l",
|
||||
"thigh_r",
|
||||
"calf_r",
|
||||
"foot_r",
|
||||
"ball_r",
|
||||
"ball_leaf_r"
|
||||
]
|
||||
},
|
||||
"seg_arm_upper_l": {
|
||||
"verts": 183,
|
||||
"bbox": {
|
||||
"min": [
|
||||
0.15183,
|
||||
-0.00827,
|
||||
1.38075
|
||||
],
|
||||
"max": [
|
||||
0.44693,
|
||||
0.13217,
|
||||
1.51998
|
||||
],
|
||||
"dims": [
|
||||
0.2951,
|
||||
0.14044,
|
||||
0.13923
|
||||
]
|
||||
},
|
||||
"vgroups": [
|
||||
"root",
|
||||
"pelvis",
|
||||
"spine_01",
|
||||
"spine_02",
|
||||
"spine_03",
|
||||
"neck_01",
|
||||
"Head",
|
||||
"clavicle_l",
|
||||
"upperarm_l",
|
||||
"lowerarm_l",
|
||||
"hand_l",
|
||||
"index_01_l",
|
||||
"index_02_l",
|
||||
"index_03_l",
|
||||
"index_04_leaf_l",
|
||||
"middle_01_l",
|
||||
"middle_02_l",
|
||||
"middle_03_l",
|
||||
"middle_04_leaf_l",
|
||||
"pinky_01_l",
|
||||
"pinky_02_l",
|
||||
"pinky_03_l",
|
||||
"pinky_04_leaf_l",
|
||||
"ring_01_l",
|
||||
"ring_02_l",
|
||||
"ring_03_l",
|
||||
"ring_04_leaf_l",
|
||||
"thumb_01_l",
|
||||
"thumb_02_l",
|
||||
"thumb_03_l",
|
||||
"thumb_04_leaf_l",
|
||||
"clavicle_r",
|
||||
"upperarm_r",
|
||||
"lowerarm_r",
|
||||
"hand_r",
|
||||
"index_01_r",
|
||||
"index_02_r",
|
||||
"index_03_r",
|
||||
"index_04_leaf_r",
|
||||
"middle_01_r",
|
||||
"middle_02_r",
|
||||
"middle_03_r",
|
||||
"middle_04_leaf_r",
|
||||
"pinky_01_r",
|
||||
"pinky_02_r",
|
||||
"pinky_03_r",
|
||||
"pinky_04_leaf_r",
|
||||
"ring_01_r",
|
||||
"ring_02_r",
|
||||
"ring_03_r",
|
||||
"ring_04_leaf_r",
|
||||
"thumb_01_r",
|
||||
"thumb_02_r",
|
||||
"thumb_03_r",
|
||||
"thumb_04_leaf_r",
|
||||
"thigh_l",
|
||||
"calf_l",
|
||||
"foot_l",
|
||||
"ball_l",
|
||||
"ball_leaf_l",
|
||||
"thigh_r",
|
||||
"calf_r",
|
||||
"foot_r",
|
||||
"ball_r",
|
||||
"ball_leaf_r"
|
||||
]
|
||||
},
|
||||
"seg_arm_upper_r": {
|
||||
"verts": 42,
|
||||
"bbox": {
|
||||
"min": [
|
||||
-0.95106,
|
||||
-1.0,
|
||||
-1.0
|
||||
],
|
||||
"max": [
|
||||
0.95106,
|
||||
1.0,
|
||||
1.0
|
||||
],
|
||||
"dims": [
|
||||
1.90212,
|
||||
2.0,
|
||||
2.0
|
||||
]
|
||||
},
|
||||
"vgroups": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"garment": {
|
||||
"verts": 919,
|
||||
"sidekick_vgroups": [
|
||||
"pelvis",
|
||||
"thigh_l",
|
||||
"thigh_r",
|
||||
"spine_01",
|
||||
"spine_02",
|
||||
"spine_03",
|
||||
"neck_01",
|
||||
"head",
|
||||
"clavicle_l",
|
||||
"upperarm_l",
|
||||
"clavicle_r",
|
||||
"upperarm_r"
|
||||
]
|
||||
},
|
||||
"alignment": {
|
||||
"scale_xy": 0.98744,
|
||||
"scale_z": 1.09356,
|
||||
"translate": [
|
||||
-0.0,
|
||||
0.01518,
|
||||
-0.04628
|
||||
],
|
||||
"sk_pelvis": [
|
||||
0.0,
|
||||
0.02817,
|
||||
0.91021
|
||||
],
|
||||
"our_pelvis": [
|
||||
-0.0,
|
||||
0.043,
|
||||
0.9491
|
||||
],
|
||||
"sk_upperarm_l": [
|
||||
0.19434,
|
||||
0.04519,
|
||||
1.37329
|
||||
],
|
||||
"our_upperarm_l": [
|
||||
0.1919,
|
||||
0.0654,
|
||||
1.4555
|
||||
]
|
||||
},
|
||||
"post_affine_bone_residuals": {
|
||||
"pelvis": {
|
||||
"residual_m": 0.0,
|
||||
"delta": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"thigh_l": {
|
||||
"residual_m": 0.05298,
|
||||
"delta": [
|
||||
0.00432,
|
||||
0.00991,
|
||||
-0.05186
|
||||
]
|
||||
},
|
||||
"thigh_r": {
|
||||
"residual_m": 0.05298,
|
||||
"delta": [
|
||||
-0.00433,
|
||||
0.00991,
|
||||
-0.05186
|
||||
]
|
||||
},
|
||||
"spine_01": {
|
||||
"residual_m": 0.08639,
|
||||
"delta": [
|
||||
-0.0,
|
||||
0.03329,
|
||||
-0.07972
|
||||
]
|
||||
},
|
||||
"spine_02": {
|
||||
"residual_m": 0.11676,
|
||||
"delta": [
|
||||
-0.0,
|
||||
0.03483,
|
||||
-0.11144
|
||||
]
|
||||
},
|
||||
"spine_03": {
|
||||
"residual_m": 0.11156,
|
||||
"delta": [
|
||||
-0.0,
|
||||
0.03047,
|
||||
-0.10732
|
||||
]
|
||||
},
|
||||
"neck_01": {
|
||||
"residual_m": 0.03682,
|
||||
"delta": [
|
||||
-0.0,
|
||||
0.0161,
|
||||
0.03311
|
||||
]
|
||||
},
|
||||
"head": {
|
||||
"residual_m": 0.0595,
|
||||
"delta": [
|
||||
-0.0,
|
||||
0.01128,
|
||||
0.05843
|
||||
]
|
||||
},
|
||||
"clavicle_l": {
|
||||
"residual_m": 0.10301,
|
||||
"delta": [
|
||||
-0.01185,
|
||||
0.09085,
|
||||
-0.04708
|
||||
]
|
||||
},
|
||||
"upperarm_l": {
|
||||
"residual_m": 0.00559,
|
||||
"delta": [
|
||||
-0.0,
|
||||
-0.00559,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"clavicle_r": {
|
||||
"residual_m": 0.10301,
|
||||
"delta": [
|
||||
0.01185,
|
||||
0.09085,
|
||||
-0.04708
|
||||
]
|
||||
},
|
||||
"upperarm_r": {
|
||||
"residual_m": 0.00559,
|
||||
"delta": [
|
||||
-0.0,
|
||||
-0.00559,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"weight_source_segments": [
|
||||
{
|
||||
"name": "RegularMale.001",
|
||||
"verts": 375
|
||||
},
|
||||
{
|
||||
"name": "RegularMale.002",
|
||||
"verts": 255
|
||||
},
|
||||
{
|
||||
"name": "RegularMale.003",
|
||||
"verts": 144
|
||||
},
|
||||
{
|
||||
"name": "RegularMale.004",
|
||||
"verts": 183
|
||||
},
|
||||
{
|
||||
"name": "RegularMale.005",
|
||||
"verts": 183
|
||||
},
|
||||
{
|
||||
"name": "RegularMale.006",
|
||||
"verts": 240
|
||||
},
|
||||
{
|
||||
"name": "RegularMale.007",
|
||||
"verts": 241
|
||||
}
|
||||
],
|
||||
"transferred_vgroups": [
|
||||
"root",
|
||||
"pelvis",
|
||||
"spine_01",
|
||||
"spine_02",
|
||||
"spine_03",
|
||||
"neck_01",
|
||||
"Head",
|
||||
"clavicle_l",
|
||||
"upperarm_l",
|
||||
"lowerarm_l",
|
||||
"hand_l",
|
||||
"index_01_l",
|
||||
"index_02_l",
|
||||
"index_03_l",
|
||||
"index_04_leaf_l",
|
||||
"middle_01_l",
|
||||
"middle_02_l",
|
||||
"middle_03_l",
|
||||
"middle_04_leaf_l",
|
||||
"pinky_01_l",
|
||||
"pinky_02_l",
|
||||
"pinky_03_l",
|
||||
"pinky_04_leaf_l",
|
||||
"ring_01_l",
|
||||
"ring_02_l",
|
||||
"ring_03_l",
|
||||
"ring_04_leaf_l",
|
||||
"thumb_01_l",
|
||||
"thumb_02_l",
|
||||
"thumb_03_l",
|
||||
"thumb_04_leaf_l",
|
||||
"clavicle_r",
|
||||
"upperarm_r",
|
||||
"lowerarm_r",
|
||||
"hand_r",
|
||||
"index_01_r",
|
||||
"index_02_r",
|
||||
"index_03_r",
|
||||
"index_04_leaf_r",
|
||||
"middle_01_r",
|
||||
"middle_02_r",
|
||||
"middle_03_r",
|
||||
"middle_04_leaf_r",
|
||||
"pinky_01_r",
|
||||
"pinky_02_r",
|
||||
"pinky_03_r",
|
||||
"pinky_04_leaf_r",
|
||||
"ring_01_r",
|
||||
"ring_02_r",
|
||||
"ring_03_r",
|
||||
"ring_04_leaf_r",
|
||||
"thumb_01_r",
|
||||
"thumb_02_r",
|
||||
"thumb_03_r",
|
||||
"thumb_04_leaf_r",
|
||||
"thigh_l",
|
||||
"calf_l",
|
||||
"foot_l",
|
||||
"ball_l",
|
||||
"ball_leaf_l",
|
||||
"thigh_r",
|
||||
"calf_r",
|
||||
"foot_r",
|
||||
"ball_r",
|
||||
"ball_leaf_r"
|
||||
],
|
||||
"zero_weight_verts": 0,
|
||||
"export": {
|
||||
"path": "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/SK_SCFI_CIVL_09_10TORS_HU01_quaternius.glb",
|
||||
"size_kb": 653
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Spike T-1089 / Synty Sidekick intake — step 1: rest-pose recon.
|
||||
|
||||
Imports the Sidekick garment FBX (with its armature) and our Quaternius
|
||||
armature.glb + average_m body segments, then prints:
|
||||
- world-space bone head/tail positions for the 12 vertex-group bones
|
||||
in BOTH armatures + per-bone delta
|
||||
- bounding boxes of the garment vs our torso-region body segments
|
||||
- object transforms as-imported (scale factors etc.)
|
||||
|
||||
Run:
|
||||
tooling/blender --background --python \
|
||||
spikes/synty-intake/scripts/01_recon_rest_pose.py
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import json
|
||||
import os
|
||||
|
||||
REPO = "/var/mnt/data/projects/settled-reach"
|
||||
GARMENT_FBX = os.path.join(
|
||||
REPO, "spikes/synty-intake/raw/Assets/Synty/SidekickCharacters/Resources",
|
||||
"Meshes/Outfits/Starter/SK_SCFI_CIVL_09_10TORS_HU01.fbx")
|
||||
OUR_ARMATURE_GLB = os.path.join(REPO, "client/assets/characters/skeleton/armature.glb")
|
||||
BODY_DIR = os.path.join(REPO, "client/assets/characters/bodies/average_m")
|
||||
BODY_SEGS = ["seg_torso", "seg_hips", "seg_neck", "seg_arm_upper_l", "seg_arm_upper_r"]
|
||||
|
||||
BOUND_BONES = ["pelvis", "thigh_l", "thigh_r", "spine_01", "spine_02", "spine_03",
|
||||
"neck_01", "head", "clavicle_l", "upperarm_l", "clavicle_r",
|
||||
"upperarm_r"]
|
||||
|
||||
OUT_JSON = os.path.join(REPO, "spikes/synty-intake/out/recon_rest_pose.json")
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
|
||||
|
||||
def import_new(op, path, **kw):
|
||||
before = set(bpy.data.objects)
|
||||
op(filepath=path, **kw)
|
||||
return list(set(bpy.data.objects) - before)
|
||||
|
||||
|
||||
def find_armature(objs):
|
||||
for o in objs:
|
||||
if o.type == 'ARMATURE':
|
||||
return o
|
||||
return None
|
||||
|
||||
|
||||
def bone_world(arm_obj, name):
|
||||
b = arm_obj.data.bones.get(name)
|
||||
if b is None:
|
||||
return None
|
||||
mw = arm_obj.matrix_world
|
||||
h = mw @ b.head_local
|
||||
t = mw @ b.tail_local
|
||||
return {"head": [round(v, 5) for v in h], "tail": [round(v, 5) for v in t]}
|
||||
|
||||
|
||||
def mesh_world_bbox(obj):
|
||||
import mathutils
|
||||
pts = [obj.matrix_world @ mathutils.Vector(c) for c in obj.bound_box]
|
||||
lo = [round(min(p[i] for p in pts), 5) for i in range(3)]
|
||||
hi = [round(max(p[i] for p in pts), 5) for i in range(3)]
|
||||
return {"min": lo, "max": hi,
|
||||
"dims": [round(hi[i] - lo[i], 5) for i in range(3)]}
|
||||
|
||||
|
||||
clear_scene()
|
||||
|
||||
report = {}
|
||||
|
||||
# --- garment FBX ---
|
||||
g_objs = import_new(bpy.ops.import_scene.fbx, GARMENT_FBX)
|
||||
g_arm = find_armature(g_objs)
|
||||
g_meshes = [o for o in g_objs if o.type == 'MESH']
|
||||
report["garment_objects"] = [
|
||||
{"name": o.name, "type": o.type,
|
||||
"scale": [round(s, 6) for s in o.scale],
|
||||
"location": [round(v, 6) for v in o.location]} for o in g_objs]
|
||||
report["sidekick_bones"] = {}
|
||||
for bn in BOUND_BONES:
|
||||
report["sidekick_bones"][bn] = bone_world(g_arm, bn)
|
||||
report["garment_meshes"] = {}
|
||||
for m in g_meshes:
|
||||
report["garment_meshes"][m.name] = {
|
||||
"verts": len(m.data.vertices),
|
||||
"bbox": mesh_world_bbox(m),
|
||||
"vgroups": [vg.name for vg in m.vertex_groups],
|
||||
"shape_keys": ([kb.name for kb in m.data.shape_keys.key_blocks]
|
||||
if m.data.shape_keys else []),
|
||||
}
|
||||
|
||||
# --- our armature ---
|
||||
o_objs = import_new(bpy.ops.import_scene.gltf, OUR_ARMATURE_GLB)
|
||||
o_arm = find_armature(o_objs)
|
||||
report["our_armature_bone_count"] = len(o_arm.data.bones)
|
||||
report["our_bones"] = {}
|
||||
deltas = {}
|
||||
for bn in BOUND_BONES:
|
||||
ours = bn if bn != "head" else "Head"
|
||||
info = bone_world(o_arm, ours)
|
||||
report["our_bones"][ours] = info
|
||||
sk = report["sidekick_bones"][bn]
|
||||
if info and sk:
|
||||
d = [round(sk["head"][i] - info["head"][i], 5) for i in range(3)]
|
||||
mag = round(sum(x * x for x in d) ** 0.5, 5)
|
||||
deltas[bn] = {"delta": d, "magnitude_m": mag}
|
||||
report["bone_head_deltas"] = deltas
|
||||
|
||||
# --- our body segments (proportion reference) ---
|
||||
report["our_body_segments"] = {}
|
||||
for seg in BODY_SEGS:
|
||||
path = os.path.join(BODY_DIR, seg + ".glb")
|
||||
objs = import_new(bpy.ops.import_scene.gltf, path)
|
||||
for m in objs:
|
||||
if m.type == 'MESH':
|
||||
report["our_body_segments"][seg] = {
|
||||
"verts": len(m.data.vertices),
|
||||
"bbox": mesh_world_bbox(m),
|
||||
"vgroups": [vg.name for vg in m.vertex_groups],
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(OUT_JSON), exist_ok=True)
|
||||
with open(OUT_JSON, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
|
||||
print("\n=== RECON SUMMARY ===")
|
||||
print(json.dumps(deltas, indent=2))
|
||||
print("Garment bbox:", json.dumps(
|
||||
{k: v["bbox"]["dims"] for k, v in report["garment_meshes"].items()}))
|
||||
print("Body seg bbox:", json.dumps(
|
||||
{k: v["bbox"]["dims"] for k, v in report["our_body_segments"].items()}))
|
||||
print("Report written:", OUT_JSON)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Spike T-1089 / Synty Sidekick intake — step 2: transplant.
|
||||
|
||||
Route decision (from 01_recon_rest_pose.py, out/recon_rest_pose.json):
|
||||
Route 1 (rename-and-rebind, keep Sidekick weights verbatim) REJECTED on
|
||||
measured evidence: spine_02/spine_03 joint heads sit 0.160/0.168 m lower in
|
||||
the Sidekick rig than ours, shoulders 0.082 m lower, clavicles 0.150 m off.
|
||||
Rest pose would render fine (inverse binds absorb it) but every spine/shoulder
|
||||
rotation would pivot the garment around joints up to 17 cm away from where the
|
||||
body underneath pivots -> swim/clip under animation.
|
||||
Route 2 (proven pipeline): affine-align garment onto our average_m body,
|
||||
then Data Transfer POLYINTERP_NEAREST weights from OUR body mesh, normalize,
|
||||
bind to OUR armature.
|
||||
|
||||
Alignment transform (documented, measured from bone landmarks):
|
||||
scale XY by ours_shoulder_x / sk_shoulder_x
|
||||
scale Z by (ours_upperarm_z - ours_pelvis_z) / (sk_upperarm_z - sk_pelvis_z)
|
||||
then translate so the pelvis landmarks coincide.
|
||||
|
||||
Run:
|
||||
tooling/blender --background --python \
|
||||
spikes/synty-intake/scripts/02_transplant_garment.py
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import json
|
||||
import os
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
REPO = "/var/mnt/data/projects/settled-reach"
|
||||
GARMENT_FBX = os.path.join(
|
||||
REPO, "spikes/synty-intake/raw/Assets/Synty/SidekickCharacters/Resources",
|
||||
"Meshes/Outfits/Starter/SK_SCFI_CIVL_09_10TORS_HU01.fbx")
|
||||
OUR_ARMATURE_GLB = os.path.join(REPO, "client/assets/characters/skeleton/armature.glb")
|
||||
BODY_DIR = os.path.join(REPO, "client/assets/characters/bodies/average_m")
|
||||
# Segments covered by / adjacent to the torso garment — the weight-transfer source.
|
||||
BODY_SEGS = ["seg_torso", "seg_hips", "seg_neck",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r"]
|
||||
|
||||
OUT_GLB = os.path.join(REPO, "spikes/synty-intake/out",
|
||||
"SK_SCFI_CIVL_09_10TORS_HU01_quaternius.glb")
|
||||
OUT_LOG = os.path.join(REPO, "spikes/synty-intake/out", "transplant_log.json")
|
||||
|
||||
log = {}
|
||||
|
||||
|
||||
def import_new(op, path, **kw):
|
||||
before = set(bpy.data.objects)
|
||||
op(filepath=path, **kw)
|
||||
return list(set(bpy.data.objects) - before)
|
||||
|
||||
|
||||
def find_armature(objs):
|
||||
for o in objs:
|
||||
if o.type == 'ARMATURE':
|
||||
return o
|
||||
return None
|
||||
|
||||
|
||||
def bone_head_world(arm, name):
|
||||
b = arm.data.bones.get(name)
|
||||
return (arm.matrix_world @ b.head_local) if b else None
|
||||
|
||||
|
||||
def deselect_all():
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
|
||||
def set_active(obj):
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
|
||||
# --- fresh scene ---
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
|
||||
# --- 1. import garment ---
|
||||
g_objs = import_new(bpy.ops.import_scene.fbx, GARMENT_FBX)
|
||||
g_arm = find_armature(g_objs)
|
||||
garment = next(o for o in g_objs if o.type == 'MESH')
|
||||
log["garment"] = {"verts": len(garment.data.vertices),
|
||||
"sidekick_vgroups": [vg.name for vg in garment.vertex_groups]}
|
||||
|
||||
# landmarks in Sidekick space (world)
|
||||
sk_pelvis = bone_head_world(g_arm, "pelvis").copy()
|
||||
sk_upperarm = bone_head_world(g_arm, "upperarm_l").copy()
|
||||
|
||||
# --- 2. import our armature ---
|
||||
o_objs = import_new(bpy.ops.import_scene.gltf, OUR_ARMATURE_GLB)
|
||||
our_arm = find_armature(o_objs)
|
||||
our_pelvis = bone_head_world(our_arm, "pelvis").copy()
|
||||
our_upperarm = bone_head_world(our_arm, "upperarm_l").copy()
|
||||
|
||||
# --- 3. detach garment from Sidekick armature, freeze world coords ---
|
||||
deselect_all()
|
||||
set_active(garment)
|
||||
garment.select_set(True)
|
||||
mw = garment.matrix_world.copy()
|
||||
garment.parent = None
|
||||
garment.matrix_world = mw
|
||||
for mod in list(garment.modifiers):
|
||||
garment.modifiers.remove(mod)
|
||||
# drop Sidekick body-blend shape keys (not part of our body-type system)
|
||||
if garment.data.shape_keys:
|
||||
set_active(garment)
|
||||
bpy.ops.object.shape_key_remove(all=True)
|
||||
# bake object transform into mesh data so object space == world space
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
# --- 4. affine alignment garment -> our proportions ---
|
||||
sxy = our_upperarm.x / sk_upperarm.x
|
||||
sz = (our_upperarm.z - our_pelvis.z) / (sk_upperarm.z - sk_pelvis.z)
|
||||
S = Matrix.Diagonal(Vector((sxy, sxy, sz, 1.0)))
|
||||
scaled_pelvis = Vector((sk_pelvis.x * sxy, sk_pelvis.y * sxy, sk_pelvis.z * sz))
|
||||
t = our_pelvis - scaled_pelvis
|
||||
M = Matrix.Translation(t) @ S
|
||||
garment.data.transform(M)
|
||||
garment.data.update()
|
||||
|
||||
log["alignment"] = {
|
||||
"scale_xy": round(sxy, 5), "scale_z": round(sz, 5),
|
||||
"translate": [round(v, 5) for v in t],
|
||||
"sk_pelvis": [round(v, 5) for v in sk_pelvis],
|
||||
"our_pelvis": [round(v, 5) for v in our_pelvis],
|
||||
"sk_upperarm_l": [round(v, 5) for v in sk_upperarm],
|
||||
"our_upperarm_l": [round(v, 5) for v in our_upperarm],
|
||||
}
|
||||
|
||||
# residual landmark error after affine, for all 12 bound bones
|
||||
residuals = {}
|
||||
for bn in ["pelvis", "thigh_l", "thigh_r", "spine_01", "spine_02", "spine_03",
|
||||
"neck_01", "head", "clavicle_l", "upperarm_l", "clavicle_r",
|
||||
"upperarm_r"]:
|
||||
sk = bone_head_world(g_arm, bn)
|
||||
ours = bone_head_world(our_arm, bn if bn != "head" else "Head")
|
||||
sk_t = M @ sk
|
||||
d = sk_t - ours
|
||||
residuals[bn] = {"residual_m": round(d.length, 5),
|
||||
"delta": [round(v, 5) for v in d]}
|
||||
log["post_affine_bone_residuals"] = residuals
|
||||
|
||||
# --- 5. delete Sidekick armature ---
|
||||
deselect_all()
|
||||
g_arm.select_set(True)
|
||||
set_active(g_arm)
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
|
||||
# --- 6. import + join our body segments as the weight-transfer source ---
|
||||
seg_meshes = []
|
||||
for seg in BODY_SEGS:
|
||||
objs = import_new(bpy.ops.import_scene.gltf,
|
||||
os.path.join(BODY_DIR, seg + ".glb"))
|
||||
picked = None
|
||||
for o in objs:
|
||||
if o.type == 'MESH' and len(o.vertex_groups) > 0:
|
||||
picked = o
|
||||
elif o.type == 'MESH':
|
||||
# debris (bounds icosphere etc.) — delete
|
||||
deselect_all()
|
||||
o.select_set(True)
|
||||
set_active(o)
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
# delete the segment's own armature copy
|
||||
arm = find_armature(objs)
|
||||
if arm and picked is not None:
|
||||
# unparent mesh first, keep transform
|
||||
pmw = picked.matrix_world.copy()
|
||||
picked.parent = None
|
||||
picked.matrix_world = pmw
|
||||
if arm:
|
||||
deselect_all()
|
||||
arm.select_set(True)
|
||||
set_active(arm)
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
if picked is None:
|
||||
print(f"WARNING: no weighted mesh in {seg}")
|
||||
continue
|
||||
for mod in list(picked.modifiers):
|
||||
picked.modifiers.remove(mod)
|
||||
seg_meshes.append(picked)
|
||||
|
||||
log["weight_source_segments"] = [
|
||||
{"name": m.name, "verts": len(m.data.vertices)} for m in seg_meshes]
|
||||
|
||||
deselect_all()
|
||||
for m in seg_meshes:
|
||||
m.select_set(True)
|
||||
set_active(seg_meshes[0])
|
||||
if len(seg_meshes) > 1:
|
||||
bpy.ops.object.join()
|
||||
body = bpy.context.view_layer.objects.active
|
||||
body.name = "weight_source_body"
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
# --- 7. weight transfer: our body -> garment ---
|
||||
# wipe Sidekick vertex groups first so only OUR weights remain
|
||||
for vg in list(garment.vertex_groups):
|
||||
garment.vertex_groups.remove(vg)
|
||||
|
||||
deselect_all()
|
||||
set_active(garment)
|
||||
garment.select_set(True)
|
||||
|
||||
dt = garment.modifiers.new(name="WeightTransfer", type='DATA_TRANSFER')
|
||||
dt.object = body
|
||||
dt.use_vert_data = True
|
||||
dt.data_types_verts = {'VGROUP_WEIGHTS'}
|
||||
dt.vert_mapping = 'POLYINTERP_NEAREST'
|
||||
dt.layers_vgroup_select_src = 'ALL'
|
||||
dt.layers_vgroup_select_dst = 'NAME'
|
||||
bpy.ops.object.datalayout_transfer(modifier=dt.name)
|
||||
bpy.ops.object.modifier_apply(modifier=dt.name)
|
||||
|
||||
# normalize
|
||||
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
|
||||
bpy.ops.object.vertex_group_normalize_all(lock_active=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
log["transferred_vgroups"] = [vg.name for vg in garment.vertex_groups]
|
||||
|
||||
# sanity: count verts with (near-)zero total weight
|
||||
zero = 0
|
||||
for v in garment.data.vertices:
|
||||
tot = sum(g.weight for g in v.groups)
|
||||
if tot < 1e-4:
|
||||
zero += 1
|
||||
log["zero_weight_verts"] = zero
|
||||
|
||||
# --- 8. bind garment to OUR armature ---
|
||||
garment.parent = our_arm
|
||||
garment.matrix_parent_inverse = our_arm.matrix_world.inverted()
|
||||
am = garment.modifiers.new(name="Armature", type='ARMATURE')
|
||||
am.object = our_arm
|
||||
|
||||
# --- 9. delete the body source, export armature + garment ---
|
||||
deselect_all()
|
||||
body.select_set(True)
|
||||
set_active(body)
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
|
||||
os.makedirs(os.path.dirname(OUT_GLB), exist_ok=True)
|
||||
deselect_all()
|
||||
our_arm.select_set(True)
|
||||
garment.select_set(True)
|
||||
set_active(our_arm)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=OUT_GLB,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=False,
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
export_morph=False,
|
||||
export_extras=False,
|
||||
)
|
||||
log["export"] = {"path": OUT_GLB, "size_kb": os.path.getsize(OUT_GLB) // 1024}
|
||||
|
||||
with open(OUT_LOG, "w") as f:
|
||||
json.dump(log, f, indent=2)
|
||||
|
||||
print("\n=== TRANSPLANT SUMMARY ===")
|
||||
print(json.dumps(log, indent=2))
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Spike T-1089 / Synty Sidekick intake — step 3 recon: the 11 body types.
|
||||
|
||||
Questions this answers (feeds 04_batch_fit_bodies.py):
|
||||
1. Do the garment-adjacent segments (torso/hips/neck/upper arms/upper legs)
|
||||
share topology (vert/poly counts) across all 11 body types? If yes, the
|
||||
Surface-Deform refit can be driven by direct vertex correspondence
|
||||
(bind SD on average_m, write target coords into the reference mesh).
|
||||
2. Do the per-segment embedded armatures differ across bodies (rest-pose
|
||||
bake), or is it the shared skeleton everywhere?
|
||||
|
||||
Run:
|
||||
tooling/blender --background --python \
|
||||
spikes/synty-intake/scripts/03_recon_bodies.py
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import json
|
||||
import os
|
||||
|
||||
REPO = "/var/mnt/data/projects/settled-reach"
|
||||
BODIES_DIR = os.path.join(REPO, "client/assets/characters/bodies")
|
||||
OUT_JSON = os.path.join(REPO, "spikes/synty-intake/out/bodies_recon.json")
|
||||
|
||||
BODY_TYPES = ["average_f", "average_m", "child", "heavy_f", "heavy_m",
|
||||
"muscular_f", "muscular_m", "teen_f", "teen_m",
|
||||
"thin_f", "thin_m"]
|
||||
|
||||
SEGS = ["seg_torso", "seg_hips", "seg_neck",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r"]
|
||||
|
||||
LANDMARK_BONES = ["pelvis", "spine_01", "spine_02", "spine_03",
|
||||
"clavicle_l", "upperarm_l", "thigh_l", "neck_01", "Head"]
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
for block_list in (bpy.data.meshes, bpy.data.armatures, bpy.data.images,
|
||||
bpy.data.materials):
|
||||
for block in list(block_list):
|
||||
if block.users == 0:
|
||||
block_list.remove(block)
|
||||
|
||||
|
||||
def import_new(path):
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
return list(set(bpy.data.objects) - before)
|
||||
|
||||
|
||||
report = {}
|
||||
|
||||
for body in BODY_TYPES:
|
||||
body_dir = os.path.join(BODIES_DIR, body)
|
||||
entry = {"segments": {}, "armature": None}
|
||||
for seg in SEGS:
|
||||
path = os.path.join(body_dir, seg + ".glb")
|
||||
if not os.path.exists(path):
|
||||
entry["segments"][seg] = None
|
||||
continue
|
||||
clear_scene()
|
||||
objs = import_new(path)
|
||||
mesh = None
|
||||
for o in objs:
|
||||
if o.type == 'MESH' and len(o.vertex_groups) > 0:
|
||||
mesh = o
|
||||
arm = None
|
||||
for o in objs:
|
||||
if o.type == 'ARMATURE':
|
||||
arm = o
|
||||
seg_info = {}
|
||||
if mesh is not None:
|
||||
seg_info["verts"] = len(mesh.data.vertices)
|
||||
seg_info["polys"] = len(mesh.data.polygons)
|
||||
seg_info["vgroups"] = len(mesh.vertex_groups)
|
||||
entry["segments"][seg] = seg_info
|
||||
# record armature landmarks once per body (from seg_torso)
|
||||
if seg == "seg_torso" and arm is not None:
|
||||
lm = {}
|
||||
for bn in LANDMARK_BONES:
|
||||
b = arm.data.bones.get(bn)
|
||||
if b is None:
|
||||
b = arm.data.bones.get(bn.lower())
|
||||
if b is not None:
|
||||
w = arm.matrix_world @ b.head_local
|
||||
lm[bn] = [round(v, 5) for v in w]
|
||||
entry["armature"] = {
|
||||
"bones": len(arm.data.bones),
|
||||
"landmarks": lm,
|
||||
}
|
||||
report[body] = entry
|
||||
|
||||
# cross-body topology comparison vs average_m
|
||||
ref = report["average_m"]["segments"]
|
||||
topo = {}
|
||||
for body in BODY_TYPES:
|
||||
diffs = []
|
||||
for seg in SEGS:
|
||||
a = ref.get(seg)
|
||||
b = report[body]["segments"].get(seg)
|
||||
if not a or not b:
|
||||
diffs.append(f"{seg}: missing")
|
||||
elif (a["verts"], a["polys"]) != (b["verts"], b["polys"]):
|
||||
diffs.append(f"{seg}: {b['verts']}v/{b['polys']}p vs "
|
||||
f"ref {a['verts']}v/{a['polys']}p")
|
||||
topo[body] = diffs if diffs else "MATCH"
|
||||
report["_topology_vs_average_m"] = topo
|
||||
|
||||
os.makedirs(os.path.dirname(OUT_JSON), exist_ok=True)
|
||||
with open(OUT_JSON, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
|
||||
print("\n=== BODIES RECON ===")
|
||||
print(json.dumps(report, indent=2))
|
||||
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
Spike T-1089 / Synty Sidekick intake — step 4: 11-body batch fit.
|
||||
|
||||
Takes the transplanted torso garment (fitted to average_m, bound to our shared
|
||||
armature = the average_m bind skeleton) through a multi-body refit, following
|
||||
the fit_outfits_to_bodies.py Surface-Deform approach, adapted for the measured
|
||||
reality that the 11 bodies do NOT share topology (03_recon_bodies.py:
|
||||
every body is independently authored; thin_*/heavy_* even lack seg_hips).
|
||||
|
||||
Per target body:
|
||||
1. Import the transplanted garment GLB; capture reference (average_m)
|
||||
landmarks from its armature; detach + freeze the garment mesh.
|
||||
2. Import the average_m reference segments, join -> ref_body.
|
||||
3. Import the target body segments (skipping missing ones), keep the
|
||||
seg_torso armature as the target bind skeleton, join -> tgt_body.
|
||||
4. Landmark affine (same machinery as 02): scale XY by shoulder ratio,
|
||||
scale Z by pelvis->upperarm span ratio, translate pelvis->pelvis.
|
||||
Applied to BOTH the garment and ref_body.
|
||||
5. BVH nearest-surface warp (replaces Surface Deform + Shrinkwrap —
|
||||
first attempt showed SD driven by a shrinkwrapped driver mesh
|
||||
spikes 14-28x on fold discontinuities, and SD bind itself is
|
||||
unreliable against the multi-shell joined segment mesh):
|
||||
for each garment vertex, find the nearest point on ref_body,
|
||||
decompose the offset into (height along surface normal +
|
||||
tangential residual), re-evaluate the same surface point on
|
||||
tgt_body via its own nearest-surface lookup, and rebuild the
|
||||
vertex at the target surface with the offset preserved. The
|
||||
per-vertex displacement field is then Laplacian-smoothed over
|
||||
the garment mesh connectivity to kill nearest-point-map
|
||||
discontinuities while keeping rigid details (back device,
|
||||
plating) coherent.
|
||||
6. Wipe vgroups, Data Transfer POLYINTERP_NEAREST weights from tgt_body,
|
||||
normalize, bind to the target body's embedded armature (same bind pose
|
||||
as the body segments -> garment deforms identically to the body under
|
||||
any runtime skeleton pose).
|
||||
7. Export armature + garment GLB (export_skins=True) to
|
||||
out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_<body>.glb
|
||||
8. Metrics (out/bodies_fit_log.json): zero-weight verts, AABB,
|
||||
edge-length distortion vs post-affine baseline (collapse/fold
|
||||
detector), signed-distance stats garment vs body surface
|
||||
(penetration detector).
|
||||
|
||||
Run:
|
||||
tooling/blender --background --python \
|
||||
spikes/synty-intake/scripts/04_batch_fit_bodies.py
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from mathutils import Matrix, Vector
|
||||
from mathutils.bvhtree import BVHTree
|
||||
|
||||
REPO = "/var/mnt/data/projects/settled-reach"
|
||||
GARMENT_GLB = os.path.join(REPO, "spikes/synty-intake/out",
|
||||
"SK_SCFI_CIVL_09_10TORS_HU01_quaternius.glb")
|
||||
BODIES_DIR = os.path.join(REPO, "client/assets/characters/bodies")
|
||||
OUT_DIR = os.path.join(REPO, "spikes/synty-intake/out/bodies")
|
||||
OUT_LOG = os.path.join(REPO, "spikes/synty-intake/out/bodies_fit_log.json")
|
||||
|
||||
BODY_TYPES = ["average_m", "average_f", "muscular_m", "muscular_f",
|
||||
"thin_m", "thin_f", "heavy_m", "heavy_f",
|
||||
"teen_m", "teen_f", "child"]
|
||||
|
||||
SEGS = ["seg_torso", "seg_hips", "seg_neck",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r"]
|
||||
|
||||
PENETRATION_MM = 3.0 # garment vertex deeper than this inside the body -> flag
|
||||
|
||||
|
||||
def deselect_all():
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
|
||||
def set_active(obj):
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
for block_list in (bpy.data.meshes, bpy.data.armatures, bpy.data.images,
|
||||
bpy.data.materials):
|
||||
for block in list(block_list):
|
||||
if block.users == 0:
|
||||
block_list.remove(block)
|
||||
|
||||
|
||||
def import_new(op, path, **kw):
|
||||
before = set(bpy.data.objects)
|
||||
op(filepath=path, **kw)
|
||||
return list(set(bpy.data.objects) - before)
|
||||
|
||||
|
||||
def find_armature(objs):
|
||||
for o in objs:
|
||||
if o.type == 'ARMATURE':
|
||||
return o
|
||||
return None
|
||||
|
||||
|
||||
def bone_head_world(arm, name):
|
||||
b = arm.data.bones.get(name)
|
||||
return (arm.matrix_world @ b.head_local).copy() if b else None
|
||||
|
||||
|
||||
def delete_obj(obj):
|
||||
deselect_all()
|
||||
obj.select_set(True)
|
||||
set_active(obj)
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
|
||||
|
||||
def freeze(obj):
|
||||
"""Unparent keeping transform, strip modifiers, bake transform to data."""
|
||||
deselect_all()
|
||||
set_active(obj)
|
||||
obj.select_set(True)
|
||||
mw = obj.matrix_world.copy()
|
||||
obj.parent = None
|
||||
obj.matrix_world = mw
|
||||
for mod in list(obj.modifiers):
|
||||
obj.modifiers.remove(mod)
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
|
||||
def import_body(body, keep_armature):
|
||||
"""Import + join a body's garment-adjacent segments.
|
||||
|
||||
Returns (joined_mesh, armature_or_None, segments_used).
|
||||
"""
|
||||
seg_meshes = []
|
||||
kept_arm = None
|
||||
used = []
|
||||
for seg in SEGS:
|
||||
path = os.path.join(BODIES_DIR, body, seg + ".glb")
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
objs = import_new(bpy.ops.import_scene.gltf, path)
|
||||
picked = None
|
||||
for o in objs:
|
||||
if o.type == 'MESH' and len(o.vertex_groups) > 0:
|
||||
picked = o
|
||||
elif o.type == 'MESH':
|
||||
delete_obj(o) # debris (bounds helpers etc.)
|
||||
arm = find_armature(objs)
|
||||
if picked is not None:
|
||||
pmw = picked.matrix_world.copy()
|
||||
picked.parent = None
|
||||
picked.matrix_world = pmw
|
||||
if arm is not None:
|
||||
if keep_armature and kept_arm is None:
|
||||
kept_arm = arm
|
||||
else:
|
||||
delete_obj(arm)
|
||||
if picked is None:
|
||||
print(f" WARNING: no weighted mesh in {body}/{seg}")
|
||||
continue
|
||||
for mod in list(picked.modifiers):
|
||||
picked.modifiers.remove(mod)
|
||||
seg_meshes.append(picked)
|
||||
used.append(seg)
|
||||
deselect_all()
|
||||
for m in seg_meshes:
|
||||
m.select_set(True)
|
||||
set_active(seg_meshes[0])
|
||||
if len(seg_meshes) > 1:
|
||||
bpy.ops.object.join()
|
||||
joined = bpy.context.view_layer.objects.active
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
return joined, kept_arm, used
|
||||
|
||||
|
||||
def edge_lengths(mesh_obj):
|
||||
verts = mesh_obj.data.vertices
|
||||
return [(verts[e.vertices[0]].co - verts[e.vertices[1]].co).length
|
||||
for e in mesh_obj.data.edges]
|
||||
|
||||
|
||||
def aabb(mesh_obj):
|
||||
xs = [v.co for v in mesh_obj.data.vertices]
|
||||
lo = Vector((min(v.x for v in xs), min(v.y for v in xs),
|
||||
min(v.z for v in xs)))
|
||||
hi = Vector((max(v.x for v in xs), max(v.y for v in xs),
|
||||
max(v.z for v in xs)))
|
||||
return lo, hi
|
||||
|
||||
|
||||
def warp_garment(garment, ref_body, tgt_body, depsgraph,
|
||||
smooth_iterations=20, smooth_lambda=0.5):
|
||||
"""Nearest-surface warp: move each garment vertex from its offset
|
||||
relative to ref_body onto the equivalent offset relative to tgt_body,
|
||||
then Laplacian-smooth the displacement field over the garment mesh."""
|
||||
bvh_ref = BVHTree.FromObject(ref_body, depsgraph)
|
||||
bvh_tgt = BVHTree.FromObject(tgt_body, depsgraph)
|
||||
|
||||
verts = garment.data.vertices
|
||||
n = len(verts)
|
||||
disp = [None] * n
|
||||
for i, v in enumerate(verts):
|
||||
co_r, n_r, _idx, _d = bvh_ref.find_nearest(v.co)
|
||||
if co_r is None:
|
||||
disp[i] = Vector((0, 0, 0))
|
||||
continue
|
||||
delta = v.co - co_r
|
||||
h = delta.dot(n_r)
|
||||
tang = delta - h * n_r
|
||||
co_t, n_t, _idx2, _d2 = bvh_tgt.find_nearest(co_r)
|
||||
if co_t is None:
|
||||
disp[i] = Vector((0, 0, 0))
|
||||
continue
|
||||
new_co = co_t + h * n_t + tang
|
||||
disp[i] = new_co - v.co
|
||||
|
||||
# adjacency from edges
|
||||
adj = [[] for _ in range(n)]
|
||||
for e in garment.data.edges:
|
||||
a, b = e.vertices
|
||||
adj[a].append(b)
|
||||
adj[b].append(a)
|
||||
|
||||
# Laplacian smoothing of the displacement field (not the geometry)
|
||||
for _ in range(smooth_iterations):
|
||||
new_disp = [None] * n
|
||||
for i in range(n):
|
||||
if not adj[i]:
|
||||
new_disp[i] = disp[i]
|
||||
continue
|
||||
avg = Vector((0, 0, 0))
|
||||
for j in adj[i]:
|
||||
avg += disp[j]
|
||||
avg /= len(adj[i])
|
||||
new_disp[i] = disp[i].lerp(avg, smooth_lambda)
|
||||
disp = new_disp
|
||||
|
||||
max_disp = 0.0
|
||||
for i, v in enumerate(verts):
|
||||
v.co = v.co + disp[i]
|
||||
if disp[i].length > max_disp:
|
||||
max_disp = disp[i].length
|
||||
garment.data.update()
|
||||
return {"max_displacement_mm": round(max_disp * 1000, 1),
|
||||
"smooth_iterations": smooth_iterations}
|
||||
|
||||
|
||||
def signed_distance_stats(garment, body_obj, depsgraph):
|
||||
"""Signed distance of each garment vertex to the body surface.
|
||||
|
||||
Positive = outside the body (along surface normal), negative = inside.
|
||||
"""
|
||||
bvh = BVHTree.FromObject(body_obj, depsgraph)
|
||||
dists = []
|
||||
for v in garment.data.vertices:
|
||||
co, normal, _idx, _d = bvh.find_nearest(v.co)
|
||||
if co is None:
|
||||
continue
|
||||
dists.append((v.co - co).dot(normal))
|
||||
dists.sort()
|
||||
n = len(dists)
|
||||
inside = [d for d in dists if d < -PENETRATION_MM / 1000.0]
|
||||
return {
|
||||
"verts_sampled": n,
|
||||
"min_signed_mm": round(dists[0] * 1000, 2),
|
||||
"p05_signed_mm": round(dists[max(0, int(n * 0.05) - 1)] * 1000, 2),
|
||||
"median_signed_mm": round(dists[n // 2] * 1000, 2),
|
||||
"max_signed_mm": round(dists[-1] * 1000, 2),
|
||||
f"verts_inside_gt_{PENETRATION_MM:g}mm": len(inside),
|
||||
"pct_inside": round(100.0 * len(inside) / n, 2),
|
||||
}
|
||||
|
||||
|
||||
log = {"garment_glb": GARMENT_GLB, "bodies": {}}
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
for body in BODY_TYPES:
|
||||
print(f"\n{'='*60}\n BODY: {body}\n{'='*60}")
|
||||
entry = {}
|
||||
clear_scene()
|
||||
|
||||
# --- 1. garment + reference landmarks ---
|
||||
g_objs = import_new(bpy.ops.import_scene.gltf, GARMENT_GLB)
|
||||
ref_arm = find_armature(g_objs)
|
||||
# the GLB also carries a bounds icosphere — the garment is the skinned
|
||||
# mesh (has vertex groups); the set-diff order is nondeterministic, so
|
||||
# pick explicitly and delete the rest
|
||||
g_meshes = [o for o in g_objs if o.type == 'MESH']
|
||||
garment = max((m for m in g_meshes if len(m.vertex_groups) > 0),
|
||||
key=lambda m: len(m.data.vertices))
|
||||
ref_pelvis = bone_head_world(ref_arm, "pelvis")
|
||||
ref_upperarm = bone_head_world(ref_arm, "upperarm_l")
|
||||
freeze(garment)
|
||||
for m in g_meshes:
|
||||
if m is not garment:
|
||||
delete_obj(m)
|
||||
delete_obj(ref_arm)
|
||||
entry["garment_verts"] = len(garment.data.vertices)
|
||||
|
||||
# --- 2. reference body (average_m) ---
|
||||
ref_body, _, _ = import_body("average_m", keep_armature=False)
|
||||
ref_body.name = "ref_body"
|
||||
|
||||
# --- 3. target body ---
|
||||
tgt_body, tgt_arm, used = import_body(body, keep_armature=True)
|
||||
tgt_body.name = "tgt_body"
|
||||
entry["segments_used"] = used
|
||||
if tgt_arm is None:
|
||||
entry["result"] = "FAIL: no target armature"
|
||||
log["bodies"][body] = entry
|
||||
continue
|
||||
tgt_pelvis = bone_head_world(tgt_arm, "pelvis")
|
||||
tgt_upperarm = bone_head_world(tgt_arm, "upperarm_l")
|
||||
|
||||
# --- 4. landmark affine ref -> tgt ---
|
||||
sxy = tgt_upperarm.x / ref_upperarm.x
|
||||
sz = (tgt_upperarm.z - tgt_pelvis.z) / (ref_upperarm.z - ref_pelvis.z)
|
||||
S = Matrix.Diagonal(Vector((sxy, sxy, sz, 1.0)))
|
||||
scaled_pelvis = Vector((ref_pelvis.x * sxy, ref_pelvis.y * sxy,
|
||||
ref_pelvis.z * sz))
|
||||
t = tgt_pelvis - scaled_pelvis
|
||||
M = Matrix.Translation(t) @ S
|
||||
garment.data.transform(M)
|
||||
garment.data.update()
|
||||
ref_body.data.transform(M)
|
||||
ref_body.data.update()
|
||||
entry["affine"] = {"scale_xy": round(sxy, 5), "scale_z": round(sz, 5),
|
||||
"translate": [round(v, 5) for v in t]}
|
||||
|
||||
# post-affine baseline for distortion metrics
|
||||
base_edges = edge_lengths(garment)
|
||||
lo, hi = aabb(garment)
|
||||
entry["aabb_post_affine"] = {"min": [round(v, 4) for v in lo],
|
||||
"max": [round(v, 4) for v in hi]}
|
||||
|
||||
# --- 5. BVH nearest-surface warp ref_body -> tgt_body ---
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
entry["warp"] = warp_garment(garment, ref_body, tgt_body, depsgraph)
|
||||
|
||||
delete_obj(ref_body)
|
||||
|
||||
# --- 7. weights from the target body ---
|
||||
for vg in list(garment.vertex_groups):
|
||||
garment.vertex_groups.remove(vg)
|
||||
deselect_all()
|
||||
set_active(garment)
|
||||
garment.select_set(True)
|
||||
dt = garment.modifiers.new(name="WeightTransfer", type='DATA_TRANSFER')
|
||||
dt.object = tgt_body
|
||||
dt.use_vert_data = True
|
||||
dt.data_types_verts = {'VGROUP_WEIGHTS'}
|
||||
dt.vert_mapping = 'POLYINTERP_NEAREST'
|
||||
dt.layers_vgroup_select_src = 'ALL'
|
||||
dt.layers_vgroup_select_dst = 'NAME'
|
||||
bpy.ops.object.datalayout_transfer(modifier=dt.name)
|
||||
bpy.ops.object.modifier_apply(modifier=dt.name)
|
||||
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
|
||||
bpy.ops.object.vertex_group_normalize_all(lock_active=False)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
zero = sum(1 for v in garment.data.vertices
|
||||
if sum(g.weight for g in v.groups) < 1e-4)
|
||||
entry["zero_weight_verts"] = zero
|
||||
entry["vgroups"] = len(garment.vertex_groups)
|
||||
|
||||
# --- 8. metrics ---
|
||||
post_edges = edge_lengths(garment)
|
||||
ratios = [p / b for p, b in zip(post_edges, base_edges) if b > 1e-9]
|
||||
entry["edge_distortion"] = {
|
||||
"max_stretch": round(max(ratios), 3),
|
||||
"max_shrink": round(min(ratios), 3),
|
||||
"edges_gt_2x": sum(1 for r in ratios if r > 2.0),
|
||||
"edges_lt_0.5x": sum(1 for r in ratios if r < 0.5),
|
||||
"edges_total": len(ratios),
|
||||
}
|
||||
lo, hi = aabb(garment)
|
||||
entry["aabb_post_fit"] = {"min": [round(v, 4) for v in lo],
|
||||
"max": [round(v, 4) for v in hi]}
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
entry["signed_distance"] = signed_distance_stats(garment, tgt_body,
|
||||
depsgraph)
|
||||
|
||||
# --- 9. bind + export ---
|
||||
garment.parent = tgt_arm
|
||||
garment.matrix_parent_inverse = tgt_arm.matrix_world.inverted()
|
||||
am = garment.modifiers.new(name="Armature", type='ARMATURE')
|
||||
am.object = tgt_arm
|
||||
delete_obj(tgt_body)
|
||||
|
||||
out_glb = os.path.join(OUT_DIR,
|
||||
f"SK_SCFI_CIVL_09_10TORS_HU01_{body}.glb")
|
||||
deselect_all()
|
||||
tgt_arm.select_set(True)
|
||||
garment.select_set(True)
|
||||
set_active(tgt_arm)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=out_glb,
|
||||
export_format='GLB',
|
||||
use_selection=True,
|
||||
export_apply=False,
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
export_morph=False,
|
||||
export_extras=False,
|
||||
)
|
||||
entry["export"] = {"path": out_glb,
|
||||
"size_kb": os.path.getsize(out_glb) // 1024}
|
||||
entry["result"] = "OK"
|
||||
log["bodies"][body] = entry
|
||||
|
||||
with open(OUT_LOG, "w") as f:
|
||||
json.dump(log, f, indent=2)
|
||||
|
||||
print("\n=== BATCH FIT SUMMARY ===")
|
||||
for body, e in log["bodies"].items():
|
||||
sd_stats = e.get("signed_distance", {})
|
||||
print(f" {body:12s} {e.get('result','?'):14s} "
|
||||
f"zero_w={e.get('zero_weight_verts','-')} "
|
||||
f"inside%={sd_stats.get('pct_inside','-')} "
|
||||
f"stretch={e.get('edge_distortion',{}).get('max_stretch','-')}")
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a .unitypackage (gzipped tar of GUID dirs) into real paths.
|
||||
|
||||
Copies the raw `asset` bytes of every FBX/texture/metadata entry to
|
||||
raw/<original pathname>. Prints a summary by extension.
|
||||
"""
|
||||
import sys
|
||||
import tarfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
|
||||
KEEP = {".fbx", ".png", ".tga", ".json", ".txt", ".sk", ".asset"}
|
||||
|
||||
|
||||
def main(pkg: str, out_root: str) -> None:
|
||||
out = Path(out_root)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
counts: Counter = Counter()
|
||||
with tarfile.open(pkg, "r:gz") as tar:
|
||||
members = {m.name: m for m in tar.getmembers()}
|
||||
guids = sorted({name.split("/")[0] for name in members if "/" in name})
|
||||
for guid in guids:
|
||||
pn = members.get(f"{guid}/pathname")
|
||||
asset = members.get(f"{guid}/asset")
|
||||
if pn is None or asset is None:
|
||||
continue
|
||||
pathname = tar.extractfile(pn).read().decode("utf-8").splitlines()[0].strip()
|
||||
ext = Path(pathname).suffix.lower()
|
||||
if ext not in KEEP:
|
||||
counts[f"(skipped {ext or 'dir'})"] += 1
|
||||
continue
|
||||
dest = out / pathname
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tar.extractfile(asset) as src, open(dest, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
counts[ext] += 1
|
||||
for ext, n in sorted(counts.items()):
|
||||
print(f"{ext}\t{n}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
@@ -0,0 +1,193 @@
|
||||
extends Node3D
|
||||
## Spike T-1089 Synty intake — 11-body batch QA scene (THROWAWAY, lives in
|
||||
## client/spike_synty_qa/ only for the res:// path; source of truth is
|
||||
## spikes/synty-intake/scripts/qa_bodies.gd).
|
||||
##
|
||||
## For each body type on disk: builds a CharacterVisual, reparents that
|
||||
## body's refitted Synty torso garment onto its skeleton (mirrors
|
||||
## character_visual.gd:517-535), screenshots idle front, then plays Walk
|
||||
## and screenshots two stride phases (front + side). Peasant pants/shoes
|
||||
## are added only for bodies that have them on disk. Saves to
|
||||
## spikes/synty-intake/out/qa_bodies/, then quits.
|
||||
|
||||
const BODIES_GLB_DIR := "res://spike_synty_qa/bodies/"
|
||||
const SHOT_DIR := "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/qa_bodies/"
|
||||
const CLOTHING_DIR := "res://assets/characters/clothing/"
|
||||
|
||||
const BODY_KEYS: Array[String] = [
|
||||
"average_m", "average_f", "muscular_m", "muscular_f",
|
||||
"thin_m", "thin_f", "heavy_m", "heavy_f",
|
||||
"teen_m", "teen_f", "child",
|
||||
]
|
||||
|
||||
const KEY_TO_ENUM := {
|
||||
"average_m": CharacterVisualDescriptor.BodyType.AVERAGE_M,
|
||||
"average_f": CharacterVisualDescriptor.BodyType.AVERAGE_F,
|
||||
"muscular_m": CharacterVisualDescriptor.BodyType.MUSCULAR_M,
|
||||
"muscular_f": CharacterVisualDescriptor.BodyType.MUSCULAR_F,
|
||||
"thin_m": CharacterVisualDescriptor.BodyType.THIN_M,
|
||||
"thin_f": CharacterVisualDescriptor.BodyType.THIN_F,
|
||||
"heavy_m": CharacterVisualDescriptor.BodyType.HEAVY_M,
|
||||
"heavy_f": CharacterVisualDescriptor.BodyType.HEAVY_F,
|
||||
"teen_m": CharacterVisualDescriptor.BodyType.TEEN_M,
|
||||
"teen_f": CharacterVisualDescriptor.BodyType.TEEN_F,
|
||||
"child": CharacterVisualDescriptor.BodyType.CHILD,
|
||||
}
|
||||
|
||||
var _cam: Camera3D = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_stage()
|
||||
_run_qa.call_deferred()
|
||||
|
||||
|
||||
func _setup_stage() -> void:
|
||||
var light := DirectionalLight3D.new()
|
||||
light.rotation_degrees = Vector3(-45, 30, 0)
|
||||
light.light_energy = 1.2
|
||||
add_child(light)
|
||||
|
||||
var env := Environment.new()
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.8, 0.8, 0.85)
|
||||
env.ambient_light_energy = 0.9
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
env.background_color = Color(0.18, 0.19, 0.22)
|
||||
var we := WorldEnvironment.new()
|
||||
we.environment = env
|
||||
add_child(we)
|
||||
|
||||
_cam = Camera3D.new()
|
||||
add_child(_cam)
|
||||
|
||||
|
||||
func _cam_front(h: float) -> void:
|
||||
_cam.position = Vector3(0, h * 0.72, h * 0.85)
|
||||
_cam.look_at_from_position(_cam.position, Vector3(0, h * 0.62, 0), Vector3.UP)
|
||||
|
||||
|
||||
func _cam_side(h: float) -> void:
|
||||
_cam.position = Vector3(h * 0.85, h * 0.72, 0)
|
||||
_cam.look_at_from_position(_cam.position, Vector3(0, h * 0.62, 0), Vector3.UP)
|
||||
|
||||
|
||||
func _attach_garment(skel: Skeleton3D, glb_path: String) -> Array[MeshInstance3D]:
|
||||
var out: Array[MeshInstance3D] = []
|
||||
var scene := load(glb_path) as PackedScene
|
||||
if scene == null:
|
||||
push_error("QA: garment GLB failed to load: " + glb_path)
|
||||
return out
|
||||
var inst := scene.instantiate()
|
||||
var stack: Array[Node] = [inst]
|
||||
while not stack.is_empty():
|
||||
var n: Node = stack.pop_back()
|
||||
for c in n.get_children():
|
||||
stack.append(c)
|
||||
if n is MeshInstance3D and (n as MeshInstance3D).skin != null:
|
||||
var mi := n as MeshInstance3D
|
||||
mi.get_parent().remove_child(mi)
|
||||
mi.owner = null
|
||||
skel.add_child(mi)
|
||||
out.append(mi)
|
||||
inst.queue_free()
|
||||
return out
|
||||
|
||||
|
||||
func _shot(fname: String) -> void:
|
||||
await RenderingServer.frame_post_draw
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
img.save_png(SHOT_DIR + fname)
|
||||
print("QA: saved ", fname)
|
||||
|
||||
|
||||
func _wait_frames(n: int) -> void:
|
||||
for i in n:
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
func _run_qa() -> void:
|
||||
DirAccess.make_dir_recursive_absolute(SHOT_DIR)
|
||||
for body_key: String in BODY_KEYS:
|
||||
var glb := BODIES_GLB_DIR + "SK_SCFI_CIVL_09_10TORS_HU01_%s.glb" % body_key
|
||||
if not ResourceLoader.exists(glb):
|
||||
print("QA: MISSING garment GLB for ", body_key, " — skipped")
|
||||
continue
|
||||
|
||||
var cv := CharacterVisual.new()
|
||||
add_child(cv)
|
||||
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.body_type = KEY_TO_ENUM[body_key]
|
||||
desc.head_id = "head_001"
|
||||
desc.hair_id = "buzzed"
|
||||
desc.eyebrow_id = "regular"
|
||||
desc.skin_tone = 3
|
||||
var slots := {}
|
||||
if ResourceLoader.exists(CLOTHING_DIR + "peasant_pants/%s.glb" % body_key):
|
||||
slots["legs"] = "peasant_pants"
|
||||
if ResourceLoader.exists(CLOTHING_DIR + "peasant_shoes/%s.glb" % body_key):
|
||||
slots["feet"] = "peasant_shoes"
|
||||
desc.clothing_slots = slots
|
||||
cv.load_descriptor(desc)
|
||||
|
||||
var skel := cv.get_skeleton()
|
||||
if skel == null:
|
||||
push_error("QA: no skeleton for " + body_key)
|
||||
cv.queue_free()
|
||||
continue
|
||||
print("QA[", body_key, "]: skeleton bones = ", skel.get_bone_count(),
|
||||
" pants = ", slots.has("legs"))
|
||||
|
||||
# rough height for camera framing (child/teen are shorter)
|
||||
var h0 := 1.75
|
||||
if body_key == "child" or body_key.begins_with("teen"):
|
||||
h0 = 1.45
|
||||
|
||||
# body-only control shot BEFORE the garment — separates body-asset
|
||||
# rendering defects from garment fit defects
|
||||
await _wait_frames(5)
|
||||
_cam_front(h0)
|
||||
await _shot("%s_0_body_only.png" % body_key)
|
||||
|
||||
var meshes := _attach_garment(skel, glb)
|
||||
for mi in meshes:
|
||||
print("QA[", body_key, "]: garment '", mi.name, "' skin binds = ",
|
||||
mi.skin.get_bind_count(), " aabb = ", mi.get_aabb())
|
||||
|
||||
var h := h0
|
||||
|
||||
await _wait_frames(5)
|
||||
_cam_front(h)
|
||||
await _shot("%s_1_idle_front.png" % body_key)
|
||||
|
||||
var ap := cv.get_animation_player()
|
||||
var walk := ""
|
||||
if ap:
|
||||
for lib_name in ap.get_animation_library_list():
|
||||
var lib := ap.get_animation_library(lib_name)
|
||||
for a in lib.get_animation_list():
|
||||
var full := str(lib_name) + "/" + str(a)
|
||||
if full.to_lower().contains("walk"):
|
||||
walk = str(a)
|
||||
if full.to_lower().ends_with("/walk"):
|
||||
break
|
||||
if walk != "":
|
||||
cv.play_animation(walk)
|
||||
await _wait_frames(24)
|
||||
_cam_front(h)
|
||||
await _shot("%s_2_walk_front.png" % body_key)
|
||||
_cam_side(h)
|
||||
await _wait_frames(8)
|
||||
await _shot("%s_3_walk_side.png" % body_key)
|
||||
else:
|
||||
push_warning("QA: no walk animation for " + body_key)
|
||||
|
||||
for mi in meshes:
|
||||
print("QA[", body_key, "]: post-walk garment aabb = ", mi.get_aabb())
|
||||
|
||||
cv.queue_free()
|
||||
await _wait_frames(2)
|
||||
|
||||
print("QA: DONE")
|
||||
get_tree().quit(0)
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://synty_qa_bodies1"]
|
||||
|
||||
[ext_resource type="Script" path="res://spike_synty_qa/qa_bodies.gd" id="1"]
|
||||
|
||||
[node name="QABodies" type="Node3D"]
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,167 @@
|
||||
extends Node3D
|
||||
## Spike T-1089 Synty intake — Godot QA scene (THROWAWAY, lives in
|
||||
## client/spike_synty_qa/ only for the res:// path; source of truth is
|
||||
## spikes/synty-intake/scripts/qa_scene.gd).
|
||||
##
|
||||
## Builds a CharacterVisual (average_m + peasant pants/shoes), reparents the
|
||||
## transplanted Synty torso garment onto its skeleton (mirrors
|
||||
## character_visual.gd:517-535), plays Walk, saves screenshots to
|
||||
## spikes/synty-intake/out/qa/, then quits.
|
||||
|
||||
const GARMENT_GLB := "res://spike_synty_qa/SK_SCFI_CIVL_09_10TORS_HU01_quaternius.glb"
|
||||
const SHOT_DIR := "/var/mnt/data/projects/settled-reach/spikes/synty-intake/out/qa/"
|
||||
|
||||
var _cv: CharacterVisual = null
|
||||
var _cam: Camera3D = null
|
||||
var _garment_meshes: Array[MeshInstance3D] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_stage()
|
||||
_cv = CharacterVisual.new()
|
||||
add_child(_cv)
|
||||
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
|
||||
desc.head_id = "head_001"
|
||||
desc.hair_id = "buzzed"
|
||||
desc.eyebrow_id = "regular"
|
||||
desc.skin_tone = 3
|
||||
desc.clothing_slots = {"legs": "peasant_pants", "feet": "peasant_shoes"}
|
||||
_cv.load_descriptor(desc)
|
||||
|
||||
var skel := _cv.get_skeleton()
|
||||
print("QA: skeleton bones = ", skel.get_bone_count() if skel else -1)
|
||||
|
||||
_attach_garment(skel)
|
||||
_run_qa.call_deferred()
|
||||
|
||||
|
||||
func _setup_stage() -> void:
|
||||
var light := DirectionalLight3D.new()
|
||||
light.rotation_degrees = Vector3(-45, 30, 0)
|
||||
light.light_energy = 1.2
|
||||
add_child(light)
|
||||
|
||||
var env := Environment.new()
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.8, 0.8, 0.85)
|
||||
env.ambient_light_energy = 0.9
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
env.background_color = Color(0.18, 0.19, 0.22)
|
||||
var we := WorldEnvironment.new()
|
||||
we.environment = env
|
||||
add_child(we)
|
||||
|
||||
_cam = Camera3D.new()
|
||||
add_child(_cam)
|
||||
_set_cam_front()
|
||||
|
||||
|
||||
func _set_cam_front() -> void:
|
||||
_cam.position = Vector3(0, 1.25, 1.4)
|
||||
_cam.look_at_from_position(_cam.position, Vector3(0, 1.15, 0), Vector3.UP)
|
||||
|
||||
|
||||
func _set_cam_side() -> void:
|
||||
_cam.position = Vector3(1.4, 1.25, 0)
|
||||
_cam.look_at_from_position(_cam.position, Vector3(0, 1.15, 0), Vector3.UP)
|
||||
|
||||
|
||||
func _set_cam_back() -> void:
|
||||
_cam.position = Vector3(0, 1.25, -1.4)
|
||||
_cam.look_at_from_position(_cam.position, Vector3(0, 1.15, 0), Vector3.UP)
|
||||
|
||||
|
||||
func _attach_garment(skel: Skeleton3D) -> void:
|
||||
# Mirror character_visual.gd:517-535 — reparent skinned meshes onto OUR
|
||||
# skeleton; matching bone names in the Skin resource drive them.
|
||||
var scene := load(GARMENT_GLB) as PackedScene
|
||||
if scene == null:
|
||||
push_error("QA: garment GLB failed to load: " + GARMENT_GLB)
|
||||
return
|
||||
var inst := scene.instantiate()
|
||||
var stack: Array[Node] = [inst]
|
||||
while not stack.is_empty():
|
||||
var n: Node = stack.pop_back()
|
||||
for c in n.get_children():
|
||||
stack.append(c)
|
||||
if n is MeshInstance3D and (n as MeshInstance3D).skin != null:
|
||||
var mi := n as MeshInstance3D
|
||||
mi.get_parent().remove_child(mi)
|
||||
mi.owner = null
|
||||
skel.add_child(mi)
|
||||
_garment_meshes.append(mi)
|
||||
inst.queue_free()
|
||||
print("QA: garment skinned meshes attached = ", _garment_meshes.size())
|
||||
for mi in _garment_meshes:
|
||||
print("QA: garment mesh '", mi.name, "' skin binds = ", mi.skin.get_bind_count())
|
||||
|
||||
|
||||
func _shot(fname: String) -> void:
|
||||
await RenderingServer.frame_post_draw
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
img.save_png(SHOT_DIR + fname)
|
||||
print("QA: saved ", fname)
|
||||
|
||||
|
||||
func _wait_frames(n: int) -> void:
|
||||
for i in n:
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
func _run_qa() -> void:
|
||||
await _wait_frames(5)
|
||||
|
||||
# report garment AABB sanity (explosion check)
|
||||
for mi in _garment_meshes:
|
||||
print("QA: garment local AABB = ", mi.get_aabb())
|
||||
|
||||
# --- idle ---
|
||||
await _shot("01_idle_front.png")
|
||||
_set_cam_side()
|
||||
await _shot("02_idle_side.png")
|
||||
_set_cam_back()
|
||||
await _shot("02b_idle_back.png")
|
||||
|
||||
# --- walk ---
|
||||
var ap := _cv.get_animation_player()
|
||||
var names: Array[String] = []
|
||||
if ap:
|
||||
for lib_name in ap.get_animation_library_list():
|
||||
var lib := ap.get_animation_library(lib_name)
|
||||
for a in lib.get_animation_list():
|
||||
names.append(str(lib_name) + "/" + str(a))
|
||||
print("QA: animations = ", names)
|
||||
|
||||
var walk := ""
|
||||
for n in names:
|
||||
if n.to_lower().contains("walk"):
|
||||
walk = n.get_slice("/", 1)
|
||||
if n.to_lower().ends_with("/walk"):
|
||||
break
|
||||
if walk == "":
|
||||
push_warning("QA: no walk animation found — staying on idle")
|
||||
else:
|
||||
print("QA: playing '", walk, "'")
|
||||
_cv.play_animation(walk)
|
||||
|
||||
# mid-stride frames at two phases, front + side
|
||||
await _wait_frames(24)
|
||||
_set_cam_front()
|
||||
await _shot("03_walk_front_a.png")
|
||||
_set_cam_side()
|
||||
await _shot("04_walk_side_a.png")
|
||||
await _wait_frames(14)
|
||||
await _shot("05_walk_side_b.png")
|
||||
_set_cam_front()
|
||||
await _shot("06_walk_front_b.png")
|
||||
_set_cam_back()
|
||||
await _wait_frames(10)
|
||||
await _shot("07_walk_back.png")
|
||||
|
||||
for mi in _garment_meshes:
|
||||
print("QA: post-walk garment local AABB = ", mi.get_aabb())
|
||||
|
||||
print("QA: DONE")
|
||||
get_tree().quit(0)
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://spike_synty_qa1"]
|
||||
|
||||
[ext_resource type="Script" path="res://spike_synty_qa/qa_scene.gd" id="1"]
|
||||
|
||||
[node name="QARoot" type="Node3D"]
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Quick recon: joined garment-adjacent body mesh AABB per body type."""
|
||||
import bpy
|
||||
import os
|
||||
|
||||
REPO = "/var/mnt/data/projects/settled-reach"
|
||||
BODIES_DIR = os.path.join(REPO, "client/assets/characters/bodies")
|
||||
BODY_TYPES = ["average_m", "average_f", "muscular_m", "muscular_f",
|
||||
"thin_m", "thin_f", "heavy_m", "heavy_f",
|
||||
"teen_m", "teen_f", "child"]
|
||||
SEGS = ["seg_torso", "seg_hips", "seg_neck",
|
||||
"seg_arm_upper_l", "seg_arm_upper_r",
|
||||
"seg_leg_upper_l", "seg_leg_upper_r"]
|
||||
|
||||
for body in BODY_TYPES:
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
xs, ys, zs = [], [], []
|
||||
t_xs, t_ys = [], []
|
||||
for seg in SEGS:
|
||||
path = os.path.join(BODIES_DIR, body, seg + ".glb")
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
for o in set(bpy.data.objects) - before:
|
||||
if o.type == 'MESH' and len(o.vertex_groups) > 0:
|
||||
for v in o.data.vertices:
|
||||
w = o.matrix_world @ v.co
|
||||
xs.append(w.x)
|
||||
ys.append(w.y)
|
||||
zs.append(w.z)
|
||||
if seg == "seg_torso":
|
||||
t_xs.append(w.x)
|
||||
t_ys.append(w.y)
|
||||
print(f"AABB {body}: full x={max(xs)-min(xs):.3f} "
|
||||
f"y={max(ys)-min(ys):.3f} z_top={max(zs):.3f} "
|
||||
f"| torso x={max(t_xs)-min(t_xs):.3f} depth y={max(t_ys)-min(t_ys):.3f}")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Quick recon: which bind skeleton do production per-body clothing GLBs embed?"""
|
||||
import bpy
|
||||
import os
|
||||
|
||||
REPO = "/var/mnt/data/projects/settled-reach"
|
||||
FILES = [
|
||||
"client/assets/characters/clothing/peasant_pants/teen_m.glb",
|
||||
"client/assets/characters/clothing/peasant_pants/average_f.glb",
|
||||
"client/assets/characters/clothing/peasant_pants/average_m.glb",
|
||||
]
|
||||
|
||||
for rel in FILES:
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
bpy.ops.import_scene.gltf(filepath=os.path.join(REPO, rel))
|
||||
for o in bpy.data.objects:
|
||||
if o.type == 'ARMATURE':
|
||||
for bn in ("pelvis", "upperarm_l"):
|
||||
b = o.data.bones.get(bn)
|
||||
if b:
|
||||
w = o.matrix_world @ b.head_local
|
||||
print(f"RECON {rel} {bn} "
|
||||
f"({w.x:.5f}, {w.y:.5f}, {w.z:.5f})")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Blender recon: dump skeleton/mesh facts for a Sidekick FBX and our armature.
|
||||
|
||||
Usage: tooling/blender --background --python recon_dump.py -- <fbx> <our_armature_glb>
|
||||
"""
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
fbx_path, glb_path = argv[0], argv[1]
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=fbx_path)
|
||||
print("=== SIDEKICK FBX:", fbx_path.split("/")[-1])
|
||||
sk_bones = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == "ARMATURE":
|
||||
sk_bones = sorted(b.name for b in obj.data.bones)
|
||||
print("ARMATURE:", obj.name, "bones:", len(sk_bones))
|
||||
elif obj.type == "MESH":
|
||||
keys = obj.data.shape_keys
|
||||
print(
|
||||
"MESH:", obj.name,
|
||||
"verts:", len(obj.data.vertices),
|
||||
"dim:", [round(d, 3) for d in obj.dimensions],
|
||||
"shapekeys:", [k.name for k in keys.key_blocks] if keys else [],
|
||||
"vgroups:", len(obj.vertex_groups),
|
||||
)
|
||||
print("SK_BONES:", ",".join(sk_bones))
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=glb_path)
|
||||
print("=== OURS:", glb_path.split("/")[-1])
|
||||
our_bones = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == "ARMATURE":
|
||||
our_bones = sorted(b.name for b in obj.data.bones)
|
||||
print("ARMATURE:", obj.name, "bones:", len(our_bones))
|
||||
print("OUR_BONES:", ",".join(our_bones))
|
||||
|
||||
overlap = set(sk_bones) & set(our_bones)
|
||||
print("OVERLAP:", len(overlap), "of", len(sk_bones), "sidekick /", len(our_bones), "ours")
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Dump vertex-group names of every mesh in an FBX."""
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
fbx_path = sys.argv[sys.argv.index("--") + 1]
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=fbx_path)
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == "MESH":
|
||||
print("MESH:", obj.name, "VGROUPS:", ",".join(g.name for g in obj.vertex_groups))
|
||||
@@ -76,6 +76,19 @@
|
||||
"atlas_gen_open": {
|
||||
"ticks": 240,
|
||||
"description": "Live: real atlas opener (HudGroups) + GJ71c Layer-1 overlays (#960)"
|
||||
},
|
||||
"locomotion_idle_live": {
|
||||
"ticks": 90,
|
||||
"live": true,
|
||||
"scene": "res://scenes/locomotion_sandbox.tscn",
|
||||
"description": "Live 3D sandbox: character idle at Hub spawn — greybox floor, -30 deg frame (T-1088)"
|
||||
},
|
||||
"locomotion_cutaway": {
|
||||
"ticks": 540,
|
||||
"live": true,
|
||||
"scene": "res://scenes/locomotion_sandbox.tscn",
|
||||
"env": { "SR_AUTOPILOT": "stance_up,south:6.0" },
|
||||
"description": "Live 3D sandbox: autopilot walks south into a wall — cutaway stub golden (T-1088)"
|
||||
}
|
||||
},
|
||||
"flows": {
|
||||
@@ -98,6 +111,23 @@
|
||||
{ "action": "MoveNorth", "label": "near wall" },
|
||||
{ "action": "MoveSouth", "label": "fog hiding" }
|
||||
]
|
||||
},
|
||||
"locomotion_gaits": {
|
||||
"interval": 2,
|
||||
"live": true,
|
||||
"scene": "res://scenes/locomotion_sandbox.tscn",
|
||||
"env": {
|
||||
"SR_AUTOPILOT": "east:2.0,stance_up,east:2.0,stance_up,east:2.0,stance_down,stance_down,stance_down,south:2.0,wait:1.5"
|
||||
},
|
||||
"description": "T-1088 stance ladder movie — NOT golden-compared. Movie mode starts no server: run `cd server && cargo run --bin settled-reach-server -- --test-mode` first, then `SR_LIVE=1 SR_PORT=9876 tests/run-visual --movie locomotion_gaits`",
|
||||
"steps": [
|
||||
{ "action": "wait", "label": "careful gait east" },
|
||||
{ "action": "wait", "label": "walk gait east" },
|
||||
{ "action": "wait", "label": "sprint gait east" },
|
||||
{ "action": "wait", "label": "crouch gait south" },
|
||||
{ "action": "wait", "label": "settling to idle" },
|
||||
{ "action": "wait", "label": "idle rest" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"crops": {
|
||||
|
||||
Reference in New Issue
Block a user